switchroom 0.18.32 → 0.19.0
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/auth-broker/index.js +17 -1
- package/dist/cli/switchroom.js +847 -729
- package/dist/host-control/main.js +18 -2
- package/dist/vault/approvals/kernel-server.js +17 -1
- package/dist/vault/broker/server.js +44 -2
- package/package.json +2 -2
- package/profiles/_base/start.sh.hbs +105 -18
- package/telegram-plugin/dist/gateway/gateway.js +60612 -56998
- 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/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/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/gateway.ts +1347 -6758
- 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-wiring.ts +333 -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/render/render.ts +25 -1
- package/telegram-plugin/status-no-truncate.ts +13 -0
- package/telegram-plugin/subagent-watcher.ts +186 -3
- 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/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 +15 -6
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +18 -9
- package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -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 +47 -10
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +27 -9
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
- package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +39 -18
- package/telegram-plugin/tests/gateway-boot-smoke.test.ts +160 -0
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +3 -7
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +8 -2
- 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-emit-after-intercepts.test.ts +14 -3
- package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
- 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 +27 -9
- 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/pending-card-durability-wiring.test.ts +16 -7
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
- package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
- 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/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 +28 -18
- 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 +14 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
- package/telegram-plugin/tests/turn-flush-safety.test.ts +183 -5
- package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +9 -4
- package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -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 +110 -20
- package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
- package/telegram-plugin/tool-activity-summary.ts +83 -35
- 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 +10 -4
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* obligation-wiring.ts — obligation open/close/cancel + the idle sweep
|
|
3
|
+
* (#2996 P8 PR-C2).
|
|
4
|
+
*
|
|
5
|
+
* Extracted VERBATIM from gateway.ts: `closeObligationOnSubstantiveReply`,
|
|
6
|
+
* `openObligationFromInbound`, `cancelInterruptedObligation` and
|
|
7
|
+
* `obligationSweep` (idle-only re-present / escalate driver). STATE stays in
|
|
8
|
+
* gateway.ts — the `ObligationLedger` instance + durable store,
|
|
9
|
+
* `pendingCrossTurnGate`, `obligationEscalateInFlight`, the grace consts, and
|
|
10
|
+
* the escalation NUDGE SEND (the one bot-api touch, kept behind the gateway's
|
|
11
|
+
* retry-policy allowlist and injected as `sendEscalationNudge`). The sweep
|
|
12
|
+
* `setInterval` stays gateway-owned (P0c: modules export the tick body).
|
|
13
|
+
*
|
|
14
|
+
* Never-storm invariants preserved by construction: grace consts injected
|
|
15
|
+
* verbatim; `obligationEscalateInFlight` crosses as the SAME shared Set
|
|
16
|
+
* reference; the represent/escalate guards are the same imported pure modules.
|
|
17
|
+
*/
|
|
18
|
+
import type { CurrentTurn, ObligationWiringDeps } from './gateway.js'
|
|
19
|
+
import type { InboundMessage } from './ipc-protocol.js'
|
|
20
|
+
import { shouldTrackDelivery } from './inbound-delivery-confirm.js'
|
|
21
|
+
import { deriveTurnId } from './derive-turn-id.js'
|
|
22
|
+
import { buildObligationRepresentInbound, type Obligation } from './obligation-ledger.js'
|
|
23
|
+
import { driveEscalation } from './escalation-drive.js'
|
|
24
|
+
import { shouldSuppressRepresent } from './represent-guard.js'
|
|
25
|
+
import { shouldDeferEscalationForBridge } from './escalation-bridge-gate.js'
|
|
26
|
+
|
|
27
|
+
export function createObligationWiring(deps: ObligationWiringDeps) {
|
|
28
|
+
const {
|
|
29
|
+
OBLIGATION_LEDGER_ENABLED,
|
|
30
|
+
HISTORY_ENABLED,
|
|
31
|
+
OBLIGATION_BACKGROUND_WORK_GRACE_MS,
|
|
32
|
+
OBLIGATION_ESCALATE_GRACE_MS,
|
|
33
|
+
OBLIGATION_REPRESENT_GRACE_MS,
|
|
34
|
+
OBLIGATION_REPRESENT_MAX,
|
|
35
|
+
OBLIGATION_REPRESENT_GUARD_MIN_REPLY_CHARS,
|
|
36
|
+
OBLIGATION_ESCALATE_MAX,
|
|
37
|
+
OBLIGATION_ESCALATE_SEND_DEADLINE_MS,
|
|
38
|
+
obligationLedger,
|
|
39
|
+
obligationEscalateInFlight,
|
|
40
|
+
pendingCrossTurnGate,
|
|
41
|
+
pendingInboundBuffer,
|
|
42
|
+
capturedResume,
|
|
43
|
+
getCurrentTurn,
|
|
44
|
+
turnInFlightForGate,
|
|
45
|
+
agentHasInFlightBackgroundWork,
|
|
46
|
+
hasOutboundDeliveredSince,
|
|
47
|
+
findTurnByOriginId,
|
|
48
|
+
bridgeAlive,
|
|
49
|
+
sendEscalationNudge,
|
|
50
|
+
} = deps
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* PR2 obligation-ledger CLOSE. Called when a SUBSTANTIVE final answer lands
|
|
54
|
+
* (not a bare interim ack — using finalAnswerSubstantive, the #2141 signal): the
|
|
55
|
+
* obligation discharged is the one for the SAME origin the answer routes to
|
|
56
|
+
* (origin_turn_id the model echoed, else the routed origin the gateway resolved,
|
|
57
|
+
* else the live turn). So 713's reply closes 713's obligation even after
|
|
58
|
+
* currentTurn flipped to 715, and 715 stays open until ITS own substantive
|
|
59
|
+
* answer. Answers to re-presented obligations (via=quoted, no model echo) close
|
|
60
|
+
* via the gateway-resolved routedOriginTurn. An ack does NOT close (so
|
|
61
|
+
* ack-then-ghost is re-presented, not re-dropped). The live-turn fallback fires
|
|
62
|
+
* only for the live turn's OWN obligation (it was the turn delivering this
|
|
63
|
+
* reply), preserving the 713/715 invariant. No-op unless the flag is on.
|
|
64
|
+
*
|
|
65
|
+
* @param routedOriginTurn — the origin the reply router already resolved
|
|
66
|
+
* (echoedTurn ?? quotedTurn); pass whenever the TURN_ORIGIN_ROUTING path ran.
|
|
67
|
+
* Skipped when null/undefined (pre-routing paths, or DM with no quote).
|
|
68
|
+
*/
|
|
69
|
+
function closeObligationOnSubstantiveReply(
|
|
70
|
+
args: Record<string, unknown>,
|
|
71
|
+
liveTurn: CurrentTurn | null | undefined,
|
|
72
|
+
routedOriginTurn?: CurrentTurn | null,
|
|
73
|
+
): void {
|
|
74
|
+
if (!OBLIGATION_LEDGER_ENABLED) return
|
|
75
|
+
const echoed = findTurnByOriginId(args.origin_turn_id as string | undefined)
|
|
76
|
+
// routedOriginTurn is the gateway-resolved origin (echoedTurn ?? quotedTurn).
|
|
77
|
+
// Only pass it as routedOriginId when it DIFFERS from the echoed turn (if
|
|
78
|
+
// echoed is present, resolveCloseTarget's first branch already handles it),
|
|
79
|
+
// and only when it is NOT the live turn (live-turn is the fallback, not the
|
|
80
|
+
// routed origin — passing live turn here would bypass the live-turn fallback
|
|
81
|
+
// logic and still close correctly, but naming matters for the 713/715 case:
|
|
82
|
+
// the routed origin on a via=quoted reply IS the origin, not "live fallback").
|
|
83
|
+
const routedOriginId =
|
|
84
|
+
routedOriginTurn != null && echoed == null
|
|
85
|
+
? routedOriginTurn.turnId
|
|
86
|
+
: null
|
|
87
|
+
const target = obligationLedger.resolveCloseTarget(echoed?.turnId, liveTurn?.turnId, routedOriginId)
|
|
88
|
+
if (target != null) obligationLedger.close(target)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* PR2 obligation-ledger OPEN. Track a fresh user inbound as an unanswered
|
|
93
|
+
* obligation the moment it is received — called BEFORE the buffer-until-idle /
|
|
94
|
+
* deliver split so a mid-turn cross-topic inbound (the 715 case: buffered while
|
|
95
|
+
* another turn runs) is tracked too. Drain paths re-deliver buffered inbounds
|
|
96
|
+
* via sendToAgent (NOT through handleInbound), so handleInbound is the ONLY
|
|
97
|
+
* point that sees every fresh inbound — opening here is mandatory for both
|
|
98
|
+
* branches. Same gate as delivery-tracking (real user turns only; synthetic /
|
|
99
|
+
* steering / `!` interrupt / empty excluded — they have no origin id / need no
|
|
100
|
+
* answer; deriveTurnId null-guards them). Idempotent → opening at buffer-time
|
|
101
|
+
* and any later delivery is safe. No-op unless the flag is on.
|
|
102
|
+
*/
|
|
103
|
+
function openObligationFromInbound(
|
|
104
|
+
inboundMsg: InboundMessage,
|
|
105
|
+
gate: { isSteering: boolean; isInterrupt: boolean; effectiveText: string },
|
|
106
|
+
): void {
|
|
107
|
+
if (!OBLIGATION_LEDGER_ENABLED) return
|
|
108
|
+
if (
|
|
109
|
+
!shouldTrackDelivery({
|
|
110
|
+
isSteering: gate.isSteering,
|
|
111
|
+
isInterrupt: gate.isInterrupt,
|
|
112
|
+
hasSource: inboundMsg.meta?.source != null,
|
|
113
|
+
effectiveText: gate.effectiveText,
|
|
114
|
+
})
|
|
115
|
+
) {
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
const oid = deriveTurnId(inboundMsg.chatId, inboundMsg.threadId, inboundMsg.messageId)
|
|
119
|
+
if (oid == null) return
|
|
120
|
+
obligationLedger.openIfAbsent({
|
|
121
|
+
originTurnId: oid,
|
|
122
|
+
chatId: inboundMsg.chatId,
|
|
123
|
+
threadId: inboundMsg.threadId,
|
|
124
|
+
messageId: inboundMsg.messageId,
|
|
125
|
+
text: inboundMsg.text ?? '',
|
|
126
|
+
openedAt: Date.now(),
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* An `!` interrupt SIGINT-kills the in-flight turn. That turn was handling a
|
|
132
|
+
* user message with an open obligation, and the killed turn does NOT reliably
|
|
133
|
+
* emit turn_end (so endCurrentTurnAtomic never closes it) — so without this the
|
|
134
|
+
* obligation survives and the idle sweep later re-presents/escalates "you have
|
|
135
|
+
* an earlier message you never answered" for a question the user EXPLICITLY
|
|
136
|
+
* cancelled. An interrupt is a deliberate redirect, so closing that obligation
|
|
137
|
+
* is the correct terminal (the user chose to interrupt; they can re-ask). Only
|
|
138
|
+
* the interrupted turn's OWN obligation is closed — queued siblings (other open
|
|
139
|
+
* obligations) are untouched. No-op when the flag is off, no turn is in flight,
|
|
140
|
+
* or the turn isn't a tracked obligation (synthetic / already closed).
|
|
141
|
+
*/
|
|
142
|
+
function cancelInterruptedObligation(): void {
|
|
143
|
+
if (!OBLIGATION_LEDGER_ENABLED) return
|
|
144
|
+
const turn = getCurrentTurn()
|
|
145
|
+
if (turn == null) return
|
|
146
|
+
if (obligationLedger.close(turn.turnId)) {
|
|
147
|
+
process.stderr.write(
|
|
148
|
+
`telegram gateway: obligation cancelled by interrupt origin=${turn.turnId}\n`,
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
// Throttle for the background-work defer diagnostic (the 5s sweep would otherwise
|
|
155
|
+
// log every tick across a multi-minute research window).
|
|
156
|
+
let lastBgWorkDeferLogMs = 0
|
|
157
|
+
|
|
158
|
+
function obligationSweep(): void {
|
|
159
|
+
if (!OBLIGATION_LEDGER_ENABLED) return
|
|
160
|
+
if (!obligationLedger.hasOpen()) return
|
|
161
|
+
if (turnInFlightForGate()) return // a turn is running — let it finish/answer
|
|
162
|
+
const agent = process.env.SWITCHROOM_AGENT_NAME ?? ''
|
|
163
|
+
const now = Date.now()
|
|
164
|
+
// Background-work grace: while genuine autonomous sub-agent work is in flight
|
|
165
|
+
// (a running worker, or an orphaned foreground sub-agent — neither visible to
|
|
166
|
+
// the turn machine), an obligation younger than the ceiling is NOT re-presented
|
|
167
|
+
// /escalated. Bounded by OBLIGATION_BACKGROUND_WORK_GRACE_MS so escalation
|
|
168
|
+
// always eventually fires. =0 disables it.
|
|
169
|
+
const backgroundWorkActive =
|
|
170
|
+
OBLIGATION_BACKGROUND_WORK_GRACE_MS > 0 && agentHasInFlightBackgroundWork(now)
|
|
171
|
+
// Grace window: skip an obligation whose handling turn ended < grace ago — its
|
|
172
|
+
// trailing slow/worker answer may still be landing (over-escalation fix).
|
|
173
|
+
// Per-represent grace: skip an obligation re-presented < grace ago — prevents
|
|
174
|
+
// the 5s sweep from immediately firing again before the re-present even lands.
|
|
175
|
+
const decision = obligationLedger.decideAtIdle(
|
|
176
|
+
OBLIGATION_ESCALATE_GRACE_MS > 0 || backgroundWorkActive || OBLIGATION_REPRESENT_GRACE_MS > 0
|
|
177
|
+
? {
|
|
178
|
+
now,
|
|
179
|
+
graceMs: OBLIGATION_ESCALATE_GRACE_MS,
|
|
180
|
+
backgroundWorkActive,
|
|
181
|
+
backgroundGraceMs: OBLIGATION_BACKGROUND_WORK_GRACE_MS,
|
|
182
|
+
representGraceMs: OBLIGATION_REPRESENT_GRACE_MS,
|
|
183
|
+
}
|
|
184
|
+
: undefined,
|
|
185
|
+
)
|
|
186
|
+
const o = decision.obligation
|
|
187
|
+
if (decision.action === 'none' || o == null) {
|
|
188
|
+
if (backgroundWorkActive && obligationLedger.hasOpen() && now - lastBgWorkDeferLogMs > 60_000) {
|
|
189
|
+
lastBgWorkDeferLogMs = now
|
|
190
|
+
process.stderr.write(
|
|
191
|
+
`telegram gateway: obligation sweep deferred — in-flight autonomous sub-agent work ` +
|
|
192
|
+
`(${obligationLedger.size()} open, bounded ${Math.round(OBLIGATION_BACKGROUND_WORK_GRACE_MS / 60_000)}m from receipt)\n`,
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
if (decision.action === 'represent') {
|
|
198
|
+
// Fix #2472 — duplicate-represent guard. Before re-presenting AGAIN, check
|
|
199
|
+
// whether the agent has ALREADY delivered a substantive outbound reply to
|
|
200
|
+
// this chat SINCE the obligation was most recently re-presented. If so the
|
|
201
|
+
// obligation is satisfied-but-misdetected (the reply landed but its routing
|
|
202
|
+
// didn't resolve back to this origin, so the normal close path missed it) —
|
|
203
|
+
// close silently and do NOT re-fire, which is what produced the near-identical
|
|
204
|
+
// duplicate in #2472 (reply 10608 answered represent_count=1, yet
|
|
205
|
+
// represent_count=2 fired anyway → duplicate 10609).
|
|
206
|
+
//
|
|
207
|
+
// The cutoff is `lastRepresentedAt` (the time of the PREVIOUS represent), NOT
|
|
208
|
+
// `openedAt`. This is load-bearing: the genuine "agent wrote a plain-text
|
|
209
|
+
// answer and never called reply" case must still represent ONCE. On the first
|
|
210
|
+
// represent `lastRepresentedAt` is undefined, so this guard is a no-op and the
|
|
211
|
+
// single represent fires as before. Only the SECOND-and-later represent is
|
|
212
|
+
// gated — exactly where a reply that landed between fires must suppress the
|
|
213
|
+
// re-ask. Falls back to false (never suppresses) if history is unavailable.
|
|
214
|
+
if (
|
|
215
|
+
shouldSuppressRepresent(o, {
|
|
216
|
+
historyEnabled: HISTORY_ENABLED,
|
|
217
|
+
// Pass the represent-guard's OWN low threshold — a terse-but-real reply
|
|
218
|
+
// must suppress the duplicate (#2472/#2474), unlike the escalate branch
|
|
219
|
+
// below which keeps the 200-char default.
|
|
220
|
+
hasOutboundDeliveredSince: (chatId, sinceMs, threadId) =>
|
|
221
|
+
hasOutboundDeliveredSince(
|
|
222
|
+
chatId,
|
|
223
|
+
sinceMs,
|
|
224
|
+
threadId,
|
|
225
|
+
OBLIGATION_REPRESENT_GUARD_MIN_REPLY_CHARS,
|
|
226
|
+
),
|
|
227
|
+
})
|
|
228
|
+
) {
|
|
229
|
+
process.stderr.write(
|
|
230
|
+
`telegram gateway: obligation closed silently — reply delivered since last represent (no re-fire) origin=${o.originTurnId}\n`,
|
|
231
|
+
)
|
|
232
|
+
obligationLedger.close(o.originTurnId)
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
// #3282 — source-aware represent: a captured snapshot ⇒ resume the non-confirmed tail byte-identically; no snapshot ⇒ the fresh-generation represent below.
|
|
236
|
+
if (o.capturedDelivery != null) { capturedResume.dispatch(o); return }
|
|
237
|
+
// Re-present goes through the bridge → buffer. Only the represent path is
|
|
238
|
+
// gated on an empty buffer (let the existing drain run first, avoid
|
|
239
|
+
// double-presenting). Escalation below is NOT gated on the buffer — it is a
|
|
240
|
+
// direct Telegram send, independent of the bridge, so a represent stranded
|
|
241
|
+
// behind a dead bridge can never block the operator nudge.
|
|
242
|
+
if (pendingInboundBuffer.depth(agent) > 0) return
|
|
243
|
+
pendingInboundBuffer.push(agent, buildObligationRepresentInbound(o, Date.now()))
|
|
244
|
+
// PR1 (cross-turn stale-card guard, §9 lever 4 / race C/D). Arm the
|
|
245
|
+
// card-OPEN gate for the synthetic turn this represent inbound will spawn:
|
|
246
|
+
// carry the obligation's `openedAt` so that turn's first card-OPEN can ask
|
|
247
|
+
// "was a substantive answer already delivered since the obligation was
|
|
248
|
+
// raised?" and, if so, suppress the card (it would otherwise narrate beneath
|
|
249
|
+
// the answer the user already received). Keyed on the obligation's
|
|
250
|
+
// `originTurnId` — the SAME id the represent inbound carries
|
|
251
|
+
// (`buildObligationRepresentInbound` reuses `o.messageId`/`o.chatId`/
|
|
252
|
+
// `o.threadId`, so the enqueue-time `deriveTurnId` reconstructs exactly
|
|
253
|
+
// `o.originTurnId`). Keying on the turn id (not chat/thread) means ONLY the
|
|
254
|
+
// exact represent turn this gate was armed for can consume it; an unrelated
|
|
255
|
+
// later foreground turn on the same chat/thread has a different originTurnId
|
|
256
|
+
// → finds no entry → its card opens normally. This closes the residual
|
|
257
|
+
// cross-contamination window where a never-enqueued represent's stale gate
|
|
258
|
+
// could suppress an unrelated turn's card (the represent/duplicate-reply
|
|
259
|
+
// family). This does NOT gate the represent SEND — the represent guard above
|
|
260
|
+
// already owns suppressing an already-satisfied represent; this only governs
|
|
261
|
+
// the decorative card.
|
|
262
|
+
pendingCrossTurnGate.set(o.originTurnId, { sinceMs: o.openedAt })
|
|
263
|
+
const attempt = obligationLedger.markRepresented(o.originTurnId)
|
|
264
|
+
process.stderr.write(
|
|
265
|
+
`telegram gateway: obligation re-presented origin=${o.originTurnId} attempt=${attempt}/${OBLIGATION_REPRESENT_MAX}\n`,
|
|
266
|
+
)
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
// escalate — re-present ladder exhausted. Before sending the user-visible
|
|
270
|
+
// apology, check whether the agent has ALREADY delivered an outbound reply
|
|
271
|
+
// to this chat since the obligation was opened. If yes, the obligation is
|
|
272
|
+
// stale (the agent did answer, just without closing the obligation via the
|
|
273
|
+
// normal close path) — close silently instead of alarming the user with a
|
|
274
|
+
// false "I may have missed this". This is Fix 4: escalate only on knowledge,
|
|
275
|
+
// not doubt. Fall back to false (safe: never suppresses) if history unavailable.
|
|
276
|
+
//
|
|
277
|
+
// #2788 Gap A — bridge-flap gate. The escalate branch DIRECT-SENDS (via
|
|
278
|
+
// bot.api.sendMessage below), bypassing the bridge. So while the bridge is
|
|
279
|
+
// down the represent branch is naturally stranded (bridge → buffer) but this
|
|
280
|
+
// branch would still fire a false "I may have missed this", even though the
|
|
281
|
+
// real reply is merely queued behind a transient outage. Defer: if the bridge
|
|
282
|
+
// is not alive, leave the obligation OPEN and re-drive on a later sweep once
|
|
283
|
+
// it recovers. Obligations survive bridge death (durable ledger, re-evaluated
|
|
284
|
+
// every sweep), so this deferral adds NO unbounded liveness dependency — the
|
|
285
|
+
// whole gateway already rests on the bridge eventually reconnecting.
|
|
286
|
+
if (shouldDeferEscalationForBridge({ bridgeAlive: bridgeAlive(agent) })) {
|
|
287
|
+
process.stderr.write(
|
|
288
|
+
`telegram gateway: obligation escalation deferred — bridge down (nudge waits for reconnect) origin=${o.originTurnId}\n`,
|
|
289
|
+
)
|
|
290
|
+
return
|
|
291
|
+
}
|
|
292
|
+
if (HISTORY_ENABLED && hasOutboundDeliveredSince(o.chatId, o.openedAt, o.threadId)) {
|
|
293
|
+
process.stderr.write(
|
|
294
|
+
`telegram gateway: obligation closed silently — outbound delivered since open origin=${o.originTurnId}\n`,
|
|
295
|
+
)
|
|
296
|
+
obligationLedger.close(o.originTurnId)
|
|
297
|
+
return
|
|
298
|
+
}
|
|
299
|
+
// Proceed with escalation: send ONE operator-visible nudge and close the
|
|
300
|
+
// obligation ONLY AFTER it actually lands. This inverts the old
|
|
301
|
+
// close-before-send (which silently dropped the terminal whenever the send
|
|
302
|
+
// failed): the close is now itself an observable terminal. A transient send
|
|
303
|
+
// failure leaves the obligation OPEN → retried next sweep; a PERMANENT one
|
|
304
|
+
// (dead topic even after thread-fallback, blocked bot) is bounded by
|
|
305
|
+
// OBLIGATION_ESCALATE_MAX → close best-effort (the user is unreachable, so a
|
|
306
|
+
// bounded give-up beats an infinite loop / a boot-surviving poison record).
|
|
307
|
+
// Drive one escalation attempt. The send is a direct Telegram nudge
|
|
308
|
+
// (retryWithThreadFallback: a stale/renumbered topic → THREAD_NOT_FOUND retries
|
|
309
|
+
// thread-less, the #2096 pattern). driveEscalation guards against concurrent
|
|
310
|
+
// sends, bounds the send with withDeadline (so a hung send can't leak the
|
|
311
|
+
// in-flight flag and wedge the obligation OPEN), closes only after a successful
|
|
312
|
+
// send, and bounds permanent failures to a best-effort close. Extracted so the
|
|
313
|
+
// hang → bounded → terminal path is executable in escalation-drive.test.ts —
|
|
314
|
+
// the path neither mtcute (can't hang Telegram) nor a synchronous test reaches.
|
|
315
|
+
void driveEscalation({
|
|
316
|
+
escId: o.originTurnId,
|
|
317
|
+
inFlight: obligationEscalateInFlight,
|
|
318
|
+
ledger: obligationLedger,
|
|
319
|
+
// The nudge send stays in gateway.ts (it owns the bot handle + the
|
|
320
|
+
// bot-api retry-policy allowlist); this module only decides WHEN it fires.
|
|
321
|
+
send: () => sendEscalationNudge(o),
|
|
322
|
+
maxAttempts: OBLIGATION_ESCALATE_MAX,
|
|
323
|
+
deadlineMs: OBLIGATION_ESCALATE_SEND_DEADLINE_MS,
|
|
324
|
+
})
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return {
|
|
328
|
+
closeObligationOnSubstantiveReply,
|
|
329
|
+
openObligationFromInbound,
|
|
330
|
+
cancelInterruptedObligation,
|
|
331
|
+
obligationSweep,
|
|
332
|
+
}
|
|
333
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Photo inbound handler (switchroom#2996 P6 cluster D).
|
|
3
|
+
*
|
|
4
|
+
* `message:photo` is the one attachment type whose gateway handling is not a
|
|
5
|
+
* pure metadata render: it downloads the largest photo size from the Telegram
|
|
6
|
+
* CDN (bounded 15s fetch), writes it into the per-agent inbox with 0600 perms,
|
|
7
|
+
* and hands the on-disk path to the coalescing inbound pipeline as the image
|
|
8
|
+
* download. Split out of the cluster-C metadata handlers precisely because it
|
|
9
|
+
* carries side-effecting IO + bot-token handling.
|
|
10
|
+
*
|
|
11
|
+
* The registration line stays in gateway.ts (order is a load-bearing invariant
|
|
12
|
+
* pinned by gateway-handler-registration-wiring.test.ts) and delegates here.
|
|
13
|
+
* The bot token is read lazily via `getToken()` — it is materialized after
|
|
14
|
+
* this deps object is constructed — and never exposed in logs (redacted on the
|
|
15
|
+
* error path). Path-safety helpers are imported directly.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
19
|
+
import type { Context, Filter } from 'grammy'
|
|
20
|
+
import { buildAttachmentPath, assertInsideInbox } from '../attachment-path.js'
|
|
21
|
+
|
|
22
|
+
export interface PhotoHandlerDeps {
|
|
23
|
+
handleInboundCoalesced: (
|
|
24
|
+
ctx: Context,
|
|
25
|
+
text: string,
|
|
26
|
+
downloadImage: (() => Promise<string | undefined>) | undefined,
|
|
27
|
+
) => Promise<void>
|
|
28
|
+
/** Lazily read the materialized bot token (set after deps construction). */
|
|
29
|
+
getToken: () => string
|
|
30
|
+
/** Per-agent inbox directory (0700). */
|
|
31
|
+
inboxDir: string
|
|
32
|
+
/** stderr log sink. */
|
|
33
|
+
log: (line: string) => void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function handlePhotoMessage(
|
|
37
|
+
ctx: Filter<Context, 'message:photo'>,
|
|
38
|
+
deps: PhotoHandlerDeps,
|
|
39
|
+
): Promise<void> {
|
|
40
|
+
const caption = ctx.message.caption ?? '(photo)'
|
|
41
|
+
await deps.handleInboundCoalesced(ctx, caption, async () => {
|
|
42
|
+
const photos = ctx.message.photo
|
|
43
|
+
const best = photos[photos.length - 1]
|
|
44
|
+
try {
|
|
45
|
+
const file = await ctx.api.getFile(best.file_id)
|
|
46
|
+
if (!file.file_path) return undefined
|
|
47
|
+
// Build download URL — token is embedded in the URL but never exposed
|
|
48
|
+
// in error messages or logs (caught and sanitized below).
|
|
49
|
+
//
|
|
50
|
+
// Bounded fetch: a stalled Telegram CDN connection without a
|
|
51
|
+
// timeout would hang the entire inbound handler, blocking the
|
|
52
|
+
// user's photo from ever being acked or seen by the agent.
|
|
53
|
+
// 15s is generous for normal photos (typical 100ms-2s) and
|
|
54
|
+
// tight enough to surface a real outage.
|
|
55
|
+
const token = deps.getToken()
|
|
56
|
+
const downloadUrl = `https://api.telegram.org/file/bot${token}/${file.file_path}`
|
|
57
|
+
const res = await fetch(downloadUrl, { signal: AbortSignal.timeout(15_000) })
|
|
58
|
+
if (!res.ok) {
|
|
59
|
+
deps.log(`telegram gateway: photo download failed: HTTP ${res.status}\n`)
|
|
60
|
+
return undefined
|
|
61
|
+
}
|
|
62
|
+
const buf = Buffer.from(await res.arrayBuffer())
|
|
63
|
+
const dlPath = buildAttachmentPath({
|
|
64
|
+
inboxDir: deps.inboxDir,
|
|
65
|
+
telegramFilePath: file.file_path,
|
|
66
|
+
fileUniqueId: best.file_unique_id,
|
|
67
|
+
now: Date.now(),
|
|
68
|
+
})
|
|
69
|
+
mkdirSync(deps.inboxDir, { recursive: true, mode: 0o700 })
|
|
70
|
+
assertInsideInbox(deps.inboxDir, dlPath)
|
|
71
|
+
writeFileSync(dlPath, buf, { mode: 0o600 })
|
|
72
|
+
return dlPath
|
|
73
|
+
} catch (err) {
|
|
74
|
+
// Sanitize error to avoid leaking bot token in logs
|
|
75
|
+
const msg = err instanceof Error ? err.message : 'unknown error'
|
|
76
|
+
deps.log(`telegram gateway: photo download failed: ${msg.replace(deps.getToken(), '<REDACTED>')}\n`)
|
|
77
|
+
return undefined
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `message:pinned_message` service-message cleanup (switchroom#2996 P6 cluster F).
|
|
3
|
+
*
|
|
4
|
+
* Suppresses the "pinned a message" service message Telegram inserts when OUR
|
|
5
|
+
* silent status-pin fires. The pin call passes `disable_notification: true`,
|
|
6
|
+
* which kills the PUSH notification but NOT the in-chat service message — so we
|
|
7
|
+
* delete that service message as it lands, but ONLY for pins we own (tracked in
|
|
8
|
+
* `statusPinState`). Manual/operator pins are never silent and are never
|
|
9
|
+
* touched. Only silent status pins reach here as an OUR-pin match, so the
|
|
10
|
+
* ownership check is the guard.
|
|
11
|
+
*
|
|
12
|
+
* The registration line stays in gateway.ts (order is a load-bearing invariant
|
|
13
|
+
* pinned by gateway-handler-registration-wiring.test.ts) and delegates here.
|
|
14
|
+
*
|
|
15
|
+
* DI note (check-bot-api-wrapping): the actual `lockedBot.api.deleteMessage`
|
|
16
|
+
* call is NOT made here — it is injected as `deleteServiceMessage`, bound in
|
|
17
|
+
* gateway.ts inside a `robustApiCall(...)` wrapper. This keeps the raw
|
|
18
|
+
* `lockedBot.api.*` callsite in gateway.ts (already inside the retry policy)
|
|
19
|
+
* and leaves this module with no raw Bot API site to allowlist.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { Context, Filter } from 'grammy'
|
|
23
|
+
import { pinnedMessageIsOurs, type TrackedStatusPin } from './status-pin-store.js'
|
|
24
|
+
import type { PinState } from '../status-pin.js'
|
|
25
|
+
|
|
26
|
+
export interface PinnedMessageHandlerDeps {
|
|
27
|
+
/** Live pinKey→PinState registry of pins we own. */
|
|
28
|
+
statusPinState: ReadonlyMap<string, PinState>
|
|
29
|
+
/** Companion pinKey→chatId registry (chat-scoped ownership). */
|
|
30
|
+
statusPinChatIds: ReadonlyMap<string, string>
|
|
31
|
+
/**
|
|
32
|
+
* Delete the pin service message. Bound in gateway.ts through
|
|
33
|
+
* `robustApiCall(() => lockedBot.api.deleteMessage(...))` so the raw Bot API
|
|
34
|
+
* call stays inside the retry policy. Rethrows on failure (the caller logs).
|
|
35
|
+
*/
|
|
36
|
+
deleteServiceMessage: (chatId: string, messageId: number) => Promise<void>
|
|
37
|
+
/** stderr log sink. */
|
|
38
|
+
log: (line: string) => void
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function handlePinnedMessage(
|
|
42
|
+
ctx: Filter<Context, 'message:pinned_message'>,
|
|
43
|
+
deps: PinnedMessageHandlerDeps,
|
|
44
|
+
): Promise<void> {
|
|
45
|
+
const pinnedId = ctx.msg.pinned_message?.message_id
|
|
46
|
+
if (pinnedId == null) return
|
|
47
|
+
const chatId = String(ctx.chat.id)
|
|
48
|
+
const serviceMsgId = ctx.msg.message_id
|
|
49
|
+
|
|
50
|
+
// Chat-scoped ownership (see pinnedMessageIsOurs): the match requires BOTH
|
|
51
|
+
// the messageId AND that the tracked entry lives in THIS chat, so a pin id
|
|
52
|
+
// colliding across chats can't delete a foreign (e.g. operator-manual) pin
|
|
53
|
+
// notice. statusPinChatIds is the companion pinKey→chatId map written on
|
|
54
|
+
// every desired-pinned reconcile.
|
|
55
|
+
const trackedPins = (): TrackedStatusPin[] => {
|
|
56
|
+
const out: TrackedStatusPin[] = []
|
|
57
|
+
for (const [pinKey, state] of deps.statusPinState) {
|
|
58
|
+
const c = deps.statusPinChatIds.get(pinKey)
|
|
59
|
+
if (c != null) out.push({ chatId: c, messageId: state.messageId })
|
|
60
|
+
}
|
|
61
|
+
return out
|
|
62
|
+
}
|
|
63
|
+
const isOurs = () => pinnedMessageIsOurs(trackedPins(), chatId, pinnedId)
|
|
64
|
+
|
|
65
|
+
if (!isOurs()) {
|
|
66
|
+
// Tolerate the reconcile-store race: wait briefly, then re-check once.
|
|
67
|
+
await new Promise(resolve => setTimeout(resolve, 250))
|
|
68
|
+
if (!isOurs()) return
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
await deps.deleteServiceMessage(chatId, serviceMsgId)
|
|
73
|
+
} catch (err) {
|
|
74
|
+
// Best-effort: a failure to delete the service message is cosmetic only —
|
|
75
|
+
// the "pinned a message" line just stays. The most likely cause in a
|
|
76
|
+
// supergroup/forum is the bot lacking can_delete_messages admin right, so
|
|
77
|
+
// surface a concise one-liner (robustApiCall rethrows this case without
|
|
78
|
+
// logging a reason) rather than swallowing silently — an operator sees WHY.
|
|
79
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
80
|
+
deps.log(
|
|
81
|
+
`telegram gateway: status-pin: could not delete pin service message ` +
|
|
82
|
+
`(chat=${chatId} msg=${serviceMsgId}) — likely missing can_delete_messages ` +
|
|
83
|
+
`admin right in this chat: ${msg}\n`,
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
renderSecretRequestCard,
|
|
4
|
+
buildSecretRequestKeyboard,
|
|
5
|
+
} from './secret-request-card.js'
|
|
6
|
+
|
|
7
|
+
describe('secret-request-card (#2996 P5 extraction — verbatim from gateway.ts)', () => {
|
|
8
|
+
describe('renderSecretRequestCard', () => {
|
|
9
|
+
it('renders the title, key, reason, and the provide-securely instruction', () => {
|
|
10
|
+
const out = renderSecretRequestCard({
|
|
11
|
+
agent: 'worker',
|
|
12
|
+
key: 'openai/api_key',
|
|
13
|
+
reason: 'need it to call the API',
|
|
14
|
+
})
|
|
15
|
+
expect(out).toContain('🔒 **worker** needs a secret:')
|
|
16
|
+
expect(out).toContain('`openai/api_key`')
|
|
17
|
+
expect(out).toContain('_need it to call the API_')
|
|
18
|
+
expect(out).toContain('Tap **Provide securely**')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('omits the reason line when none is given', () => {
|
|
22
|
+
const out = renderSecretRequestCard({ agent: 'worker', key: 'openai/api_key' })
|
|
23
|
+
// The reason line is the only `_…_` emphasis block; absent when no reason.
|
|
24
|
+
expect(out).not.toMatch(/\n_.*_$/m)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('does NOT harden card breaks (prose body, preserved from the inline version)', () => {
|
|
28
|
+
// The inline renderer joined lines plainly (no hardenCardBreaks); this
|
|
29
|
+
// pins that difference from the vault-save card so the move stays verbatim.
|
|
30
|
+
const out = renderSecretRequestCard({ agent: 'worker', key: 'k/v' })
|
|
31
|
+
expect(out.split('\n')[0]).toBe('🔒 **worker** needs a secret:')
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe('buildSecretRequestKeyboard', () => {
|
|
36
|
+
it('carries the vsp:* callback_data contract', () => {
|
|
37
|
+
const kb = buildSecretRequestKeyboard('abcd1234')
|
|
38
|
+
const flat = kb.inline_keyboard.flat()
|
|
39
|
+
expect(flat.map((b) => b.callback_data)).toEqual([
|
|
40
|
+
'vsp:provide:abcd1234',
|
|
41
|
+
'vsp:decline:abcd1234',
|
|
42
|
+
])
|
|
43
|
+
expect(flat.map((b) => b.text)).toEqual(['🔐 Provide securely', '🚫 Decline'])
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure renderer + inline-keyboard builder for the agent-initiated
|
|
3
|
+
* `request_secret` card (#2045 — the agent does NOT have a value and asks the
|
|
4
|
+
* operator to PROVIDE one securely). Callback prefix `vsp:`
|
|
5
|
+
* (vault-secret-provide), handled by handleSecretRequestCallback in
|
|
6
|
+
* callback-query-handlers.ts.
|
|
7
|
+
*
|
|
8
|
+
* Extracted verbatim from gateway.ts (#2996 P5); the render body is unchanged.
|
|
9
|
+
* Unlike the vault-save card this renderer does NOT call hardenCardBreaks — the
|
|
10
|
+
* body is a short prose block, not labelled field lines — so the move preserves
|
|
11
|
+
* that difference exactly.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { escapeHtmlForTg } from '../shared/bot-runtime.js'
|
|
15
|
+
|
|
16
|
+
/** Minimal shape the card needs — a subset of PendingSecretRequest. */
|
|
17
|
+
export interface SecretRequestCardInput {
|
|
18
|
+
agent: string
|
|
19
|
+
key: string
|
|
20
|
+
reason?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function renderSecretRequestCard(req: SecretRequestCardInput): string {
|
|
24
|
+
const lines: string[] = [
|
|
25
|
+
`🔒 **${escapeHtmlForTg(req.agent)}** needs a secret:`,
|
|
26
|
+
`\`${req.key}\``,
|
|
27
|
+
]
|
|
28
|
+
if (req.reason) lines.push(`_${escapeHtmlForTg(req.reason)}_`)
|
|
29
|
+
lines.push(
|
|
30
|
+
'',
|
|
31
|
+
'Tap **Provide securely**, then send the value as your next message. I’ll delete it instantly and store it in the vault — it is never shown in chat or to the agent.',
|
|
32
|
+
)
|
|
33
|
+
return lines.join('\n')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function buildSecretRequestKeyboard(stageId: string): { inline_keyboard: Array<Array<{ text: string; callback_data: string }>> } {
|
|
37
|
+
return {
|
|
38
|
+
inline_keyboard: [
|
|
39
|
+
[
|
|
40
|
+
{ text: '🔐 Provide securely', callback_data: `vsp:provide:${stageId}` },
|
|
41
|
+
{ text: '🚫 Decline', callback_data: `vsp:decline:${stageId}` },
|
|
42
|
+
],
|
|
43
|
+
],
|
|
44
|
+
}
|
|
45
|
+
}
|