@s0nderlabs/anima-plugin-telegram 0.19.18 → 0.20.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@s0nderlabs/anima-plugin-telegram",
3
- "version": "0.19.18",
3
+ "version": "0.20.0",
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.18",
31
+ "@s0nderlabs/anima-core": "0.20.0",
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
+ })
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { buildTelegramCommands } from './commands'
3
+
4
+ describe('buildTelegramCommands', () => {
5
+ it('returns at least the v0.20.0 cross-surface commands', () => {
6
+ const out = buildTelegramCommands()
7
+ const names = out.map(c => c.command)
8
+ expect(names).toContain('yolo')
9
+ expect(names).toContain('perms')
10
+ expect(names).toContain('reset')
11
+ })
12
+
13
+ it('command names have no leading slash', () => {
14
+ for (const c of buildTelegramCommands()) {
15
+ expect(c.command.startsWith('/')).toBe(false)
16
+ }
17
+ })
18
+
19
+ it('every entry has a non-empty description', () => {
20
+ for (const c of buildTelegramCommands()) {
21
+ expect(c.description.length).toBeGreaterThan(0)
22
+ }
23
+ })
24
+
25
+ it('argHint is folded into description for /perms', () => {
26
+ const perms = buildTelegramCommands().find(c => c.command === 'perms')
27
+ expect(perms).toBeDefined()
28
+ expect(perms!.description).toContain('off|prompt|strict')
29
+ })
30
+
31
+ it('respects Telegram length limits (32 / 256)', () => {
32
+ for (const c of buildTelegramCommands()) {
33
+ expect(c.command.length).toBeLessThanOrEqual(32)
34
+ expect(c.description.length).toBeLessThanOrEqual(256)
35
+ }
36
+ })
37
+
38
+ it('omits TUI-only commands', () => {
39
+ const names = buildTelegramCommands().map(c => c.command)
40
+ expect(names).not.toContain('sync')
41
+ expect(names).not.toContain('model')
42
+ expect(names).not.toContain('jobs')
43
+ expect(names).not.toContain('help')
44
+ })
45
+ })
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Builds the Telegram BotCommand list registered via `bot.api.setMyCommands`.
3
+ * Sourced from the shared `@s0nderlabs/anima-core` registry, filtered to
4
+ * surfaces:['tg']. Telegram clips command names to 32 chars and descriptions
5
+ * to 256, so we trim defensively. Argument hints are folded into the
6
+ * description because grammY's BotCommand has no separate hint field.
7
+ */
8
+
9
+ import { commandsForSurface } from '@s0nderlabs/anima-core'
10
+
11
+ export interface TelegramBotCommand {
12
+ command: string
13
+ description: string
14
+ }
15
+
16
+ const NAME_LIMIT = 32
17
+ const DESC_LIMIT = 256
18
+
19
+ export function buildTelegramCommands(): TelegramBotCommand[] {
20
+ const out: TelegramBotCommand[] = []
21
+ for (const c of commandsForSurface('tg')) {
22
+ const name = c.name.slice(0, NAME_LIMIT)
23
+ const hint = c.argHint ? ` <${c.argHint}>` : ''
24
+ const description = `${c.description}${hint}`.slice(0, DESC_LIMIT)
25
+ out.push({ command: name, description })
26
+ }
27
+ return out
28
+ }
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, TELEGRAM_ALLOWED_UPDATES, 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'
@@ -39,7 +44,9 @@ export {
39
44
  parseBypassCommand,
40
45
  type ActiveSession,
41
46
  type BypassCommand,
47
+ type ParsedBypass,
42
48
  } from './session-state'
49
+ export { buildTelegramCommands, type TelegramBotCommand } from './commands'
43
50
  export {
44
51
  type ApprovalChoice,
45
52
  APPROVAL_CALLBACK_PREFIX,
package/src/listener.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Bot, type Context, GrammyError, HttpError } from 'grammy'
2
2
  import { type ApprovalChoice, parseCallbackData } from './approval-keyboard'
3
3
  import { escapeChunkSuffixForMarkdownV2, splitMessage } from './chunking'
4
+ import { buildTelegramCommands } from './commands'
4
5
  import { DebounceBuffer, type FlushedBatch } from './debounce'
