virtualcode 1.9.2 → 2.0.1

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/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { tool } from "@opencode-ai/plugin/tool";
2
2
  import { Telegraf } from "telegraf";
3
- import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync } from "node:fs";
3
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync, chmodSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { homedir } from "node:os";
6
6
  const LINK_FILE = join(homedir(), ".config", "opencode", "telegram-links.json");
@@ -8,51 +8,86 @@ const LINK_TMP = LINK_FILE + ".tmp";
8
8
  const TOKEN_FILE = join(homedir(), ".config", "opencode", "telegram-token.json");
9
9
  const TOKEN_TMP = TOKEN_FILE + ".tmp";
10
10
  const CONFIG_DIR = join(homedir(), ".config", "opencode");
11
- function loadLinks() {
11
+ const TOKEN_REGEX = /^\d{8,12}:[\w-]{30,50}$/;
12
+ const MAX_LRU_SIZE = 100;
13
+ const PENDING_TIMEOUT_MS = 30_000;
14
+ const RECONNECT_DELAYS = [5_000, 10_000, 20_000, 30_000];
15
+ const MAX_TELEGRAM_INPUT = 4096;
16
+ const MAX_TUI_TEXT = 400;
17
+ const MIN_PREFIX_LEN = 4;
18
+ const MAX_TOKENS_PER_CHUNK = 25;
19
+ const MIN_HISTORY_LIMIT = 1;
20
+ const FILE_MODE = 0o600;
21
+ function safeChmod(p) {
12
22
  try {
13
- if (existsSync(LINK_FILE)) {
14
- return JSON.parse(readFileSync(LINK_FILE, "utf-8"));
15
- }
23
+ chmodSync(p, FILE_MODE);
16
24
  }
17
25
  catch { }
18
- return {};
19
26
  }
20
- function persistLinks(links) {
27
+ function atomicWrite(path, tmp, data) {
21
28
  mkdirSync(CONFIG_DIR, { recursive: true });
22
- writeFileSync(LINK_TMP, JSON.stringify(links, null, 2));
29
+ writeFileSync(tmp, data);
30
+ safeChmod(tmp);
23
31
  try {
24
- renameSync(LINK_TMP, LINK_FILE);
32
+ renameSync(tmp, path);
25
33
  }
26
34
  catch {
27
- writeFileSync(LINK_FILE, JSON.stringify(links, null, 2));
35
+ writeFileSync(path, data);
28
36
  }
37
+ safeChmod(path);
38
+ }
39
+ function loadLinks() {
40
+ try {
41
+ if (existsSync(LINK_FILE)) {
42
+ const raw = readFileSync(LINK_FILE, "utf-8");
43
+ const parsed = JSON.parse(raw);
44
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
45
+ return parsed;
46
+ }
47
+ }
48
+ }
49
+ catch (err) {
50
+ debugLog("loadLinks failed:", userError(err));
51
+ }
52
+ return {};
53
+ }
54
+ function persistLinks(links) {
55
+ atomicWrite(LINK_FILE, LINK_TMP, JSON.stringify(links, null, 2));
29
56
  }
30
57
  function loadSavedToken() {
31
58
  try {
32
59
  if (existsSync(TOKEN_FILE)) {
33
60
  const data = JSON.parse(readFileSync(TOKEN_FILE, "utf-8"));
34
- if (data?.token)
61
+ if (typeof data?.token === "string" && data.token.length > 0)
35
62
  return data.token;
36
63
  }
37
64
  }
38
- catch { }
65
+ catch (err) {
66
+ debugLog("loadSavedToken failed:", userError(err));
67
+ }
39
68
  return null;
40
69
  }
41
- function saveToken(token) {
42
- mkdirSync(CONFIG_DIR, { recursive: true });
43
- writeFileSync(TOKEN_TMP, JSON.stringify({ token }, null, 2));
70
+ function clearSavedToken() {
44
71
  try {
45
- renameSync(TOKEN_TMP, TOKEN_FILE);
46
- }
47
- catch {
48
- writeFileSync(TOKEN_FILE, JSON.stringify({ token }, null, 2));
72
+ if (existsSync(TOKEN_FILE))
73
+ renameSync(TOKEN_FILE, TOKEN_FILE + ".bak");
49
74
  }
75
+ catch { }
76
+ }
77
+ function saveToken(token) {
78
+ atomicWrite(TOKEN_FILE, TOKEN_TMP, JSON.stringify({ token }, null, 2));
79
+ }
80
+ function isValidToken(token) {
81
+ if (typeof token !== "string")
82
+ return false;
83
+ if (token.length > 100)
84
+ return false;
85
+ return TOKEN_REGEX.test(token);
50
86
  }
51
87
  function userError(err) {
52
88
  if (!err)
53
89
  return "Unknown error";
54
90
  if (err instanceof Error) {
55
- // Only first line — never include stack traces
56
91
  const first = err.message.split("\n")[0].trim();
57
92
  return first || "Unknown error";
58
93
  }
@@ -61,38 +96,41 @@ function userError(err) {
61
96
  }
62
97
  const DEBUG = !!process.env.DEBUG_TELEGRAM;
63
98
  function debugLog(...args) {
64
- console.error("[telegram-plugin]", ...args);
65
99
  if (DEBUG) {
100
+ console.error("[telegram-plugin]", ...args);
66
101
  for (const a of args) {
67
102
  if (a instanceof Error)
68
- console.error(a);
103
+ console.error(a.stack || a.message);
69
104
  }
70
105
  }
71
106
  }
72
107
  function sanitizeUI(text, maxLen = 200) {
73
108
  if (!text)
74
109
  return "";
75
- // Remove stack trace lines (" at ...")
76
- let cleaned = text
110
+ let cleaned = String(text)
77
111
  .split("\n")
78
112
  .filter((line) => !/^\s*at\s/.test(line))
79
113
  .join(" ")
80
114
  .trim();
81
- // Collapse whitespace
82
115
  cleaned = cleaned.replace(/\s+/g, " ");
83
- // Truncate
84
116
  if (cleaned.length > maxLen) {
85
- cleaned = cleaned.slice(0, maxLen - 3).trim() + "...";
117
+ cleaned = cleaned.slice(0, Math.max(0, maxLen - 3)).trim() + "...";
86
118
  }
87
119
  return cleaned;
88
120
  }
121
+ function sanitizeTUI(text, maxLen = MAX_TUI_TEXT) {
122
+ const s = sanitizeUI(text, maxLen);
123
+ if (!s)
124
+ return "";
125
+ return s.length > maxLen ? s.slice(0, maxLen - 3) + "..." : s;
126
+ }
89
127
  const KNOWN_ERRORS = [
90
- [/Expected a string starting with "ses"/, "Invalid session ID."],
91
- [/ECONNRESET/, "Connection lost."],
92
- [/(409|Conflict)/, "Another bot instance running."],
93
- [/(401|Unauthorized|invalid.*token)/i, "Invalid token."],
94
- [/(403|Forbidden)/, "Bot blocked."],
95
- [/(socket|timeout)/i, "Connection timed out."],
128
+ [/Expected a string starting with "ses"/, "Invalid session ID."],
129
+ [/ECONNRESET/, "Connection lost."],
130
+ [/(409|Conflict)/, "Another bot instance running."],
131
+ [/(401|Unauthorized|invalid.*token)/i, "Invalid token."],
132
+ [/(403|Forbidden)/, "Bot blocked."],
133
+ [/(socket|timeout)/i, "Connection timed out."],
96
134
  ];
97
135
  function handlePluginError(err, context) {
98
136
  const msg = userError(err);
@@ -101,9 +139,11 @@ function handlePluginError(err, context) {
101
139
  if (pattern.test(msg))
102
140
  return friendly;
103
141
  }
104
- return "Something went wrong.";
142
+ return "Something went wrong.";
105
143
  }
106
144
  function chunkText(text, maxLen = 4000) {
145
+ if (!text)
146
+ return [""];
107
147
  if (text.length <= maxLen)
108
148
  return [text];
109
149
  const chunks = [];
@@ -113,8 +153,33 @@ function chunkText(text, maxLen = 4000) {
113
153
  return chunks;
114
154
  }
115
155
  function fmtId(id) {
156
+ if (typeof id !== "string")
157
+ return "(invalid)";
116
158
  return id.length > 12 ? id.slice(0, 12) + "..." : id;
117
159
  }
160
+ function findSessionById(id, list) {
161
+ if (typeof id !== "string" || !Array.isArray(list))
162
+ return { status: "not_found" };
163
+ const exact = list.find((s) => s && typeof s.id === "string" && s.id === id);
164
+ if (exact)
165
+ return { status: "found", session: exact };
166
+ if (id.length < MIN_PREFIX_LEN)
167
+ return { status: "not_found" };
168
+ const prefix = list.filter((s) => s && typeof s.id === "string" && s.id.startsWith(id));
169
+ if (prefix.length === 1)
170
+ return { status: "found", session: prefix[0] };
171
+ if (prefix.length > 1)
172
+ return { status: "ambiguous" };
173
+ return { status: "not_found" };
174
+ }
175
+ function safeList(arr) {
176
+ if (!Array.isArray(arr))
177
+ return [];
178
+ return arr.filter((s) => s && typeof s === "object" && typeof s.id === "string");
179
+ }
180
+ function delay(ms) {
181
+ return new Promise((r) => setTimeout(r, ms));
182
+ }
118
183
  const TelegramPlugin = async ({ client, directory }, options) => {
119
184
  const config = options;
120
185
  const allowedSet = config?.allowed_users?.length ? new Set(config.allowed_users) : null;
@@ -122,6 +187,10 @@ const TelegramPlugin = async ({ client, directory }, options) => {
122
187
  let bot = null;
123
188
  let botReady = false;
124
189
  let botStarting = false;
190
+ let userStopped = false;
191
+ let savedToken = null;
192
+ let reconnectTimer = null;
193
+ let reconnectAttempts = 0;
125
194
  const lastForwardedBySession = new Map();
126
195
  const pendingTelegram = new Map();
127
196
  const links = loadLinks();
@@ -167,44 +236,112 @@ const TelegramPlugin = async ({ client, directory }, options) => {
167
236
  }
168
237
  persistLinks(links);
169
238
  }
170
- async function sendToChats(chats, text) {
171
- if (!botReady)
239
+ function lruSet(map, key, value) {
240
+ if (map.has(key)) {
241
+ map.delete(key);
242
+ }
243
+ else if (map.size >= MAX_LRU_SIZE) {
244
+ const oldest = map.keys().next().value;
245
+ if (oldest !== undefined)
246
+ map.delete(oldest);
247
+ }
248
+ map.set(key, value);
249
+ }
250
+ function setPending(sessionId, chatId) {
251
+ const existing = pendingTelegram.get(sessionId);
252
+ if (existing)
253
+ clearTimeout(existing.timer);
254
+ const timer = setTimeout(() => {
255
+ pendingTelegram.delete(sessionId);
256
+ }, PENDING_TIMEOUT_MS);
257
+ pendingTelegram.set(sessionId, { chatId, timer });
258
+ }
259
+ function clearPending(sessionId) {
260
+ const entry = pendingTelegram.get(sessionId);
261
+ if (entry) {
262
+ clearTimeout(entry.timer);
263
+ pendingTelegram.delete(sessionId);
264
+ }
265
+ }
266
+ function scheduleReconnect() {
267
+ if (userStopped || !savedToken)
268
+ return;
269
+ if (reconnectTimer)
172
270
  return;
173
- const chunks = chunkText(text);
271
+ if (reconnectAttempts >= 10) {
272
+ debugLog("reconnect attempts exhausted; giving up until next event");
273
+ return;
274
+ }
275
+ const delayMs = RECONNECT_DELAYS[Math.min(reconnectAttempts, RECONNECT_DELAYS.length - 1)];
276
+ reconnectAttempts++;
277
+ debugLog("scheduling reconnect in", delayMs, "ms (attempt", reconnectAttempts + ")");
278
+ reconnectTimer = setTimeout(async () => {
279
+ reconnectTimer = null;
280
+ if (savedToken && !userStopped) {
281
+ await startBot(savedToken);
282
+ }
283
+ }, delayMs);
284
+ }
285
+ async function sendToChats(chats, text) {
286
+ if (!botReady || !bot)
287
+ return false;
288
+ const safeText = sanitizeUI(text, 4000);
289
+ const chunks = chunkText(safeText, 4000);
290
+ let sentAny = false;
174
291
  for (const chatId of chats) {
175
292
  for (const chunk of chunks) {
176
- try {
177
- await bot.telegram.sendMessage(chatId, chunk);
178
- }
179
- catch (err) {
180
- const msg = handlePluginError(err, "sendMessage");
181
- if (msg.includes("blocked") || msg.includes("invalid")) {
182
- botReady = false;
293
+ let attempt = 0;
294
+ while (attempt < 2) {
295
+ try {
296
+ await bot.telegram.sendMessage(chatId, chunk);
297
+ sentAny = true;
298
+ break;
183
299
  }
300
+ catch (err) {
301
+ attempt++;
302
+ const msg = handlePluginError(err, "sendMessage");
303
+ if (msg.includes("blocked")) {
304
+ removeLink(chatId);
305
+ break;
306
+ }
307
+ if (msg.includes("Invalid token")) {
308
+ botReady = false;
309
+ scheduleReconnect();
310
+ return sentAny;
311
+ }
312
+ if (attempt >= 2) {
313
+ debugLog("sendMessage gave up for chat", chatId, ":", msg);
314
+ }
315
+ else {
316
+ await delay(500);
317
+ }
318
+ }
319
+ }
320
+ if (chunks.length > MAX_TOKENS_PER_CHUNK) {
321
+ await delay(50);
184
322
  }
185
323
  }
186
324
  }
325
+ return sentAny;
187
326
  }
188
327
  async function sendToSession(sessionId, text) {
189
328
  const chats = sessionToChats.get(sessionId);
190
329
  if (!chats)
191
- return;
192
- await sendToChats(chats, text);
193
- }
194
- async function findSessionById(id, list) {
195
- const exact = list.find((s) => s.id === id);
196
- if (exact)
197
- return exact;
198
- const prefix = list.filter((s) => s.id.startsWith(id));
199
- if (prefix.length === 1)
200
- return prefix[0];
201
- return null;
330
+ return false;
331
+ return await sendToChats(chats, text) ?? false;
202
332
  }
203
333
  async function startBot(token) {
204
334
  if (botStarting)
205
335
  return;
336
+ if (!isValidToken(token)) {
337
+ debugLog("startBot: invalid token format");
338
+ return;
339
+ }
206
340
  botStarting = true;
207
341
  botReady = false;
342
+ userStopped = false;
343
+ savedToken = token;
344
+ reconnectAttempts = 0;
208
345
  try {
209
346
  try {
210
347
  bot?.stop();
@@ -214,144 +351,251 @@ const TelegramPlugin = async ({ client, directory }, options) => {
214
351
  bot.catch((err) => {
215
352
  handlePluginError(err, "bot.catch");
216
353
  });
354
+ bot.command("start", async (ctx) => {
355
+ if (allowedSet && (!ctx.from || !allowedSet.has(ctx.from.id)))
356
+ return;
357
+ try {
358
+ await ctx.reply("virtualcode - OpenCode Telegram bridge\n\n" +
359
+ "Quick setup:\n" +
360
+ "1. /ls - list your sessions\n" +
361
+ "2. /link <ID> - bind this chat\n" +
362
+ "3. Send any message to talk to OpenCode\n\n" +
363
+ "Type /help for all commands.");
364
+ }
365
+ catch { }
366
+ });
217
367
  bot.command("link", async (ctx) => {
218
- if (allowedSet && !allowedSet.has(ctx.from.id))
368
+ if (allowedSet && (!ctx.from || !allowedSet.has(ctx.from.id)))
219
369
  return;
220
- const arg = ctx.payload.trim();
370
+ const arg = ctx.payload.trim().slice(0, 200);
221
371
  if (!arg) {
222
- await ctx.reply("Usage: /link <sessionID>");
372
+ try {
373
+ await ctx.reply("Usage: /link <sessionID>");
374
+ }
375
+ catch { }
223
376
  return;
224
377
  }
225
378
  try {
226
379
  const list = await client.session.list();
227
380
  if (list.error || !list.data) {
228
- await ctx.reply("❌ Could not load sessions.");
381
+ try {
382
+ await ctx.reply("Could not load sessions.");
383
+ }
384
+ catch { }
229
385
  return;
230
386
  }
231
- const found = await findSessionById(arg, list.data);
232
- if (!found) {
233
- await ctx.reply("❌ Session not found.");
387
+ const sessions = safeList(list.data);
388
+ const result = findSessionById(arg, sessions);
389
+ if (result.status === "not_found") {
390
+ try {
391
+ await ctx.reply(arg.length < MIN_PREFIX_LEN ? "Prefix too short (min 4 chars)." : "Session not found.");
392
+ }
393
+ catch { }
394
+ return;
395
+ }
396
+ if (result.status === "ambiguous") {
397
+ try {
398
+ await ctx.reply("Multiple sessions match. Use the full ID from /ls.");
399
+ }
400
+ catch { }
234
401
  return;
235
402
  }
236
403
  const old = links[ctx.chat.id];
237
- addLink(ctx.chat.id, found.id);
404
+ addLink(ctx.chat.id, result.session.id);
238
405
  if (old) {
239
- await ctx.reply("Switched to " + fmtId(found.id) + " (from " + fmtId(old) + ")");
406
+ try {
407
+ await ctx.reply("Switched to " + fmtId(result.session.id) + " (from " + fmtId(old) + ")");
408
+ }
409
+ catch { }
240
410
  }
241
411
  else {
242
- await ctx.reply("Linked to " + (found.title || fmtId(found.id)));
412
+ try {
413
+ await ctx.reply("Linked to " + (result.session.title || fmtId(result.session.id)));
414
+ }
415
+ catch { }
243
416
  }
244
417
  }
245
418
  catch (err) {
246
419
  const msg = handlePluginError(err, "/link");
247
- await ctx.reply(sanitizeUI(msg));
420
+ try {
421
+ await ctx.reply(sanitizeUI(msg));
422
+ }
423
+ catch { }
248
424
  }
249
425
  });
250
426
  bot.command("unlink", async (ctx) => {
251
- if (allowedSet && !allowedSet.has(ctx.from.id))
427
+ if (allowedSet && (!ctx.from || !allowedSet.has(ctx.from.id)))
252
428
  return;
253
429
  if (!links[ctx.chat.id]) {
254
- await ctx.reply("Not linked.");
430
+ try {
431
+ await ctx.reply("Not linked.");
432
+ }
433
+ catch { }
255
434
  return;
256
435
  }
257
436
  removeLink(ctx.chat.id);
258
- await ctx.reply("Unlinked.");
437
+ try {
438
+ await ctx.reply("Unlinked.");
439
+ }
440
+ catch { }
259
441
  });
260
442
  bot.command("status", async (ctx) => {
261
- if (allowedSet && !allowedSet.has(ctx.from.id))
443
+ if (allowedSet && (!ctx.from || !allowedSet.has(ctx.from.id)))
262
444
  return;
263
445
  const sessionId = links[ctx.chat.id];
264
446
  if (!sessionId) {
265
- await ctx.reply("Not linked. Use /link <ID>");
447
+ try {
448
+ await ctx.reply("Not linked. Use /link <ID>");
449
+ }
450
+ catch { }
266
451
  return;
267
452
  }
268
- await ctx.reply("Connected | Session: " + fmtId(sessionId) + " | Project: " + (directory || "none"));
453
+ try {
454
+ await ctx.reply("Connected | Session: " + fmtId(sessionId) + " | Project: " + (directory || "none"));
455
+ }
456
+ catch { }
269
457
  });
270
458
  bot.command(["ls", "sessions"], async (ctx) => {
271
- if (allowedSet && !allowedSet.has(ctx.from.id))
459
+ if (allowedSet && (!ctx.from || !allowedSet.has(ctx.from.id)))
272
460
  return;
273
461
  try {
274
462
  const res = await client.session.list();
275
463
  if (res.error || !res.data) {
276
- await ctx.reply("❌ Could not load sessions.");
464
+ try {
465
+ await ctx.reply("Could not load sessions.");
466
+ }
467
+ catch { }
277
468
  return;
278
469
  }
279
470
  const current = links[ctx.chat.id];
280
- const lines = res.data.slice(-20).map((s, i, arr) => {
281
- const num = arr.length - i;
282
- const marker = s.id === current ? " \u2705" : "";
283
- const label = s.title || s.id.slice(0, 16);
284
- return `${num}. ${label} — ${s.id}${marker}`;
471
+ const sessions = safeList(res.data);
472
+ const recent = sessions.slice(-20);
473
+ const lines = recent.map((s, i) => {
474
+ const num = recent.length - i;
475
+ const marker = s.id === current ? " *" : "";
476
+ const label = (s.title || s.id.slice(0, 16)).slice(0, 60);
477
+ return num + ". " + label + " -- " + s.id + marker;
285
478
  });
286
- await ctx.reply("Sessions:\n" + (lines.length ? lines.join("\n") : "None"));
479
+ try {
480
+ await ctx.reply("Sessions:\n" + (lines.length ? lines.join("\n") : "None"));
481
+ }
482
+ catch { }
287
483
  }
288
484
  catch (err) {
289
485
  const msg = handlePluginError(err, "/ls");
290
- await ctx.reply(sanitizeUI(msg));
486
+ try {
487
+ await ctx.reply(sanitizeUI(msg));
488
+ }
489
+ catch { }
291
490
  }
292
491
  });
293
492
  bot.command("use", async (ctx) => {
294
- if (allowedSet && !allowedSet.has(ctx.from.id))
493
+ if (allowedSet && (!ctx.from || !allowedSet.has(ctx.from.id)))
295
494
  return;
296
- const arg = ctx.payload.trim();
495
+ const arg = ctx.payload.trim().slice(0, 200);
297
496
  if (!arg) {
298
- await ctx.reply("Usage: /use <number|ID>");
497
+ try {
498
+ await ctx.reply("Usage: /use <number|ID>");
499
+ }
500
+ catch { }
299
501
  return;
300
502
  }
301
503
  try {
302
504
  const res = await client.session.list();
303
505
  if (res.error || !res.data) {
304
- await ctx.reply("❌ Could not load sessions.");
506
+ try {
507
+ await ctx.reply("Could not load sessions.");
508
+ }
509
+ catch { }
305
510
  return;
306
511
  }
512
+ const sessions = safeList(res.data);
307
513
  const num = parseInt(arg);
514
+ if (!isNaN(num) && num > 0 && num <= sessions.length) {
515
+ const s = sessions[sessions.length - num];
516
+ addLink(ctx.chat.id, s.id);
517
+ try {
518
+ await ctx.reply("Switched to " + (s.title || fmtId(s.id)));
519
+ }
520
+ catch { }
521
+ return;
522
+ }
308
523
  if (!isNaN(num) && num > 0) {
309
- if (num > res.data.length) {
310
- await ctx.reply("Only " + res.data.length + " sessions available.");
311
- return;
524
+ try {
525
+ await ctx.reply("Only " + sessions.length + " sessions available.");
312
526
  }
313
- const s = res.data[res.data.length - num];
314
- if (!s)
315
- return;
316
- addLink(ctx.chat.id, s.id);
317
- await ctx.reply("Switched to " + (s.title || fmtId(s.id)));
527
+ catch { }
318
528
  return;
319
529
  }
320
- const found = await findSessionById(arg, res.data);
321
- if (!found) {
322
- await ctx.reply("❌ Session not found.");
530
+ const result = findSessionById(arg, sessions);
531
+ if (result.status === "not_found") {
532
+ try {
533
+ await ctx.reply(arg.length < MIN_PREFIX_LEN ? "Prefix too short (min 4 chars)." : "Session not found.");
534
+ }
535
+ catch { }
323
536
  return;
324
537
  }
325
- addLink(ctx.chat.id, found.id);
326
- await ctx.reply("Switched to " + (found.title || fmtId(found.id)));
538
+ if (result.status === "ambiguous") {
539
+ try {
540
+ await ctx.reply("Multiple sessions match. Use the full ID from /ls.");
541
+ }
542
+ catch { }
543
+ return;
544
+ }
545
+ addLink(ctx.chat.id, result.session.id);
546
+ try {
547
+ await ctx.reply("Switched to " + (result.session.title || fmtId(result.session.id)));
548
+ }
549
+ catch { }
327
550
  }
328
551
  catch (err) {
329
552
  const msg = handlePluginError(err, "/use");
330
- await ctx.reply(sanitizeUI(msg));
553
+ try {
554
+ await ctx.reply(sanitizeUI(msg));
555
+ }
556
+ catch { }
331
557
  }
332
558
  });
333
559
  bot.command("history", async (ctx) => {
334
- if (allowedSet && !allowedSet.has(ctx.from.id))
560
+ if (allowedSet && (!ctx.from || !allowedSet.has(ctx.from.id)))
335
561
  return;
336
562
  const sessionId = links[ctx.chat.id];
337
563
  if (!sessionId) {
338
- await ctx.reply("Not linked. Use /link <ID>");
564
+ try {
565
+ await ctx.reply("Not linked. Use /link <ID>");
566
+ }
567
+ catch { }
339
568
  return;
340
569
  }
341
570
  try {
342
- const limitText = ctx.payload.trim();
343
- const limit = limitText ? Math.min(parseInt(limitText) || 20, 100) : 20;
571
+ const limitText = ctx.payload.trim().slice(0, 10);
572
+ let limit = MIN_HISTORY_LIMIT;
573
+ if (limitText) {
574
+ const parsed = parseInt(limitText);
575
+ if (!isNaN(parsed) && parsed > 0) {
576
+ limit = Math.min(parsed, 100);
577
+ }
578
+ }
579
+ else {
580
+ limit = 20;
581
+ }
344
582
  const res = await client.session.messages({ path: { id: sessionId }, query: { directory, limit } });
345
583
  if (res.error || !res.data || res.data.length === 0) {
346
- await ctx.reply("No messages found.");
584
+ try {
585
+ await ctx.reply("No messages found.");
586
+ }
587
+ catch { }
347
588
  return;
348
589
  }
349
590
  const lines = [];
350
591
  for (const msg of res.data) {
351
- const role = msg.info.role === "user" ? "\uD83D\uDC64" : "\uD83E\uDD16";
352
- const text = msg.parts
353
- .filter((p) => p.type === "text" && !p.synthetic)
354
- .map((p) => p.text)
592
+ if (!msg || !msg.info)
593
+ continue;
594
+ const role = msg.info.role === "user" ? "[User]" : "[AI]";
595
+ const parts = Array.isArray(msg.parts) ? msg.parts : [];
596
+ const text = parts
597
+ .filter((p) => p && p.type === "text" && !p.synthetic)
598
+ .map((p) => String(p.text || ""))
355
599
  .join("\n")
356
600
  .trim();
357
601
  if (!text)
@@ -360,41 +604,70 @@ const TelegramPlugin = async ({ client, directory }, options) => {
360
604
  lines.push(role + " " + truncated);
361
605
  }
362
606
  if (lines.length === 0) {
363
- await ctx.reply("No text messages found.");
607
+ try {
608
+ await ctx.reply("No text messages found.");
609
+ }
610
+ catch { }
364
611
  return;
365
612
  }
366
613
  const text = lines.join("\n\n");
367
614
  for (const chunk of chunkText(text)) {
368
- await ctx.reply(chunk);
615
+ try {
616
+ await ctx.reply(chunk);
617
+ }
618
+ catch { }
369
619
  }
370
620
  }
371
621
  catch (err) {
372
622
  const msg = handlePluginError(err, "/history");
373
- await ctx.reply(sanitizeUI(msg));
623
+ try {
624
+ await ctx.reply(sanitizeUI(msg));
625
+ }
626
+ catch { }
374
627
  }
375
628
  });
376
629
  bot.command("help", async (ctx) => {
377
- if (allowedSet && !allowedSet.has(ctx.from.id))
630
+ if (allowedSet && (!ctx.from || !allowedSet.has(ctx.from.id)))
378
631
  return;
379
- await ctx.reply("/link <sessionId> - Bind this chat to a session (use ID from /ls)\n" +
380
- "/unlink - Remove binding\n" +
381
- "/status - Show connection state\n" +
382
- "/ls - List recent sessions (shows number + title + ID)\n" +
383
- "/use <number|sessionId> - Switch active session (number from /ls)\n" +
384
- "/history [N] - View last N messages\n" +
385
- "/help - Show this help\n\n" +
386
- "Any other message will be sent to the linked session.");
632
+ try {
633
+ await ctx.reply("/link <sessionId> - Bind this chat to a session\n" +
634
+ "/unlink - Remove binding\n" +
635
+ "/status - Show connection state\n" +
636
+ "/ls - List recent sessions\n" +
637
+ "/use <number|ID> - Switch active session\n" +
638
+ "/history [N] - View last N messages\n" +
639
+ "/help - Show this help\n\n" +
640
+ "Any other message will be sent to the linked session.");
641
+ }
642
+ catch { }
643
+ });
644
+ bot.on(["photo", "sticker", "document", "video", "audio", "voice"], async (ctx) => {
645
+ if (allowedSet && (!ctx.from || !allowedSet.has(ctx.from.id)))
646
+ return;
647
+ try {
648
+ await ctx.reply("Only text messages are supported.");
649
+ }
650
+ catch { }
387
651
  });
388
652
  bot.on("text", async (ctx) => {
389
- if (allowedSet && !allowedSet.has(ctx.from.id))
653
+ if (allowedSet && (!ctx.from || !allowedSet.has(ctx.from.id)))
390
654
  return;
391
655
  const sessionId = links[ctx.chat.id];
392
656
  if (!sessionId) {
393
- await ctx.reply("Not linked. Use /link <ID>");
657
+ try {
658
+ await ctx.reply("Not linked. Use /link <ID>");
659
+ }
660
+ catch { }
394
661
  return;
395
662
  }
396
- const working = await ctx.reply("\u23F3 ...");
397
- pendingTelegram.set(sessionId, ctx.chat.id);
663
+ let working = null;
664
+ try {
665
+ working = await ctx.reply("...");
666
+ }
667
+ catch (err) {
668
+ debugLog("working reply failed:", userError(err));
669
+ }
670
+ setPending(sessionId, ctx.chat.id);
398
671
  try {
399
672
  const res = await client.session.prompt({
400
673
  path: { id: sessionId },
@@ -403,24 +676,32 @@ const TelegramPlugin = async ({ client, directory }, options) => {
403
676
  },
404
677
  query: { directory },
405
678
  });
406
- if (res.error) {
679
+ if (res?.error) {
407
680
  const msg = handlePluginError(res.error, "prompt");
408
- await ctx.reply(sanitizeUI(msg));
681
+ try {
682
+ await ctx.reply(sanitizeUI(msg));
683
+ }
684
+ catch { }
409
685
  }
410
686
  }
411
687
  catch (err) {
412
688
  const msg = handlePluginError(err, "prompt");
413
- await ctx.reply(sanitizeUI(msg));
689
+ try {
690
+ await ctx.reply(sanitizeUI(msg));
691
+ }
692
+ catch { }
414
693
  }
415
- pendingTelegram.delete(sessionId);
416
- try {
417
- await ctx.deleteMessage(working.message_id);
694
+ clearPending(sessionId);
695
+ if (working?.message_id) {
696
+ try {
697
+ await ctx.deleteMessage(working.message_id);
698
+ }
699
+ catch { }
418
700
  }
419
- catch { }
420
701
  });
421
702
  if (notifyOnReconnect) {
422
703
  for (const chatId of Object.keys(links)) {
423
- bot.telegram.sendMessage(Number(chatId), "OpenCode Telegram bridge reconnected.").catch(() => { });
704
+ bot.telegram.sendMessage(Number(chatId), "Telegram bridge reconnected.").catch(() => { });
424
705
  }
425
706
  }
426
707
  try {
@@ -431,19 +712,26 @@ const TelegramPlugin = async ({ client, directory }, options) => {
431
712
  debugLog("startBot failed:", msg);
432
713
  botStarting = false;
433
714
  botReady = false;
715
+ scheduleReconnect();
434
716
  return;
435
717
  }
436
718
  botReady = true;
437
719
  botStarting = false;
438
- bot.launch().catch((err) => {
720
+ bot.launch().catch(async (err) => {
439
721
  handlePluginError(err, "bot.launch");
440
722
  botReady = false;
723
+ try {
724
+ await bot?.stop();
725
+ }
726
+ catch { }
727
+ scheduleReconnect();
441
728
  });
442
729
  }
443
730
  catch (err) {
444
731
  handlePluginError(err, "startBot");
445
732
  botStarting = false;
446
733
  botReady = false;
734
+ scheduleReconnect();
447
735
  }
448
736
  }
449
737
  const configToken = config?.token;
@@ -470,7 +758,7 @@ const TelegramPlugin = async ({ client, directory }, options) => {
470
758
  if (event.type === "session.status" && event.properties.status.type === "idle") {
471
759
  const sid = event.properties.sessionID;
472
760
  const pending = pendingTelegram.get(sid);
473
- const chats = pending ? new Set([pending]) : sessionToChats.get(sid);
761
+ const chats = pending ? new Set([pending.chatId]) : sessionToChats.get(sid);
474
762
  if (!chats || chats.size === 0)
475
763
  return;
476
764
  let msgs;
@@ -485,7 +773,7 @@ const TelegramPlugin = async ({ client, directory }, options) => {
485
773
  const last = [...msgs.data].reverse().find((m) => m.info.role === "assistant");
486
774
  if (!last || lastForwardedBySession.get(sid) === last.info.id)
487
775
  return;
488
- lastForwardedBySession.set(sid, last.info.id);
776
+ lruSet(lastForwardedBySession, sid, last.info.id);
489
777
  const text = last.parts
490
778
  .filter((p) => p.type === "text" && !p.synthetic)
491
779
  .map((p) => p.text)
@@ -509,13 +797,24 @@ const TelegramPlugin = async ({ client, directory }, options) => {
509
797
  await startBot(saved);
510
798
  }
511
799
  }
800
+ if (!output || !Array.isArray(output.parts))
801
+ return;
512
802
  for (const part of output.parts) {
513
- if (part.type !== "text")
803
+ if (!part || part.type !== "text" || typeof part.text !== "string")
514
804
  continue;
515
- const text = part.text;
805
+ let text = part.text;
806
+ if (text.length > MAX_TELEGRAM_INPUT) {
807
+ text = text.slice(0, MAX_TELEGRAM_INPUT);
808
+ }
516
809
  if (text.startsWith("/telegram")) {
517
- const args = text.slice("/telegram".length).trim().toLowerCase();
518
- if (args === "disconnect" || args === "stop") {
810
+ const rawArgs = text.slice("/telegram".length).trim().slice(0, 200);
811
+ const cmd = rawArgs.toLowerCase();
812
+ if (cmd === "disconnect" || cmd === "stop") {
813
+ userStopped = true;
814
+ if (reconnectTimer) {
815
+ clearTimeout(reconnectTimer);
816
+ reconnectTimer = null;
817
+ }
519
818
  try {
520
819
  bot?.stop();
521
820
  }
@@ -523,33 +822,41 @@ const TelegramPlugin = async ({ client, directory }, options) => {
523
822
  botReady = false;
524
823
  bot = null;
525
824
  botStarting = false;
526
- if (loadSavedToken())
527
- saveToken("");
825
+ savedToken = null;
826
+ clearSavedToken();
528
827
  part.text = "Telegram bot disconnected and token removed.";
529
828
  return;
530
829
  }
531
- if (args === "status" || args === "") {
830
+ if (cmd === "status" || cmd === "") {
532
831
  if (botReady) {
533
832
  part.text =
534
833
  "Telegram bot is connected.\n" +
535
- " /telegram <new_token> Change bot token\n" +
536
- " /telegram disconnect Stop bot and remove token\n" +
537
- " /telegram Open setup dialog (type /telegram in command palette)";
834
+ "- /telegram <new_token> - Change bot token\n" +
835
+ "- /telegram disconnect - Stop bot and remove token\n" +
836
+ "- /telegram - Open setup dialog (type /telegram in command palette)";
538
837
  }
539
838
  else {
540
839
  part.text =
541
840
  "Telegram bot is not connected.\n" +
542
- " /telegram <your_bot_token> Connect with a token\n" +
543
- " /telegram Open setup dialog (type /telegram in command palette)";
841
+ "- /telegram <your_bot_token> - Connect with a token\n" +
842
+ "- /telegram - Open setup dialog (type /telegram in command palette)";
544
843
  }
844
+ part.text = sanitizeTUI(part.text, MAX_TUI_TEXT);
845
+ return;
846
+ }
847
+ const tokenMatch = rawArgs.match(/^\s*["'`]*([^\s"'`]+)["'`]*\s*$/);
848
+ if (!tokenMatch) {
849
+ part.text = sanitizeTUI("Invalid token.", MAX_TUI_TEXT);
850
+ return;
851
+ }
852
+ const token = tokenMatch[1];
853
+ if (!isValidToken(token)) {
854
+ part.text = sanitizeTUI("Invalid token.", MAX_TUI_TEXT);
545
855
  return;
546
856
  }
547
- const token = args;
548
857
  saveToken(token);
549
858
  await startBot(token);
550
- part.text = botReady
551
- ? "\u2705 Connected."
552
- : "\u274c Invalid token.";
859
+ part.text = sanitizeTUI(botReady ? "Connected." : "Invalid token.", MAX_TUI_TEXT);
553
860
  return;
554
861
  }
555
862
  }
@@ -566,20 +873,44 @@ const TelegramPlugin = async ({ client, directory }, options) => {
566
873
  sessionId: tool.schema.string().optional().describe("Target session ID (defaults to current)"),
567
874
  },
568
875
  async execute({ text, sessionId }, ctx) {
876
+ if (typeof text !== "string" || text.length === 0) {
877
+ return { output: "No text to send." };
878
+ }
879
+ if (!botReady) {
880
+ return { output: "Telegram bot is not connected." };
881
+ }
882
+ if (!ctx?.sessionID && !sessionId) {
883
+ return { output: "No session context." };
884
+ }
569
885
  const targetId = sessionId || ctx.sessionID;
570
- await sendToSession(targetId, text);
571
- return { output: "Message sent to Telegram" };
886
+ if (typeof targetId !== "string") {
887
+ return { output: "Invalid session ID." };
888
+ }
889
+ const safeText = text.length > 4000 ? text.slice(0, 4000) + "..." : text;
890
+ const ok = await sendToSession(targetId, safeText);
891
+ return { output: ok ? "Message sent to Telegram" : "No linked chats." };
572
892
  },
573
893
  }),
574
894
  },
575
895
  async dispose() {
896
+ userStopped = true;
897
+ if (reconnectTimer) {
898
+ clearTimeout(reconnectTimer);
899
+ reconnectTimer = null;
900
+ }
901
+ for (const [, entry] of pendingTelegram) {
902
+ clearTimeout(entry.timer);
903
+ }
904
+ pendingTelegram.clear();
905
+ lastForwardedBySession.clear();
576
906
  try {
577
- bot?.stop();
907
+ await bot?.stop();
578
908
  }
579
909
  catch { }
580
910
  botReady = false;
581
911
  botStarting = false;
582
912
  bot = null;
913
+ savedToken = null;
583
914
  },
584
915
  };
585
916
  };