switchroom 0.18.31 → 0.18.33
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 +4 -2
- package/dist/auth-broker/index.js +21 -3
- package/dist/cli/notion-write-pretool.mjs +4 -2
- package/dist/cli/switchroom.js +1410 -852
- package/dist/host-control/main.js +22 -4
- package/dist/vault/approvals/kernel-server.js +21 -3
- package/dist/vault/broker/server.js +48 -4
- package/package.json +4 -3
- package/profiles/_base/start.sh.hbs +148 -23
- package/telegram-plugin/dist/gateway/gateway.js +62726 -58282
- package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
- package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
- package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
- package/telegram-plugin/gateway/backstop-delivery.ts +223 -23
- package/telegram-plugin/gateway/boot-card.ts +169 -1
- package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
- package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
- package/telegram-plugin/gateway/captured-answer-resume.ts +259 -0
- package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
- package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
- package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
- package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
- package/telegram-plugin/gateway/disconnect-flush.ts +6 -44
- package/telegram-plugin/gateway/gateway-import-clean.test.ts +188 -0
- package/telegram-plugin/gateway/gateway.ts +6403 -13456
- package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +7 -15
- package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +35 -68
- package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
- package/telegram-plugin/gateway/inbound-router.ts +400 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
- package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
- package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
- package/telegram-plugin/gateway/model-command.ts +23 -0
- package/telegram-plugin/gateway/narrative-lane.ts +865 -0
- package/telegram-plugin/gateway/obligation-ledger.ts +42 -0
- package/telegram-plugin/gateway/obligation-store.ts +37 -1
- package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +2012 -0
- package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
- package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
- package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
- package/telegram-plugin/gateway/secret-request-card.ts +45 -0
- package/telegram-plugin/gateway/stream-render.ts +2166 -0
- package/telegram-plugin/gateway/turn-end.ts +606 -0
- package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
- package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
- package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
- package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
- package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
- package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
- package/telegram-plugin/narrative-dedup.ts +24 -1
- package/telegram-plugin/narrative-flush.ts +2 -2
- package/telegram-plugin/pending-user-notice.ts +59 -13
- package/telegram-plugin/render/render.ts +25 -1
- package/telegram-plugin/status-no-truncate.ts +13 -0
- package/telegram-plugin/subagent-watcher.ts +297 -31
- package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
- package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
- package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
- package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
- package/telegram-plugin/tests/backstop-delivery.test.ts +167 -0
- package/telegram-plugin/tests/backstop-readback-probe.test.ts +144 -0
- package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
- package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
- package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +28 -9
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +21 -12
- package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
- package/telegram-plugin/tests/captured-answer-resume.test.ts +358 -0
- package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
- package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
- package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +76 -29
- package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +4 -1
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +45 -16
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
- package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +270 -0
- package/telegram-plugin/tests/gateway-boot-smoke.test.ts +150 -0
- package/telegram-plugin/tests/gateway-bot-construction-deferral.test.ts +251 -0
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +5 -128
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +299 -0
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +18 -5
- package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
- package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
- package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +54 -150
- package/telegram-plugin/tests/inbound-delivery-cutover-gate.test.ts +10 -14
- package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +6 -7
- package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +0 -16
- package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +18 -7
- package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
- package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +69 -14
- package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
- package/telegram-plugin/tests/model-command.test.ts +30 -0
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +36 -12
- package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
- package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
- package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
- package/telegram-plugin/tests/obligation-ledger.test.ts +40 -0
- package/telegram-plugin/tests/obligation-store.test.ts +43 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +18 -8
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +9 -6
- package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
- package/telegram-plugin/tests/photo-reroute-wiring.test.ts +5 -2
- package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
- package/telegram-plugin/tests/render/render.test.ts +42 -0
- package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -2
- package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
- package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +31 -20
- package/telegram-plugin/tests/send-reply-golden.test.ts +571 -0
- package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
- package/telegram-plugin/tests/stop-command.test.ts +22 -12
- package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
- package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
- package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +305 -0
- package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +32 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
- package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +8 -12
- package/telegram-plugin/tests/turn-flush-safety.test.ts +191 -11
- package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +117 -0
- package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +2 -2
- package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
- package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
- package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
- package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
- package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +236 -20
- package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
- package/telegram-plugin/tool-activity-summary.ts +110 -38
- package/telegram-plugin/turn-flush-safety.ts +80 -14
- package/telegram-plugin/uat/restart-capability.ts +76 -0
- package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
- package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
- package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
- package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
- package/telegram-plugin/worker-activity-feed.ts +34 -4
- package/telegram-plugin/gateway/busy-key-reaper.ts +0 -113
- package/telegram-plugin/gateway/gate-parity-probe.ts +0 -102
- package/telegram-plugin/tests/busy-key-reaper.test.ts +0 -192
- package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +0 -75
- package/telegram-plugin/tests/gate-parity-probe.test.ts +0 -171
- package/telegram-plugin/tests/parallel-turns-deadlock-fix.test.ts +0 -217
|
@@ -29,6 +29,17 @@ import { readFileSync } from 'node:fs'
|
|
|
29
29
|
import { resolve } from 'node:path'
|
|
30
30
|
|
|
31
31
|
const gatewaySrc = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8')
|
|
32
|
+
// #2996 P4-A: the enqueue turn-ctor SET (`setCurrentTurn(next, statusKey(…))`)
|
|
33
|
+
// and two of the three keyed-liveness comparison sites moved VERBATIM into
|
|
34
|
+
// stream-render.ts with handleSessionEvent. In the extracted module the
|
|
35
|
+
// flag-OFF liveness read routes through the injected accessor
|
|
36
|
+
// (`getCurrentTurn() === turn`) rather than the `currentTurn` module global —
|
|
37
|
+
// same invariant, accessor spelling. These wiring oracles span both files.
|
|
38
|
+
const streamSrc = readFileSync(resolve(__dirname, '..', 'gateway', 'stream-render.ts'), 'utf-8')
|
|
39
|
+
// #2996 P8 PR-C3: the silence-poke fallback body (keyed delete + purgeChatStale
|
|
40
|
+
// self-heal) moved verbatim to liveness-wiring.ts — those oracles read there.
|
|
41
|
+
const livenessSrc = readFileSync(resolve(__dirname, '..', 'gateway', 'liveness-wiring.ts'), 'utf-8')
|
|
42
|
+
const gatewayAndStreamSrc = gatewaySrc + '\n' + streamSrc
|
|
32
43
|
|
|
33
44
|
// ---------------------------------------------------------------------------
|
|
34
45
|
// (a) SOURCE-READ STRUCTURAL ORACLE
|
|
@@ -51,19 +62,27 @@ describe('PR-4e source-read oracle — the wiring the per-topic map depends on',
|
|
|
51
62
|
const rawAssigns = gatewaySrc.match(/^[^/\n]*\bcurrentTurn = (?!currentTurnMap\.get\(\)|null$)/gm) ?? []
|
|
52
63
|
expect(rawAssigns, `unexpected raw currentTurn assignment(s): ${JSON.stringify(rawAssigns)}`).toEqual([])
|
|
53
64
|
|
|
54
|
-
// The turn ctor SET routes through setCurrentTurn with the statusKey
|
|
55
|
-
|
|
65
|
+
// The turn ctor SET routes through setCurrentTurn with the statusKey
|
|
66
|
+
// (now in stream-render.ts's `enqueue` branch — #2996 P4-A).
|
|
67
|
+
expect(gatewayAndStreamSrc).toMatch(/setCurrentTurn\(next, statusKey\(ev\.chatId, enqThreadIdNum\)\)/)
|
|
56
68
|
// The disconnect-flush clears the WHOLE map (every entry is a ghost).
|
|
57
69
|
expect(gatewaySrc).toMatch(/clearAllCurrentTurns\(\)/)
|
|
58
|
-
// The silence-poke fallback does a keyed delete for the wedged turn's key
|
|
59
|
-
|
|
70
|
+
// The silence-poke fallback does a keyed delete for the wedged turn's key
|
|
71
|
+
// (#2996 P8 PR-C3: the fallback body lives in liveness-wiring.ts).
|
|
72
|
+
expect(livenessSrc).toMatch(/endCurrentTurnForKey\(wedgedTurn, fbKey\)/)
|
|
60
73
|
})
|
|
61
74
|
|
|
62
75
|
it('endCurrentTurnAtomic closes the leak AT ORIGIN — keyed liveness guard + keyed delete', () => {
|
|
63
76
|
// Anchor on the `function ` prefix only — the signature is multi-line
|
|
64
77
|
// (gained an `opts?` param + `number | null` return in the send-honesty
|
|
65
78
|
// work), and `function ` disambiguates the definition from its call sites.
|
|
66
|
-
|
|
79
|
+
// #2996 P8 PR-B: the funnel body moved verbatim to turn-end.ts (gateway
|
|
80
|
+
// keeps a thin delegating wrapper) — read the body there.
|
|
81
|
+
const turnEndSrc = readFileSync(
|
|
82
|
+
resolve(__dirname, '..', 'gateway', 'turn-end.ts'),
|
|
83
|
+
'utf-8',
|
|
84
|
+
)
|
|
85
|
+
const body = turnEndSrc
|
|
67
86
|
.split('function endCurrentTurnAtomic(')[1]
|
|
68
87
|
?.split('\n}')[0] ?? ''
|
|
69
88
|
expect(body.length).toBeGreaterThan(50)
|
|
@@ -88,8 +107,12 @@ describe('PR-4e source-read oracle — the wiring the per-topic map depends on',
|
|
|
88
107
|
// the flag, resolve THIS turn by ITS OWN topic key. We assert the keyed
|
|
89
108
|
// form appears the expected number of times AND the literal flag-OFF form is
|
|
90
109
|
// retained (the silence-liveness-wiring oracle requires `currentTurn === turn`).
|
|
91
|
-
|
|
92
|
-
|
|
110
|
+
// #2996 P4-A: two of the three sites moved to stream-render.ts, where the
|
|
111
|
+
// flag-OFF read is spelled `getCurrentTurn() === turn` (injected accessor);
|
|
112
|
+
// the one remaining in gateway.ts keeps `currentTurn === turn`. Match either
|
|
113
|
+
// spelling across both files — the population is still exactly 3.
|
|
114
|
+
const keyedLiveness = gatewayAndStreamSrc.match(
|
|
115
|
+
/EMISSION_AUTHORITY_ENABLED \? turnLiveForItsTopic\(turn\) : (?:currentTurn|getCurrentTurn\(\)) === turn/g,
|
|
93
116
|
) ?? []
|
|
94
117
|
expect(keyedLiveness.length).toBe(3)
|
|
95
118
|
})
|
|
@@ -105,7 +128,8 @@ describe('PR-4e source-read oracle — the wiring the per-topic map depends on',
|
|
|
105
128
|
})
|
|
106
129
|
|
|
107
130
|
it('the self-heal backstop drops per-topic entries for the swept chat (purgeChatStale)', () => {
|
|
108
|
-
|
|
131
|
+
// #2996 P8 PR-C3: the self-heal lives in the extracted fallback body.
|
|
132
|
+
expect(livenessSrc).toMatch(/currentTurnMap\.purgeChatStale\(fbChatId,/)
|
|
109
133
|
})
|
|
110
134
|
})
|
|
111
135
|
|
|
@@ -101,14 +101,17 @@ describe('inbound gate holds while approval card is outstanding (#2841)', () =>
|
|
|
101
101
|
expect(fn).toMatch(/hasPendingApproval\s*=\s*pendingPermissions\.size\s*>\s*0/)
|
|
102
102
|
})
|
|
103
103
|
|
|
104
|
-
it('turnInFlightMachineOnly
|
|
104
|
+
it('turnInFlightMachineOnly reads the delivery machine (sole gate, #2996 P1)', () => {
|
|
105
105
|
// #3262: the machine-in-turn signal moved here (read by the /model & /effort
|
|
106
|
-
// resolver without the approval hold).
|
|
107
|
-
// and the
|
|
108
|
-
//
|
|
106
|
+
// resolver without the approval hold). #2996 P1 deleted the legacy
|
|
107
|
+
// claudeBusyKeys branch and the gate-parity probe — the machine read is
|
|
108
|
+
// the ONLY busy source now; a reintroduced legacy branch would silently
|
|
109
|
+
// fork the busy read again.
|
|
109
110
|
const fn = slice(GATEWAY, 'function turnInFlightMachineOnly()', 1400)
|
|
110
|
-
expect(fn).
|
|
111
|
-
|
|
111
|
+
expect(fn).toContain('return isMachineInTurn()')
|
|
112
|
+
// Code-shape pins (comments may still narrate the deleted legacy design):
|
|
113
|
+
expect(fn).not.toMatch(/claudeBusyKeys\.size/)
|
|
114
|
+
expect(fn).not.toMatch(/probeGateParity\(/)
|
|
112
115
|
// NOT gated by the approval hold — that stays in turnInFlightForGate.
|
|
113
116
|
expect(fn).not.toContain('hasPendingApproval')
|
|
114
117
|
})
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outcome pins for the photo inbound handler extracted from gateway.ts
|
|
3
|
+
* (switchroom#2996 P6 cluster D). Unlike the metadata-only attachment handlers,
|
|
4
|
+
* photo downloads the largest size from the Telegram CDN into the inbox and
|
|
5
|
+
* returns the on-disk path as the coalesced image. The download closure is
|
|
6
|
+
* driven here against a fake grammy ctx + fetch, pinning: caption-or-fallback
|
|
7
|
+
* envelope, largest-size selection, inbox write, and token redaction on error.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
11
|
+
import { mkdtempSync, rmSync, readFileSync, existsSync } from 'node:fs'
|
|
12
|
+
import { tmpdir } from 'node:os'
|
|
13
|
+
import { join } from 'node:path'
|
|
14
|
+
import { handlePhotoMessage, type PhotoHandlerDeps } from '../gateway/photo-message-handler.js'
|
|
15
|
+
|
|
16
|
+
const TOKEN = '123:SECRET-TOKEN'
|
|
17
|
+
let inboxDir: string
|
|
18
|
+
|
|
19
|
+
function makeDeps(overrides: Partial<PhotoHandlerDeps> = {}) {
|
|
20
|
+
const logs: string[] = []
|
|
21
|
+
// capture the download closure passed to handleInboundCoalesced so the test
|
|
22
|
+
// can invoke it and assert the returned path.
|
|
23
|
+
let captured: { text: string; download?: () => Promise<string | undefined> } | undefined
|
|
24
|
+
const handleInboundCoalesced = vi.fn(async (_ctx: any, text: string, download: any) => {
|
|
25
|
+
captured = { text, download }
|
|
26
|
+
})
|
|
27
|
+
const deps: PhotoHandlerDeps = {
|
|
28
|
+
handleInboundCoalesced,
|
|
29
|
+
getToken: () => TOKEN,
|
|
30
|
+
inboxDir,
|
|
31
|
+
log: line => logs.push(line),
|
|
32
|
+
...overrides,
|
|
33
|
+
}
|
|
34
|
+
return { deps, handleInboundCoalesced, logs, getCaptured: () => captured }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function ctxWith(photo: unknown[], caption?: string, getFile?: any): any {
|
|
38
|
+
return {
|
|
39
|
+
message: { photo, caption },
|
|
40
|
+
api: { getFile: getFile ?? vi.fn() },
|
|
41
|
+
chat: { id: 42 },
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
inboxDir = mkdtempSync(join(tmpdir(), 'photo-inbox-'))
|
|
47
|
+
})
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
rmSync(inboxDir, { recursive: true, force: true })
|
|
50
|
+
vi.restoreAllMocks()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('handlePhotoMessage', () => {
|
|
54
|
+
it('uses the caption as the envelope, falling back to (photo)', async () => {
|
|
55
|
+
const { deps, handleInboundCoalesced } = makeDeps()
|
|
56
|
+
await handlePhotoMessage(ctxWith([{ file_id: 'p1', file_unique_id: 'u1' }]), deps)
|
|
57
|
+
expect(handleInboundCoalesced.mock.calls[0][1]).toBe('(photo)')
|
|
58
|
+
|
|
59
|
+
const { deps: deps2, handleInboundCoalesced: h2 } = makeDeps()
|
|
60
|
+
await handlePhotoMessage(ctxWith([{ file_id: 'p1', file_unique_id: 'u1' }], 'look'), deps2)
|
|
61
|
+
expect(h2.mock.calls[0][1]).toBe('look')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('downloads the largest size and writes it into the inbox', async () => {
|
|
65
|
+
const getFile = vi.fn(async () => ({ file_path: 'photos/big.jpg' }))
|
|
66
|
+
const ctx = ctxWith(
|
|
67
|
+
[{ file_id: 'small', file_unique_id: 'us' }, { file_id: 'big', file_unique_id: 'ub' }],
|
|
68
|
+
undefined,
|
|
69
|
+
getFile,
|
|
70
|
+
)
|
|
71
|
+
const { deps, getCaptured } = makeDeps()
|
|
72
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
73
|
+
new Response(new Uint8Array([1, 2, 3]), { status: 200 }),
|
|
74
|
+
)
|
|
75
|
+
await handlePhotoMessage(ctx, deps)
|
|
76
|
+
const dlPath = await getCaptured()!.download!()
|
|
77
|
+
// Largest (last) size selected.
|
|
78
|
+
expect(getFile).toHaveBeenCalledWith('big')
|
|
79
|
+
// Token embedded in the CDN URL.
|
|
80
|
+
expect(fetchSpy.mock.calls[0][0]).toContain(`/bot${TOKEN}/photos/big.jpg`)
|
|
81
|
+
expect(dlPath).toBeDefined()
|
|
82
|
+
expect(existsSync(dlPath!)).toBe(true)
|
|
83
|
+
expect(Array.from(readFileSync(dlPath!))).toEqual([1, 2, 3])
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('returns undefined and logs HTTP status on a failed download', async () => {
|
|
87
|
+
const getFile = vi.fn(async () => ({ file_path: 'photos/x.jpg' }))
|
|
88
|
+
const { deps, getCaptured, logs } = makeDeps()
|
|
89
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 502 }))
|
|
90
|
+
await handlePhotoMessage(ctxWith([{ file_id: 'p', file_unique_id: 'u' }], undefined, getFile), deps)
|
|
91
|
+
const dlPath = await getCaptured()!.download!()
|
|
92
|
+
expect(dlPath).toBeUndefined()
|
|
93
|
+
expect(logs.some(l => l.includes('HTTP 502'))).toBe(true)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('redacts the bot token from a thrown error message', async () => {
|
|
97
|
+
const getFile = vi.fn(async () => ({ file_path: 'photos/x.jpg' }))
|
|
98
|
+
const { deps, getCaptured, logs } = makeDeps()
|
|
99
|
+
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error(`connect failed to bot${TOKEN}/x`))
|
|
100
|
+
await handlePhotoMessage(ctxWith([{ file_id: 'p', file_unique_id: 'u' }], undefined, getFile), deps)
|
|
101
|
+
const dlPath = await getCaptured()!.download!()
|
|
102
|
+
expect(dlPath).toBeUndefined()
|
|
103
|
+
const line = logs.find(l => l.startsWith('telegram gateway: photo download failed:'))!
|
|
104
|
+
expect(line).toContain('<REDACTED>')
|
|
105
|
+
expect(line).not.toContain(TOKEN)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('returns undefined when the file has no path', async () => {
|
|
109
|
+
const getFile = vi.fn(async () => ({}))
|
|
110
|
+
const { deps, getCaptured } = makeDeps()
|
|
111
|
+
await handlePhotoMessage(ctxWith([{ file_id: 'p', file_unique_id: 'u' }], undefined, getFile), deps)
|
|
112
|
+
expect(await getCaptured()!.download!()).toBeUndefined()
|
|
113
|
+
})
|
|
114
|
+
})
|
|
@@ -21,12 +21,15 @@ import { describe, it, expect } from 'vitest'
|
|
|
21
21
|
import { readFileSync } from 'node:fs'
|
|
22
22
|
import { resolve } from 'node:path'
|
|
23
23
|
|
|
24
|
+
// #2996 P2: executeReply's body (incl. the file-send + result + history
|
|
25
|
+
// block this suite pins) moved VERBATIM to outbound-send-path.ts
|
|
26
|
+
// (`sendReply`); the slice reads there now. Assertions unchanged.
|
|
24
27
|
const gatewaySrc = readFileSync(
|
|
25
|
-
resolve(__dirname, '..', 'gateway', '
|
|
28
|
+
resolve(__dirname, '..', 'gateway', 'outbound-send-path.ts'),
|
|
26
29
|
'utf-8',
|
|
27
30
|
)
|
|
28
31
|
|
|
29
|
-
// Slice
|
|
32
|
+
// Slice sendReply's file-send + result + history block: from the
|
|
30
33
|
// photo-precheck anchor to the recordOutbound call for replies.
|
|
31
34
|
const sliceStart = gatewaySrc.indexOf('const photoPrecheck = new Map')
|
|
32
35
|
const sliceEnd = gatewaySrc.indexOf('history recordOutbound (reply) failed')
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outcome pins for the `message:pinned_message` service-message cleanup handler
|
|
3
|
+
* extracted from gateway.ts (switchroom#2996 P6 cluster F). Asserts the
|
|
4
|
+
* chat-scoped ownership guard (only OUR pins get their service message
|
|
5
|
+
* deleted), the reconcile-store race retry, and best-effort logging on a
|
|
6
|
+
* delete failure — against injected pin-state maps + a mock deleteServiceMessage.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
10
|
+
import {
|
|
11
|
+
handlePinnedMessage,
|
|
12
|
+
type PinnedMessageHandlerDeps,
|
|
13
|
+
} from '../gateway/pinned-message-handler.js'
|
|
14
|
+
|
|
15
|
+
function makeDeps(over: Partial<PinnedMessageHandlerDeps> = {}) {
|
|
16
|
+
const deleteServiceMessage = vi.fn(async () => {})
|
|
17
|
+
const log = vi.fn()
|
|
18
|
+
const deps: PinnedMessageHandlerDeps = {
|
|
19
|
+
statusPinState: new Map(),
|
|
20
|
+
statusPinChatIds: new Map(),
|
|
21
|
+
deleteServiceMessage,
|
|
22
|
+
log,
|
|
23
|
+
...over,
|
|
24
|
+
}
|
|
25
|
+
return { deps, deleteServiceMessage, log }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function ctxWith(chatId: number, pinnedMessageId: number | undefined, serviceMsgId = 999): any {
|
|
29
|
+
return {
|
|
30
|
+
msg: {
|
|
31
|
+
message_id: serviceMsgId,
|
|
32
|
+
pinned_message: pinnedMessageId == null ? undefined : { message_id: pinnedMessageId },
|
|
33
|
+
},
|
|
34
|
+
chat: { id: chatId },
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('handlePinnedMessage — ownership guard', () => {
|
|
39
|
+
it('deletes the service message when the pin is ours in this chat', async () => {
|
|
40
|
+
const { deps, deleteServiceMessage } = makeDeps({
|
|
41
|
+
statusPinState: new Map([['fg:a', { messageId: 555 }]]),
|
|
42
|
+
statusPinChatIds: new Map([['fg:a', '42']]),
|
|
43
|
+
})
|
|
44
|
+
await handlePinnedMessage(ctxWith(42, 555, 999), deps)
|
|
45
|
+
expect(deleteServiceMessage).toHaveBeenCalledWith('42', 999)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('does NOT delete when the pinned id matches but the tracked chat differs', async () => {
|
|
49
|
+
const { deps, deleteServiceMessage } = makeDeps({
|
|
50
|
+
statusPinState: new Map([['fg:a', { messageId: 555 }]]),
|
|
51
|
+
statusPinChatIds: new Map([['fg:a', '99']]), // tracked in a DIFFERENT chat
|
|
52
|
+
})
|
|
53
|
+
await handlePinnedMessage(ctxWith(42, 555, 999), deps)
|
|
54
|
+
expect(deleteServiceMessage).not.toHaveBeenCalled()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('does nothing when there is no pinned_message id', async () => {
|
|
58
|
+
const { deps, deleteServiceMessage } = makeDeps()
|
|
59
|
+
await handlePinnedMessage(ctxWith(42, undefined), deps)
|
|
60
|
+
expect(deleteServiceMessage).not.toHaveBeenCalled()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('does not delete an untracked (operator-manual) pin', async () => {
|
|
64
|
+
const { deps, deleteServiceMessage } = makeDeps()
|
|
65
|
+
await handlePinnedMessage(ctxWith(42, 555, 999), deps)
|
|
66
|
+
expect(deleteServiceMessage).not.toHaveBeenCalled()
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe('handlePinnedMessage — reconcile-store race', () => {
|
|
71
|
+
it('re-checks ownership once after the store catches up', async () => {
|
|
72
|
+
// REAL timers on purpose: this suite also runs under `bun test`, whose
|
|
73
|
+
// vitest shim does not support vi.useFakeTimers — fake timers here left
|
|
74
|
+
// the global clock mocked and hung the NEXT test file in CI (the exact
|
|
75
|
+
// #3354 bun-test failure). The handler waits 250ms before its single
|
|
76
|
+
// re-check; populate the store inside that window and await the real
|
|
77
|
+
// delay.
|
|
78
|
+
const state = new Map<string, { messageId: number }>()
|
|
79
|
+
const chatIds = new Map<string, string>()
|
|
80
|
+
const { deps, deleteServiceMessage } = makeDeps({
|
|
81
|
+
statusPinState: state,
|
|
82
|
+
statusPinChatIds: chatIds,
|
|
83
|
+
})
|
|
84
|
+
const p = handlePinnedMessage(ctxWith(42, 555, 999), deps)
|
|
85
|
+
// Store catches up during the 250ms race window.
|
|
86
|
+
state.set('fg:a', { messageId: 555 })
|
|
87
|
+
chatIds.set('fg:a', '42')
|
|
88
|
+
await p
|
|
89
|
+
expect(deleteServiceMessage).toHaveBeenCalledWith('42', 999)
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
describe('handlePinnedMessage — best-effort delete failure', () => {
|
|
94
|
+
it('logs a concise reason when the delete throws', async () => {
|
|
95
|
+
const { deps, log } = makeDeps({
|
|
96
|
+
statusPinState: new Map([['fg:a', { messageId: 555 }]]),
|
|
97
|
+
statusPinChatIds: new Map([['fg:a', '42']]),
|
|
98
|
+
deleteServiceMessage: vi.fn(async () => {
|
|
99
|
+
throw new Error('not enough rights')
|
|
100
|
+
}),
|
|
101
|
+
})
|
|
102
|
+
await handlePinnedMessage(ctxWith(42, 555, 999), deps)
|
|
103
|
+
expect(log).toHaveBeenCalledTimes(1)
|
|
104
|
+
const line = log.mock.calls[0][0] as string
|
|
105
|
+
expect(line).toContain('could not delete pin service message')
|
|
106
|
+
expect(line).toContain('not enough rights')
|
|
107
|
+
})
|
|
108
|
+
})
|
|
@@ -143,6 +143,48 @@ describe("render: inline palette", () => {
|
|
|
143
143
|
});
|
|
144
144
|
});
|
|
145
145
|
|
|
146
|
+
describe("render: line-leading issue refs are not headings (#3304 regression)", () => {
|
|
147
|
+
it("multi-line message whose first line starts with #3304 stays a paragraph", () => {
|
|
148
|
+
const md = "#3304 is merged. The regression suite is green.\n\nNext up: the follow-up PR.";
|
|
149
|
+
const doc = parse(md);
|
|
150
|
+
// Spec-correct parse: `#` without a following space is NOT an ATX heading.
|
|
151
|
+
expect(doc.blocks.map((b) => b.type)).toEqual(["paragraph", "paragraph"]);
|
|
152
|
+
|
|
153
|
+
const out = render(doc);
|
|
154
|
+
// The line-leading `#` must be escaped so Telegram's non-spec rich parser
|
|
155
|
+
// cannot promote the line to a heading...
|
|
156
|
+
expect(out.startsWith("\\#3304 is merged.")).toBe(true);
|
|
157
|
+
expect(out).not.toMatch(/^#{1,6} /m);
|
|
158
|
+
// ...and the escape resolves back to the literal "#3304" on re-parse
|
|
159
|
+
// (what the reader sees), with no heading node anywhere.
|
|
160
|
+
const reparsed = parse(out);
|
|
161
|
+
expect(reparsed.blocks.map((b) => b.type)).toEqual(["paragraph", "paragraph"]);
|
|
162
|
+
expect((reparsed.blocks[0] as any).children[0].text).toContain("#3304 is merged.");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("issue ref at line start mid-message is escaped too", () => {
|
|
166
|
+
const md = "Shipped today:\n#123 closes the loop on retries.";
|
|
167
|
+
const out = render(parse(md));
|
|
168
|
+
expect(out).toContain("\n\\#123 closes the loop");
|
|
169
|
+
const reparsed = parse(out);
|
|
170
|
+
expect(reparsed.blocks.every((b) => b.type !== "heading")).toBe(true);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("a genuine `# Heading` still renders as a heading", () => {
|
|
174
|
+
const md = "# Release notes\n\n#456 fixed the flake.";
|
|
175
|
+
const out = render(parse(md));
|
|
176
|
+
expect(out.startsWith("# Release notes")).toBe(true);
|
|
177
|
+
expect(out).toContain("\\#456 fixed the flake.");
|
|
178
|
+
const reparsed = parse(out);
|
|
179
|
+
expect(reparsed.blocks[0].type).toBe("heading");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("mid-line `#` is left alone", () => {
|
|
183
|
+
const md = "merged in PR #789 yesterday";
|
|
184
|
+
expect(render(parse(md))).toBe(md);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
146
188
|
describe("render: block palette", () => {
|
|
147
189
|
it("heading levels 1-6", () => {
|
|
148
190
|
for (let level = 1; level <= 6; level++) {
|
|
@@ -43,8 +43,10 @@ describe('#1713 + #1728 — reply tool reaction contract', () => {
|
|
|
43
43
|
const fs = require('node:fs') as typeof import('node:fs')
|
|
44
44
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
45
45
|
const path = require('node:path') as typeof import('node:path')
|
|
46
|
+
// #2996 P2: executeReply's body moved verbatim to outbound-send-path.ts
|
|
47
|
+
// (`sendReply`); the post-send block anchors live there now.
|
|
46
48
|
const src = fs.readFileSync(
|
|
47
|
-
path.resolve(__dirname, '../gateway/
|
|
49
|
+
path.resolve(__dirname, '../gateway/outbound-send-path.ts'),
|
|
48
50
|
'utf8',
|
|
49
51
|
)
|
|
50
52
|
// Find the executeReply post-send block — anchored on the
|
|
@@ -75,8 +77,10 @@ describe('#1713 + #1728 — reply tool reaction contract', () => {
|
|
|
75
77
|
const fs = require('node:fs') as typeof import('node:fs')
|
|
76
78
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
77
79
|
const path = require('node:path') as typeof import('node:path')
|
|
80
|
+
// #2996 P2: executeReply's body moved verbatim to outbound-send-path.ts
|
|
81
|
+
// (`sendReply`); the post-send block anchors live there now.
|
|
78
82
|
const src = fs.readFileSync(
|
|
79
|
-
path.resolve(__dirname, '../gateway/
|
|
83
|
+
path.resolve(__dirname, '../gateway/outbound-send-path.ts'),
|
|
80
84
|
'utf8',
|
|
81
85
|
)
|
|
82
86
|
const anchor = src.indexOf("fresh sendMessage from reply tool is a user-visible")
|
|
@@ -31,10 +31,14 @@ import { describe, it, expect } from "vitest";
|
|
|
31
31
|
import { readFileSync } from "node:fs";
|
|
32
32
|
import { resolve } from "node:path";
|
|
33
33
|
|
|
34
|
-
const gatewaySrc =
|
|
35
|
-
resolve(__dirname, "..", "gateway", "gateway.ts"),
|
|
36
|
-
"
|
|
37
|
-
)
|
|
34
|
+
const gatewaySrc =
|
|
35
|
+
readFileSync(resolve(__dirname, "..", "gateway", "gateway.ts"), "utf-8") +
|
|
36
|
+
"\n" +
|
|
37
|
+
// P7 PR-9 (#2996): the secret-detect pipeline intercept moved verbatim into
|
|
38
|
+
// gateway/inbound-interceptors.ts (interceptSecretDetectPipeline); the
|
|
39
|
+
// FAIL-CLOSED block anchor now resolves there. deleteSensitiveMessage is
|
|
40
|
+
// injected via deps but every branch still routes through it.
|
|
41
|
+
readFileSync(resolve(__dirname, "..", "gateway", "inbound-interceptors.ts"), "utf-8");
|
|
38
42
|
|
|
39
43
|
/** Slice the gateway source between two anchor strings. */
|
|
40
44
|
function sliceBetween(src: string, from: string, to: string): string {
|
|
@@ -19,10 +19,20 @@ import { readFileSync } from 'node:fs'
|
|
|
19
19
|
* bytes directly.
|
|
20
20
|
*/
|
|
21
21
|
describe('secret-detect pipeline fail-closed contract (gateway)', () => {
|
|
22
|
-
|
|
22
|
+
// P7 PR-9 (#2996): the pipeline intercept (incl. the fail-closed catch)
|
|
23
|
+
// moved verbatim into gateway/inbound-interceptors.ts
|
|
24
|
+
// (interceptSecretDetectPipeline). The catch now returns `{ handled: true }`
|
|
25
|
+
// and the gateway call site early-returns on `handled` BEFORE the sinks —
|
|
26
|
+
// the contract is unchanged, split across the seam.
|
|
27
|
+
const gatewaySrc = readFileSync(
|
|
23
28
|
new URL('../gateway/gateway.ts', import.meta.url),
|
|
24
29
|
'utf8',
|
|
25
30
|
)
|
|
31
|
+
const interceptorsSrc = readFileSync(
|
|
32
|
+
new URL('../gateway/inbound-interceptors.ts', import.meta.url),
|
|
33
|
+
'utf8',
|
|
34
|
+
)
|
|
35
|
+
const src = gatewaySrc + '\n' + interceptorsSrc
|
|
26
36
|
|
|
27
37
|
it('has exactly one [secret-detect] pipeline error catch marker', () => {
|
|
28
38
|
const matches = src.match(/\[secret-detect\] pipeline error/g) ?? []
|
|
@@ -30,25 +40,23 @@ describe('secret-detect pipeline fail-closed contract (gateway)', () => {
|
|
|
30
40
|
})
|
|
31
41
|
|
|
32
42
|
it('the catch block around runPipeline returns (does not fall through to recordInbound / broadcast)', () => {
|
|
33
|
-
|
|
43
|
+
// Interceptor side: after the error marker, the catch must return
|
|
44
|
+
// `{ handled: true }` (drop) — never fall out of the catch body.
|
|
45
|
+
const idx = interceptorsSrc.indexOf('[secret-detect] pipeline error')
|
|
34
46
|
expect(idx).toBeGreaterThan(0)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
expect(recordOffset).toBeGreaterThan(0)
|
|
49
|
-
expect(broadcastOffset).toBeGreaterThan(0)
|
|
50
|
-
expect(returnOffset).toBeLessThan(recordOffset)
|
|
51
|
-
expect(returnOffset).toBeLessThan(broadcastOffset)
|
|
47
|
+
const tail = interceptorsSrc.slice(idx, idx + 2000)
|
|
48
|
+
expect(tail).toMatch(/\n\s*return \{ handled: true \}/)
|
|
49
|
+
// Gateway side: the delegation early-returns on `handled` BEFORE both
|
|
50
|
+
// sinks, so a dropped message can never reach them.
|
|
51
|
+
const callIdx = gatewaySrc.indexOf('await interceptSecretDetectPipeline(')
|
|
52
|
+
expect(callIdx).toBeGreaterThan(0)
|
|
53
|
+
const afterCall = gatewaySrc.slice(callIdx)
|
|
54
|
+
const handledReturn = afterCall.indexOf('if (secretOutcome.handled) return')
|
|
55
|
+
const recordOffset = afterCall.indexOf('recordInbound(')
|
|
56
|
+
const broadcastOffset = afterCall.indexOf('ipcServer.broadcast(inboundMsg)')
|
|
57
|
+
expect(handledReturn).toBeGreaterThan(0)
|
|
58
|
+
expect(recordOffset).toBeGreaterThan(handledReturn)
|
|
59
|
+
expect(broadcastOffset).toBeGreaterThan(handledReturn)
|
|
52
60
|
})
|
|
53
61
|
|
|
54
62
|
it('catch body warns the user that the message was dropped', () => {
|
|
@@ -58,15 +66,17 @@ describe('secret-detect pipeline fail-closed contract (gateway)', () => {
|
|
|
58
66
|
})
|
|
59
67
|
|
|
60
68
|
it('no path exists where recordInbound or broadcast is called with the raw effectiveText after a pipeline throw', () => {
|
|
61
|
-
// Structural guarantee
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
expect(
|
|
68
|
-
const
|
|
69
|
-
|
|
69
|
+
// Structural guarantee across the seam: the interceptor catch returns
|
|
70
|
+
// handled:true, and the gateway adopts the scrubbed effectiveText only
|
|
71
|
+
// on the handled:false path — there is no branch that reaches the sinks
|
|
72
|
+
// after a throw. Pin: the catch body ends in `return { handled: true }`
|
|
73
|
+
// with no intervening effectiveText pass-through.
|
|
74
|
+
const catchIdx = interceptorsSrc.indexOf('[secret-detect] pipeline error')
|
|
75
|
+
expect(catchIdx).toBeGreaterThan(0)
|
|
76
|
+
const catchTail = interceptorsSrc.slice(catchIdx, catchIdx + 2000)
|
|
77
|
+
const returnIdx = catchTail.search(/\n\s*return \{ handled: true \}/)
|
|
78
|
+
expect(returnIdx).toBeGreaterThan(0)
|
|
79
|
+
expect(catchTail.slice(0, returnIdx)).not.toMatch(/handled: false/)
|
|
70
80
|
})
|
|
71
81
|
|
|
72
82
|
it('server.ts no longer contains the secret-detect intercept', () => {
|