5
6
  import { formatTelegramChannel } from './format'
6
7
  import { RateLimiter } from './limits'
@@ -38,6 +39,23 @@ import { startTypingLoop } from './typing'
38
39
  const RETRY_INTERVAL_MS = 30_000
39
40
  const MAX_LOCK_RETRY_ATTEMPTS = 12
40
41
 
42
+ /**
43
+ * Map an `ApprovalChoice` to the human-readable resolution label appended
44
+ * to the approval message after a click. Mirrors the keyboard labels so
45
+ * operators see the same wording in the resolved message that they tapped.
46
+ */
47
+ export function formatApprovalResolution(choice: ApprovalChoice, byUserId: number): string {
48
+ const label =
49
+ choice === 'once'
50
+ ? '✅ Allowed once'
51
+ : choice === 'session'
52
+ ? '✅ Allowed for session'
53
+ : choice === 'always'
54
+ ? '✅ Always allowed'
55
+ : '❌ Denied'
56
+ return `${label} (by ${byUserId})`
57
+ }
58
+
41
59
  /**
42
60
  * Update kinds we ask Telegram to deliver via long-poll.
43
61
  *
@@ -136,6 +154,24 @@ export class TelegramListener {
136
154
  } catch {
137
155
  /* ignore */
138
156
  }
157
+ // Resolve the modal visually: append the choice + drop the inline
158
+ // keyboard. Without this, every clicked approval message stays on
159
+ // screen with all four buttons, leaving operators unsure whether
160
+ // their tap registered. Best-effort — if the edit fails (rate
161
+ // limit, message age, deleted), the underlying approval is still
162
+ // resolved at the runtime level, so we swallow the error.
163
+ const originalText =
164
+ typeof q.message?.text === 'string' && q.message.text.length > 0 ? q.message.text : null
165
+ const suffix = formatApprovalResolution(parsed.choice, q.from.id)
166
+ try {
167
+ if (originalText) {
168
+ await ctx.editMessageText(`${originalText}\n\n${suffix}`, { reply_markup: undefined })
169
+ } else {
170
+ await ctx.editMessageReplyMarkup({ reply_markup: undefined })
171
+ }
172
+ } catch {
173
+ /* ignore — modal was clicked, runtime already resolved; keyboard cleanup is cosmetic */
174
+ }
139
175
  }
140
176
 
