@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,121 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
2
+ import { mkdtempSync, rmSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import {
6
+ BotTokenLockedError,
7
+ acquireTelegramTokenLock,
8
+ classifyStartFailure,
9
+ clearWebhookBeforePolling,
10
+ } from './recovery'
11
+
12
+ let lockDir: string
13
+
14
+ beforeEach(() => {
15
+ lockDir = mkdtempSync(join(tmpdir(), 'anima-recovery-test-'))
16
+ })
17
+
18
+ afterEach(() => {
19
+ rmSync(lockDir, { recursive: true, force: true })
20
+ })
21
+
22
+ describe('acquireTelegramTokenLock', () => {
23
+ it('returns release/refresh handle when nothing else holds the token', () => {
24
+ const lock = acquireTelegramTokenLock('123:fake-token-a', { rootDir: lockDir })
25
+ expect(lock.release).toBeFunction()
26
+ expect(lock.refresh).toBeFunction()
27
+ expect(lock.refresh()).toBe(true)
28
+ lock.release()
29
+ })
30
+
31
+ it('throws BotTokenLockedError when the same token is held', () => {
32
+ const a = acquireTelegramTokenLock('123:fake-token-a', { rootDir: lockDir })
33
+ expect(() => acquireTelegramTokenLock('123:fake-token-a', { rootDir: lockDir })).toThrow(
34
+ BotTokenLockedError,
35
+ )
36
+ a.release()
37
+ })
38
+
39
+ it('different tokens do not collide', () => {
40
+ const a = acquireTelegramTokenLock('111:token-x', { rootDir: lockDir })
41
+ const b = acquireTelegramTokenLock('222:token-y', { rootDir: lockDir })
42
+ expect(a).toBeDefined()
43
+ expect(b).toBeDefined()
44
+ a.release()
45
+ b.release()
46
+ })
47
+
48
+ it('release allows re-acquire by another caller', () => {
49
+ const a = acquireTelegramTokenLock('agent:t', { rootDir: lockDir })
50
+ a.release()
51
+ const b = acquireTelegramTokenLock('agent:t', { rootDir: lockDir })
52
+ expect(b).toBeDefined()
53
+ b.release()
54
+ })
55
+ })
56
+
57
+ describe('classifyStartFailure', () => {
58
+ it('classifies error_code 409 as conflict + retryable', () => {
59
+ const v = classifyStartFailure({ error_code: 409, message: 'Conflict' })
60
+ expect(v.kind).toBe('conflict')
61
+ expect(v.retryable).toBe(true)
62
+ })
63
+
64
+ it('classifies error_code 401 as auth + non-retryable', () => {
65
+ const v = classifyStartFailure({ error_code: 401, message: 'Unauthorized' })
66
+ expect(v.kind).toBe('auth')
67
+ expect(v.retryable).toBe(false)
68
+ })
69
+
70
+ it('classifies ECONNRESET as network + retryable', () => {
71
+ const v = classifyStartFailure(new Error('connect ECONNRESET 1.2.3.4'))
72
+ expect(v.kind).toBe('network')
73
+ expect(v.retryable).toBe(true)
74
+ })
75
+
76
+ it('classifies socket hang up as network', () => {
77
+ const v = classifyStartFailure(new Error('socket hang up'))
78
+ expect(v.kind).toBe('network')
79
+ })
80
+
81
+ it('classifies aborted as cancelled', () => {
82
+ const v = classifyStartFailure(new Error('AbortError: aborted'))
83
+ expect(v.kind).toBe('cancelled')
84
+ expect(v.retryable).toBe(false)
85
+ })
86
+
87
+ it('falls through to fatal for unknown errors', () => {
88
+ const v = classifyStartFailure(new Error('something weird'))
89
+ expect(v.kind).toBe('fatal')
90
+ expect(v.retryable).toBe(false)
91
+ })
92
+ })
93
+
94
+ describe('clearWebhookBeforePolling', () => {
95
+ it('calls bot.api.deleteWebhook with drop_pending_updates=false', async () => {
96
+ let called = false
97
+ let receivedArgs: unknown
98
+ const fakeBot = {
99
+ api: {
100
+ deleteWebhook: async (args: unknown) => {
101
+ called = true
102
+ receivedArgs = args
103
+ },
104
+ },
105
+ }
106
+ await clearWebhookBeforePolling(fakeBot as never)
107
+ expect(called).toBe(true)
108
+ expect(receivedArgs).toEqual({ drop_pending_updates: false })
109
+ })
110
+
111
+ it('swallows deleteWebhook errors', async () => {
112
+ const fakeBot = {
113
+ api: {
114
+ deleteWebhook: async () => {
115
+ throw new Error('telegram down')
116
+ },
117
+ },
118
+ }
119
+ await expect(clearWebhookBeforePolling(fakeBot as never)).resolves.toBeUndefined()
120
+ })
121
+ })
@@ -0,0 +1,105 @@
1
+ // Listener recovery primitives.
2
+ //
3
+ // Token lock: only one anima process per machine can poll a given bot token.
4
+ // Cross-machine collisions (laptop + sandbox both polling same bot) still
5
+ // produce 409 Conflict on bot.start. We classify those failures so callers
6
+ // can decide retry/abort. The 3-retry-409 + 10-retry-network state machines
7
+ // hermes runs internally are deferred to v0.19 when we adopt @grammyjs/runner
8
+ // for finer-grained polling control. For v0.18.x the lock plus explicit
9
+ // classification is sufficient.
10
+
11
+ import {
12
+ DEFAULT_LOCK_TTL_SECONDS,
13
+ type ScopedLockHandle,
14
+ acquireScopedLock,
15
+ } from '@s0nderlabs/anima-core'
16
+ import type { Bot } from 'grammy'
17
+
18
+ export const TELEGRAM_TOKEN_LOCK_SCOPE = 'telegram-bot-token'
19
+
20
+ export class BotTokenLockedError extends Error {
21
+ readonly heldByPid: number
22
+ readonly heldSinceSec: number
23
+ constructor(pid: number, sinceSec: number) {
24
+ super(`telegram bot token already in use by pid ${pid} (started ${sinceSec})`)
25
+ this.name = 'BotTokenLockedError'
26
+ this.heldByPid = pid
27
+ this.heldSinceSec = sinceSec
28
+ }
29
+ }
30
+
31
+ export interface AcquireTokenLockOpts {
32
+ agentId?: string
33
+ ttl?: number
34
+ rootDir?: string
35
+ }
36
+
37
+ export interface TokenLock {
38
+ release: () => void
39
+ refresh: () => boolean
40
+ }
41
+
42
+ export function acquireTelegramTokenLock(
43
+ botToken: string,
44
+ opts: AcquireTokenLockOpts = {},
45
+ ): TokenLock {
46
+ const identity = `${opts.agentId ?? 'default'}:${botToken}`
47
+ const result = acquireScopedLock({
48
+ scope: TELEGRAM_TOKEN_LOCK_SCOPE,
49
+ identity,
50
+ ttl: opts.ttl ?? DEFAULT_LOCK_TTL_SECONDS,
51
+ rootDir: opts.rootDir,
52
+ })
53
+ if (!result.acquired || !result.handle) {
54
+ const ex = result.existing ?? { pid: -1, startedAt: 0, updatedAt: 0 }
55
+ throw new BotTokenLockedError(ex.pid, ex.startedAt)
56
+ }
57
+ return wrapLockHandle(result.handle)
58
+ }
59
+
60
+ function wrapLockHandle(handle: ScopedLockHandle): TokenLock {
61
+ return { release: handle.releaseFn, refresh: handle.refreshFn }
62
+ }
63
+
64
+ /**
65
+ * Pre-polling webhook clear. grammy does this internally on bot.start, but
66
+ * making it explicit lets us surface failures (rare but possible if someone
67
+ * sets a webhook between init and start_polling).
68
+ */
69
+ export async function clearWebhookBeforePolling(bot: Bot): Promise<void> {
70
+ try {
71
+ await bot.api.deleteWebhook({ drop_pending_updates: false })
72
+ } catch {
73
+ // Best-effort; grammy retries on bot.start. Caller can opt in to logging.
74
+ }
75
+ }
76
+
77
+ export type StartFailureKind = 'conflict' | 'network' | 'auth' | 'fatal' | 'cancelled'
78
+
79
+ export interface StartFailure {
80
+ kind: StartFailureKind
81
+ detail: string
82
+ retryable: boolean
83
+ }
84
+
85
+ export function classifyStartFailure(err: unknown): StartFailure {
86
+ const msg = err instanceof Error ? err.message : String(err)
87
+ const lower = msg.toLowerCase()
88
+ const code = (err as { error_code?: number }).error_code
89
+ if (code === 409) return { kind: 'conflict', detail: msg, retryable: true }
90
+ if (code === 401) return { kind: 'auth', detail: msg, retryable: false }
91
+ if (
92
+ lower.includes('econnreset') ||
93
+ lower.includes('econnrefused') ||
94
+ lower.includes('enetunreach') ||
95
+ lower.includes('socket hang up') ||
96
+ lower.includes('eai_again') ||
97
+ lower.includes('network')
98
+ ) {
99
+ return { kind: 'network', detail: msg, retryable: true }
100
+ }
101
+ if (lower.includes('aborted') || lower.includes('cancelled')) {
102
+ return { kind: 'cancelled', detail: msg, retryable: false }
103
+ }
104
+ return { kind: 'fatal', detail: msg, retryable: false }
105
+ }
@@ -0,0 +1,127 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import {
3
+ DELIVERY_FAILURE_NOTICE,
4
+ RETRYABLE_PATTERNS,
5
+ TIMEOUT_PATTERNS,
6
+ classifyError,
7
+ isReplyNotFound,
8
+ isRetryable,
9
+ isThreadNotFound,
10
+ isTimeout,
11
+ sendWithRetry,
12
+ } from './retry'
13
+
14
+ describe('classifyError', () => {
15
+ it('treats timeouts as fail (NOT retryable)', () => {
16
+ expect(classifyError(new Error('ETIMEDOUT'))).toBe('fail')
17
+ expect(classifyError(new Error('Request Timeout'))).toBe('fail')
18
+ expect(classifyError(new Error('readtimeout'))).toBe('fail')
19
+ })
20
+ it('treats connection errors as retry', () => {
21
+ expect(classifyError(new Error('ECONNRESET'))).toBe('retry')
22
+ expect(classifyError(new Error('ECONNREFUSED'))).toBe('retry')
23
+ expect(classifyError(new Error('Network unreachable'))).toBe('retry')
24
+ expect(classifyError(new Error('socket hang up'))).toBe('retry')
25
+ })
26
+ it('forbidden / blocked surfaces as fail-silent', () => {
27
+ expect(classifyError(new Error('Forbidden: bot was blocked by the user'))).toBe('fail-silent')
28
+ expect(classifyError(new Error('chat not found'))).toBe('fail-silent')
29
+ })
30
+ it('400 bad request is fail (not retried)', () => {
31
+ expect(classifyError(new Error('Bad Request: message is empty'))).toBe('fail')
32
+ })
33
+ })
34
+
35
+ describe('sendWithRetry', () => {
36
+ it('returns on first success', async () => {
37
+ let calls = 0
38
+ const r = await sendWithRetry(async () => {
39
+ calls += 1
40
+ return 'ok'
41
+ })
42
+ expect(r).toBe('ok')
43
+ expect(calls).toBe(1)
44
+ })
45
+ it('retries on retryable error then succeeds', async () => {
46
+ let calls = 0
47
+ const r = await sendWithRetry(
48
+ async () => {
49
+ calls += 1
50
+ if (calls < 3) throw new Error('ECONNRESET')
51
+ return 'ok'
52
+ },
53
+ { maxRetries: 3, baseDelayMs: 1 },
54
+ )
55
+ expect(r).toBe('ok')
56
+ expect(calls).toBe(3)
57
+ })
58
+ it('does NOT retry on timeout', async () => {
59
+ let calls = 0
60
+ await expect(
61
+ sendWithRetry(
62
+ async () => {
63
+ calls += 1
64
+ throw new Error('ETIMEDOUT')
65
+ },
66
+ { maxRetries: 3, baseDelayMs: 1 },
67
+ ),
68
+ ).rejects.toThrow('ETIMEDOUT')
69
+ expect(calls).toBe(1)
70
+ })
71
+ it('throws after exhausting retries', async () => {
72
+ let calls = 0
73
+ await expect(
74
+ sendWithRetry(
75
+ async () => {
76
+ calls += 1
77
+ throw new Error('ECONNRESET')
78
+ },
79
+ { maxRetries: 2, baseDelayMs: 1 },
80
+ ),
81
+ ).rejects.toThrow('ECONNRESET')
82
+ expect(calls).toBe(3)
83
+ })
84
+ })
85
+
86
+ describe('classifier helpers (isRetryable / isTimeout)', () => {
87
+ it('isRetryable matches all RETRYABLE_PATTERNS', () => {
88
+ for (const p of RETRYABLE_PATTERNS) {
89
+ expect(isRetryable(new Error(`error: ${p}`))).toBe(true)
90
+ }
91
+ })
92
+
93
+ it('isTimeout matches all TIMEOUT_PATTERNS', () => {
94
+ for (const p of TIMEOUT_PATTERNS) {
95
+ expect(isTimeout(new Error(`error: ${p}`))).toBe(true)
96
+ }
97
+ })
98
+
99
+ it('isRetryable is false for timeouts (true delivery-unknown errors)', () => {
100
+ expect(isRetryable(new Error('readtimeout'))).toBe(false)
101
+ expect(isRetryable(new Error('writetimeout'))).toBe(false)
102
+ })
103
+
104
+ it('isReplyNotFound matches the BadRequest text', () => {
105
+ expect(isReplyNotFound(new Error('Bad Request: reply message not found'))).toBe(true)
106
+ expect(isReplyNotFound(new Error('Bad Request: message to be replied not found'))).toBe(true)
107
+ })
108
+
109
+ it('isReplyNotFound is false for unrelated errors', () => {
110
+ expect(isReplyNotFound(new Error('Forbidden'))).toBe(false)
111
+ })
112
+
113
+ it('isThreadNotFound matches the BadRequest text', () => {
114
+ expect(isThreadNotFound(new Error('Bad Request: message thread not found'))).toBe(true)
115
+ })
116
+ })
117
+
118
+ describe('DELIVERY_FAILURE_NOTICE', () => {
119
+ it('starts with the warning sigil and is single-line', () => {
120
+ expect(DELIVERY_FAILURE_NOTICE.startsWith('⚠️')).toBe(true)
121
+ expect(DELIVERY_FAILURE_NOTICE.includes('\n')).toBe(false)
122
+ })
123
+
124
+ it('mentions delivery failure', () => {
125
+ expect(DELIVERY_FAILURE_NOTICE.toLowerCase()).toContain('delivery failed')
126
+ })
127
+ })
package/src/retry.ts ADDED
@@ -0,0 +1,114 @@
1
+ // sendMessage / setMessageReaction retry classifier.
2
+ //
3
+ // RULE: timeout errors are NOT retryable, because the message MAY have been
4
+ // delivered already. Retrying could double-send. Connection errors (ECONNRESET,
5
+ // ENETUNREACH) are retryable because the server never received the request.
6
+ //
7
+ // Pattern from hermes (`base.py:1302` `_send_with_retry`).
8
+
9
+ export type RetryClassification = 'retry' | 'fail' | 'fail-silent'
10
+
11
+ /** Substrings that signal a transient network/connection failure. */
12
+ export const RETRYABLE_PATTERNS = [
13
+ 'connecterror',
14
+ 'connectionerror',
15
+ 'connectionreset',
16
+ 'connectionrefused',
17
+ 'connecttimeout',
18
+ 'network',
19
+ 'broken pipe',
20
+ 'remotedisconnected',
21
+ 'eoferror',
22
+ 'enetunreach',
23
+ 'eai_again',
24
+ 'socket hang up',
25
+ 'econnreset',
26
+ 'econnrefused',
27
+ ] as const
28
+
29
+ /** Substrings that signal a true delivery-status-unknown timeout (NOT retryable). */
30
+ export const TIMEOUT_PATTERNS = [
31
+ 'timed out',
32
+ 'readtimeout',
33
+ 'writetimeout',
34
+ 'etimedout',
35
+ 'request timeout',
36
+ 'aborted',
37
+ ] as const
38
+
39
+ /** User-facing notice when retries exhaust mid-stream. Hermes-aligned text. */
40
+ export const DELIVERY_FAILURE_NOTICE =
41
+ '⚠️ Message delivery failed after multiple attempts. Please try again. Your request was processed but the response could not be sent.'
42
+
43
+ export function isRetryable(err: unknown): boolean {
44
+ const lower = errorMessage(err).toLowerCase()
45
+ return RETRYABLE_PATTERNS.some(p => lower.includes(p))
46
+ }
47
+
48
+ export function isTimeout(err: unknown): boolean {
49
+ const lower = errorMessage(err).toLowerCase()
50
+ return TIMEOUT_PATTERNS.some(p => lower.includes(p))
51
+ }
52
+
53
+ export function isReplyNotFound(err: unknown): boolean {
54
+ const lower = errorMessage(err).toLowerCase()
55
+ return (
56
+ lower.includes('reply message not found') ||
57
+ lower.includes('replied message not found') ||
58
+ lower.includes('message to be replied')
59
+ )
60
+ }
61
+
62
+ export function isThreadNotFound(err: unknown): boolean {
63
+ const lower = errorMessage(err).toLowerCase()
64
+ return lower.includes('thread') && lower.includes('not found')
65
+ }
66
+
67
+ export function classifyError(err: unknown): RetryClassification {
68
+ if (isTimeout(err)) return 'fail'
69
+ if (isRetryable(err)) return 'retry'
70
+ const lower = errorMessage(err).toLowerCase()
71
+ if (
72
+ lower.includes('forbidden') ||
73
+ lower.includes('chat not found') ||
74
+ lower.includes('blocked')
75
+ ) {
76
+ return 'fail-silent'
77
+ }
78
+ if (lower.includes('bad request') || lower.includes('400')) return 'fail'
79
+ return 'retry'
80
+ }
81
+
82
+ export interface RetryOpts {
83
+ /** Max retry attempts. Default 2 (so 3 total attempts). */
84
+ maxRetries?: number
85
+ /** Base delay in ms; doubles per attempt. Default 250. */
86
+ baseDelayMs?: number
87
+ }
88
+
89
+ export async function sendWithRetry<T>(fn: () => Promise<T>, opts: RetryOpts = {}): Promise<T> {
90
+ const maxRetries = opts.maxRetries ?? 2
91
+ const baseDelay = opts.baseDelayMs ?? 250
92
+ let lastErr: unknown
93
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
94
+ try {
95
+ return await fn()
96
+ } catch (err) {
97
+ lastErr = err
98
+ const verdict = classifyError(err)
99
+ if (verdict !== 'retry' || attempt === maxRetries) throw err
100
+ await new Promise(r => setTimeout(r, baseDelay * 2 ** attempt))
101
+ }
102
+ }
103
+ throw lastErr
104
+ }
105
+
106
+ function errorMessage(err: unknown): string {
107
+ if (err instanceof Error) return err.message
108
+ if (typeof err === 'string') return err
109
+ try {
110
+ return JSON.stringify(err)
111
+ } catch {
112
+ return String(err)
113
+ }
114
+ }
@@ -0,0 +1,163 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
2
+ import { mkdtempSync, rmSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { PairingStore } from '@s0nderlabs/anima-core'
6
+ import { type SanitizeInput, sanitizeInbound } from './sanitize'
7
+
8
+ const baseInput: SanitizeInput = {
9
+ chatType: 'private',
10
+ chatId: 12345,
11
+ fromId: 12345,
12
+ fromIsBot: false,
13
+ fromUsername: 'elpabl0',
14
+ fromFirstName: 'Alkautsar',
15
+ fromLastName: null,
16
+ text: 'hello',
17
+ messageId: 1,
18
+ forwardedFrom: null,
19
+ mediaGroupId: null,
20
+ }
21
+
22
+ let pairingDir: string
23
+
24
+ beforeEach(() => {
25
+ pairingDir = mkdtempSync(join(tmpdir(), 'anima-sanitize-pairing-'))
26
+ })
27
+
28
+ afterEach(() => {
29
+ rmSync(pairingDir, { recursive: true, force: true })
30
+ })
31
+
32
+ describe('sanitizeInbound', () => {
33
+ it('accepts a plain DM from a user in the allowlist', () => {
34
+ const r = sanitizeInbound(baseInput, { allowedUserIds: [12345] })
35
+ expect(r.ok).toBe(true)
36
+ if (r.ok) {
37
+ expect(r.event.chatId).toBe(12345)
38
+ expect(r.event.text).toBe('hello')
39
+ expect(r.event.username).toBe('elpabl0')
40
+ expect(r.event.displayName).toBe('Alkautsar')
41
+ }
42
+ })
43
+
44
+ it('drops not-private chat types', () => {
45
+ const r = sanitizeInbound({ ...baseInput, chatType: 'group' }, { allowedUserIds: [12345] })
46
+ expect(r.ok).toBe(false)
47
+ if (!r.ok) expect(r.reason).toBe('not-private-chat')
48
+ })
49
+
50
+ it('drops bots', () => {
51
+ const r = sanitizeInbound({ ...baseInput, fromIsBot: true }, { allowedUserIds: [12345] })
52
+ expect(r.ok).toBe(false)
53
+ if (!r.ok) expect(r.reason).toBe('sender-is-bot')
54
+ })
55
+
56
+ it('drops forwarded messages', () => {
57
+ const r = sanitizeInbound(
58
+ { ...baseInput, forwardedFrom: { id: 1 } },
59
+ { allowedUserIds: [12345] },
60
+ )
61
+ expect(r.ok).toBe(false)
62
+ if (!r.ok) expect(r.reason).toBe('forwarded-message')
63
+ })
64
+
65
+ it('drops media groups', () => {
66
+ const r = sanitizeInbound({ ...baseInput, mediaGroupId: 'xyz' }, { allowedUserIds: [12345] })
67
+ expect(r.ok).toBe(false)
68
+ if (!r.ok) expect(r.reason).toBe('media-group')
69
+ })
70
+
71
+ it('drops empty/whitespace text', () => {
72
+ const r = sanitizeInbound({ ...baseInput, text: ' ' }, { allowedUserIds: [12345] })
73
+ expect(r.ok).toBe(false)
74
+ if (!r.ok) expect(r.reason).toBe('no-text')
75
+ })
76
+
77
+ it('truncates over-cap text', () => {
78
+ const r = sanitizeInbound(
79
+ { ...baseInput, text: 'x'.repeat(3000) },
80
+ { allowedUserIds: [12345], maxTextLength: 100 },
81
+ )
82
+ expect(r.ok).toBe(true)
83
+ if (r.ok) {
84
+ expect(r.event.text.length).toBeLessThan(150)
85
+ expect(r.event.text).toContain('[message truncated]')
86
+ }
87
+ })
88
+
89
+ it('rejects null fromId', () => {
90
+ const r = sanitizeInbound({ ...baseInput, fromId: null }, { allowedUserIds: [12345] })
91
+ expect(r.ok).toBe(false)
92
+ if (!r.ok) expect(r.reason).toBe('no-sender-id')
93
+ })
94
+
95
+ it('default-deny: empty allowedUserIds + no pairingStore rejects unknown senders', () => {
96
+ const r = sanitizeInbound({ ...baseInput, fromId: 99999 }, { allowedUserIds: [] })
97
+ expect(r.ok).toBe(false)
98
+ if (!r.ok) expect(r.reason).toBe('no-allowlist-default-deny')
99
+ })
100
+
101
+ it('default-deny: non-empty allowedUserIds without sender + no pairingStore rejects', () => {
102
+ const r = sanitizeInbound({ ...baseInput, fromId: 99999 }, { allowedUserIds: [12345] })
103
+ expect(r.ok).toBe(false)
104
+ if (!r.ok) expect(r.reason).toBe('sender-not-allowed')
105
+ })
106
+
107
+ it('pairing flow: unknown sender gets a pairing code', () => {
108
+ const store = new PairingStore({ dir: pairingDir })
109
+ const r = sanitizeInbound(
110
+ { ...baseInput, fromId: 99999 },
111
+ {
112
+ allowedUserIds: [],
113
+ pairingStore: store,
114
+ },
115
+ )
116
+ expect(r.ok).toBe(false)
117
+ if (!r.ok) {
118
+ expect(r.action).toBe('send-pairing-code')
119
+ expect(r.code).toBeDefined()
120
+ expect(r.code!.length).toBe(8)
121
+ expect(r.pairedUserId).toBe(99999)
122
+ }
123
+ })
124
+
125
+ it('pairing flow: approved user passes through even when not in static allowlist', () => {
126
+ const store = new PairingStore({ dir: pairingDir })
127
+ const code = store.generateCode('telegram', '99999', 'phantom')!
128
+ store.approveCode('telegram', code)
129
+ const r = sanitizeInbound(
130
+ { ...baseInput, fromId: 99999 },
131
+ {
132
+ allowedUserIds: [],
133
+ pairingStore: store,
134
+ },
135
+ )
136
+ expect(r.ok).toBe(true)
137
+ if (r.ok) expect(r.event.userId).toBe(99999)
138
+ })
139
+
140
+ it('pairing flow: rate-limited unknown sender gets pairing-rate-limited reason', () => {
141
+ const store = new PairingStore({ dir: pairingDir })
142
+ // Burn 3 codes to hit MAX_PENDING_PER_PLATFORM
143
+ for (let i = 0; i < 3; i++) store.generateCode('telegram', `bot-${i}`, '')
144
+ const r = sanitizeInbound(
145
+ { ...baseInput, fromId: 99999 },
146
+ {
147
+ allowedUserIds: [],
148
+ pairingStore: store,
149
+ },
150
+ )
151
+ expect(r.ok).toBe(false)
152
+ if (!r.ok) expect(r.reason).toBe('pairing-rate-limited')
153
+ })
154
+
155
+ it('explicit allowlist wins even when pairingStore is present', () => {
156
+ const store = new PairingStore({ dir: pairingDir })
157
+ const r = sanitizeInbound(baseInput, {
158
+ allowedUserIds: [12345],
159
+ pairingStore: store,
160
+ })
161
+ expect(r.ok).toBe(true)
162
+ })
163
+ })