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/README.md +33 -0
- package/dist/bridge.js +232 -68
- package/dist/cli.js +67 -11
- package/dist/delivery.js +108 -0
- package/dist/health.js +10 -3
- package/dist/media-store.js +125 -0
- package/dist/media.js +111 -0
- package/dist/slack.js +204 -22
- package/dist/telegram.js +238 -14
- package/package.json +1 -1
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
}
|