polygram 0.7.3 → 0.7.5
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/deliver.js +10 -1
- package/lib/parse-response.js +56 -0
- package/lib/process-manager.js +22 -0
- package/lib/status-reactions.js +90 -8
- package/lib/stream-reply.js +7 -0
- package/lib/telegram.js +26 -1
- package/package.json +1 -1
- package/polygram.js +89 -19
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
|
|
3
3
|
"name": "polygram",
|
|
4
|
-
"version": "0.7.
|
|
4
|
+
"version": "0.7.5",
|
|
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/deliver.js
CHANGED
|
@@ -33,6 +33,11 @@ async function deliverReplies({
|
|
|
33
33
|
threadId = null,
|
|
34
34
|
chunks,
|
|
35
35
|
replyToMessageId = null,
|
|
36
|
+
// 0.7.4: optional quoted-snippet text for reply_parameters.quote (Telegram's
|
|
37
|
+
// highlighted-quote reply API, makes the reply bubble header show a specific
|
|
38
|
+
// snippet rather than the full first ~100 chars of the original message).
|
|
39
|
+
// Only attached on chunks[0] alongside reply_parameters.message_id.
|
|
40
|
+
quoteText = null,
|
|
36
41
|
meta = {},
|
|
37
42
|
logger = console,
|
|
38
43
|
}) {
|
|
@@ -56,7 +61,11 @@ async function deliverReplies({
|
|
|
56
61
|
// allow_sending_without_reply: long turns give the user time to
|
|
57
62
|
// delete their original message; without this flag Telegram
|
|
58
63
|
// rejects with MESSAGE_NOT_FOUND and the whole reply is lost.
|
|
59
|
-
params.reply_parameters = {
|
|
64
|
+
params.reply_parameters = {
|
|
65
|
+
message_id: replyToMessageId,
|
|
66
|
+
allow_sending_without_reply: true,
|
|
67
|
+
...(quoteText && quoteText.trim() ? { quote: quoteText.trim().slice(0, 1024) } : {}),
|
|
68
|
+
};
|
|
60
69
|
}
|
|
61
70
|
try {
|
|
62
71
|
const res = await send(bot, 'sendMessage', params, meta);
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse Claude's final-turn text into one of three outbound shapes:
|
|
3
|
+
* - sticker (single emoji that maps to a sticker, OR literal
|
|
4
|
+
* `[sticker:NAME]` mimic — see below)
|
|
5
|
+
* - reaction (single emoji not mapped to a sticker)
|
|
6
|
+
* - text (everything else)
|
|
7
|
+
*
|
|
8
|
+
* Why this lives in lib/: polygram.js is a top-level script (calls main()
|
|
9
|
+
* at bottom) and can't be require()'d from a test without starting a bot.
|
|
10
|
+
* Pulling parseResponse out lets tests cover the regex edge cases.
|
|
11
|
+
*
|
|
12
|
+
* 0.7.5 (item: sticker regression):
|
|
13
|
+
* deriveOutboundText (lib/telegram.js) synthesises `[sticker:<name>]` for
|
|
14
|
+
* sendSticker calls so the messages.text column has *something* legible.
|
|
15
|
+
* On session resume Claude reads its own past assistant rows and sees
|
|
16
|
+
* `[sticker:working]` as the assistant message text — and starts mimicking
|
|
17
|
+
* the format LITERALLY, emitting the string `[sticker:working]` as plain
|
|
18
|
+
* text. parseResponse used to fall through to the chunked-text path, so
|
|
19
|
+
* the placeholder ended up rendered in the user's chat instead of an
|
|
20
|
+
* actual sticker.
|
|
21
|
+
*
|
|
22
|
+
* Match shape: optional whitespace, `[sticker:`, NAME (alnum/_/-), `]`,
|
|
23
|
+
* optional whitespace. NAME must resolve in the supplied stickerMap;
|
|
24
|
+
* unknown NAMEs fall through to the text path so a genuine
|
|
25
|
+
* "[sticker:foo]" message (e.g. someone joking, or a stale name from an
|
|
26
|
+
* older deploy) still reaches the user verbatim.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const STICKER_TAG_RE = /^\s*\[sticker:([A-Za-z0-9_-]+)\]\s*$/;
|
|
30
|
+
|
|
31
|
+
function parseResponse(text, { stickerMap = {}, emojiToSticker = {} } = {}) {
|
|
32
|
+
const trimmed = (text || '').trim();
|
|
33
|
+
|
|
34
|
+
const tagMatch = trimmed.match(STICKER_TAG_RE);
|
|
35
|
+
if (tagMatch) {
|
|
36
|
+
const name = tagMatch[1];
|
|
37
|
+
const fileId = stickerMap[name];
|
|
38
|
+
if (fileId) {
|
|
39
|
+
return { text: '', sticker: fileId, stickerLabel: name, reaction: null };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const emojiOnly = /^\p{Emoji_Presentation}$/u.test(trimmed)
|
|
44
|
+
|| /^\p{Emoji}️?$/u.test(trimmed);
|
|
45
|
+
|
|
46
|
+
if (emojiOnly && trimmed) {
|
|
47
|
+
if (emojiToSticker[trimmed]) {
|
|
48
|
+
return { text: '', sticker: emojiToSticker[trimmed], stickerLabel: trimmed, reaction: null };
|
|
49
|
+
}
|
|
50
|
+
return { text: '', sticker: null, stickerLabel: null, reaction: trimmed };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { text: trimmed, sticker: null, stickerLabel: null, reaction: null };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = { parseResponse, STICKER_TAG_RE };
|
package/lib/process-manager.js
CHANGED
|
@@ -284,6 +284,14 @@ class ProcessManager {
|
|
|
284
284
|
// id means a new message (typically after a tool-result cycle).
|
|
285
285
|
const messageId = event.message?.id;
|
|
286
286
|
const added = extractAssistantText(event);
|
|
287
|
+
// 0.7.4 (item B): first sign Claude is doing real work on this
|
|
288
|
+
// pending. Fire onFirstStream ONCE, regardless of whether the
|
|
289
|
+
// assistant message has text or only tool_use blocks (some turns
|
|
290
|
+
// emit tool_use first with no preamble).
|
|
291
|
+
if (added || (Array.isArray(event.message?.content)
|
|
292
|
+
&& event.message.content.some((b) => b?.type === 'tool_use'))) {
|
|
293
|
+
head.fireFirstStream?.();
|
|
294
|
+
}
|
|
287
295
|
if (added) {
|
|
288
296
|
// Pre-0.7.0 we did `streamText = streamText + '\n\n' + added`,
|
|
289
297
|
// which DUPLICATED text on every update because `added` is
|
|
@@ -448,6 +456,20 @@ class ProcessManager {
|
|
|
448
456
|
idleTimer: null,
|
|
449
457
|
maxTimer: null,
|
|
450
458
|
activated: false,
|
|
459
|
+
// 0.7.4 (item B): set true when the first stream event (assistant
|
|
460
|
+
// text or tool_use) arrives for this pending. Fires
|
|
461
|
+
// `context.onFirstStream` once. Used by polygram to flip the
|
|
462
|
+
// status reaction QUEUED → THINKING when Claude actually starts
|
|
463
|
+
// producing output, not when the pending becomes queue head
|
|
464
|
+
// (which can be ~hundreds of ms before the first token).
|
|
465
|
+
firstStreamFired: false,
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
pending.fireFirstStream = () => {
|
|
469
|
+
if (pending.firstStreamFired) return;
|
|
470
|
+
pending.firstStreamFired = true;
|
|
471
|
+
try { context?.onFirstStream?.(); }
|
|
472
|
+
catch (err) { this.logger.error(`[${entry.label}] onFirstStream: ${err.message}`); }
|
|
451
473
|
};
|
|
452
474
|
|
|
453
475
|
const fireTimeout = (reason) => {
|
package/lib/status-reactions.js
CHANGED
|
@@ -43,18 +43,48 @@ const STATES = {
|
|
|
43
43
|
|
|
44
44
|
const TERMINAL_STATES = new Set(['DONE', 'ERROR', 'TIMEOUT']);
|
|
45
45
|
const DEFAULT_THROTTLE_MS = 800;
|
|
46
|
+
// 0.7.4 (item A): after this long with no setState() call (Claude is
|
|
47
|
+
// silently chugging on a long tool / model latency), auto-flip to STALL
|
|
48
|
+
// (🥱) so the user has a visible cue that the bot is alive but slow.
|
|
49
|
+
// 10s matches OpenClaw's "yawn after 10s of nothing".
|
|
50
|
+
const DEFAULT_STALL_MS = 10_000;
|
|
51
|
+
// 30s without a heartbeat is "we're worried" territory — promote to
|
|
52
|
+
// TIMEOUT (😨) so the user knows it might be stuck. Distinct from the
|
|
53
|
+
// pm's 5-minute hard idle timeout, which actually rejects the turn.
|
|
54
|
+
const DEFAULT_FREEZE_MS = 30_000;
|
|
46
55
|
|
|
47
|
-
// Tool name → state classifier. Case-insensitive
|
|
48
|
-
//
|
|
49
|
-
//
|
|
56
|
+
// Tool name → state classifier. Case-insensitive substring match so we
|
|
57
|
+
// don't have to enumerate every existing or future tool. Order matters:
|
|
58
|
+
// WEB checks first because "WebFetch" contains "fetch" but should map
|
|
59
|
+
// to ⚡, not whatever the generic fetcher gets. Skill-prefixed tools
|
|
60
|
+
// (e.g. "mcp__plugin_playwright_playwright__browser_click") are still
|
|
61
|
+
// caught by the substring check.
|
|
62
|
+
//
|
|
63
|
+
// 0.7.4 (item C): pre-fix, anything not exactly matching a tiny regex
|
|
64
|
+
// (e.g. WebSearch_v2, custom Bash variants, MCP-namespaced tools) fell
|
|
65
|
+
// through to generic TOOL (🔥), losing the more-specific signal. The
|
|
66
|
+
// substring match recovers the right state for both built-ins and most
|
|
67
|
+
// MCP/skill tools without listing them by name.
|
|
50
68
|
function classifyToolName(name) {
|
|
51
69
|
if (typeof name !== 'string' || !name) return 'TOOL';
|
|
52
|
-
|
|
53
|
-
if (
|
|
54
|
-
|
|
70
|
+
const n = name.toLowerCase();
|
|
71
|
+
if (n.includes('web') || n.includes('fetch') || n.includes('browser') || n.includes('search')) return 'WEB';
|
|
72
|
+
// WRITING before CODING: "TodoWrite" contains both "todo" and "write" —
|
|
73
|
+
// we want it to land at ✍ (WRITING), not 👨💻 (CODING).
|
|
74
|
+
if (n.includes('todo') || n.includes('task') || n.includes('skill')) return 'WRITING';
|
|
75
|
+
if (n.includes('read') || n.includes('write') || n.includes('edit')
|
|
76
|
+
|| n.includes('bash') || n.includes('grep') || n.includes('glob')
|
|
77
|
+
|| n.includes('notebook')) return 'CODING';
|
|
55
78
|
return 'TOOL';
|
|
56
79
|
}
|
|
57
80
|
|
|
81
|
+
// 0.7.4 (item J): generic, almost-universally-available fallbacks. Used
|
|
82
|
+
// when a group's `available_reactions` allowlist excludes every emoji in
|
|
83
|
+
// a state's preferred chain. Better to show *some* reaction (e.g. 👍 for
|
|
84
|
+
// "done" in a group that only allows thumbs) than to silently emit
|
|
85
|
+
// nothing and leave the user wondering whether the bot is alive.
|
|
86
|
+
const GENERIC_FALLBACKS = ['👍', '👀', '🔥'];
|
|
87
|
+
|
|
58
88
|
/**
|
|
59
89
|
* Resolve the best-available emoji from a chain given an allowlist.
|
|
60
90
|
* If allowlist is null/undefined, assume default-available set and
|
|
@@ -66,7 +96,11 @@ function resolveEmoji(chain, allowlist) {
|
|
|
66
96
|
for (const emoji of chain) {
|
|
67
97
|
if (allowed.has(emoji)) return emoji;
|
|
68
98
|
}
|
|
69
|
-
|
|
99
|
+
for (const emoji of GENERIC_FALLBACKS) {
|
|
100
|
+
if (allowed.has(emoji)) return emoji;
|
|
101
|
+
}
|
|
102
|
+
// Nothing in the chain or generic set is allowed — signal "no
|
|
103
|
+
// reaction possible".
|
|
70
104
|
return null;
|
|
71
105
|
}
|
|
72
106
|
|
|
@@ -85,14 +119,23 @@ function createReactionManager({
|
|
|
85
119
|
apply,
|
|
86
120
|
availableEmojis = null,
|
|
87
121
|
throttleMs = DEFAULT_THROTTLE_MS,
|
|
122
|
+
stallMs = DEFAULT_STALL_MS,
|
|
123
|
+
freezeMs = DEFAULT_FREEZE_MS,
|
|
88
124
|
logError = () => {},
|
|
89
125
|
} = {}) {
|
|
90
126
|
if (typeof apply !== 'function') throw new Error('apply function required');
|
|
91
127
|
let currentState = null;
|
|
92
128
|
let currentEmoji = null;
|
|
93
129
|
let lastFlushTs = 0;
|
|
130
|
+
let lastSetStateTs = 0;
|
|
94
131
|
let pendingTimer = null;
|
|
132
|
+
let stallTimer = null;
|
|
133
|
+
let freezeTimer = null;
|
|
95
134
|
let stopped = false;
|
|
135
|
+
// States the auto-stall path may transition to. Once we've already
|
|
136
|
+
// shown STALL or TIMEOUT we don't downgrade or rearm — only an
|
|
137
|
+
// explicit setState() call (Claude resumed) can move us forward.
|
|
138
|
+
const STALL_PROMOTABLE = new Set(['THINKING', 'CODING', 'WEB', 'TOOL', 'WRITING']);
|
|
96
139
|
|
|
97
140
|
const flush = async (stateName) => {
|
|
98
141
|
if (stopped) return;
|
|
@@ -109,17 +152,51 @@ function createReactionManager({
|
|
|
109
152
|
}
|
|
110
153
|
};
|
|
111
154
|
|
|
155
|
+
const clearStallTimers = () => {
|
|
156
|
+
if (stallTimer) { clearTimeout(stallTimer); stallTimer = null; }
|
|
157
|
+
if (freezeTimer) { clearTimeout(freezeTimer); freezeTimer = null; }
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const armStallTimers = () => {
|
|
161
|
+
clearStallTimers();
|
|
162
|
+
if (stopped) return;
|
|
163
|
+
if (!STALL_PROMOTABLE.has(currentState)) return;
|
|
164
|
+
stallTimer = setTimeout(() => {
|
|
165
|
+
stallTimer = null;
|
|
166
|
+
// Re-check state at fire time — caller may have advanced past a
|
|
167
|
+
// promotable state in the interim.
|
|
168
|
+
if (stopped || TERMINAL_STATES.has(currentState)) return;
|
|
169
|
+
if (!STALL_PROMOTABLE.has(currentState)) return;
|
|
170
|
+
flush('STALL');
|
|
171
|
+
}, stallMs);
|
|
172
|
+
stallTimer.unref?.();
|
|
173
|
+
freezeTimer = setTimeout(() => {
|
|
174
|
+
freezeTimer = null;
|
|
175
|
+
if (stopped || TERMINAL_STATES.has(currentState)) return;
|
|
176
|
+
flush('TIMEOUT');
|
|
177
|
+
}, freezeMs);
|
|
178
|
+
freezeTimer.unref?.();
|
|
179
|
+
};
|
|
180
|
+
|
|
112
181
|
const setState = (stateName) => {
|
|
113
182
|
if (stopped) return;
|
|
114
183
|
if (!STATES[stateName]) return;
|
|
115
184
|
currentState = stateName;
|
|
185
|
+
lastSetStateTs = Date.now();
|
|
116
186
|
|
|
117
|
-
// Terminal states flush immediately, bypassing throttle
|
|
187
|
+
// Terminal states flush immediately, bypassing throttle, and
|
|
188
|
+
// disarm any pending stall promotion.
|
|
118
189
|
if (TERMINAL_STATES.has(stateName)) {
|
|
119
190
|
if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = null; }
|
|
191
|
+
clearStallTimers();
|
|
120
192
|
return flush(stateName);
|
|
121
193
|
}
|
|
122
194
|
|
|
195
|
+
// Any explicit setState resets the stall clock — Claude clearly is
|
|
196
|
+
// doing *something*. Re-arm only if the new state is promotable
|
|
197
|
+
// (no point arming over QUEUED/STALL/TIMEOUT itself).
|
|
198
|
+
armStallTimers();
|
|
199
|
+
|
|
123
200
|
const elapsed = Date.now() - lastFlushTs;
|
|
124
201
|
if (elapsed >= throttleMs) {
|
|
125
202
|
if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = null; }
|
|
@@ -137,6 +214,7 @@ function createReactionManager({
|
|
|
137
214
|
|
|
138
215
|
const clear = async () => {
|
|
139
216
|
if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = null; }
|
|
217
|
+
clearStallTimers();
|
|
140
218
|
if (currentEmoji == null) return;
|
|
141
219
|
currentEmoji = null;
|
|
142
220
|
try { await apply(null); }
|
|
@@ -146,6 +224,7 @@ function createReactionManager({
|
|
|
146
224
|
const stop = () => {
|
|
147
225
|
stopped = true;
|
|
148
226
|
if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = null; }
|
|
227
|
+
clearStallTimers();
|
|
149
228
|
};
|
|
150
229
|
|
|
151
230
|
return {
|
|
@@ -165,4 +244,7 @@ module.exports = {
|
|
|
165
244
|
STATES,
|
|
166
245
|
TERMINAL_STATES,
|
|
167
246
|
DEFAULT_THROTTLE_MS,
|
|
247
|
+
DEFAULT_STALL_MS,
|
|
248
|
+
DEFAULT_FREEZE_MS,
|
|
249
|
+
GENERIC_FALLBACKS,
|
|
168
250
|
};
|
package/lib/stream-reply.js
CHANGED
|
@@ -38,6 +38,12 @@ const DEFAULT_MIN_CHARS = 30;
|
|
|
38
38
|
// identical to a viewer and halves the edit volume.
|
|
39
39
|
const DEFAULT_THROTTLE_MS = 1000;
|
|
40
40
|
|
|
41
|
+
// 0.7.4: floor matches OpenClaw's `Math.max(250, throttleMs)` clamp —
|
|
42
|
+
// any value below 250ms would burn through Telegram's per-message edit-
|
|
43
|
+
// rate budget faster than necessary. Defends against operator misconfig
|
|
44
|
+
// (`streamThrottleMs: 50`) without rejecting the config outright.
|
|
45
|
+
const THROTTLE_FLOOR_MS = 250;
|
|
46
|
+
|
|
41
47
|
function createStreamer({
|
|
42
48
|
send, // async (text) -> { message_id }
|
|
43
49
|
edit, // async (msg_id, text) -> void
|
|
@@ -50,6 +56,7 @@ function createStreamer({
|
|
|
50
56
|
cancel = clearTimeout,
|
|
51
57
|
logger = console,
|
|
52
58
|
} = {}) {
|
|
59
|
+
throttleMs = Math.max(THROTTLE_FLOOR_MS, throttleMs);
|
|
53
60
|
let state = 'idle'; // 'idle' | 'live' | 'finalized'
|
|
54
61
|
let msgId = null;
|
|
55
62
|
let currentText = ''; // what's on screen right now (truncated to maxLen)
|
package/lib/telegram.js
CHANGED
|
@@ -111,6 +111,28 @@ function deriveOutboundText(method, params, meta) {
|
|
|
111
111
|
async function send({ bot, method, params, db = null, meta = {}, logger = console }) {
|
|
112
112
|
const chatId = params.chat_id != null ? String(params.chat_id) : null;
|
|
113
113
|
const threadId = params.message_thread_id != null ? String(params.message_thread_id) : null;
|
|
114
|
+
|
|
115
|
+
// 0.7.4: empty-text short-circuit. Pre-fix, an empty params.text on
|
|
116
|
+
// sendMessage/editMessageText reached Telegram and 400'd with
|
|
117
|
+
// "message text is empty"; the row was marked failed and propagated
|
|
118
|
+
// as a user-facing "Hit a snag" — confusing because the bot didn't
|
|
119
|
+
// really fail. Throw a typed error before the API call so callers
|
|
120
|
+
// can detect + skip silently if appropriate.
|
|
121
|
+
if (method === 'sendMessage' || method === 'editMessageText') {
|
|
122
|
+
const t = params.text;
|
|
123
|
+
if (typeof t !== 'string' || t.length === 0) {
|
|
124
|
+
throw new Error(`telegram ${method}: text is empty`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (method === 'sendPhoto' || method === 'sendVideo'
|
|
128
|
+
|| method === 'sendAudio' || method === 'sendDocument' || method === 'sendAnimation') {
|
|
129
|
+
// Caption is optional for media sends; only check if explicitly set
|
|
130
|
+
// to a non-string. Empty caption is fine (just send the media).
|
|
131
|
+
if (params.caption != null && typeof params.caption !== 'string') {
|
|
132
|
+
throw new Error(`telegram ${method}: caption must be a string when set`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
114
136
|
// Capture outbound text BEFORE markdown-escaping so the transcript stays
|
|
115
137
|
// human-readable. "Mr. O'Brien said 3.14" is searchable; "Mr\. O'Brien
|
|
116
138
|
// said 3\.14" is not. The user's chat view shows the rendered text, which
|
|
@@ -199,7 +221,10 @@ async function send({ bot, method, params, db = null, meta = {}, logger = consol
|
|
|
199
221
|
//
|
|
200
222
|
// Each layer is a closure built per call; allocation cost is
|
|
201
223
|
// negligible vs. network RTT.
|
|
202
|
-
|
|
224
|
+
// 0.7.4: aligned with OpenClaw's 30s cap. A misconfigured retry_after
|
|
225
|
+
// (Telegram occasionally returns 5+ minute hints during outages)
|
|
226
|
+
// shouldn't park the call beyond a reasonable user-attention budget.
|
|
227
|
+
const RETRY_AFTER_CAP_MS = 30_000;
|
|
203
228
|
const tryOnce = async (p) => {
|
|
204
229
|
try {
|
|
205
230
|
return await rawAttempt(p);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "polygram",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.5",
|
|
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
|
@@ -204,6 +204,49 @@ function dbWrite(fn, context) {
|
|
|
204
204
|
}
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
// 0.7.4 (item I): per-chat allowlist of available reactions.
|
|
208
|
+
//
|
|
209
|
+
// Telegram groups can restrict which emojis members may use as
|
|
210
|
+
// reactions via `available_reactions`. When the bot is in such a group
|
|
211
|
+
// and tries to apply a reaction outside the allowlist, the API returns
|
|
212
|
+
// REACTION_INVALID and the user sees no progress signal at all.
|
|
213
|
+
//
|
|
214
|
+
// We probe via getChat() once per chat (cached forever — admins rarely
|
|
215
|
+
// change the setting and we'll learn of changes the next bot restart),
|
|
216
|
+
// derive the allowlist (or null = "default Telegram set, no
|
|
217
|
+
// restriction"), and pass it into createReactionManager so resolveEmoji
|
|
218
|
+
// can pick the best-allowed emoji from each state's chain.
|
|
219
|
+
const reactionAllowlistCache = new Map();
|
|
220
|
+
async function getReactionAllowlist(bot, chatId) {
|
|
221
|
+
if (reactionAllowlistCache.has(chatId)) return reactionAllowlistCache.get(chatId);
|
|
222
|
+
let allowlist = null;
|
|
223
|
+
try {
|
|
224
|
+
const chat = await bot.api.getChat(chatId);
|
|
225
|
+
const ar = chat?.available_reactions;
|
|
226
|
+
// Telegram returns:
|
|
227
|
+
// - undefined / { type: 'all' } → no restriction (all emojis allowed)
|
|
228
|
+
// - { type: 'some', reactions: [{type, emoji}, ...] } → restricted
|
|
229
|
+
// - { type: 'none' } → reactions disabled entirely
|
|
230
|
+
if (ar?.type === 'some' && Array.isArray(ar.reactions)) {
|
|
231
|
+
allowlist = new Set(ar.reactions
|
|
232
|
+
.filter((r) => r?.type === 'emoji' && r.emoji)
|
|
233
|
+
.map((r) => r.emoji));
|
|
234
|
+
} else if (ar?.type === 'none') {
|
|
235
|
+
// Empty set — resolveEmoji will return null, the apply callback
|
|
236
|
+
// will short-circuit, and we won't waste API calls on a chat
|
|
237
|
+
// where reactions can't render at all.
|
|
238
|
+
allowlist = new Set();
|
|
239
|
+
}
|
|
240
|
+
// 'all' / undefined → leave allowlist null (chain[0] always wins).
|
|
241
|
+
} catch (err) {
|
|
242
|
+
console.error(`[reactions] getChat ${chatId} failed: ${err.message}`);
|
|
243
|
+
// On failure, cache null (assume default set) so we don't retry on
|
|
244
|
+
// every turn. A bot restart re-probes.
|
|
245
|
+
}
|
|
246
|
+
reactionAllowlistCache.set(chatId, allowlist);
|
|
247
|
+
return allowlist;
|
|
248
|
+
}
|
|
249
|
+
|
|
207
250
|
// Convenience for the most common dbWrite pattern: log an event.
|
|
208
251
|
// Pre-0.6.9 every call site was dbWrite(() => db.logEvent(KIND, {...}),
|
|
209
252
|
// `log ${KIND}`) — three repeated lines for one logical operation.
|
|
@@ -379,16 +422,23 @@ function extractAttachments(msg) {
|
|
|
379
422
|
|
|
380
423
|
async function transcribeVoiceAttachments(downloaded, { chatId, msgId, label, botApi, threadId }) {
|
|
381
424
|
const voiceCfg = config.bot?.voice || config.voice;
|
|
382
|
-
if (!voiceCfg?.enabled) return;
|
|
425
|
+
if (!voiceCfg?.enabled) return { ackEmitted: false };
|
|
383
426
|
const provider = voiceCfg.provider || 'openai';
|
|
384
427
|
const providerCfg = voiceCfg[provider] || {};
|
|
385
428
|
const targets = downloaded.filter((a) => isVoiceAttachment(a) && a.path);
|
|
386
|
-
if (!targets.length) return;
|
|
429
|
+
if (!targets.length) return { ackEmitted: false };
|
|
387
430
|
|
|
388
431
|
// Acknowledge receipt with a reaction so the user knows we heard them.
|
|
389
432
|
// Cheap, robust (no state), and survives transcription failure.
|
|
433
|
+
// 0.7.4 (item G): we report `ackEmitted: true` so the caller can skip
|
|
434
|
+
// the reactor's QUEUED → 👀 transition. Pre-fix, 👂 was visible for
|
|
435
|
+
// ~milliseconds before 👀 overwrote it on the same message — wasted
|
|
436
|
+
// API call and confusing flicker. Now 👂 stays until Claude actually
|
|
437
|
+
// starts work and the reactor flips to THINKING (🤔).
|
|
390
438
|
const ack = voiceCfg.ackReaction || '👂';
|
|
439
|
+
let ackEmitted = false;
|
|
391
440
|
if (ack && botApi) {
|
|
441
|
+
ackEmitted = true;
|
|
392
442
|
tg(botApi, 'setMessageReaction', {
|
|
393
443
|
chat_id: chatId, message_id: msgId,
|
|
394
444
|
reaction: [{ type: 'emoji', emoji: ack }],
|
|
@@ -432,7 +482,7 @@ async function transcribeVoiceAttachments(downloaded, { chatId, msgId, label, bo
|
|
|
432
482
|
// combined transcript so FTS finds "what Maria said" via the
|
|
433
483
|
// normal chat search path.
|
|
434
484
|
const successful = targets.filter((a) => a.transcription?.text);
|
|
435
|
-
if (!successful.length) return;
|
|
485
|
+
if (!successful.length) return { ackEmitted };
|
|
436
486
|
for (const a of successful) {
|
|
437
487
|
if (a.id != null) {
|
|
438
488
|
dbWrite(() => db.setAttachmentTranscription(a.id, JSON.stringify(a.transcription)),
|
|
@@ -443,6 +493,7 @@ async function transcribeVoiceAttachments(downloaded, { chatId, msgId, label, bo
|
|
|
443
493
|
dbWrite(() => db.setMessageText({
|
|
444
494
|
chat_id: chatId, msg_id: msgId, text: combinedText,
|
|
445
495
|
}), 'persist voice transcription');
|
|
496
|
+
return { ackEmitted };
|
|
446
497
|
}
|
|
447
498
|
|
|
448
499
|
// Bounded concurrency for parallel fetches. A 10-photo album used to be
|
|
@@ -910,19 +961,21 @@ const stdinLock = createAsyncLock();
|
|
|
910
961
|
// hammering sendChatAction every 4s for the full turn duration.
|
|
911
962
|
|
|
912
963
|
// ─── Response parsing (stickers, reactions) ─────────────────────────
|
|
913
|
-
|
|
964
|
+
// Implementation lives in lib/parse-response.js so tests can require it
|
|
965
|
+
// without starting a bot (polygram.js is a top-level script that calls
|
|
966
|
+
// main() at bottom). The wrapper here supplies the runtime stickerMap /
|
|
967
|
+
// emojiToSticker that the parser looks up against.
|
|
968
|
+
//
|
|
969
|
+
// 0.7.5: parser also recognises a literal `[sticker:NAME]` pattern in
|
|
970
|
+
// addition to single-emoji shortcuts. Claude reads its own past outbound
|
|
971
|
+
// rows on session resume, sees `[sticker:working]` (the placeholder
|
|
972
|
+
// deriveOutboundText synthesises for sendSticker rows), and starts
|
|
973
|
+
// mimicking the format as plain text. Without the new branch the
|
|
974
|
+
// placeholder was rendered verbatim in the chat instead of swapped for
|
|
975
|
+
// the actual sticker.
|
|
976
|
+
const { parseResponse: parseResponseImpl } = require('./lib/parse-response');
|
|
914
977
|
function parseResponse(text) {
|
|
915
|
-
|
|
916
|
-
const emojiOnly = /^\p{Emoji_Presentation}$/u.test(trimmed) || /^\p{Emoji}\uFE0F?$/u.test(trimmed);
|
|
917
|
-
|
|
918
|
-
if (emojiOnly && trimmed) {
|
|
919
|
-
if (emojiToSticker[trimmed]) {
|
|
920
|
-
return { text: '', sticker: emojiToSticker[trimmed], stickerLabel: trimmed, reaction: null };
|
|
921
|
-
}
|
|
922
|
-
return { text: '', sticker: null, stickerLabel: null, reaction: trimmed };
|
|
923
|
-
}
|
|
924
|
-
|
|
925
|
-
return { text: trimmed, sticker: null, stickerLabel: null, reaction: null };
|
|
978
|
+
return parseResponseImpl(text, { stickerMap, emojiToSticker });
|
|
926
979
|
}
|
|
927
980
|
|
|
928
981
|
// ─── Cron/IPC send ─────────────────────────────────────────────────
|
|
@@ -1649,9 +1702,9 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1649
1702
|
}
|
|
1650
1703
|
}
|
|
1651
1704
|
|
|
1652
|
-
await transcribeVoiceAttachments(downloaded, {
|
|
1705
|
+
const voiceAck = await transcribeVoiceAttachments(downloaded, {
|
|
1653
1706
|
chatId, msgId: msg.message_id, label, botApi: bot, threadId,
|
|
1654
|
-
});
|
|
1707
|
+
}) || { ackEmitted: false };
|
|
1655
1708
|
|
|
1656
1709
|
const prompt = formatPrompt(msg, sessionCtx, downloaded);
|
|
1657
1710
|
const stopTyping = startTyping({
|
|
@@ -1784,6 +1837,11 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1784
1837
|
// notifications), updates in place, one emoji per message. Uses
|
|
1785
1838
|
// setMessageReaction which skips the DB row (the tg() wrapper
|
|
1786
1839
|
// short-circuits that method), so no transcript spam.
|
|
1840
|
+
// 0.7.4 (item I): probe the chat's available_reactions allowlist on
|
|
1841
|
+
// first turn (cached after). resolveEmoji uses this to pick the best
|
|
1842
|
+
// emoji from each state's chain that's actually permitted in this
|
|
1843
|
+
// group, falling back to a generic set (👍/👀/🔥) before giving up.
|
|
1844
|
+
const availableEmojis = await getReactionAllowlist(bot, chatId);
|
|
1787
1845
|
const reactor = createReactionManager({
|
|
1788
1846
|
apply: async (emoji) => {
|
|
1789
1847
|
const params = {
|
|
@@ -1794,13 +1852,20 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1794
1852
|
await tg(bot, 'setMessageReaction', params,
|
|
1795
1853
|
{ source: 'status-reaction', botName: BOT_NAME });
|
|
1796
1854
|
},
|
|
1855
|
+
availableEmojis,
|
|
1797
1856
|
logError: (m) => console.error(`[${label}] ${m}`),
|
|
1798
1857
|
});
|
|
1799
1858
|
// Start at QUEUED (👀) so user sees their message was received but
|
|
1800
1859
|
// not yet being worked on. pm calls context.onActivate when this
|
|
1801
1860
|
// pending becomes the queue head (Claude is actually starting it),
|
|
1802
1861
|
// at which point we flip to THINKING (🤔).
|
|
1803
|
-
|
|
1862
|
+
// 0.7.4 (item G): if voice ack (👂) was just emitted by
|
|
1863
|
+
// transcribeVoiceAttachments, skip QUEUED — its 👀 would overwrite the
|
|
1864
|
+
// ack within milliseconds, wasting an API call and flickering. Let 👂
|
|
1865
|
+
// stay until THINKING flips it to 🤔 when Claude actually starts work.
|
|
1866
|
+
if (!voiceAck.ackEmitted) {
|
|
1867
|
+
reactor.setState('QUEUED');
|
|
1868
|
+
}
|
|
1804
1869
|
|
|
1805
1870
|
// Mark the inbound row terminal so boot replay doesn't pick it up
|
|
1806
1871
|
// again. Must fire down EVERY non-throwing exit path (early returns
|
|
@@ -1819,7 +1884,12 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1819
1884
|
// get routed to their own streamer/reactor.
|
|
1820
1885
|
const result = await sendToProcess(sessionKey, prompt, {
|
|
1821
1886
|
streamer, reactor, sourceMsgId: msg.message_id,
|
|
1822
|
-
|
|
1887
|
+
// 0.7.4 (item B): fire THINKING when Claude actually starts
|
|
1888
|
+
// emitting (first assistant text or tool_use). Pre-fix, onActivate
|
|
1889
|
+
// (queue-head transition) flipped to THINKING the moment we wrote
|
|
1890
|
+
// stdin, even though Claude could spend hundreds of ms loading.
|
|
1891
|
+
// Result: long flat 🤔 with nothing happening; users assumed stall.
|
|
1892
|
+
onFirstStream: () => reactor.setState('THINKING'),
|
|
1823
1893
|
});
|
|
1824
1894
|
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
|
|
1825
1895
|
|