@s0nderlabs/anima-plugin-telegram 0.19.13 → 0.19.14
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/package.json +2 -2
- package/src/listener-retry.test.ts +97 -0
- package/src/listener.ts +46 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@s0nderlabs/anima-plugin-telegram",
|
|
3
|
-
"version": "0.19.
|
|
3
|
+
"version": "0.19.14",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Telegram gateway plugin for anima — long-poll bot, debounced dispatch, reactions, allowlist",
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"test": "bun test"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@s0nderlabs/anima-core": "0.19.
|
|
31
|
+
"@s0nderlabs/anima-core": "0.19.14",
|
|
32
32
|
"grammy": "^1.42.0",
|
|
33
33
|
"zod": "^3.23.8"
|
|
34
34
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
|
2
|
+
import { existsSync, mkdtempSync, rmSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { TelegramListener } from './listener'
|
|
6
|
+
import { acquireTelegramTokenLock } from './recovery'
|
|
7
|
+
import type { TelegramRuntimeContext } from './types'
|
|
8
|
+
|
|
9
|
+
let lockDir: string
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
lockDir = mkdtempSync(join(tmpdir(), 'anima-listener-retry-'))
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
rmSync(lockDir, { recursive: true, force: true })
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const FAKE_TOKEN = '999:does-not-call-network'
|
|
20
|
+
|
|
21
|
+
function makeOpts(): TelegramRuntimeContext & { lockRootDir: string; apiRoot: string } {
|
|
22
|
+
return {
|
|
23
|
+
botToken: FAKE_TOKEN,
|
|
24
|
+
allowedUserIds: [42],
|
|
25
|
+
agentName: 'retry-canary',
|
|
26
|
+
pairingStore: undefined,
|
|
27
|
+
dispatchUserMessage: async () => ({ response: 'ok' }),
|
|
28
|
+
onProcessingStart: async () => {},
|
|
29
|
+
onProcessingEnd: async () => {},
|
|
30
|
+
approvalBridge: undefined,
|
|
31
|
+
lockRootDir: lockDir,
|
|
32
|
+
// Point grammY at an unreachable host so any accidental network call
|
|
33
|
+
// would fail fast. We never reach bot.start() in these tests because
|
|
34
|
+
// the lock path returns first.
|
|
35
|
+
apiRoot: 'http://127.0.0.1:1',
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('TelegramListener lock-retry', () => {
|
|
40
|
+
it('does NOT throw when the bot-token lock is held; retains running=false until the lock frees', async () => {
|
|
41
|
+
// Pre-occupy the lock. From the listener's perspective this is a
|
|
42
|
+
// zombie/leftover holder it must wait out.
|
|
43
|
+
const blocker = acquireTelegramTokenLock(FAKE_TOKEN, {
|
|
44
|
+
agentId: 'retry-canary',
|
|
45
|
+
rootDir: lockDir,
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const listener = new TelegramListener(makeOpts())
|
|
49
|
+
// Pre-fix this would throw BotTokenLockedError synchronously after the
|
|
50
|
+
// build-runtime catch and never re-attempt. Now it must swallow,
|
|
51
|
+
// schedule a retry timer, and remain stoppable.
|
|
52
|
+
await expect(listener.start()).resolves.toBeUndefined()
|
|
53
|
+
|
|
54
|
+
// stop() should release whatever we held + cancel the retry timer.
|
|
55
|
+
await listener.stop()
|
|
56
|
+
blocker.release()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('stop() cancels a pending retry without leaking timers', async () => {
|
|
60
|
+
const blocker = acquireTelegramTokenLock(FAKE_TOKEN, {
|
|
61
|
+
agentId: 'retry-canary',
|
|
62
|
+
rootDir: lockDir,
|
|
63
|
+
})
|
|
64
|
+
const listener = new TelegramListener(makeOpts())
|
|
65
|
+
await listener.start() // schedules retry because blocker holds the lock
|
|
66
|
+
// Immediately stop. If the retry timer wasn't unref'd / cleared the
|
|
67
|
+
// bun:test process would hang waiting for it (visible as a >30s test
|
|
68
|
+
// timeout; this assertion fails fast otherwise).
|
|
69
|
+
await listener.stop()
|
|
70
|
+
blocker.release()
|
|
71
|
+
// After stop+release, fresh acquisition by an outside caller works
|
|
72
|
+
// (no orphaned listener still holding the lock).
|
|
73
|
+
const now = acquireTelegramTokenLock(FAKE_TOKEN, {
|
|
74
|
+
agentId: 'retry-canary',
|
|
75
|
+
rootDir: lockDir,
|
|
76
|
+
})
|
|
77
|
+
expect(now).toBeDefined()
|
|
78
|
+
now.release()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('lock-clear path: when the prior holder releases, the next start succeeds', async () => {
|
|
82
|
+
const prior = acquireTelegramTokenLock(FAKE_TOKEN, {
|
|
83
|
+
agentId: 'retry-canary',
|
|
84
|
+
rootDir: lockDir,
|
|
85
|
+
})
|
|
86
|
+
const listener = new TelegramListener(makeOpts())
|
|
87
|
+
await listener.start() // pending retry; lock not yet acquired
|
|
88
|
+
prior.release()
|
|
89
|
+
// Retry runs every 30s in production; we verify the lockfile state
|
|
90
|
+
// rather than waiting on real timers. Pending retry won't fire in this
|
|
91
|
+
// synchronous window, but the listener.stop() path must still succeed.
|
|
92
|
+
await listener.stop()
|
|
93
|
+
// After listener.stop() with retry pending and prior released, the
|
|
94
|
+
// lockfile dir is empty (no orphan).
|
|
95
|
+
expect(existsSync(join(lockDir, 'telegram-bot-token-cbae9eeaf0ee85c6.lock'))).toBe(false)
|
|
96
|
+
})
|
|
97
|
+
})
|
package/src/listener.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { formatPairingMessage } from './pairing-flow'
|
|
|
9
9
|
import { reactError, reactProcessing, reactSuccess } from './reactions'
|
|
10
10
|
import {
|
|
11
11
|
BotTokenLockedError,
|
|
12
|
+
TELEGRAM_TOKEN_LOCK_SCOPE,
|
|
12
13
|
type TokenLock,
|
|
13
14
|
acquireTelegramTokenLock,
|
|
14
15
|
classifyStartFailure,
|
|
@@ -29,6 +30,12 @@ import type { TelegramDispatchInput, TelegramRuntimeContext } from './types'
|
|
|
29
30
|
* webhook, then boots grammy in long-poll mode. `stop()` releases the lock
|
|
30
31
|
* and stops the bot. Both are idempotent.
|
|
31
32
|
*/
|
|
33
|
+
/** Retry cadence + cap when the TG bot-token lock is held by a (possibly
|
|
34
|
+
* zombie) prior holder. 12 × 30s = 6 minutes, comfortably past the 5-minute
|
|
35
|
+
* lock TTL so a stale-but-tenable lock auto-evicts. */
|
|
36
|
+
const RETRY_INTERVAL_MS = 30_000
|
|
37
|
+
const MAX_LOCK_RETRY_ATTEMPTS = 12
|
|
38
|
+
|
|
32
39
|
export interface TelegramListenerOpts extends TelegramRuntimeContext {
|
|
33
40
|
/** Optional override of the Telegram Bot API root. Used by the mock-bot test. */
|
|
34
41
|
apiRoot?: string
|
|
@@ -49,6 +56,9 @@ export class TelegramListener {
|
|
|
49
56
|
private running = false
|
|
50
57
|
private tokenLock: TokenLock | null = null
|
|
51
58
|
private refreshTimer: ReturnType<typeof setInterval> | null = null
|
|
59
|
+
private retryTimer: ReturnType<typeof setTimeout> | null = null
|
|
60
|
+
private retryAttempts = 0
|
|
61
|
+
private stopped = false
|
|
52
62
|
private approvalResolver:
|
|
53
63
|
| ((approvalId: string, choice: ApprovalChoice, fromUserId: number) => void)
|
|
54
64
|
| null = null
|
|
@@ -110,7 +120,7 @@ export class TelegramListener {
|
|
|
110
120
|
}
|
|
111
121
|
|
|
112
122
|
async start(): Promise<void> {
|
|
113
|
-
if (this.running) return
|
|
123
|
+
if (this.running || this.stopped) return
|
|
114
124
|
|
|
115
125
|
try {
|
|
116
126
|
this.tokenLock = acquireTelegramTokenLock(this.opts.botToken, {
|
|
@@ -118,12 +128,24 @@ export class TelegramListener {
|
|
|
118
128
|
rootDir: this.opts.lockRootDir,
|
|
119
129
|
})
|
|
120
130
|
} catch (err) {
|
|
131
|
+
// Lock contention is recoverable: the prior holder may be a zombie or
|
|
132
|
+
// a stale lockfile from an ungraceful exit (see
|
|
133
|
+
// feedback-tg-token-lock-zombie-after-upgrade.md). Retry every 30s up
|
|
134
|
+
// to 12 attempts (6 minutes, past the 5-minute lock TTL) so we
|
|
135
|
+
// eventually reclaim once the existing entry expires. Without this,
|
|
136
|
+
// a single failed lock acquisition silenced the bot for the entire
|
|
137
|
+
// harness lifetime.
|
|
121
138
|
if (err instanceof BotTokenLockedError) {
|
|
122
|
-
console.warn(
|
|
139
|
+
console.warn(
|
|
140
|
+
`[telegram] cannot start listener: ${err.message}; will retry in ${RETRY_INTERVAL_MS / 1000}s`,
|
|
141
|
+
)
|
|
142
|
+
this.scheduleStartRetry()
|
|
143
|
+
return
|
|
123
144
|
}
|
|
124
145
|
throw err
|
|
125
146
|
}
|
|
126
147
|
|
|
148
|
+
this.retryAttempts = 0
|
|
127
149
|
this.running = true
|
|
128
150
|
console.log(`[telegram] listener.start() called for @${this.opts.agentName}`)
|
|
129
151
|
|
|
@@ -169,6 +191,11 @@ export class TelegramListener {
|
|
|
169
191
|
}
|
|
170
192
|
|
|
171
193
|
async stop(): Promise<void> {
|
|
194
|
+
this.stopped = true
|
|
195
|
+
if (this.retryTimer) {
|
|
196
|
+
clearTimeout(this.retryTimer)
|
|
197
|
+
this.retryTimer = null
|
|
198
|
+
}
|
|
172
199
|
if (!this.running) {
|
|
173
200
|
this.releaseLock()
|
|
174
201
|
return
|
|
@@ -188,6 +215,23 @@ export class TelegramListener {
|
|
|
188
215
|
this.releaseLock()
|
|
189
216
|
}
|
|
190
217
|
|
|
218
|
+
private scheduleStartRetry(): void {
|
|
219
|
+
if (this.stopped) return
|
|
220
|
+
if (this.retryAttempts >= MAX_LOCK_RETRY_ATTEMPTS) {
|
|
221
|
+
console.error(
|
|
222
|
+
`[telegram] gave up acquiring bot-token lock after ${this.retryAttempts} attempts; manual intervention required (rm ~/.anima/locks/${TELEGRAM_TOKEN_LOCK_SCOPE}-*.lock)`,
|
|
223
|
+
)
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
if (this.retryTimer) clearTimeout(this.retryTimer)
|
|
227
|
+
this.retryAttempts += 1
|
|
228
|
+
this.retryTimer = setTimeout(() => {
|
|
229
|
+
this.retryTimer = null
|
|
230
|
+
void this.start()
|
|
231
|
+
}, RETRY_INTERVAL_MS)
|
|
232
|
+
this.retryTimer.unref?.()
|
|
233
|
+
}
|
|
234
|
+
|
|
191
235
|
private releaseLock(): void {
|
|
192
236
|
if (this.tokenLock) {
|
|
193
237
|
try {
|