polygram 0.6.15 β 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 +156 -47
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');
|
|
@@ -786,6 +789,9 @@ function errorReplyText(err) {
|
|
|
786
789
|
if (/Process (exited|killed)/i.test(msg)) {
|
|
787
790
|
return 'π₯ Something crashed on my end. Try again.';
|
|
788
791
|
}
|
|
792
|
+
if (/error_during_execution/i.test(msg)) {
|
|
793
|
+
return 'π₯ Something went wrong mid-stream. Try again.';
|
|
794
|
+
}
|
|
789
795
|
const reason = msg.split('\n')[0].slice(0, 120);
|
|
790
796
|
return `Hit a snag: ${reason || 'unknown error'}. Try resending.`;
|
|
791
797
|
}
|
|
@@ -906,22 +912,6 @@ function parseResponse(text) {
|
|
|
906
912
|
return { text: trimmed, sticker: null, stickerLabel: null, reaction: null };
|
|
907
913
|
}
|
|
908
914
|
|
|
909
|
-
// βββ Reply chunking βββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
910
|
-
|
|
911
|
-
function chunkText(text, maxLen = TG_MAX_LEN) {
|
|
912
|
-
if (text.length <= maxLen) return [text];
|
|
913
|
-
const chunks = [];
|
|
914
|
-
let remaining = text;
|
|
915
|
-
while (remaining.length > 0) {
|
|
916
|
-
if (remaining.length <= maxLen) { chunks.push(remaining); break; }
|
|
917
|
-
let splitAt = remaining.lastIndexOf('\n', maxLen);
|
|
918
|
-
if (splitAt < maxLen * 0.3) splitAt = maxLen;
|
|
919
|
-
chunks.push(remaining.slice(0, splitAt));
|
|
920
|
-
remaining = remaining.slice(splitAt).replace(/^\n/, '');
|
|
921
|
-
}
|
|
922
|
-
return chunks;
|
|
923
|
-
}
|
|
924
|
-
|
|
925
915
|
// βββ Cron/IPC send βββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
926
916
|
|
|
927
917
|
// Allowlist of Telegram Bot API methods external callers (cron) may invoke.
|
|
@@ -1660,11 +1650,18 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1660
1650
|
});
|
|
1661
1651
|
|
|
1662
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;
|
|
1663
1659
|
const outMetaBase = {
|
|
1664
1660
|
source: 'bot-reply-stream',
|
|
1665
1661
|
botName: BOT_NAME,
|
|
1666
1662
|
model: chatConfig.model,
|
|
1667
1663
|
effort: chatConfig.effort,
|
|
1664
|
+
...(linkPreview === false ? { linkPreview: false } : {}),
|
|
1668
1665
|
};
|
|
1669
1666
|
|
|
1670
1667
|
// Streaming is unconditional as of 0.4.0 β matches OpenClaw's model and
|
|
@@ -1707,6 +1704,21 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1707
1704
|
throw err;
|
|
1708
1705
|
}
|
|
1709
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
|
+
},
|
|
1710
1722
|
minChars: botCfg.streamMinChars,
|
|
1711
1723
|
throttleMs: botCfg.streamThrottleMs,
|
|
1712
1724
|
logger: { error: (m) => console.error(`[${label}] ${m}`) },
|
|
@@ -1761,7 +1773,16 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1761
1773
|
if (result.error) {
|
|
1762
1774
|
console.error(`[${label}] Error (${elapsed}s):`, result.error);
|
|
1763
1775
|
reactor.setState('ERROR');
|
|
1764
|
-
|
|
1776
|
+
// 0.6.16: pre-fix, silently markReplied()+return β the user got an
|
|
1777
|
+
// error reaction emoji on their message but no actual reply text,
|
|
1778
|
+
// AND 'replied' status meant boot replay didn't re-dispatch on next
|
|
1779
|
+
// boot. Worst-case: shutdown-killed turn (e.g. polygram upgrade
|
|
1780
|
+
// mid-stream) β user sends "yes", sees π€―, gets no answer ever,
|
|
1781
|
+
// the row is silently lost. Promote to a thrown error so
|
|
1782
|
+
// dispatchHandleMessage's catch correctly distinguishes shutdown
|
|
1783
|
+
// (β 'replay-pending', boot replay retries) from runtime failure
|
|
1784
|
+
// (β 'failed', user gets an apology with retry hint).
|
|
1785
|
+
if (!result.text) throw new Error(result.error);
|
|
1765
1786
|
} else {
|
|
1766
1787
|
// Clear the progress reaction instead of stamping π β the reply
|
|
1767
1788
|
// bubble itself is the "done" signal and a permanent thumbs-up on
|
|
@@ -1770,34 +1791,89 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1770
1791
|
reactor.clear().catch(() => {});
|
|
1771
1792
|
}
|
|
1772
1793
|
|
|
1773
|
-
|
|
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
|
+
}
|
|
1774
1819
|
|
|
1775
1820
|
const parsed = parseResponse(result.text);
|
|
1776
1821
|
const outMeta = { ...outMetaBase, sessionId: result.sessionId, costUsd: result.cost };
|
|
1777
1822
|
|
|
1778
|
-
//
|
|
1779
|
-
//
|
|
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.
|
|
1780
1837
|
if (parsed.text) {
|
|
1838
|
+
await streamer.flushDraft();
|
|
1781
1839
|
const fin = await streamer.finalize(parsed.text);
|
|
1782
1840
|
if (fin.streamed) {
|
|
1783
|
-
if (
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
...(threadId && { message_thread_id: threadId }),
|
|
1790
|
-
}, outMeta);
|
|
1791
|
-
} catch (err) {
|
|
1792
|
-
console.error(`[${label}] overflow sendMessage failed: ${err.message}`);
|
|
1793
|
-
}
|
|
1794
|
-
}
|
|
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;
|
|
1795
1847
|
}
|
|
1796
|
-
|
|
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) || '?'}`);
|
|
1797
1872
|
markReplied();
|
|
1798
1873
|
return;
|
|
1799
1874
|
}
|
|
1800
|
-
// Not streamed (response too short
|
|
1875
|
+
// Not streamed (response too short β never crossed minChars).
|
|
1876
|
+
// Fall through to the normal send path below.
|
|
1801
1877
|
}
|
|
1802
1878
|
|
|
1803
1879
|
if (parsed.reaction) {
|
|
@@ -1817,19 +1893,20 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1817
1893
|
console.error(`[${label}] sendSticker failed: ${err.message}`);
|
|
1818
1894
|
});
|
|
1819
1895
|
} else if (parsed.text) {
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
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
|
+
});
|
|
1833
1910
|
}
|
|
1834
1911
|
|
|
1835
1912
|
console.log(`[${label}] ${elapsed}s | ${result.text.length} chars | ${chatConfig.model}/${chatConfig.effort} | $${result.cost?.toFixed(4) || '?'}`);
|
|
@@ -2468,6 +2545,38 @@ async function main() {
|
|
|
2468
2545
|
const head = entry.pendingQueue?.[0];
|
|
2469
2546
|
const r = head?.context?.reactor;
|
|
2470
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();
|
|
2471
2580
|
},
|
|
2472
2581
|
// Fires after a graceful /model or /effort drain has actually
|
|
2473
2582
|
// swapped to the new settings. Post a confirmation back to the
|