polygram 0.7.2 → 0.7.4

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.2",
4
+ "version": "0.7.4",
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",
@@ -15,6 +15,8 @@
15
15
  "attachmentConcurrency": 6,
16
16
  "queueWarnThreshold": 20,
17
17
  "replayWindowMs": 180000,
18
+ "_comment_chrome": "Opt-in to Claude Code's Chrome-extension browser-automation integration. Default false. Requires the 'Claude in Chrome' extension installed in the daemon-user's Chrome browser AND a live GUI session (Chrome must be running). See https://code.claude.com/docs/en/chrome. Per-chat override via `config.chats.<id>.chrome`.",
19
+ "chrome": false,
18
20
  "_comment_pairedChatDefaults": "When a user sends /pair <CODE> from a private chat that isn't in config.chats yet, polygram auto-creates a chat entry using these defaults (merged over top-level `defaults`). Leave out `cwd` at your peril — without it, auto-onboarded DMs have no working directory and pairing will fail.",
19
21
  "pairedChatDefaults": {
20
22
  "agent": "admin",
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);
@@ -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.2",
3
+ "version": "0.7.4",
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
@@ -640,6 +691,19 @@ let pm = null; // ProcessManager, created in main()
640
691
 
641
692
  function spawnClaude(sessionKey, ctx) {
642
693
  const { chatConfig, existingSessionId, label, chatId } = ctx;
694
+ // 0.7.3: Claude Code's Chrome-extension integration (browser
695
+ // automation via the "Claude in Chrome" extension) is OPT-IN and
696
+ // NOT enabled by default in `claude`. Polygram lets chats turn it
697
+ // on via `config.chats.<id>.chrome: true` (chat-level wins) or
698
+ // `config.bot.chrome: true` (per-bot default). When opting in, the
699
+ // extension must be installed in the daemon-user's Chrome and the
700
+ // user must have a live Aqua session (so Chrome is running). Falls
701
+ // back to --no-chrome for chats that don't opt in (matches our
702
+ // pre-0.7.3 default — defensive against any "enabled by default"
703
+ // that might have been set in claude's persistent state).
704
+ const wantChrome = chatConfig.chrome != null
705
+ ? chatConfig.chrome === true
706
+ : config.bot?.chrome === true;
643
707
  const args = [
644
708
  '-p',
645
709
  '--input-format', 'stream-json',
@@ -648,7 +712,7 @@ function spawnClaude(sessionKey, ctx) {
648
712
  '--model', chatConfig.model || config.defaults.model,
649
713
  '--effort', chatConfig.effort || config.defaults.effort,
650
714
  '--permission-mode', 'bypassPermissions',
651
- '--no-chrome',
715
+ wantChrome ? '--chrome' : '--no-chrome',
652
716
  ];
653
717
  if (chatConfig.agent) args.push('--agent', chatConfig.agent);
654
718
  if (existingSessionId) args.push('--resume', existingSessionId);
@@ -1636,9 +1700,9 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1636
1700
  }
1637
1701
  }
1638
1702
 
1639
- await transcribeVoiceAttachments(downloaded, {
1703
+ const voiceAck = await transcribeVoiceAttachments(downloaded, {
1640
1704
  chatId, msgId: msg.message_id, label, botApi: bot, threadId,
1641
- });
1705
+ }) || { ackEmitted: false };
1642
1706
 
1643
1707
  const prompt = formatPrompt(msg, sessionCtx, downloaded);
1644
1708
  const stopTyping = startTyping({
@@ -1771,6 +1835,11 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1771
1835
  // notifications), updates in place, one emoji per message. Uses
1772
1836
  // setMessageReaction which skips the DB row (the tg() wrapper
1773
1837
  // short-circuits that method), so no transcript spam.
1838
+ // 0.7.4 (item I): probe the chat's available_reactions allowlist on
1839
+ // first turn (cached after). resolveEmoji uses this to pick the best
1840
+ // emoji from each state's chain that's actually permitted in this
1841
+ // group, falling back to a generic set (👍/👀/🔥) before giving up.
1842
+ const availableEmojis = await getReactionAllowlist(bot, chatId);
1774
1843
  const reactor = createReactionManager({
1775
1844
  apply: async (emoji) => {
1776
1845
  const params = {
@@ -1781,13 +1850,20 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1781
1850
  await tg(bot, 'setMessageReaction', params,
1782
1851
  { source: 'status-reaction', botName: BOT_NAME });
1783
1852
  },
1853
+ availableEmojis,
1784
1854
  logError: (m) => console.error(`[${label}] ${m}`),
1785
1855
  });
1786
1856
  // Start at QUEUED (👀) so user sees their message was received but
1787
1857
  // not yet being worked on. pm calls context.onActivate when this
1788
1858
  // pending becomes the queue head (Claude is actually starting it),
1789
1859
  // at which point we flip to THINKING (🤔).
1790
- reactor.setState('QUEUED');
1860
+ // 0.7.4 (item G): if voice ack (👂) was just emitted by
1861
+ // transcribeVoiceAttachments, skip QUEUED — its 👀 would overwrite the
1862
+ // ack within milliseconds, wasting an API call and flickering. Let 👂
1863
+ // stay until THINKING flips it to 🤔 when Claude actually starts work.
1864
+ if (!voiceAck.ackEmitted) {
1865
+ reactor.setState('QUEUED');
1866
+ }
1791
1867
 
1792
1868
  // Mark the inbound row terminal so boot replay doesn't pick it up
1793
1869
  // again. Must fire down EVERY non-throwing exit path (early returns
@@ -1806,7 +1882,12 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1806
1882
  // get routed to their own streamer/reactor.
1807
1883
  const result = await sendToProcess(sessionKey, prompt, {
1808
1884
  streamer, reactor, sourceMsgId: msg.message_id,
1809
- onActivate: () => reactor.setState('THINKING'),
1885
+ // 0.7.4 (item B): fire THINKING when Claude actually starts
1886
+ // emitting (first assistant text or tool_use). Pre-fix, onActivate
1887
+ // (queue-head transition) flipped to THINKING the moment we wrote
1888
+ // stdin, even though Claude could spend hundreds of ms loading.
1889
+ // Result: long flat 🤔 with nothing happening; users assumed stall.
1890
+ onFirstStream: () => reactor.setState('THINKING'),
1810
1891
  });
1811
1892
  const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
1812
1893