polygram 0.4.0 → 0.4.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "polygram",
4
- "version": "0.4.0",
4
+ "version": "0.4.2",
5
5
  "description": "Telegram integration for Claude Code that preserves the OpenClaw per-chat session model. Migration target for OpenClaw users. Multi-bot, multi-chat, per-topic isolation; SQLite transcripts; inline-keyboard approvals. Bundles /polygram:status|logs|pair-code|approvals admin commands and a history skill.",
6
6
  "keywords": [
7
7
  "telegram",
package/lib/db.js CHANGED
@@ -8,7 +8,7 @@ const fs = require('fs');
8
8
  const path = require('path');
9
9
  const Database = require('better-sqlite3');
10
10
 
11
- const SCHEMA_VERSION = 4;
11
+ const SCHEMA_VERSION = 5;
12
12
 
13
13
  function open(dbPath) {
14
14
  const db = new Database(dbPath);
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Buffer Telegram messages that share a `media_group_id` so they can be
3
+ * dispatched as ONE logical turn to Claude.
4
+ *
5
+ * Why: when a user uploads N photos "in one message," Telegram delivers N
6
+ * distinct Message updates (one per photo, all tagged with the same
7
+ * `media_group_id`). Without this buffer, polygram sees each photo as a
8
+ * separate turn — Claude answers the first and the others either queue
9
+ * behind it (consuming warm-process capacity) or fire their own turns
10
+ * with no text.
11
+ *
12
+ * Pattern (matches OpenClaw's `MEDIA_GROUP_TIMEOUT_MS`):
13
+ * - Messages arriving faster than `flushMs` apart stay in the same
14
+ * group; timer resets on each arrival.
15
+ * - Group flushes `flushMs` after the LAST sibling arrives.
16
+ * - Stragglers arriving after a flush create a new group (new turn).
17
+ * Telegram usually ships all siblings within ~100ms, so 500ms of
18
+ * headroom catches virtually everything.
19
+ *
20
+ * I/O-pure: accepts `timerFn`/`clearTimerFn` for test injection.
21
+ */
22
+
23
+ const DEFAULT_FLUSH_MS = 500;
24
+
25
+ function createMediaGroupBuffer({
26
+ flushMs = DEFAULT_FLUSH_MS,
27
+ onFlush,
28
+ timerFn = setTimeout,
29
+ clearTimerFn = clearTimeout,
30
+ } = {}) {
31
+ if (typeof onFlush !== 'function') throw new Error('onFlush required');
32
+ const entries = new Map(); // key → { messages, timer }
33
+
34
+ const flushKey = (key) => {
35
+ const entry = entries.get(key);
36
+ if (!entry) return;
37
+ entries.delete(key);
38
+ // Defensive: onFlush errors must not break future group buffering.
39
+ try { onFlush(entry.messages, key); }
40
+ catch { /* caller can log if it cares */ }
41
+ };
42
+
43
+ const add = (key, msg) => {
44
+ let entry = entries.get(key);
45
+ if (!entry) {
46
+ entry = { messages: [], timer: null };
47
+ entries.set(key, entry);
48
+ }
49
+ entry.messages.push(msg);
50
+ if (entry.timer) clearTimerFn(entry.timer);
51
+ const t = timerFn(() => flushKey(key), flushMs);
52
+ // Don't keep the node event loop alive waiting for a buffered group
53
+ // that never grew further — especially in tests.
54
+ t?.unref?.();
55
+ entry.timer = t;
56
+ };
57
+
58
+ const flushAll = () => {
59
+ for (const key of Array.from(entries.keys())) {
60
+ const entry = entries.get(key);
61
+ if (entry?.timer) clearTimerFn(entry.timer);
62
+ flushKey(key);
63
+ }
64
+ };
65
+
66
+ return {
67
+ add,
68
+ flushAll,
69
+ get size() { return entries.size; },
70
+ };
71
+ }
72
+
73
+ module.exports = { createMediaGroupBuffer, DEFAULT_FLUSH_MS };
@@ -23,6 +23,15 @@ const DEFAULT_KILL_TIMEOUT_MS = 3000;
23
23
  * (they count as Claude activity) but are NOT rendered to Telegram.
24
24
  * Streaming every tool call to chat produces a noisy "_Calling X_"
25
25
  * ladder that adds no information users can act on.
26
+ *
27
+ * Trailing-colon normalisation: Claude writes preambles like "Checking
28
+ * this:" followed by a tool_use. Because we hide tool_use in the stream,
29
+ * the colon becomes an orphan pointing at invisible work. Replace a
30
+ * trailing `:` with `…` — the ellipsis reads as "doing it now" and
31
+ * preserves the natural flow. Only the LAST colon in the joined text is
32
+ * touched; mid-sentence colons ("Here's the plan: step 1, step 2")
33
+ * stay intact. Also guards against `::` sequences (code / emoticons) by
34
+ * requiring the preceding char to not also be `:`.
26
35
  */
27
36
  function extractAssistantText(event) {
28
37
  const blocks = event?.message?.content;
@@ -34,7 +43,7 @@ function extractAssistantText(event) {
34
43
  parts.push(b.text);
35
44
  }
36
45
  }
37
- return parts.join('\n\n').trim();
46
+ return parts.join('\n\n').trim().replace(/([^:]):\s*$/, '$1…');
38
47
  }
39
48
 
40
49
  class ProcessManager {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Telegram daemon for Claude Code that preserves the OpenClaw per-chat session model. Migration path for OpenClaw users moving to Claude Code.",
5
5
  "main": "lib/ipc-client.js",
6
6
  "bin": {
package/polygram.js CHANGED
@@ -35,6 +35,7 @@ const { createStreamer } = require('./lib/stream-reply');
35
35
  const { isAbortRequest } = require('./lib/abort-detector');
36
36
  const { startTyping } = require('./lib/typing-indicator');
37
37
  const { createReactionManager, classifyToolName } = require('./lib/status-reactions');
38
+ const { createMediaGroupBuffer } = require('./lib/media-group-buffer');
38
39
  const {
39
40
  createStore: createApprovalsStore,
40
41
  matchesAnyPattern: matchesApprovalPattern,
@@ -230,6 +231,12 @@ function sanitizeFilename(name) {
230
231
  }
231
232
 
232
233
  function extractAttachments(msg) {
234
+ // Media-group bundling path: when we synthesised a single message from
235
+ // several siblings sharing a media_group_id, the merged attachment list
236
+ // was pre-computed in `_mergedAttachments`. Return it directly instead
237
+ // of running the per-field extraction against the primary message.
238
+ if (Array.isArray(msg._mergedAttachments)) return msg._mergedAttachments;
239
+
233
240
  const items = [];
234
241
  if (msg.document) {
235
242
  const d = msg.document;
@@ -1400,42 +1407,14 @@ function createBot(token) {
1400
1407
  return newChat;
1401
1408
  }
1402
1409
 
1403
- bot.on('message', async (ctx) => {
1404
- if (!isWellFormedMessage(ctx.message)) {
1405
- dbWrite(() => db.logEvent('malformed-update', {
1406
- bot: BOT_NAME,
1407
- update_id: ctx.update?.update_id,
1408
- reason: 'missing chat.id / message_id',
1409
- }), 'log malformed-update');
1410
- return;
1411
- }
1412
- const chatId = ctx.chat.id.toString();
1413
- let chatConfig = config.chats[chatId];
1414
-
1415
- // Auto-onboarding: /pair <CODE> from an unconfigured private chat.
1416
- // Without this, the !chatConfig drop below would silently eat pair
1417
- // claims from DMs the operator hasn't pre-listed — defeating the
1418
- // whole point of pair codes (which exist to grant access without
1419
- // pre-configuration). Group chats are not auto-onboarded: they must
1420
- // still be added to config.json by the operator, because adding a
1421
- // group can affect multiple users.
1422
- if (!chatConfig && ctx.chat.type === 'private') {
1423
- const probe = (ctx.message.text || '').trim();
1424
- const pairMatch = /^\/pair(?:@\S+)?\s+(\S+)\s*$/.exec(probe);
1425
- if (pairMatch) {
1426
- chatConfig = await onboardPairedChat(ctx, pairMatch[1]);
1427
- if (!chatConfig) return;
1428
- recordInbound(ctx.message);
1429
- return;
1430
- }
1431
- }
1410
+ // Shared post-validation dispatch. Called directly for single messages
1411
+ // and for the synthesised "primary" of a media-group bundle.
1412
+ const dispatchRegularMessage = async (msg) => {
1413
+ const chatId = msg.chat.id.toString();
1414
+ const chatConfig = config.chats[chatId];
1432
1415
  if (!chatConfig) return;
1433
1416
 
1434
- // Record every inbound msg, even unaddressed ones — needed for reply-to
1435
- // lookups and the transcript skill.
1436
- recordInbound(ctx.message);
1437
-
1438
- const rawText = ctx.message.text || '';
1417
+ const rawText = msg.text || '';
1439
1418
  const cleanText = mentionRe ? rawText.replace(mentionRe, '').trim() : rawText.trim();
1440
1419
 
1441
1420
  // Abort: skip the queue entirely. Matches bilingual natural-language
@@ -1445,13 +1424,13 @@ function createBot(token) {
1445
1424
  // the user sees the bot heard them — silent abort is worse than
1446
1425
  // acknowledged abort.
1447
1426
  if (isAbortRequest(cleanText)) {
1448
- const threadId = ctx.message.message_thread_id?.toString();
1427
+ const threadId = msg.message_thread_id?.toString();
1449
1428
  const sessionKey = getSessionKey(chatId, threadId, chatConfig);
1450
1429
  const hadActive = pm.has(sessionKey) && !!pm.get(sessionKey)?.inFlight;
1451
1430
  const dropped = drainQueuesForChat(chatId);
1452
1431
  await pm.killChat(chatId).catch(() => {});
1453
1432
  dbWrite(() => db.logEvent('abort-requested', {
1454
- chat_id: chatId, user_id: ctx.message.from?.id || null,
1433
+ chat_id: chatId, user_id: msg.from?.id || null,
1455
1434
  had_active: hadActive, queued_dropped: dropped,
1456
1435
  trigger: cleanText.slice(0, 40),
1457
1436
  }), 'log abort-requested');
@@ -1461,7 +1440,7 @@ function createBot(token) {
1461
1440
  try {
1462
1441
  await tg(bot, 'sendMessage', {
1463
1442
  chat_id: chatId, text: reply,
1464
- reply_parameters: { message_id: ctx.message.message_id, allow_sending_without_reply: true },
1443
+ reply_parameters: { message_id: msg.message_id, allow_sending_without_reply: true },
1465
1444
  ...(threadId && { message_thread_id: threadId }),
1466
1445
  }, { source: 'abort-ack', botName: BOT_NAME });
1467
1446
  } catch {}
@@ -1472,23 +1451,90 @@ function createBot(token) {
1472
1451
  const isAdminCmd = botAllowsCommands && ADMIN_CMD_RE.test(cleanText);
1473
1452
  const isPairClaim = PAIR_CLAIM_RE.test(cleanText);
1474
1453
  if (isAdminCmd || isPairClaim) {
1475
- ctx.message.text = cleanText;
1476
- const threadId = ctx.message.message_thread_id?.toString();
1454
+ msg.text = cleanText;
1455
+ const threadId = msg.message_thread_id?.toString();
1477
1456
  const sessionKey = getSessionKey(chatId, threadId, chatConfig);
1478
- await handleMessage(sessionKey, chatId, ctx.message, bot);
1457
+ await handleMessage(sessionKey, chatId, msg, bot);
1479
1458
  return;
1480
1459
  }
1481
1460
 
1482
- if (!shouldHandle(ctx.message, chatConfig, botUsername)) return;
1461
+ if (!shouldHandle(msg, chatConfig, botUsername)) return;
1483
1462
 
1484
1463
  if (botUsername) {
1485
- ctx.message.text = cleanText;
1464
+ msg.text = cleanText;
1486
1465
  }
1487
1466
 
1488
- const threadId = ctx.message.message_thread_id?.toString();
1467
+ const threadId = msg.message_thread_id?.toString();
1489
1468
  const sessionKey = getSessionKey(chatId, threadId, chatConfig);
1469
+ await enqueue(sessionKey, chatId, msg, bot);
1470
+ };
1471
+
1472
+ // Media-group buffer: coalesce multi-photo uploads (Telegram delivers
1473
+ // each attachment as a separate Message sharing a `media_group_id`) into
1474
+ // a single synthetic turn with all attachments merged. Timer resets on
1475
+ // every new sibling, so as long as messages arrive faster than the
1476
+ // DEFAULT_FLUSH_MS window apart they stay in the same bundle.
1477
+ const mediaBuffer = createMediaGroupBuffer({
1478
+ onFlush: (messages) => {
1479
+ if (!messages || messages.length === 0) return;
1480
+ // Primary = the (usually first) message with text/caption; that's
1481
+ // where the user's actual prompt lives. Fall back to index 0 for
1482
+ // all-media-no-text groups.
1483
+ const primary = messages.find((m) => m.text || m.caption) || messages[0];
1484
+ const merged = messages.flatMap((m) => extractAttachments(m));
1485
+ const synthetic = { ...primary, _mergedAttachments: merged };
1486
+ // Carry the primary's text verbatim (dispatchRegularMessage re-cleans
1487
+ // the mention). Caption → text so downstream sees it uniformly.
1488
+ if (!synthetic.text && synthetic.caption) synthetic.text = synthetic.caption;
1489
+ dispatchRegularMessage(synthetic).catch((err) =>
1490
+ console.error(`[${BOT_NAME}] media-group dispatch error: ${err.message}`));
1491
+ },
1492
+ });
1493
+
1494
+ bot.on('message', async (ctx) => {
1495
+ if (!isWellFormedMessage(ctx.message)) {
1496
+ dbWrite(() => db.logEvent('malformed-update', {
1497
+ bot: BOT_NAME,
1498
+ update_id: ctx.update?.update_id,
1499
+ reason: 'missing chat.id / message_id',
1500
+ }), 'log malformed-update');
1501
+ return;
1502
+ }
1503
+ const chatId = ctx.chat.id.toString();
1504
+ let chatConfig = config.chats[chatId];
1505
+
1506
+ // Auto-onboarding: /pair <CODE> from an unconfigured private chat.
1507
+ // Without this, the !chatConfig drop below would silently eat pair
1508
+ // claims from DMs the operator hasn't pre-listed — defeating the
1509
+ // whole point of pair codes (which exist to grant access without
1510
+ // pre-configuration). Group chats are not auto-onboarded: they must
1511
+ // still be added to config.json by the operator, because adding a
1512
+ // group can affect multiple users.
1513
+ if (!chatConfig && ctx.chat.type === 'private') {
1514
+ const probe = (ctx.message.text || '').trim();
1515
+ const pairMatch = /^\/pair(?:@\S+)?\s+(\S+)\s*$/.exec(probe);
1516
+ if (pairMatch) {
1517
+ chatConfig = await onboardPairedChat(ctx, pairMatch[1]);
1518
+ if (!chatConfig) return;
1519
+ recordInbound(ctx.message);
1520
+ return;
1521
+ }
1522
+ }
1523
+ if (!chatConfig) return;
1524
+
1525
+ // Record every inbound msg, even unaddressed ones — needed for reply-to
1526
+ // lookups and the transcript skill.
1527
+ recordInbound(ctx.message);
1528
+
1529
+ // Multi-photo / album upload: Telegram delivers siblings as separate
1530
+ // Messages sharing a media_group_id. Stash each and let the buffer
1531
+ // dispatch them together 500ms after the last sibling arrives.
1532
+ if (ctx.message.media_group_id) {
1533
+ mediaBuffer.add(`${chatId}:${ctx.message.media_group_id}`, ctx.message);
1534
+ return;
1535
+ }
1490
1536
 
1491
- await enqueue(sessionKey, chatId, ctx.message, bot);
1537
+ await dispatchRegularMessage(ctx.message);
1492
1538
  });
1493
1539
 
1494
1540
  bot.on('callback_query:data', async (ctx) => {