@copilotkit/channels-telegram 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +250 -0
  3. package/dist/adapter.d.ts +117 -0
  4. package/dist/adapter.d.ts.map +1 -0
  5. package/dist/adapter.js +584 -0
  6. package/dist/built-in-context.d.ts +22 -0
  7. package/dist/built-in-context.d.ts.map +1 -0
  8. package/dist/built-in-context.js +77 -0
  9. package/dist/built-in-tools.d.ts +23 -0
  10. package/dist/built-in-tools.d.ts.map +1 -0
  11. package/dist/built-in-tools.js +46 -0
  12. package/dist/chunked-edit-stream.d.ts +70 -0
  13. package/dist/chunked-edit-stream.d.ts.map +1 -0
  14. package/dist/chunked-edit-stream.js +197 -0
  15. package/dist/conversation-store.d.ts +56 -0
  16. package/dist/conversation-store.d.ts.map +1 -0
  17. package/dist/conversation-store.js +98 -0
  18. package/dist/download-files.d.ts +68 -0
  19. package/dist/download-files.d.ts.map +1 -0
  20. package/dist/download-files.js +157 -0
  21. package/dist/event-renderer.d.ts +39 -0
  22. package/dist/event-renderer.d.ts.map +1 -0
  23. package/dist/event-renderer.js +295 -0
  24. package/dist/format-fallback.d.ts +6 -0
  25. package/dist/format-fallback.d.ts.map +1 -0
  26. package/dist/format-fallback.js +21 -0
  27. package/dist/index.d.ts +20 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +14 -0
  30. package/dist/interaction.d.ts +65 -0
  31. package/dist/interaction.d.ts.map +1 -0
  32. package/dist/interaction.js +159 -0
  33. package/dist/listener.d.ts +16 -0
  34. package/dist/listener.d.ts.map +1 -0
  35. package/dist/listener.js +419 -0
  36. package/dist/render/budget.d.ts +18 -0
  37. package/dist/render/budget.d.ts.map +1 -0
  38. package/dist/render/budget.js +24 -0
  39. package/dist/render/telegram.d.ts +11 -0
  40. package/dist/render/telegram.d.ts.map +1 -0
  41. package/dist/render/telegram.js +429 -0
  42. package/dist/telegram-html.d.ts +22 -0
  43. package/dist/telegram-html.d.ts.map +1 -0
  44. package/dist/telegram-html.js +98 -0
  45. package/dist/types.d.ts +83 -0
  46. package/dist/types.d.ts.map +1 -0
  47. package/dist/types.js +2 -0
  48. package/package.json +57 -0
