@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 ADDED
@@ -0,0 +1,43 @@
1
+ # @s0nderlabs/anima-plugin-telegram
2
+
3
+ Telegram gateway for anima. Operator DMs `@anima_<name>_bot` from any phone; the agent (running in 0G Sandbox or local) replies.
4
+
5
+ ## Highlights
6
+
7
+ - **Long-poll outbound only.** Works in both Local and Sandbox modes without inbound port exposure.
8
+ - **Allowlisted DMs.** Only configured `allowedUserIds` reach the brain.
9
+ - **Reactions as feedback.** 👀 on processing start, 👍 on success, 👎 on error.
10
+ - **Per-chat debounce.** 600ms quiet window collapses fragmented typing into one brain turn.
11
+ - **Rate-limited.** 30 messages / 60s per user via token bucket.
12
+ - **DM-only MVP.** Group / channel / forwarded messages dropped silently.
13
+ - **Sandboxed handoff.** Bot token never leaves the operator's encrypted blob; harness receives via ECIES envelope.
14
+
15
+ ## Quickstart
16
+
17
+ ```
18
+ anima telegram setup # one-time interactive: bot token + allowed user IDs
19
+ anima # start the TUI; listener boots automatically
20
+ # DM @anima_<name>_bot from your phone — agent replies
21
+ ```
22
+
23
+ ## Architecture
24
+
25
+ The plugin registers one listener (`telegram-bot`) on the gateway. The listener:
26
+
27
+ 1. Spins up a `grammy.Bot` with the operator's token in long-poll mode.
28
+ 2. On inbound DM from an allowed user, reactions go to 👀, the message is buffered through the per-chat debounce, then dispatched to the brain via `ctx.telegram.dispatchUserMessage(input)`.
29
+ 3. The brain runs a normal turn (refresh prefix, infer, tool calls, sync flush). Source label `'telegram'` flows into the prompt as `<channel source="telegram" chat="..." user="...">${text}</channel>`.
30
+ 4. On turn end, the assistant text is sent back via `bot.api.sendMessage`. Reaction transitions to 👍 (success) or 👎 (error).
31
+
32
+ ## Why grammy?
33
+
34
+ TS-first, lightweight (~30KB minified), bun-compatible. Telegraf and python-telegram-bot are reasonable alternatives but not TS-first.
35
+
36
+ ## Out of scope (future)
37
+
38
+ - Webhook mode (long-poll covers MVP needs)
39
+ - Group chat support
40
+ - Bot API 9.4 DM Topics (per-A2A-peer topic isolation)
41
+ - Inline-keyboard exec approval (TG turns currently force `permission='off'`)
42
+ - Voice transcription / TTS
43
+ - DNS-over-HTTPS fallback IPs / proxy support
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@s0nderlabs/anima-plugin-telegram",
3
+ "version": "0.19.1",
4
+ "type": "module",
5
+ "description": "Telegram gateway plugin for anima — long-poll bot, debounced dispatch, reactions, allowlist",
6
+ "license": "MIT",
7
+ "homepage": "https://github.com/s0nderlabs/anima",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/s0nderlabs/anima.git",
11
+ "directory": "packages/plugin-telegram"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/s0nderlabs/anima/issues"
15
+ },
16
+ "keywords": ["0g", "anima", "agent", "telegram", "grammy", "gateway", "plugin"],
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "engines": {
21
+ "bun": ">=1.1"
22
+ },
23
+ "files": ["src", "README.md"],
24
+ "main": "./src/index.ts",
25
+ "types": "./src/index.ts",
26
+ "scripts": {
27
+ "build": "tsc -b",
28
+ "test": "bun test"
29
+ },
30
+ "dependencies": {
31
+ "@s0nderlabs/anima-core": "0.18.3",
32
+ "grammy": "^1.42.0",
33
+ "zod": "^3.23.8"
34
+ }
35
+ }
@@ -0,0 +1,142 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import {
3
+ APPROVAL_CALLBACK_PREFIX,
4
+ buildApprovalKeyboard,
5
+ handleApprovalCallback,
6
+ makeApprovalIdFactory,
7
+ parseCallbackData,
8
+ } from './approval-keyboard'
9
+
10
+ describe('buildApprovalKeyboard', () => {
11
+ it('produces 4 buttons in 2 rows with the correct callback_data prefix', () => {
12
+ const k = buildApprovalKeyboard('a-1')
13
+ expect(k.inline_keyboard.length).toBe(2)
14
+ expect(k.inline_keyboard[0]!.length).toBe(2)
15
+ expect(k.inline_keyboard[1]!.length).toBe(2)
16
+ for (const row of k.inline_keyboard) {
17
+ for (const btn of row) {
18
+ const data = (btn as { callback_data?: string }).callback_data
19
+ expect(data).toMatch(new RegExp(`^${APPROVAL_CALLBACK_PREFIX}:`))
20
+ expect(data).toContain(':a-1')
21
+ }
22
+ }
23
+ })
24
+ })
25
+
26
+ describe('parseCallbackData', () => {
27
+ it('parses well-formed once callback', () => {
28
+ expect(parseCallbackData('ea:once:a-1')).toEqual({ choice: 'once', approvalId: 'a-1' })
29
+ })
30
+ it('parses session/always/deny', () => {
31
+ expect(parseCallbackData('ea:session:a-1')?.choice).toBe('session')
32
+ expect(parseCallbackData('ea:always:a-1')?.choice).toBe('always')
33
+ expect(parseCallbackData('ea:deny:a-1')?.choice).toBe('deny')
34
+ })
35
+ it('rejects malformed prefix', () => {
36
+ expect(parseCallbackData('xx:once:a-1')).toBeNull()
37
+ })
38
+ it('rejects malformed choice', () => {
39
+ expect(parseCallbackData('ea:nope:a-1')).toBeNull()
40
+ })
41
+ it('rejects empty string', () => {
42
+ expect(parseCallbackData('')).toBeNull()
43
+ expect(parseCallbackData(undefined)).toBeNull()
44
+ })
45
+ })
46
+
47
+ describe('handleApprovalCallback', () => {
48
+ function makePending() {
49
+ const pending = new Map<string, (choice: 'once' | 'session' | 'always' | 'deny') => void>()
50
+ let resolved: { id: string; choice: string } | null = null
51
+ pending.set('a-1', choice => {
52
+ resolved = { id: 'a-1', choice }
53
+ })
54
+ return { pending, getResolved: () => resolved }
55
+ }
56
+
57
+ it('resolves on first match + pops the entry', () => {
58
+ const { pending, getResolved } = makePending()
59
+ const r = handleApprovalCallback({
60
+ callbackData: 'ea:once:a-1',
61
+ fromUserId: 100,
62
+ allowedUserIds: [100],
63
+ pendingApprovals: pending,
64
+ })
65
+ expect(r.kind).toBe('resolved')
66
+ expect(getResolved()?.choice).toBe('once')
67
+ expect(pending.has('a-1')).toBe(false)
68
+ })
69
+
70
+ it('rejects unauthorized clicker', () => {
71
+ const { pending } = makePending()
72
+ const r = handleApprovalCallback({
73
+ callbackData: 'ea:once:a-1',
74
+ fromUserId: 999,
75
+ allowedUserIds: [100],
76
+ pendingApprovals: pending,
77
+ })
78
+ expect(r.kind).toBe('unauthorized')
79
+ expect(pending.has('a-1')).toBe(true)
80
+ })
81
+
82
+ it('marks unknown approvalId', () => {
83
+ const { pending } = makePending()
84
+ const r = handleApprovalCallback({
85
+ callbackData: 'ea:once:a-999',
86
+ fromUserId: 100,
87
+ allowedUserIds: [100],
88
+ pendingApprovals: pending,
89
+ })
90
+ expect(r.kind).toBe('unknown-approval')
91
+ })
92
+
93
+ it('marks malformed callback', () => {
94
+ const { pending } = makePending()
95
+ const r = handleApprovalCallback({
96
+ callbackData: 'garbage',
97
+ fromUserId: 100,
98
+ allowedUserIds: [100],
99
+ pendingApprovals: pending,
100
+ })
101
+ expect(r.kind).toBe('malformed')
102
+ })
103
+
104
+ it('allows all when allowedUserIds is empty (pairing-only mode)', () => {
105
+ const { pending } = makePending()
106
+ const r = handleApprovalCallback({
107
+ callbackData: 'ea:once:a-1',
108
+ fromUserId: 9999,
109
+ allowedUserIds: [],
110
+ pendingApprovals: pending,
111
+ })
112
+ // Callers with empty allowedUserIds rely on the listener-side pairing
113
+ // gate, so callback re-validation is permissive here.
114
+ expect(r.kind).toBe('resolved')
115
+ })
116
+
117
+ it('second click on same approvalId returns unknown-approval', () => {
118
+ const { pending } = makePending()
119
+ handleApprovalCallback({
120
+ callbackData: 'ea:once:a-1',
121
+ fromUserId: 100,
122
+ allowedUserIds: [100],
123
+ pendingApprovals: pending,
124
+ })
125
+ const second = handleApprovalCallback({
126
+ callbackData: 'ea:once:a-1',
127
+ fromUserId: 100,
128
+ allowedUserIds: [100],
129
+ pendingApprovals: pending,
130
+ })
131
+ expect(second.kind).toBe('unknown-approval')
132
+ })
133
+ })
134
+
135
+ describe('makeApprovalIdFactory', () => {
136
+ it('returns monotonically increasing ids', () => {
137
+ const next = makeApprovalIdFactory()
138
+ expect(next()).toBe('a-1')
139
+ expect(next()).toBe('a-2')
140
+ expect(next()).toBe('a-3')
141
+ })
142
+ })
@@ -0,0 +1,108 @@
1
+ // Inline-keyboard approval for tool calls when the active session is on TG.
2
+ //
3
+ // Pattern from hermes telegram.py:1080-1132 (button layout) + 1462-1473
4
+ // (callback re-validation). 4 buttons in 2 rows: Once / Session / Always /
5
+ // Deny. Callback data format: `ea:<once|session|always|deny>:<approvalId>`.
6
+ //
7
+ // The handler re-validates the clicker against `allowedUserIds` because
8
+ // inline-keyboard buttons are visible to any chat-member but only authorized
9
+ // users may click. One-shot pop pattern: the resolver Map drops the entry
10
+ // after the first match so a stale double-click can't re-resolve.
11
+
12
+ import type { InlineKeyboardMarkup } from 'grammy/types'
13
+
14
+ export type ApprovalChoice = 'once' | 'session' | 'always' | 'deny'
15
+
16
+ export const APPROVAL_CALLBACK_PREFIX = 'ea'
17
+
18
+ export function buildApprovalKeyboard(approvalId: string): InlineKeyboardMarkup {
19
+ return {
20
+ inline_keyboard: [
21
+ [
22
+ { text: '✅ Allow Once', callback_data: makeCallbackData('once', approvalId) },
23
+ { text: '✅ Session', callback_data: makeCallbackData('session', approvalId) },
24
+ ],
25
+ [
26
+ { text: '✅ Always', callback_data: makeCallbackData('always', approvalId) },
27
+ { text: '❌ Deny', callback_data: makeCallbackData('deny', approvalId) },
28
+ ],
29
+ ],
30
+ }
31
+ }
32
+
33
+ function makeCallbackData(choice: ApprovalChoice, approvalId: string): string {
34
+ // 64-byte callback_data limit — approvalId must stay short. We use a
35
+ // monotonic counter (e.g. `a-12345`) so well within budget.
36
+ return `${APPROVAL_CALLBACK_PREFIX}:${choice}:${approvalId}`
37
+ }
38
+
39
+ export interface ParsedCallback {
40
+ choice: ApprovalChoice
41
+ approvalId: string
42
+ }
43
+
44
+ export function parseCallbackData(data: string | undefined): ParsedCallback | null {
45
+ if (!data) return null
46
+ const parts = data.split(':')
47
+ if (parts.length !== 3) return null
48
+ if (parts[0] !== APPROVAL_CALLBACK_PREFIX) return null
49
+ const choice = parts[1]
50
+ const approvalId = parts[2]
51
+ if (
52
+ !approvalId ||
53
+ (choice !== 'once' && choice !== 'session' && choice !== 'always' && choice !== 'deny')
54
+ ) {
55
+ return null
56
+ }
57
+ return { choice: choice as ApprovalChoice, approvalId }
58
+ }
59
+
60
+ export type ResolveOutcome =
61
+ | { kind: 'resolved'; approvalId: string; choice: ApprovalChoice; clicker: number }
62
+ | { kind: 'unauthorized'; approvalId: string; clicker: number }
63
+ | { kind: 'unknown-approval'; approvalId: string; clicker: number }
64
+ | { kind: 'malformed' }
65
+
66
+ export interface HandleCallbackInput {
67
+ callbackData: string | undefined
68
+ fromUserId: number
69
+ allowedUserIds: number[]
70
+ pendingApprovals: Map<string, (choice: ApprovalChoice) => void>
71
+ }
72
+
73
+ /**
74
+ * Decide what to do with a `callback_query`. The bot handler should call this
75
+ * pure function then `answerCallbackQuery` based on the outcome.
76
+ */
77
+ export function handleApprovalCallback(input: HandleCallbackInput): ResolveOutcome {
78
+ const parsed = parseCallbackData(input.callbackData)
79
+ if (!parsed) return { kind: 'malformed' }
80
+
81
+ if (input.allowedUserIds.length > 0 && !input.allowedUserIds.includes(input.fromUserId)) {
82
+ return { kind: 'unauthorized', approvalId: parsed.approvalId, clicker: input.fromUserId }
83
+ }
84
+
85
+ const resolver = input.pendingApprovals.get(parsed.approvalId)
86
+ if (!resolver) {
87
+ return { kind: 'unknown-approval', approvalId: parsed.approvalId, clicker: input.fromUserId }
88
+ }
89
+
90
+ // One-shot pop closes the race against double-clicks
91
+ input.pendingApprovals.delete(parsed.approvalId)
92
+ resolver(parsed.choice)
93
+ return {
94
+ kind: 'resolved',
95
+ approvalId: parsed.approvalId,
96
+ choice: parsed.choice,
97
+ clicker: input.fromUserId,
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Mint a new approval id. Monotonic counter as a string so callback_data
103
+ * stays short. Caller seeds and increments.
104
+ */
105
+ export function makeApprovalIdFactory(): () => string {
106
+ let next = 1
107
+ return () => `a-${next++}`
108
+ }
@@ -0,0 +1,63 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { escapeChunkSuffixForMarkdownV2, splitMessage } from './chunking'
3
+
4
+ describe('splitMessage', () => {
5
+ it('returns the input unchanged when shorter than maxLen', () => {
6
+ const r = splitMessage('hello world', { maxLen: 100 })
7
+ expect(r).toEqual(['hello world'])
8
+ })
9
+
10
+ it('splits into multiple chunks with (N/N) suffix', () => {
11
+ const text = 'word '.repeat(2000)
12
+ const r = splitMessage(text, { maxLen: 1000 })
13
+ expect(r.length).toBeGreaterThan(1)
14
+ for (let i = 0; i < r.length; i++) {
15
+ expect(r[i]).toMatch(new RegExp(`\\(${i + 1}/${r.length}\\)$`))
16
+ }
17
+ })
18
+
19
+ it('omits suffix when numbered=false', () => {
20
+ const text = 'x'.repeat(5000)
21
+ const r = splitMessage(text, { maxLen: 1000, numbered: false })
22
+ expect(r.length).toBeGreaterThan(1)
23
+ for (const c of r) expect(c).not.toMatch(/\(\d+\/\d+\)$/)
24
+ })
25
+
26
+ it('preserves total content across chunks (modulo whitespace at split points)', () => {
27
+ const text = 'hello '.repeat(1000)
28
+ const r = splitMessage(text, { maxLen: 500, numbered: false })
29
+ const reassembled = r.join(' ').replace(/\s+/g, ' ').trim()
30
+ expect(reassembled.split(' ').length).toBe(1000)
31
+ })
32
+
33
+ it('avoids splitting inside fenced code blocks', () => {
34
+ const code = `\`\`\`\n${'line\n'.repeat(100)}\`\`\``
35
+ const text = `intro\n${code}\noutro`
36
+ const r = splitMessage(text, { maxLen: 200, numbered: false })
37
+ const opens = r.filter(c => c.includes('```'))
38
+ expect(opens.length % 2).toBe(0)
39
+ })
40
+
41
+ it('handles single huge token without spaces', () => {
42
+ const text = 'x'.repeat(10000)
43
+ const r = splitMessage(text, { maxLen: 1000 })
44
+ expect(r.length).toBe(10)
45
+ })
46
+
47
+ it('returns one element when text length exactly equals maxLen', () => {
48
+ const text = 'x'.repeat(100)
49
+ const r = splitMessage(text, { maxLen: 100 })
50
+ expect(r).toEqual([text])
51
+ })
52
+ })
53
+
54
+ describe('escapeChunkSuffixForMarkdownV2', () => {
55
+ it('escapes parens in the suffix', () => {
56
+ expect(escapeChunkSuffixForMarkdownV2('hello (1/2)')).toBe('hello \\(1/2\\)')
57
+ expect(escapeChunkSuffixForMarkdownV2('hello (10/10)')).toBe('hello \\(10/10\\)')
58
+ })
59
+
60
+ it('leaves text without suffix untouched', () => {
61
+ expect(escapeChunkSuffixForMarkdownV2('hello world')).toBe('hello world')
62
+ })
63
+ })
@@ -0,0 +1,63 @@
1
+ // Long-message chunking with (1/N) (2/N) suffix.
2
+ //
3
+ // Pattern from hermes telegram.py:829-836. Telegram's hard limit is 4096
4
+ // characters per message. We split at 4000 (leave room for the suffix) and
5
+ // attach `(1/N)` `(2/N)` `(N/N)` to each chunk. We avoid breaking inside
6
+ // fenced code blocks so the runtime grammar stays intact across chunks.
7
+
8
+ const DEFAULT_MAX_LEN = 4000
9
+
10
+ export interface SplitOpts {
11
+ maxLen?: number
12
+ /** Add `(1/N)` suffixes to multi-chunk output. Default true. */
13
+ numbered?: boolean
14
+ }
15
+
16
+ export function splitMessage(text: string, opts: SplitOpts = {}): string[] {
17
+ const maxLen = opts.maxLen ?? DEFAULT_MAX_LEN
18
+ const numbered = opts.numbered ?? true
19
+
20
+ if (text.length <= maxLen) return [text]
21
+
22
+ const chunks: string[] = []
23
+ let cursor = 0
24
+ while (cursor < text.length) {
25
+ let end = Math.min(cursor + maxLen, text.length)
26
+
27
+ // Avoid splitting inside a fenced code block — if there's an unclosed
28
+ // ``` between cursor and end, back up to the last newline before end.
29
+ if (end < text.length) {
30
+ const segment = text.slice(cursor, end)
31
+ const fencesInSegment = (segment.match(/```/g) || []).length
32
+ if (fencesInSegment % 2 === 1) {
33
+ const lastNewline = text.lastIndexOf('\n', end - 1)
34
+ if (lastNewline > cursor) end = lastNewline
35
+ } else {
36
+ // Prefer to split on word boundary when possible
37
+ const lastSpace = text.lastIndexOf(' ', end)
38
+ const lastNewline = text.lastIndexOf('\n', end)
39
+ const splitAt = Math.max(lastSpace, lastNewline)
40
+ if (splitAt > cursor + Math.floor(maxLen / 2)) {
41
+ end = splitAt
42
+ }
43
+ }
44
+ }
45
+
46
+ chunks.push(text.slice(cursor, end))
47
+ cursor = end
48
+ // Skip the leading whitespace at the new cursor (we split on it)
49
+ while (cursor < text.length && (text[cursor] === ' ' || text[cursor] === '\n')) cursor++
50
+ }
51
+
52
+ if (!numbered || chunks.length === 1) return chunks
53
+ const total = chunks.length
54
+ return chunks.map((c, i) => `${c} (${i + 1}/${total})`)
55
+ }
56
+
57
+ /**
58
+ * If a chunk is going through MarkdownV2 mode, the parens in `(N/N)` need to
59
+ * be escaped. Hermes telegram.py:836.
60
+ */
61
+ export function escapeChunkSuffixForMarkdownV2(text: string): string {
62
+ return text.replace(/\s\((\d+)\/(\d+)\)$/, ' \\($1/$2\\)')
63
+ }
@@ -0,0 +1,140 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
2
+ import { DebounceBuffer, type FlushedBatch } from './debounce'
3
+
4
+ describe('DebounceBuffer', () => {
5
+ let originalSetTimeout: typeof setTimeout
6
+ let originalClearTimeout: typeof clearTimeout
7
+ let scheduled: { fn: () => void; delay: number; id: number }[] = []
8
+ let nextId = 1
9
+
10
+ beforeEach(() => {
11
+ originalSetTimeout = global.setTimeout
12
+ originalClearTimeout = global.clearTimeout
13
+ scheduled = []
14
+ nextId = 1
15
+ global.setTimeout = ((fn: () => void, delay: number) => {
16
+ const id = nextId++
17
+ scheduled.push({ fn, delay, id })
18
+ return id as unknown as ReturnType<typeof setTimeout>
19
+ }) as typeof setTimeout
20
+ global.clearTimeout = ((id: number) => {
21
+ scheduled = scheduled.filter(s => s.id !== id)
22
+ }) as typeof clearTimeout
23
+ })
24
+
25
+ afterEach(() => {
26
+ global.setTimeout = originalSetTimeout
27
+ global.clearTimeout = originalClearTimeout
28
+ })
29
+
30
+ function fragment(
31
+ overrides: Partial<{
32
+ text: string
33
+ messageId: number
34
+ ts: number
35
+ userId: number
36
+ username: string | null
37
+ displayName: string | null
38
+ }> = {},
39
+ ) {
40
+ return {
41
+ text: overrides.text ?? 'hi',
42
+ messageId: overrides.messageId ?? 1,
43
+ ts: overrides.ts ?? 1,
44
+ userId: overrides.userId ?? 100,
45
+ username: overrides.username ?? null,
46
+ displayName: overrides.displayName ?? null,
47
+ }
48
+ }
49
+
50
+ function fireLatestTimer(): void {
51
+ const last = scheduled.pop()
52
+ if (last) last.fn()
53
+ }
54
+
55
+ it('coalesces rapid fragments into one batch', () => {
56
+ const flushed: { chatId: number; batch: FlushedBatch }[] = []
57
+ const buf = new DebounceBuffer((chatId, batch) => flushed.push({ chatId, batch }), {
58
+ quietPeriodMs: 100,
59
+ })
60
+ buf.push(1, fragment({ text: 'hello', messageId: 1, ts: 100 }))
61
+ buf.push(1, fragment({ text: 'world', messageId: 2, ts: 200 }))
62
+ buf.push(1, fragment({ text: '!', messageId: 3, ts: 300 }))
63
+ expect(flushed).toHaveLength(0)
64
+ fireLatestTimer()
65
+ expect(flushed).toHaveLength(1)
66
+ expect(flushed[0]!.chatId).toBe(1)
67
+ expect(flushed[0]!.batch.text).toBe('hello\nworld\n!')
68
+ expect(flushed[0]!.batch.fragmentCount).toBe(3)
69
+ expect(flushed[0]!.batch.latestMessageId).toBe(3)
70
+ })
71
+
72
+ it('keeps separate buffers per chat', () => {
73
+ const flushed: { chatId: number; batch: FlushedBatch }[] = []
74
+ const buf = new DebounceBuffer((chatId, batch) => flushed.push({ chatId, batch }), {
75
+ quietPeriodMs: 100,
76
+ })
77
+ buf.push(1, fragment({ text: 'a' }))
78
+ buf.push(2, fragment({ text: 'b' }))
79
+ expect(flushed).toHaveLength(0)
80
+ expect(scheduled).toHaveLength(2)
81
+ while (scheduled.length > 0) fireLatestTimer()
82
+ expect(flushed).toHaveLength(2)
83
+ expect(flushed.find(f => f.chatId === 1)?.batch.text).toBe('a')
84
+ expect(flushed.find(f => f.chatId === 2)?.batch.text).toBe('b')
85
+ })
86
+
87
+ it('forced flush via flushAll', () => {
88
+ const flushed: { chatId: number; batch: FlushedBatch }[] = []
89
+ const buf = new DebounceBuffer((chatId, batch) => flushed.push({ chatId, batch }))
90
+ buf.push(1, fragment({ text: 'pending' }))
91
+ buf.flushAll()
92
+ expect(flushed).toHaveLength(1)
93
+ expect(flushed[0]!.batch.text).toBe('pending')
94
+ })
95
+
96
+ it('exceeding maxBufferChars triggers immediate flush', () => {
97
+ const flushed: { chatId: number; batch: FlushedBatch }[] = []
98
+ const buf = new DebounceBuffer((chatId, batch) => flushed.push({ chatId, batch }), {
99
+ maxBufferChars: 10,
100
+ })
101
+ buf.push(1, fragment({ text: 'aaaaa', messageId: 1, ts: 1 }))
102
+ buf.push(1, fragment({ text: 'bbbbbb', messageId: 2, ts: 2 }))
103
+ expect(flushed).toHaveLength(1)
104
+ expect(flushed[0]!.batch.text).toBe('aaaaa\nbbbbbb')
105
+ })
106
+
107
+ it('uses quietPeriodMs delay for short fragments', () => {
108
+ const buf = new DebounceBuffer(() => {}, {
109
+ quietPeriodMs: 600,
110
+ adaptiveDelayMs: 2000,
111
+ adaptiveSplitThreshold: 4000,
112
+ })
113
+ buf.push(1, fragment({ text: 'short' }))
114
+ expect(scheduled[0]!.delay).toBe(600)
115
+ })
116
+
117
+ it('uses adaptiveDelayMs delay when last fragment exceeds threshold', () => {
118
+ const buf = new DebounceBuffer(() => {}, {
119
+ quietPeriodMs: 600,
120
+ adaptiveDelayMs: 2000,
121
+ adaptiveSplitThreshold: 100,
122
+ })
123
+ const longText = 'x'.repeat(150)
124
+ buf.push(1, fragment({ text: longText }))
125
+ expect(scheduled[0]!.delay).toBe(2000)
126
+ })
127
+
128
+ it('FlushedBatch carries userId, username, displayName from the last fragment', () => {
129
+ const flushed: { chatId: number; batch: FlushedBatch }[] = []
130
+ const buf = new DebounceBuffer((chatId, batch) => flushed.push({ chatId, batch }), {
131
+ quietPeriodMs: 100,
132
+ })
133
+ buf.push(1, fragment({ text: 'a', userId: 111, username: 'alice', displayName: 'Alice' }))
134
+ buf.push(1, fragment({ text: 'b', userId: 111, username: 'alice', displayName: 'Alice' }))
135
+ fireLatestTimer()
136
+ expect(flushed[0]!.batch.userId).toBe(111)
137
+ expect(flushed[0]!.batch.username).toBe('alice')
138
+ expect(flushed[0]!.batch.displayName).toBe('Alice')
139
+ })
140
+ })