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