opencode-router 0.11.128 → 0.11.129

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,8 @@ export function createTelegramAdapter(identity, config, logger, onMessage) {
28
172
  const msg = ctx.message;
29
173
  if (!msg?.chat)
30
174
  return;
175
+ const mediaCandidates = extractMediaCandidates(msg);
176
+ const hasMedia = mediaCandidates.length > 0;
31
177
  const chatType = msg.chat.type;
32
178
  const isGroup = chatType === "group" || chatType === "supergroup" || chatType === "channel";
33
179
  // In groups, check if groups are enabled
@@ -36,8 +182,6 @@ export function createTelegramAdapter(identity, config, logger, onMessage) {
36
182
  return;
37
183
  }
38
184
  let text = msg.text ?? msg.caption ?? "";
39
- if (!text.trim())
40
- return;
41
185
  // In groups, only respond if the bot is @mentioned
42
186
  if (isGroup) {
43
187
  const botUsername = ctx.me?.username;
@@ -52,18 +196,41 @@ export function createTelegramAdapter(identity, config, logger, onMessage) {
52
196
  }
53
197
  // Strip the @mention from the message
54
198
  text = text.replace(mentionPattern, "").trim();
55
- if (!text) {
199
+ if (!text && !hasMedia) {
56
200
  log.debug({ chatId: msg.chat.id }, "telegram message ignored (empty after removing mention)");
57
201
  return;
58
202
  }
59
203
  }
60
- log.debug({ chatId: msg.chat.id, chatType, isGroup, length: text.length, preview: text.slice(0, 120) }, "telegram message received");
204
+ if (!text.trim() && !hasMedia) {
205
+ return;
206
+ }
207
+ const parts = [];
208
+ if (text.trim()) {
209
+ parts.push({ type: "text", text: text.trim() });
210
+ }
211
+ for (const candidate of mediaCandidates) {
212
+ const part = await downloadCandidate(String(msg.chat.id), candidate);
213
+ if ((msg.caption ?? "").trim() && part.type === "media") {
214
+ parts.push({ ...part, caption: msg.caption?.trim() });
215
+ }
216
+ else {
217
+ parts.push(part);
218
+ }
219
+ }
220
+ const textForPrompt = parts
221
+ .filter((part) => part.type === "text")
222
+ .map((part) => part.text)
223
+ .join("\n")
224
+ .trim();
225
+ const preview = textForPrompt || `${parts.filter((part) => part.type === "media").length} media attachment(s)`;
226
+ log.debug({ chatId: msg.chat.id, chatType, isGroup, length: textForPrompt.length, preview: preview.slice(0, 120) }, "telegram message received");
61
227
  try {
62
228
  await onMessage({
63
229
  channel: "telegram",
64
230
  identityId: identity.id,
65
231
  peerId: String(msg.chat.id),
66
- text,
232
+ text: textForPrompt,
233
+ parts,
67
234
  raw: msg,
68
235
  });
69
236
  }
@@ -71,6 +238,60 @@ export function createTelegramAdapter(identity, config, logger, onMessage) {
71
238
  log.error({ error, peerId: msg.chat.id }, "telegram inbound handler failed");
72
239
  }
73
240
  });
241
+ const sendMessageInternal = async (peerId, message) => {
242
+ const chatId = parseTelegramPeerId(peerId);
243
+ if (chatId === null) {
244
+ throw invalidTelegramPeerIdError();
245
+ }
246
+ const partResults = [];
247
+ let sentParts = 0;
248
+ for (let index = 0; index < message.parts.length; index += 1) {
249
+ const part = message.parts[index];
250
+ try {
251
+ if (part.type === "text") {
252
+ const chunks = chunkText(part.text, MAX_TEXT_LENGTH);
253
+ for (const chunk of chunks) {
254
+ await withDeliveryRetry("telegram.sendMessage", () => bot.api.sendMessage(chatId, chunk), {
255
+ logger: log,
256
+ });
257
+ }
258
+ }
259
+ else if (part.type === "image") {
260
+ await withDeliveryRetry("telegram.sendPhoto", () => bot.api.sendPhoto(chatId, new InputFile(part.filePath, part.filename), {
261
+ ...(truncateCaption(part.caption) ? { caption: truncateCaption(part.caption) } : {}),
262
+ }), { logger: log });
263
+ }
264
+ else if (part.type === "audio") {
265
+ await withDeliveryRetry("telegram.sendAudio", () => bot.api.sendAudio(chatId, new InputFile(part.filePath, part.filename), {
266
+ ...(truncateCaption(part.caption) ? { caption: truncateCaption(part.caption) } : {}),
267
+ }), { logger: log });
268
+ }
269
+ else {
270
+ await withDeliveryRetry("telegram.sendDocument", () => bot.api.sendDocument(chatId, new InputFile(part.filePath, part.filename), {
271
+ ...(truncateCaption(part.caption) ? { caption: truncateCaption(part.caption) } : {}),
272
+ }), { logger: log });
273
+ }
274
+ sentParts += 1;
275
+ partResults.push({ index, type: part.type, sent: true });
276
+ }
277
+ catch (error) {
278
+ const classified = classifyDeliveryError(error);
279
+ partResults.push({
280
+ index,
281
+ type: part.type,
282
+ sent: false,
283
+ error: classified.message,
284
+ code: classified.code,
285
+ retryable: classified.retryable,
286
+ });
287
+ }
288
+ }
289
+ return {
290
+ attemptedParts: message.parts.length,
291
+ sentParts,
292
+ partResults,
293
+ };
294
+ };
74
295
  return {
75
296
  name: "telegram",
76
297
  identityId: identity.id,
@@ -84,14 +305,17 @@ export function createTelegramAdapter(identity, config, logger, onMessage) {
84
305
  bot.stop();
85
306
  log.info("telegram adapter stopped");
86
307
  },
308
+ async sendMessage(peerId, message) {
309
+ return sendMessageInternal(peerId, message);
310
+ },
87
311
  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;
312
+ const result = await sendMessageInternal(peerId, {
313
+ parts: [{ type: "text", text }],
314
+ });
315
+ if (result.sentParts === 0) {
316
+ const firstError = result.partResults.find((part) => !part.sent)?.error;
317
+ throw new Error(firstError || "Failed to deliver Telegram text message");
93
318
  }
94
- await bot.api.sendMessage(chatId, text);
95
319
  },
96
320
  };
97
321
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-router",
3
- "version": "0.11.128",
3
+ "version": "0.11.129",
4
4
  "description": "opencode-router: Slack + Telegram bridge + directory routing for a running opencode server",
5
5
  "private": false,
6
6
  "type": "module",