@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,405 @@
1
+ import { Bot, type Context, GrammyError, HttpError } from 'grammy'
2
+ import { type ApprovalChoice, parseCallbackData } from './approval-keyboard'
3
+ import { escapeChunkSuffixForMarkdownV2, splitMessage } from './chunking'
4
+ import { DebounceBuffer, type FlushedBatch } from './debounce'
5
+ import { formatTelegramChannel } from './format'
6
+ import { RateLimiter } from './limits'
7
+ import { escapeMarkdownV2, isMarkdownParseError, stripMarkdownV2 } from './markdown'
8
+ import { formatPairingMessage } from './pairing-flow'
9
+ import { reactError, reactProcessing, reactSuccess } from './reactions'
10
+ import {
11
+ BotTokenLockedError,
12
+ type TokenLock,
13
+ acquireTelegramTokenLock,
14
+ classifyStartFailure,
15
+ clearWebhookBeforePolling,
16
+ } from './recovery'
17
+ import { DELIVERY_FAILURE_NOTICE, sendWithRetry } from './retry'
18
+ import { sanitizeInbound } from './sanitize'
19
+ import { buildSessionKey } from './session-key'
20
+ import type { TelegramDispatchInput, TelegramRuntimeContext } from './types'
21
+
22
+ /**
23
+ * Long-poll Telegram bot. Inbound DMs from allowedUserIds are debounced and
24
+ * dispatched to the brain via `dispatchUserMessage`. Reactions transition
25
+ * from 👀 (processing) → 👍/👎 (success/error). Reply text is sent back via
26
+ * grammy's `bot.api.sendMessage` with retry-classified backoff.
27
+ *
28
+ * Lifecycle: `start()` acquires a host-wide token lock, clears any stale
29
+ * webhook, then boots grammy in long-poll mode. `stop()` releases the lock
30
+ * and stops the bot. Both are idempotent.
31
+ */
32
+ export interface TelegramListenerOpts extends TelegramRuntimeContext {
33
+ /** Optional override of the Telegram Bot API root. Used by the mock-bot test. */
34
+ apiRoot?: string
35
+ /** Optional per-user rate-limit. Default capacity=30, window=60s. */
36
+ rateLimit?: { capacity: number; windowMs: number }
37
+ /** Optional debounce window. Default 600ms. */
38
+ debounceMs?: number
39
+ /** Optional override of the locks dir (test only). */
40
+ lockRootDir?: string
41
+ }
42
+
43
+ export class TelegramListener {
44
+ private readonly opts: TelegramListenerOpts
45
+ private readonly bot: Bot
46
+ private readonly limiter: RateLimiter
47
+ private readonly debounce: DebounceBuffer
48
+ private readonly inflight = new Map<number, Promise<void>>()
49
+ private running = false
50
+ private tokenLock: TokenLock | null = null
51
+ private refreshTimer: ReturnType<typeof setInterval> | null = null
52
+
53
+ constructor(opts: TelegramListenerOpts) {
54
+ this.opts = opts
55
+ this.bot = new Bot(opts.botToken, opts.apiRoot ? { client: { apiRoot: opts.apiRoot } } : {})
56
+ this.limiter = new RateLimiter(opts.rateLimit)
57
+ this.debounce = new DebounceBuffer((chatId, batch) => this.handleFlushed(chatId, batch), {
58
+ quietPeriodMs: opts.debounceMs,
59
+ })
60
+ this.bot.on('message', ctx => this.onMessage(ctx))
61
+ this.bot.catch(err => {
62
+ const msg = err instanceof Error ? err.message : String(err)
63
+ this.log(`grammy.catch: ${msg.slice(0, 200)}`)
64
+ })
65
+ }
66
+
67
+ async start(): Promise<void> {
68
+ if (this.running) return
69
+
70
+ try {
71
+ this.tokenLock = acquireTelegramTokenLock(this.opts.botToken, {
72
+ agentId: this.opts.agentName,
73
+ rootDir: this.opts.lockRootDir,
74
+ })
75
+ } catch (err) {
76
+ if (err instanceof BotTokenLockedError) {
77
+ console.warn(`[telegram] cannot start listener: ${err.message}`)
78
+ }
79
+ throw err
80
+ }
81
+
82
+ this.running = true
83
+ console.log(`[telegram] listener.start() called for @${this.opts.agentName}`)
84
+
85
+ if (this.opts.allowedUserIds.length === 0 && !this.opts.pairingStore) {
86
+ console.warn(
87
+ '[telegram] no allowlist configured AND no pairing store. ' +
88
+ 'All inbound messages will be DROPPED. Configure allowedUserIds via ' +
89
+ '`anima telegram setup` or enable pairing.',
90
+ )
91
+ }
92
+
93
+ // Wire approval bridge if the dispatcher provided one. The bridge has two
94
+ // slots: sendApproval (we fill with a closure over this.bot) and
95
+ // installCallbackHandler (we fill with a registrar over bot.on('callback_query')).
96
+ if (this.opts.approvalBridge) {
97
+ this.opts.approvalBridge.sendApproval.current = (chatId, text, approvalId) =>
98
+ this.sendApprovalMessage(chatId, text, approvalId)
99
+ this.opts.approvalBridge.installCallbackHandler.current = handler =>
100
+ this.installCallbackHandler(handler)
101
+ }
102
+
103
+ await clearWebhookBeforePolling(this.bot)
104
+
105
+ this.refreshTimer = setInterval(() => {
106
+ if (this.tokenLock && !this.tokenLock.refresh()) {
107
+ console.warn('[telegram] token lock lost - stopping listener')
108
+ void this.stop()
109
+ }
110
+ }, 60_000)
111
+
112
+ void this.bot
113
+ .start({
114
+ onStart: info => console.log(`[telegram] listener active @${info.username}`),
115
+ drop_pending_updates: true,
116
+ allowed_updates: ['message'],
117
+ })
118
+ .catch(err => {
119
+ const verdict = classifyStartFailure(err)
120
+ console.error(`[telegram] bot.start ${verdict.kind}: ${verdict.detail.slice(0, 400)}`)
121
+ this.running = false
122
+ this.releaseLock()
123
+ })
124
+ }
125
+
126
+ async stop(): Promise<void> {
127
+ if (!this.running) {
128
+ this.releaseLock()
129
+ return
130
+ }
131
+ this.running = false
132
+ if (this.refreshTimer) {
133
+ clearInterval(this.refreshTimer)
134
+ this.refreshTimer = null
135
+ }
136
+ this.debounce.flushAll()
137
+ try {
138
+ await this.bot.stop()
139
+ } catch {
140
+ // grammy stop can throw if start hasn't completed; ignore.
141
+ }
142
+ await Promise.allSettled([...this.inflight.values()])
143
+ this.releaseLock()
144
+ }
145
+
146
+ private releaseLock(): void {
147
+ if (this.tokenLock) {
148
+ try {
149
+ this.tokenLock.release()
150
+ } catch {
151
+ /* best-effort */
152
+ }
153
+ this.tokenLock = null
154
+ }
155
+ }
156
+
157
+ /** Send the inline-keyboard approval message. Used by the approval bridge. */
158
+ private async sendApprovalMessage(
159
+ chatId: number,
160
+ body: string,
161
+ approvalId: string,
162
+ ): Promise<void> {
163
+ const { buildApprovalKeyboard } = await import('./approval-keyboard')
164
+ await sendWithRetry(() =>
165
+ this.bot.api.sendMessage(chatId, body, {
166
+ reply_markup: buildApprovalKeyboard(approvalId),
167
+ }),
168
+ )
169
+ }
170
+
171
+ /**
172
+ * Register a single callback_query dispatcher. Parses + re-validates the
173
+ * clicker against `allowedUserIds`, then forwards to the caller's resolver
174
+ * which owns the pending-approval Map. grammy doesn't expose middleware
175
+ * unregister, so we no-op the returned function and rely on bot.stop()
176
+ * for teardown.
177
+ */
178
+ private installCallbackHandler(
179
+ onResolve: (approvalId: string, choice: ApprovalChoice, fromUserId: number) => void,
180
+ ): () => void {
181
+ const handler = async (ctx: Context): Promise<void> => {
182
+ const q = ctx.callbackQuery
183
+ if (!q) return
184
+ const parsed = parseCallbackData(q.data)
185
+ if (!parsed) {
186
+ try {
187
+ await ctx.answerCallbackQuery({ text: 'malformed approval callback' })
188
+ } catch {
189
+ /* ignore */
190
+ }
191
+ return
192
+ }
193
+ if (this.opts.allowedUserIds.length > 0 && !this.opts.allowedUserIds.includes(q.from.id)) {
194
+ try {
195
+ await ctx.answerCallbackQuery({ text: '⛔ You are not authorized to approve commands.' })
196
+ } catch {
197
+ /* ignore */
198
+ }
199
+ return
200
+ }
201
+ onResolve(parsed.approvalId, parsed.choice, q.from.id)
202
+ try {
203
+ await ctx.answerCallbackQuery({ text: `✓ ${parsed.choice}` })
204
+ } catch {
205
+ /* ignore */
206
+ }
207
+ }
208
+ this.bot.on('callback_query:data', handler)
209
+ return () => {}
210
+ }
211
+
212
+ /**
213
+ * Handle one inbound TG update. Sanitize → rate-limit → debounce.
214
+ * Errors here are swallowed (logged) so grammy stays alive.
215
+ */
216
+ private async onMessage(ctx: Context): Promise<void> {
217
+ const msg = ctx.message
218
+ if (!msg) return
219
+ const sanitized = sanitizeInbound(
220
+ {
221
+ chatType: msg.chat.type,
222
+ chatId: msg.chat.id,
223
+ fromId: msg.from?.id ?? null,
224
+ fromIsBot: msg.from?.is_bot ?? false,
225
+ fromUsername: msg.from?.username ?? null,
226
+ fromFirstName: msg.from?.first_name ?? null,
227
+ fromLastName: msg.from?.last_name ?? null,
228
+ text: msg.text ?? msg.caption ?? null,
229
+ messageId: msg.message_id,
230
+ forwardedFrom:
231
+ (msg as { forward_from?: unknown; forward_origin?: unknown }).forward_from ??
232
+ (msg as { forward_origin?: unknown }).forward_origin ??
233
+ null,
234
+ mediaGroupId: msg.media_group_id ?? null,
235
+ },
236
+ {
237
+ allowedUserIds: this.opts.allowedUserIds,
238
+ pairingStore: this.opts.pairingStore,
239
+ },
240
+ )
241
+ if (!sanitized.ok) {
242
+ if (sanitized.action === 'send-pairing-code' && sanitized.code) {
243
+ const text = formatPairingMessage({
244
+ code: sanitized.code,
245
+ agentName: this.opts.agentName,
246
+ })
247
+ try {
248
+ await this.bot.api.sendMessage(msg.chat.id, text)
249
+ } catch (sendErr) {
250
+ this.log(`pairing-code send failed: ${(sendErr as Error).message?.slice(0, 200) ?? ''}`)
251
+ }
252
+ }
253
+ this.log(`drop: ${sanitized.reason} from chat=${msg.chat.id}`)
254
+ return
255
+ }
256
+ const event = sanitized.event
257
+ if (this.limiter.shouldDrop(event.userId)) {
258
+ this.log(`rate-limit-drop user=${event.userId}`)
259
+ void reactError(this.bot, event.chatId, event.messageId)
260
+ return
261
+ }
262
+ this.debounce.push(event.chatId, {
263
+ text: event.text,
264
+ messageId: event.messageId,
265
+ ts: event.ts,
266
+ userId: event.userId,
267
+ username: event.username,
268
+ displayName: event.displayName,
269
+ })
270
+ }
271
+
272
+ private handleFlushed(chatId: number, batch: FlushedBatch): void {
273
+ const existing = this.inflight.get(chatId)
274
+ const next = (existing ?? Promise.resolve()).then(() => this.dispatchOne(chatId, batch))
275
+ this.inflight.set(
276
+ chatId,
277
+ next.finally(() => {
278
+ if (this.inflight.get(chatId) === next) this.inflight.delete(chatId)
279
+ }),
280
+ )
281
+ }
282
+
283
+ private async dispatchOne(chatId: number, batch: FlushedBatch): Promise<void> {
284
+ const messageId = batch.latestMessageId
285
+ void reactProcessing(this.bot, chatId, messageId)
286
+ if (this.opts.onProcessingStart) {
287
+ try {
288
+ await this.opts.onProcessingStart(chatId, messageId)
289
+ } catch {
290
+ /* never block on hook failures */
291
+ }
292
+ }
293
+ let ok = true
294
+ try {
295
+ const input: TelegramDispatchInput = {
296
+ text: batch.text,
297
+ chatId,
298
+ userId: batch.userId,
299
+ username: batch.username,
300
+ displayName: batch.displayName,
301
+ latestMessageId: messageId,
302
+ sessionKey: buildSessionKey({ agentName: this.opts.agentName, chatId }),
303
+ }
304
+ const channelText = formatTelegramChannel({
305
+ chatId,
306
+ username: batch.username,
307
+ displayName: batch.displayName,
308
+ text: batch.text,
309
+ })
310
+ const result = await this.opts.dispatchUserMessage({ ...input, text: channelText })
311
+ const reply = result.response.trim()
312
+ if (reply.length > 0) {
313
+ await this.sendChunked(chatId, reply, messageId)
314
+ }
315
+ void reactSuccess(this.bot, chatId, messageId)
316
+ } catch (err) {
317
+ ok = false
318
+ const msg = err instanceof Error ? err.message : String(err)
319
+ this.log(`dispatch failed: ${msg.slice(0, 200)}`)
320
+ void reactError(this.bot, chatId, messageId)
321
+ try {
322
+ await this.bot.api.sendMessage(
323
+ chatId,
324
+ 'sorry, something went wrong on my side. try again in a moment.',
325
+ { reply_parameters: { message_id: messageId, allow_sending_without_reply: true } },
326
+ )
327
+ } catch {
328
+ /* swallow */
329
+ }
330
+ }
331
+ if (this.opts.onProcessingEnd) {
332
+ try {
333
+ await this.opts.onProcessingEnd(chatId, messageId, ok)
334
+ } catch {
335
+ /* never block */
336
+ }
337
+ }
338
+ }
339
+
340
+ /**
341
+ * Send a (possibly long) reply with MarkdownV2 + chunking. Falls back to
342
+ * plain-text on parse_error. On retry exhaustion, sends the delivery-failure
343
+ * notice. First chunk is reply-linked; subsequent chunks are not.
344
+ */
345
+ private async sendChunked(chatId: number, body: string, replyToMessageId: number): Promise<void> {
346
+ const chunks = splitMessage(body)
347
+ let firstSend = true
348
+ for (const chunk of chunks) {
349
+ const md = escapeChunkSuffixForMarkdownV2(escapeMarkdownV2(chunk))
350
+ try {
351
+ await sendWithRetry(() =>
352
+ this.bot.api.sendMessage(chatId, md, {
353
+ parse_mode: 'MarkdownV2',
354
+ reply_parameters: firstSend
355
+ ? { message_id: replyToMessageId, allow_sending_without_reply: true }
356
+ : undefined,
357
+ }),
358
+ )
359
+ } catch (err) {
360
+ if (isMarkdownParseError(err)) {
361
+ // Plain-text fallback for this chunk
362
+ try {
363
+ await sendWithRetry(() =>
364
+ this.bot.api.sendMessage(chatId, stripMarkdownV2(chunk), {
365
+ reply_parameters: firstSend
366
+ ? { message_id: replyToMessageId, allow_sending_without_reply: true }
367
+ : undefined,
368
+ }),
369
+ )
370
+ } catch (fallbackErr) {
371
+ // Even plain-text failed; surface delivery-failure notice once.
372
+ this.log(`send fallback failed: ${(fallbackErr as Error).message?.slice(0, 200)}`)
373
+ try {
374
+ await this.bot.api.sendMessage(chatId, DELIVERY_FAILURE_NOTICE)
375
+ } catch {
376
+ /* best-effort */
377
+ }
378
+ return
379
+ }
380
+ } else {
381
+ this.log(`send failed: ${(err as Error).message?.slice(0, 200)}`)
382
+ try {
383
+ await this.bot.api.sendMessage(chatId, DELIVERY_FAILURE_NOTICE)
384
+ } catch {
385
+ /* best-effort */
386
+ }
387
+ return
388
+ }
389
+ }
390
+ firstSend = false
391
+ }
392
+ }
393
+
394
+ private log(line: string): void {
395
+ if (this.opts.debug) console.log(`[telegram] ${line}`)
396
+ }
397
+ }
398
+
399
+ /** Telegram caps messages at 4096 chars. We cap at 4000 to leave header room. */
400
+ export function capForTelegram(text: string): string {
401
+ if (text.length <= 4000) return text
402
+ return `${text.slice(0, 3970)}\n[reply truncated]`
403
+ }
404
+
405
+ export { GrammyError, HttpError }
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { escapeMarkdownV2, isMarkdownParseError, stripMarkdownV2 } from './markdown'
3
+
4
+ describe('escapeMarkdownV2', () => {
5
+ it('escapes all reserved characters', () => {
6
+ expect(escapeMarkdownV2('a_b*c')).toBe('a\\_b\\*c')
7
+ expect(escapeMarkdownV2('hello [world]')).toBe('hello \\[world\\]')
8
+ expect(escapeMarkdownV2('1.2.3')).toBe('1\\.2\\.3')
9
+ expect(escapeMarkdownV2('a-b+c')).toBe('a\\-b\\+c')
10
+ expect(escapeMarkdownV2('!hi')).toBe('\\!hi')
11
+ })
12
+
13
+ it('passes plain text untouched', () => {
14
+ expect(escapeMarkdownV2('hello world')).toBe('hello world')
15
+ expect(escapeMarkdownV2('abc 123')).toBe('abc 123')
16
+ })
17
+
18
+ it('escapes backslashes themselves', () => {
19
+ expect(escapeMarkdownV2('path\\to')).toBe('path\\\\to')
20
+ })
21
+ })
22
+
23
+ describe('stripMarkdownV2', () => {
24
+ it('removes escape backslashes', () => {
25
+ expect(stripMarkdownV2('a\\.b\\.c')).toBe('a.b.c')
26
+ expect(stripMarkdownV2('hello\\!')).toBe('hello!')
27
+ })
28
+
29
+ it('strips bold markers', () => {
30
+ expect(stripMarkdownV2('*hello*')).toBe('hello')
31
+ expect(stripMarkdownV2('this is *bold*.')).toBe('this is bold.')
32
+ })
33
+
34
+ it('strips spoiler markers', () => {
35
+ expect(stripMarkdownV2('this is ||hidden||.')).toBe('this is hidden.')
36
+ })
37
+
38
+ it('strips strikethrough markers', () => {
39
+ expect(stripMarkdownV2('~deleted~ now')).toBe('deleted now')
40
+ })
41
+
42
+ it('preserves plain text', () => {
43
+ expect(stripMarkdownV2('hello world')).toBe('hello world')
44
+ })
45
+
46
+ it('strips a chain of formatting on one line', () => {
47
+ expect(stripMarkdownV2('*bold* and ~strike~ together')).toBe('bold and strike together')
48
+ })
49
+ })
50
+
51
+ describe('isMarkdownParseError', () => {
52
+ it('matches the canonical TG parse error string', () => {
53
+ expect(isMarkdownParseError(new Error("Bad Request: can't parse entities: ..."))).toBe(true)
54
+ })
55
+
56
+ it('matches hermes-cited variant', () => {
57
+ expect(isMarkdownParseError(new Error('cannot parse entities at offset 5'))).toBe(true)
58
+ })
59
+
60
+ it('is false for unrelated errors', () => {
61
+ expect(isMarkdownParseError(new Error('Forbidden'))).toBe(false)
62
+ expect(isMarkdownParseError(new Error('ECONNRESET'))).toBe(false)
63
+ })
64
+ })
@@ -0,0 +1,50 @@
1
+ // MarkdownV2 escape + plain-text fallback.
2
+ //
3
+ // Pattern from hermes telegram.py:84. The Bot API requires every reserved
4
+ // character in MarkdownV2 entity ranges to be escaped with backslash, even
5
+ // inside code blocks for some characters. This module exposes a single
6
+ // `escapeMarkdownV2` function for the safe path and `stripMarkdownV2` for
7
+ // the plain-text fallback when parse_error fires on send.
8
+
9
+ const MARKDOWN_V2_ESCAPE_REGEX = /([_*[\]()~`>#+\-=|{}.!\\])/g
10
+
11
+ export function escapeMarkdownV2(text: string): string {
12
+ return text.replace(MARKDOWN_V2_ESCAPE_REGEX, '\\$1')
13
+ }
14
+
15
+ /**
16
+ * Strip MarkdownV2 markers so a parse_error fallback can send the same content
17
+ * as plain text. Handles the four common formatting markers (`*bold*`,
18
+ * `_italic_`, `~strike~`, `||spoiler||`) plus drops escape backslashes.
19
+ *
20
+ * Code-block markers stay (TG renders them as plain text without parse_mode).
21
+ */
22
+ export function stripMarkdownV2(text: string): string {
23
+ let out = text
24
+ // Drop escape backslashes that were applied by escapeMarkdownV2
25
+ out = out.replace(/\\([_*[\]()~`>#+\-=|{}.!\\])/g, '$1')
26
+ // Strip ||spoiler|| (must run before * and _ since `||` shares chars)
27
+ out = out.replace(/\|\|([^|]+)\|\|/g, '$1')
28
+ // Strip *bold* (greedy-safe since markdown only allows single-line *bold*)
29
+ out = out.replace(/\*([^*]+)\*/g, '$1')
30
+ // Strip _italic_
31
+ out = out.replace(/(?:^|[\s])_([^_]+)_(?=[\s]|$)/g, ' $1')
32
+ // Strip ~strike~
33
+ out = out.replace(/~([^~]+)~/g, '$1')
34
+ return out
35
+ }
36
+
37
+ /**
38
+ * Detect if a grammy / Bot API error is a MarkdownV2 parse error so callers
39
+ * can fall back to plain-text. Hermes pattern: the error message contains
40
+ * "can't parse entities" with the MarkdownV2 mention.
41
+ */
42
+ export function isMarkdownParseError(err: unknown): boolean {
43
+ const msg = err instanceof Error ? err.message : String(err)
44
+ const lower = msg.toLowerCase()
45
+ return (
46
+ lower.includes("can't parse entities") ||
47
+ lower.includes('cannot parse entities') ||
48
+ (lower.includes('bad request') && (lower.includes('parse') || lower.includes('entities')))
49
+ )
50
+ }
@@ -0,0 +1,31 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { formatPairingMessage } from './pairing-flow'
3
+
4
+ describe('formatPairingMessage', () => {
5
+ it('includes the code and the default approve command', () => {
6
+ const msg = formatPairingMessage({ code: 'ABCDEFGH' })
7
+ expect(msg).toContain('ABCDEFGH')
8
+ expect(msg).toContain('anima pairing approve telegram ABCDEFGH')
9
+ })
10
+
11
+ it('greets with the agent name when provided', () => {
12
+ const msg = formatPairingMessage({ code: 'ABCDEFGH', agentName: 'specter' })
13
+ expect(msg).toContain('specter')
14
+ })
15
+
16
+ it('mentions 1-hour TTL', () => {
17
+ const msg = formatPairingMessage({ code: 'ABCDEFGH' })
18
+ expect(msg).toContain('1 hour')
19
+ })
20
+
21
+ it('honors approveCommand override', () => {
22
+ const msg = formatPairingMessage({ code: 'XX', approveCommand: 'custom-cmd XX' })
23
+ expect(msg).toContain('custom-cmd XX')
24
+ expect(msg).not.toContain('anima pairing approve')
25
+ })
26
+
27
+ it('starts with the lock emoji', () => {
28
+ const msg = formatPairingMessage({ code: 'XX' })
29
+ expect(msg.startsWith('🔐')).toBe(true)
30
+ })
31
+ })
@@ -0,0 +1,31 @@
1
+ // Pairing-flow message formatter.
2
+ //
3
+ // When an unknown user DMs the bot, the listener replies with a pairing code
4
+ // they can give to the operator. The operator approves out-of-band via
5
+ // `anima pairing approve telegram <code>`, which writes the user-id to
6
+ // `~/.anima/agents/<id>/pairing/telegram-approved.json`. The next message
7
+ // from that user passes sanitize and reaches the brain.
8
+
9
+ export interface PairingMessageOpts {
10
+ code: string
11
+ agentName?: string
12
+ /** Optional override of the approval CLI hint. */
13
+ approveCommand?: string
14
+ }
15
+
16
+ export function formatPairingMessage(opts: PairingMessageOpts): string {
17
+ const cmd = opts.approveCommand ?? `anima pairing approve telegram ${opts.code}`
18
+ const greeting = opts.agentName
19
+ ? `🔐 Hi! I'm ${opts.agentName} and I don't recognize you yet.`
20
+ : "🔐 Hi! I don't recognize you yet."
21
+ return [
22
+ greeting,
23
+ '',
24
+ `Your pairing code: ${opts.code}`,
25
+ '',
26
+ 'Send this code to the bot owner and ask them to approve you. They will run:',
27
+ ` ${cmd}`,
28
+ '',
29
+ "Codes expire in 1 hour. Once approved, send your next message and I'll respond.",
30
+ ].join('\n')
31
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Atomic reaction state machine: 👀 (processing) → 👍 (success) | 👎 (error).
3
+ * Telegram's setMessageReaction REPLACES all bot reactions on the message in
4
+ * one call, so transitions are atomic. No remove step needed.
5
+ *
6
+ * If the bot doesn't have permission to react in the chat (rare for DMs but
7
+ * possible if the user blocks), the call fails silently and message handling
8
+ * continues. We never let a reaction failure abort a brain turn.
9
+ */
10
+ import type { Bot } from 'grammy'
11
+ import type { ReactionTypeEmoji } from 'grammy/types'
12
+
13
+ type Emoji = ReactionTypeEmoji['emoji']
14
+
15
+ export const REACTION_PROCESSING: Emoji = '\u{1F440}' // 👀
16
+ export const REACTION_OK: Emoji = '\u{1F44D}' // 👍
17
+ export const REACTION_ERR: Emoji = '\u{1F44E}' // 👎
18
+
19
+ export async function reactProcessing(bot: Bot, chatId: number, messageId: number): Promise<void> {
20
+ await safeSetReaction(bot, chatId, messageId, REACTION_PROCESSING)
21
+ }
22
+
23
+ export async function reactSuccess(bot: Bot, chatId: number, messageId: number): Promise<void> {
24
+ await safeSetReaction(bot, chatId, messageId, REACTION_OK)
25
+ }
26
+
27
+ export async function reactError(bot: Bot, chatId: number, messageId: number): Promise<void> {
28
+ await safeSetReaction(bot, chatId, messageId, REACTION_ERR)
29
+ }
30
+
31
+ export async function clearReaction(bot: Bot, chatId: number, messageId: number): Promise<void> {
32
+ await safeSetReactionEmpty(bot, chatId, messageId)
33
+ }
34
+
35
+ async function safeSetReaction(
36
+ bot: Bot,
37
+ chatId: number,
38
+ messageId: number,
39
+ emoji: Emoji,
40
+ ): Promise<void> {
41
+ try {
42
+ await bot.api.setMessageReaction(chatId, messageId, [{ type: 'emoji', emoji }])
43
+ } catch {
44
+ // Reaction failures are cosmetic; never block the turn on them.
45
+ }
46
+ }
47
+
48
+ async function safeSetReactionEmpty(bot: Bot, chatId: number, messageId: number): Promise<void> {
49
+ try {
50
+ await bot.api.setMessageReaction(chatId, messageId, [])
51
+ } catch {
52
+ // Same: silent.
53
+ }
54
+ }