141
177
  async start(): Promise<void> {
@@ -188,6 +224,19 @@ export class TelegramListener {
188
224
 
189
225
  await clearWebhookBeforePolling(this.bot)
190
226
 
227
+ // v0.20.0: register the bot command menu so Telegram clients show
228
+ // the autocomplete list when the operator types `/`. Sourced from the
229
+ // shared registry; safe to call repeatedly (Telegram dedupes).
230
+ try {
231
+ await this.bot.api.setMyCommands(buildTelegramCommands(), {
232
+ scope: { type: 'default' },
233
+ })
234
+ } catch (err) {
235
+ console.warn(
236
+ `[telegram] setMyCommands failed (non-fatal): ${(err as Error).message?.slice(0, 200) ?? 'unknown'}`,
237
+ )
238
+ }
239
+
191
240
  this.refreshTimer = setInterval(() => {
192
241
  if (this.tokenLock && !this.tokenLock.refresh()) {
193
242
  console.warn('[telegram] token lock lost - stopping listener')
@@ -7,20 +7,33 @@ describe('parseBypassCommand', () => {
7
7
  expect(parseBypassCommand('what time is it')).toBeNull()
8
8
  })
9
9
 
10
- it('matches each bypass command verbatim', () => {
10
+ it('matches each bypass command verbatim with empty args', () => {
11
11
  for (const cmd of BYPASS_COMMANDS) {
12
- expect(parseBypassCommand(cmd)).toBe(cmd)
12
+ const r = parseBypassCommand(cmd)
13
+ expect(r).toEqual({ command: cmd, args: [] })
13
14
  }
14
15
  })
15
16
 
16
- it('is case-insensitive', () => {
17
- expect(parseBypassCommand('/STOP')).toBe('/stop')
18
- expect(parseBypassCommand('/Reset')).toBe('/reset')
17
+ it('is case-insensitive on the command name', () => {
18
+ expect(parseBypassCommand('/STOP')?.command).toBe('/stop')
19
+ expect(parseBypassCommand('/Reset')?.command).toBe('/reset')
19
20
  })
20
21
 
21
- it('ignores args after the command', () => {
22
- expect(parseBypassCommand('/stop please')).toBe('/stop')
23
- expect(parseBypassCommand('/new with arg')).toBe('/new')
22
+ it('captures whitespace-split args after the command', () => {
23
+ expect(parseBypassCommand('/stop please')).toEqual({ command: '/stop', args: ['please'] })
24
+ expect(parseBypassCommand('/new with arg')).toEqual({
25
+ command: '/new',
26
+ args: ['with', 'arg'],
27
+ })
28
+ })
29
+
30
+ it('parses /perms with mode arg', () => {
31
+ expect(parseBypassCommand('/perms strict')).toEqual({ command: '/perms', args: ['strict'] })
32
+ expect(parseBypassCommand('/perms')).toEqual({ command: '/perms', args: [] })
33
+ })
34
+
35
+ it('parses /yolo with no args', () => {
36
+ expect(parseBypassCommand('/yolo')).toEqual({ command: '/yolo', args: [] })
24
37
  })
25
38
 
26
39
  it('returns null for unknown slash commands', () => {
@@ -34,7 +47,7 @@ describe('parseBypassCommand', () => {
34
47
  })
35
48
 
36
49
  it('strips leading whitespace', () => {
37
- expect(parseBypassCommand(' /stop')).toBe('/stop')
50
+ expect(parseBypassCommand(' /stop')?.command).toBe('/stop')
38
51
  })
39
52
  })
40
53
 
@@ -4,10 +4,9 @@
4
4
  // dispatch is the load-bearing detail: without it, two messages in the same
5
5
  // event-loop tick can both pass the active-check and both spawn brain turns.
6
6
  //
7
- // Bypass commands (verbatim from hermes base.py:1430): /approve, /deny,
8
- // /status, /stop, /new, /reset, /background, /restart. These are dispatched
9
- // inline (skipping the active-session guard) so the operator can interrupt
10
- // or steer a turn that's already mid-flight from their phone.
7
+ // Bypass commands: hermes-derived (/approve, /deny, /status, /stop, /new,
8
+ // /reset, /background, /restart) plus v0.20.0 additions (/yolo, /perms) so
9
+ // operators can flip permission mode from their phone without restarting.
11
10
 
12
11
  export const BYPASS_COMMANDS = [
13
12
  '/stop',
@@ -18,22 +17,32 @@ export const BYPASS_COMMANDS = [
18
17
  '/deny',
19
18
  '/background',
20
19
  '/restart',
20
+ '/yolo',
21
+ '/perms',
21
22
  ] as const
22
23
 
23
24
  export type BypassCommand = (typeof BYPASS_COMMANDS)[number]
24
25
 
26
+ export interface ParsedBypass {
27
+ command: BypassCommand
28
+ args: string[]
29
+ }
30
+
25
31
  /**
26
32
  * Detect a bypass command at the start of an inbound message. Returns the
27
- * canonical lowercase command if matched, else null. Args after the command
28
- * (e.g. `/stop please`) are ignored only the leading slash-token matters.
33
+ * canonical lowercase command + whitespace-split args, or null when the
34
+ * message isn't a bypass command. v0.20.0 changed the return shape from
35
+ * `BypassCommand | null` to `ParsedBypass | null` so handlers (especially
36
+ * `/perms <mode>`) can read the args alongside the name.
29
37
  */
30
- export function parseBypassCommand(text: string): BypassCommand | null {
38
+ export function parseBypassCommand(text: string): ParsedBypass | null {
31
39
  const trimmed = text.trim()
32
40
  if (!trimmed.startsWith('/')) return null
33
- const head = trimmed.split(/\s+/)[0]?.toLowerCase()
41
+ const parts = trimmed.split(/\s+/)
42
+ const head = parts[0]?.toLowerCase()
34
43
  if (!head) return null
35
44
  if ((BYPASS_COMMANDS as readonly string[]).includes(head)) {
36
- return head as BypassCommand
45
+ return { command: head as BypassCommand, args: parts.slice(1) }
37
46
  }
38
47
  return null
39
48
  }