@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,584 @@
1
+ import { Bot, InputFile } from "grammy";
2
+ import { toPlatformEmoji } from "@copilotkit/channels-ui";
3
+ import { TelegramConversationStore } from "./conversation-store.js";
4
+ import { attachTelegramListener } from "./listener.js";
5
+ import { createRunRenderer } from "./event-renderer.js";
6
+ import { decodeInteraction, conversationKeyOf } from "./interaction.js";
7
+ import { renderTelegram } from "./render/telegram.js";
8
+ import { ChunkedEditStream } from "./chunked-edit-stream.js";
9
+ import { telegramHtml } from "./telegram-html.js";
10
+ import { withTelegramFormatFallback, stripHtml } from "./format-fallback.js";
11
+ import { DM_SCOPE } from "./types.js";
12
+ /**
13
+ * Update types that the Telegram adapter subscribes to. Includes
14
+ * `message_reaction` so the bot receives emoji-reaction events.
15
+ *
16
+ * In **group** chats the bot must be an administrator to receive
17
+ * `message_reaction` updates; private chats and channels work without
18
+ * additional permissions.
19
+ *
20
+ * For **webhook** deployments pass this list to `setWebhook`:
21
+ * ```ts
22
+ * await bot.api.setWebhook(url, { allowed_updates: [...TELEGRAM_ALLOWED_UPDATES] });
23
+ * ```
24
+ * Long-polling (`start()`) passes it automatically.
25
+ */
26
+ export const TELEGRAM_ALLOWED_UPDATES = [
27
+ "message",
28
+ "edited_message",
29
+ "callback_query",
30
+ "message_reaction",
31
+ ];
32
+ /**
33
+ * Telegram `PlatformAdapter`: ingress via grammY (long-polling or webhook),
34
+ * egress via the package's HTML renderer + chunked-edit streaming.
35
+ *
36
+ * Construction is side-effect-free — the grammY {@link Bot} is created but no
37
+ * network call is made until {@link start} (which owns `getMe` + ingress).
38
+ */
39
+ export class TelegramAdapter {
40
+ opts;
41
+ platform = "telegram";
42
+ capabilities = {
43
+ supportsModals: false,
44
+ supportsTyping: true,
45
+ supportsReactions: true,
46
+ supportsEphemeral: false,
47
+ supportsStreaming: true,
48
+ supportsSuggestedPrompts: false,
49
+ supportsThreadTitle: true,
50
+ };
51
+ ackDeadlineMs = 3000;
52
+ /** Public/assignable so tests can inject a fake (`adapter.bot = fake`). */
53
+ bot;
54
+ botUsername = "";
55
+ botUserId = 0;
56
+ store = new TelegramConversationStore();
57
+ /** In webhook mode, the Node HTTP server standing up the webhook endpoint. */
58
+ webhookServer;
59
+ constructor(opts) {
60
+ this.opts = opts;
61
+ // SIDE-EFFECT-FREE: the grammY Bot constructor performs no network I/O.
62
+ // start() owns getMe() + ingress.
63
+ this.bot = new Bot(opts.token);
64
+ }
65
+ /** Greeting text to post on conversation start (wired by the example's onThreadStarted). */
66
+ get greeting() {
67
+ return this.opts.greeting;
68
+ }
69
+ /** Suggested prompt chips to surface on conversation start. */
70
+ get suggestedPrompts() {
71
+ return this.opts.suggestedPrompts;
72
+ }
73
+ async start(sink) {
74
+ const me = await this.bot.api.getMe();
75
+ this.botUsername = me.username;
76
+ this.botUserId = me.id;
77
+ // Resilience boundary. Without a registered error handler, grammy rethrows
78
+ // any uncaught error from update processing, which stops the polling runner;
79
+ // because start() has already returned, the event loop then drains and the
80
+ // process exits silently (code 0). Catching here logs the error and KEEPS
81
+ // the bot polling, and lets grammy advance the offset so a failing update is
82
+ // consumed rather than re-delivered forever (the "poison pill" loop).
83
+ this.bot.catch((err) => {
84
+ const updateId = err.ctx?.update?.update_id;
85
+ console.error(`[bot-telegram] error handling update${updateId !== undefined ? ` ${updateId}` : ""}:`, err.error);
86
+ });
87
+ attachTelegramListener({
88
+ bot: this.bot,
89
+ store: this.store,
90
+ botUsername: this.botUsername,
91
+ botUserId: this.botUserId,
92
+ sink,
93
+ botToken: this.opts.token,
94
+ getFilePath: async (fileId) => (await this.bot.api.getFile(fileId)).file_path ?? "",
95
+ });
96
+ const mode = this.resolveMode();
97
+ if (mode === "webhook") {
98
+ await this.startWebhook();
99
+ }
100
+ else {
101
+ // Long-polling: bot.start() resolves only when the bot is stopped, so we
102
+ // fire it (don't await to completion) and let it run in the background.
103
+ // Surface a startup rejection (e.g. 409 "terminated by other getUpdates
104
+ // request" or a revoked token) instead of swallowing it silently.
105
+ this.bot
106
+ .start({ allowed_updates: [...TELEGRAM_ALLOWED_UPDATES] })
107
+ .catch((err) => console.error("[telegram] long-polling failed to start:", err));
108
+ }
109
+ }
110
+ /** Resolve the effective ingress mode, expanding "auto" by environment. */
111
+ resolveMode() {
112
+ const mode = this.opts.mode ?? "polling";
113
+ if (mode === "polling")
114
+ return "polling";
115
+ if (mode === "webhook")
116
+ return "webhook";
117
+ // "auto": prefer webhook in serverless environments, else long-poll. But
118
+ // only choose webhook when a domain is actually configured — otherwise
119
+ // startWebhook() would throw, contradicting "auto"'s documented fallback to
120
+ // polling. (Explicit mode: "webhook" without a domain still throws, since
121
+ // that is a real misconfiguration.)
122
+ const serverless = process.env.VERCEL ||
123
+ process.env.AWS_LAMBDA_FUNCTION_NAME ||
124
+ process.env.NETLIFY;
125
+ return serverless && this.opts.webhook?.domain ? "webhook" : "polling";
126
+ }
127
+ /**
128
+ * Register the webhook with Telegram and stand up a minimal Node HTTP server
129
+ * that feeds updates to grammY via {@link webhookCallback}. Requires
130
+ * `opts.webhook.domain`.
131
+ */
132
+ async startWebhook() {
133
+ const webhook = this.opts.webhook;
134
+ if (!webhook?.domain) {
135
+ throw new Error("Telegram webhook mode requires opts.webhook.domain");
136
+ }
137
+ const normalizedPath = webhook.path
138
+ ? webhook.path.startsWith("/")
139
+ ? webhook.path
140
+ : `/${webhook.path}`
141
+ : "/telegram";
142
+ const url = `${webhook.domain.replace(/\/$/, "")}${normalizedPath}`;
143
+ await this.bot.api.setWebhook(url, webhook.secretToken ? { secret_token: webhook.secretToken } : undefined);
144
+ const { webhookCallback } = await import("grammy");
145
+ const { createServer } = await import("node:http");
146
+ const handler = webhookCallback(this.bot, "http", webhook.secretToken ? { secretToken: webhook.secretToken } : undefined);
147
+ const server = createServer((req, res) => {
148
+ if (req.url && req.url.split("?")[0] === normalizedPath) {
149
+ void handler(req, res);
150
+ }
151
+ else {
152
+ res.statusCode = 404;
153
+ res.end();
154
+ }
155
+ });
156
+ this.webhookServer = server;
157
+ // Telegram only delivers webhook updates to ports 443/80/88/8443, so an
158
+ // ephemeral port (0) would be unreachable. Default to 8443.
159
+ const port = webhook.port ?? 8443;
160
+ // Surface bind failures (EADDRINUSE/EACCES) clearly: without an "error"
161
+ // listener the emitted error becomes an uncaught exception that crashes the
162
+ // process with a cryptic stack. Reject the start promise on the first
163
+ // listen error so callers can handle it.
164
+ await new Promise((resolve, reject) => {
165
+ let settled = false;
166
+ server.on("error", (err) => {
167
+ console.error(`[telegram] webhook server error (port ${port}):`, err);
168
+ if (!settled) {
169
+ settled = true;
170
+ reject(err);
171
+ }
172
+ });
173
+ server.listen(port, () => {
174
+ console.log(`[telegram] webhook server listening on port ${port}`);
175
+ if (!settled) {
176
+ settled = true;
177
+ resolve();
178
+ }
179
+ });
180
+ });
181
+ }
182
+ async stop() {
183
+ // Tear down the webhook server + registration before stopping the bot, so a
184
+ // stop/restart cleanly rebinds the socket instead of leaking it.
185
+ // Order: deleteWebhook first (so Telegram stops sending), then close the
186
+ // local HTTP server (no more refused-socket errors on in-flight POSTs), then
187
+ // stop the bot. The old reverse order (close server → deleteWebhook) left a
188
+ // window where Telegram kept POSTing to an already-closed socket.
189
+ if (this.webhookServer) {
190
+ const server = this.webhookServer;
191
+ this.webhookServer = undefined;
192
+ await this.bot.api
193
+ .deleteWebhook()
194
+ .catch((e) => console.error("[telegram] deleteWebhook failed:", e));
195
+ await new Promise((resolve) => server.close(() => resolve()));
196
+ }
197
+ await this.bot.stop();
198
+ }
199
+ render(ir) {
200
+ return renderTelegram(ir);
201
+ }
202
+ async post(target, ir) {
203
+ const t = target;
204
+ const p = renderTelegram(ir);
205
+ const replyMarkup = this.toReplyMarkup(p.inlineKeyboard);
206
+ const photos = p.photos ?? [];
207
+ const hasText = p.text.trim().length > 0;
208
+ let chatId;
209
+ let messageId;
210
+ if (hasText) {
211
+ // Text (with optional photos riding along as separate messages). The
212
+ // returned ref references the text message.
213
+ const sent = await withTelegramFormatFallback((o) => this.bot.api.sendMessage(t.chatId, o.text, {
214
+ ...(o.parseMode ? { parse_mode: o.parseMode } : {}),
215
+ ...(t.messageThreadId !== undefined
216
+ ? { message_thread_id: t.messageThreadId }
217
+ : {}),
218
+ ...(replyMarkup ? { reply_markup: replyMarkup } : {}),
219
+ ...(t.replyToMessageId !== undefined
220
+ ? {
221
+ reply_parameters: { message_id: t.replyToMessageId },
222
+ }
223
+ : {}),
224
+ }), p.text);
225
+ // Photos ride along as separate messages.
226
+ for (const photo of photos) {
227
+ await this.bot.api.sendPhoto(t.chatId, photo.url, {
228
+ ...(photo.caption ? { caption: photo.caption } : {}),
229
+ ...(t.messageThreadId !== undefined
230
+ ? { message_thread_id: t.messageThreadId }
231
+ : {}),
232
+ });
233
+ }
234
+ chatId = sent.chat.id;
235
+ messageId = sent.message_id;
236
+ }
237
+ else if (photos.length > 0) {
238
+ // Image-only render: skip the empty sendMessage (Telegram rejects an
239
+ // empty text body with a "message text is empty" error that the format
240
+ // fallback does NOT catch) and post the photo(s) instead. The first
241
+ // photo carries the inline keyboard and reply-to; the ref references it.
242
+ const [first, ...rest] = photos;
243
+ const sent = await this.bot.api.sendPhoto(t.chatId, first.url, {
244
+ ...(first.caption ? { caption: first.caption } : {}),
245
+ ...(t.messageThreadId !== undefined
246
+ ? { message_thread_id: t.messageThreadId }
247
+ : {}),
248
+ ...(replyMarkup ? { reply_markup: replyMarkup } : {}),
249
+ ...(t.replyToMessageId !== undefined
250
+ ? { reply_parameters: { message_id: t.replyToMessageId } }
251
+ : {}),
252
+ });
253
+ for (const photo of rest) {
254
+ await this.bot.api.sendPhoto(t.chatId, photo.url, {
255
+ ...(photo.caption ? { caption: photo.caption } : {}),
256
+ ...(t.messageThreadId !== undefined
257
+ ? { message_thread_id: t.messageThreadId }
258
+ : {}),
259
+ });
260
+ }
261
+ chatId = sent.chat.id;
262
+ messageId = sent.message_id;
263
+ }
264
+ else {
265
+ // Nothing to post (no text, no photos). Return a harmless empty ref —
266
+ // update()/delete() guard against a 0 messageId, so nothing is edited.
267
+ return { id: "", chatId: t.chatId, messageId: 0 };
268
+ }
269
+ const ref = {
270
+ id: `${chatId}:${messageId}`,
271
+ chatId,
272
+ messageId,
273
+ };
274
+ // Record the outbound message for lightweight within-session context.
275
+ // Store the plain-text form (not raw HTML) since getMessages feeds this
276
+ // back to the agent as context. Skip when the stripped text is empty
277
+ // (image-only post) to avoid recording a blank bot turn into history.
278
+ const plainText = stripHtml(p.text);
279
+ if (plainText.trim()) {
280
+ this.store.recordMessage(this.conversationKeyForTarget(t), {
281
+ text: plainText,
282
+ ts: String(messageId),
283
+ isBot: true,
284
+ });
285
+ }
286
+ return ref;
287
+ }
288
+ async update(ref, ir) {
289
+ const r = ref;
290
+ // Guard against a bogus ref (e.g. the empty ref returned by stream() when
291
+ // no chunk was posted): a message id of 0/undefined can never be edited.
292
+ if (!r || !r.messageId)
293
+ return;
294
+ const p = renderTelegram(ir);
295
+ // Guard against an image-only IR (empty text). Calling editMessageText with
296
+ // "" triggers a "message text is empty" error from Telegram (NOT caught by
297
+ // withTelegramFormatFallback). Calling it on a photo ref triggers "there is
298
+ // no text in the message to edit". Editing media is unsupported — no-op.
299
+ if (!p.text.trim())
300
+ return;
301
+ const replyMarkup = this.toReplyMarkup(p.inlineKeyboard);
302
+ try {
303
+ await withTelegramFormatFallback((o) => this.bot.api.editMessageText(r.chatId, r.messageId, o.text, {
304
+ ...(o.parseMode ? { parse_mode: o.parseMode } : {}),
305
+ ...(replyMarkup ? { reply_markup: replyMarkup } : {}),
306
+ }), p.text);
307
+ }
308
+ catch (err) {
309
+ // Telegram rejects an edit whose text equals the current text. That is a
310
+ // no-op from our perspective, so swallow it; let other errors propagate.
311
+ if (err instanceof Error &&
312
+ /message is not modified/i.test(err.message)) {
313
+ return;
314
+ }
315
+ throw err;
316
+ }
317
+ }
318
+ async stream(target, chunks) {
319
+ const t = target;
320
+ let firstRef;
321
+ const stream = new ChunkedEditStream({
322
+ postPlaceholder: async (text) => {
323
+ // Wrap in the format fallback so a chunk that splits an HTML tag
324
+ // degrades to plain text instead of failing the send.
325
+ const sent = await withTelegramFormatFallback((o) => this.bot.api.sendMessage(t.chatId, o.text, {
326
+ ...(o.parseMode ? { parse_mode: o.parseMode } : {}),
327
+ ...(t.messageThreadId !== undefined
328
+ ? { message_thread_id: t.messageThreadId }
329
+ : {}),
330
+ }), text);
331
+ if (!firstRef) {
332
+ firstRef = {
333
+ id: `${sent.chat.id}:${sent.message_id}`,
334
+ chatId: sent.chat.id,
335
+ messageId: sent.message_id,
336
+ };
337
+ }
338
+ return sent.message_id;
339
+ },
340
+ editAt: async (messageId, text) => {
341
+ // Wrap in the format fallback so a chunk that splits an HTML tag
342
+ // degrades to plain text instead of failing the edit.
343
+ await withTelegramFormatFallback((o) => this.bot.api.editMessageText(t.chatId, messageId, o.text, o.parseMode ? { parse_mode: o.parseMode } : {}), text);
344
+ },
345
+ transform: telegramHtml,
346
+ });
347
+ let acc = "";
348
+ for await (const chunk of chunks) {
349
+ acc += chunk;
350
+ stream.append(acc);
351
+ }
352
+ // ChunkedEditStream.finish() rejects on a failed terminal edit. The
353
+ // streamed content is best-effort, so log the failure rather than
354
+ // swallowing it or letting it abort the response.
355
+ try {
356
+ await stream.finish();
357
+ }
358
+ catch (err) {
359
+ console.error("[telegram] stream finish failed:", err);
360
+ }
361
+ return firstRef ?? { id: "", chatId: t.chatId, messageId: 0 };
362
+ }
363
+ async delete(ref) {
364
+ const r = ref;
365
+ // Guard against a bogus ref (e.g. the empty ref returned by stream() when
366
+ // no chunk was posted): a message id of 0/undefined can never be deleted.
367
+ if (!r || !r.messageId)
368
+ return;
369
+ await this.bot.api.deleteMessage(r.chatId, r.messageId);
370
+ }
371
+ createRunRenderer(target) {
372
+ const t = target;
373
+ return createRunRenderer({
374
+ postPlaceholder: async (text) => {
375
+ // Wrap in the format fallback so a chunk that splits an HTML tag
376
+ // degrades to plain text instead of failing the send (mirrors stream()).
377
+ const sent = await withTelegramFormatFallback((o) => this.bot.api.sendMessage(t.chatId, o.text, {
378
+ ...(o.parseMode ? { parse_mode: o.parseMode } : {}),
379
+ ...(t.messageThreadId !== undefined
380
+ ? { message_thread_id: t.messageThreadId }
381
+ : {}),
382
+ }), text);
383
+ return sent.message_id;
384
+ },
385
+ editAt: async (messageId, text) => {
386
+ // Wrap in the format fallback so a chunk that splits an HTML tag
387
+ // degrades to plain text instead of failing the edit (mirrors stream()).
388
+ await withTelegramFormatFallback((o) => this.bot.api.editMessageText(t.chatId, messageId, o.text, o.parseMode ? { parse_mode: o.parseMode } : {}), text);
389
+ },
390
+ setTyping: async () => {
391
+ await this.bot.api.sendChatAction(t.chatId, "typing", t.messageThreadId !== undefined
392
+ ? { message_thread_id: t.messageThreadId }
393
+ : {});
394
+ },
395
+ ...(this.opts.interruptEventNames
396
+ ? { interruptEventNames: this.opts.interruptEventNames }
397
+ : {}),
398
+ ...(this.opts.showToolStatus !== undefined
399
+ ? { showToolStatus: this.opts.showToolStatus }
400
+ : {}),
401
+ });
402
+ }
403
+ decodeInteraction(raw) {
404
+ return decodeInteraction(raw);
405
+ }
406
+ async lookupUser(q) {
407
+ const query = q.query.trim();
408
+ if (!query.startsWith("@"))
409
+ return undefined;
410
+ try {
411
+ const chat = (await this.bot.api.getChat(query));
412
+ return {
413
+ id: String(chat.id),
414
+ name: chat.title ?? chat.first_name,
415
+ handle: chat.username,
416
+ };
417
+ }
418
+ catch {
419
+ return undefined;
420
+ }
421
+ }
422
+ get conversationStore() {
423
+ return this.store;
424
+ }
425
+ async getMessages(target) {
426
+ const t = target;
427
+ return this.store.getMessages(this.conversationKeyForTarget(t));
428
+ }
429
+ async postFile(target, { bytes, filename, }) {
430
+ const t = target;
431
+ try {
432
+ const result = await this.bot.api.sendDocument(t.chatId, new InputFile(Buffer.from(bytes), filename), t.messageThreadId !== undefined
433
+ ? { message_thread_id: t.messageThreadId }
434
+ : {});
435
+ return { ok: true, fileId: result.document?.file_id };
436
+ }
437
+ catch (e) {
438
+ return { ok: false, error: String(e) };
439
+ }
440
+ }
441
+ async registerCommands(specs) {
442
+ // Telegram restricts command names to 1–32 chars of [a-z0-9_] (no hyphens,
443
+ // unlike Slack/Discord) and rejects the WHOLE setMyCommands call with
444
+ // 400 BOT_COMMAND_INVALID if any name violates that. Convert hyphens to
445
+ // underscores so e.g. `/file-issue` registers here as `/file_issue`; engine
446
+ // routing still matches because `normalizeCommandName` collapses "-"→"_",
447
+ // so an incoming `/file_issue` reaches the `file-issue` handler. Names still
448
+ // invalid after conversion (spaces, other punctuation, >32 chars) are
449
+ // skipped with a warning rather than failing the whole call.
450
+ const valid = [];
451
+ for (const s of specs) {
452
+ const tgName = s.name.toLowerCase().replace(/-/g, "_");
453
+ if (!/^[a-z0-9_]{1,32}$/.test(tgName)) {
454
+ console.warn(`[bot-telegram] skipping command "/${s.name}": cannot map to a Telegram-valid name (1–32 chars of [a-z0-9_])`);
455
+ continue;
456
+ }
457
+ valid.push({
458
+ command: tgName,
459
+ // Telegram allows 1–256 chars for the description; truncate defensively.
460
+ description: (s.description ?? s.name).slice(0, 256),
461
+ });
462
+ }
463
+ // An empty array would clear all commands — skip the call entirely instead.
464
+ if (valid.length === 0)
465
+ return;
466
+ await this.bot.api.setMyCommands(valid);
467
+ }
468
+ async setThreadTitle(target, title) {
469
+ const t = target;
470
+ if (t.messageThreadId === undefined) {
471
+ return { ok: false, error: "no forum topic" };
472
+ }
473
+ try {
474
+ await this.bot.api.editForumTopic(t.chatId, t.messageThreadId, {
475
+ name: title,
476
+ });
477
+ return { ok: true };
478
+ }
479
+ catch (e) {
480
+ return { ok: false, error: String(e) };
481
+ }
482
+ }
483
+ async addReaction(target, messageRef, emoji) {
484
+ const r = messageRef;
485
+ const chatId = r.chatId ?? target.chatId;
486
+ const messageId = Number(r.messageId ?? r.id);
487
+ const token = toPlatformEmoji(emoji, "telegram") ?? emoji;
488
+ try {
489
+ // grammy types ReactionTypeEmoji.emoji as a strict union of allowed emoji;
490
+ // cast via unknown since we accept any EmojiValue passthrough.
491
+ await this.bot.api.setMessageReaction(chatId, messageId, [
492
+ {
493
+ type: "emoji",
494
+ emoji: token,
495
+ },
496
+ ]);
497
+ return { ok: true };
498
+ }
499
+ catch (err) {
500
+ return { ok: false, error: err.message };
501
+ }
502
+ }
503
+ async removeReaction(target, messageRef, _emoji) {
504
+ const r = messageRef;
505
+ const chatId = r.chatId ?? target.chatId;
506
+ const messageId = Number(r.messageId ?? r.id);
507
+ try {
508
+ // Telegram clears the bot's reactions by setting an empty list.
509
+ await this.bot.api.setMessageReaction(chatId, messageId, []);
510
+ return { ok: true };
511
+ }
512
+ catch (err) {
513
+ return { ok: false, error: err.message };
514
+ }
515
+ }
516
+ async postEphemeral(_target, user, ir, opts) {
517
+ if (!opts.fallbackToDM)
518
+ return null; // no native ephemeral on Telegram
519
+ const userId = typeof user === "string" ? user : user.id;
520
+ const payload = renderTelegram(ir);
521
+ const replyMarkup = this.toReplyMarkup(payload.inlineKeyboard);
522
+ try {
523
+ const sent = await this.bot.api.sendMessage(String(userId), payload.text, {
524
+ ...(payload.parseMode ? { parse_mode: payload.parseMode } : {}),
525
+ ...(replyMarkup ? { reply_markup: replyMarkup } : {}),
526
+ });
527
+ return {
528
+ ok: true,
529
+ usedFallback: true,
530
+ ref: {
531
+ id: `${sent.chat.id}:${sent.message_id}`,
532
+ chatId: sent.chat.id,
533
+ messageId: sent.message_id,
534
+ },
535
+ };
536
+ }
537
+ catch (err) {
538
+ return { ok: false, error: err.message };
539
+ }
540
+ }
541
+ async setSuggestedPrompts(_target, _prompts, _opts) {
542
+ // Telegram has no pinned-prompt pane analogous to Slack's assistant pane.
543
+ return { ok: false, error: "unsupported" };
544
+ }
545
+ /**
546
+ * Return the store's conversation key for a {@link ReplyTarget}.
547
+ *
548
+ * Prefers the `conversationKey` field stamped at ingress (by the listener or
549
+ * `decodeInteraction`), which carries the exact key used for the live
550
+ * conversation — including the `user:<id>` scope that non-forum group chats
551
+ * require. Falls back to a best-effort derivation (covers DMs and forum
552
+ * topics, but cannot reproduce `user:` for plain group chats).
553
+ */
554
+ conversationKeyForTarget(t) {
555
+ if (t.conversationKey)
556
+ return t.conversationKey;
557
+ const key = {
558
+ chatId: String(t.chatId),
559
+ scope: t.messageThreadId !== undefined
560
+ ? `topic:${t.messageThreadId}`
561
+ : DM_SCOPE,
562
+ };
563
+ return conversationKeyOf(key);
564
+ }
565
+ /** Map the renderer's inline keyboard to grammY's `reply_markup` shape. */
566
+ toReplyMarkup(keyboard) {
567
+ if (!keyboard || keyboard.length === 0)
568
+ return undefined;
569
+ return {
570
+ inline_keyboard: keyboard.map((row) => row.map((b) => {
571
+ // URL buttons take precedence; otherwise a callback button. The
572
+ // renderer always supplies one or the other, but fall back to an
573
+ // empty callback so the discriminated union is satisfied.
574
+ if (b.url)
575
+ return { text: b.text, url: b.url };
576
+ return { text: b.text, callback_data: b.callbackData ?? "" };
577
+ })),
578
+ };
579
+ }
580
+ }
581
+ /** Construct a Telegram `PlatformAdapter`. */
582
+ export function telegram(opts) {
583
+ return new TelegramAdapter(opts);
584
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Telegram-platform-universal context entries — knowledge the LLM needs
3
+ * about Telegram itself (tagging procedure, Markdown formatting, conversation
4
+ * model). Apps spread `defaultTelegramContext` into the `context:` config
5
+ * they pass to `createBot`.
6
+ *
7
+ * Each entry is exported individually too so apps can cherry-pick.
8
+ */
9
+ import type { ContextEntry } from "@copilotkit/channels";
10
+ /** Telegram context entry — alias of `@copilotkit/channels`'s {@link ContextEntry}. */
11
+ export type TelegramContextEntry = ContextEntry;
12
+ export declare const telegramTaggingContext: TelegramContextEntry;
13
+ export declare const telegramFormattingContext: TelegramContextEntry;
14
+ export declare const telegramConversationModelContext: TelegramContextEntry;
15
+ /**
16
+ * The default context entries the SDK ships. Spread into your
17
+ * `createBot({context: …})`:
18
+ *
19
+ * context: [...defaultTelegramContext, ...myAppContext],
20
+ */
21
+ export declare const defaultTelegramContext: ReadonlyArray<TelegramContextEntry>;
22
+ //# sourceMappingURL=built-in-context.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"built-in-context.d.ts","sourceRoot":"","sources":["../src/built-in-context.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAC;AAEzD,uFAAuF;AACvF,MAAM,MAAM,oBAAoB,GAAG,YAAY,CAAC;AAEhD,eAAO,MAAM,sBAAsB,EAAE,oBAsBpC,CAAC;AAEF,eAAO,MAAM,yBAAyB,EAAE,oBAkBvC,CAAC;AAEF,eAAO,MAAM,gCAAgC,EAAE,oBAuB9C,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,sBAAsB,EAAE,aAAa,CAAC,oBAAoB,CAItE,CAAC"}
@@ -0,0 +1,77 @@
1
+ export const telegramTaggingContext = {
2
+ description: "How to @-mention people on Telegram — REQUIRED PROCEDURE",
3
+ value: [
4
+ "You are running on Telegram. Whenever the user asks you to tag,",
5
+ "ping, @-mention, or otherwise notify a specific person by name,",
6
+ "you MUST follow this procedure BEFORE composing your reply:",
7
+ "",
8
+ " 1. Call the `lookup_telegram_user` tool with the person's name",
9
+ " as the `query` argument.",
10
+ " 2. If the tool returns a username, write `@username` verbatim",
11
+ " wherever you would have written the person's name. This is",
12
+ " the ONLY way Telegram will link to their profile and surface",
13
+ " the mention to them.",
14
+ " 3. If the tool returns no username (the account has none or was",
15
+ " not found), write the person's plain display name without @",
16
+ " — never invent a handle.",
17
+ "",
18
+ 'Plain text like "Alice" or "@alice" written without going through',
19
+ "the lookup tool may not match the real handle and will not notify",
20
+ "anyone. Skipping the tool when a tag was asked for is a failure",
21
+ "of the task.",
22
+ ].join("\n"),
23
+ };
24
+ export const telegramFormattingContext = {
25
+ description: "Formatting Telegram replies",
26
+ value: [
27
+ "Write standard Markdown — the bridge translates it to Telegram HTML",
28
+ "for you before posting. Specifically:",
29
+ "",
30
+ "- **bold** renders as bold text; *italic* renders as italic.",
31
+ "- Use `inline code` and fenced code blocks (``` ... ```) normally.",
32
+ "- Links: write `[text](https://url)` — they convert to Telegram's",
33
+ " anchor tags.",
34
+ "- Bullet lists with `-` or `*` markers render as plain indented",
35
+ " text (Telegram has no native list elements), which still reads",
36
+ " cleanly.",
37
+ "",
38
+ "Do NOT pre-emptively write raw Telegram HTML (<b>, <i>, <code>,",
39
+ "<a href=...>) — the bridge handles that conversion and double-",
40
+ "encoding will break your output. Just write standard Markdown.",
41
+ ].join("\n"),
42
+ };
43
+ export const telegramConversationModelContext = {
44
+ description: "Telegram conversation model",
45
+ value: [
46
+ "Telegram has three distinct conversation surfaces the bot can",
47
+ "operate in, and each has a different threading model:",
48
+ "",
49
+ " DM (private chat): A single flat ongoing conversation between",
50
+ " the bot and one user. There are no threads; every message is",
51
+ " part of the same running dialogue.",
52
+ "",
53
+ " Forum supergroup (topics enabled): The group is divided into",
54
+ " named topics, each of which is its own independent conversation.",
55
+ " The bot participates in one topic at a time; context does not",
56
+ " bleed across topics.",
57
+ "",
58
+ " Normal group: The bot only participates when @-mentioned. Each",
59
+ " mention starts a reply chain, and the bot's context is scoped",
60
+ " to that chain — other messages in the group that don't mention",
61
+ " the bot are not visible to it.",
62
+ "",
63
+ "You don't need to handle routing yourself; the runtime delivers",
64
+ "messages in the right context. Just reply naturally.",
65
+ ].join("\n"),
66
+ };
67
+ /**
68
+ * The default context entries the SDK ships. Spread into your
69
+ * `createBot({context: …})`:
70
+ *
71
+ * context: [...defaultTelegramContext, ...myAppContext],
72
+ */
73
+ export const defaultTelegramContext = [
74
+ telegramTaggingContext,
75
+ telegramFormattingContext,
76
+ telegramConversationModelContext,
77
+ ];