polygram 0.6.16 → 0.7.0
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/README.md +1 -1
- package/lib/announces.js +70 -0
- package/lib/deliver.js +69 -0
- package/lib/net-errors.js +52 -3
- package/lib/process-manager.js +30 -6
- package/lib/sent-cache.js +71 -0
- package/lib/stream-reply.js +127 -18
- package/lib/telegram-chunk.js +278 -0
- package/lib/telegram-format.js +107 -1
- package/lib/telegram.js +134 -39
- package/package.json +1 -1
- package/polygram.js +143 -46
package/polygram.js
CHANGED
|
@@ -32,6 +32,9 @@ const { parseBotArg, parseDbArg, filterConfigToBot } = require('./lib/config-sco
|
|
|
32
32
|
const { createStore: createPairingsStore, parseTtl: parsePairingTtl } = require('./lib/pairings');
|
|
33
33
|
const { transcribe: transcribeVoice, isVoiceAttachment } = require('./lib/voice');
|
|
34
34
|
const { createStreamer } = require('./lib/stream-reply');
|
|
35
|
+
const { chunkMarkdownText } = require('./lib/telegram-chunk');
|
|
36
|
+
const { deliverReplies } = require('./lib/deliver');
|
|
37
|
+
const { announce, shouldAnnounce } = require('./lib/announces');
|
|
35
38
|
const { isAbortRequest } = require('./lib/abort-detector');
|
|
36
39
|
const { startTyping } = require('./lib/typing-indicator');
|
|
37
40
|
const { redactBotToken } = require('./lib/net-errors');
|
|
@@ -909,22 +912,6 @@ function parseResponse(text) {
|
|
|
909
912
|
return { text: trimmed, sticker: null, stickerLabel: null, reaction: null };
|
|
910
913
|
}
|
|
911
914
|
|
|
912
|
-
// ─── Reply chunking ─────────────────────────────────────────────────
|
|
913
|
-
|
|
914
|
-
function chunkText(text, maxLen = TG_MAX_LEN) {
|
|
915
|
-
if (text.length <= maxLen) return [text];
|
|
916
|
-
const chunks = [];
|
|
917
|
-
let remaining = text;
|
|
918
|
-
while (remaining.length > 0) {
|
|
919
|
-
if (remaining.length <= maxLen) { chunks.push(remaining); break; }
|
|
920
|
-
let splitAt = remaining.lastIndexOf('\n', maxLen);
|
|
921
|
-
if (splitAt < maxLen * 0.3) splitAt = maxLen;
|
|
922
|
-
chunks.push(remaining.slice(0, splitAt));
|
|
923
|
-
remaining = remaining.slice(splitAt).replace(/^\n/, '');
|
|
924
|
-
}
|
|
925
|
-
return chunks;
|
|
926
|
-
}
|
|
927
|
-
|
|
928
915
|
// ─── Cron/IPC send ─────────────────────────────────────────────────
|
|
929
916
|
|
|
930
917
|
// Allowlist of Telegram Bot API methods external callers (cron) may invoke.
|
|
@@ -1663,11 +1650,18 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1663
1650
|
});
|
|
1664
1651
|
|
|
1665
1652
|
const botCfg = config.bot || {};
|
|
1653
|
+
// 0.7.0: per-chat / per-bot link-preview opt-out (port from OpenClaw).
|
|
1654
|
+
// chat-level wins over bot-level. Default (both undefined) preserves
|
|
1655
|
+
// Telegram's native auto-preview behavior.
|
|
1656
|
+
const linkPreview = chatConfig.linkPreview != null
|
|
1657
|
+
? chatConfig.linkPreview
|
|
1658
|
+
: botCfg.linkPreview;
|
|
1666
1659
|
const outMetaBase = {
|
|
1667
1660
|
source: 'bot-reply-stream',
|
|
1668
1661
|
botName: BOT_NAME,
|
|
1669
1662
|
model: chatConfig.model,
|
|
1670
1663
|
effort: chatConfig.effort,
|
|
1664
|
+
...(linkPreview === false ? { linkPreview: false } : {}),
|
|
1671
1665
|
};
|
|
1672
1666
|
|
|
1673
1667
|
// Streaming is unconditional as of 0.4.0 — matches OpenClaw's model and
|
|
@@ -1710,6 +1704,21 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1710
1704
|
throw err;
|
|
1711
1705
|
}
|
|
1712
1706
|
},
|
|
1707
|
+
deleteMessage: async (messageId) => {
|
|
1708
|
+
// 0.7.0: route preview-discard through tg() so the action is
|
|
1709
|
+
// visible in the events log and gets the same retry/network
|
|
1710
|
+
// protections as other API calls. Failure is non-fatal: stale
|
|
1711
|
+
// bubble at chat top is acceptable when the actual reply has
|
|
1712
|
+
// already been redelivered as fresh chunks.
|
|
1713
|
+
try {
|
|
1714
|
+
await tg(bot, 'deleteMessage', {
|
|
1715
|
+
chat_id: chatId, message_id: messageId,
|
|
1716
|
+
}, { source: 'bot-reply-stream-discard', botName: BOT_NAME });
|
|
1717
|
+
} catch (err) {
|
|
1718
|
+
console.error(`[${label}] discard preview failed: ${err.message}`);
|
|
1719
|
+
throw err;
|
|
1720
|
+
}
|
|
1721
|
+
},
|
|
1713
1722
|
minChars: botCfg.streamMinChars,
|
|
1714
1723
|
throttleMs: botCfg.streamThrottleMs,
|
|
1715
1724
|
logger: { error: (m) => console.error(`[${label}] ${m}`) },
|
|
@@ -1782,34 +1791,89 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1782
1791
|
reactor.clear().catch(() => {});
|
|
1783
1792
|
}
|
|
1784
1793
|
|
|
1785
|
-
|
|
1794
|
+
// 0.7.0: empty-response fallback (port from OpenClaw —
|
|
1795
|
+
// EMPTY_RESPONSE_FALLBACK at reply-CdjLMJxg.js:40323). When
|
|
1796
|
+
// Claude finishes WITHOUT producing any text (e.g. only tool
|
|
1797
|
+
// calls, or aborted before writing the assistant message), send
|
|
1798
|
+
// a placeholder so the user doesn't see silence with no reaction.
|
|
1799
|
+
// NO_REPLY is an explicit "stay silent" signal from the agent —
|
|
1800
|
+
// those still markReplied silently.
|
|
1801
|
+
if (result.text === 'NO_REPLY') { markReplied(); return; }
|
|
1802
|
+
if (!result.text) {
|
|
1803
|
+
try {
|
|
1804
|
+
await tg(bot, 'sendMessage', {
|
|
1805
|
+
chat_id: chatId,
|
|
1806
|
+
text: 'No response generated. Please try again.',
|
|
1807
|
+
...(threadId && { message_thread_id: threadId }),
|
|
1808
|
+
reply_parameters: { message_id: msg.message_id, allow_sending_without_reply: true },
|
|
1809
|
+
}, { ...outMetaBase, source: 'empty-response-fallback' });
|
|
1810
|
+
} catch (err) {
|
|
1811
|
+
console.error(`[${label}] empty-response fallback send failed: ${err.message}`);
|
|
1812
|
+
}
|
|
1813
|
+
logEvent('telegram-empty-response-fallback', {
|
|
1814
|
+
chat_id: chatId, msg_id: msg.message_id, bot: BOT_NAME,
|
|
1815
|
+
});
|
|
1816
|
+
markReplied();
|
|
1817
|
+
return;
|
|
1818
|
+
}
|
|
1786
1819
|
|
|
1787
1820
|
const parsed = parseResponse(result.text);
|
|
1788
1821
|
const outMeta = { ...outMetaBase, sessionId: result.sessionId, costUsd: result.cost };
|
|
1789
1822
|
|
|
1790
|
-
//
|
|
1791
|
-
//
|
|
1823
|
+
// 0.7.0 streamed text path: OpenClaw's preview-becomes-final flow.
|
|
1824
|
+
//
|
|
1825
|
+
// 1. flushDraft() — drain any pending throttled edit so the
|
|
1826
|
+
// bubble's visible state is up-to-date before deciding.
|
|
1827
|
+
// 2. finalize(parsed.text) — try to bring the bubble to the
|
|
1828
|
+
// final body. Returns rich result describing whether the
|
|
1829
|
+
// preview can stand as the final reply.
|
|
1830
|
+
// 3a. finalEditOk:true → preview IS final, done.
|
|
1831
|
+
// 3b. overflow OR !finalEditOk → discard preview, redeliver via
|
|
1832
|
+
// deliverReplies(chunkMarkdownText(...)). This is the path
|
|
1833
|
+
// that fixes msg-10794: if the live bubble couldn't render
|
|
1834
|
+
// the full body (size or parse error), we delete it cleanly
|
|
1835
|
+
// and send the proper chunks fresh at chat bottom — no
|
|
1836
|
+
// content lost, no stranded edit-failure bubble.
|
|
1792
1837
|
if (parsed.text) {
|
|
1838
|
+
await streamer.flushDraft();
|
|
1793
1839
|
const fin = await streamer.finalize(parsed.text);
|
|
1794
1840
|
if (fin.streamed) {
|
|
1795
|
-
if (
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
...(threadId && { message_thread_id: threadId }),
|
|
1802
|
-
}, outMeta);
|
|
1803
|
-
} catch (err) {
|
|
1804
|
-
console.error(`[${label}] overflow sendMessage failed: ${err.message}`);
|
|
1805
|
-
}
|
|
1806
|
-
}
|
|
1841
|
+
if (fin.finalEditOk) {
|
|
1842
|
+
// Preview was successfully edited to the final text.
|
|
1843
|
+
// No follow-up messages needed.
|
|
1844
|
+
console.log(`[${label}] ${elapsed}s | ${result.text.length} chars | streamed | ${chatConfig.model}/${chatConfig.effort} | $${result.cost?.toFixed(4) || '?'}`);
|
|
1845
|
+
markReplied();
|
|
1846
|
+
return;
|
|
1807
1847
|
}
|
|
1808
|
-
|
|
1848
|
+
// Preview can't hold the final body (overflow OR last edit
|
|
1849
|
+
// failed even after our HTML→plain fallback). Delete it and
|
|
1850
|
+
// send the body as proper chunks.
|
|
1851
|
+
try { await streamer.discard(); }
|
|
1852
|
+
catch (err) { console.error(`[${label}] discard failed: ${err.message}`); }
|
|
1853
|
+
const chunks = chunkMarkdownText(parsed.text, TG_MAX_LEN);
|
|
1854
|
+
const r = await deliverReplies({
|
|
1855
|
+
bot,
|
|
1856
|
+
send: (b, method, params, m) => tg(b, method, params, m),
|
|
1857
|
+
chatId,
|
|
1858
|
+
threadId,
|
|
1859
|
+
chunks,
|
|
1860
|
+
replyToMessageId: msg.message_id,
|
|
1861
|
+
meta: outMeta,
|
|
1862
|
+
logger: { error: (m) => console.error(`[${label}] ${m}`) },
|
|
1863
|
+
});
|
|
1864
|
+
const reason = fin.overflow ? 'overflow' : 'edit-failed';
|
|
1865
|
+
logEvent('telegram-stream-redeliver', {
|
|
1866
|
+
chat_id: chatId, msg_id: msg.message_id,
|
|
1867
|
+
reason, chunks: chunks.length,
|
|
1868
|
+
delivered: r.sent.length, failed: r.failed.length,
|
|
1869
|
+
bot: BOT_NAME,
|
|
1870
|
+
});
|
|
1871
|
+
console.log(`[${label}] ${elapsed}s | ${result.text.length} chars | streamed-redeliver(${reason}, ${chunks.length} chunks) | ${chatConfig.model}/${chatConfig.effort} | $${result.cost?.toFixed(4) || '?'}`);
|
|
1809
1872
|
markReplied();
|
|
1810
1873
|
return;
|
|
1811
1874
|
}
|
|
1812
|
-
// Not streamed (response too short
|
|
1875
|
+
// Not streamed (response too short — never crossed minChars).
|
|
1876
|
+
// Fall through to the normal send path below.
|
|
1813
1877
|
}
|
|
1814
1878
|
|
|
1815
1879
|
if (parsed.reaction) {
|
|
@@ -1829,19 +1893,20 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1829
1893
|
console.error(`[${label}] sendSticker failed: ${err.message}`);
|
|
1830
1894
|
});
|
|
1831
1895
|
} else if (parsed.text) {
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1896
|
+
// 0.7.0: use markdown-aware chunker + deliverReplies primitive.
|
|
1897
|
+
// The old chunkText was newline/byte-only; chunkMarkdownText also
|
|
1898
|
+
// respects code-fence boundaries (closes + reopens across chunks).
|
|
1899
|
+
const chunks = chunkMarkdownText(parsed.text, TG_MAX_LEN);
|
|
1900
|
+
await deliverReplies({
|
|
1901
|
+
bot,
|
|
1902
|
+
send: (b, method, params, m) => tg(b, method, params, m),
|
|
1903
|
+
chatId,
|
|
1904
|
+
threadId,
|
|
1905
|
+
chunks,
|
|
1906
|
+
replyToMessageId: msg.message_id,
|
|
1907
|
+
meta: outMeta,
|
|
1908
|
+
logger: { error: (m) => console.error(`[${label}] ${m}`) },
|
|
1909
|
+
});
|
|
1845
1910
|
}
|
|
1846
1911
|
|
|
1847
1912
|
console.log(`[${label}] ${elapsed}s | ${result.text.length} chars | ${chatConfig.model}/${chatConfig.effort} | $${result.cost?.toFixed(4) || '?'}`);
|
|
@@ -2480,6 +2545,38 @@ async function main() {
|
|
|
2480
2545
|
const head = entry.pendingQueue?.[0];
|
|
2481
2546
|
const r = head?.context?.reactor;
|
|
2482
2547
|
if (r) r.setState(classifyToolName(toolName));
|
|
2548
|
+
// 0.7.0 (Phase J): opt-in subagent announce. When Claude uses
|
|
2549
|
+
// the Task tool to spawn a subagent, post a brief informational
|
|
2550
|
+
// message to the chat so the user knows a heavier turn is in
|
|
2551
|
+
// progress. Off by default (per-bot or per-chat
|
|
2552
|
+
// `announceSubagents: true` opts in). Per-chat debounce 30s
|
|
2553
|
+
// prevents announce-storms in tool-heavy turns.
|
|
2554
|
+
const chatCfg = config.chats[entry.chatId] || {};
|
|
2555
|
+
const optIn = chatCfg.announceSubagents != null
|
|
2556
|
+
? chatCfg.announceSubagents
|
|
2557
|
+
: config.bot?.announceSubagents;
|
|
2558
|
+
if (toolName === 'Task' && optIn === true) {
|
|
2559
|
+
if (shouldAnnounce(entry.chatId)) {
|
|
2560
|
+
announce({
|
|
2561
|
+
send: (b, method, params, m) => tg(b, method, params, m),
|
|
2562
|
+
bot, chatId: entry.chatId,
|
|
2563
|
+
threadId: head?.context?.threadId ?? null,
|
|
2564
|
+
text: '🤖 Spawning subagent…',
|
|
2565
|
+
meta: { botName: BOT_NAME, source: 'subagent-announce' },
|
|
2566
|
+
logger: { error: (m) => console.error(`[${entry.label}] ${m}`) },
|
|
2567
|
+
});
|
|
2568
|
+
}
|
|
2569
|
+
}
|
|
2570
|
+
},
|
|
2571
|
+
// 0.7.0 (Phase F): each new top-level assistant message gets its
|
|
2572
|
+
// own bubble. When Claude emits text, then tool_use, then more
|
|
2573
|
+
// text in a NEW assistant message (typical of tool-heavy turns),
|
|
2574
|
+
// the previous bubble's content stays visible as a "thinking out
|
|
2575
|
+
// loud" intermediate; the new message starts fresh below.
|
|
2576
|
+
onAssistantMessageStart: (sessionKey, entry) => {
|
|
2577
|
+
const head = entry.pendingQueue?.[0];
|
|
2578
|
+
const s = head?.context?.streamer;
|
|
2579
|
+
if (s) s.forceNewMessage();
|
|
2483
2580
|
},
|
|
2484
2581
|
// Fires after a graceful /model or /effort drain has actually
|
|
2485
2582
|
// swapped to the new settings. Post a confirmation back to the
|