switchroom 0.13.51 → 0.13.53
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/dist/agent-scheduler/index.js +317 -132
- package/dist/auth-broker/index.js +494 -156
- package/dist/cli/drive-write-pretool.mjs +18 -3
- package/dist/cli/switchroom.js +2452 -1114
- package/dist/host-control/main.js +246 -127
- package/dist/vault/approvals/kernel-server.js +8269 -8146
- package/dist/vault/broker/server.js +2811 -2688
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +17 -4
- package/profiles/_shared/agent-self-service.md.hbs +12 -22
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md.hbs +8 -1
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/skills/switchroom-status/SKILL.md +8 -6
- package/telegram-plugin/chat-lock.ts +87 -19
- package/telegram-plugin/dist/gateway/gateway.js +752 -120
- package/telegram-plugin/gateway/disconnect-flush.ts +32 -0
- package/telegram-plugin/gateway/gateway.ts +258 -55
- package/telegram-plugin/gateway/inbound-coalesce.ts +19 -6
- package/telegram-plugin/stream-reply-handler.ts +10 -8
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +116 -0
- package/telegram-plugin/tests/inbound-coalesce.test.ts +20 -4
- package/telegram-plugin/tests/outbound-ordering.test.ts +228 -0
- package/telegram-plugin/tests/parallel-turns-deadlock-fix.test.ts +217 -0
- package/telegram-plugin/tests/typing-wrap.test.ts +65 -8
- package/telegram-plugin/typing-wrap.ts +43 -21
|
@@ -137,11 +137,24 @@ export function createInboundCoalescer<T>(opts: InboundCoalescerOptions<T>): Inb
|
|
|
137
137
|
}
|
|
138
138
|
|
|
139
139
|
/**
|
|
140
|
-
* Build a coalesce key from `(chatId, userId)`. Identity-stable
|
|
141
|
-
* messages from the same sender in the same chat
|
|
142
|
-
*
|
|
143
|
-
*
|
|
140
|
+
* Build a coalesce key from `(chatId, threadId, userId)`. Identity-stable
|
|
141
|
+
* across messages from the same sender in the same chat and topic,
|
|
142
|
+
* distinct across:
|
|
143
|
+
* - different senders (so one user's typing isn't merged with another's)
|
|
144
|
+
* - different topics (so a user typing in #planning isn't merged with
|
|
145
|
+
* the same user's message in #admin — supergroup-mode invariant,
|
|
146
|
+
* CPO decision #9 ratified 2026-05-27)
|
|
147
|
+
*
|
|
148
|
+
* `threadId` collapses `null`/`undefined`/`0` to `_` via the same
|
|
149
|
+
* convention as `chatKey()`. The 1.5s coalesce window is per-topic
|
|
150
|
+
* intent ("user sends 3 sentences as one thought") — applying it
|
|
151
|
+
* cross-topic merges genuinely separate conversations.
|
|
144
152
|
*/
|
|
145
|
-
export function inboundCoalesceKey(
|
|
146
|
-
|
|
153
|
+
export function inboundCoalesceKey(
|
|
154
|
+
chatId: string,
|
|
155
|
+
threadId: number | null | undefined,
|
|
156
|
+
userId: string,
|
|
157
|
+
): string {
|
|
158
|
+
const t = threadId == null || threadId === 0 ? '_' : String(threadId)
|
|
159
|
+
return `${chatId}:${t}:${userId}`
|
|
147
160
|
}
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
type RetryPolicy,
|
|
24
24
|
} from './stream-controller.js'
|
|
25
25
|
import { sanitizeTelegramHtml } from './html-sanitize.js'
|
|
26
|
+
import { chatKey, chatKeyWithSuffix } from './gateway/chat-key.js'
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
29
|
* Builds the inline status-accent header line for `reply` / `stream_reply`.
|
|
@@ -311,14 +312,15 @@ function streamKey(
|
|
|
311
312
|
lane?: string,
|
|
312
313
|
turnKey?: string,
|
|
313
314
|
): string {
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
|
|
319
|
-
const base =
|
|
320
|
-
|
|
321
|
-
|
|
315
|
+
// Adopt the canonical chatKey() / chatKeyWithSuffix() primitives from
|
|
316
|
+
// gateway/chat-key.ts (PR2 of supergroup mode — kills the previously
|
|
317
|
+
// inlined copy of the key expression). The brand erases to string at
|
|
318
|
+
// runtime, so callers using `streamKey` as a `Map<string, T>` key
|
|
319
|
+
// continue to work unchanged.
|
|
320
|
+
const base = lane != null && lane.length > 0
|
|
321
|
+
? chatKeyWithSuffix(chatId, threadId ?? null, lane)
|
|
322
|
+
: chatKey(chatId, threadId ?? null)
|
|
323
|
+
return turnKey != null && turnKey.length > 0 ? `${base}:${turnKey}` : base
|
|
322
324
|
}
|
|
323
325
|
|
|
324
326
|
export async function handleStreamReply(
|
|
@@ -50,6 +50,14 @@ function makeDeps(agentName: string | null) {
|
|
|
50
50
|
['chat1:thr1:msg1', 100],
|
|
51
51
|
['chat2:thr2:msg2', 200],
|
|
52
52
|
])
|
|
53
|
+
// PR3b: claudeBusyKeys tracks turns actually handed to claude. In a
|
|
54
|
+
// healthy registered-disconnect scenario both maps would carry the
|
|
55
|
+
// same keys (delivery succeeded); the dangling-sweep tests below
|
|
56
|
+
// override individual deps to exercise the orphaned-key path.
|
|
57
|
+
const claudeBusyKeys = new Set<string>([
|
|
58
|
+
'chat1:thr1:msg1',
|
|
59
|
+
'chat2:thr2:msg2',
|
|
60
|
+
])
|
|
53
61
|
const activeDraftStreams = new Map<string, FakeStream>([
|
|
54
62
|
['chat1:thr1:r1', { isFinal: () => false, finalize: finalizeA }],
|
|
55
63
|
['chat2:thr2:r2', { isFinal: () => true, finalize: finalizeB }],
|
|
@@ -66,6 +74,7 @@ function makeDeps(agentName: string | null) {
|
|
|
66
74
|
activeStatusReactions,
|
|
67
75
|
activeReactionMsgIds,
|
|
68
76
|
activeTurnStartedAt,
|
|
77
|
+
claudeBusyKeys,
|
|
69
78
|
activeDraftStreams,
|
|
70
79
|
activeDraftParseModes,
|
|
71
80
|
clearActiveReactions,
|
|
@@ -169,6 +178,7 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
|
|
|
169
178
|
['ghost:thr:msg', { chatId: 'ghost', messageId: 42 }],
|
|
170
179
|
]),
|
|
171
180
|
activeTurnStartedAt: new Map<string, number>([['ghost:thr:msg', 100]]),
|
|
181
|
+
claudeBusyKeys: new Set<string>(['ghost:thr:msg']),
|
|
172
182
|
activeDraftStreams: new Map<string, FakeStream>(),
|
|
173
183
|
activeDraftParseModes: new Map<string, 'HTML' | 'MarkdownV2' | undefined>(),
|
|
174
184
|
clearActiveReactions,
|
|
@@ -179,6 +189,10 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
|
|
|
179
189
|
|
|
180
190
|
flushOnAgentDisconnect(deps)
|
|
181
191
|
|
|
192
|
+
// PR3b: claudeBusyKeys swept alongside the activeTurnStartedAt
|
|
193
|
+
// dangling entry — both maps mirror each other on registered
|
|
194
|
+
// disconnects, so a key in one is always a key in the other.
|
|
195
|
+
expect(deps.claudeBusyKeys.size).toBe(0)
|
|
182
196
|
// The sweep fired and cleared the dangling entry.
|
|
183
197
|
expect(deps.activeTurnStartedAt.size).toBe(0)
|
|
184
198
|
expect(deps.activeReactionMsgIds.size).toBe(0)
|
|
@@ -222,6 +236,7 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
|
|
|
222
236
|
activeStatusReactions: new Map<string, FakeCtrl>(),
|
|
223
237
|
activeReactionMsgIds: new Map<string, { chatId: string; messageId: number }>(),
|
|
224
238
|
activeTurnStartedAt: new Map<string, number>([['real-turn:thr:msg', 100]]),
|
|
239
|
+
claudeBusyKeys: new Set<string>(['real-turn:thr:msg']),
|
|
225
240
|
activeDraftStreams: new Map<string, FakeStream>(),
|
|
226
241
|
activeDraftParseModes: new Map<string, 'HTML' | 'MarkdownV2' | undefined>(),
|
|
227
242
|
clearActiveReactions: vi.fn(),
|
|
@@ -237,6 +252,106 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
|
|
|
237
252
|
expect(onDanglingTurnsSwept).not.toHaveBeenCalled()
|
|
238
253
|
})
|
|
239
254
|
|
|
255
|
+
// PR3b orphan-sweep regression: synthetic-inbound deliveries
|
|
256
|
+
// (cron, reactions, vault, button-callback) bypass handleInbound's
|
|
257
|
+
// fresh-turn branch and so never stamp activeTurnStartedAt. They
|
|
258
|
+
// DO mark claudeBusyKeys. If their turn dies without turn_end, the
|
|
259
|
+
// activeTurnStartedAt-keyed dangling sweep misses them — orphan
|
|
260
|
+
// persists in claudeBusyKeys → fleet gate wedges. This test pins
|
|
261
|
+
// the post-sweep claudeBusyKeys.clear() fix.
|
|
262
|
+
it('sweeps claudeBusyKeys orphans that have NO activeTurnStartedAt entry (PR3b follow-up)', () => {
|
|
263
|
+
const onDanglingTurnsSwept = vi.fn()
|
|
264
|
+
const log = vi.fn()
|
|
265
|
+
const deps = {
|
|
266
|
+
agentName: 'clerk',
|
|
267
|
+
activeStatusReactions: new Map<string, FakeCtrl>(),
|
|
268
|
+
activeReactionMsgIds: new Map<string, { chatId: string; messageId: number }>(),
|
|
269
|
+
activeTurnStartedAt: new Map<string, number>(),
|
|
270
|
+
// The orphan scenario: claude was handed a turn (e.g. cron
|
|
271
|
+
// synthetic delivered), so claudeBusyKeys has it, but
|
|
272
|
+
// activeTurnStartedAt was never set because cron bypasses
|
|
273
|
+
// handleInbound's fresh-turn branch.
|
|
274
|
+
claudeBusyKeys: new Set<string>(['cron-only-key:_']),
|
|
275
|
+
activeDraftStreams: new Map<string, FakeStream>(),
|
|
276
|
+
activeDraftParseModes: new Map<string, 'HTML' | 'MarkdownV2' | undefined>(),
|
|
277
|
+
clearActiveReactions: vi.fn(),
|
|
278
|
+
disposeProgressDriver: vi.fn(),
|
|
279
|
+
onDanglingTurnsSwept,
|
|
280
|
+
log,
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
flushOnAgentDisconnect(deps)
|
|
284
|
+
|
|
285
|
+
// The orphan is cleared even though it never had an
|
|
286
|
+
// activeTurnStartedAt entry.
|
|
287
|
+
expect(deps.claudeBusyKeys.size).toBe(0)
|
|
288
|
+
// The activeTurnStartedAt-keyed sweep wasn't fired (nothing in
|
|
289
|
+
// that map to sweep) — so onDanglingTurnsSwept shouldn't fire
|
|
290
|
+
// either. The orphan sweep is a separate observation.
|
|
291
|
+
expect(onDanglingTurnsSwept).not.toHaveBeenCalled()
|
|
292
|
+
// But it logs the orphan-clear so operators can see it.
|
|
293
|
+
expect(
|
|
294
|
+
log.mock.calls.some((c: unknown[]) =>
|
|
295
|
+
typeof c[0] === 'string' && /orphan claudeBusyKeys/.test(c[0]),
|
|
296
|
+
),
|
|
297
|
+
).toBe(true)
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it('orphan-sweep singular vs plural log message agrees with count', () => {
|
|
301
|
+
// Tiny grammar regression: "1 entry" vs "2 entries".
|
|
302
|
+
const log = vi.fn()
|
|
303
|
+
const baseDeps = {
|
|
304
|
+
agentName: 'clerk',
|
|
305
|
+
activeStatusReactions: new Map<string, FakeCtrl>(),
|
|
306
|
+
activeReactionMsgIds: new Map<string, { chatId: string; messageId: number }>(),
|
|
307
|
+
activeTurnStartedAt: new Map<string, number>(),
|
|
308
|
+
activeDraftStreams: new Map<string, FakeStream>(),
|
|
309
|
+
activeDraftParseModes: new Map<string, 'HTML' | 'MarkdownV2' | undefined>(),
|
|
310
|
+
clearActiveReactions: vi.fn(),
|
|
311
|
+
disposeProgressDriver: vi.fn(),
|
|
312
|
+
}
|
|
313
|
+
// Singular form.
|
|
314
|
+
flushOnAgentDisconnect({
|
|
315
|
+
...baseDeps,
|
|
316
|
+
claudeBusyKeys: new Set<string>(['k1:_']),
|
|
317
|
+
log,
|
|
318
|
+
})
|
|
319
|
+
expect(log.mock.calls.some((c: unknown[]) =>
|
|
320
|
+
typeof c[0] === 'string' && / 1 orphan claudeBusyKeys entry /.test(c[0]),
|
|
321
|
+
)).toBe(true)
|
|
322
|
+
// Plural form.
|
|
323
|
+
log.mockClear()
|
|
324
|
+
flushOnAgentDisconnect({
|
|
325
|
+
...baseDeps,
|
|
326
|
+
claudeBusyKeys: new Set<string>(['k1:_', 'k2:1']),
|
|
327
|
+
log,
|
|
328
|
+
})
|
|
329
|
+
expect(log.mock.calls.some((c: unknown[]) =>
|
|
330
|
+
typeof c[0] === 'string' && / 2 orphan claudeBusyKeys entries /.test(c[0]),
|
|
331
|
+
)).toBe(true)
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
it('does NOT fire orphan-sweep log when claudeBusyKeys is empty', () => {
|
|
335
|
+
// Zero-noise discipline: every disconnect for a healthy idle
|
|
336
|
+
// agent shouldn't produce a "0 orphan claudeBusyKeys" line.
|
|
337
|
+
const log = vi.fn()
|
|
338
|
+
flushOnAgentDisconnect({
|
|
339
|
+
agentName: 'clerk',
|
|
340
|
+
activeStatusReactions: new Map<string, FakeCtrl>(),
|
|
341
|
+
activeReactionMsgIds: new Map<string, { chatId: string; messageId: number }>(),
|
|
342
|
+
activeTurnStartedAt: new Map<string, number>(),
|
|
343
|
+
claudeBusyKeys: new Set<string>(),
|
|
344
|
+
activeDraftStreams: new Map<string, FakeStream>(),
|
|
345
|
+
activeDraftParseModes: new Map<string, 'HTML' | 'MarkdownV2' | undefined>(),
|
|
346
|
+
clearActiveReactions: vi.fn(),
|
|
347
|
+
disposeProgressDriver: vi.fn(),
|
|
348
|
+
log,
|
|
349
|
+
})
|
|
350
|
+
expect(log.mock.calls.some((c: unknown[]) =>
|
|
351
|
+
typeof c[0] === 'string' && /orphan claudeBusyKeys/.test(c[0]),
|
|
352
|
+
)).toBe(false)
|
|
353
|
+
})
|
|
354
|
+
|
|
240
355
|
it('omitting onDanglingTurnsSwept is safe (optional callback)', () => {
|
|
241
356
|
// Backward-compat guard — existing callers that don't pass the new
|
|
242
357
|
// callback still work without runtime error.
|
|
@@ -245,6 +360,7 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
|
|
|
245
360
|
activeStatusReactions: new Map<string, FakeCtrl>(),
|
|
246
361
|
activeReactionMsgIds: new Map<string, { chatId: string; messageId: number }>(),
|
|
247
362
|
activeTurnStartedAt: new Map<string, number>([['ghost:thr:msg', 100]]),
|
|
363
|
+
claudeBusyKeys: new Set<string>(['ghost:thr:msg']),
|
|
248
364
|
activeDraftStreams: new Map<string, FakeStream>(),
|
|
249
365
|
activeDraftParseModes: new Map<string, 'HTML' | 'MarkdownV2' | undefined>(),
|
|
250
366
|
clearActiveReactions: vi.fn(),
|
|
@@ -21,10 +21,26 @@ beforeEach(() => { vi.useFakeTimers() })
|
|
|
21
21
|
afterEach(() => { vi.useRealTimers() })
|
|
22
22
|
|
|
23
23
|
describe('inboundCoalesceKey', () => {
|
|
24
|
-
it('combines chatId and userId so distinct senders never collide', () => {
|
|
25
|
-
expect(inboundCoalesceKey('c1', 'u1')).not.toBe(inboundCoalesceKey('c1', 'u2'))
|
|
26
|
-
expect(inboundCoalesceKey('c1', 'u1')).not.toBe(inboundCoalesceKey('c2', 'u1'))
|
|
27
|
-
expect(inboundCoalesceKey('c1', 'u1')).toBe(inboundCoalesceKey('c1', 'u1'))
|
|
24
|
+
it('combines chatId, threadId, and userId so distinct senders never collide', () => {
|
|
25
|
+
expect(inboundCoalesceKey('c1', null, 'u1')).not.toBe(inboundCoalesceKey('c1', null, 'u2'))
|
|
26
|
+
expect(inboundCoalesceKey('c1', null, 'u1')).not.toBe(inboundCoalesceKey('c2', null, 'u1'))
|
|
27
|
+
expect(inboundCoalesceKey('c1', null, 'u1')).toBe(inboundCoalesceKey('c1', null, 'u1'))
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('keeps the same user\'s messages in DIFFERENT topics in distinct buckets (supergroup-mode)', () => {
|
|
31
|
+
// CPO decision #9 ratified 2026-05-27: per-topic coalesce intent.
|
|
32
|
+
// The 1.5s window is "user sends 3 sentences as one thought" —
|
|
33
|
+
// applying it cross-topic merges genuinely separate conversations.
|
|
34
|
+
expect(inboundCoalesceKey('c1', 17, 'u1')).not.toBe(inboundCoalesceKey('c1', 23, 'u1'))
|
|
35
|
+
expect(inboundCoalesceKey('c1', 17, 'u1')).not.toBe(inboundCoalesceKey('c1', null, 'u1'))
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('collapses null / undefined / 0 thread IDs to the same key (chatKey convention)', () => {
|
|
39
|
+
const k1 = inboundCoalesceKey('c1', null, 'u1')
|
|
40
|
+
const k2 = inboundCoalesceKey('c1', undefined, 'u1')
|
|
41
|
+
const k3 = inboundCoalesceKey('c1', 0, 'u1')
|
|
42
|
+
expect(k1).toBe(k2)
|
|
43
|
+
expect(k1).toBe(k3)
|
|
28
44
|
})
|
|
29
45
|
})
|
|
30
46
|
|
|
@@ -170,6 +170,234 @@ describe('wrapBot — bot.api.* calls auto-lock by first-arg chat id', () => {
|
|
|
170
170
|
expect(rb.message_id).toBe(20)
|
|
171
171
|
})
|
|
172
172
|
|
|
173
|
+
it('SAME chat + DIFFERENT thread sendMessage calls dispatch concurrently (supergroup-mode unblock)', async () => {
|
|
174
|
+
// PR2 of supergroup-mode: chat-lock moved from chatId-only keying
|
|
175
|
+
// to (chat, thread) keying. Two topics in the same supergroup must
|
|
176
|
+
// not artificially serialize at the bot.api layer.
|
|
177
|
+
const lock = createChatLock()
|
|
178
|
+
const bot = createMockBot()
|
|
179
|
+
const wrapped = lock.wrapBot({ api: bot.api as unknown as Record<string, unknown> }) as unknown as typeof bot
|
|
180
|
+
|
|
181
|
+
const dTopicA = deferred<{ message_id: number }>()
|
|
182
|
+
const dTopicB = deferred<{ message_id: number }>()
|
|
183
|
+
const started: string[] = []
|
|
184
|
+
bot.api.sendMessage.mockImplementationOnce(async (_c: string, t: string) => {
|
|
185
|
+
started.push(`A:${t}`)
|
|
186
|
+
return dTopicA.promise
|
|
187
|
+
})
|
|
188
|
+
bot.api.sendMessage.mockImplementationOnce(async (_c: string, t: string) => {
|
|
189
|
+
started.push(`B:${t}`)
|
|
190
|
+
return dTopicB.promise
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
// Same chatId "supergrp" but different message_thread_id.
|
|
194
|
+
const pA = wrapped.api.sendMessage('supergrp', 'planning msg', { message_thread_id: 17 })
|
|
195
|
+
const pB = wrapped.api.sendMessage('supergrp', 'cron digest', { message_thread_id: 23 })
|
|
196
|
+
await Promise.resolve(); await Promise.resolve()
|
|
197
|
+
// BOTH should have started — supergroup-mode parallelism guarantee.
|
|
198
|
+
expect(started).toEqual(['A:planning msg', 'B:cron digest'])
|
|
199
|
+
|
|
200
|
+
// Resolve B first; A's resolution must not delay B's return.
|
|
201
|
+
dTopicB.resolve({ message_id: 200 })
|
|
202
|
+
const rB = await pB
|
|
203
|
+
expect(rB.message_id).toBe(200)
|
|
204
|
+
|
|
205
|
+
dTopicA.resolve({ message_id: 100 })
|
|
206
|
+
const rA = await pA
|
|
207
|
+
expect(rA.message_id).toBe(100)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('SAME chat + SAME thread sendMessage calls STILL serialize (per-topic ordering preserved)', async () => {
|
|
211
|
+
// Per-topic message order matters (a reply tied to a message_id
|
|
212
|
+
// must see that message exist first). The lock must still serialize
|
|
213
|
+
// within a topic.
|
|
214
|
+
const lock = createChatLock()
|
|
215
|
+
const bot = createMockBot()
|
|
216
|
+
const wrapped = lock.wrapBot({ api: bot.api as unknown as Record<string, unknown> }) as unknown as typeof bot
|
|
217
|
+
|
|
218
|
+
const dSlow = deferred<{ message_id: number }>()
|
|
219
|
+
const startOrder: string[] = []
|
|
220
|
+
bot.api.sendMessage.mockImplementationOnce(async (_c: string, t: string) => {
|
|
221
|
+
startOrder.push(`first:${t}`)
|
|
222
|
+
const r = await dSlow.promise
|
|
223
|
+
startOrder.push(`first:end:${t}`)
|
|
224
|
+
return r
|
|
225
|
+
})
|
|
226
|
+
bot.api.sendMessage.mockImplementationOnce(async (_c: string, t: string) => {
|
|
227
|
+
startOrder.push(`second:${t}`)
|
|
228
|
+
return { message_id: 2 }
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
const p1 = wrapped.api.sendMessage('supergrp', 'first', { message_thread_id: 17 })
|
|
232
|
+
const p2 = wrapped.api.sendMessage('supergrp', 'second', { message_thread_id: 17 })
|
|
233
|
+
|
|
234
|
+
await Promise.resolve(); await Promise.resolve()
|
|
235
|
+
// Same thread → second waits for first.
|
|
236
|
+
expect(startOrder).toEqual(['first:first'])
|
|
237
|
+
|
|
238
|
+
dSlow.resolve({ message_id: 1 })
|
|
239
|
+
await Promise.all([p1, p2])
|
|
240
|
+
expect(startOrder).toEqual(['first:first', 'first:end:first', 'second:second'])
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('a 429 from one topic does NOT stall sends to a different topic in the same chat', async () => {
|
|
244
|
+
// CPO #8=B guardrail: when grammY's autoRetry transformer backs off
|
|
245
|
+
// on a 429 in topic A, topic B's sends must keep flowing. This test
|
|
246
|
+
// pins the contract by simulating a slow/rejecting first call (proxy
|
|
247
|
+
// for a 429+backoff path) on topic A and asserting topic B doesn't
|
|
248
|
+
// wait on it.
|
|
249
|
+
const lock = createChatLock()
|
|
250
|
+
const bot = createMockBot()
|
|
251
|
+
const wrapped = lock.wrapBot({ api: bot.api as unknown as Record<string, unknown> }) as unknown as typeof bot
|
|
252
|
+
|
|
253
|
+
// Topic A's send hangs (proxy for an in-flight 429-backoff path);
|
|
254
|
+
// topic B's send must NOT wait on it.
|
|
255
|
+
const dTopicA = deferred<{ message_id: number }>()
|
|
256
|
+
const started: string[] = []
|
|
257
|
+
bot.api.sendMessage.mockImplementationOnce(async () => {
|
|
258
|
+
started.push('topicA:start')
|
|
259
|
+
return dTopicA.promise
|
|
260
|
+
})
|
|
261
|
+
bot.api.sendMessage.mockImplementationOnce(async () => {
|
|
262
|
+
started.push('topicB:start')
|
|
263
|
+
return { message_id: 2 }
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
const pA = wrapped.api.sendMessage('supergrp', 'overloaded', { message_thread_id: 17 })
|
|
267
|
+
const pB = wrapped.api.sendMessage('supergrp', 'unaffected', { message_thread_id: 23 })
|
|
268
|
+
|
|
269
|
+
// Topic B should complete WITHOUT waiting on topic A's hang.
|
|
270
|
+
const rB = await pB
|
|
271
|
+
expect(rB.message_id).toBe(2)
|
|
272
|
+
expect(started).toContain('topicB:start')
|
|
273
|
+
|
|
274
|
+
// Resolve A so the lock chain drains cleanly.
|
|
275
|
+
dTopicA.resolve({ message_id: 1 })
|
|
276
|
+
const rA = await pA
|
|
277
|
+
expect(rA.message_id).toBe(1)
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('strips message_thread_id=1 from sendMessage opts (General-topic Bot API quirk)', async () => {
|
|
281
|
+
// Telegram's General topic has id=1 at MTProto but the Bot API
|
|
282
|
+
// REJECTS message_thread_id=1 on send with HTTP 400 "message thread
|
|
283
|
+
// not found" (tdlib/telegram-bot-api#447). The wrapper strips id=1
|
|
284
|
+
// from the opts bag before the underlying API call.
|
|
285
|
+
const lock = createChatLock()
|
|
286
|
+
const bot = createMockBot()
|
|
287
|
+
const wrapped = lock.wrapBot({ api: bot.api as unknown as Record<string, unknown> }) as unknown as typeof bot
|
|
288
|
+
|
|
289
|
+
bot.api.sendMessage.mockImplementationOnce(async () => ({ message_id: 1 }))
|
|
290
|
+
|
|
291
|
+
await wrapped.api.sendMessage('supergrp', 'hello General', { message_thread_id: 1 })
|
|
292
|
+
|
|
293
|
+
const callOpts = bot.api.sendMessage.mock.calls[0]![2]
|
|
294
|
+
expect(callOpts).toBeDefined()
|
|
295
|
+
expect((callOpts as Record<string, unknown>).message_thread_id).toBeUndefined()
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it('preserves other opts when stripping message_thread_id=1', async () => {
|
|
299
|
+
const lock = createChatLock()
|
|
300
|
+
const bot = createMockBot()
|
|
301
|
+
const wrapped = lock.wrapBot({ api: bot.api as unknown as Record<string, unknown> }) as unknown as typeof bot
|
|
302
|
+
bot.api.sendMessage.mockImplementationOnce(async () => ({ message_id: 1 }))
|
|
303
|
+
|
|
304
|
+
await wrapped.api.sendMessage('supergrp', 'hello', {
|
|
305
|
+
message_thread_id: 1,
|
|
306
|
+
parse_mode: 'HTML',
|
|
307
|
+
reply_to_message_id: 42,
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
const callOpts = bot.api.sendMessage.mock.calls[0]![2] as Record<string, unknown>
|
|
311
|
+
expect(callOpts.message_thread_id).toBeUndefined()
|
|
312
|
+
expect(callOpts.parse_mode).toBe('HTML')
|
|
313
|
+
expect(callOpts.reply_to_message_id).toBe(42)
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
it('does NOT mutate the caller\'s opts object (strip uses a copy)', async () => {
|
|
317
|
+
const lock = createChatLock()
|
|
318
|
+
const bot = createMockBot()
|
|
319
|
+
const wrapped = lock.wrapBot({ api: bot.api as unknown as Record<string, unknown> }) as unknown as typeof bot
|
|
320
|
+
bot.api.sendMessage.mockImplementationOnce(async () => ({ message_id: 1 }))
|
|
321
|
+
|
|
322
|
+
const callerOpts = { message_thread_id: 1, parse_mode: 'HTML' as const }
|
|
323
|
+
await wrapped.api.sendMessage('supergrp', 'hello', callerOpts)
|
|
324
|
+
|
|
325
|
+
expect(callerOpts.message_thread_id).toBe(1) // caller's object untouched
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
it('does NOT strip non-1 thread IDs', async () => {
|
|
329
|
+
const lock = createChatLock()
|
|
330
|
+
const bot = createMockBot()
|
|
331
|
+
const wrapped = lock.wrapBot({ api: bot.api as unknown as Record<string, unknown> }) as unknown as typeof bot
|
|
332
|
+
bot.api.sendMessage.mockImplementationOnce(async () => ({ message_id: 1 }))
|
|
333
|
+
|
|
334
|
+
await wrapped.api.sendMessage('supergrp', 'planning msg', { message_thread_id: 17 })
|
|
335
|
+
|
|
336
|
+
const callOpts = bot.api.sendMessage.mock.calls[0]![2] as Record<string, unknown>
|
|
337
|
+
expect(callOpts.message_thread_id).toBe(17) // non-General threads untouched
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
it('two SAME-chat General-topic sends still serialize (id=1 normalized to chat-root lane)', async () => {
|
|
341
|
+
// After stripping id=1, the lock key normalizes to chatKey(chatId, null)
|
|
342
|
+
// = `chatId:_`. Two General-topic sends queue through the same
|
|
343
|
+
// chat-root lane preserving order (Telegram per-chat order matters).
|
|
344
|
+
const lock = createChatLock()
|
|
345
|
+
const bot = createMockBot()
|
|
346
|
+
const wrapped = lock.wrapBot({ api: bot.api as unknown as Record<string, unknown> }) as unknown as typeof bot
|
|
347
|
+
|
|
348
|
+
const dSlow = deferred<{ message_id: number }>()
|
|
349
|
+
const order: string[] = []
|
|
350
|
+
bot.api.sendMessage.mockImplementationOnce(async (_c: string, t: string) => {
|
|
351
|
+
order.push(`first:${t}`)
|
|
352
|
+
const r = await dSlow.promise
|
|
353
|
+
order.push(`first:end:${t}`)
|
|
354
|
+
return r
|
|
355
|
+
})
|
|
356
|
+
bot.api.sendMessage.mockImplementationOnce(async (_c: string, t: string) => {
|
|
357
|
+
order.push(`second:${t}`)
|
|
358
|
+
return { message_id: 2 }
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
const p1 = wrapped.api.sendMessage('supergrp', 'general msg 1', { message_thread_id: 1 })
|
|
362
|
+
const p2 = wrapped.api.sendMessage('supergrp', 'general msg 2', { message_thread_id: 1 })
|
|
363
|
+
|
|
364
|
+
await Promise.resolve(); await Promise.resolve()
|
|
365
|
+
expect(order).toEqual(['first:general msg 1']) // second waits
|
|
366
|
+
|
|
367
|
+
dSlow.resolve({ message_id: 1 })
|
|
368
|
+
await Promise.all([p1, p2])
|
|
369
|
+
expect(order).toEqual(['first:general msg 1', 'first:end:general msg 1', 'second:general msg 2'])
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
it('General-topic strip does NOT serialize against non-General sends in the same chat', async () => {
|
|
373
|
+
// id=1 → chat-root lane. id=17 → its own lane. Independent dispatch.
|
|
374
|
+
const lock = createChatLock()
|
|
375
|
+
const bot = createMockBot()
|
|
376
|
+
const wrapped = lock.wrapBot({ api: bot.api as unknown as Record<string, unknown> }) as unknown as typeof bot
|
|
377
|
+
|
|
378
|
+
const dGeneral = deferred<{ message_id: number }>()
|
|
379
|
+
const dPlanning = deferred<{ message_id: number }>()
|
|
380
|
+
const started: string[] = []
|
|
381
|
+
bot.api.sendMessage.mockImplementationOnce(async () => {
|
|
382
|
+
started.push('general')
|
|
383
|
+
return dGeneral.promise
|
|
384
|
+
})
|
|
385
|
+
bot.api.sendMessage.mockImplementationOnce(async () => {
|
|
386
|
+
started.push('planning')
|
|
387
|
+
return dPlanning.promise
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
const pGeneral = wrapped.api.sendMessage('supergrp', 'g', { message_thread_id: 1 })
|
|
391
|
+
const pPlanning = wrapped.api.sendMessage('supergrp', 'p', { message_thread_id: 17 })
|
|
392
|
+
|
|
393
|
+
await Promise.resolve(); await Promise.resolve()
|
|
394
|
+
expect(started).toEqual(['general', 'planning']) // both started concurrently
|
|
395
|
+
|
|
396
|
+
dPlanning.resolve({ message_id: 17 })
|
|
397
|
+
dGeneral.resolve({ message_id: 1 })
|
|
398
|
+
await Promise.all([pGeneral, pPlanning])
|
|
399
|
+
})
|
|
400
|
+
|
|
173
401
|
it('react (setMessageReaction) queues behind an in-flight reply (sendMessage) to the same chat', async () => {
|
|
174
402
|
const lock = createChatLock()
|
|
175
403
|
const bot = createMockBot()
|