@@ -0,0 +1,159 @@
1
+ import { DM_SCOPE } from "./types.js";
2
+ /**
3
+ * Stable string key shared by ingress (listener) and interaction decoding so
4
+ * the bot's awaitChoice waiters resolve. Both paths MUST derive the
5
+ * conversation key from this single helper — a mismatch silently strands
6
+ * the waiter.
7
+ */
8
+ export function conversationKeyOf(key) {
9
+ return `tg:${key.chatId}:${key.scope}`;
10
+ }
11
+ /**
12
+ * Derive a ConversationKey from a Telegram message/chat object.
13
+ *
14
+ * - Private chat → DM_SCOPE
15
+ * - Forum supergroup with message_thread_id → "topic:<thread_id>"
16
+ * - Other group → "user:<senderUserId>"
17
+ *
18
+ * Non-forum groups are keyed by the SENDER's user id (not message ids) so that
19
+ * each user has one ongoing conversation per group: a fresh @mention (which has
20
+ * no reply, hence a unique message_id) continues the same conversation rather
21
+ * than spawning a new one, and a callback_query (whose `message` is the BOT's
22
+ * own message) still resolves to the clicking user's conversation. Pass an
23
+ * explicit `userId` to override `message.from?.id` (callbacks must pass the
24
+ * clicking user's id so the key matches ingress).
25
+ */
26
+ export function deriveConversationKey(message, userId) {
27
+ const { chat } = message;
28
+ const chatId = String(chat.id);
29
+ if (chat.type === "private") {
30
+ return { chatId, scope: DM_SCOPE };
31
+ }
32
+ // Treat message_thread_id as a forum topic ONLY in forum supergroups. In a
33
+ // regular (non-forum) supergroup Telegram also sets message_thread_id on any
34
+ // REPLY message (it doubles as the reply-thread id), so keying off it there
35
+ // would defeat per-user keying (each reply would spawn a new conversation).
36
+ if (message.message_thread_id !== undefined && message.chat?.is_forum) {
37
+ return { chatId, scope: `topic:${message.message_thread_id}` };
38
+ }
39
+ return { chatId, scope: `user:${userId ?? message.from?.id ?? "unknown"}` };
40
+ }
41
+ /**
42
+ * Map a Telegram `from` user to a PlatformUser.
43
+ */
44
+ export function toPlatformUser(from) {
45
+ if (!from)
46
+ return undefined;
47
+ const name = [from.first_name, from.last_name].filter(Boolean).join(" ") || undefined;
48
+ return {
49
+ id: String(from.id),
50
+ name,
51
+ handle: from.username,
52
+ };
53
+ }
54
+ const emojiSet = (list) => new Set((list ?? [])
55
+ .filter((r) => r.type === "emoji" && r.emoji)
56
+ .map((r) => r.emoji));
57
+ /**
58
+ * Decode a Telegram `message_reaction` update into zero or more
59
+ * {@link IncomingReaction} events — one per emoji that was added or removed.
60
+ * Only `type === "emoji"` entries are considered; custom_emoji are ignored.
61
+ */
62
+ export function decodeReaction(update) {
63
+ const mr = update
64
+ .message_reaction;
65
+ if (!mr?.chat?.id || mr.message_id === undefined)
66
+ return [];
67
+ const oldSet = emojiSet(mr.old_reaction);
68
+ const newSet = emojiSet(mr.new_reaction);
69
+ const chatId = mr.chat.id;
70
+ const message = {
71
+ chat: mr.chat,
72
+ message_id: mr.message_id,
73
+ message_thread_id: mr.message_thread_id,
74
+ };
75
+ const ck = deriveConversationKey(message, mr.user?.id);
76
+ const conversationKey = conversationKeyOf(ck);
77
+ const replyTarget = {
78
+ chatId,
79
+ messageThreadId: mr.chat.is_forum ? mr.message_thread_id : undefined,
80
+ conversationKey,
81
+ };
82
+ const user = mr.user
83
+ ? {
84
+ id: String(mr.user.id),
85
+ name: mr.user.first_name,
86
+ handle: mr.user.username,
87
+ }
88
+ : undefined;
89
+ const base = {
90
+ user,
91
+ conversationKey,
92
+ replyTarget,
93
+ messageId: String(mr.message_id),
94
+ // Update-capable ref (chatId + numeric messageId) so an onReaction handler
95
+ // can edit the reacted message in place via thread.update.
96
+ messageRef: { id: String(mr.message_id), chatId, messageId: mr.message_id },
97
+ raw: update,
98
+ };
99
+ const out = [];
100
+ for (const e of newSet)
101
+ if (!oldSet.has(e))
102
+ out.push({ ...base, rawEmoji: e, added: true });
103
+ for (const e of oldSet)
104
+ if (!newSet.has(e))
105
+ out.push({ ...base, rawEmoji: e, added: false });
106
+ return out;
107
+ }
108
+ /**
109
+ * Decode a Telegram callback_query update (grammY or raw Bot API) into a
110
+ * bot InteractionEvent.
111
+ *
112
+ * Accepts either:
113
+ * - A grammY update: `{ callback_query: { ... } }`
114
+ * - A bare callback query object: `{ id, data, from, message }`
115
+ */
116
+ export function decodeInteraction(raw) {
117
+ if (!raw || typeof raw !== "object")
118
+ return undefined;
119
+ const obj = raw;
120
+ // Unwrap grammY update wrapper if present.
121
+ const cq = (obj.callback_query ?? obj);
122
+ if (!cq || typeof cq !== "object")
123
+ return undefined;
124
+ // Must have callback data.
125
+ if (!cq.data || typeof cq.data !== "string")
126
+ return undefined;
127
+ const message = cq.message;
128
+ if (!message)
129
+ return undefined;
130
+ const from = cq.from;
131
+ // A callback_query's `message` is the BOT's message, so its `from` is the
132
+ // bot. Derive the key from the CLICKING user's id (cq.from) so non-forum
133
+ // group keys match the ingress key produced by the listener.
134
+ const ck = deriveConversationKey(message, from?.id);
135
+ const conversationKey = conversationKeyOf(ck);
136
+ const replyTarget = {
137
+ chatId: message.chat.id,
138
+ // Only attach a forum thread id in forum supergroups. In non-forum chats
139
+ // message_thread_id is the reply-thread id and Telegram rejects sends that
140
+ // attach it ("message thread not found").
141
+ messageThreadId: message.chat?.is_forum
142
+ ? message.message_thread_id
143
+ : undefined,
144
+ conversationKey,
145
+ };
146
+ const messageRef = {
147
+ id: `${message.chat.id}:${message.message_id}`,
148
+ chatId: message.chat.id,
149
+ messageId: message.message_id,
150
+ };
151
+ const user = from ? toPlatformUser(from) : undefined;
152
+ return {
153
+ id: cq.data,
154
+ conversationKey,
155
+ replyTarget,
156
+ messageRef,
157
+ user,
158
+ };
159
+ }
@@ -0,0 +1,16 @@
1
+ import type { Bot } from "grammy";
2
+ import type { IngressSink } from "@copilotkit/channels";
3
+ import type { TelegramConversationStore } from "./conversation-store.js";
4
+ export interface ListenerConfig {
5
+ bot: Bot;
6
+ store: TelegramConversationStore;
7
+ botUsername: string;
8
+ botUserId: number;
9
+ sink: IngressSink;
10
+ /** Telegram bot token, used to download inbound files. */
11
+ botToken: string;
12
+ /** Resolve a Telegram fileId to its file path (e.g. via the getFile API). */
13
+ getFilePath: (fileId: string) => Promise<string>;
14
+ }
15
+ export declare function attachTelegramListener(config: ListenerConfig): void;
16
+ //# sourceMappingURL=listener.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"listener.d.ts","sourceRoot":"","sources":["../src/listener.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,QAAQ,CAAC;AAClC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AACxD,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,yBAAyB,CAAC;AAgBzE,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,GAAG,CAAC;IACT,KAAK,EAAE,yBAAyB,CAAC;IACjC,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,WAAW,CAAC;IAClB,0DAA0D;IAC1D,QAAQ,EAAE,MAAM,CAAC;IACjB,6EAA6E;IAC7E,WAAW,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;CAClD;AA2ED,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,cAAc,GAAG,IAAI,CA6cnE"}
@@ -0,0 +1,419 @@
1
+ import { conversationKeyOf, deriveConversationKey, toPlatformUser, decodeInteraction, decodeReaction, } from "./interaction.js";
2
+ import { buildFileContentParts } from "./download-files.js";
3
+ /** Escape special regex characters in a string so it can be used in a RegExp. */
4
+ function escapeRegExp(s) {
5
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6
+ }
7
+ /**
8
+ * Extract downloadable file refs from any media-bearing message (used for the
9
+ * message a user *replied to*). Mirrors the per-type extraction in the
10
+ * `message:*` handlers — photo (largest size), document, video, audio, voice.
11
+ */
12
+ function fileRefsFromMessage(m) {
13
+ if (m.photo?.length) {
14
+ const largest = m.photo[m.photo.length - 1];
15
+ // Telegram re-encodes most photos to JPEG (see the message:photo handler).
16
+ if (largest)
17
+ return [{ fileId: largest.file_id, mimeType: "image/jpeg" }];
18
+ }
19
+ if (m.document) {
20
+ return [
21
+ {
22
+ fileId: m.document.file_id,
23
+ fileName: m.document.file_name,
24
+ mimeType: m.document.mime_type,
25
+ size: m.document.file_size,
26
+ },
27
+ ];
28
+ }
29
+ for (const media of [m.video, m.audio, m.voice]) {
30
+ if (media) {
31
+ return [
32
+ {
33
+ fileId: media.file_id,
34
+ mimeType: media.mime_type,
35
+ size: media.file_size,
36
+ },
37
+ ];
38
+ }
39
+ }
40
+ return [];
41
+ }
42
+ export function attachTelegramListener(config) {
43
+ const { bot, botUsername, botUserId, sink, store, botToken, getFilePath } = config;
44
+ /**
45
+ * When the inbound message is a Telegram *reply*, fold the referenced
46
+ * message's content into the turn so the agent knows what the user is
47
+ * pointing at — e.g. replying to an earlier image with "what's in it". Its
48
+ * media is downloaded (reusing the inbound-file pipeline) and its text is
49
+ * quoted. Returns [] when the replied-to message carries nothing usable.
50
+ */
51
+ async function buildReplyContextParts(reply) {
52
+ const refs = fileRefsFromMessage(reply);
53
+ const quotedRaw = (reply.text ?? reply.caption ?? "").trim();
54
+ const quoted = quotedRaw.length > 500 ? `${quotedRaw.slice(0, 500)}…` : quotedRaw;
55
+ if (refs.length === 0 && !quoted)
56
+ return [];
57
+ const desc = [
58
+ quoted ? `text: "${quoted}"` : null,
59
+ refs.length ? "an attached file" : null,
60
+ ]
61
+ .filter(Boolean)
62
+ .join(" and ");
63
+ const parts = [
64
+ { type: "text", text: `[In reply to an earlier message — ${desc}:]` },
65
+ ];
66
+ if (refs.length) {
67
+ const { parts: fileParts, notes } = await buildFileContentParts(refs, botToken, getFilePath);
68
+ parts.push(...fileParts);
69
+ if (notes.length) {
70
+ parts.push({
71
+ type: "text",
72
+ text: `[attachment notes: ${notes.join("; ")}]`,
73
+ });
74
+ }
75
+ }
76
+ return parts;
77
+ }
78
+ /**
79
+ * Apply loop-guard + group gating and (when answered) enqueue the user's
80
+ * message onto the store and emit `onTurn`. Returns without effect when the
81
+ * turn should be ignored (own message, or un-addressed group message).
82
+ *
83
+ * @param ctx grammY context (provides `chat`).
84
+ * @param msg the inbound message.
85
+ * @param userText the addressing text used for gating + handler context
86
+ * (text body or media caption; "" when absent).
87
+ * @param buildContent produces the agent message content for an answered
88
+ * turn — the mention-stripped string for text, or an
89
+ * array of content parts for media.
90
+ */
91
+ async function handleTurn(ctx, msg, userText, buildContent) {
92
+ const from = msg.from;
93
+ // LOOP GUARD: ignore our own messages.
94
+ if (from?.id === botUserId)
95
+ return;
96
+ // Bug 4 fix: guard against an empty botUsername. `@${escapeRegExp("")}\b`
97
+ // would be `/@\b/`, which matches a bare "@". When botUsername is empty
98
+ // there is no mention to match, so use a regex that never matches.
99
+ const mentionRegex = botUsername
100
+ ? new RegExp(`@${escapeRegExp(botUsername)}\\b`, "i")
101
+ : undefined;
102
+ // Decide whether to answer.
103
+ const chatType = ctx.chat.type;
104
+ let shouldAnswer = false;
105
+ if (chatType === "private") {
106
+ shouldAnswer = true;
107
+ }
108
+ else {
109
+ // group / supergroup: answer if @mentioned (case-insensitive, word-boundary)
110
+ // or reply to bot's message.
111
+ const mentionedBot = mentionRegex?.test(userText) ?? false;
112
+ const replyToBot = msg.reply_to_message?.from?.id === botUserId;
113
+ shouldAnswer = mentionedBot || replyToBot;
114
+ }
115
+ if (!shouldAnswer)
116
+ return;
117
+ // Bug 1 & 2 fix: strip the @mention wherever it appears (first occurrence),
118
+ // case-insensitively, collapsing surrounding whitespace so the agent gets
119
+ // clean text. With no botUsername there is nothing to strip.
120
+ let strippedText = (mentionRegex ? userText.replace(mentionRegex, "") : userText)
121
+ .replace(/\s{2,}/g, " ")
122
+ .trim();
123
+ const turnConversationKey = conversationKeyOf(deriveConversationKey(msg));
124
+ // Build the agent content, then fold in any replied-to message so the
125
+ // agent can resolve a Telegram reply ("what's in this image" pointing at an
126
+ // earlier photo). Reply context is appended after the user's own content.
127
+ let content = buildContent(strippedText);
128
+ if (msg.reply_to_message) {
129
+ const replyParts = await buildReplyContextParts(msg.reply_to_message);
130
+ if (replyParts.length) {
131
+ const baseParts = typeof content === "string"
132
+ ? content
133
+ ? [{ type: "text", text: content }]
134
+ : []
135
+ : content;
136
+ content = [...baseParts, ...replyParts];
137
+ }
138
+ }
139
+ // Enqueue the user's message so getOrCreate delivers it to the agent. The
140
+ // listener runs before the bot handler that calls runAgent → getOrCreate,
141
+ // so the drain happens at exactly the right time.
142
+ store.enqueueUserMessage(turnConversationKey, content);
143
+ // Bug 3 fix: record the inbound user turn so getMessages() includes it.
144
+ // Record the STRIPPED text (the same clean text handed to the agent, not
145
+ // the raw text including the @mention) and stamp `ts` so history ordering
146
+ // matches the outbound records.
147
+ config.store.recordMessage(turnConversationKey, {
148
+ text: strippedText,
149
+ ts: String(msg.message_id),
150
+ isBot: false,
151
+ user: from ? toPlatformUser(from) : undefined,
152
+ });
153
+ // CRITICAL: run the turn WITHOUT blocking grammY's poll loop. grammY's
154
+ // built-in long polling processes updates SEQUENTIALLY — it awaits one
155
+ // update's handler before fetching the next. If we awaited the full turn
156
+ // here, a blocking human-in-the-loop step (confirm_write → awaitChoice)
157
+ // would pause polling indefinitely: the callback_query that resolves the
158
+ // choice can only arrive via the next getUpdates, which never happens while
159
+ // this handler is blocked. With no in-flight poll request (and only a
160
+ // pending promise, which does not ref the event loop) the process would
161
+ // then drain and exit(0) silently — a deadlock, not a crash. Firing the
162
+ // turn async lets polling continue and deliver that callback. Errors are
163
+ // logged here since nothing awaits the promise.
164
+ void Promise.resolve(sink.onTurn({
165
+ conversationKey: turnConversationKey,
166
+ replyTarget: {
167
+ chatId: ctx.chat.id,
168
+ // Only attach a forum thread id in forum supergroups. In non-forum
169
+ // chats Telegram sets message_thread_id on reply messages, but
170
+ // attaching it to a send is rejected ("message thread not found").
171
+ messageThreadId: ctx.chat.is_forum
172
+ ? msg.message_thread_id
173
+ : undefined,
174
+ replyToMessageId: msg.message_id,
175
+ conversationKey: turnConversationKey,
176
+ },
177
+ userText: strippedText,
178
+ user: from ? toPlatformUser(from) : undefined,
179
+ platform: "telegram",
180
+ })).catch((e) => {
181
+ console.error(`[bot-telegram] turn failed for conversationKey=${turnConversationKey}:`, e);
182
+ });
183
+ }
184
+ /** Build media content parts from file refs + caption, then route the turn. */
185
+ async function handleMedia(ctx, fileRefs) {
186
+ const msg = ctx.message;
187
+ if (!msg)
188
+ return;
189
+ // LOOP GUARD up-front to avoid downloading files for our own messages.
190
+ if (msg.from?.id === botUserId)
191
+ return;
192
+ const caption = msg.caption ?? "";
193
+ // Gating is applied inside handleTurn against the caption; only build the
194
+ // (potentially expensive) file parts once we know the turn is answered.
195
+ // Bug 1 fix: use case-insensitive, word-boundary regex for mention check.
196
+ // Bug 4 fix: guard against an empty botUsername (see handleTurn).
197
+ const mediaMentionRegex = botUsername
198
+ ? new RegExp(`@${escapeRegExp(botUsername)}\\b`, "i")
199
+ : undefined;
200
+ const chatType = ctx.chat.type;
201
+ let shouldAnswer = chatType === "private";
202
+ if (!shouldAnswer) {
203
+ const mentionedBot = mediaMentionRegex?.test(caption) ?? false;
204
+ const replyToBot = msg.reply_to_message?.from?.id === botUserId;
205
+ shouldAnswer = mentionedBot || replyToBot;
206
+ }
207
+ if (!shouldAnswer)
208
+ return;
209
+ const { parts, notes } = await buildFileContentParts(fileRefs, botToken, getFilePath);
210
+ await handleTurn(ctx, msg, caption, (strippedCaption) => [
211
+ ...(strippedCaption
212
+ ? [{ type: "text", text: strippedCaption }]
213
+ : []),
214
+ ...parts,
215
+ ...(notes.length
216
+ ? [
217
+ {
218
+ type: "text",
219
+ text: `[attachment notes: ${notes.join("; ")}]`,
220
+ },
221
+ ]
222
+ : []),
223
+ ]);
224
+ }
225
+ // ── Text messages ──────────────────────────────────────────────────
226
+ bot.on("message:text", async (ctx) => {
227
+ const msg = ctx.message;
228
+ const from = msg.from;
229
+ // LOOP GUARD: ignore our own messages.
230
+ if (from?.id === botUserId)
231
+ return;
232
+ const text = msg.text ?? "";
233
+ const entities = msg
234
+ .entities ?? [];
235
+ const cmdEntity = entities.find((e) => e.type === "bot_command" && e.offset === 0);
236
+ if (cmdEntity) {
237
+ // Extract the command token (e.g. "/triage" or "/triage@cpk_bot")
238
+ const cmdToken = text.slice(cmdEntity.offset, cmdEntity.offset + cmdEntity.length);
239
+ // Parse: /cmd[@bot]
240
+ const withoutSlash = cmdToken.slice(1); // strip leading "/"
241
+ const atIdx = withoutSlash.indexOf("@");
242
+ let cmd;
243
+ let targetBot;
244
+ if (atIdx !== -1) {
245
+ cmd = withoutSlash.slice(0, atIdx);
246
+ targetBot = withoutSlash.slice(atIdx + 1);
247
+ }
248
+ else {
249
+ cmd = withoutSlash;
250
+ targetBot = undefined;
251
+ }
252
+ // If targeted at another bot, ignore. Guard against an undefined/empty
253
+ // botUsername (consistent with the mention-path guard): when we have no
254
+ // username we cannot prove the command targets us, so skip the compare
255
+ // and let it through rather than throwing on `botUsername.toLowerCase()`.
256
+ if (targetBot !== undefined &&
257
+ botUsername &&
258
+ targetBot.toLowerCase() !== botUsername.toLowerCase()) {
259
+ return;
260
+ }
261
+ // Bug 2 fix: `/start` is handled exclusively by the dedicated
262
+ // bot.command("start") handler (which emits onThreadStarted). Returning
263
+ // early here prevents a double-dispatch where both onCommand("start") and
264
+ // onThreadStarted fire for the same /start message.
265
+ if (cmd.toLowerCase() === "start") {
266
+ return;
267
+ }
268
+ // Rest of the text after the command token
269
+ const rest = text.slice(cmdEntity.offset + cmdEntity.length).trim();
270
+ const commandConversationKey = conversationKeyOf(deriveConversationKey(msg));
271
+ // Commands do NOT enqueue: their args are injected by the command handler
272
+ // via runAgent({ prompt }), so enqueuing here would double-deliver.
273
+ // Fire async (not awaited) for the same reason as onTurn above — a command
274
+ // whose agent hits confirm_write must not block grammY's poll loop.
275
+ void Promise.resolve(sink.onCommand({
276
+ command: cmd.toLowerCase(),
277
+ text: rest,
278
+ conversationKey: commandConversationKey,
279
+ replyTarget: {
280
+ chatId: ctx.chat.id,
281
+ // Forum thread id only in forum supergroups (see onTurn above).
282
+ messageThreadId: ctx.chat.is_forum
283
+ ? msg.message_thread_id
284
+ : undefined,
285
+ conversationKey: commandConversationKey,
286
+ },
287
+ user: from ? toPlatformUser(from) : undefined,
288
+ platform: "telegram",
289
+ })).catch((e) => console.error("[bot-telegram] command failed:", e));
290
+ return;
291
+ }
292
+ // Non-command text: gate, enqueue the (mention-stripped) text, emit onTurn.
293
+ await handleTurn(ctx, msg, text, (strippedText) => strippedText);
294
+ });
295
+ // ── Media messages ─────────────────────────────────────────────────
296
+ bot.on("message:photo", async (ctx) => {
297
+ const photos = ctx.message.photo ?? [];
298
+ const largest = photos[photos.length - 1];
299
+ if (!largest)
300
+ return;
301
+ // Bug 5: at this point we only have the file_id, not the bytes, so we
302
+ // cannot sniff the real MIME type here. Telegram re-encodes most photos to
303
+ // JPEG, so "image/jpeg" is a reasonable default. Telegram photos can in
304
+ // principle be PNG/WebP; refining the MIME from the download response's
305
+ // Content-Type belongs in the download layer (out of scope for this file).
306
+ await handleMedia(ctx, [
307
+ { fileId: largest.file_id, mimeType: "image/jpeg" },
308
+ ]);
309
+ });
310
+ bot.on("message:document", async (ctx) => {
311
+ const doc = ctx.message.document;
312
+ if (!doc)
313
+ return;
314
+ await handleMedia(ctx, [
315
+ {
316
+ fileId: doc.file_id,
317
+ fileName: doc.file_name,
318
+ mimeType: doc.mime_type,
319
+ size: doc.file_size,
320
+ },
321
+ ]);
322
+ });
323
+ bot.on("message:video", async (ctx) => {
324
+ const video = ctx.message.video;
325
+ if (!video)
326
+ return;
327
+ await handleMedia(ctx, [
328
+ {
329
+ fileId: video.file_id,
330
+ mimeType: video.mime_type,
331
+ size: video.file_size,
332
+ },
333
+ ]);
334
+ });
335
+ bot.on("message:audio", async (ctx) => {
336
+ const audio = ctx.message.audio;
337
+ if (!audio)
338
+ return;
339
+ await handleMedia(ctx, [
340
+ {
341
+ fileId: audio.file_id,
342
+ mimeType: audio.mime_type,
343
+ size: audio.file_size,
344
+ },
345
+ ]);
346
+ });
347
+ bot.on("message:voice", async (ctx) => {
348
+ const voice = ctx.message.voice;
349
+ if (!voice)
350
+ return;
351
+ await handleMedia(ctx, [
352
+ {
353
+ fileId: voice.file_id,
354
+ mimeType: voice.mime_type,
355
+ size: voice.file_size,
356
+ },
357
+ ]);
358
+ });
359
+ // ── Callback queries (inline keyboard interactions) ─────────────────
360
+ bot.on("callback_query:data", async (ctx) => {
361
+ // Ack FIRST to clear the client spinner. The ack itself may throw (e.g. a
362
+ // stale button → "query is too old"); that must NOT block decode/dispatch,
363
+ // otherwise the awaitChoice waiter is stranded. Wrap it in its own
364
+ // try/catch, log on failure, then ALWAYS decode + dispatch.
365
+ try {
366
+ await ctx.answerCallbackQuery();
367
+ }
368
+ catch (err) {
369
+ console.error("[bot-telegram] callback ack failed:", err);
370
+ }
371
+ // Guard the dispatch: a throw here (decode, action handler, or the resumed
372
+ // agent turn) must NOT escape into grammy's poll loop and crash the bot.
373
+ try {
374
+ const evt = decodeInteraction(ctx.update);
375
+ if (evt) {
376
+ await sink.onInteraction(evt);
377
+ }
378
+ }
379
+ catch (err) {
380
+ console.error("[bot-telegram] onInteraction failed:", err);
381
+ }
382
+ });
383
+ // ── Emoji reactions ────────────────────────────────────────────────
384
+ bot.on("message_reaction", async (ctx) => {
385
+ // LOOP GUARD: ignore the bot's OWN reactions. If our setMessageReaction
386
+ // egress is ever echoed back as a message_reaction update, dispatching it
387
+ // to sink.onReaction would treat it as a user reaction (loop risk for a
388
+ // catch-all handler). Mirrors the from?.id === botUserId guard the message
389
+ // handlers use, and Discord's user?.bot skip.
390
+ const reactor = ctx.update.message_reaction?.user;
391
+ if (reactor?.id === botUserId)
392
+ return;
393
+ try {
394
+ for (const evt of decodeReaction(ctx.update))
395
+ await sink.onReaction(evt);
396
+ }
397
+ catch (err) {
398
+ console.error("[bot-telegram] onReaction failed:", err);
399
+ }
400
+ });
401
+ // ── /start command (private chats only) ────────────────────────────
402
+ bot.command("start", async (ctx) => {
403
+ if (ctx.chat.type !== "private")
404
+ return;
405
+ const msg = ctx.message;
406
+ if (!msg)
407
+ return;
408
+ const startConversationKey = conversationKeyOf(deriveConversationKey(msg));
409
+ await sink.onThreadStarted({
410
+ conversationKey: startConversationKey,
411
+ replyTarget: {
412
+ chatId: ctx.chat.id,
413
+ conversationKey: startConversationKey,
414
+ },
415
+ user: msg.from ? toPlatformUser(msg.from) : undefined,
416
+ platform: "telegram",
417
+ });
418
+ });
419
+ }
@@ -0,0 +1,18 @@
1
+ export declare const TELEGRAM_LIMITS: {
2
+ readonly messageText: 4096;
3
+ readonly caption: 1024;
4
+ readonly callbackData: 64;
5
+ readonly buttonsPerRow: 8;
6
+ readonly buttonsPerMessage: 100;
7
+ readonly buttonText: 64;
8
+ readonly photosPerMessage: 10;
9
+ };
10
+ export declare const byteLen: (s: string) => number;
11
+ /** Truncate to max chars, appending an ellipsis marker if the input was longer. Never returns >max. */
12
+ export declare function truncateText(text: string, max: number): string;
13
+ /** Clamp an array to max items; return the kept items plus how many overflowed (for an overflow signal). */
14
+ export declare function clampArray<T>(items: readonly T[], max: number): {
15
+ items: T[];
16
+ overflow: number;
17
+ };
18
+ //# sourceMappingURL=budget.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"budget.d.ts","sourceRoot":"","sources":["../../src/render/budget.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,eAAe;;;;;;;;CAQlB,CAAC;AAEX,eAAO,MAAM,OAAO,GAAI,GAAG,MAAM,WAAiC,CAAC;AAEnE,uGAAuG;AACvG,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAI9D;AAED,4GAA4G;AAC5G,wBAAgB,UAAU,CAAC,CAAC,EAC1B,KAAK,EAAE,SAAS,CAAC,EAAE,EACnB,GAAG,EAAE,MAAM,GACV;IAAE,KAAK,EAAE,CAAC,EAAE,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAGlC"}
@@ -0,0 +1,24 @@
1
+ export const TELEGRAM_LIMITS = {
2
+ messageText: 4096,
3
+ caption: 1024,
4
+ callbackData: 64,
5
+ buttonsPerRow: 8,
6
+ buttonsPerMessage: 100,
7
+ buttonText: 64,
8
+ photosPerMessage: 10,
9
+ };
10
+ export const byteLen = (s) => Buffer.byteLength(s, "utf8");
11
+ /** Truncate to max chars, appending an ellipsis marker if the input was longer. Never returns >max. */
12
+ export function truncateText(text, max) {
13
+ if (text.length <= max)
14
+ return text;
15
+ if (max <= 1)
16
+ return text.slice(0, max);
17
+ return text.slice(0, max - 1) + "…"; // …
18
+ }
19
+ /** Clamp an array to max items; return the kept items plus how many overflowed (for an overflow signal). */
20
+ export function clampArray(items, max) {
21
+ if (items.length <= max)
22
+ return { items: [...items], overflow: 0 };
23
+ return { items: items.slice(0, max), overflow: items.length - max };
24
+ }
@@ -0,0 +1,11 @@
1
+ import type { BotNode } from "@copilotkit/channels-ui";
2
+ import type { TelegramPayload } from "../types.js";
3
+ /**
4
+ * Render a cross-platform component IR tree into a Telegram Bot API payload.
5
+ *
6
+ * The renderer is total: unknown intrinsic types are skipped rather than
7
+ * throwing. Telegram limits are enforced via {@link truncateText},
8
+ * {@link clampArray}, and {@link byteLen}.
9
+ */
10
+ export declare function renderTelegram(ir: BotNode[]): TelegramPayload;
11
+ //# sourceMappingURL=telegram.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"telegram.d.ts","sourceRoot":"","sources":["../../src/render/telegram.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,yBAAyB,CAAC;AACvD,OAAO,KAAK,EAAwB,eAAe,EAAE,MAAM,aAAa,CAAC;AA6BzE;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,EAAE,EAAE,OAAO,EAAE,GAAG,eAAe,CAoD7D"}