virtualcode 2.0.0 → 2.0.2

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