@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 +2 -2
- package/src/approval-resolution.test.ts +25 -0
- package/src/commands.test.ts +45 -0
- package/src/commands.ts +28 -0
- package/src/index.ts +8 -1
- package/src/listener.ts +49 -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.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.
|
|
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
|
+
})
|
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
|
@@ -27,7 +27,12 @@ export type {
|
|
|
27
27
|
TelegramToolEvent,
|
|
28
28
|
} from './types'
|
|
29
29
|
export { ProgressTracker, PROGRESS_EDIT_INTERVAL } from './progress'
|
|
30
|
-
export {
|
|
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
|
-
|
|
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
|
}
|