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.
@@ -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.3",
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 = { message_id: replyToMessageId, allow_sending_without_reply: true };
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 };
@@ -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) => {
@@ -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 contains for tool names
48
- // that share a category (Read/Write/Edit are all "coding"; WebFetch +
49
- // WebSearch are "web"; everything else is generic TOOL).
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
- if (/^(Web)/i.test(name)) return 'WEB';
53
- if (/^(Bash|Read|Write|Edit|NotebookEdit|Glob|Grep)$/.test(name)) return 'CODING';
54
- if (/^(TodoWrite|Task)$/.test(name)) return 'WRITING';
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
- // Nothing in the chain is allowed — signal "no reaction possible".
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
  };
@@ -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
- const RETRY_AFTER_CAP_MS = 60_000;
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",
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
- const trimmed = text.trim();
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
- reactor.setState('QUEUED');
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
- onActivate: () => reactor.setState('THINKING'),
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