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,400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inbound routing seam (switchroom#2996 P7).
|
|
3
|
+
*
|
|
4
|
+
* `routeInbound` is the single entry point the gateway's coalesced inbound
|
|
5
|
+
* surfaces (`bot.on('message:text')` and the terminal
|
|
6
|
+
* `installUnhandledMessageCatchAll`) delegate through. TODAY it is a pure
|
|
7
|
+
* indirection: it forwards straight to the existing `handleInboundCoalesced`
|
|
8
|
+
* via an injected `InboundRouterDeps` — behaviour-identical, zero control-flow
|
|
9
|
+
* change. It exists so the later P7 stages can (a) move the intercept gauntlet
|
|
10
|
+
* into `inbound-interceptors.ts` behind this same Deps boundary and (b) put the
|
|
11
|
+
* eventual v2 consolidated control flow behind a deterministic kill switch at
|
|
12
|
+
* one chokepoint, mirroring the P6 `MediaEnvelopeDeps` injection idiom.
|
|
13
|
+
*
|
|
14
|
+
* The Deps object holds LIVE references to the gateway's collaborators (never
|
|
15
|
+
* value snapshots), bound once in gateway.ts alongside `mediaEnvelopeDeps`.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { Context } from 'grammy'
|
|
19
|
+
import type { AttachmentMeta } from './gateway.js'
|
|
20
|
+
import {
|
|
21
|
+
interceptStopKeyword,
|
|
22
|
+
interceptInterruptMarker,
|
|
23
|
+
interceptPermissionReply,
|
|
24
|
+
interceptAuthAdd,
|
|
25
|
+
interceptLoopbackRelay,
|
|
26
|
+
interceptReauth,
|
|
27
|
+
interceptVault,
|
|
28
|
+
interceptSecretStagingCommand,
|
|
29
|
+
type InboundInterceptorDeps,
|
|
30
|
+
type InterceptOutcome,
|
|
31
|
+
type StopKeywordParams,
|
|
32
|
+
type InterruptMarkerParams,
|
|
33
|
+
type InterruptMarkerOutcome,
|
|
34
|
+
type AuthAddParams,
|
|
35
|
+
} from './inbound-interceptors.js'
|
|
36
|
+
import type { InboundMessage } from './ipc-protocol.js'
|
|
37
|
+
import { deriveTurnId } from './derive-turn-id.js'
|
|
38
|
+
import { formatReplyToText } from '../steering.js'
|
|
39
|
+
import { fmtLocalStamp, resolveEnvTimezone } from '../shared/local-time.js'
|
|
40
|
+
import { safeResolvePersonName, type PersonDirectory } from './resolve-person.js'
|
|
41
|
+
import {
|
|
42
|
+
parseForwardOrigin,
|
|
43
|
+
dedupeForwardOrigins,
|
|
44
|
+
buildForwardOriginMeta,
|
|
45
|
+
forwardOriginDateIso,
|
|
46
|
+
type ForwardOriginInfo,
|
|
47
|
+
} from './forward-origin.js'
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Collaborators `routeInbound` needs, injected from gateway.ts module scope so
|
|
51
|
+
* this module stays importable + unit-testable without gateway's boot side
|
|
52
|
+
* effects. Extended by later P7 stages as intercepts move across the boundary;
|
|
53
|
+
* every field is a live reference (function / Map / object), not a snapshot.
|
|
54
|
+
*/
|
|
55
|
+
export interface InboundRouterDeps {
|
|
56
|
+
handleInboundCoalesced: (
|
|
57
|
+
ctx: Context,
|
|
58
|
+
text: string,
|
|
59
|
+
downloadImage: (() => Promise<string | undefined>) | undefined,
|
|
60
|
+
attachment?: AttachmentMeta,
|
|
61
|
+
) => Promise<void>
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Route a coalesced inbound message. Pure indirection in P7 PR-1 — forwards to
|
|
66
|
+
* `deps.handleInboundCoalesced` unchanged.
|
|
67
|
+
*/
|
|
68
|
+
export async function routeInbound(
|
|
69
|
+
ctx: Context,
|
|
70
|
+
text: string,
|
|
71
|
+
downloadImage: (() => Promise<string | undefined>) | undefined,
|
|
72
|
+
attachment: AttachmentMeta | undefined,
|
|
73
|
+
deps: InboundRouterDeps,
|
|
74
|
+
): Promise<void> {
|
|
75
|
+
return deps.handleInboundCoalesced(ctx, text, downloadImage, attachment)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
// ─── P7 PR-10: admission head + pure envelope builders ─────────────────────
|
|
80
|
+
|
|
81
|
+
/** Admission decision facts injected from gateway.ts (all live refs / fixed
|
|
82
|
+
* values). `topicId` is the boot-fixed TOPIC_ID scoping; `gate` is the
|
|
83
|
+
* allowFrom access gate; `logGateDeny` the rate-limited deny logger. */
|
|
84
|
+
export interface InboundAdmissionDeps {
|
|
85
|
+
topicId: number | null
|
|
86
|
+
gate: (ctx: Context) =>
|
|
87
|
+
| { action: 'deliver'; access: unknown }
|
|
88
|
+
| { action: 'drop'; reason: unknown }
|
|
89
|
+
| { action: 'pair'; code: string; isResend: boolean }
|
|
90
|
+
logGateDeny: (ctx: Context, reason: never) => void
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Outcome of admission: `admitted: false` — fully handled (topic-scoped out,
|
|
94
|
+
* gate-dropped, or pairing replied); `admitted: true` carries the gate's
|
|
95
|
+
* access payload for downstream person-resolution. */
|
|
96
|
+
export type InboundAdmissionResult =
|
|
97
|
+
| { admitted: false }
|
|
98
|
+
| { admitted: true; access: unknown }
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Topic-scope + access-gate admission head of handleInbound, moved verbatim
|
|
102
|
+
* (#2996 P7 PR-10). Drop/pair early-return semantics unchanged: TOPIC_ID
|
|
103
|
+
* scoping silently ignores out-of-topic messages; gate 'drop' logs the deny;
|
|
104
|
+
* gate 'pair' replies with the pairing instruction.
|
|
105
|
+
*/
|
|
106
|
+
export async function admitInbound(
|
|
107
|
+
ctx: Context,
|
|
108
|
+
deps: InboundAdmissionDeps,
|
|
109
|
+
): Promise<InboundAdmissionResult> {
|
|
110
|
+
const isTopicMessage = ctx.message?.is_topic_message ?? false
|
|
111
|
+
const messageThreadId = ctx.message?.message_thread_id
|
|
112
|
+
|
|
113
|
+
if (deps.topicId != null) {
|
|
114
|
+
if (!isTopicMessage || messageThreadId !== deps.topicId) return { admitted: false }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const result = deps.gate(ctx)
|
|
118
|
+
if (result.action === 'drop') {
|
|
119
|
+
deps.logGateDeny(ctx, result.reason as never)
|
|
120
|
+
return { admitted: false }
|
|
121
|
+
}
|
|
122
|
+
if (result.action === 'pair') {
|
|
123
|
+
const lead = result.isResend ? 'Still pending' : 'Pairing required'
|
|
124
|
+
await ctx.reply(`${lead} — run in Claude Code:\n\n/telegram:access pair ${result.code}`)
|
|
125
|
+
return { admitted: false }
|
|
126
|
+
}
|
|
127
|
+
return { admitted: true, access: result.access }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Inputs for {@link buildReplyForwardContext} (pure). */
|
|
131
|
+
export interface ReplyForwardContextParams {
|
|
132
|
+
ctx: Context
|
|
133
|
+
coalescedForwardOrigins: ForwardOriginInfo[] | undefined
|
|
134
|
+
/** REPLY_TO_TEXT_MAX — fixed constant. */
|
|
135
|
+
replyToTextMax: number
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Pure builder: Telegram-native reply context (#119) + server-stamped
|
|
139
|
+
* forward-origin context. Moved verbatim (#2996 P7 PR-10); raw-vs-escaped
|
|
140
|
+
* split preserved (raw for SQLite, escaped for channel meta). */
|
|
141
|
+
export function buildReplyForwardContext(p: ReplyForwardContextParams): {
|
|
142
|
+
replyToMessageId: number | undefined
|
|
143
|
+
replyToText: string | undefined
|
|
144
|
+
replyToTextEscaped: string | undefined
|
|
145
|
+
forwardOrigins: ForwardOriginInfo[]
|
|
146
|
+
forwardOriginMeta: Record<string, string>
|
|
147
|
+
primaryForwardOrigin: ForwardOriginInfo | undefined
|
|
148
|
+
} {
|
|
149
|
+
// Telegram-native reply context (issue #119). Same pattern as server.ts:
|
|
150
|
+
// `replyToText` is raw (for SQLite); `replyToTextEscaped` is XML-escaped
|
|
151
|
+
// (for channel meta).
|
|
152
|
+
const replyToMsg = p.ctx.message?.reply_to_message
|
|
153
|
+
const replyToMessageId = replyToMsg?.message_id
|
|
154
|
+
const replyToTextRaw = replyToMsg
|
|
155
|
+
? (replyToMsg.text ?? replyToMsg.caption ?? undefined)
|
|
156
|
+
: undefined
|
|
157
|
+
const replyToText = replyToTextRaw != null
|
|
158
|
+
? (replyToTextRaw.length > p.replyToTextMax
|
|
159
|
+
? replyToTextRaw.slice(0, p.replyToTextMax - 1) + '…'
|
|
160
|
+
: replyToTextRaw)
|
|
161
|
+
: undefined
|
|
162
|
+
const replyToTextEscaped = formatReplyToText(replyToTextRaw, p.replyToTextMax)
|
|
163
|
+
|
|
164
|
+
// Forwarded-message origin context. `forward_origin` is stamped by
|
|
165
|
+
// Telegram's servers (Bot API 7.0+) — the forwarding user cannot forge
|
|
166
|
+
// it via the message body, so it rides the trusted attrs lane and is
|
|
167
|
+
// NEVER folded into the body text. Coalesced bursts pass their deduped
|
|
168
|
+
// per-entry origins; direct/bypass paths parse this p.ctx's own origin.
|
|
169
|
+
// Same raw-vs-escaped split as reply_to_text: `forwardOrigins` carries
|
|
170
|
+
// raw (truncated) names for SQLite, `buildForwardOriginMeta` escapes at
|
|
171
|
+
// the channel-meta boundary.
|
|
172
|
+
const forwardOrigins = p.coalescedForwardOrigins
|
|
173
|
+
?? dedupeForwardOrigins([parseForwardOrigin(p.ctx.message?.forward_origin)])
|
|
174
|
+
const forwardOriginMeta = buildForwardOriginMeta(forwardOrigins)
|
|
175
|
+
const primaryForwardOrigin = forwardOrigins[0]
|
|
176
|
+
return {
|
|
177
|
+
replyToMessageId,
|
|
178
|
+
replyToText,
|
|
179
|
+
replyToTextEscaped,
|
|
180
|
+
forwardOrigins,
|
|
181
|
+
forwardOriginMeta,
|
|
182
|
+
primaryForwardOrigin,
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Inputs for {@link buildInboundEnvelope} — every field is an at-call
|
|
187
|
+
* captured value (steering meta, at-receipt snapshots, resolved attachments),
|
|
188
|
+
* never a live getter. */
|
|
189
|
+
export interface EnvelopeBuildParams {
|
|
190
|
+
ctx: Context
|
|
191
|
+
chat_id: string
|
|
192
|
+
messageThreadId: number | undefined
|
|
193
|
+
msgId: number | undefined
|
|
194
|
+
effectiveText: string
|
|
195
|
+
imagePath: string | undefined
|
|
196
|
+
attachment: AttachmentMeta | undefined
|
|
197
|
+
attachmentCount: number
|
|
198
|
+
extraMeta: Record<string, string>
|
|
199
|
+
from: { id: number; username?: string }
|
|
200
|
+
access: { groups: Record<string, { allowFrom?: string[] } | undefined> }
|
|
201
|
+
isSteering: boolean
|
|
202
|
+
isQueuedPrefix: boolean
|
|
203
|
+
isQueuedMidTurn: boolean
|
|
204
|
+
priorTurnInProgress: boolean
|
|
205
|
+
secondsSinceTurnStart: number | undefined
|
|
206
|
+
priorAssistantPreview: string | undefined
|
|
207
|
+
replyToMessageId: number | undefined
|
|
208
|
+
replyToTextEscaped: string | undefined
|
|
209
|
+
forwardOriginMeta: Record<string, string>
|
|
210
|
+
/** TOPIC_FRAMING_ENABLED — fixed constant. */
|
|
211
|
+
topicFramingEnabled: boolean
|
|
212
|
+
/** Captured at call time; the gateway's PERSON_DIRECTORY is a boot-assigned
|
|
213
|
+
* mutable let, so the value crosses per message. */
|
|
214
|
+
personDirectory: PersonDirectory
|
|
215
|
+
isDmChatId: (chatId: string | null | undefined) => boolean
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Pure builder for the ipc-protocol InboundMessage envelope (meta lane +
|
|
220
|
+
* attachment fields + steering/queued flags + origin-turn-id + topic-scope
|
|
221
|
+
* directive). Moved verbatim from handleInbound (#2996 P7 PR-10); the machine
|
|
222
|
+
* emit and deliver-or-buffer dispatch stay in gateway.ts, wired exactly as
|
|
223
|
+
* before.
|
|
224
|
+
*/
|
|
225
|
+
export function buildInboundEnvelope(p: EnvelopeBuildParams): InboundMessage {
|
|
226
|
+
// Dispatch to connected bridge via IPC
|
|
227
|
+
// Component 3 — stable origin turn id stamped into the meta the model
|
|
228
|
+
// reads, so a reply can echo it back (origin_turn_id) and the gateway
|
|
229
|
+
// can route the answer to the turn that owns it even after currentTurn
|
|
230
|
+
// flips. Derived from chat/thread/messageId, matching the turnId the
|
|
231
|
+
// enqueue handler computes for the turn this inbound starts.
|
|
232
|
+
const originTurnId = deriveTurnId(p.chat_id, p.messageThreadId ?? null, p.msgId)
|
|
233
|
+
// Component 4 — per-turn topic framing. In a forum supergroup a queued
|
|
234
|
+
// cross-topic message could tempt the model to also answer a pending
|
|
235
|
+
// question from another topic. A one-line directive (only for topic
|
|
236
|
+
// inbounds, only when framing is enabled) pins the model to THIS
|
|
237
|
+
// message's topic. DMs / non-topic chats get nothing.
|
|
238
|
+
const topicScope =
|
|
239
|
+
p.topicFramingEnabled && p.messageThreadId != null
|
|
240
|
+
? 'This message belongs to the current topic only — answer ONLY this question, in this topic. Do not also answer a pending message from another topic.'
|
|
241
|
+
: undefined
|
|
242
|
+
// person_id name resolution (docs/configuration.md, resolve-person.ts):
|
|
243
|
+
// chat-scoped, boot-time-static lookup — falls back to today's raw
|
|
244
|
+
// id/username behavior (fail-open) whenever unresolved or when group
|
|
245
|
+
// membership can't be positively confirmed from p.access.json's allowFrom.
|
|
246
|
+
// Never touches p.access.json itself and never blocks/denies anything.
|
|
247
|
+
const rawUser = p.from.username ?? String(p.from.id)
|
|
248
|
+
const displayUser = safeResolvePersonName(
|
|
249
|
+
p.personDirectory,
|
|
250
|
+
{
|
|
251
|
+
telegramId: String(p.from.id),
|
|
252
|
+
username: p.from.username,
|
|
253
|
+
isDm: p.isDmChatId(p.chat_id),
|
|
254
|
+
groupAllowFrom: p.access.groups[p.chat_id]?.allowFrom,
|
|
255
|
+
},
|
|
256
|
+
rawUser,
|
|
257
|
+
)
|
|
258
|
+
const inboundMsg: InboundMessage = {
|
|
259
|
+
type: 'inbound',
|
|
260
|
+
chatId: p.chat_id,
|
|
261
|
+
...(p.messageThreadId != null ? { threadId: p.messageThreadId } : {}),
|
|
262
|
+
messageId: p.msgId ?? 0,
|
|
263
|
+
user: displayUser,
|
|
264
|
+
userId: p.from.id,
|
|
265
|
+
ts: p.ctx.message?.date ?? Math.floor(Date.now() / 1000),
|
|
266
|
+
text: p.effectiveText,
|
|
267
|
+
...(p.imagePath ? { imagePath: p.imagePath } : {}),
|
|
268
|
+
...(p.attachment ? {
|
|
269
|
+
attachment: {
|
|
270
|
+
fileId: p.attachment.file_id,
|
|
271
|
+
mimeType: p.attachment.mime ?? 'application/octet-stream',
|
|
272
|
+
...(p.attachment.name ? { fileName: p.attachment.name } : {}),
|
|
273
|
+
},
|
|
274
|
+
} : {}),
|
|
275
|
+
meta: {
|
|
276
|
+
chat_id: p.chat_id,
|
|
277
|
+
...(p.msgId != null ? { message_id: String(p.msgId) } : {}),
|
|
278
|
+
user: displayUser,
|
|
279
|
+
user_id: String(p.from.id),
|
|
280
|
+
// Model-facing `ts="…"` on the inbound <channel> tag. Rendered as the
|
|
281
|
+
// agent's LOCAL am/pm wall-clock (NOT UTC ISO) so the model never reads
|
|
282
|
+
// a competing UTC "now" — the numeric epoch survives on InboundMessage.ts
|
|
283
|
+
// (above) and in the SQLite history for any machine consumer.
|
|
284
|
+
ts: fmtLocalStamp((p.ctx.message?.date ?? 0) * 1000, resolveEnvTimezone()),
|
|
285
|
+
...(p.messageThreadId != null ? { message_thread_id: String(p.messageThreadId) } : {}),
|
|
286
|
+
// Component 3 — origin turn id. The model is told to pass this back
|
|
287
|
+
// as origin_turn_id on the reply so the answer routes to the topic
|
|
288
|
+
// this message came from (turn-origin routing). The reply tool also
|
|
289
|
+
// resolves it from the live/recent turn registry, so a model that
|
|
290
|
+
// omits it still routes correctly via the live-turn fallback.
|
|
291
|
+
...(originTurnId != null ? { origin_turn_id: originTurnId } : {}),
|
|
292
|
+
// Component 4 — per-turn topic-scope directive (supergroup topics).
|
|
293
|
+
...(topicScope != null ? { topic_scope: topicScope } : {}),
|
|
294
|
+
...(p.imagePath ? { image_path: p.imagePath } : {}),
|
|
295
|
+
// Telegram-native reply context (issue #119). When set, the user
|
|
296
|
+
// long-pressed a prior message and chose "Reply" — the agent should
|
|
297
|
+
// treat this as the antecedent for "this" / "that" / pronoun
|
|
298
|
+
// references in the body, instead of asking the user what they meant.
|
|
299
|
+
...(p.replyToMessageId != null ? { reply_to_message_id: String(p.replyToMessageId) } : {}),
|
|
300
|
+
// Use the XML-escaped form for the meta — the raw form is in the
|
|
301
|
+
// SQLite buffer for verbatim retrieval via get_recent_messages.
|
|
302
|
+
...(p.replyToTextEscaped != null && p.replyToTextEscaped.length > 0 ? { reply_to_text: p.replyToTextEscaped } : {}),
|
|
303
|
+
// Forwarded-message origin (server-stamped, attrs-only — see above).
|
|
304
|
+
// forwarded_from / forwarded_from_type / forwarded_from_id /
|
|
305
|
+
// forwarded_date, plus numbered _2.. siblings for a multi-origin
|
|
306
|
+
// burst. Names are XML-escaped inside buildForwardOriginMeta.
|
|
307
|
+
...p.forwardOriginMeta,
|
|
308
|
+
// queued="true" when mid-turn with no steer prefix (new default), or
|
|
309
|
+
// with explicit /queue or /q prefix (legacy alias).
|
|
310
|
+
...((p.isQueuedMidTurn || p.isQueuedPrefix) ? { queued: 'true' } : {}),
|
|
311
|
+
// steering="true" only when explicit /steer or /s prefix used.
|
|
312
|
+
...(p.isSteering ? { steering: 'true' } : {}),
|
|
313
|
+
...(p.priorTurnInProgress ? { prior_turn_in_progress: 'true' } : {}),
|
|
314
|
+
...(p.priorTurnInProgress && p.secondsSinceTurnStart != null ? { seconds_since_turn_start: String(p.secondsSinceTurnStart) } : {}),
|
|
315
|
+
...(p.priorTurnInProgress && p.priorAssistantPreview != null && p.priorAssistantPreview.length > 0 ? { prior_assistant_preview: p.priorAssistantPreview } : {}),
|
|
316
|
+
...(p.attachment ? {
|
|
317
|
+
attachment_kind: p.attachment.kind,
|
|
318
|
+
attachment_file_id: p.attachment.file_id,
|
|
319
|
+
...(p.attachment.size != null ? { attachment_size: String(p.attachment.size) } : {}),
|
|
320
|
+
...(p.attachment.mime ? { attachment_mime: p.attachment.mime } : {}),
|
|
321
|
+
...(p.attachment.name ? { attachment_name: p.attachment.name } : {}),
|
|
322
|
+
} : {}),
|
|
323
|
+
// A2: numbered fields for the 2nd..Nth p.attachment + a total count so
|
|
324
|
+
// the agent reads every item in a coalesced multi-media burst.
|
|
325
|
+
...(p.attachmentCount > 1 ? { attachment_count: String(p.attachmentCount) } : {}),
|
|
326
|
+
...p.extraMeta,
|
|
327
|
+
},
|
|
328
|
+
}
|
|
329
|
+
return inboundMsg
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
// ─── P7 PR-11: kill-switched v2 intercept chain ────────────────────────────
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* THE deterministic cutover switch (#2996 P7 PR-11, design §2). Read ONCE at
|
|
337
|
+
* module load (F2: flags are const-captured — flipping requires an agent
|
|
338
|
+
* restart, same as every other `_ENABLED` flag). Default ON (canary-validated
|
|
339
|
+
* on v0.18.33 — 5/5 UAT, DM + supergroup): the v2 consolidated intercept chain
|
|
340
|
+
* is the production control flow. The v2 chain and the legacy sequence call the
|
|
341
|
+
* SAME extracted intercept functions in the SAME order — the characterization
|
|
342
|
+
* harness is the parity oracle (F6; the #3316-removed gate-parity probe is not
|
|
343
|
+
* relied on), and tests/inbound-router-v2-chain.test.ts pins the v2 path with
|
|
344
|
+
* the flag set env-before-import.
|
|
345
|
+
*
|
|
346
|
+
* Rollback (escape hatch): `SWITCHROOM_INBOUND_ROUTER_V2=0` + agent restart.
|
|
347
|
+
* No model involvement.
|
|
348
|
+
*/
|
|
349
|
+
export const INBOUND_ROUTER_V2 = process.env.SWITCHROOM_INBOUND_ROUTER_V2 !== '0'
|
|
350
|
+
|
|
351
|
+
/** Outcome of the pre-turn chain: stop-keyword then interrupt-marker. */
|
|
352
|
+
export type PreTurnChainOutcome = InterruptMarkerOutcome
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* v2 chain, segment 1 — the pre-turn kill switches, in load-bearing order:
|
|
356
|
+
* stop-keyword (#3020) THEN interrupt-marker (#575). Between this chain and
|
|
357
|
+
* the paste-back chain the caller runs the stay-behind status-KPI telemetry
|
|
358
|
+
* block (F3 — non-intercepting, position preserved).
|
|
359
|
+
*/
|
|
360
|
+
export async function runPreTurnIntercepts(
|
|
361
|
+
p: { stop: StopKeywordParams; interrupt: InterruptMarkerParams },
|
|
362
|
+
deps: InboundInterceptorDeps,
|
|
363
|
+
): Promise<PreTurnChainOutcome> {
|
|
364
|
+
const stopOutcome = await interceptStopKeyword(p.stop, deps)
|
|
365
|
+
if (stopOutcome.handled) return { handled: true }
|
|
366
|
+
return interceptInterruptMarker(p.interrupt, deps)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* v2 chain, segment 2 — the reply/paste-back gauntlet, in load-bearing order:
|
|
371
|
+
* permission-reply → auth-add → loopback-relay (incl. #3084 fail-safe) →
|
|
372
|
+
* reauth → vault → secret-staging command. Auth-add-before-reauth and
|
|
373
|
+
* fail-safe-before-reauth orderings are enforced structurally here (the
|
|
374
|
+
* design's PR-11 goal: ordering by code, not by comment).
|
|
375
|
+
*/
|
|
376
|
+
export async function runPasteBackIntercepts(
|
|
377
|
+
p: AuthAddParams,
|
|
378
|
+
deps: InboundInterceptorDeps,
|
|
379
|
+
): Promise<InterceptOutcome> {
|
|
380
|
+
const perm = interceptPermissionReply(
|
|
381
|
+
{ text: p.text, chat_id: p.chat_id, msgId: p.msgId },
|
|
382
|
+
deps,
|
|
383
|
+
)
|
|
384
|
+
if (perm.handled) return perm
|
|
385
|
+
const authAdd = await interceptAuthAdd(p, deps)
|
|
386
|
+
if (authAdd.handled) return authAdd
|
|
387
|
+
const loopback = await interceptLoopbackRelay(p, deps)
|
|
388
|
+
if (loopback.handled) return loopback
|
|
389
|
+
const reauth = await interceptReauth(p, deps)
|
|
390
|
+
if (reauth.handled) return reauth
|
|
391
|
+
const vault = await interceptVault(
|
|
392
|
+
{ ctx: p.ctx, text: p.text, chat_id: p.chat_id, msgId: p.msgId },
|
|
393
|
+
deps,
|
|
394
|
+
)
|
|
395
|
+
if (vault.handled) return vault
|
|
396
|
+
return interceptSecretStagingCommand(
|
|
397
|
+
{ ctx: p.ctx, text: p.text, chat_id: p.chat_id, msgId: p.msgId },
|
|
398
|
+
deps,
|
|
399
|
+
)
|
|
400
|
+
}
|