switchroom 0.20.0 → 0.20.2
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/bin/handoff-briefing.sh +213 -74
- package/dist/agent-scheduler/index.js +2 -2
- package/dist/auth-broker/index.js +4 -3
- package/dist/buzz-gateway/index.js +166 -6
- package/dist/cli/notion-write-pretool.mjs +2 -2
- package/dist/cli/switchroom.js +24704 -16399
- package/dist/host-control/main.js +44 -10
- package/dist/vault/approvals/kernel-server.js +4 -3
- package/dist/vault/broker/server.js +4 -3
- package/package.json +3 -2
- package/profiles/_base/start.sh.hbs +79 -10
- package/telegram-plugin/dist/gateway/gateway.js +1400 -964
- package/telegram-plugin/gateway/access-store.test.ts +234 -0
- package/telegram-plugin/gateway/access-store.ts +194 -0
- package/telegram-plugin/gateway/boot-briefing-builder.ts +135 -7
- package/telegram-plugin/gateway/boot-briefing-capability.ts +31 -0
- package/telegram-plugin/gateway/boot-briefing-wiring.ts +166 -4
- package/telegram-plugin/gateway/buzz-mirror-correlation-store.ts +285 -0
- package/telegram-plugin/gateway/buzz-mirror.ts +177 -12
- package/telegram-plugin/gateway/gateway.ts +43 -123
- package/telegram-plugin/gateway/inbound-router.ts +93 -3
- package/telegram-plugin/gateway/outbound-send-path.ts +48 -1
- package/telegram-plugin/gateway/pending-turn-env.ts +10 -1
- package/telegram-plugin/tests/boot-briefing-builder.test.ts +422 -31
- package/telegram-plugin/tests/buzz-mirror-correlation-store.test.ts +173 -0
- package/telegram-plugin/tests/buzz-mirror.test.ts +297 -1
- package/telegram-plugin/tests/outbound-send-path.test.ts +24 -0
- package/telegram-plugin/tests/reply-to-buffer-fallback.test.ts +273 -0
- package/telegram-plugin/tests/reply-to-buffer-history.test.ts +134 -0
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
import { randomUUID } from "crypto";
|
|
27
|
+
import { join } from "path";
|
|
27
28
|
import type { OutboundToBuzzMessage } from "./ipc-protocol.js";
|
|
28
29
|
import {
|
|
29
30
|
resolveRoute,
|
|
@@ -32,6 +33,10 @@ import {
|
|
|
32
33
|
type BuzzCoords,
|
|
33
34
|
type Channel,
|
|
34
35
|
} from "./channel-route.js";
|
|
36
|
+
import {
|
|
37
|
+
createCorrelationStore,
|
|
38
|
+
type CorrelationStore,
|
|
39
|
+
} from "./buzz-mirror-correlation-store.js";
|
|
35
40
|
|
|
36
41
|
export type BuzzPeerSender = (msg: OutboundToBuzzMessage) => boolean;
|
|
37
42
|
|
|
@@ -47,6 +52,14 @@ export interface BuzzMirrorConfig {
|
|
|
47
52
|
* threaded replies still work (they carry their own channelId).
|
|
48
53
|
*/
|
|
49
54
|
defaultChannelId: string;
|
|
55
|
+
/**
|
|
56
|
+
* Absolute path to the durable msg→Buzz correlation journal (#4222). When
|
|
57
|
+
* set, the `${chatId}:${messageId}` → published-event map is persisted and
|
|
58
|
+
* reloaded on construction, so an `edit_message` correction survives a gateway
|
|
59
|
+
* restart. Omit (dev/one-shot/tests) to keep the map in-memory only — same
|
|
60
|
+
* bound, no durability. See `buzz-mirror-correlation-store.ts`.
|
|
61
|
+
*/
|
|
62
|
+
correlationJournalPath?: string;
|
|
50
63
|
/** Optional log sink (defaults to a no-op). */
|
|
51
64
|
log?: (msg: string) => void;
|
|
52
65
|
}
|
|
@@ -68,6 +81,27 @@ export interface MirrorReplyInput {
|
|
|
68
81
|
* the published Buzz event to correct.
|
|
69
82
|
*/
|
|
70
83
|
telegramMessageKeys: string[];
|
|
84
|
+
/**
|
|
85
|
+
* `${chatId}:${messageId}` of the Telegram message this outbound answer is
|
|
86
|
+
* itself a reply to (its Telegram `reply_to` antecedent), when it has one.
|
|
87
|
+
* Used ONLY on the telegram-origin path to thread the mirrored Buzz event
|
|
88
|
+
* under the antecedent's previously-published Buzz event (NIP-10 outbound
|
|
89
|
+
* continuity). When the antecedent was never mirrored (e.g. it is a user's
|
|
90
|
+
* inbound message, or it aged past the correlation bound) the lookup misses
|
|
91
|
+
* and the mirror stays a flat top-level post — no wrong/guessed tag. Absent
|
|
92
|
+
* for a non-reply answer. Ignored on the buzz-origin path (that thread is
|
|
93
|
+
* bound by `ownerBuzzCoords`, not a Telegram antecedent).
|
|
94
|
+
*/
|
|
95
|
+
antecedentTelegramMessageKey?: string;
|
|
96
|
+
/**
|
|
97
|
+
* True IFF `antecedentTelegramMessageKey` is the quote-opt-in DEFAULT — i.e.
|
|
98
|
+
* the caller had no explicit/model-supplied `reply_to` and defaulted it to the
|
|
99
|
+
* latest INBOUND user message (#4301). That message is never in the
|
|
100
|
+
* correlation store, so its lookup ALWAYS misses; distinguishing it lets the
|
|
101
|
+
* mirror log the expected flat fallback quietly with a distinct reason instead
|
|
102
|
+
* of as an "outbound thread MISS", so a genuine eviction miss stays visible.
|
|
103
|
+
*/
|
|
104
|
+
antecedentIsQuoteOptInDefault?: boolean;
|
|
71
105
|
}
|
|
72
106
|
|
|
73
107
|
export interface MirrorCorrectionInput {
|
|
@@ -86,6 +120,14 @@ const MAX_TRACKED = 4096;
|
|
|
86
120
|
interface PendingPublish {
|
|
87
121
|
channelId: string;
|
|
88
122
|
telegramMessageKeys: string[];
|
|
123
|
+
/**
|
|
124
|
+
* The NIP-10 thread root this in-flight event belongs to, when it threaded
|
|
125
|
+
* under a parent. Undefined for a fresh top-level post — in which case the
|
|
126
|
+
* event's OWN id (learned in `onPublishResult`) becomes the thread root. Stored
|
|
127
|
+
* so the correlation record carries the root a LATER reply's `root` marker
|
|
128
|
+
* needs.
|
|
129
|
+
*/
|
|
130
|
+
threadRootId?: string;
|
|
89
131
|
}
|
|
90
132
|
|
|
91
133
|
class BuzzMirror {
|
|
@@ -97,9 +139,20 @@ class BuzzMirror {
|
|
|
97
139
|
private readonly pending = new Map<string, PendingPublish>();
|
|
98
140
|
private readonly pendingOrder: string[] = [];
|
|
99
141
|
|
|
100
|
-
/**
|
|
101
|
-
|
|
102
|
-
|
|
142
|
+
/**
|
|
143
|
+
* `${chatId}:${messageId}` → the published Buzz event it maps to. Durable
|
|
144
|
+
* (JSONL journal) when `correlationJournalPath` is configured, so a correction
|
|
145
|
+
* survives a gateway restart (#4222); the store enforces the same MAX_TRACKED
|
|
146
|
+
* FIFO bound in memory and on disk.
|
|
147
|
+
*/
|
|
148
|
+
private readonly msgToBuzz: CorrelationStore;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Count of `mirrorCorrection` calls whose key was genuinely absent from the
|
|
152
|
+
* correlation store (never mirrored, or evicted past the bound) — surfaced for
|
|
153
|
+
* the loud-miss regression assertion, not just the log line.
|
|
154
|
+
*/
|
|
155
|
+
private correctionMisses = 0;
|
|
103
156
|
|
|
104
157
|
/** `${chatId}:${messageId}` → live correction debounce timer. */
|
|
105
158
|
private readonly correctionTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
@@ -107,6 +160,16 @@ class BuzzMirror {
|
|
|
107
160
|
constructor(cfg: BuzzMirrorConfig) {
|
|
108
161
|
this.cfg = cfg;
|
|
109
162
|
this.log = cfg.log ?? (() => {});
|
|
163
|
+
this.msgToBuzz = createCorrelationStore({
|
|
164
|
+
journalPath: cfg.correlationJournalPath,
|
|
165
|
+
capacity: MAX_TRACKED,
|
|
166
|
+
log: this.log,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Test/introspection: number of corrections that missed the correlation store. */
|
|
171
|
+
getCorrectionMisses(): number {
|
|
172
|
+
return this.correctionMisses;
|
|
110
173
|
}
|
|
111
174
|
|
|
112
175
|
/** Register the transport to the duplex Buzz peer (ipcServer.sendToBuzzPeer). */
|
|
@@ -114,6 +177,13 @@ class BuzzMirror {
|
|
|
114
177
|
this.sender = sender;
|
|
115
178
|
}
|
|
116
179
|
|
|
180
|
+
/** Release the correlation journal fd and cancel any pending correction timers. */
|
|
181
|
+
close(): void {
|
|
182
|
+
for (const timer of this.correctionTimers.values()) clearTimeout(timer);
|
|
183
|
+
this.correctionTimers.clear();
|
|
184
|
+
this.msgToBuzz.close();
|
|
185
|
+
}
|
|
186
|
+
|
|
117
187
|
private evict<T>(map: Map<string, T>, order: string[]): void {
|
|
118
188
|
while (order.length > MAX_TRACKED) {
|
|
119
189
|
const k = order.shift();
|
|
@@ -158,10 +228,61 @@ class BuzzMirror {
|
|
|
158
228
|
replyToEventId = input.ownerBuzzCoords.eventId;
|
|
159
229
|
threadRootId = input.ownerBuzzCoords.threadRoot;
|
|
160
230
|
} else {
|
|
161
|
-
// TELEGRAM-origin →
|
|
162
|
-
//
|
|
231
|
+
// TELEGRAM-origin → post to the configured channel. Not an owner-bound
|
|
232
|
+
// thread, so the S1 guard does not apply (design §3.3).
|
|
163
233
|
if (!this.cfg.defaultChannelId) return; // no channel to post into
|
|
164
234
|
channelId = this.cfg.defaultChannelId;
|
|
235
|
+
|
|
236
|
+
// NIP-10 OUTBOUND thread continuity: if this answer is itself a reply to
|
|
237
|
+
// a Telegram message that was ALREADY mirrored to Buzz, thread the new
|
|
238
|
+
// event under that antecedent's published event — a `reply` marker for
|
|
239
|
+
// the immediate parent and a `root` marker for the thread root — so a
|
|
240
|
+
// Telegram-origin reply chain renders threaded on the Buzz desktop
|
|
241
|
+
// instead of flat. The antecedent→event resolution reuses the SAME
|
|
242
|
+
// durable msg→event correlation store (#4280) the correction path uses;
|
|
243
|
+
// no new lookup surface. A MISS (antecedent never mirrored — e.g. it is
|
|
244
|
+
// the user's own inbound message — or evicted past MAX_TRACKED) leaves
|
|
245
|
+
// the post flat rather than emitting a wrong/guessed tag.
|
|
246
|
+
if (input.antecedentTelegramMessageKey) {
|
|
247
|
+
const parent = this.msgToBuzz.get(input.antecedentTelegramMessageKey);
|
|
248
|
+
if (parent && parent.channelId === channelId) {
|
|
249
|
+
replyToEventId = parent.eventId; // immediate parent → NIP-10 `reply`
|
|
250
|
+
// Thread root → NIP-10 `root`. Fall back to the parent's own id when
|
|
251
|
+
// the parent has no recorded root (it was itself top-level, or was
|
|
252
|
+
// journaled before threadRoot was tracked): then parent IS the root.
|
|
253
|
+
threadRootId = parent.threadRoot ?? parent.eventId;
|
|
254
|
+
} else if (parent) {
|
|
255
|
+
// #4299 CROSS-CHANNEL GUARD: the antecedent WAS mirrored, but into a
|
|
256
|
+
// DIFFERENT Buzz channel than this event's target (e.g. it was
|
|
257
|
+
// recorded via a buzz-origin threaded reply whose channelId came from
|
|
258
|
+
// the inbound event's own `h`-tag). This event publishes into
|
|
259
|
+
// `defaultChannelId`; threading it under a foreign-channel parent
|
|
260
|
+
// would carry e-tags that point into another group. Mirror FLAT
|
|
261
|
+
// instead — same fallback as a MISS, no cross-group e-tag.
|
|
262
|
+
this.log(
|
|
263
|
+
`buzz-mirror: outbound thread CROSS-CHANNEL — antecedent ` +
|
|
264
|
+
`${input.antecedentTelegramMessageKey} was mirrored into channel ` +
|
|
265
|
+
`${parent.channelId} != target ${channelId}; mirroring flat ` +
|
|
266
|
+
`(no cross-group e-tag)`,
|
|
267
|
+
);
|
|
268
|
+
} else if (input.antecedentIsQuoteOptInDefault) {
|
|
269
|
+
// #4301: quote-opt-in defaulted `reply_to` to the latest INBOUND user
|
|
270
|
+
// message, which is never in the correlation store — so this "miss"
|
|
271
|
+
// is EXPECTED, not an eviction. Log it quietly with a distinct reason
|
|
272
|
+
// (no "MISS") so genuine eviction misses stay visible in the logs.
|
|
273
|
+
this.log(
|
|
274
|
+
`buzz-mirror: outbound thread default-quote — antecedent ` +
|
|
275
|
+
`${input.antecedentTelegramMessageKey} is the latest inbound user ` +
|
|
276
|
+
`message (never mirrored); mirroring flat (expected, not an eviction)`,
|
|
277
|
+
);
|
|
278
|
+
} else {
|
|
279
|
+
this.log(
|
|
280
|
+
`buzz-mirror: outbound thread MISS — no Buzz correlation for ` +
|
|
281
|
+
`Telegram antecedent ${input.antecedentTelegramMessageKey}; ` +
|
|
282
|
+
`mirroring flat (never mirrored, or evicted past the bound)`,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
165
286
|
}
|
|
166
287
|
|
|
167
288
|
this.publish(
|
|
@@ -187,7 +308,22 @@ class BuzzMirror {
|
|
|
187
308
|
mirrorCorrection(input: MirrorCorrectionInput): void {
|
|
188
309
|
try {
|
|
189
310
|
const target = this.msgToBuzz.get(input.telegramMessageKey);
|
|
190
|
-
if (!target)
|
|
311
|
+
if (!target) {
|
|
312
|
+
// No mapping for this key — either it was genuinely never mirrored, or
|
|
313
|
+
// it aged out past MAX_TRACKED (memory AND journal). Either way the
|
|
314
|
+
// correction cannot land, so DON'T fail silently: emit a loud log and
|
|
315
|
+
// bump the miss counter so the gap is observable (#4222, audit "at
|
|
316
|
+
// minimum" ask). Pre-#4222 a restart also landed here (empty map) — the
|
|
317
|
+
// durable journal is what keeps that from being the common case.
|
|
318
|
+
this.correctionMisses++;
|
|
319
|
+
this.log(
|
|
320
|
+
`buzz-mirror: CORRECTION MISS — no Buzz correlation for Telegram ` +
|
|
321
|
+
`message ${input.telegramMessageKey}; the edit could NOT be mirrored ` +
|
|
322
|
+
`(never mirrored, or evicted past MAX_TRACKED=${MAX_TRACKED}). ` +
|
|
323
|
+
`Buzz copy may be stale. total_misses=${this.correctionMisses}`,
|
|
324
|
+
);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
191
327
|
|
|
192
328
|
const existing = this.correctionTimers.get(input.telegramMessageKey);
|
|
193
329
|
if (existing) clearTimeout(existing);
|
|
@@ -239,12 +375,17 @@ class BuzzMirror {
|
|
|
239
375
|
return;
|
|
240
376
|
}
|
|
241
377
|
// Record the published event against each Telegram message it mirrored, so
|
|
242
|
-
// a later edit_message on any of them can target it for a correction
|
|
378
|
+
// a later edit_message on any of them can target it for a correction AND a
|
|
379
|
+
// later reply whose antecedent is one of these messages can thread under it.
|
|
380
|
+
// The store persists (durable journal) + enforces the MAX_TRACKED FIFO bound.
|
|
381
|
+
//
|
|
382
|
+
// threadRoot: the root this event belongs to. If it threaded under a parent
|
|
383
|
+
// (`p.threadRootId` set) that parent's root IS this event's root; otherwise
|
|
384
|
+
// this is a fresh top-level post and its OWN id is the thread root.
|
|
385
|
+
const threadRoot = p.threadRootId ?? msg.eventId;
|
|
243
386
|
for (const key of p.telegramMessageKeys) {
|
|
244
|
-
this.msgToBuzz.set(key, { eventId: msg.eventId, channelId: p.channelId });
|
|
245
|
-
this.msgOrder.push(key);
|
|
387
|
+
this.msgToBuzz.set(key, { eventId: msg.eventId, channelId: p.channelId, threadRoot });
|
|
246
388
|
}
|
|
247
|
-
this.evict(this.msgToBuzz, this.msgOrder);
|
|
248
389
|
}
|
|
249
390
|
|
|
250
391
|
private publish(
|
|
@@ -270,6 +411,7 @@ class BuzzMirror {
|
|
|
270
411
|
this.pending.set(correlationId, {
|
|
271
412
|
channelId: fields.channelId,
|
|
272
413
|
telegramMessageKeys,
|
|
414
|
+
threadRootId: fields.threadRootId,
|
|
273
415
|
});
|
|
274
416
|
this.pendingOrder.push(correlationId);
|
|
275
417
|
this.evict(this.pending, this.pendingOrder);
|
|
@@ -285,6 +427,7 @@ let singleton: BuzzMirror | null = null;
|
|
|
285
427
|
* every hook site is a no-op.
|
|
286
428
|
*/
|
|
287
429
|
export function initBuzzMirror(cfg: BuzzMirrorConfig): BuzzMirror {
|
|
430
|
+
singleton?.close(); // release any prior instance's journal fd + timers
|
|
288
431
|
singleton = new BuzzMirror(cfg);
|
|
289
432
|
return singleton;
|
|
290
433
|
}
|
|
@@ -293,6 +436,24 @@ export function getBuzzMirror(): BuzzMirror | null {
|
|
|
293
436
|
return singleton;
|
|
294
437
|
}
|
|
295
438
|
|
|
439
|
+
/**
|
|
440
|
+
* Resolve the durable msg→Buzz correlation journal path from env (#4222). Lives
|
|
441
|
+
* under the same `$TELEGRAM_STATE_DIR/buzz/` dir as the sidecar's dedup journal
|
|
442
|
+
* but at a DISTINCT filename — a collision on `journal.jsonl` would corrupt
|
|
443
|
+
* both. `BUZZ_MIRROR_CORRELATION_PATH` overrides. When `TELEGRAM_STATE_DIR` is
|
|
444
|
+
* unset (dev/one-shot), returns undefined so the store degrades to in-memory
|
|
445
|
+
* only rather than scattering a journal under the home dir.
|
|
446
|
+
*/
|
|
447
|
+
export function resolveCorrelationJournalPath(
|
|
448
|
+
env: Record<string, string | undefined> = process.env,
|
|
449
|
+
): string | undefined {
|
|
450
|
+
const override = env.BUZZ_MIRROR_CORRELATION_PATH?.trim();
|
|
451
|
+
if (override) return override;
|
|
452
|
+
const stateDir = env.TELEGRAM_STATE_DIR?.trim();
|
|
453
|
+
if (!stateDir) return undefined;
|
|
454
|
+
return join(stateDir, "buzz", "mirror-correlation.jsonl");
|
|
455
|
+
}
|
|
456
|
+
|
|
296
457
|
/**
|
|
297
458
|
* Boot the hub mirror from env at gateway startup — the single wiring seam the
|
|
298
459
|
* gateway calls. DARK BY DEFAULT and by construction: returns null (leaving
|
|
@@ -300,8 +461,10 @@ export function getBuzzMirror(): BuzzMirror | null {
|
|
|
300
461
|
* (1) `BUZZ_ENABLED` is truthy, AND
|
|
301
462
|
* (2) the S2-narrowed mode (`parseConfiguredMirrorMode`) is `both`;
|
|
302
463
|
* a configured `origin`/`off` degrades to dark, never a half-live mirror.
|
|
303
|
-
* The Buzz env vars are
|
|
304
|
-
*
|
|
464
|
+
* The Buzz env vars are projected at compose time from `channels.buzz`
|
|
465
|
+
* (src/agents/compose.ts), with BUZZ_ENABLED=1 gated on `enabled === true`;
|
|
466
|
+
* an enabled:false/absent block leaves them unset, so for those agents this
|
|
467
|
+
* is inert by construction. `sender` is the transport to the duplex peer
|
|
305
468
|
* (`ipcServer.sendToBuzzPeer`). Returns the booted instance for tests.
|
|
306
469
|
*/
|
|
307
470
|
export function maybeBootBuzzMirror(
|
|
@@ -315,6 +478,7 @@ export function maybeBootBuzzMirror(
|
|
|
315
478
|
mode,
|
|
316
479
|
agentName: env.SWITCHROOM_AGENT_NAME?.trim() ?? "",
|
|
317
480
|
defaultChannelId: env.BUZZ_CHANNEL_IDS?.trim() ?? "",
|
|
481
|
+
correlationJournalPath: resolveCorrelationJournalPath(env),
|
|
318
482
|
log: (m) => process.stderr.write(`telegram gateway: buzz-mirror — ${m}\n`),
|
|
319
483
|
});
|
|
320
484
|
bm.attachSender(sender);
|
|
@@ -323,6 +487,7 @@ export function maybeBootBuzzMirror(
|
|
|
323
487
|
|
|
324
488
|
/** Test-only: tear down the singleton so cases don't leak state into each other. */
|
|
325
489
|
export function __resetBuzzMirrorForTests(): void {
|
|
490
|
+
singleton?.close();
|
|
326
491
|
singleton = null;
|
|
327
492
|
}
|
|
328
493
|
|
|
@@ -127,6 +127,7 @@ import {
|
|
|
127
127
|
routeInbound,
|
|
128
128
|
admitInbound,
|
|
129
129
|
buildReplyForwardContext,
|
|
130
|
+
resolveReplyToFromBuffer,
|
|
130
131
|
buildInboundEnvelope,
|
|
131
132
|
INBOUND_ROUTER_V2,
|
|
132
133
|
runPreTurnIntercepts,
|
|
@@ -433,7 +434,7 @@ import { parseLitellmNoticeWindowMs } from '../litellm-local-notice.js'
|
|
|
433
434
|
import { createLitellmLocalNoticeRunner, decideRateLimitedSurface } from './litellm-local-notice-wiring.js'
|
|
434
435
|
import { runFleetAutoFallback, renderFallbackFailureNotice, evaluateFallbackFailureNotice, evaluateAllBlockedNotice, type FallbackFailureNoticeState, type FallbackAllBlockedNoticeState } from '../auto-fallback-fleet.js'
|
|
435
436
|
import { startRestartWatchdog } from './restart-watchdog.js'
|
|
436
|
-
import {
|
|
437
|
+
import { createAccessStore } from './access-store.js'
|
|
437
438
|
|
|
438
439
|
/**
|
|
439
440
|
* Truncation cap for the `reply_to_text` channel-meta attribute (issue #119).
|
|
@@ -1536,10 +1537,6 @@ export type Access = {
|
|
|
1536
1537
|
}
|
|
1537
1538
|
}
|
|
1538
1539
|
|
|
1539
|
-
function defaultAccess(): Access {
|
|
1540
|
-
return { dmPolicy: 'pairing', allowFrom: [], groups: {}, pending: {} }
|
|
1541
|
-
}
|
|
1542
|
-
|
|
1543
1540
|
// Rich-message wire cap (#2669): the rich path allows 32768 UTF-8 chars.
|
|
1544
1541
|
const MAX_CHUNK_LIMIT = RICH_MESSAGE_MAX_CHARS
|
|
1545
1542
|
const MAX_ATTACHMENT_BYTES = 50 * 1024 * 1024
|
|
@@ -1580,90 +1577,18 @@ function assertSendable(f: string): void {
|
|
|
1580
1577
|
}
|
|
1581
1578
|
}
|
|
1582
1579
|
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
return {
|
|
1596
|
-
dmPolicy: parsed.dmPolicy ?? 'pairing',
|
|
1597
|
-
allowFrom,
|
|
1598
|
-
groups,
|
|
1599
|
-
pending: parsed.pending ?? {},
|
|
1600
|
-
mentionPatterns: parsed.mentionPatterns,
|
|
1601
|
-
ackReaction: parsed.ackReaction,
|
|
1602
|
-
replyToMode: parsed.replyToMode,
|
|
1603
|
-
textChunkLimit: parsed.textChunkLimit,
|
|
1604
|
-
chunkMode: parsed.chunkMode,
|
|
1605
|
-
parseMode: parsed.parseMode,
|
|
1606
|
-
disableLinkPreview: parsed.disableLinkPreview,
|
|
1607
|
-
coalescingGapMs: parsed.coalescingGapMs,
|
|
1608
|
-
litellmNoticeWindowMs: parsed.litellmNoticeWindowMs,
|
|
1609
|
-
coalesceMaxAttachments: parsed.coalesceMaxAttachments,
|
|
1610
|
-
interruptSafeBoundary: parsed.interruptSafeBoundary,
|
|
1611
|
-
interruptMaxWaitMs: parsed.interruptMaxWaitMs,
|
|
1612
|
-
statusReactions: parsed.statusReactions,
|
|
1613
|
-
historyEnabled: parsed.historyEnabled,
|
|
1614
|
-
historyRetentionDays: parsed.historyRetentionDays,
|
|
1615
|
-
// #596: telegram features projected into access.json by scaffold.
|
|
1616
|
-
// Without these passthroughs, gateway readers (`access.voice_in`,
|
|
1617
|
-
// `access.telegraph`, `access.stickers`) silently see undefined.
|
|
1618
|
-
stickers: parsed.stickers,
|
|
1619
|
-
voice_in: parsed.voice_in,
|
|
1620
|
-
voice_out: parsed.voice_out,
|
|
1621
|
-
telegraph: parsed.telegraph,
|
|
1622
|
-
// #789: button-choice-confirmation config projected by scaffold.
|
|
1623
|
-
button_choice_confirmation: parsed.button_choice_confirmation,
|
|
1624
|
-
}
|
|
1625
|
-
} catch (err) {
|
|
1626
|
-
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return defaultAccess()
|
|
1627
|
-
try { renameSync(ACCESS_FILE, `${ACCESS_FILE}.corrupt-${Date.now()}`) } catch {}
|
|
1628
|
-
process.stderr.write(`telegram gateway: access.json is corrupt, moved aside. Starting fresh.\n`)
|
|
1629
|
-
return defaultAccess()
|
|
1630
|
-
}
|
|
1631
|
-
}
|
|
1632
|
-
|
|
1633
|
-
const BOOT_ACCESS: Access | null = STATIC
|
|
1634
|
-
? (() => {
|
|
1635
|
-
const a = readAccessFile()
|
|
1636
|
-
if (a.dmPolicy === 'pairing') {
|
|
1637
|
-
process.stderr.write('telegram gateway: static mode — dmPolicy "pairing" downgraded to "allowlist"\n')
|
|
1638
|
-
a.dmPolicy = 'allowlist'
|
|
1639
|
-
}
|
|
1640
|
-
a.pending = {}
|
|
1641
|
-
return a
|
|
1642
|
-
})()
|
|
1643
|
-
: null
|
|
1644
|
-
|
|
1645
|
-
function loadAccess(): Access {
|
|
1646
|
-
return BOOT_ACCESS ?? readAccessFile()
|
|
1647
|
-
}
|
|
1648
|
-
|
|
1649
|
-
/**
|
|
1650
|
-
* Read `people.json` (the scaffold's plain projection of `users:` entries
|
|
1651
|
-
* that carry a `person_id`). Fail-open: ENOENT or corrupt/malformed JSON
|
|
1652
|
-
* returns an empty array rather than throwing — this feature must never
|
|
1653
|
-
* block startup. Unlike `access.json` this file is never gateway-mutated,
|
|
1654
|
-
* so there's no "move corrupt file aside" concern; the scaffold owns and
|
|
1655
|
-
* regenerates it on every reconcile.
|
|
1656
|
-
*/
|
|
1657
|
-
function readPeopleFile(): RawPersonEntry[] {
|
|
1658
|
-
try {
|
|
1659
|
-
const raw = readFileSync(PEOPLE_FILE, 'utf8')
|
|
1660
|
-
const parsed = JSON.parse(raw) as { entries?: unknown }
|
|
1661
|
-
if (!Array.isArray(parsed.entries)) return []
|
|
1662
|
-
return parsed.entries as RawPersonEntry[]
|
|
1663
|
-
} catch {
|
|
1664
|
-
return []
|
|
1665
|
-
}
|
|
1666
|
-
}
|
|
1580
|
+
// Access/allowlist file layer (access.json + people.json). Extracted to
|
|
1581
|
+
// ./access-store.ts (switchroom#4248) to relieve the gateway line-ratchet;
|
|
1582
|
+
// behavior is byte-identical. The factory runs the static-mode BOOT_ACCESS
|
|
1583
|
+
// snapshot eagerly at THIS point in startup — same timing as the inline
|
|
1584
|
+
// version — so the frozen-allowlist semantics are unchanged.
|
|
1585
|
+
const { loadAccess, readPeopleFile, assertAllowedChat, saveAccess, pruneExpired } =
|
|
1586
|
+
createAccessStore({
|
|
1587
|
+
accessFile: ACCESS_FILE,
|
|
1588
|
+
peopleFile: PEOPLE_FILE,
|
|
1589
|
+
stateDir: STATE_DIR,
|
|
1590
|
+
isStatic: STATIC,
|
|
1591
|
+
})
|
|
1667
1592
|
|
|
1668
1593
|
/**
|
|
1669
1594
|
* Boot-time-only person-name directory (see resolve-person.ts module doc).
|
|
@@ -1673,34 +1598,6 @@ function readPeopleFile(): RawPersonEntry[] {
|
|
|
1673
1598
|
*/
|
|
1674
1599
|
let PERSON_DIRECTORY: PersonDirectory = { byTelegramKey: {} }
|
|
1675
1600
|
|
|
1676
|
-
function assertAllowedChat(chat_id: string | number): void {
|
|
1677
|
-
const id = String(chat_id)
|
|
1678
|
-
const access = loadAccess()
|
|
1679
|
-
if (access.allowFrom.includes(id)) return
|
|
1680
|
-
if (id in access.groups) return
|
|
1681
|
-
throw new Error(`chat ${id} is not allowlisted — add via /telegram:access`)
|
|
1682
|
-
}
|
|
1683
|
-
|
|
1684
|
-
function saveAccess(a: Access): void {
|
|
1685
|
-
if (STATIC) return
|
|
1686
|
-
mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 })
|
|
1687
|
-
const tmp = ACCESS_FILE + '.tmp'
|
|
1688
|
-
writeFileSync(tmp, JSON.stringify(a, null, 2) + '\n', { mode: 0o600 })
|
|
1689
|
-
renameSync(tmp, ACCESS_FILE)
|
|
1690
|
-
}
|
|
1691
|
-
|
|
1692
|
-
function pruneExpired(a: Access): boolean {
|
|
1693
|
-
const now = Date.now()
|
|
1694
|
-
let changed = false
|
|
1695
|
-
for (const [code, p] of Object.entries(a.pending)) {
|
|
1696
|
-
if (p.expiresAt < now) {
|
|
1697
|
-
delete a.pending[code]
|
|
1698
|
-
changed = true
|
|
1699
|
-
}
|
|
1700
|
-
}
|
|
1701
|
-
return changed
|
|
1702
|
-
}
|
|
1703
|
-
|
|
1704
1601
|
// ─── History ──────────────────────────────────────────────────────────────
|
|
1705
1602
|
const HISTORY_ACCESS = loadAccess()
|
|
1706
1603
|
const HISTORY_ENABLED = HISTORY_ACCESS.historyEnabled !== false
|
|
@@ -10181,7 +10078,7 @@ if (isGatewayMain && !STATIC && OBLIGATION_LEDGER_ENABLED) {
|
|
|
10181
10078
|
// --continue suppression, resume-window dedup, budget) lives in
|
|
10182
10079
|
// boot-briefing-wiring.ts / boot-briefing-builder.ts; never throws.
|
|
10183
10080
|
if (isGatewayMain && HISTORY_ENABLED) {
|
|
10184
|
-
maybeQueueBootBriefing({
|
|
10081
|
+
await maybeQueueBootBriefing({
|
|
10185
10082
|
env: process.env,
|
|
10186
10083
|
stateDir: STATE_DIR,
|
|
10187
10084
|
resumeMsg: bootResumeInbound?.msg ?? null,
|
|
@@ -15182,17 +15079,39 @@ export async function handleInbound(
|
|
|
15182
15079
|
|
|
15183
15080
|
// Reply-to + forward-origin context — moved to buildReplyForwardContext
|
|
15184
15081
|
// (#2996 P7 PR-10, pure builder).
|
|
15082
|
+
const replyForwardCtx = buildReplyForwardContext({
|
|
15083
|
+
ctx,
|
|
15084
|
+
coalescedForwardOrigins,
|
|
15085
|
+
replyToTextMax: REPLY_TO_TEXT_MAX,
|
|
15086
|
+
})
|
|
15185
15087
|
const {
|
|
15186
15088
|
replyToMessageId,
|
|
15187
|
-
replyToText,
|
|
15188
|
-
replyToTextEscaped,
|
|
15189
15089
|
forwardOrigins,
|
|
15190
15090
|
forwardOriginMeta,
|
|
15191
15091
|
primaryForwardOrigin,
|
|
15192
|
-
} =
|
|
15193
|
-
|
|
15194
|
-
|
|
15092
|
+
} = replyForwardCtx
|
|
15093
|
+
|
|
15094
|
+
// Reply-to buffer fallback (post-reset continuity, resolveReplyToFromBuffer).
|
|
15095
|
+
// On a native reply to the BOT's OWN message, Telegram delivers
|
|
15096
|
+
// reply_to_message.message_id but NOT its .text — so the live reply text is
|
|
15097
|
+
// empty even though we authored (and, via recordOutbound, persisted) that
|
|
15098
|
+
// message to history.db (role='assistant', 30-day retention). Recover it so
|
|
15099
|
+
// the antecedent survives a session reset. Fills replyToText (raw, so the
|
|
15100
|
+
// recordInbound write below persists it — envelope-only would leave the row
|
|
15101
|
+
// NULL and starve future briefings), replyToTextEscaped (channel meta), and
|
|
15102
|
+
// replyToRole (the reply_to_role attribute). Only when the live text is
|
|
15103
|
+
// empty; degrades silently when history is off / the row is missing.
|
|
15104
|
+
const {
|
|
15105
|
+
replyToText,
|
|
15106
|
+
replyToTextEscaped,
|
|
15107
|
+
replyToRole,
|
|
15108
|
+
} = resolveReplyToFromBuffer({
|
|
15109
|
+
replyToMessageId,
|
|
15110
|
+
replyToText: replyForwardCtx.replyToText,
|
|
15111
|
+
replyToTextEscaped: replyForwardCtx.replyToTextEscaped,
|
|
15112
|
+
historyEnabled: HISTORY_ENABLED,
|
|
15195
15113
|
replyToTextMax: REPLY_TO_TEXT_MAX,
|
|
15114
|
+
lookup: (messageId) => lookupMessageRoleAndText(chat_id, messageId),
|
|
15196
15115
|
})
|
|
15197
15116
|
|
|
15198
15117
|
if (HISTORY_ENABLED) {
|
|
@@ -15269,6 +15188,7 @@ export async function handleInbound(
|
|
|
15269
15188
|
priorAssistantPreview,
|
|
15270
15189
|
replyToMessageId,
|
|
15271
15190
|
replyToTextEscaped,
|
|
15191
|
+
replyToRole,
|
|
15272
15192
|
forwardOriginMeta,
|
|
15273
15193
|
topicFramingEnabled: TOPIC_FRAMING_ENABLED,
|
|
15274
15194
|
personDirectory: PERSON_DIRECTORY,
|
|
@@ -151,9 +151,20 @@ export function buildReplyForwardContext(p: ReplyForwardContextParams): {
|
|
|
151
151
|
// (for channel meta).
|
|
152
152
|
const replyToMsg = p.ctx.message?.reply_to_message
|
|
153
153
|
const replyToMessageId = replyToMsg?.message_id
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
154
|
+
// Native partial-quote preference (Bot API 7.0+ `message.quote`, issue #119
|
|
155
|
+
// follow-up). When the user long-presses a message and drag-selects a
|
|
156
|
+
// substring before choosing Reply, Telegram delivers only that quoted span
|
|
157
|
+
// on `message.quote.text` — the user is pointing at that exact slice, so it
|
|
158
|
+
// is a stronger antecedent than the full parent message. Prefer it over the
|
|
159
|
+
// parent `.text`/`.caption`, and over the history-buffer fallback the
|
|
160
|
+
// gateway runs when neither is present. Accessed defensively in case the
|
|
161
|
+
// installed grammy/@grammyjs/types predate `TextQuote`.
|
|
162
|
+
const quoteText = p.ctx.message?.quote?.text
|
|
163
|
+
const replyToTextRaw = (quoteText != null && quoteText.length > 0)
|
|
164
|
+
? quoteText
|
|
165
|
+
: replyToMsg
|
|
166
|
+
? (replyToMsg.text ?? replyToMsg.caption ?? undefined)
|
|
167
|
+
: undefined
|
|
157
168
|
const replyToText = replyToTextRaw != null
|
|
158
169
|
? (replyToTextRaw.length > p.replyToTextMax
|
|
159
170
|
? replyToTextRaw.slice(0, p.replyToTextMax - 1) + '…'
|
|
@@ -183,6 +194,73 @@ export function buildReplyForwardContext(p: ReplyForwardContextParams): {
|
|
|
183
194
|
}
|
|
184
195
|
}
|
|
185
196
|
|
|
197
|
+
/** Inputs for {@link resolveReplyToFromBuffer}. */
|
|
198
|
+
export interface ReplyToBufferFallbackParams {
|
|
199
|
+
/** From {@link buildReplyForwardContext}. */
|
|
200
|
+
replyToMessageId: number | undefined
|
|
201
|
+
/** Raw reply text off the live update (for the SQLite write); empty when the
|
|
202
|
+
* reply target is the bot's own message (Telegram omits its text). */
|
|
203
|
+
replyToText: string | undefined
|
|
204
|
+
/** XML-escaped reply text for the channel meta; empty in the same case. */
|
|
205
|
+
replyToTextEscaped: string | undefined
|
|
206
|
+
/** HISTORY_ENABLED — the DB is only present when history is on. */
|
|
207
|
+
historyEnabled: boolean
|
|
208
|
+
/** REPLY_TO_TEXT_MAX. */
|
|
209
|
+
replyToTextMax: number
|
|
210
|
+
/** `lookupMessageRoleAndText` bound to the chat, or any equivalent. May
|
|
211
|
+
* throw (e.g. requireDb when history disabled mid-run) — this is caught. */
|
|
212
|
+
lookup: (messageId: number) => { role: 'user' | 'assistant'; text: string } | null
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Reply-to buffer fallback (post-reset continuity). On a native reply to the
|
|
217
|
+
* BOT's OWN message, Telegram delivers `reply_to_message.message_id` but NOT
|
|
218
|
+
* its `.text` — so the live reply text is empty even though the gateway
|
|
219
|
+
* authored (and, via `recordOutbound`, persisted) that message. This recovers
|
|
220
|
+
* the antecedent from the local history buffer so it survives a session reset
|
|
221
|
+
* (`resume_mode: handoff`), where the transcript is gone.
|
|
222
|
+
*
|
|
223
|
+
* Returns updated `replyToText` (raw, for the SQLite `recordInbound` write —
|
|
224
|
+
* envelope-only would leave the row NULL and starve future handoff briefings),
|
|
225
|
+
* `replyToTextEscaped` (for the channel-meta `reply_to_text`), and the
|
|
226
|
+
* recovered `replyToRole` ('assistant' = the bot's own message, disambiguating
|
|
227
|
+
* the "you're replying to yourself" case). Pure except for the injected
|
|
228
|
+
* `lookup`. Only fills in when the live reply text is empty — never overwrites
|
|
229
|
+
* a non-empty live value (a partial-quote or a reply to a person's message).
|
|
230
|
+
* Degrades silently to the id-only inputs on any lookup failure.
|
|
231
|
+
*/
|
|
232
|
+
export function resolveReplyToFromBuffer(p: ReplyToBufferFallbackParams): {
|
|
233
|
+
replyToText: string | undefined
|
|
234
|
+
replyToTextEscaped: string | undefined
|
|
235
|
+
replyToRole: 'user' | 'assistant' | undefined
|
|
236
|
+
} {
|
|
237
|
+
let replyToText = p.replyToText
|
|
238
|
+
let replyToTextEscaped = p.replyToTextEscaped
|
|
239
|
+
let replyToRole: 'user' | 'assistant' | undefined
|
|
240
|
+
const liveTextEmpty = replyToTextEscaped == null || replyToTextEscaped.length === 0
|
|
241
|
+
if (p.historyEnabled && p.replyToMessageId != null && liveTextEmpty) {
|
|
242
|
+
try {
|
|
243
|
+
const recovered = p.lookup(p.replyToMessageId)
|
|
244
|
+
if (recovered && recovered.text.length > 0) {
|
|
245
|
+
replyToText =
|
|
246
|
+
recovered.text.length > p.replyToTextMax
|
|
247
|
+
? recovered.text.slice(0, p.replyToTextMax - 1) + '…'
|
|
248
|
+
: recovered.text
|
|
249
|
+
replyToTextEscaped = formatReplyToText(recovered.text, p.replyToTextMax)
|
|
250
|
+
replyToRole = recovered.role
|
|
251
|
+
} else if (recovered) {
|
|
252
|
+
// Row exists (authorship known) but text is empty/redacted — still
|
|
253
|
+
// surface the role so the model knows whose message it is replying to.
|
|
254
|
+
replyToRole = recovered.role
|
|
255
|
+
}
|
|
256
|
+
} catch {
|
|
257
|
+
// History disabled mid-run / requireDb throws / row missing — degrade
|
|
258
|
+
// silently to the id-only inputs (current behavior).
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return { replyToText, replyToTextEscaped, replyToRole }
|
|
262
|
+
}
|
|
263
|
+
|
|
186
264
|
/** Inputs for {@link buildInboundEnvelope} — every field is an at-call
|
|
187
265
|
* captured value (steering meta, at-receipt snapshots, resolved attachments),
|
|
188
266
|
* never a live getter. */
|
|
@@ -206,6 +284,11 @@ export interface EnvelopeBuildParams {
|
|
|
206
284
|
priorAssistantPreview: string | undefined
|
|
207
285
|
replyToMessageId: number | undefined
|
|
208
286
|
replyToTextEscaped: string | undefined
|
|
287
|
+
/** Authorship of the replied-to message ('assistant' = the bot's own
|
|
288
|
+
* message, 'user' = a person's), when known — recovered from the history
|
|
289
|
+
* buffer by handleInbound's reply-to fallback. Undefined when the role
|
|
290
|
+
* can't be determined (e.g. text came live off the update, not the DB). */
|
|
291
|
+
replyToRole: 'user' | 'assistant' | undefined
|
|
209
292
|
forwardOriginMeta: Record<string, string>
|
|
210
293
|
/** TOPIC_FRAMING_ENABLED — fixed constant. */
|
|
211
294
|
topicFramingEnabled: boolean
|
|
@@ -300,6 +383,13 @@ export function buildInboundEnvelope(p: EnvelopeBuildParams): InboundMessage {
|
|
|
300
383
|
// Use the XML-escaped form for the meta — the raw form is in the
|
|
301
384
|
// SQLite buffer for verbatim retrieval via get_recent_messages.
|
|
302
385
|
...(p.replyToTextEscaped != null && p.replyToTextEscaped.length > 0 ? { reply_to_text: p.replyToTextEscaped } : {}),
|
|
386
|
+
// Authorship of the replied-to message. Disambiguates "you are replying
|
|
387
|
+
// to the BOT's own message" (assistant) from "…to a person's message"
|
|
388
|
+
// (user) — the incident where a native reply to one of the bot's own
|
|
389
|
+
// messages ("is this added as a calendar invite yet?") left the agent
|
|
390
|
+
// guessing the antecedent. Free: the history-buffer lookup that recovers
|
|
391
|
+
// reply_to_text already returns the role. Emitted only when known.
|
|
392
|
+
...(p.replyToRole != null ? { reply_to_role: p.replyToRole } : {}),
|
|
303
393
|
// Forwarded-message origin (server-stamped, attrs-only — see above).
|
|
304
394
|
// forwarded_from / forwarded_from_type / forwarded_from_id /
|
|
305
395
|
// forwarded_date, plus numbered _2.. siblings for a multi-origin
|