@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/listener.ts
ADDED
|
@@ -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
|
+
})
|
package/src/markdown.ts
ADDED
|
@@ -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
|
+
}
|
package/src/reactions.ts
ADDED
|
@@ -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
|
+
}
|