@poncho-ai/messaging 0.2.9 → 0.3.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.
- package/.turbo/turbo-build.log +5 -5
- package/CHANGELOG.md +11 -0
- package/dist/index.d.ts +31 -1
- package/dist/index.js +439 -2
- package/package.json +3 -2
- package/src/adapters/telegram/index.ts +344 -0
- package/src/adapters/telegram/utils.ts +369 -0
- package/src/adapters/telegram/verify.ts +18 -0
- package/src/bridge.ts +4 -2
- package/src/index.ts +2 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
> @poncho-ai/messaging@0.
|
|
2
|
+
> @poncho-ai/messaging@0.3.0 build /Users/cesar/Dev/latitude/poncho-ai/packages/messaging
|
|
3
3
|
> tsup src/index.ts --format esm --dts
|
|
4
4
|
|
|
5
5
|
[34mCLI[39m Building entry: src/index.ts
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
[34mCLI[39m tsup v8.5.1
|
|
8
8
|
[34mCLI[39m Target: es2022
|
|
9
9
|
[34mESM[39m Build start
|
|
10
|
-
[32mESM[39m [1mdist/index.js [22m[
|
|
11
|
-
[32mESM[39m ⚡️ Build success in
|
|
10
|
+
[32mESM[39m [1mdist/index.js [22m[32m44.67 KB[39m
|
|
11
|
+
[32mESM[39m ⚡️ Build success in 55ms
|
|
12
12
|
[34mDTS[39m Build start
|
|
13
|
-
[32mDTS[39m ⚡️ Build success in
|
|
14
|
-
[32mDTS[39m [1mdist/index.d.ts [22m[
|
|
13
|
+
[32mDTS[39m ⚡️ Build success in 3450ms
|
|
14
|
+
[32mDTS[39m [1mdist/index.d.ts [22m[32m9.98 KB[39m
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# @poncho-ai/messaging
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [`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.
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- Updated dependencies [[`de28ef5`](https://github.com/cesr/poncho-ai/commit/de28ef5acceed921269d15816acd392f5208f03d)]:
|
|
12
|
+
- @poncho-ai/sdk@1.4.1
|
|
13
|
+
|
|
3
14
|
## 0.2.9
|
|
4
15
|
|
|
5
16
|
### Patch Changes
|
package/dist/index.d.ts
CHANGED
|
@@ -189,6 +189,36 @@ declare class ResendAdapter implements MessagingAdapter {
|
|
|
189
189
|
private fetchAndDownloadAttachments;
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
+
interface TelegramAdapterOptions {
|
|
193
|
+
botTokenEnv?: string;
|
|
194
|
+
webhookSecretEnv?: string;
|
|
195
|
+
}
|
|
196
|
+
declare class TelegramAdapter implements MessagingAdapter {
|
|
197
|
+
readonly platform: "telegram";
|
|
198
|
+
readonly autoReply = true;
|
|
199
|
+
readonly hasSentInCurrentRequest = false;
|
|
200
|
+
private botToken;
|
|
201
|
+
private webhookSecret;
|
|
202
|
+
private botUsername;
|
|
203
|
+
private botId;
|
|
204
|
+
private readonly botTokenEnv;
|
|
205
|
+
private readonly webhookSecretEnv;
|
|
206
|
+
private handler;
|
|
207
|
+
private readonly sessionCounters;
|
|
208
|
+
private lastUpdateId;
|
|
209
|
+
constructor(options?: TelegramAdapterOptions);
|
|
210
|
+
initialize(): Promise<void>;
|
|
211
|
+
onMessage(handler: IncomingMessageHandler): void;
|
|
212
|
+
registerRoutes(router: RouteRegistrar): void;
|
|
213
|
+
sendReply(threadRef: ThreadRef, content: string, options?: {
|
|
214
|
+
files?: FileAttachment[];
|
|
215
|
+
}): Promise<void>;
|
|
216
|
+
indicateProcessing(threadRef: ThreadRef): Promise<() => Promise<void>>;
|
|
217
|
+
private handleRequest;
|
|
218
|
+
private sessionKey;
|
|
219
|
+
private extractFiles;
|
|
220
|
+
}
|
|
221
|
+
|
|
192
222
|
/** Extract the bare email address from a formatted string like `"Name <addr>"`. */
|
|
193
223
|
declare function extractEmailAddress(formatted: string): string;
|
|
194
224
|
/** Extract the display name from `"Name <addr>"`, or return `undefined`. */
|
|
@@ -241,4 +271,4 @@ declare function markdownToEmailHtml(text: string): string;
|
|
|
241
271
|
*/
|
|
242
272
|
declare function matchesSenderPattern(sender: string, patterns: string[] | undefined): boolean;
|
|
243
273
|
|
|
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 };
|
|
274
|
+
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
|
|
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("
|
|
48
|
+
const title = titleParts.join(" ") || `${message.platform} thread`;
|
|
47
49
|
const conversation = await this.runner.getOrCreateConversation(
|
|
48
50
|
conversationId,
|
|
49
51
|
{
|
|
@@ -918,10 +920,445 @@ 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
|
+
handler;
|
|
1140
|
+
sessionCounters = /* @__PURE__ */ new Map();
|
|
1141
|
+
lastUpdateId = 0;
|
|
1142
|
+
constructor(options = {}) {
|
|
1143
|
+
this.botTokenEnv = options.botTokenEnv ?? "TELEGRAM_BOT_TOKEN";
|
|
1144
|
+
this.webhookSecretEnv = options.webhookSecretEnv ?? "TELEGRAM_WEBHOOK_SECRET";
|
|
1145
|
+
}
|
|
1146
|
+
// -----------------------------------------------------------------------
|
|
1147
|
+
// MessagingAdapter implementation
|
|
1148
|
+
// -----------------------------------------------------------------------
|
|
1149
|
+
async initialize() {
|
|
1150
|
+
this.botToken = process.env[this.botTokenEnv] ?? "";
|
|
1151
|
+
this.webhookSecret = process.env[this.webhookSecretEnv] ?? "";
|
|
1152
|
+
if (!this.botToken) {
|
|
1153
|
+
throw new Error(
|
|
1154
|
+
`Telegram messaging: ${this.botTokenEnv} environment variable is not set`
|
|
1155
|
+
);
|
|
1156
|
+
}
|
|
1157
|
+
const me = await getMe(this.botToken);
|
|
1158
|
+
this.botUsername = me.username;
|
|
1159
|
+
this.botId = me.id;
|
|
1160
|
+
}
|
|
1161
|
+
onMessage(handler) {
|
|
1162
|
+
this.handler = handler;
|
|
1163
|
+
}
|
|
1164
|
+
registerRoutes(router) {
|
|
1165
|
+
router(
|
|
1166
|
+
"POST",
|
|
1167
|
+
"/api/messaging/telegram",
|
|
1168
|
+
(req, res) => this.handleRequest(req, res)
|
|
1169
|
+
);
|
|
1170
|
+
}
|
|
1171
|
+
async sendReply(threadRef, content, options) {
|
|
1172
|
+
const chatId = threadRef.channelId;
|
|
1173
|
+
const replyTo = threadRef.messageId ? Number(threadRef.messageId) : void 0;
|
|
1174
|
+
if (content) {
|
|
1175
|
+
const chunks = splitMessage2(content);
|
|
1176
|
+
for (const chunk of chunks) {
|
|
1177
|
+
await sendMessage(this.botToken, chatId, chunk, {
|
|
1178
|
+
reply_to_message_id: replyTo
|
|
1179
|
+
});
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
if (options?.files) {
|
|
1183
|
+
for (const file of options.files) {
|
|
1184
|
+
if (file.mediaType.startsWith("image/")) {
|
|
1185
|
+
await sendPhoto(this.botToken, chatId, file.data, {
|
|
1186
|
+
reply_to_message_id: replyTo,
|
|
1187
|
+
filename: file.filename
|
|
1188
|
+
});
|
|
1189
|
+
} else {
|
|
1190
|
+
await sendDocument(this.botToken, chatId, file.data, {
|
|
1191
|
+
reply_to_message_id: replyTo,
|
|
1192
|
+
filename: file.filename,
|
|
1193
|
+
mediaType: file.mediaType
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
async indicateProcessing(threadRef) {
|
|
1200
|
+
const chatId = threadRef.channelId;
|
|
1201
|
+
await sendChatAction(this.botToken, chatId, "typing");
|
|
1202
|
+
const interval = setInterval(() => {
|
|
1203
|
+
void sendChatAction(this.botToken, chatId, "typing").catch(() => {
|
|
1204
|
+
});
|
|
1205
|
+
}, TYPING_INTERVAL_MS);
|
|
1206
|
+
return async () => {
|
|
1207
|
+
clearInterval(interval);
|
|
1208
|
+
};
|
|
1209
|
+
}
|
|
1210
|
+
// -----------------------------------------------------------------------
|
|
1211
|
+
// HTTP request handling
|
|
1212
|
+
// -----------------------------------------------------------------------
|
|
1213
|
+
async handleRequest(req, res) {
|
|
1214
|
+
const rawBody = await collectBody3(req);
|
|
1215
|
+
if (this.webhookSecret) {
|
|
1216
|
+
const headerSecret = req.headers["x-telegram-bot-api-secret-token"];
|
|
1217
|
+
if (!verifyTelegramSecret(this.webhookSecret, headerSecret)) {
|
|
1218
|
+
res.writeHead(401);
|
|
1219
|
+
res.end("Invalid secret");
|
|
1220
|
+
return;
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
let payload;
|
|
1224
|
+
try {
|
|
1225
|
+
payload = JSON.parse(rawBody);
|
|
1226
|
+
} catch {
|
|
1227
|
+
res.writeHead(400);
|
|
1228
|
+
res.end("Invalid JSON");
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
1231
|
+
if (payload.update_id <= this.lastUpdateId) {
|
|
1232
|
+
res.writeHead(200);
|
|
1233
|
+
res.end();
|
|
1234
|
+
return;
|
|
1235
|
+
}
|
|
1236
|
+
this.lastUpdateId = payload.update_id;
|
|
1237
|
+
const message = payload.message;
|
|
1238
|
+
if (!message) {
|
|
1239
|
+
res.writeHead(200);
|
|
1240
|
+
res.end();
|
|
1241
|
+
return;
|
|
1242
|
+
}
|
|
1243
|
+
const text = message.text ?? message.caption ?? "";
|
|
1244
|
+
const hasFiles = !!(message.photo || message.document);
|
|
1245
|
+
if (!text && !hasFiles) {
|
|
1246
|
+
res.writeHead(200);
|
|
1247
|
+
res.end();
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1250
|
+
const chatId = String(message.chat.id);
|
|
1251
|
+
const chatType = message.chat.type;
|
|
1252
|
+
const isGroup = chatType === "group" || chatType === "supergroup";
|
|
1253
|
+
const entities = message.entities ?? message.caption_entities;
|
|
1254
|
+
const newMatch = text.match(NEW_COMMAND_RE);
|
|
1255
|
+
if (newMatch) {
|
|
1256
|
+
const suffix = newMatch[1];
|
|
1257
|
+
if (isGroup && suffix && suffix.toLowerCase() !== this.botUsername.toLowerCase()) {
|
|
1258
|
+
res.writeHead(200);
|
|
1259
|
+
res.end();
|
|
1260
|
+
return;
|
|
1261
|
+
}
|
|
1262
|
+
const key2 = this.sessionKey(message);
|
|
1263
|
+
const current = this.sessionCounters.get(key2) ?? 0;
|
|
1264
|
+
this.sessionCounters.set(key2, current + 1);
|
|
1265
|
+
res.writeHead(200);
|
|
1266
|
+
res.end();
|
|
1267
|
+
await sendMessage(
|
|
1268
|
+
this.botToken,
|
|
1269
|
+
chatId,
|
|
1270
|
+
"Conversation reset. Send a new message to start fresh.",
|
|
1271
|
+
{
|
|
1272
|
+
reply_to_message_id: message.message_id,
|
|
1273
|
+
message_thread_id: message.message_thread_id
|
|
1274
|
+
}
|
|
1275
|
+
);
|
|
1276
|
+
return;
|
|
1277
|
+
}
|
|
1278
|
+
if (isGroup) {
|
|
1279
|
+
if (!isBotMentioned(entities, this.botUsername, this.botId, text)) {
|
|
1280
|
+
res.writeHead(200);
|
|
1281
|
+
res.end();
|
|
1282
|
+
return;
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
const cleanText = isGroup ? stripMention2(text, entities, this.botUsername, this.botId) : text;
|
|
1286
|
+
if (!cleanText && !hasFiles) {
|
|
1287
|
+
res.writeHead(200);
|
|
1288
|
+
res.end();
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
1291
|
+
res.writeHead(200);
|
|
1292
|
+
res.end();
|
|
1293
|
+
if (!this.handler) return;
|
|
1294
|
+
const files = await this.extractFiles(message);
|
|
1295
|
+
const key = this.sessionKey(message);
|
|
1296
|
+
const session = this.sessionCounters.get(key) ?? 0;
|
|
1297
|
+
const topicId = message.message_thread_id;
|
|
1298
|
+
const platformThreadId = topicId ? `${chatId}:${topicId}:${session}` : `${chatId}:${session}`;
|
|
1299
|
+
const userId = String(message.from?.id ?? "unknown");
|
|
1300
|
+
const userName = [message.from?.first_name, message.from?.last_name].filter(Boolean).join(" ") || void 0;
|
|
1301
|
+
const ponchoMessage = {
|
|
1302
|
+
text: cleanText,
|
|
1303
|
+
files: files.length > 0 ? files : void 0,
|
|
1304
|
+
threadRef: {
|
|
1305
|
+
platformThreadId,
|
|
1306
|
+
channelId: chatId,
|
|
1307
|
+
messageId: String(message.message_id)
|
|
1308
|
+
},
|
|
1309
|
+
sender: { id: userId, name: userName },
|
|
1310
|
+
platform: "telegram",
|
|
1311
|
+
raw: message
|
|
1312
|
+
};
|
|
1313
|
+
void this.handler(ponchoMessage).catch((err) => {
|
|
1314
|
+
console.error(
|
|
1315
|
+
"[telegram-adapter] unhandled message handler error",
|
|
1316
|
+
err
|
|
1317
|
+
);
|
|
1318
|
+
});
|
|
1319
|
+
}
|
|
1320
|
+
// -----------------------------------------------------------------------
|
|
1321
|
+
// Helpers
|
|
1322
|
+
// -----------------------------------------------------------------------
|
|
1323
|
+
sessionKey(message) {
|
|
1324
|
+
const chatId = String(message.chat.id);
|
|
1325
|
+
return message.message_thread_id ? `${chatId}:${message.message_thread_id}` : chatId;
|
|
1326
|
+
}
|
|
1327
|
+
async extractFiles(message) {
|
|
1328
|
+
const files = [];
|
|
1329
|
+
try {
|
|
1330
|
+
if (message.photo && message.photo.length > 0) {
|
|
1331
|
+
const largest = message.photo[message.photo.length - 1];
|
|
1332
|
+
const filePath = await getFile(this.botToken, largest.file_id);
|
|
1333
|
+
const { data } = await downloadFile(this.botToken, filePath);
|
|
1334
|
+
files.push({ data, mediaType: "image/jpeg", filename: "photo.jpg" });
|
|
1335
|
+
}
|
|
1336
|
+
if (message.document) {
|
|
1337
|
+
const filePath = await getFile(
|
|
1338
|
+
this.botToken,
|
|
1339
|
+
message.document.file_id
|
|
1340
|
+
);
|
|
1341
|
+
const downloaded = await downloadFile(this.botToken, filePath);
|
|
1342
|
+
files.push({
|
|
1343
|
+
data: downloaded.data,
|
|
1344
|
+
mediaType: message.document.mime_type ?? downloaded.mediaType,
|
|
1345
|
+
filename: message.document.file_name
|
|
1346
|
+
});
|
|
1347
|
+
}
|
|
1348
|
+
} catch (err) {
|
|
1349
|
+
console.warn(
|
|
1350
|
+
"[telegram-adapter] failed to download file:",
|
|
1351
|
+
err instanceof Error ? err.message : err
|
|
1352
|
+
);
|
|
1353
|
+
}
|
|
1354
|
+
return files;
|
|
1355
|
+
}
|
|
1356
|
+
};
|
|
921
1357
|
export {
|
|
922
1358
|
AgentBridge,
|
|
923
1359
|
ResendAdapter,
|
|
924
1360
|
SlackAdapter,
|
|
1361
|
+
TelegramAdapter,
|
|
925
1362
|
buildReplyHeaders,
|
|
926
1363
|
buildReplySubject,
|
|
927
1364
|
deriveRootMessageId,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@poncho-ai/messaging",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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.
|
|
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,344 @@
|
|
|
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
|
+
}
|
|
33
|
+
|
|
34
|
+
const collectBody = (req: http.IncomingMessage): Promise<string> =>
|
|
35
|
+
new Promise((resolve, reject) => {
|
|
36
|
+
const chunks: Buffer[] = [];
|
|
37
|
+
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
38
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
39
|
+
req.on("error", reject);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export class TelegramAdapter implements MessagingAdapter {
|
|
43
|
+
readonly platform = "telegram" as const;
|
|
44
|
+
readonly autoReply = true;
|
|
45
|
+
readonly hasSentInCurrentRequest = false;
|
|
46
|
+
|
|
47
|
+
private botToken = "";
|
|
48
|
+
private webhookSecret = "";
|
|
49
|
+
private botUsername = "";
|
|
50
|
+
private botId = 0;
|
|
51
|
+
private readonly botTokenEnv: string;
|
|
52
|
+
private readonly webhookSecretEnv: string;
|
|
53
|
+
private handler: IncomingMessageHandler | undefined;
|
|
54
|
+
private readonly sessionCounters = new Map<string, number>();
|
|
55
|
+
private lastUpdateId = 0;
|
|
56
|
+
|
|
57
|
+
constructor(options: TelegramAdapterOptions = {}) {
|
|
58
|
+
this.botTokenEnv = options.botTokenEnv ?? "TELEGRAM_BOT_TOKEN";
|
|
59
|
+
this.webhookSecretEnv =
|
|
60
|
+
options.webhookSecretEnv ?? "TELEGRAM_WEBHOOK_SECRET";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// -----------------------------------------------------------------------
|
|
64
|
+
// MessagingAdapter implementation
|
|
65
|
+
// -----------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
async initialize(): Promise<void> {
|
|
68
|
+
this.botToken = process.env[this.botTokenEnv] ?? "";
|
|
69
|
+
this.webhookSecret = process.env[this.webhookSecretEnv] ?? "";
|
|
70
|
+
|
|
71
|
+
if (!this.botToken) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`Telegram messaging: ${this.botTokenEnv} environment variable is not set`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const me = await getMe(this.botToken);
|
|
78
|
+
this.botUsername = me.username;
|
|
79
|
+
this.botId = me.id;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
onMessage(handler: IncomingMessageHandler): void {
|
|
83
|
+
this.handler = handler;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
registerRoutes(router: RouteRegistrar): void {
|
|
87
|
+
router("POST", "/api/messaging/telegram", (req, res) =>
|
|
88
|
+
this.handleRequest(req, res),
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async sendReply(
|
|
93
|
+
threadRef: ThreadRef,
|
|
94
|
+
content: string,
|
|
95
|
+
options?: { files?: FileAttachment[] },
|
|
96
|
+
): Promise<void> {
|
|
97
|
+
const chatId = threadRef.channelId;
|
|
98
|
+
const replyTo = threadRef.messageId
|
|
99
|
+
? Number(threadRef.messageId)
|
|
100
|
+
: undefined;
|
|
101
|
+
|
|
102
|
+
if (content) {
|
|
103
|
+
const chunks = splitMessage(content);
|
|
104
|
+
for (const chunk of chunks) {
|
|
105
|
+
await sendMessage(this.botToken, chatId, chunk, {
|
|
106
|
+
reply_to_message_id: replyTo,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (options?.files) {
|
|
112
|
+
for (const file of options.files) {
|
|
113
|
+
if (file.mediaType.startsWith("image/")) {
|
|
114
|
+
await sendPhoto(this.botToken, chatId, file.data, {
|
|
115
|
+
reply_to_message_id: replyTo,
|
|
116
|
+
filename: file.filename,
|
|
117
|
+
});
|
|
118
|
+
} else {
|
|
119
|
+
await sendDocument(this.botToken, chatId, file.data, {
|
|
120
|
+
reply_to_message_id: replyTo,
|
|
121
|
+
filename: file.filename,
|
|
122
|
+
mediaType: file.mediaType,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async indicateProcessing(
|
|
130
|
+
threadRef: ThreadRef,
|
|
131
|
+
): Promise<() => Promise<void>> {
|
|
132
|
+
const chatId = threadRef.channelId;
|
|
133
|
+
|
|
134
|
+
await sendChatAction(this.botToken, chatId, "typing");
|
|
135
|
+
|
|
136
|
+
const interval = setInterval(() => {
|
|
137
|
+
void sendChatAction(this.botToken, chatId, "typing").catch(() => {});
|
|
138
|
+
}, TYPING_INTERVAL_MS);
|
|
139
|
+
|
|
140
|
+
return async () => {
|
|
141
|
+
clearInterval(interval);
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// -----------------------------------------------------------------------
|
|
146
|
+
// HTTP request handling
|
|
147
|
+
// -----------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
private async handleRequest(
|
|
150
|
+
req: http.IncomingMessage,
|
|
151
|
+
res: http.ServerResponse,
|
|
152
|
+
): Promise<void> {
|
|
153
|
+
const rawBody = await collectBody(req);
|
|
154
|
+
|
|
155
|
+
// -- Secret verification ----------------------------------------------
|
|
156
|
+
if (this.webhookSecret) {
|
|
157
|
+
const headerSecret = req.headers[
|
|
158
|
+
"x-telegram-bot-api-secret-token"
|
|
159
|
+
] as string | undefined;
|
|
160
|
+
if (!verifyTelegramSecret(this.webhookSecret, headerSecret)) {
|
|
161
|
+
res.writeHead(401);
|
|
162
|
+
res.end("Invalid secret");
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
let payload: TelegramUpdate;
|
|
168
|
+
try {
|
|
169
|
+
payload = JSON.parse(rawBody) as TelegramUpdate;
|
|
170
|
+
} catch {
|
|
171
|
+
res.writeHead(400);
|
|
172
|
+
res.end("Invalid JSON");
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// -- Update deduplication -----------------------------------------------
|
|
177
|
+
if (payload.update_id <= this.lastUpdateId) {
|
|
178
|
+
res.writeHead(200);
|
|
179
|
+
res.end();
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
this.lastUpdateId = payload.update_id;
|
|
183
|
+
|
|
184
|
+
const message = payload.message;
|
|
185
|
+
if (!message) {
|
|
186
|
+
res.writeHead(200);
|
|
187
|
+
res.end();
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const text = message.text ?? message.caption ?? "";
|
|
192
|
+
const hasFiles = !!(message.photo || message.document);
|
|
193
|
+
|
|
194
|
+
if (!text && !hasFiles) {
|
|
195
|
+
res.writeHead(200);
|
|
196
|
+
res.end();
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const chatId = String(message.chat.id);
|
|
201
|
+
const chatType = message.chat.type;
|
|
202
|
+
const isGroup = chatType === "group" || chatType === "supergroup";
|
|
203
|
+
const entities = message.entities ?? message.caption_entities;
|
|
204
|
+
|
|
205
|
+
// -- /new command -----------------------------------------------------
|
|
206
|
+
const newMatch = text.match(NEW_COMMAND_RE);
|
|
207
|
+
if (newMatch) {
|
|
208
|
+
const suffix = newMatch[1];
|
|
209
|
+
if (
|
|
210
|
+
isGroup &&
|
|
211
|
+
suffix &&
|
|
212
|
+
suffix.toLowerCase() !== this.botUsername.toLowerCase()
|
|
213
|
+
) {
|
|
214
|
+
res.writeHead(200);
|
|
215
|
+
res.end();
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const key = this.sessionKey(message);
|
|
220
|
+
const current = this.sessionCounters.get(key) ?? 0;
|
|
221
|
+
this.sessionCounters.set(key, current + 1);
|
|
222
|
+
|
|
223
|
+
res.writeHead(200);
|
|
224
|
+
res.end();
|
|
225
|
+
|
|
226
|
+
await sendMessage(
|
|
227
|
+
this.botToken,
|
|
228
|
+
chatId,
|
|
229
|
+
"Conversation reset. Send a new message to start fresh.",
|
|
230
|
+
{
|
|
231
|
+
reply_to_message_id: message.message_id,
|
|
232
|
+
message_thread_id: message.message_thread_id,
|
|
233
|
+
},
|
|
234
|
+
);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// -- Group mention filter ---------------------------------------------
|
|
239
|
+
if (isGroup) {
|
|
240
|
+
if (!isBotMentioned(entities, this.botUsername, this.botId, text)) {
|
|
241
|
+
res.writeHead(200);
|
|
242
|
+
res.end();
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const cleanText = isGroup
|
|
248
|
+
? stripMention(text, entities, this.botUsername, this.botId)
|
|
249
|
+
: text;
|
|
250
|
+
|
|
251
|
+
if (!cleanText && !hasFiles) {
|
|
252
|
+
res.writeHead(200);
|
|
253
|
+
res.end();
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Acknowledge immediately so Telegram doesn't retry.
|
|
258
|
+
res.writeHead(200);
|
|
259
|
+
res.end();
|
|
260
|
+
|
|
261
|
+
if (!this.handler) return;
|
|
262
|
+
|
|
263
|
+
// -- File extraction --------------------------------------------------
|
|
264
|
+
const files = await this.extractFiles(message);
|
|
265
|
+
|
|
266
|
+
// -- Build thread ref -------------------------------------------------
|
|
267
|
+
const key = this.sessionKey(message);
|
|
268
|
+
const session = this.sessionCounters.get(key) ?? 0;
|
|
269
|
+
const topicId = message.message_thread_id;
|
|
270
|
+
const platformThreadId = topicId
|
|
271
|
+
? `${chatId}:${topicId}:${session}`
|
|
272
|
+
: `${chatId}:${session}`;
|
|
273
|
+
|
|
274
|
+
const userId = String(message.from?.id ?? "unknown");
|
|
275
|
+
const userName =
|
|
276
|
+
[message.from?.first_name, message.from?.last_name]
|
|
277
|
+
.filter(Boolean)
|
|
278
|
+
.join(" ") || undefined;
|
|
279
|
+
|
|
280
|
+
const ponchoMessage: PonchoIncomingMessage = {
|
|
281
|
+
text: cleanText,
|
|
282
|
+
files: files.length > 0 ? files : undefined,
|
|
283
|
+
threadRef: {
|
|
284
|
+
platformThreadId,
|
|
285
|
+
channelId: chatId,
|
|
286
|
+
messageId: String(message.message_id),
|
|
287
|
+
},
|
|
288
|
+
sender: { id: userId, name: userName },
|
|
289
|
+
platform: "telegram",
|
|
290
|
+
raw: message,
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
void this.handler(ponchoMessage).catch((err) => {
|
|
294
|
+
console.error(
|
|
295
|
+
"[telegram-adapter] unhandled message handler error",
|
|
296
|
+
err,
|
|
297
|
+
);
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// -----------------------------------------------------------------------
|
|
302
|
+
// Helpers
|
|
303
|
+
// -----------------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
private sessionKey(message: TelegramMessage): string {
|
|
306
|
+
const chatId = String(message.chat.id);
|
|
307
|
+
return message.message_thread_id
|
|
308
|
+
? `${chatId}:${message.message_thread_id}`
|
|
309
|
+
: chatId;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private async extractFiles(
|
|
313
|
+
message: TelegramMessage,
|
|
314
|
+
): Promise<FileAttachment[]> {
|
|
315
|
+
const files: FileAttachment[] = [];
|
|
316
|
+
try {
|
|
317
|
+
if (message.photo && message.photo.length > 0) {
|
|
318
|
+
const largest = message.photo[message.photo.length - 1]!;
|
|
319
|
+
const filePath = await getFile(this.botToken, largest.file_id);
|
|
320
|
+
const { data } = await downloadFile(this.botToken, filePath);
|
|
321
|
+
files.push({ data, mediaType: "image/jpeg", filename: "photo.jpg" });
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (message.document) {
|
|
325
|
+
const filePath = await getFile(
|
|
326
|
+
this.botToken,
|
|
327
|
+
message.document.file_id,
|
|
328
|
+
);
|
|
329
|
+
const downloaded = await downloadFile(this.botToken, filePath);
|
|
330
|
+
files.push({
|
|
331
|
+
data: downloaded.data,
|
|
332
|
+
mediaType: message.document.mime_type ?? downloaded.mediaType,
|
|
333
|
+
filename: message.document.file_name,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
} catch (err) {
|
|
337
|
+
console.warn(
|
|
338
|
+
"[telegram-adapter] failed to download file:",
|
|
339
|
+
err instanceof Error ? err.message : err,
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
return files;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
@@ -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
|
|
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("
|
|
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,
|