@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.
- package/README.md +43 -0
- package/package.json +35 -0
- package/src/approval-keyboard.test.ts +142 -0
- package/src/approval-keyboard.ts +108 -0
- package/src/chunking.test.ts +63 -0
- package/src/chunking.ts +63 -0
- package/src/debounce.test.ts +140 -0
- package/src/debounce.ts +105 -0
- package/src/format.test.ts +75 -0
- package/src/format.ts +46 -0
- package/src/guidance.ts +20 -0
- package/src/index.ts +105 -0
- package/src/limits.test.ts +38 -0
- package/src/limits.ts +60 -0
- package/src/listener.ts +405 -0
- package/src/markdown.test.ts +64 -0
- package/src/markdown.ts +50 -0
- package/src/pairing-flow.test.ts +31 -0
- package/src/pairing-flow.ts +31 -0
- package/src/reactions.ts +54 -0
- package/src/recovery.test.ts +121 -0
- package/src/recovery.ts +105 -0
- package/src/retry.test.ts +127 -0
- package/src/retry.ts +114 -0
- package/src/sanitize.test.ts +163 -0
- package/src/sanitize.ts +128 -0
- package/src/session-key.test.ts +40 -0
- package/src/session-key.ts +37 -0
- package/src/session-state.test.ts +103 -0
- package/src/session-state.ts +93 -0
- package/src/types.ts +114 -0
package/src/sanitize.ts
ADDED
|
@@ -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
|
+
}
|