@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.
- package/LICENSE +21 -0
- package/README.md +250 -0
- package/dist/adapter.d.ts +117 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +584 -0
- package/dist/built-in-context.d.ts +22 -0
- package/dist/built-in-context.d.ts.map +1 -0
- package/dist/built-in-context.js +77 -0
- package/dist/built-in-tools.d.ts +23 -0
- package/dist/built-in-tools.d.ts.map +1 -0
- package/dist/built-in-tools.js +46 -0
- package/dist/chunked-edit-stream.d.ts +70 -0
- package/dist/chunked-edit-stream.d.ts.map +1 -0
- package/dist/chunked-edit-stream.js +197 -0
- package/dist/conversation-store.d.ts +56 -0
- package/dist/conversation-store.d.ts.map +1 -0
- package/dist/conversation-store.js +98 -0
- package/dist/download-files.d.ts +68 -0
- package/dist/download-files.d.ts.map +1 -0
- package/dist/download-files.js +157 -0
- package/dist/event-renderer.d.ts +39 -0
- package/dist/event-renderer.d.ts.map +1 -0
- package/dist/event-renderer.js +295 -0
- package/dist/format-fallback.d.ts +6 -0
- package/dist/format-fallback.d.ts.map +1 -0
- package/dist/format-fallback.js +21 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/interaction.d.ts +65 -0
- package/dist/interaction.d.ts.map +1 -0
- package/dist/interaction.js +159 -0
- package/dist/listener.d.ts +16 -0
- package/dist/listener.d.ts.map +1 -0
- package/dist/listener.js +419 -0
- package/dist/render/budget.d.ts +18 -0
- package/dist/render/budget.d.ts.map +1 -0
- package/dist/render/budget.js +24 -0
- package/dist/render/telegram.d.ts +11 -0
- package/dist/render/telegram.d.ts.map +1 -0
- package/dist/render/telegram.js +429 -0
- package/dist/telegram-html.d.ts +22 -0
- package/dist/telegram-html.d.ts.map +1 -0
- package/dist/telegram-html.js +98 -0
- package/dist/types.d.ts +83 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/package.json +57 -0
package/dist/adapter.js
ADDED
|
@@ -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
|
+
];
|