@s0nderlabs/anima-plugin-telegram 0.19.17 → 0.19.19

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@s0nderlabs/anima-plugin-telegram",
3
- "version": "0.19.17",
3
+ "version": "0.19.19",
4
4
  "type": "module",
5
5
  "description": "Telegram gateway plugin for anima — long-poll bot, debounced dispatch, reactions, allowlist",
6
6
  "license": "MIT",
@@ -28,7 +28,7 @@
28
28
  "test": "bun test"
29
29
  },
30
30
  "dependencies": {
31
- "@s0nderlabs/anima-core": "0.19.17",
31
+ "@s0nderlabs/anima-core": "0.19.19",
32
32
  "grammy": "^1.42.0",
33
33
  "zod": "^3.23.8"
34
34
  }
@@ -0,0 +1,25 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { formatApprovalResolution } from './listener'
3
+
4
+ /**
5
+ * Regression test for v0.19.19: every choice maps to a human-readable
6
+ * suffix so the post-click modal edit shows the operator what they tapped.
7
+ * v0.19.18 left modals visible after click because the listener only
8
+ * answered the popup but never edited the message body. v0.19.19 edits the
9
+ * text to append this suffix and removes the inline keyboard.
10
+ */
11
+ describe('formatApprovalResolution', () => {
12
+ it('labels each choice distinctly with the clicker user id', () => {
13
+ expect(formatApprovalResolution('once', 42)).toBe('✅ Allowed once (by 42)')
14
+ expect(formatApprovalResolution('session', 42)).toBe('✅ Allowed for session (by 42)')
15
+ expect(formatApprovalResolution('always', 42)).toBe('✅ Always allowed (by 42)')
16
+ expect(formatApprovalResolution('deny', 42)).toBe('❌ Denied (by 42)')
17
+ })
18
+
19
+ it('uses ✅ for permitting choices and ❌ only for deny', () => {
20
+ for (const choice of ['once', 'session', 'always'] as const) {
21
+ expect(formatApprovalResolution(choice, 1)).toMatch(/^✅/)
22
+ }
23
+ expect(formatApprovalResolution('deny', 1)).toMatch(/^❌/)
24
+ })
25
+ })
package/src/index.ts CHANGED
@@ -27,7 +27,12 @@ export type {
27
27
  TelegramToolEvent,
28
28
  } from './types'
29
29
  export { ProgressTracker, PROGRESS_EDIT_INTERVAL } from './progress'
30
- export { TelegramListener, capForTelegram } from './listener'
30
+ export {
31
+ TelegramListener,
32
+ TELEGRAM_ALLOWED_UPDATES,
33
+ capForTelegram,
34
+ formatApprovalResolution,
35
+ } from './listener'
31
36
  export { buildSessionKey, sanitizeAgentName } from './session-key'
32
37
  export { formatTelegramChannel, formatInboundPreview } from './format'
33
38
  export { RateLimiter } from './limits'
@@ -0,0 +1,25 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { TELEGRAM_ALLOWED_UPDATES } from './listener'
3
+
4
+ /**
5
+ * Regression test for v0.19.18 callback_query polling fix.
6
+ *
7
+ * v0.18.0 introduced inline-keyboard approvals but only subscribed to
8
+ * `'message'` updates in `bot.start({ allowed_updates })`. v0.19.10 fixed
9
+ * the handler-registration path but left the polling spec narrow, so every
10
+ * keyboard tap was silently filtered out by Telegram before grammY ever
11
+ * saw it. Operators saw the modal "do nothing" — the harness saw zero
12
+ * resolution events. This test pins both kinds in the polling spec so a
13
+ * future refactor cannot quietly drop the second one again.
14
+ */
15
+ describe('TELEGRAM_ALLOWED_UPDATES', () => {
16
+ it('subscribes to both message and callback_query updates', () => {
17
+ expect(TELEGRAM_ALLOWED_UPDATES).toContain('message')
18
+ expect(TELEGRAM_ALLOWED_UPDATES).toContain('callback_query')
19
+ })
20
+
21
+ it('does not over-subscribe to update kinds we have no handler for', () => {
22
+ // Keep the wire payload minimal. Add new kinds only when a handler exists.
23
+ expect([...TELEGRAM_ALLOWED_UPDATES].sort()).toEqual(['callback_query', 'message'])
24
+ })
25
+ })
package/src/listener.ts CHANGED
@@ -38,6 +38,40 @@ import { startTypingLoop } from './typing'
38
38
  const RETRY_INTERVAL_MS = 30_000
