@s0nderlabs/anima-plugin-telegram 0.19.19 → 0.20.1

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.19",
3
+ "version": "0.20.1",
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.19",
31
+ "@s0nderlabs/anima-core": "0.20.1",
32
32
  "grammy": "^1.42.0",
33
33
  "zod": "^3.23.8"
34
34
  }
@@ -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
@@ -44,7 +44,9 @@ export {
44
44
  parseBypassCommand,
45
45
  type ActiveSession,
46
46
  type BypassCommand,
47
+ type ParsedBypass,
47
48
  } from './session-state'
49
+ export { buildTelegramCommands, type TelegramBotCommand } from './commands'
48
50
  export {
49
51
  type ApprovalChoice,
50
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'
@@ -223,6 +224,19 @@ export class TelegramListener {
223
224
 
224
225
  await clearWebhookBeforePolling(this.bot)
225
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
+
226
240
  this.refreshTimer = setInterval(() => {
227
241
  if (this.tokenLock && !this.tokenLock.refresh()) {
228
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
  }