@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 +2 -2
- package/src/commands.test.ts +45 -0
- package/src/commands.ts +28 -0
- package/src/index.ts +2 -0
- package/src/listener.ts +14 -0
- package/src/session-state.test.ts +22 -9
- package/src/session-state.ts +18 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@s0nderlabs/anima-plugin-telegram",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
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
|
+
})
|
package/src/commands.ts
ADDED
|
@@ -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
|
-
|
|
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('
|
|
22
|
-
expect(parseBypassCommand('/stop please')).
|
|
23
|
-
expect(parseBypassCommand('/new with arg')).
|
|
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
|
|
package/src/session-state.ts
CHANGED
|
@@ -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
|
|
8
|
-
// /
|
|
9
|
-
//
|
|
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
|
|
28
|
-
*
|
|
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):
|
|
38
|
+
export function parseBypassCommand(text: string): ParsedBypass | null {
|
|
31
39
|
const trimmed = text.trim()
|
|
32
40
|
if (!trimmed.startsWith('/')) return null
|
|
33
|
-
const
|
|
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
|
}
|