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