opencode-router 0.11.128 → 0.11.130

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/dist/telegram.js CHANGED
@@ -1,4 +1,6 @@
1
- import { Bot } from "grammy";
1
+ import { Bot, InputFile } from "grammy";
2
+ import { classifyDeliveryError, withDeliveryRetry } from "./delivery.js";
3
+ import { chunkText } from "./text.js";
2
4
  const MAX_TEXT_LENGTH = 4096;
3
5
  const TELEGRAM_CHAT_ID_PATTERN = /^-?\d+$/;
4
6
  export function isTelegramPeerId(peerId) {
@@ -13,14 +15,156 @@ export function parseTelegramPeerId(peerId) {
13
15
  return null;
14
16
  return parsed;
15
17
  }
16
- export function createTelegramAdapter(identity, config, logger, onMessage) {
18
+ function invalidTelegramPeerIdError() {
19
+ const error = new Error("Telegram peerId must be a numeric chat_id. Usernames like @name are not valid direct targets.");
20
+ error.status = 400;
21
+ return error;
22
+ }
23
+ export function createTelegramAdapter(identity, config, logger, onMessage, mediaStore, deps = {}) {
17
24
  const token = identity.token?.trim() ?? "";
18
25
  if (!token) {
19
26
  throw new Error("Telegram token is required for Telegram adapter");
20
27
  }
21
28
  const log = logger.child({ channel: "telegram", identityId: identity.id });
22
29
  log.debug({ tokenPresent: true }, "telegram adapter init");
23
- const bot = new Bot(token);
30
+ const BotImpl = deps.Bot ?? Bot;
31
+ const bot = new BotImpl(token);
32
+ const truncateCaption = (value) => {
33
+ const text = (value ?? "").trim();
34
+ if (!text)
35
+ return undefined;
36
+ return text.length <= 1024 ? text : text.slice(0, 1024);
37
+ };
38
+ const extractMediaCandidates = (message) => {
39
+ const candidates = [];
40
+ if (Array.isArray(message?.photo) && message.photo.length > 0) {
41
+ const largest = message.photo[message.photo.length - 1];
42
+ if (largest?.file_id) {
43
+ candidates.push({
44
+ kind: "image",
45
+ fileId: String(largest.file_id),
46
+ fileUniqueId: typeof largest.file_unique_id === "string" ? largest.file_unique_id : undefined,
47
+ filename: typeof largest.file_unique_id === "string"
48
+ ? `photo-${largest.file_unique_id}.jpg`
49
+ : `photo-${String(largest.file_id)}.jpg`,
50
+ mimeType: "image/jpeg",
51
+ sizeBytes: typeof largest.file_size === "number" ? largest.file_size : undefined,
52
+ });
53
+ }
54
+ }
55
+ if (message?.document?.file_id) {
56
+ const document = message.document;
57
+ const mimeType = typeof document.mime_type === "string" ? document.mime_type : undefined;
58
+ const normalizedKind = typeof mimeType === "string" && mimeType.startsWith("image/")
59
+ ? "image"
60
+ : typeof mimeType === "string" && mimeType.startsWith("audio/")
61
+ ? "audio"
62
+ : "file";
63
+ candidates.push({
64
+ kind: normalizedKind,
65
+ fileId: String(document.file_id),
66
+ fileUniqueId: typeof document.file_unique_id === "string" ? document.file_unique_id : undefined,
67
+ filename: typeof document.file_name === "string" ? document.file_name : undefined,
68
+ mimeType,
69
+ sizeBytes: typeof document.file_size === "number" ? document.file_size : undefined,
70
+ });
71
+ }
72
+ if (message?.audio?.file_id) {
73
+ const audio = message.audio;
74
+ candidates.push({
75
+ kind: "audio",
76
+ fileId: String(audio.file_id),
77
+ fileUniqueId: typeof audio.file_unique_id === "string" ? audio.file_unique_id : undefined,
78
+ filename: typeof audio.file_name === "string" ? audio.file_name : undefined,
79
+ mimeType: typeof audio.mime_type === "string" ? audio.mime_type : undefined,
80
+ sizeBytes: typeof audio.file_size === "number" ? audio.file_size : undefined,
81
+ });
82
+ }
83
+ if (message?.voice?.file_id) {
84
+ const voice = message.voice;
85
+ candidates.push({
86
+ kind: "audio",
87
+ fileId: String(voice.file_id),
88
+ fileUniqueId: typeof voice.file_unique_id === "string" ? voice.file_unique_id : undefined,
89
+ filename: typeof voice.file_unique_id === "string"
90
+ ? `voice-${voice.file_unique_id}.ogg`
91
+ : `voice-${String(voice.file_id)}.ogg`,
92
+ mimeType: "audio/ogg",
93
+ sizeBytes: typeof voice.file_size === "number" ? voice.file_size : undefined,
94
+ });
95
+ }
96
+ return candidates;
97
+ };
98
+ const downloadCandidate = async (chatId, candidate) => {
99
+ if (!mediaStore) {
100
+ return {
101
+ type: "media",
102
+ media: {
103
+ id: candidate.fileUniqueId || candidate.fileId,
104
+ kind: candidate.kind,
105
+ source: "telegram",
106
+ status: "failed",
107
+ providerFileId: candidate.fileId,
108
+ ...(candidate.fileUniqueId ? { providerFileUniqueId: candidate.fileUniqueId } : {}),
109
+ ...(candidate.filename ? { filename: candidate.filename } : {}),
110
+ ...(candidate.mimeType ? { mimeType: candidate.mimeType } : {}),
111
+ ...(typeof candidate.sizeBytes === "number" ? { sizeBytes: candidate.sizeBytes } : {}),
112
+ error: "media store unavailable",
113
+ },
114
+ };
115
+ }
116
+ try {
117
+ const file = await withDeliveryRetry("telegram.getFile", () => bot.api.getFile(candidate.fileId), { logger: log });
118
+ const filePath = typeof file?.file_path === "string" ? String(file.file_path) : "";
119
+ if (!filePath) {
120
+ throw new Error(`Telegram file path missing for file_id ${candidate.fileId}`);
121
+ }
122
+ const url = `https://api.telegram.org/file/bot${token}/${filePath}`;
123
+ const stored = await withDeliveryRetry("telegram.download", () => mediaStore.downloadInbound({
124
+ channel: "telegram",
125
+ identityId: identity.id,
126
+ peerId: chatId,
127
+ kind: candidate.kind,
128
+ url,
129
+ ...(candidate.filename ? { filename: candidate.filename } : {}),
130
+ ...(candidate.mimeType ? { mimeType: candidate.mimeType } : {}),
131
+ }), { logger: log });
132
+ return {
133
+ type: "media",
134
+ media: {
135
+ id: candidate.fileUniqueId || candidate.fileId,
136
+ kind: candidate.kind,
137
+ source: "telegram",
138
+ status: "ready",
139
+ filePath: stored.filePath,
140
+ filename: stored.filename,
141
+ ...(stored.mimeType ? { mimeType: stored.mimeType } : {}),
142
+ sizeBytes: stored.sizeBytes,
143
+ providerFileId: candidate.fileId,
144
+ ...(candidate.fileUniqueId ? { providerFileUniqueId: candidate.fileUniqueId } : {}),
145
+ providerUrl: url,
146
+ },
147
+ };
148
+ }
149
+ catch (error) {
150
+ const classified = classifyDeliveryError(error);
151
+ return {
152
+ type: "media",
153
+ media: {
154
+ id: candidate.fileUniqueId || candidate.fileId,
155
+ kind: candidate.kind,
156
+ source: "telegram",
157
+ status: "failed",
158
+ providerFileId: candidate.fileId,
159
+ ...(candidate.fileUniqueId ? { providerFileUniqueId: candidate.fileUniqueId } : {}),
160
+ ...(candidate.filename ? { filename: candidate.filename } : {}),
161
+ ...(candidate.mimeType ? { mimeType: candidate.mimeType } : {}),
162
+ ...(typeof candidate.sizeBytes === "number" ? { sizeBytes: candidate.sizeBytes } : {}),
163
+ error: `${classified.code}: ${classified.message}`,
164
+ },
165
+ };
166
+ }
167
+ };
24
168
  bot.catch((err) => {
25
169
  log.error({ error: err.error }, "telegram bot error");
26
170
  });
@@ -28,6 +172,16 @@ export function createTelegramAdapter(identity, config, logger, onMessage) {
28
172
  const msg = ctx.message;
29
173
  if (!msg?.chat)
30
174
  return;
175
+ const fromId = typeof msg.from?.id === "number" ? msg.from.id : null;
176
+ const selfId = typeof ctx.me?.id === "number" ? ctx.me.id : null;
177
+ const fromMe = fromId !== null && selfId !== null && fromId === selfId;
178
+ const fromBot = msg.from?.is_bot === true;
179
+ if (fromMe || fromBot) {
180
+ log.debug({ chatId: msg.chat.id, fromId, selfId }, "telegram message ignored (bot-originated)");
181
+ return;
182
+ }
183
+ const mediaCandidates = extractMediaCandidates(msg);
184
+ const hasMedia = mediaCandidates.length > 0;
31
185
  const chatType = msg.chat.type;
32
186
  const isGroup = chatType === "group" || chatType === "supergroup" || chatType === "channel";
33
187
  // In groups, check if groups are enabled
@@ -36,8 +190,6 @@ export function createTelegramAdapter(identity, config, logger, onMessage) {
36
190
  return;
37
191
  }
38
192
  let text = msg.text ?? msg.caption ?? "";
39
- if (!text.trim())
40
- return;
41
193
  // In groups, only respond if the bot is @mentioned
42
194
  if (isGroup) {
43
195
  const botUsername = ctx.me?.username;
@@ -52,25 +204,103 @@ export function createTelegramAdapter(identity, config, logger, onMessage) {
52
204
  }
53
205
  // Strip the @mention from the message
54
206
  text = text.replace(mentionPattern, "").trim();
55
- if (!text) {
207
+ if (!text && !hasMedia) {
56
208
  log.debug({ chatId: msg.chat.id }, "telegram message ignored (empty after removing mention)");
57
209
  return;
58
210
  }
59
211
  }
60
- log.debug({ chatId: msg.chat.id, chatType, isGroup, length: text.length, preview: text.slice(0, 120) }, "telegram message received");
212
+ if (!text.trim() && !hasMedia) {
213
+ return;
214
+ }
215
+ const parts = [];
216
+ if (text.trim()) {
217
+ parts.push({ type: "text", text: text.trim() });
218
+ }
219
+ for (const candidate of mediaCandidates) {
220
+ const part = await downloadCandidate(String(msg.chat.id), candidate);
221
+ if ((msg.caption ?? "").trim() && part.type === "media") {
222
+ parts.push({ ...part, caption: msg.caption?.trim() });
223
+ }
224
+ else {
225
+ parts.push(part);
226
+ }
227
+ }
228
+ const textForPrompt = parts
229
+ .filter((part) => part.type === "text")
230
+ .map((part) => part.text)
231
+ .join("\n")
232
+ .trim();
233
+ const preview = textForPrompt || `${parts.filter((part) => part.type === "media").length} media attachment(s)`;
234
+ log.debug({ chatId: msg.chat.id, chatType, isGroup, length: textForPrompt.length, preview: preview.slice(0, 120) }, "telegram message received");
61
235
  try {
62
236
  await onMessage({
63
237
  channel: "telegram",
64
238
  identityId: identity.id,
65
239
  peerId: String(msg.chat.id),
66
- text,
240
+ text: textForPrompt,
241
+ parts,
67
242
  raw: msg,
243
+ fromMe,
68
244
  });
69
245
  }
70
246
  catch (error) {
71
247
  log.error({ error, peerId: msg.chat.id }, "telegram inbound handler failed");
72
248
  }
73
249
  });
250
+ const sendMessageInternal = async (peerId, message) => {
251
+ const chatId = parseTelegramPeerId(peerId);
252
+ if (chatId === null) {
253
+ throw invalidTelegramPeerIdError();
254
+ }
255
+ const partResults = [];
256
+ let sentParts = 0;
257
+ for (let index = 0; index < message.parts.length; index += 1) {
258
+ const part = message.parts[index];
259
+ try {
260
+ if (part.type === "text") {
261
+ const chunks = chunkText(part.text, MAX_TEXT_LENGTH);
262
+ for (const chunk of chunks) {
263
+ await withDeliveryRetry("telegram.sendMessage", () => bot.api.sendMessage(chatId, chunk), {
264
+ logger: log,
265
+ });
266
+ }
267
+ }
268
+ else if (part.type === "image") {
269
+ await withDeliveryRetry("telegram.sendPhoto", () => bot.api.sendPhoto(chatId, new InputFile(part.filePath, part.filename), {
270
+ ...(truncateCaption(part.caption) ? { caption: truncateCaption(part.caption) } : {}),
271
+ }), { logger: log });
272
+ }
273
+ else if (part.type === "audio") {
274
+ await withDeliveryRetry("telegram.sendAudio", () => bot.api.sendAudio(chatId, new InputFile(part.filePath, part.filename), {
275
+ ...(truncateCaption(part.caption) ? { caption: truncateCaption(part.caption) } : {}),
276
+ }), { logger: log });
277
+ }
278
+ else {
279
+ await withDeliveryRetry("telegram.sendDocument", () => bot.api.sendDocument(chatId, new InputFile(part.filePath, part.filename), {
280
+ ...(truncateCaption(part.caption) ? { caption: truncateCaption(part.caption) } : {}),
281
+ }), { logger: log });
282
+ }
283
+ sentParts += 1;
284
+ partResults.push({ index, type: part.type, sent: true });
285
+ }
286
+ catch (error) {
287
+ const classified = classifyDeliveryError(error);
288
+ partResults.push({
289
+ index,
290
+ type: part.type,
291
+ sent: false,
292
+ error: classified.message,
293
+ code: classified.code,
294
+ retryable: classified.retryable,
295
+ });
296
+ }
297
+ }
298
+ return {
299
+ attemptedParts: message.parts.length,
300
+ sentParts,
301
+ partResults,
302
+ };
303
+ };
74
304
  return {
75
305
  name: "telegram",
76
306
  identityId: identity.id,
@@ -84,14 +314,17 @@ export function createTelegramAdapter(identity, config, logger, onMessage) {
84
314
  bot.stop();
85
315
  log.info("telegram adapter stopped");
86
316
  },
317
+ async sendMessage(peerId, message) {
318
+ return sendMessageInternal(peerId, message);
319
+ },
87
320
  async sendText(peerId, text) {
88
- const chatId = parseTelegramPeerId(peerId);
89
- if (chatId === null) {
90
- const error = new Error("Telegram peerId must be a numeric chat_id. Usernames like @name are not valid direct targets.");
91
- error.status = 400;
92
- throw error;
321
+ const result = await sendMessageInternal(peerId, {
322
+ parts: [{ type: "text", text }],
323
+ });
324
+ if (result.sentParts === 0) {
325
+ const firstError = result.partResults.find((part) => !part.sent)?.error;
326
+ throw new Error(firstError || "Failed to deliver Telegram text message");
93
327
  }
94
- await bot.api.sendMessage(chatId, text);
95
328
  },
96
329
  };
97
330
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-router",
3
- "version": "0.11.128",
3
+ "version": "0.11.130",
4
4
  "description": "opencode-router: Slack + Telegram bridge + directory routing for a running opencode server",
5
5
  "private": false,
6
6
  "type": "module",