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.
- package/.claude-plugin/plugin.json +1 -1
- package/lib/db.js +1 -1
- package/lib/media-group-buffer.js +73 -0
- package/lib/process-manager.js +10 -1
- package/package.json +1 -1
- package/polygram.js +90 -44
|
@@ -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.
|
|
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
|
@@ -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 };
|
package/lib/process-manager.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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:
|
|
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
|
-
|
|
1476
|
-
const threadId =
|
|
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,
|
|
1457
|
+
await handleMessage(sessionKey, chatId, msg, bot);
|
|
1479
1458
|
return;
|
|
1480
1459
|
}
|
|
1481
1460
|
|
|
1482
|
-
if (!shouldHandle(
|
|
1461
|
+
if (!shouldHandle(msg, chatConfig, botUsername)) return;
|
|
1483
1462
|
|
|
1484
1463
|
if (botUsername) {
|
|
1485
|
-
|
|
1464
|
+
msg.text = cleanText;
|
|
1486
1465
|
}
|
|
1487
1466
|
|
|
1488
|
-
const threadId =
|
|
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
|
|
1537
|
+
await dispatchRegularMessage(ctx.message);
|
|
1492
1538
|
});
|
|
1493
1539
|
|
|
1494
1540
|
bot.on('callback_query:data', async (ctx) => {
|