@s0nderlabs/anima-plugin-telegram 0.19.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.
@@ -0,0 +1,128 @@
1
+ import type { PairingStore } from '@s0nderlabs/anima-core'
2
+ import type { TelegramInboundEvent } from './types'
3
+
4
+ /**
5
+ * MVP filter: only DMs from authorized users are dispatched. Group chat,
6
+ * channel posts, forwarded messages, and bot-to-bot messages are dropped.
7
+ *
8
+ * Authorization (hermes default-deny model):
9
+ * - If `allowedUserIds` contains the sender, accept.
10
+ * - Else if `pairingStore` is provided and the sender is approved, accept.
11
+ * - Else if `pairingStore` is provided, generate a pairing code and return
12
+ * `{ ok: false, action: 'send-pairing-code', code }` so the listener can
13
+ * DM the code to the sender. The operator approves out-of-band via
14
+ * `anima pairing approve telegram <code>`.
15
+ * - Else (no allowlist + no pairing) reject with `no-allowlist-default-deny`.
16
+ *
17
+ * Returns null when the message should be dropped (with reason in debug logs).
18
+ * Returns a normalized TelegramInboundEvent when accepted.
19
+ */
20
+ export interface SanitizeOpts {
21
+ allowedUserIds: number[]
22
+ /** Hard cap on text length. Default 2000 chars (TG max is 4096). */
23
+ maxTextLength?: number
24
+ /** Optional pairing store. When present, unknown senders get a pairing code. */
25
+ pairingStore?: Pick<PairingStore, 'isApproved' | 'generateCode'>
26
+ /** Platform key passed to pairingStore (always 'telegram' for this plugin). */
27
+ pairingPlatform?: string
28
+ }
29
+
30
+ export interface SanitizeInput {
31
+ chatType: 'private' | 'group' | 'supergroup' | 'channel'
32
+ chatId: number
33
+ fromId: number | null
34
+ fromIsBot: boolean
35
+ fromUsername: string | null
36
+ fromFirstName: string | null
37
+ fromLastName: string | null
38
+ text: string | null
39
+ messageId: number
40
+ forwardedFrom: unknown
41
+ mediaGroupId: string | null
42
+ }
43
+
44
+ export type SanitizeResult =
45
+ | { ok: true; event: TelegramInboundEvent }
46
+ | {
47
+ ok: false
48
+ reason: SanitizeReason
49
+ action?: 'send-pairing-code'
50
+ code?: string
51
+ pairedUserId?: number
52
+ pairedUserName?: string | null
53
+ }
54
+
55
+ export type SanitizeReason =
56
+ | 'not-private-chat'
57
+ | 'sender-is-bot'
58
+ | 'sender-not-allowed'
59
+ | 'no-allowlist-default-deny'
60
+ | 'pairing-rate-limited'
61
+ | 'forwarded-message'
62
+ | 'no-text'
63
+ | 'no-sender-id'
64
+ | 'media-group'
65
+
66
+ export function sanitizeInbound(input: SanitizeInput, opts: SanitizeOpts): SanitizeResult {
67
+ if (input.chatType !== 'private') return { ok: false, reason: 'not-private-chat' }
68
+ if (input.fromIsBot) return { ok: false, reason: 'sender-is-bot' }
69
+ if (input.fromId === null) return { ok: false, reason: 'no-sender-id' }
70
+ if (input.forwardedFrom != null) return { ok: false, reason: 'forwarded-message' }
71
+ if (input.mediaGroupId != null) return { ok: false, reason: 'media-group' }
72
+ if (typeof input.text !== 'string' || input.text.trim().length === 0) {
73
+ return { ok: false, reason: 'no-text' }
74
+ }
75
+
76
+ const platform = opts.pairingPlatform ?? 'telegram'
77
+ const inAllowlist = opts.allowedUserIds.includes(input.fromId)
78
+ const pairingApproved = opts.pairingStore?.isApproved(platform, String(input.fromId)) ?? false
79
+
80
+ if (!inAllowlist && !pairingApproved) {
81
+ if (opts.pairingStore) {
82
+ const code = opts.pairingStore.generateCode(
83
+ platform,
84
+ String(input.fromId),
85
+ input.fromUsername ?? formatDisplayName(input.fromFirstName, input.fromLastName) ?? '',
86
+ )
87
+ if (code) {
88
+ return {
89
+ ok: false,
90
+ reason: 'sender-not-allowed',
91
+ action: 'send-pairing-code',
92
+ code,
93
+ pairedUserId: input.fromId,
94
+ pairedUserName:
95
+ input.fromUsername ?? formatDisplayName(input.fromFirstName, input.fromLastName),
96
+ }
97
+ }
98
+ return { ok: false, reason: 'pairing-rate-limited' }
99
+ }
100
+ if (opts.allowedUserIds.length === 0) {
101
+ return { ok: false, reason: 'no-allowlist-default-deny' }
102
+ }
103
+ return { ok: false, reason: 'sender-not-allowed' }
104
+ }
105
+
106
+ const cap = opts.maxTextLength ?? 2000
107
+ let text = input.text
108
+ if (text.length > cap) text = `${text.slice(0, cap)}\n[message truncated]`
109
+ const displayName = formatDisplayName(input.fromFirstName, input.fromLastName)
110
+ return {
111
+ ok: true,
112
+ event: {
113
+ chatId: input.chatId,
114
+ userId: input.fromId,
115
+ username: input.fromUsername,
116
+ displayName,
117
+ text,
118
+ messageId: input.messageId,
119
+ ts: Date.now(),
120
+ },
121
+ }
122
+ }
123
+
124
+ function formatDisplayName(first: string | null, last: string | null): string | null {
125
+ const parts = [first, last].filter((s): s is string => typeof s === 'string' && s.length > 0)
126
+ if (parts.length === 0) return null
127
+ return parts.join(' ')
128
+ }
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { buildSessionKey, sanitizeAgentName } from './session-key'
3
+
4
+ describe('buildSessionKey', () => {
5
+ it('DM key for vanilla agent name + chat id', () => {
6
+ expect(buildSessionKey({ agentName: 'specter', chatId: 12345 })).toBe(
7
+ 'agent:specter:telegram:dm:12345',
8
+ )
9
+ })
10
+ it('group key with thread id', () => {
11
+ expect(
12
+ buildSessionKey({ agentName: 'enigma', chatId: -100123, threadId: 7, isGroup: true }),
13
+ ).toBe('agent:enigma:telegram:group:-100123:7')
14
+ })
15
+ it('group key without thread id falls back to thread 0', () => {
16
+ expect(buildSessionKey({ agentName: 'enigma', chatId: -100123, isGroup: true })).toBe(
17
+ 'agent:enigma:telegram:group:-100123:0',
18
+ )
19
+ })
20
+ it('agent name is sanitized', () => {
21
+ expect(buildSessionKey({ agentName: 'Spec/t.er!', chatId: 1 })).toBe(
22
+ 'agent:specter:telegram:dm:1',
23
+ )
24
+ })
25
+ it('empty agent name falls back to anima', () => {
26
+ expect(buildSessionKey({ agentName: ' ', chatId: 1 })).toBe('agent:anima:telegram:dm:1')
27
+ })
28
+ })
29
+
30
+ describe('sanitizeAgentName', () => {
31
+ it('lowercases', () => {
32
+ expect(sanitizeAgentName('SPECTER')).toBe('specter')
33
+ })
34
+ it('strips special chars', () => {
35
+ expect(sanitizeAgentName('foo.bar/baz!')).toBe('foobarbaz')
36
+ })
37
+ it('preserves hyphens', () => {
38
+ expect(sanitizeAgentName('foo-bar')).toBe('foo-bar')
39
+ })
40
+ })
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Pure helpers for building stable session keys per inbound surface. The brain
3
+ * prompt's `<channel source="telegram" chat="..." user="...">` wrapping uses
4
+ * these so memory writes can be partitioned per chat (future) and rate
5
+ * limiting can scope per chat.
6
+ *
7
+ * Format mirrors hermes:
8
+ * agent:<name>:telegram:dm:<chatId>
9
+ * agent:<name>:telegram:group:<chatId>:<threadId> (post-MVP)
10
+ *
11
+ * Pure — no IO, no mutation. Safe to test exhaustively.
12
+ */
13
+ export interface BuildSessionKeyInput {
14
+ agentName: string
15
+ chatId: number
16
+ threadId?: number
17
+ isGroup?: boolean
18
+ }
19
+
20
+ export function buildSessionKey(input: BuildSessionKeyInput): string {
21
+ const safeName = sanitizeAgentName(input.agentName)
22
+ if (input.isGroup) {
23
+ const t = input.threadId ?? 0
24
+ return `agent:${safeName}:telegram:group:${input.chatId}:${t}`
25
+ }
26
+ return `agent:${safeName}:telegram:dm:${input.chatId}`
27
+ }
28
+
29
+ /**
30
+ * Strip characters that would confuse memory paths or prompt parsing.
31
+ * Allow lowercase alpha + digits + hyphen only. Empty string falls back to
32
+ * "anima" so the key is always well-formed.
33
+ */
34
+ export function sanitizeAgentName(raw: string): string {
35
+ const cleaned = raw.toLowerCase().replace(/[^a-z0-9-]/g, '')
36
+ return cleaned.length > 0 ? cleaned : 'anima'
37
+ }
@@ -0,0 +1,103 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { ActiveSessionTracker, BYPASS_COMMANDS, parseBypassCommand } from './session-state'
3
+
4
+ describe('parseBypassCommand', () => {
5
+ it('returns null for non-slash text', () => {
6
+ expect(parseBypassCommand('hello there')).toBeNull()
7
+ expect(parseBypassCommand('what time is it')).toBeNull()
8
+ })
9
+
10
+ it('matches each bypass command verbatim', () => {
11
+ for (const cmd of BYPASS_COMMANDS) {
12
+ expect(parseBypassCommand(cmd)).toBe(cmd)
13
+ }
14
+ })
15
+
16
+ it('is case-insensitive', () => {
17
+ expect(parseBypassCommand('/STOP')).toBe('/stop')
18
+ expect(parseBypassCommand('/Reset')).toBe('/reset')
19
+ })
20
+
21
+ it('ignores args after the command', () => {
22
+ expect(parseBypassCommand('/stop please')).toBe('/stop')
23
+ expect(parseBypassCommand('/new with arg')).toBe('/new')
24
+ })
25
+
26
+ it('returns null for unknown slash commands', () => {
27
+ expect(parseBypassCommand('/foobar')).toBeNull()
28
+ expect(parseBypassCommand('/help')).toBeNull()
29
+ })
30
+
31
+ it('returns null for empty text', () => {
32
+ expect(parseBypassCommand('')).toBeNull()
33
+ expect(parseBypassCommand(' ')).toBeNull()
34
+ })
35
+
36
+ it('strips leading whitespace', () => {
37
+ expect(parseBypassCommand(' /stop')).toBe('/stop')
38
+ })
39
+ })
40
+
41
+ describe('ActiveSessionTracker', () => {
42
+ it('isActive returns false initially', () => {
43
+ const t = new ActiveSessionTracker()
44
+ expect(t.isActive('a')).toBe(false)
45
+ })
46
+
47
+ it('markActive then markIdle round-trip', () => {
48
+ const t = new ActiveSessionTracker()
49
+ t.markActive('a')
50
+ expect(t.isActive('a')).toBe(true)
51
+ t.markIdle('a')
52
+ expect(t.isActive('a')).toBe(false)
53
+ })
54
+
55
+ it('different keys do not collide', () => {
56
+ const t = new ActiveSessionTracker()
57
+ t.markActive('a')
58
+ expect(t.isActive('b')).toBe(false)
59
+ })
60
+
61
+ it('abortActive calls the stored AbortController', () => {
62
+ const t = new ActiveSessionTracker()
63
+ const ctrl = new AbortController()
64
+ t.markActive('a', ctrl)
65
+ expect(t.abortActive('a')).toBe(true)
66
+ expect(ctrl.signal.aborted).toBe(true)
67
+ })
68
+
69
+ it('abortActive returns false when there is no active session', () => {
70
+ const t = new ActiveSessionTracker()
71
+ expect(t.abortActive('a')).toBe(false)
72
+ })
73
+
74
+ it('abortActive returns false when active session has no AbortController', () => {
75
+ const t = new ActiveSessionTracker()
76
+ t.markActive('a', null)
77
+ expect(t.abortActive('a')).toBe(false)
78
+ })
79
+
80
+ it('setPending / takePending one-shot', () => {
81
+ const t = new ActiveSessionTracker()
82
+ t.setPending('a', { kind: 'queued' })
83
+ expect(t.takePending('a')).toEqual({ kind: 'queued' })
84
+ expect(t.takePending('a')).toBeUndefined()
85
+ })
86
+
87
+ it('activeKeys lists all active session keys', () => {
88
+ const t = new ActiveSessionTracker()
89
+ t.markActive('a')
90
+ t.markActive('b')
91
+ expect(t.activeKeys().sort()).toEqual(['a', 'b'])
92
+ })
93
+
94
+ it('synchronous mark-active closes the race window', () => {
95
+ // Simulate two messages arriving in the same tick: only the first should
96
+ // see isActive=false; the second sees isActive=true even though no async
97
+ // work has started yet.
98
+ const t = new ActiveSessionTracker()
99
+ expect(t.isActive('a')).toBe(false)
100
+ t.markActive('a')
101
+ expect(t.isActive('a')).toBe(true)
102
+ })
103
+ })
@@ -0,0 +1,93 @@
1
+ // Active-session tracker for telegram turns.
2
+ //
3
+ // Mirrors hermes base.py:1417-1488. The synchronous mark-active BEFORE async
4
+ // dispatch is the load-bearing detail: without it, two messages in the same
5
+ // event-loop tick can both pass the active-check and both spawn brain turns.
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.
11
+
12
+ export const BYPASS_COMMANDS = [
13
+ '/stop',
14
+ '/new',
15
+ '/reset',
16
+ '/status',
17
+ '/approve',
18
+ '/deny',
19
+ '/background',
20
+ '/restart',
21
+ ] as const
22
+
23
+ export type BypassCommand = (typeof BYPASS_COMMANDS)[number]
24
+
25
+ /**
26
+ * 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.
29
+ */
30
+ export function parseBypassCommand(text: string): BypassCommand | null {
31
+ const trimmed = text.trim()
32
+ if (!trimmed.startsWith('/')) return null
33
+ const head = trimmed.split(/\s+/)[0]?.toLowerCase()
34
+ if (!head) return null
35
+ if ((BYPASS_COMMANDS as readonly string[]).includes(head)) {
36
+ return head as BypassCommand
37
+ }
38
+ return null
39
+ }
40
+
41
+ export interface ActiveSession {
42
+ abortCtrl: AbortController | null
43
+ startedAt: number
44
+ }
45
+
46
+ /**
47
+ * Per-session-key state machine. `markActive` MUST be called synchronously
48
+ * BEFORE any async dispatch (the race-window-closing detail). `markIdle`
49
+ * fires from the dispatch's `finally`. `setPending` queues an interrupt
50
+ * payload; the dispatcher reads it on the next iteration.
51
+ */
52
+ export class ActiveSessionTracker {
53
+ readonly #sessions = new Map<string, ActiveSession>()
54
+ readonly #pending = new Map<string, unknown>()
55
+
56
+ isActive(key: string): boolean {
57
+ return this.#sessions.has(key)
58
+ }
59
+
60
+ markActive(key: string, abortCtrl: AbortController | null = null): void {
61
+ this.#sessions.set(key, { abortCtrl, startedAt: Date.now() })
62
+ }
63
+
64
+ markIdle(key: string): void {
65
+ this.#sessions.delete(key)
66
+ }
67
+
68
+ getAbortController(key: string): AbortController | null {
69
+ return this.#sessions.get(key)?.abortCtrl ?? null
70
+ }
71
+
72
+ /** Called by `/stop` bypass: aborts the active turn for `key` if any. */
73
+ abortActive(key: string): boolean {
74
+ const session = this.#sessions.get(key)
75
+ if (!session?.abortCtrl) return false
76
+ session.abortCtrl.abort()
77
+ return true
78
+ }
79
+
80
+ setPending(key: string, event: unknown): void {
81
+ this.#pending.set(key, event)
82
+ }
83
+
84
+ takePending(key: string): unknown | undefined {
85
+ const v = this.#pending.get(key)
86
+ this.#pending.delete(key)
87
+ return v
88
+ }
89
+
90
+ activeKeys(): string[] {
91
+ return Array.from(this.#sessions.keys())
92
+ }
93
+ }
package/src/types.ts ADDED
@@ -0,0 +1,114 @@
1
+ import type { PairingStore } from '@s0nderlabs/anima-core'
2
+
3
+ /**
4
+ * Side-band runtime context for plugin-telegram. The CLI (chat.tsx, local
5
+ * mode) or harness (build-runtime.ts, sandbox mode) builds this and attaches
6
+ * it to the plugin context as `(ctx as any).telegram` before loadPlugins.
7
+ *
8
+ * Without this side-band, the plugin registers nothing (soft-init for unit
9
+ * tests / non-telegram contexts). Mirrors the comms / onchain pattern.
10
+ */
11
+ export interface TelegramRuntimeContext {
12
+ /** Bot token from @BotFather, post-decryption. NEVER goes to activity log. */
13
+ botToken: string
14
+ /**
15
+ * Telegram user IDs allowed to DM this bot. Anyone else's messages are
16
+ * dropped silently (no reply, no reaction, no log entry beyond a debug line).
17
+ */
18
+ allowedUserIds: number[]
19
+ /**
20
+ * Agent's display name (e.g. "specter", "enigma"). Used in session-key
21
+ * formatting so each agent's TG context is distinct in the brain prompt.
22
+ */
23
+ agentName: string
24
+ /**
25
+ * Brain dispatch callback. The listener invokes this when a debounced inbound
26
+ * is ready. The CLI or harness implementation:
27
+ * 1. wraps `text` in a `<channel source="telegram" ...>` prompt fragment
28
+ * 2. fires brain.infer with `source: 'telegram'`
29
+ * 3. flushes per-turn sync
30
+ * 4. returns the assistant string for the listener to send back via TG
31
+ */
32
+ dispatchUserMessage: (input: TelegramDispatchInput) => Promise<TelegramDispatchResult>
33
+ /** Optional hook fired before reaction transitions to 👀. CLI may push a TUI row. */
34
+ onProcessingStart?: (chatId: number, messageId: number) => Promise<void> | void
35
+ /** Optional hook fired after reaction transitions to 👍/👎. */
36
+ onProcessingEnd?: (chatId: number, messageId: number, ok: boolean) => Promise<void> | void
37
+ /** Verbose grammy logs. Default false. */
38
+ debug?: boolean
39
+ /**
40
+ * Optional pairing store. When present, unknown senders get a pairing code
41
+ * via DM and the operator approves via `anima pairing approve telegram <code>`.
42
+ * When absent, the listener uses static allowlist only (default-deny on empty).
43
+ */
44
+ pairingStore?: PairingStore
45
+ /**
46
+ * Optional approval bridge. When present, the listener fills the inner
47
+ * `sendApproval` + `installCallbackHandler` slots on start so chat-telegram
48
+ * (or the harness build-runtime in sandbox mode) can swap a TG-side
49
+ * permission prompter at the start of a turn. When absent, the local TUI
50
+ * modal handles all approvals as before.
51
+ */
52
+ approvalBridge?: TelegramApprovalBridge
53
+ }
54
+
55
+ export type ApprovalChoiceKind = 'once' | 'session' | 'always' | 'deny'
56
+
57
+ /**
58
+ * Mutable bridge object created by the dispatcher (chat-telegram or
59
+ * harness/build-runtime) and filled by the listener on start. The dispatcher
60
+ * holds the resolver Map; the listener holds the bot. They cooperate via this
61
+ * bridge so the inline-keyboard approval can roundtrip TG → brain → TG.
62
+ */
63
+ export interface TelegramApprovalBridge {
64
+ /** Filled by listener.start(). Sends the approval inline keyboard. */
65
+ sendApproval: {
66
+ current: ((chatId: number, text: string, approvalId: string) => Promise<void>) | null
67
+ }
68
+ /**
69
+ * Filled by listener.start(). Lets the dispatcher install a single
70
+ * callback_query handler that the listener fans out per click. Returns
71
+ * an unregister function.
72
+ */
73
+ installCallbackHandler: {
74
+ current:
75
+ | ((
76
+ handler: (approvalId: string, choice: ApprovalChoiceKind, fromUserId: number) => void,
77
+ ) => () => void)
78
+ | null
79
+ }
80
+ }
81
+
82
+ export interface TelegramDispatchInput {
83
+ /** Composed text after debounce flush; safe to feed into brain prompt. */
84
+ text: string
85
+ /** TG numeric chat id (== user id for 1-on-1 DMs). */
86
+ chatId: number
87
+ /** TG numeric user id of the sender (always in `allowedUserIds`). */
88
+ userId: number
89
+ /** Display username of the sender (no `@` prefix), or null if unset. */
90
+ username: string | null
91
+ /** Display first/last name of the sender, or null. */
92
+ displayName: string | null
93
+ /** TG message id of the LATEST fragment in the debounced burst. Used for reactions. */
94
+ latestMessageId: number
95
+ /** Stable session key for this chat: `agent:<name>:telegram:dm:<chatId>`. */
96
+ sessionKey: string
97
+ }
98
+
99
+ export interface TelegramDispatchResult {
100
+ /** Assistant text to echo back to the user. Empty string skips the reply. */
101
+ response: string
102
+ /** Optional 0G mainnet sync tx hash, surfaced as a footer if non-empty. */
103
+ syncTx?: string
104
+ }
105
+
106
+ export interface TelegramInboundEvent {
107
+ chatId: number
108
+ userId: number
109
+ username: string | null
110
+ displayName: string | null
111
+ text: string
112
+ messageId: number
113
+ ts: number
114
+ }