39
39
  const MAX_LOCK_RETRY_ATTEMPTS = 12
40
40
 
41
+ /**
42
+ * Map an `ApprovalChoice` to the human-readable resolution label appended
43
+ * to the approval message after a click. Mirrors the keyboard labels so
44
+ * operators see the same wording in the resolved message that they tapped.
45
+ */
46
+ export function formatApprovalResolution(choice: ApprovalChoice, byUserId: number): string {
47
+ const label =
48
+ choice === 'once'
49
+ ? '✅ Allowed once'
50
+ : choice === 'session'
51
+ ? '✅ Allowed for session'
52
+ : choice === 'always'
53
+ ? '✅ Always allowed'
54
+ : '❌ Denied'
55
+ return `${label} (by ${byUserId})`
56
+ }
57
+
58
+ /**
59
+ * Update kinds we ask Telegram to deliver via long-poll.
60
+ *
61
+ * `'message'` covers inbound DMs we dispatch to the brain. `'callback_query'`
62
+ * covers inline-keyboard taps (the [Allow Once / Session / Always / Deny]
63
+ * buttons rendered for tool approvals). Without `'callback_query'` here,
64
+ * Telegram silently filters the click events out of `getUpdates`, the
65
+ * `bot.on('callback_query:data', ...)` handler never fires, and operator
66
+ * taps register on the device but never reach the harness — the modal
67
+ * appears stuck and the brain's tool call hangs until timeout.
68
+ *
69
+ * Latent bug from v0.18.0 (the introduction of inline-keyboard approvals);
70
+ * v0.19.10 fixed handler registration but not the polling spec, which is
71
+ * why no live drive caught it before v0.19.18.
72
+ */
73
+ export const TELEGRAM_ALLOWED_UPDATES = ['message', 'callback_query'] as const
74
+
41
75
  export interface TelegramListenerOpts extends TelegramRuntimeContext {
42
76
  /** Optional override of the Telegram Bot API root. Used by the mock-bot test. */
43
77
  apiRoot?: string
@@ -119,6 +153,24 @@ export class TelegramListener {
119
153
  } catch {
120
154
  /* ignore */
121
155
  }
156
+ // Resolve the modal visually: append the choice + drop the inline
157
+ // keyboard. Without this, every clicked approval message stays on
158
+ // screen with all four buttons, leaving operators unsure whether
159
+ // their tap registered. Best-effort — if the edit fails (rate
160
+ // limit, message age, deleted), the underlying approval is still
161
+ // resolved at the runtime level, so we swallow the error.
162
+ const originalText =
163
+ typeof q.message?.text === 'string' && q.message.text.length > 0 ? q.message.text : null
164
+ const suffix = formatApprovalResolution(parsed.choice, q.from.id)
165
+ try {
166
+ if (originalText) {
167
+ await ctx.editMessageText(`${originalText}\n\n${suffix}`, { reply_markup: undefined })
168
+ } else {
169
+ await ctx.editMessageReplyMarkup({ reply_markup: undefined })
170
+ }
171
+ } catch {
172
+ /* ignore — modal was clicked, runtime already resolved; keyboard cleanup is cosmetic */
173
+ }
122
174
  }
123
175
 
124
176
  async start(): Promise<void> {
@@ -182,7 +234,7 @@ export class TelegramListener {
182
234
  .start({
183
235
  onStart: info => console.log(`[telegram] listener active @${info.username}`),
184
236
  drop_pending_updates: true,
185
- allowed_updates: ['message'],
237
+ allowed_updates: [...TELEGRAM_ALLOWED_UPDATES],
186
238
  })
187
239
  .catch(err => {
188
240
  const verdict = classifyStartFailure(err)