UNPKG

agents

Version:

A home for your AI agents

427 lines (426 loc) 17.4 kB
import { t as isChannelMessageSurface } from "../surface-bZZJqBka.js"; import { a as isRecord, i as encodeUtf8, n as defaultText, o as renderInput, r as emptyIngressResponse, s as uncertain } from "../internal-CYlgHl1l.js"; import { i as createPacer, r as consumeChunks, t as matchesPath } from "../ingress-BfetZbMO.js"; //#region src/channels/adapters/telegram.ts const TELEGRAM_MESSAGE_LIMIT = 4096; const DEFAULT_STREAM_INTERVAL_MS = 500; const DEFAULT_TELEGRAM_WEBHOOK_PATH = "/webhooks/telegram"; const APPROVAL_INSTRUCTIONS = "Reply YES to approve or NO to reject."; const INTERACTION_FOOTER = /\n\nReply YES to approve or NO to reject\.\n\n\[channel-interaction:v1:([A-Za-z0-9_-]+)\]$/; function channelIdentity(chatId) { return `telegram:chat:${chatId}`; } function userIdentity(userId) { return `telegram:user:${userId}`; } function messageIdentity(chatId, messageId) { return `${channelIdentity(chatId)}:message:${messageId}`; } function updateIdentity(chatId, updateId) { return `${channelIdentity(chatId)}:update:${updateId}`; } function encodeInteractionId(interactionId) { const bytes = encodeUtf8(interactionId); let binary = ""; for (const byte of bytes) binary += String.fromCharCode(byte); return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } function decodeInteractionId(value) { try { const base64 = value.replace(/-/g, "+").replace(/_/g, "/"); const padding = "=".repeat((4 - base64.length % 4) % 4); const binary = atob(base64 + padding); const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)); return new TextDecoder("utf-8", { fatal: true }).decode(bytes) || void 0; } catch { return; } } function approvalText(interactionId, request) { return [ ...request.title ? [request.title] : [], request.summary, `Input:\n${renderInput(request.input)}`, APPROVAL_INSTRUCTIONS, `[channel-interaction:v1:${encodeInteractionId(interactionId)}]` ].join("\n\n"); } function asApiResponse(value) { return value !== null && typeof value === "object" ? value : void 0; } function classifyResponse(response, payload) { if (payload.ok === true) { const result = asApiResponse(payload.result); if (typeof result?.message_id === "number") return { status: "delivered", reference: String(result.message_id) }; return uncertain("TELEGRAM_DELIVERY_ERROR", "Telegram returned an invalid delivery response"); } if (payload.ok === false) { const errorCode = typeof payload.error_code === "number" ? payload.error_code : response.status; const message = typeof payload.description === "string" ? payload.description : "Telegram rejected the message"; const error = { code: `TELEGRAM_API_ERROR_${errorCode}`, message }; if (errorCode >= 500) return { status: "uncertain", error }; return { status: "failed", retryable: errorCode === 429, error }; } return uncertain("TELEGRAM_DELIVERY_ERROR", "Telegram returned an invalid delivery response"); } function asTelegramMessage(value) { return value !== null && typeof value === "object" ? value : void 0; } function telegramActor(message) { const sender = message.from; if (sender) { const fullName = [sender.first_name, sender.last_name].filter(Boolean).join(" "); return { id: userIdentity(sender.id), identity: { subject: `user:${sender.id}` }, ...sender.username && { username: sender.username }, ...fullName && { fullName }, isBot: sender.is_bot }; } const senderChat = message.sender_chat; if (!senderChat) return void 0; const fullName = senderChat.title ?? [senderChat.first_name, senderChat.last_name].filter(Boolean).join(" "); return { id: channelIdentity(senderChat.id), identity: { subject: `chat:${senderChat.id}` }, ...senderChat.username && { username: senderChat.username }, ...fullName && { fullName }, isBot: "unknown" }; } function chatLabel(chat) { return `Telegram · ${(chat.title ?? (chat.username ? `@${chat.username}` : void 0) ?? [chat.first_name, chat.last_name].filter(Boolean).join(" ") ?? String(chat.id)) || chat.id}`; } function eventContext(message, eventId, botUserId) { const actor = telegramActor(message); const channelId = channelIdentity(message.chat.id); return { eventId, thread: { id: typeof message.message_thread_id === "number" ? `${channelId}:topic:${message.message_thread_id}` : channelId, isDirectMessage: message.chat.type === "private" ? true : message.chat.type === "group" || message.chat.type === "supergroup" || message.chat.type === "channel" ? false : "unknown" }, replySurface: { version: 1, address: { chatId: String(message.chat.id), ...botUserId && { botUserId }, ...typeof message.message_thread_id === "number" && { messageThreadId: message.message_thread_id }, replyToMessageId: message.message_id }, label: chatLabel(message.chat) }, ...actor && { actor } }; } function telegramTimestamp(value) { if (typeof value !== "number" || !Number.isFinite(value)) return void 0; const date = /* @__PURE__ */ new Date(value * 1e3); return Number.isFinite(date.getTime()) ? date.toISOString() : void 0; } function normalizedMessage(message, eventId, edited, botUserId) { const reply = asTelegramMessage(message.reply_to_message); const sentAt = telegramTimestamp(message.date); const editedAt = telegramTimestamp(message.edit_date); return { type: "message", ...eventContext(message, eventId, botUserId), message: { id: messageIdentity(message.chat.id, message.message_id), text: message.text, ...typeof reply?.message_id === "number" && { reply: { id: messageIdentity(message.chat.id, reply.message_id), ...typeof reply.text === "string" && { text: reply.text } } }, ...(sentAt || edited || editedAt) && { metadata: { ...sentAt && { sentAt }, ...edited && { edited: true }, ...editedAt && { editedAt } } } } }; } function approvalResponse(message, eventId, botUserId) { if (botUserId === void 0) return void 0; if (message.text !== "YES" && message.text !== "NO") return void 0; const reply = asTelegramMessage(message.reply_to_message); if (typeof reply?.message_id !== "number") return void 0; const author = reply.from; if (author?.is_bot !== true || author.id !== botUserId) return void 0; const encodedInteractionId = typeof reply.text === "string" ? reply.text.match(INTERACTION_FOOTER)?.[1] : void 0; const interactionId = encodedInteractionId ? decodeInteractionId(encodedInteractionId) : void 0; if (!interactionId) return void 0; return { type: "approval-response", ...eventContext(message, eventId, botUserId), interactionId, decision: message.text === "YES" ? "approve" : "reject", reference: messageIdentity(message.chat.id, message.message_id) }; } function inboundEvent(update, botUserId) { const edited = update.edited_message !== void 0; const message = asTelegramMessage(edited ? update.edited_message : update.message); if (typeof update.update_id !== "number" || typeof message?.message_id !== "number" || typeof message.text !== "string" || typeof message.chat?.id !== "number") return; const eventId = updateIdentity(message.chat.id, update.update_id); return approvalResponse(message, eventId, botUserId) ?? normalizedMessage(message, eventId, edited, botUserId); } /** Recover this bot's numeric user id from its BotFather token prefix. */ function telegramBotUserId(botToken) { const prefix = botToken.match(/^(\d+):/)?.[1]; if (!prefix) return void 0; const botUserId = Number(prefix); return Number.isSafeInteger(botUserId) && botUserId > 0 ? botUserId : void 0; } /** Create dependency-free Telegram webhook ingress for a destination chat. */ function telegramWebhook(options) { if (!options.secretToken.trim()) throw new Error("secretToken is required to create Telegram webhook ingress"); if (options.botUserId !== void 0 && (!Number.isSafeInteger(options.botUserId) || options.botUserId <= 0)) throw new Error("botUserId must be a positive Telegram user id"); const botUserId = options.botUserId; const path = options.path ?? DEFAULT_TELEGRAM_WEBHOOK_PATH; return { async receive(request) { if (!matchesPath(request, path)) return null; if (request.method !== "POST") return emptyIngressResponse(405); if (request.headers.get("x-telegram-bot-api-secret-token") !== options.secretToken) return emptyIngressResponse(401); let update; try { update = await request.json(); } catch { return emptyIngressResponse(400); } if (update === null || typeof update !== "object") return emptyIngressResponse(400); const raw = update; const event = inboundEvent(raw, botUserId); return { events: event ? [{ event, raw }] : [], response: new Response(null, { status: 200 }) }; } }; } function telegramSurface(surface) { if (!isChannelMessageSurface(surface) || surface.version !== 1 || !isRecord(surface.address) || typeof surface.address.chatId !== "string" || surface.address.chatId.length === 0 || surface.address.botUserId !== void 0 && (!Number.isSafeInteger(surface.address.botUserId) || Number(surface.address.botUserId) <= 0)) return; if (surface.address.messageThreadId !== void 0 && !Number.isSafeInteger(surface.address.messageThreadId) || surface.address.replyToMessageId !== void 0 && !Number.isSafeInteger(surface.address.replyToMessageId)) return; return surface; } /** * Whether a chat can show an animated draft. * * `sendMessageDraft` is private chats only, and Telegram gives private chats * positive ids. Anywhere else the answer is simply sent once at the end. */ function supportsDraft(chatId) { const numeric = Number(chatId); return Number.isSafeInteger(numeric) && numeric > 0; } /** A non-zero draft identifier; reusing one animates between snapshots. */ function newDraftId() { const [random] = crypto.getRandomValues(/* @__PURE__ */ new Uint32Array(1)); return (random ?? 1) % 2147483647 + 1; } function splitText(text, limit) { const pieces = []; let piece = ""; let length = 0; for (const character of text) { if (length === limit) { pieces.push(piece); piece = ""; length = 0; } piece += character; length += 1; } if (piece.length > 0) pieces.push(piece); return pieces; } function textLength(text) { return [...text].length; } /** Create a configured Telegram Bot API Channel. */ function telegram(options) { if (!options.botToken.trim()) throw new Error("botToken is required to create a Telegram channel"); const fetch = options.fetch ?? globalThis.fetch; const apiBaseUrl = (options.apiBaseUrl ?? "https://api.telegram.org").replace(/\/$/, ""); const maxLength = options.maxLength ?? TELEGRAM_MESSAGE_LIMIT; if (!Number.isInteger(maxLength) || maxLength < 1 || maxLength > TELEGRAM_MESSAGE_LIMIT) throw new Error(`maxLength must be an integer between 1 and ${TELEGRAM_MESSAGE_LIMIT}`); const toText = options.toText ?? defaultText; const streamIntervalMs = options.streamIntervalMs ?? DEFAULT_STREAM_INTERVAL_MS; if (!Number.isInteger(streamIntervalMs) || streamIntervalMs < 0) throw new Error("streamIntervalMs must be a non-negative integer"); const botUserId = telegramBotUserId(options.botToken); const ingress = options.webhook ? telegramWebhook({ ...options.webhook, botUserId }) : void 0; async function send(destinationValue, text, parseMode) { const destination = telegramSurface(destinationValue); if (!destination || botUserId !== void 0 && destination.address.botUserId !== void 0 && destination.address.botUserId !== botUserId) return { status: "failed", retryable: false, error: { code: "TELEGRAM_SURFACE_INVALID", message: `Telegram cannot parse the address for Channel "${destinationValue.channelKey}"` } }; if (textLength(text) > maxLength) return { status: "failed", retryable: false, error: { code: "TELEGRAM_MESSAGE_TOO_LONG", message: `Telegram message exceeds the configured ${maxLength}-character limit` } }; let response; try { response = await fetch(`${apiBaseUrl}/bot${options.botToken}/sendMessage`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ chat_id: destination.address.chatId, text, ...destination.address.messageThreadId !== void 0 && { message_thread_id: destination.address.messageThreadId }, ...destination.address.replyToMessageId !== void 0 && { reply_parameters: { message_id: destination.address.replyToMessageId } }, ...parseMode && { parse_mode: parseMode } }) }); } catch { return uncertain("TELEGRAM_DELIVERY_ERROR", "Telegram delivery failed with an unknown outcome"); } let payload; try { payload = await response.json(); } catch { return uncertain("TELEGRAM_DELIVERY_ERROR", "Telegram returned an invalid delivery response"); } const apiResponse = asApiResponse(payload); return apiResponse ? classifyResponse(response, apiResponse) : uncertain("TELEGRAM_DELIVERY_ERROR", "Telegram returned an invalid delivery response"); } /** * Show one ephemeral preview. Failures are swallowed on purpose: a draft is * a 30-second animation, and losing one must never cost the real message. * * The configured parse mode is deliberately not applied. A partial answer * routinely holds unbalanced markup, which Telegram rejects outright. */ async function sendDraft(destination, draftId, text) { try { return asApiResponse(await (await fetch(`${apiBaseUrl}/bot${options.botToken}/sendMessageDraft`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ chat_id: Number(destination.address.chatId), draft_id: draftId, text, ...destination.address.messageThreadId !== void 0 && { message_thread_id: destination.address.messageThreadId } }) })).json())?.ok === true; } catch { return false; } } /** Persist the answer, splitting it when it outgrows one Telegram message. */ async function sendComplete(destination, text) { const [head, ...tail] = splitText(text, maxLength); const parseMode = tail.length === 0 ? options.parseMode : void 0; const first = await send(destination, head, parseMode); if (first.status !== "delivered") return first; for (const piece of tail) if ((await send(destination, piece, parseMode)).status !== "delivered") return uncertain("TELEGRAM_STREAM_PARTIAL", "Telegram accepted only part of a split answer", first.reference); return first; } async function streamMessage(destinationValue, chunks, streamOptions) { const destination = telegramSurface(destinationValue); if (!destination || botUserId !== void 0 && destination.address.botUserId !== void 0 && destination.address.botUserId !== botUserId) { await chunks.cancel().catch(() => {}); return { status: "failed", retryable: false, error: { code: "TELEGRAM_SURFACE_INVALID", message: `Telegram cannot parse the address for Channel "${destinationValue.channelKey}"` } }; } const draftId = supportsDraft(destination.address.chatId) ? newDraftId() : void 0; const prefix = streamOptions.title ? `${streamOptions.title}\n\n` : ""; const shouldPreview = createPacer(streamIntervalMs); let answer = ""; let draftsStopped = draftId === void 0; return consumeChunks(chunks, { async onChunk(chunk) { if (chunk.type !== "text" || chunk.text.length === 0) return; answer += chunk.text; if (draftsStopped || !shouldPreview()) return; if (!await sendDraft(destination, draftId, splitText(`${prefix}${answer}`, maxLength)[0] ?? "")) draftsStopped = true; }, async onFinish(outcome) { if (answer.length === 0) return { status: "failed", retryable: false, error: outcome.interrupted ? { code: "TELEGRAM_STREAM_INTERRUPTED", message: "The answer ended before producing any text to send" } : { code: "TELEGRAM_STREAM_EMPTY", message: "The stream carried no text to send" } }; const result = await sendComplete(destination, `${prefix}${answer}`); if (!outcome.interrupted || result.status !== "delivered") return result; return uncertain("TELEGRAM_STREAM_INTERRUPTED", "An incomplete answer was sent because the stream ended early", result.reference); } }); } return { ...options.route && { route: options.route }, ...ingress && { ingress }, contactSurface(identity) { if ((identity.scope ?? "default") !== "default") return null; const match = identity.subject.match(/^(user|chat):(-?\d+)$/); if (!match) return null; return { version: 1, address: { chatId: match[2], ...botUserId && { botUserId } }, label: `Telegram · ${match[1]} ${match[2]}` }; }, deliver(destination, message) { return send(destination, toText(message), options.parseMode); }, stream(destination, chunks, streamOptions) { return streamMessage(destination, chunks, streamOptions); }, requestApproval(destination, { interactionId, request }) { if (interactionId.length === 0) return Promise.resolve({ status: "failed", retryable: false, error: { code: "TELEGRAM_INTERACTION_ID_REQUIRED", message: "Telegram approval requests require a non-empty interaction id" } }); return send(destination, approvalText(interactionId, request)); } }; } //#endregion export { telegram, telegramWebhook }; //# sourceMappingURL=telegram.js.map