@s0nderlabs/anima-plugin-telegram 0.21.4 → 0.21.6
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 +1 -1
- package/src/index.ts +1 -0
- package/src/recovery.test.ts +67 -1
- package/src/recovery.ts +26 -0
package/package.json
CHANGED
package/src/index.ts
CHANGED
package/src/recovery.test.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
|
2
|
-
import {
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
4
|
import { tmpdir } from 'node:os'
|
|
4
5
|
import { join } from 'node:path'
|
|
5
6
|
import {
|
|
6
7
|
BotTokenLockedError,
|
|
7
8
|
acquireTelegramTokenLock,
|
|
8
9
|
classifyStartFailure,
|
|
10
|
+
clearStaleTelegramTokenLock,
|
|
9
11
|
clearWebhookBeforePolling,
|
|
10
12
|
} from './recovery'
|
|
11
13
|
|
|
@@ -91,6 +93,70 @@ describe('classifyStartFailure', () => {
|
|
|
91
93
|
})
|
|
92
94
|
})
|
|
93
95
|
|
|
96
|
+
describe('clearStaleTelegramTokenLock', () => {
|
|
97
|
+
it('returns no-lock when nothing exists', () => {
|
|
98
|
+
const r = clearStaleTelegramTokenLock('token-xyz', { agentId: 'agent-7', rootDir: lockDir })
|
|
99
|
+
expect(r.cleared).toBe(false)
|
|
100
|
+
expect(r.reason).toBe('no-lock')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('returns alive-pid when a live owner holds the lock', () => {
|
|
104
|
+
const lock = acquireTelegramTokenLock('token-live', { agentId: 'agent-7', rootDir: lockDir })
|
|
105
|
+
const r = clearStaleTelegramTokenLock('token-live', { agentId: 'agent-7', rootDir: lockDir })
|
|
106
|
+
expect(r.cleared).toBe(false)
|
|
107
|
+
expect(r.reason).toBe('alive-pid')
|
|
108
|
+
lock.release()
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('clears a dead-PID lock written manually', () => {
|
|
112
|
+
const identity = 'agent-7:token-zombie'
|
|
113
|
+
const hash = createHash('sha256').update(identity).digest('hex').slice(0, 16)
|
|
114
|
+
const path = join(lockDir, `telegram-bot-token-${hash}.lock`)
|
|
115
|
+
writeFileSync(
|
|
116
|
+
path,
|
|
117
|
+
JSON.stringify({
|
|
118
|
+
pid: 999_996,
|
|
119
|
+
scope: 'telegram-bot-token',
|
|
120
|
+
identityHash: hash,
|
|
121
|
+
startedAt: Math.floor(Date.now() / 1000),
|
|
122
|
+
updatedAt: Math.floor(Date.now() / 1000),
|
|
123
|
+
ttl: 600,
|
|
124
|
+
}),
|
|
125
|
+
)
|
|
126
|
+
const r = clearStaleTelegramTokenLock('token-zombie', { agentId: 'agent-7', rootDir: lockDir })
|
|
127
|
+
expect(r.cleared).toBe(true)
|
|
128
|
+
expect(r.reason).toBe('cleared-stale')
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('lets a fresh acquire follow a clear', () => {
|
|
132
|
+
const identity = 'agent-X:token-recoverable'
|
|
133
|
+
const hash = createHash('sha256').update(identity).digest('hex').slice(0, 16)
|
|
134
|
+
const path = join(lockDir, `telegram-bot-token-${hash}.lock`)
|
|
135
|
+
writeFileSync(
|
|
136
|
+
path,
|
|
137
|
+
JSON.stringify({
|
|
138
|
+
pid: 999_995,
|
|
139
|
+
scope: 'telegram-bot-token',
|
|
140
|
+
identityHash: hash,
|
|
141
|
+
startedAt: 0,
|
|
142
|
+
updatedAt: 0,
|
|
143
|
+
ttl: 1,
|
|
144
|
+
}),
|
|
145
|
+
)
|
|
146
|
+
const cleanup = clearStaleTelegramTokenLock('token-recoverable', {
|
|
147
|
+
agentId: 'agent-X',
|
|
148
|
+
rootDir: lockDir,
|
|
149
|
+
})
|
|
150
|
+
expect(cleanup.cleared).toBe(true)
|
|
151
|
+
const lock = acquireTelegramTokenLock('token-recoverable', {
|
|
152
|
+
agentId: 'agent-X',
|
|
153
|
+
rootDir: lockDir,
|
|
154
|
+
})
|
|
155
|
+
expect(lock).toBeDefined()
|
|
156
|
+
lock.release()
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
94
160
|
describe('clearWebhookBeforePolling', () => {
|
|
95
161
|
it('calls bot.api.deleteWebhook with drop_pending_updates=false', async () => {
|
|
96
162
|
let called = false
|
package/src/recovery.ts
CHANGED
|
@@ -9,9 +9,11 @@
|
|
|
9
9
|
// classification is sufficient.
|
|
10
10
|
|
|
11
11
|
import {
|
|
12
|
+
type ClearStaleScopedLockResult,
|
|
12
13
|
DEFAULT_LOCK_TTL_SECONDS,
|
|
13
14
|
type ScopedLockHandle,
|
|
14
15
|
acquireScopedLock,
|
|
16
|
+
clearStaleScopedLock,
|
|
15
17
|
} from '@s0nderlabs/anima-core'
|
|
16
18
|
import type { Bot } from 'grammy'
|
|
17
19
|
|
|
@@ -61,6 +63,30 @@ function wrapLockHandle(handle: ScopedLockHandle): TokenLock {
|
|
|
61
63
|
return { release: handle.releaseFn, refresh: handle.refreshFn }
|
|
62
64
|
}
|
|
63
65
|
|
|
66
|
+
/**
|
|
67
|
+
* v0.21.5: gateway boot must proactively reap a zombie/crashed listener's
|
|
68
|
+
* bot-token lock before TelegramListener.start() runs. Without this, the
|
|
69
|
+
* acquire path calls scheduleStartRetry which waits 30s × 12 attempts (6 min)
|
|
70
|
+
* for the TTL to expire — operators see "TG silent" the whole time.
|
|
71
|
+
*
|
|
72
|
+
* Returns whether a stale lock was cleared. Never deletes a lock held by a
|
|
73
|
+
* live foreign PID (that's the legitimate "another anima is polling this bot"
|
|
74
|
+
* case, where the listener should fail loud).
|
|
75
|
+
*
|
|
76
|
+
* Identity hash matches `acquireTelegramTokenLock`: `${agentId ?? 'default'}:${botToken}`.
|
|
77
|
+
*/
|
|
78
|
+
export function clearStaleTelegramTokenLock(
|
|
79
|
+
botToken: string,
|
|
80
|
+
opts: AcquireTokenLockOpts = {},
|
|
81
|
+
): ClearStaleScopedLockResult {
|
|
82
|
+
const identity = `${opts.agentId ?? 'default'}:${botToken}`
|
|
83
|
+
return clearStaleScopedLock({
|
|
84
|
+
scope: TELEGRAM_TOKEN_LOCK_SCOPE,
|
|
85
|
+
identity,
|
|
86
|
+
rootDir: opts.rootDir,
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
64
90
|
/**
|
|
65
91
|
* Pre-polling webhook clear. grammy does this internally on bot.start, but
|
|
66
92
|
* making it explicit lets us surface failures (rare but possible if someone
|