@poncho-ai/messaging 0.2.9 → 0.4.0

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.
@@ -1,5 +1,5 @@
1
1
 
2
- > @poncho-ai/messaging@0.2.9 build /Users/cesar/Dev/latitude/poncho-ai/packages/messaging
2
+ > @poncho-ai/messaging@0.4.0 build /Users/cesar/Dev/latitude/poncho-ai/packages/messaging
3
3
  > tsup src/index.ts --format esm --dts
4
4
 
5
5
  CLI Building entry: src/index.ts
@@ -7,8 +7,8 @@
7
7
  CLI tsup v8.5.1
8
8
  CLI Target: es2022
9
9
  ESM Build start
10
- ESM dist/index.js 30.49 KB
11
- ESM ⚡️ Build success in 58ms
10
+ ESM dist/index.js 44.99 KB
11
+ ESM ⚡️ Build success in 69ms
12
12
  DTS Build start
13
- DTS ⚡️ Build success in 1611ms
14
- DTS dist/index.d.ts 8.98 KB
13
+ DTS ⚡️ Build success in 1830ms
14
+ DTS dist/index.d.ts 10.05 KB
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # @poncho-ai/messaging
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [`3216e80`](https://github.com/cesr/poncho-ai/commit/3216e8072027896dd1cc5f29b1a7b0eea9ee1ff5) Thanks [@cesr](https://github.com/cesr)! - Add `allowedUserIds` option to Telegram adapter for restricting bot access to specific users.
8
+
9
+ ## 0.3.0
10
+
11
+ ### Minor Changes
12
+
13
+ - [`de28ef5`](https://github.com/cesr/poncho-ai/commit/de28ef5acceed921269d15816acd392f5208f03d) Thanks [@cesr](https://github.com/cesr)! - Add Telegram messaging adapter with private/group chat support, file attachments, /new command, and typing indicators.
14
+
15
+ ### Patch Changes
16
+
17
+ - Updated dependencies [[`de28ef5`](https://github.com/cesr/poncho-ai/commit/de28ef5acceed921269d15816acd392f5208f03d)]:
18
+ - @poncho-ai/sdk@1.4.1
19
+
3
20
  ## 0.2.9
4
21
 
5
22
  ### Patch Changes
package/dist/index.d.ts CHANGED
@@ -189,6 +189,38 @@ declare class ResendAdapter implements MessagingAdapter {
189
189
  private fetchAndDownloadAttachments;
190
190
  }
191
191
 
192
+ interface TelegramAdapterOptions {
193
+ botTokenEnv?: string;
194
+ webhookSecretEnv?: string;
195
+ allowedUserIds?: number[];
196
+ }
197
+ declare class TelegramAdapter implements MessagingAdapter {
198
+ readonly platform: "telegram";
199
+ readonly autoReply = true;
200
+ readonly hasSentInCurrentRequest = false;
201
+ private botToken;
202
+ private webhookSecret;
203
+ private botUsername;
204
+ private botId;
205
+ private readonly botTokenEnv;
206
+ private readonly webhookSecretEnv;
207
+ private readonly allowedUserIds;
208
+ private handler;
209
+ private readonly sessionCounters;
210
+ private lastUpdateId;
211
+ constructor(options?: TelegramAdapterOptions);
212
+ initialize(): Promise<void>;
213
+ onMessage(handler: IncomingMessageHandler): void;
214
+ registerRoutes(router: RouteRegistrar): void;
215
+ sendReply(threadRef: ThreadRef, content: string, options?: {
216
+ files?: FileAttachment[];
217
+ }): Promise<void>;
218
+ indicateProcessing(threadRef: ThreadRef): Promise<() => Promise<void>>;
219
+ private handleRequest;
220
+ private sessionKey;
221
+ private extractFiles;
222
+ }
223
+
192
224
  /** Extract the bare email address from a formatted string like `"Name <addr>"`. */
193
225
  declare function extractEmailAddress(formatted: string): string;
194
226
  /** Extract the display name from `"Name <addr>"`, or return `undefined`. */
@@ -241,4 +273,4 @@ declare function markdownToEmailHtml(text: string): string;
241
273
  */
242
274
  declare function matchesSenderPattern(sender: string, patterns: string[] | undefined): boolean;
243
275
 
244
- export { AgentBridge, type AgentBridgeOptions, type AgentRunner, type FileAttachment, type IncomingMessage, type IncomingMessageHandler, type MessagingAdapter, ResendAdapter, type ResendAdapterOptions, type RouteHandler, type RouteRegistrar, SlackAdapter, type SlackAdapterOptions, type ThreadRef, buildReplyHeaders, buildReplySubject, deriveRootMessageId, extractDisplayName, extractEmailAddress, markdownToEmailHtml, matchesSenderPattern, parseReferences, stripQuotedReply };
276
+ export { AgentBridge, type AgentBridgeOptions, type AgentRunner, type FileAttachment, type IncomingMessage, type IncomingMessageHandler, type MessagingAdapter, ResendAdapter, type ResendAdapterOptions, type RouteHandler, type RouteRegistrar, SlackAdapter, type SlackAdapterOptions, TelegramAdapter, type TelegramAdapterOptions, type ThreadRef, buildReplyHeaders, buildReplySubject, deriveRootMessageId, extractDisplayName, extractEmailAddress, markdownToEmailHtml, matchesSenderPattern, parseReferences, stripQuotedReply };
package/dist/index.js CHANGED
@@ -41,9 +41,11 @@ var AgentBridge = class {
41
41
  message.platform,
42
42
  message.threadRef
43
43
  );
44
- const titleParts = [message.sender.id];
44
+ const platformTag = `[${message.platform.charAt(0).toUpperCase()}${message.platform.slice(1)}]`;
45
+ const senderLabel = message.sender.name || message.sender.id;
46
+ const titleParts = [platformTag, senderLabel];
45
47
  if (message.subject) titleParts.push(message.subject);
46
- const title = titleParts.join(" \u2014 ") || `${message.platform} thread`;
48
+ const title = titleParts.join(" ") || `${message.platform} thread`;
47
49
  const conversation = await this.runner.getOrCreateConversation(
48
50
  conversationId,
49
51
  {
@@ -918,10 +920,454 @@ var ResendAdapter = class {
918
920
  return results;
919
921
  }
920
922
  };
923
+
924
+ // src/adapters/telegram/verify.ts
925
+ import { timingSafeEqual as timingSafeEqual2 } from "crypto";
926
+ var verifyTelegramSecret = (expected, received) => {
927
+ if (!received) return false;
928
+ if (expected.length !== received.length) return false;
929
+ return timingSafeEqual2(Buffer.from(expected), Buffer.from(received));
930
+ };
931
+
932
+ // src/adapters/telegram/utils.ts
933
+ var TELEGRAM_API = "https://api.telegram.org";
934
+ var TELEGRAM_MAX_MESSAGE_LENGTH = 4096;
935
+ var telegramFetch = async (token, method, body) => {
936
+ const res = await fetch(`${TELEGRAM_API}/bot${token}/${method}`, {
937
+ method: "POST",
938
+ headers: { "content-type": "application/json" },
939
+ body: JSON.stringify(body)
940
+ });
941
+ return await res.json();
942
+ };
943
+ var telegramUpload = async (token, method, formData) => {
944
+ const res = await fetch(`${TELEGRAM_API}/bot${token}/${method}`, {
945
+ method: "POST",
946
+ body: formData
947
+ });
948
+ return await res.json();
949
+ };
950
+ var getMe = async (token) => {
951
+ const result = await telegramFetch(token, "getMe", {});
952
+ if (!result.ok) {
953
+ throw new Error(`Telegram getMe failed: ${result.description}`);
954
+ }
955
+ const user = result.result;
956
+ return { id: user.id, username: user.username ?? "" };
957
+ };
958
+ var getFile = async (token, fileId) => {
959
+ const result = await telegramFetch(token, "getFile", { file_id: fileId });
960
+ if (!result.ok) {
961
+ throw new Error(`Telegram getFile failed: ${result.description}`);
962
+ }
963
+ const file = result.result;
964
+ if (!file.file_path) {
965
+ throw new Error("Telegram getFile: no file_path returned");
966
+ }
967
+ return file.file_path;
968
+ };
969
+ var EXTENSION_MEDIA_TYPES = {
970
+ jpg: "image/jpeg",
971
+ jpeg: "image/jpeg",
972
+ png: "image/png",
973
+ gif: "image/gif",
974
+ webp: "image/webp",
975
+ pdf: "application/pdf",
976
+ mp4: "video/mp4",
977
+ ogg: "audio/ogg",
978
+ mp3: "audio/mpeg"
979
+ };
980
+ var inferMediaType = (filePath, header) => {
981
+ if (header && header !== "application/octet-stream") return header;
982
+ const ext = filePath.split(".").pop()?.toLowerCase();
983
+ if (ext && EXTENSION_MEDIA_TYPES[ext]) return EXTENSION_MEDIA_TYPES[ext];
984
+ return header ?? "application/octet-stream";
985
+ };
986
+ var downloadFile = async (token, filePath) => {
987
+ const url = `${TELEGRAM_API}/file/bot${token}/${filePath}`;
988
+ const res = await fetch(url);
989
+ if (!res.ok) {
990
+ throw new Error(`Telegram file download failed: ${res.status}`);
991
+ }
992
+ const buffer = Buffer.from(await res.arrayBuffer());
993
+ const mediaType = inferMediaType(filePath, res.headers.get("content-type"));
994
+ return { data: buffer.toString("base64"), mediaType };
995
+ };
996
+ var sendMessage = async (token, chatId, text, opts) => {
997
+ const body = { chat_id: chatId, text };
998
+ if (opts?.reply_to_message_id) {
999
+ body.reply_parameters = {
1000
+ message_id: opts.reply_to_message_id,
1001
+ allow_sending_without_reply: true
1002
+ };
1003
+ }
1004
+ if (opts?.message_thread_id) {
1005
+ body.message_thread_id = opts.message_thread_id;
1006
+ }
1007
+ const result = await telegramFetch(token, "sendMessage", body);
1008
+ if (!result.ok) {
1009
+ throw new Error(`Telegram sendMessage failed: ${result.description}`);
1010
+ }
1011
+ };
1012
+ var sendPhoto = async (token, chatId, photoData, opts) => {
1013
+ const formData = new FormData();
1014
+ formData.append("chat_id", String(chatId));
1015
+ const blob = new Blob([Buffer.from(photoData, "base64")]);
1016
+ formData.append("photo", blob, opts?.filename ?? "photo.jpg");
1017
+ if (opts?.caption) formData.append("caption", opts.caption);
1018
+ if (opts?.reply_to_message_id) {
1019
+ formData.append(
1020
+ "reply_parameters",
1021
+ JSON.stringify({
1022
+ message_id: opts.reply_to_message_id,
1023
+ allow_sending_without_reply: true
1024
+ })
1025
+ );
1026
+ }
1027
+ if (opts?.message_thread_id) {
1028
+ formData.append("message_thread_id", String(opts.message_thread_id));
1029
+ }
1030
+ const result = await telegramUpload(token, "sendPhoto", formData);
1031
+ if (!result.ok) {
1032
+ throw new Error(`Telegram sendPhoto failed: ${result.description}`);
1033
+ }
1034
+ };
1035
+ var sendDocument = async (token, chatId, docData, opts) => {
1036
+ const formData = new FormData();
1037
+ formData.append("chat_id", String(chatId));
1038
+ const blob = new Blob([Buffer.from(docData, "base64")], {
1039
+ type: opts?.mediaType
1040
+ });
1041
+ formData.append("document", blob, opts?.filename ?? "file");
1042
+ if (opts?.caption) formData.append("caption", opts.caption);
1043
+ if (opts?.reply_to_message_id) {
1044
+ formData.append(
1045
+ "reply_parameters",
1046
+ JSON.stringify({
1047
+ message_id: opts.reply_to_message_id,
1048
+ allow_sending_without_reply: true
1049
+ })
1050
+ );
1051
+ }
1052
+ if (opts?.message_thread_id) {
1053
+ formData.append("message_thread_id", String(opts.message_thread_id));
1054
+ }
1055
+ const result = await telegramUpload(token, "sendDocument", formData);
1056
+ if (!result.ok) {
1057
+ throw new Error(`Telegram sendDocument failed: ${result.description}`);
1058
+ }
1059
+ };
1060
+ var sendChatAction = async (token, chatId, action) => {
1061
+ await telegramFetch(token, "sendChatAction", {
1062
+ chat_id: chatId,
1063
+ action
1064
+ });
1065
+ };
1066
+ var splitMessage2 = (text) => {
1067
+ if (text.length <= TELEGRAM_MAX_MESSAGE_LENGTH) return [text];
1068
+ const chunks = [];
1069
+ let remaining = text;
1070
+ while (remaining.length > 0) {
1071
+ if (remaining.length <= TELEGRAM_MAX_MESSAGE_LENGTH) {
1072
+ chunks.push(remaining);
1073
+ break;
1074
+ }
1075
+ let cutPoint = remaining.lastIndexOf(
1076
+ "\n",
1077
+ TELEGRAM_MAX_MESSAGE_LENGTH
1078
+ );
1079
+ if (cutPoint <= 0) {
1080
+ cutPoint = TELEGRAM_MAX_MESSAGE_LENGTH;
1081
+ }
1082
+ chunks.push(remaining.slice(0, cutPoint));
1083
+ remaining = remaining.slice(cutPoint).replace(/^\n/, "");
1084
+ }
1085
+ return chunks;
1086
+ };
1087
+ var isBotMentioned = (entities, botUsername, botId, text) => {
1088
+ if (!entities || entities.length === 0) return false;
1089
+ const lower = botUsername.toLowerCase();
1090
+ for (const entity of entities) {
1091
+ if (entity.type === "mention") {
1092
+ const mentioned = text.slice(entity.offset, entity.offset + entity.length);
1093
+ if (mentioned.toLowerCase() === `@${lower}`) return true;
1094
+ }
1095
+ if (entity.type === "text_mention" && entity.user?.id === botId) {
1096
+ return true;
1097
+ }
1098
+ }
1099
+ return false;
1100
+ };
1101
+ var stripMention2 = (text, entities, botUsername, botId) => {
1102
+ if (!entities || entities.length === 0) return text.trim();
1103
+ const lower = botUsername.toLowerCase();
1104
+ for (const entity of entities) {
1105
+ let match = false;
1106
+ if (entity.type === "mention") {
1107
+ const mentioned = text.slice(entity.offset, entity.offset + entity.length);
1108
+ if (mentioned.toLowerCase() === `@${lower}`) match = true;
1109
+ }
1110
+ if (entity.type === "text_mention" && entity.user?.id === botId) {
1111
+ match = true;
1112
+ }
1113
+ if (match) {
1114
+ return (text.slice(0, entity.offset) + text.slice(entity.offset + entity.length)).trim();
1115
+ }
1116
+ }
1117
+ return text.trim();
1118
+ };
1119
+
1120
+ // src/adapters/telegram/index.ts
1121
+ var TYPING_INTERVAL_MS = 4e3;
1122
+ var NEW_COMMAND_RE = /^\/new(?:@(\S+))?$/i;
1123
+ var collectBody3 = (req) => new Promise((resolve, reject) => {
1124
+ const chunks = [];
1125
+ req.on("data", (chunk) => chunks.push(chunk));
1126
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
1127
+ req.on("error", reject);
1128
+ });
1129
+ var TelegramAdapter = class {
1130
+ platform = "telegram";
1131
+ autoReply = true;
1132
+ hasSentInCurrentRequest = false;
1133
+ botToken = "";
1134
+ webhookSecret = "";
1135
+ botUsername = "";
1136
+ botId = 0;
1137
+ botTokenEnv;
1138
+ webhookSecretEnv;
1139
+ allowedUserIds;
1140
+ handler;
1141
+ sessionCounters = /* @__PURE__ */ new Map();
1142
+ lastUpdateId = 0;
1143
+ constructor(options = {}) {
1144
+ this.botTokenEnv = options.botTokenEnv ?? "TELEGRAM_BOT_TOKEN";
1145
+ this.webhookSecretEnv = options.webhookSecretEnv ?? "TELEGRAM_WEBHOOK_SECRET";
1146
+ this.allowedUserIds = options.allowedUserIds && options.allowedUserIds.length > 0 ? options.allowedUserIds : void 0;
1147
+ }
1148
+ // -----------------------------------------------------------------------
1149
+ // MessagingAdapter implementation
1150
+ // -----------------------------------------------------------------------
1151
+ async initialize() {
1152
+ this.botToken = process.env[this.botTokenEnv] ?? "";
1153
+ this.webhookSecret = process.env[this.webhookSecretEnv] ?? "";
1154
+ if (!this.botToken) {
1155
+ throw new Error(
1156
+ `Telegram messaging: ${this.botTokenEnv} environment variable is not set`
1157
+ );
1158
+ }
1159
+ const me = await getMe(this.botToken);
1160
+ this.botUsername = me.username;
1161
+ this.botId = me.id;
1162
+ }
1163
+ onMessage(handler) {
1164
+ this.handler = handler;
1165
+ }
1166
+ registerRoutes(router) {
1167
+ router(
1168
+ "POST",
1169
+ "/api/messaging/telegram",
1170
+ (req, res) => this.handleRequest(req, res)
1171
+ );
1172
+ }
1173
+ async sendReply(threadRef, content, options) {
1174
+ const chatId = threadRef.channelId;
1175
+ const replyTo = threadRef.messageId ? Number(threadRef.messageId) : void 0;
1176
+ if (content) {
1177
+ const chunks = splitMessage2(content);
1178
+ for (const chunk of chunks) {
1179
+ await sendMessage(this.botToken, chatId, chunk, {
1180
+ reply_to_message_id: replyTo
1181
+ });
1182
+ }
1183
+ }
1184
+ if (options?.files) {
1185
+ for (const file of options.files) {
1186
+ if (file.mediaType.startsWith("image/")) {
1187
+ await sendPhoto(this.botToken, chatId, file.data, {
1188
+ reply_to_message_id: replyTo,
1189
+ filename: file.filename
1190
+ });
1191
+ } else {
1192
+ await sendDocument(this.botToken, chatId, file.data, {
1193
+ reply_to_message_id: replyTo,
1194
+ filename: file.filename,
1195
+ mediaType: file.mediaType
1196
+ });
1197
+ }
1198
+ }
1199
+ }
1200
+ }
1201
+ async indicateProcessing(threadRef) {
1202
+ const chatId = threadRef.channelId;
1203
+ await sendChatAction(this.botToken, chatId, "typing");
1204
+ const interval = setInterval(() => {
1205
+ void sendChatAction(this.botToken, chatId, "typing").catch(() => {
1206
+ });
1207
+ }, TYPING_INTERVAL_MS);
1208
+ return async () => {
1209
+ clearInterval(interval);
1210
+ };
1211
+ }
1212
+ // -----------------------------------------------------------------------
1213
+ // HTTP request handling
1214
+ // -----------------------------------------------------------------------
1215
+ async handleRequest(req, res) {
1216
+ const rawBody = await collectBody3(req);
1217
+ if (this.webhookSecret) {
1218
+ const headerSecret = req.headers["x-telegram-bot-api-secret-token"];
1219
+ if (!verifyTelegramSecret(this.webhookSecret, headerSecret)) {
1220
+ res.writeHead(401);
1221
+ res.end("Invalid secret");
1222
+ return;
1223
+ }
1224
+ }
1225
+ let payload;
1226
+ try {
1227
+ payload = JSON.parse(rawBody);
1228
+ } catch {
1229
+ res.writeHead(400);
1230
+ res.end("Invalid JSON");
1231
+ return;
1232
+ }
1233
+ if (payload.update_id <= this.lastUpdateId) {
1234
+ res.writeHead(200);
1235
+ res.end();
1236
+ return;
1237
+ }
1238
+ this.lastUpdateId = payload.update_id;
1239
+ const message = payload.message;
1240
+ if (!message) {
1241
+ res.writeHead(200);
1242
+ res.end();
1243
+ return;
1244
+ }
1245
+ const text = message.text ?? message.caption ?? "";
1246
+ const hasFiles = !!(message.photo || message.document);
1247
+ if (!text && !hasFiles) {
1248
+ res.writeHead(200);
1249
+ res.end();
1250
+ return;
1251
+ }
1252
+ if (this.allowedUserIds && message.from) {
1253
+ if (!this.allowedUserIds.includes(message.from.id)) {
1254
+ res.writeHead(200);
1255
+ res.end();
1256
+ return;
1257
+ }
1258
+ }
1259
+ const chatId = String(message.chat.id);
1260
+ const chatType = message.chat.type;
1261
+ const isGroup = chatType === "group" || chatType === "supergroup";
1262
+ const entities = message.entities ?? message.caption_entities;
1263
+ const newMatch = text.match(NEW_COMMAND_RE);
1264
+ if (newMatch) {
1265
+ const suffix = newMatch[1];
1266
+ if (isGroup && suffix && suffix.toLowerCase() !== this.botUsername.toLowerCase()) {
1267
+ res.writeHead(200);
1268
+ res.end();
1269
+ return;
1270
+ }
1271
+ const key2 = this.sessionKey(message);
1272
+ const current = this.sessionCounters.get(key2) ?? 0;
1273
+ this.sessionCounters.set(key2, current + 1);
1274
+ res.writeHead(200);
1275
+ res.end();
1276
+ await sendMessage(
1277
+ this.botToken,
1278
+ chatId,
1279
+ "Conversation reset. Send a new message to start fresh.",
1280
+ {
1281
+ reply_to_message_id: message.message_id,
1282
+ message_thread_id: message.message_thread_id
1283
+ }
1284
+ );
1285
+ return;
1286
+ }
1287
+ if (isGroup) {
1288
+ if (!isBotMentioned(entities, this.botUsername, this.botId, text)) {
1289
+ res.writeHead(200);
1290
+ res.end();
1291
+ return;
1292
+ }
1293
+ }
1294
+ const cleanText = isGroup ? stripMention2(text, entities, this.botUsername, this.botId) : text;
1295
+ if (!cleanText && !hasFiles) {
1296
+ res.writeHead(200);
1297
+ res.end();
1298
+ return;
1299
+ }
1300
+ res.writeHead(200);
1301
+ res.end();
1302
+ if (!this.handler) return;
1303
+ const files = await this.extractFiles(message);
1304
+ const key = this.sessionKey(message);
1305
+ const session = this.sessionCounters.get(key) ?? 0;
1306
+ const topicId = message.message_thread_id;
1307
+ const platformThreadId = topicId ? `${chatId}:${topicId}:${session}` : `${chatId}:${session}`;
1308
+ const userId = String(message.from?.id ?? "unknown");
1309
+ const userName = [message.from?.first_name, message.from?.last_name].filter(Boolean).join(" ") || void 0;
1310
+ const ponchoMessage = {
1311
+ text: cleanText,
1312
+ files: files.length > 0 ? files : void 0,
1313
+ threadRef: {
1314
+ platformThreadId,
1315
+ channelId: chatId,
1316
+ messageId: String(message.message_id)
1317
+ },
1318
+ sender: { id: userId, name: userName },
1319
+ platform: "telegram",
1320
+ raw: message
1321
+ };
1322
+ void this.handler(ponchoMessage).catch((err) => {
1323
+ console.error(
1324
+ "[telegram-adapter] unhandled message handler error",
1325
+ err
1326
+ );
1327
+ });
1328
+ }
1329
+ // -----------------------------------------------------------------------
1330
+ // Helpers
1331
+ // -----------------------------------------------------------------------
1332
+ sessionKey(message) {
1333
+ const chatId = String(message.chat.id);
1334
+ return message.message_thread_id ? `${chatId}:${message.message_thread_id}` : chatId;
1335
+ }
1336
+ async extractFiles(message) {
1337
+ const files = [];
1338
+ try {
1339
+ if (message.photo && message.photo.length > 0) {
1340
+ const largest = message.photo[message.photo.length - 1];
1341
+ const filePath = await getFile(this.botToken, largest.file_id);
1342
+ const { data } = await downloadFile(this.botToken, filePath);
1343
+ files.push({ data, mediaType: "image/jpeg", filename: "photo.jpg" });
1344
+ }
1345
+ if (message.document) {
1346
+ const filePath = await getFile(
1347
+ this.botToken,
1348
+ message.document.file_id
1349
+ );
1350
+ const downloaded = await downloadFile(this.botToken, filePath);
1351
+ files.push({
1352
+ data: downloaded.data,
1353
+ mediaType: message.document.mime_type ?? downloaded.mediaType,
1354
+ filename: message.document.file_name
1355
+ });
1356
+ }
1357
+ } catch (err) {
1358
+ console.warn(
1359
+ "[telegram-adapter] failed to download file:",
1360
+ err instanceof Error ? err.message : err
1361
+ );
1362
+ }
1363
+ return files;
1364
+ }
1365
+ };
921
1366
  export {
922
1367
  AgentBridge,
923
1368
  ResendAdapter,
924
1369
  SlackAdapter,
1370
+ TelegramAdapter,
925
1371
  buildReplyHeaders,
926
1372
  buildReplySubject,
927
1373
  deriveRootMessageId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poncho-ai/messaging",
3
- "version": "0.2.9",
3
+ "version": "0.4.0",
4
4
  "description": "Messaging platform adapters for Poncho agents (Slack, Telegram, etc.)",
5
5
  "repository": {
6
6
  "type": "git",
@@ -20,7 +20,7 @@
20
20
  }
21
21
  },
22
22
  "dependencies": {
23
- "@poncho-ai/sdk": "1.4.0"
23
+ "@poncho-ai/sdk": "1.4.1"
24
24
  },
25
25
  "peerDependencies": {
26
26
  "resend": ">=4.0.0"
@@ -40,6 +40,7 @@
40
40
  "agent",
41
41
  "messaging",
42
42
  "slack",
43
+ "telegram",
43
44
  "email",
44
45
  "resend"
45
46
  ],
@@ -0,0 +1,359 @@
1
+ import type http from "node:http";
2
+ import type {
3
+ FileAttachment,
4
+ IncomingMessage as PonchoIncomingMessage,
5
+ IncomingMessageHandler,
6
+ MessagingAdapter,
7
+ RouteRegistrar,
8
+ ThreadRef,
9
+ } from "../../types.js";
10
+ import { verifyTelegramSecret } from "./verify.js";
11
+ import {
12
+ type TelegramMessage,
13
+ type TelegramUpdate,
14
+ downloadFile,
15
+ getFile,
16
+ getMe,
17
+ isBotMentioned,
18
+ sendChatAction,
19
+ sendDocument,
20
+ sendMessage,
21
+ sendPhoto,
22
+ splitMessage,
23
+ stripMention,
24
+ } from "./utils.js";
25
+
26
+ const TYPING_INTERVAL_MS = 4_000;
27
+ const NEW_COMMAND_RE = /^\/new(?:@(\S+))?$/i;
28
+
29
+ export interface TelegramAdapterOptions {
30
+ botTokenEnv?: string;
31
+ webhookSecretEnv?: string;
32
+ allowedUserIds?: number[];
33
+ }
34
+
35
+ const collectBody = (req: http.IncomingMessage): Promise<string> =>
36
+ new Promise((resolve, reject) => {
37
+ const chunks: Buffer[] = [];
38
+ req.on("data", (chunk: Buffer) => chunks.push(chunk));
39
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
40
+ req.on("error", reject);
41
+ });
42
+
43
+ export class TelegramAdapter implements MessagingAdapter {
44
+ readonly platform = "telegram" as const;
45
+ readonly autoReply = true;
46
+ readonly hasSentInCurrentRequest = false;
47
+
48
+ private botToken = "";
49
+ private webhookSecret = "";
50
+ private botUsername = "";
51
+ private botId = 0;
52
+ private readonly botTokenEnv: string;
53
+ private readonly webhookSecretEnv: string;
54
+ private readonly allowedUserIds: number[] | undefined;
55
+ private handler: IncomingMessageHandler | undefined;
56
+ private readonly sessionCounters = new Map<string, number>();
57
+ private lastUpdateId = 0;
58
+
59
+ constructor(options: TelegramAdapterOptions = {}) {
60
+ this.botTokenEnv = options.botTokenEnv ?? "TELEGRAM_BOT_TOKEN";
61
+ this.webhookSecretEnv =
62
+ options.webhookSecretEnv ?? "TELEGRAM_WEBHOOK_SECRET";
63
+ this.allowedUserIds =
64
+ options.allowedUserIds && options.allowedUserIds.length > 0
65
+ ? options.allowedUserIds
66
+ : undefined;
67
+ }
68
+
69
+ // -----------------------------------------------------------------------
70
+ // MessagingAdapter implementation
71
+ // -----------------------------------------------------------------------
72
+
73
+ async initialize(): Promise<void> {
74
+ this.botToken = process.env[this.botTokenEnv] ?? "";
75
+ this.webhookSecret = process.env[this.webhookSecretEnv] ?? "";
76
+
77
+ if (!this.botToken) {
78
+ throw new Error(
79
+ `Telegram messaging: ${this.botTokenEnv} environment variable is not set`,
80
+ );
81
+ }
82
+
83
+ const me = await getMe(this.botToken);
84
+ this.botUsername = me.username;
85
+ this.botId = me.id;
86
+ }
87
+
88
+ onMessage(handler: IncomingMessageHandler): void {
89
+ this.handler = handler;
90
+ }
91
+
92
+ registerRoutes(router: RouteRegistrar): void {
93
+ router("POST", "/api/messaging/telegram", (req, res) =>
94
+ this.handleRequest(req, res),
95
+ );
96
+ }
97
+
98
+ async sendReply(
99
+ threadRef: ThreadRef,
100
+ content: string,
101
+ options?: { files?: FileAttachment[] },
102
+ ): Promise<void> {
103
+ const chatId = threadRef.channelId;
104
+ const replyTo = threadRef.messageId
105
+ ? Number(threadRef.messageId)
106
+ : undefined;
107
+
108
+ if (content) {
109
+ const chunks = splitMessage(content);
110
+ for (const chunk of chunks) {
111
+ await sendMessage(this.botToken, chatId, chunk, {
112
+ reply_to_message_id: replyTo,
113
+ });
114
+ }
115
+ }
116
+
117
+ if (options?.files) {
118
+ for (const file of options.files) {
119
+ if (file.mediaType.startsWith("image/")) {
120
+ await sendPhoto(this.botToken, chatId, file.data, {
121
+ reply_to_message_id: replyTo,
122
+ filename: file.filename,
123
+ });
124
+ } else {
125
+ await sendDocument(this.botToken, chatId, file.data, {
126
+ reply_to_message_id: replyTo,
127
+ filename: file.filename,
128
+ mediaType: file.mediaType,
129
+ });
130
+ }
131
+ }
132
+ }
133
+ }
134
+
135
+ async indicateProcessing(
136
+ threadRef: ThreadRef,
137
+ ): Promise<() => Promise<void>> {
138
+ const chatId = threadRef.channelId;
139
+
140
+ await sendChatAction(this.botToken, chatId, "typing");
141
+
142
+ const interval = setInterval(() => {
143
+ void sendChatAction(this.botToken, chatId, "typing").catch(() => {});
144
+ }, TYPING_INTERVAL_MS);
145
+
146
+ return async () => {
147
+ clearInterval(interval);
148
+ };
149
+ }
150
+
151
+ // -----------------------------------------------------------------------
152
+ // HTTP request handling
153
+ // -----------------------------------------------------------------------
154
+
155
+ private async handleRequest(
156
+ req: http.IncomingMessage,
157
+ res: http.ServerResponse,
158
+ ): Promise<void> {
159
+ const rawBody = await collectBody(req);
160
+
161
+ // -- Secret verification ----------------------------------------------
162
+ if (this.webhookSecret) {
163
+ const headerSecret = req.headers[
164
+ "x-telegram-bot-api-secret-token"
165
+ ] as string | undefined;
166
+ if (!verifyTelegramSecret(this.webhookSecret, headerSecret)) {
167
+ res.writeHead(401);
168
+ res.end("Invalid secret");
169
+ return;
170
+ }
171
+ }
172
+
173
+ let payload: TelegramUpdate;
174
+ try {
175
+ payload = JSON.parse(rawBody) as TelegramUpdate;
176
+ } catch {
177
+ res.writeHead(400);
178
+ res.end("Invalid JSON");
179
+ return;
180
+ }
181
+
182
+ // -- Update deduplication -----------------------------------------------
183
+ if (payload.update_id <= this.lastUpdateId) {
184
+ res.writeHead(200);
185
+ res.end();
186
+ return;
187
+ }
188
+ this.lastUpdateId = payload.update_id;
189
+
190
+ const message = payload.message;
191
+ if (!message) {
192
+ res.writeHead(200);
193
+ res.end();
194
+ return;
195
+ }
196
+
197
+ const text = message.text ?? message.caption ?? "";
198
+ const hasFiles = !!(message.photo || message.document);
199
+
200
+ if (!text && !hasFiles) {
201
+ res.writeHead(200);
202
+ res.end();
203
+ return;
204
+ }
205
+
206
+ // -- User allowlist -----------------------------------------------------
207
+ if (this.allowedUserIds && message.from) {
208
+ if (!this.allowedUserIds.includes(message.from.id)) {
209
+ res.writeHead(200);
210
+ res.end();
211
+ return;
212
+ }
213
+ }
214
+
215
+ const chatId = String(message.chat.id);
216
+ const chatType = message.chat.type;
217
+ const isGroup = chatType === "group" || chatType === "supergroup";
218
+ const entities = message.entities ?? message.caption_entities;
219
+
220
+ // -- /new command -----------------------------------------------------
221
+ const newMatch = text.match(NEW_COMMAND_RE);
222
+ if (newMatch) {
223
+ const suffix = newMatch[1];
224
+ if (
225
+ isGroup &&
226
+ suffix &&
227
+ suffix.toLowerCase() !== this.botUsername.toLowerCase()
228
+ ) {
229
+ res.writeHead(200);
230
+ res.end();
231
+ return;
232
+ }
233
+
234
+ const key = this.sessionKey(message);
235
+ const current = this.sessionCounters.get(key) ?? 0;
236
+ this.sessionCounters.set(key, current + 1);
237
+
238
+ res.writeHead(200);
239
+ res.end();
240
+
241
+ await sendMessage(
242
+ this.botToken,
243
+ chatId,
244
+ "Conversation reset. Send a new message to start fresh.",
245
+ {
246
+ reply_to_message_id: message.message_id,
247
+ message_thread_id: message.message_thread_id,
248
+ },
249
+ );
250
+ return;
251
+ }
252
+
253
+ // -- Group mention filter ---------------------------------------------
254
+ if (isGroup) {
255
+ if (!isBotMentioned(entities, this.botUsername, this.botId, text)) {
256
+ res.writeHead(200);
257
+ res.end();
258
+ return;
259
+ }
260
+ }
261
+
262
+ const cleanText = isGroup
263
+ ? stripMention(text, entities, this.botUsername, this.botId)
264
+ : text;
265
+
266
+ if (!cleanText && !hasFiles) {
267
+ res.writeHead(200);
268
+ res.end();
269
+ return;
270
+ }
271
+
272
+ // Acknowledge immediately so Telegram doesn't retry.
273
+ res.writeHead(200);
274
+ res.end();
275
+
276
+ if (!this.handler) return;
277
+
278
+ // -- File extraction --------------------------------------------------
279
+ const files = await this.extractFiles(message);
280
+
281
+ // -- Build thread ref -------------------------------------------------
282
+ const key = this.sessionKey(message);
283
+ const session = this.sessionCounters.get(key) ?? 0;
284
+ const topicId = message.message_thread_id;
285
+ const platformThreadId = topicId
286
+ ? `${chatId}:${topicId}:${session}`
287
+ : `${chatId}:${session}`;
288
+
289
+ const userId = String(message.from?.id ?? "unknown");
290
+ const userName =
291
+ [message.from?.first_name, message.from?.last_name]
292
+ .filter(Boolean)
293
+ .join(" ") || undefined;
294
+
295
+ const ponchoMessage: PonchoIncomingMessage = {
296
+ text: cleanText,
297
+ files: files.length > 0 ? files : undefined,
298
+ threadRef: {
299
+ platformThreadId,
300
+ channelId: chatId,
301
+ messageId: String(message.message_id),
302
+ },
303
+ sender: { id: userId, name: userName },
304
+ platform: "telegram",
305
+ raw: message,
306
+ };
307
+
308
+ void this.handler(ponchoMessage).catch((err) => {
309
+ console.error(
310
+ "[telegram-adapter] unhandled message handler error",
311
+ err,
312
+ );
313
+ });
314
+ }
315
+
316
+ // -----------------------------------------------------------------------
317
+ // Helpers
318
+ // -----------------------------------------------------------------------
319
+
320
+ private sessionKey(message: TelegramMessage): string {
321
+ const chatId = String(message.chat.id);
322
+ return message.message_thread_id
323
+ ? `${chatId}:${message.message_thread_id}`
324
+ : chatId;
325
+ }
326
+
327
+ private async extractFiles(
328
+ message: TelegramMessage,
329
+ ): Promise<FileAttachment[]> {
330
+ const files: FileAttachment[] = [];
331
+ try {
332
+ if (message.photo && message.photo.length > 0) {
333
+ const largest = message.photo[message.photo.length - 1]!;
334
+ const filePath = await getFile(this.botToken, largest.file_id);
335
+ const { data } = await downloadFile(this.botToken, filePath);
336
+ files.push({ data, mediaType: "image/jpeg", filename: "photo.jpg" });
337
+ }
338
+
339
+ if (message.document) {
340
+ const filePath = await getFile(
341
+ this.botToken,
342
+ message.document.file_id,
343
+ );
344
+ const downloaded = await downloadFile(this.botToken, filePath);
345
+ files.push({
346
+ data: downloaded.data,
347
+ mediaType: message.document.mime_type ?? downloaded.mediaType,
348
+ filename: message.document.file_name,
349
+ });
350
+ }
351
+ } catch (err) {
352
+ console.warn(
353
+ "[telegram-adapter] failed to download file:",
354
+ err instanceof Error ? err.message : err,
355
+ );
356
+ }
357
+ return files;
358
+ }
359
+ }
@@ -0,0 +1,369 @@
1
+ const TELEGRAM_API = "https://api.telegram.org";
2
+ const TELEGRAM_MAX_MESSAGE_LENGTH = 4096;
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Telegram Bot API object types (subset used by the adapter)
6
+ // ---------------------------------------------------------------------------
7
+
8
+ export interface TelegramEntity {
9
+ type: string;
10
+ offset: number;
11
+ length: number;
12
+ user?: { id: number; username?: string };
13
+ }
14
+
15
+ export interface TelegramUser {
16
+ id: number;
17
+ is_bot: boolean;
18
+ first_name: string;
19
+ last_name?: string;
20
+ username?: string;
21
+ }
22
+
23
+ export interface TelegramChat {
24
+ id: number;
25
+ type: "private" | "group" | "supergroup" | "channel";
26
+ }
27
+
28
+ export interface TelegramPhotoSize {
29
+ file_id: string;
30
+ file_unique_id: string;
31
+ width: number;
32
+ height: number;
33
+ file_size?: number;
34
+ }
35
+
36
+ export interface TelegramDocument {
37
+ file_id: string;
38
+ file_unique_id: string;
39
+ file_name?: string;
40
+ mime_type?: string;
41
+ file_size?: number;
42
+ }
43
+
44
+ export interface TelegramMessage {
45
+ message_id: number;
46
+ from?: TelegramUser;
47
+ chat: TelegramChat;
48
+ date: number;
49
+ text?: string;
50
+ caption?: string;
51
+ entities?: TelegramEntity[];
52
+ caption_entities?: TelegramEntity[];
53
+ photo?: TelegramPhotoSize[];
54
+ document?: TelegramDocument;
55
+ message_thread_id?: number;
56
+ }
57
+
58
+ export interface TelegramUpdate {
59
+ update_id: number;
60
+ message?: TelegramMessage;
61
+ }
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Low-level fetch helpers
65
+ // ---------------------------------------------------------------------------
66
+
67
+ interface TelegramApiResult {
68
+ ok: boolean;
69
+ result?: unknown;
70
+ description?: string;
71
+ }
72
+
73
+ const telegramFetch = async (
74
+ token: string,
75
+ method: string,
76
+ body: Record<string, unknown>,
77
+ ): Promise<TelegramApiResult> => {
78
+ const res = await fetch(`${TELEGRAM_API}/bot${token}/${method}`, {
79
+ method: "POST",
80
+ headers: { "content-type": "application/json" },
81
+ body: JSON.stringify(body),
82
+ });
83
+ return (await res.json()) as TelegramApiResult;
84
+ };
85
+
86
+ const telegramUpload = async (
87
+ token: string,
88
+ method: string,
89
+ formData: FormData,
90
+ ): Promise<TelegramApiResult> => {
91
+ const res = await fetch(`${TELEGRAM_API}/bot${token}/${method}`, {
92
+ method: "POST",
93
+ body: formData,
94
+ });
95
+ return (await res.json()) as TelegramApiResult;
96
+ };
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Bot info
100
+ // ---------------------------------------------------------------------------
101
+
102
+ export const getMe = async (
103
+ token: string,
104
+ ): Promise<{ id: number; username: string }> => {
105
+ const result = await telegramFetch(token, "getMe", {});
106
+ if (!result.ok) {
107
+ throw new Error(`Telegram getMe failed: ${result.description}`);
108
+ }
109
+ const user = result.result as TelegramUser;
110
+ return { id: user.id, username: user.username ?? "" };
111
+ };
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // File download
115
+ // ---------------------------------------------------------------------------
116
+
117
+ export const getFile = async (
118
+ token: string,
119
+ fileId: string,
120
+ ): Promise<string> => {
121
+ const result = await telegramFetch(token, "getFile", { file_id: fileId });
122
+ if (!result.ok) {
123
+ throw new Error(`Telegram getFile failed: ${result.description}`);
124
+ }
125
+ const file = result.result as { file_id: string; file_path?: string };
126
+ if (!file.file_path) {
127
+ throw new Error("Telegram getFile: no file_path returned");
128
+ }
129
+ return file.file_path;
130
+ };
131
+
132
+ const EXTENSION_MEDIA_TYPES: Record<string, string> = {
133
+ jpg: "image/jpeg",
134
+ jpeg: "image/jpeg",
135
+ png: "image/png",
136
+ gif: "image/gif",
137
+ webp: "image/webp",
138
+ pdf: "application/pdf",
139
+ mp4: "video/mp4",
140
+ ogg: "audio/ogg",
141
+ mp3: "audio/mpeg",
142
+ };
143
+
144
+ const inferMediaType = (filePath: string, header: string | null): string => {
145
+ if (header && header !== "application/octet-stream") return header;
146
+ const ext = filePath.split(".").pop()?.toLowerCase();
147
+ if (ext && EXTENSION_MEDIA_TYPES[ext]) return EXTENSION_MEDIA_TYPES[ext];
148
+ return header ?? "application/octet-stream";
149
+ };
150
+
151
+ export const downloadFile = async (
152
+ token: string,
153
+ filePath: string,
154
+ ): Promise<{ data: string; mediaType: string }> => {
155
+ const url = `${TELEGRAM_API}/file/bot${token}/${filePath}`;
156
+ const res = await fetch(url);
157
+ if (!res.ok) {
158
+ throw new Error(`Telegram file download failed: ${res.status}`);
159
+ }
160
+ const buffer = Buffer.from(await res.arrayBuffer());
161
+ const mediaType = inferMediaType(filePath, res.headers.get("content-type"));
162
+ return { data: buffer.toString("base64"), mediaType };
163
+ };
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // Sending messages
167
+ // ---------------------------------------------------------------------------
168
+
169
+ export const sendMessage = async (
170
+ token: string,
171
+ chatId: number | string,
172
+ text: string,
173
+ opts?: { reply_to_message_id?: number; message_thread_id?: number },
174
+ ): Promise<void> => {
175
+ const body: Record<string, unknown> = { chat_id: chatId, text };
176
+ if (opts?.reply_to_message_id) {
177
+ body.reply_parameters = {
178
+ message_id: opts.reply_to_message_id,
179
+ allow_sending_without_reply: true,
180
+ };
181
+ }
182
+ if (opts?.message_thread_id) {
183
+ body.message_thread_id = opts.message_thread_id;
184
+ }
185
+ const result = await telegramFetch(token, "sendMessage", body);
186
+ if (!result.ok) {
187
+ throw new Error(`Telegram sendMessage failed: ${result.description}`);
188
+ }
189
+ };
190
+
191
+ export const sendPhoto = async (
192
+ token: string,
193
+ chatId: number | string,
194
+ photoData: string,
195
+ opts?: {
196
+ caption?: string;
197
+ reply_to_message_id?: number;
198
+ message_thread_id?: number;
199
+ filename?: string;
200
+ },
201
+ ): Promise<void> => {
202
+ const formData = new FormData();
203
+ formData.append("chat_id", String(chatId));
204
+ const blob = new Blob([Buffer.from(photoData, "base64")]);
205
+ formData.append("photo", blob, opts?.filename ?? "photo.jpg");
206
+ if (opts?.caption) formData.append("caption", opts.caption);
207
+ if (opts?.reply_to_message_id) {
208
+ formData.append(
209
+ "reply_parameters",
210
+ JSON.stringify({
211
+ message_id: opts.reply_to_message_id,
212
+ allow_sending_without_reply: true,
213
+ }),
214
+ );
215
+ }
216
+ if (opts?.message_thread_id) {
217
+ formData.append("message_thread_id", String(opts.message_thread_id));
218
+ }
219
+ const result = await telegramUpload(token, "sendPhoto", formData);
220
+ if (!result.ok) {
221
+ throw new Error(`Telegram sendPhoto failed: ${result.description}`);
222
+ }
223
+ };
224
+
225
+ export const sendDocument = async (
226
+ token: string,
227
+ chatId: number | string,
228
+ docData: string,
229
+ opts?: {
230
+ caption?: string;
231
+ reply_to_message_id?: number;
232
+ message_thread_id?: number;
233
+ filename?: string;
234
+ mediaType?: string;
235
+ },
236
+ ): Promise<void> => {
237
+ const formData = new FormData();
238
+ formData.append("chat_id", String(chatId));
239
+ const blob = new Blob([Buffer.from(docData, "base64")], {
240
+ type: opts?.mediaType,
241
+ });
242
+ formData.append("document", blob, opts?.filename ?? "file");
243
+ if (opts?.caption) formData.append("caption", opts.caption);
244
+ if (opts?.reply_to_message_id) {
245
+ formData.append(
246
+ "reply_parameters",
247
+ JSON.stringify({
248
+ message_id: opts.reply_to_message_id,
249
+ allow_sending_without_reply: true,
250
+ }),
251
+ );
252
+ }
253
+ if (opts?.message_thread_id) {
254
+ formData.append("message_thread_id", String(opts.message_thread_id));
255
+ }
256
+ const result = await telegramUpload(token, "sendDocument", formData);
257
+ if (!result.ok) {
258
+ throw new Error(`Telegram sendDocument failed: ${result.description}`);
259
+ }
260
+ };
261
+
262
+ // ---------------------------------------------------------------------------
263
+ // Typing indicator
264
+ // ---------------------------------------------------------------------------
265
+
266
+ export const sendChatAction = async (
267
+ token: string,
268
+ chatId: number | string,
269
+ action: string,
270
+ ): Promise<void> => {
271
+ await telegramFetch(token, "sendChatAction", {
272
+ chat_id: chatId,
273
+ action,
274
+ });
275
+ };
276
+
277
+ // ---------------------------------------------------------------------------
278
+ // Message splitting (same pattern as Slack, adapted for 4096 limit)
279
+ // ---------------------------------------------------------------------------
280
+
281
+ export const splitMessage = (text: string): string[] => {
282
+ if (text.length <= TELEGRAM_MAX_MESSAGE_LENGTH) return [text];
283
+
284
+ const chunks: string[] = [];
285
+ let remaining = text;
286
+
287
+ while (remaining.length > 0) {
288
+ if (remaining.length <= TELEGRAM_MAX_MESSAGE_LENGTH) {
289
+ chunks.push(remaining);
290
+ break;
291
+ }
292
+
293
+ let cutPoint = remaining.lastIndexOf(
294
+ "\n",
295
+ TELEGRAM_MAX_MESSAGE_LENGTH,
296
+ );
297
+ if (cutPoint <= 0) {
298
+ cutPoint = TELEGRAM_MAX_MESSAGE_LENGTH;
299
+ }
300
+
301
+ chunks.push(remaining.slice(0, cutPoint));
302
+ remaining = remaining.slice(cutPoint).replace(/^\n/, "");
303
+ }
304
+
305
+ return chunks;
306
+ };
307
+
308
+ // ---------------------------------------------------------------------------
309
+ // Mention detection & stripping
310
+ // ---------------------------------------------------------------------------
311
+
312
+ /**
313
+ * Check whether the bot is mentioned in a message's entities.
314
+ * Works with both `@username` mentions and `text_mention` entities.
315
+ */
316
+ export const isBotMentioned = (
317
+ entities: TelegramEntity[] | undefined,
318
+ botUsername: string,
319
+ botId: number,
320
+ text: string,
321
+ ): boolean => {
322
+ if (!entities || entities.length === 0) return false;
323
+ const lower = botUsername.toLowerCase();
324
+
325
+ for (const entity of entities) {
326
+ if (entity.type === "mention") {
327
+ const mentioned = text.slice(entity.offset, entity.offset + entity.length);
328
+ if (mentioned.toLowerCase() === `@${lower}`) return true;
329
+ }
330
+ if (entity.type === "text_mention" && entity.user?.id === botId) {
331
+ return true;
332
+ }
333
+ }
334
+
335
+ return false;
336
+ };
337
+
338
+ /**
339
+ * Remove the first bot mention from the message text, using entity
340
+ * offsets for accuracy. Falls back to regex if no entity matches.
341
+ */
342
+ export const stripMention = (
343
+ text: string,
344
+ entities: TelegramEntity[] | undefined,
345
+ botUsername: string,
346
+ botId: number,
347
+ ): string => {
348
+ if (!entities || entities.length === 0) return text.trim();
349
+ const lower = botUsername.toLowerCase();
350
+
351
+ for (const entity of entities) {
352
+ let match = false;
353
+ if (entity.type === "mention") {
354
+ const mentioned = text.slice(entity.offset, entity.offset + entity.length);
355
+ if (mentioned.toLowerCase() === `@${lower}`) match = true;
356
+ }
357
+ if (entity.type === "text_mention" && entity.user?.id === botId) {
358
+ match = true;
359
+ }
360
+ if (match) {
361
+ return (
362
+ text.slice(0, entity.offset) +
363
+ text.slice(entity.offset + entity.length)
364
+ ).trim();
365
+ }
366
+ }
367
+
368
+ return text.trim();
369
+ };
@@ -0,0 +1,18 @@
1
+ import { timingSafeEqual } from "node:crypto";
2
+
3
+ /**
4
+ * Verify the Telegram webhook secret token.
5
+ *
6
+ * When registering the webhook via `setWebhook`, a `secret_token` can be
7
+ * provided. Telegram then sends it in the `X-Telegram-Bot-Api-Secret-Token`
8
+ * header on every update. This function compares the expected and received
9
+ * values using timing-safe equality.
10
+ */
11
+ export const verifyTelegramSecret = (
12
+ expected: string,
13
+ received: string | undefined,
14
+ ): boolean => {
15
+ if (!received) return false;
16
+ if (expected.length !== received.length) return false;
17
+ return timingSafeEqual(Buffer.from(expected), Buffer.from(received));
18
+ };
package/src/bridge.ts CHANGED
@@ -65,9 +65,11 @@ export class AgentBridge {
65
65
  message.threadRef,
66
66
  );
67
67
 
68
- const titleParts = [message.sender.id];
68
+ const platformTag = `[${message.platform.charAt(0).toUpperCase()}${message.platform.slice(1)}]`;
69
+ const senderLabel = message.sender.name || message.sender.id;
70
+ const titleParts = [platformTag, senderLabel];
69
71
  if (message.subject) titleParts.push(message.subject);
70
- const title = titleParts.join(" ") || `${message.platform} thread`;
72
+ const title = titleParts.join(" ") || `${message.platform} thread`;
71
73
 
72
74
  const conversation = await this.runner.getOrCreateConversation(
73
75
  conversationId,
package/src/index.ts CHANGED
@@ -15,6 +15,8 @@ export { SlackAdapter } from "./adapters/slack/index.js";
15
15
  export type { SlackAdapterOptions } from "./adapters/slack/index.js";
16
16
  export { ResendAdapter } from "./adapters/resend/index.js";
17
17
  export type { ResendAdapterOptions } from "./adapters/resend/index.js";
18
+ export { TelegramAdapter } from "./adapters/telegram/index.js";
19
+ export type { TelegramAdapterOptions } from "./adapters/telegram/index.js";
18
20
 
19
21
  export {
20
22
  buildReplyHeaders,