switchroom 0.18.12 → 0.18.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-scheduler/index.js +57 -9
- package/dist/auth-broker/index.js +174 -72
- package/dist/cli/autoaccept-poll.js +23 -0
- package/dist/cli/drive-write-pretool.mjs +24 -1
- package/dist/cli/foreground-hog-pretool.mjs +264 -0
- package/dist/cli/ms-365-write-pretool.mjs +31 -8
- package/dist/cli/notion-write-pretool.mjs +9 -2
- package/dist/cli/skill-validate-pretool.mjs +144 -2847
- package/dist/cli/switchroom.js +986 -3131
- package/dist/host-control/main.js +216 -2863
- package/dist/vault/approvals/kernel-server.js +67 -1
- package/dist/vault/broker/server.js +98 -45
- package/package.json +1 -1
- package/profiles/coding/CLAUDE.md.hbs +2 -0
- package/profiles/default/CLAUDE.md.hbs +2 -0
- package/skills/switchroom-architecture/telegram.md +0 -1
- package/telegram-plugin/auth-snapshot-format.ts +37 -5
- package/telegram-plugin/auto-fallback-fleet.ts +29 -1
- package/telegram-plugin/bridge/bridge.ts +2 -0
- package/telegram-plugin/dist/bridge/bridge.js +51 -3
- package/telegram-plugin/dist/gateway/gateway.js +1251 -2368
- package/telegram-plugin/dist/server.js +67 -3
- package/telegram-plugin/format.ts +19 -0
- package/telegram-plugin/gateway/approval-hold.ts +21 -2
- package/telegram-plugin/gateway/auth-broker-client.ts +1 -0
- package/telegram-plugin/gateway/auth-command.ts +14 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +12 -0
- package/telegram-plugin/gateway/forward-origin.ts +235 -0
- package/telegram-plugin/gateway/gateway.ts +445 -83
- package/telegram-plugin/gateway/throttle-tier-wiring.ts +268 -0
- package/telegram-plugin/history.ts +106 -6
- package/telegram-plugin/inline-keyboard-callbacks.ts +94 -0
- package/telegram-plugin/model-unavailable.ts +61 -13
- package/telegram-plugin/outbound-field-redact.ts +69 -0
- package/telegram-plugin/render/render.ts +32 -14
- package/telegram-plugin/render/rich-render.ts +40 -32
- package/telegram-plugin/scoped-approval.ts +11 -2
- package/telegram-plugin/secret-detect/chunker.ts +18 -4
- package/telegram-plugin/secret-detect/index.ts +12 -56
- package/telegram-plugin/send-gate-degraded.test.ts +131 -0
- package/telegram-plugin/send-gate.test.ts +25 -6
- package/telegram-plugin/send-gate.ts +82 -8
- package/telegram-plugin/session-tail.ts +82 -7
- package/telegram-plugin/stream-controller.ts +3 -2
- package/telegram-plugin/subagent-watcher.ts +71 -16
- package/telegram-plugin/tests/approval-hold-outcome.test.ts +36 -5
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +72 -0
- package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
- package/telegram-plugin/tests/forward-origin.test.ts +309 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
- package/telegram-plugin/tests/history.test.ts +272 -0
- package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
- package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +164 -0
- package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
- package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
- package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
- package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +6 -4
- package/telegram-plugin/tests/render/render.test.ts +88 -0
- package/telegram-plugin/tests/render/rich-render.test.ts +41 -22
- package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
- package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
- package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
- package/telegram-plugin/tests/single-mode-stream-reply.test.ts +5 -3
- package/telegram-plugin/tests/status-accent.test.ts +5 -3
- package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +20 -20
- package/telegram-plugin/tests/stream-reply-handler.test.ts +5 -2
- package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
- package/telegram-plugin/tests/throttle-tier-wiring.test.ts +290 -0
- package/telegram-plugin/tests/throttle-tier.test.ts +278 -0
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
- package/telegram-plugin/throttle-tier.ts +226 -0
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +8 -7
- package/telegram-plugin/worktree-watch-cwds.ts +194 -5
- package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
- package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
|
@@ -17062,6 +17062,48 @@ var init_operator_events = __esm(() => {
|
|
|
17062
17062
|
cooldownMap = new Map;
|
|
17063
17063
|
});
|
|
17064
17064
|
|
|
17065
|
+
// quota-check.ts
|
|
17066
|
+
var init_quota_check = () => {};
|
|
17067
|
+
|
|
17068
|
+
// text-voice-scrub.ts
|
|
17069
|
+
var NULL = "\x00", FENCE_PH, INLINE_PH, HTML_CODE_PH, HTML_PRE_PH, URL_PH;
|
|
17070
|
+
var init_text_voice_scrub = __esm(() => {
|
|
17071
|
+
FENCE_PH = `${NULL}VS_FENCE`;
|
|
17072
|
+
INLINE_PH = `${NULL}VS_INLINE`;
|
|
17073
|
+
HTML_CODE_PH = `${NULL}VS_HTMLCODE`;
|
|
17074
|
+
HTML_PRE_PH = `${NULL}VS_HTMLPRE`;
|
|
17075
|
+
URL_PH = `${NULL}VS_URL`;
|
|
17076
|
+
});
|
|
17077
|
+
|
|
17078
|
+
// card-format.ts
|
|
17079
|
+
var init_card_format = __esm(() => {
|
|
17080
|
+
init_text_voice_scrub();
|
|
17081
|
+
});
|
|
17082
|
+
|
|
17083
|
+
// model-unavailable.ts
|
|
17084
|
+
function isTransientUpstreamSignal(text) {
|
|
17085
|
+
if (typeof text !== "string" || text.length === 0)
|
|
17086
|
+
return false;
|
|
17087
|
+
const sample = text.length > 16384 ? text.slice(0, 16384) : text;
|
|
17088
|
+
const lower = sample.toLowerCase();
|
|
17089
|
+
return transientUpstreamSignals.some((s) => lower.includes(s));
|
|
17090
|
+
}
|
|
17091
|
+
var transientUpstreamSignals;
|
|
17092
|
+
var init_model_unavailable = __esm(() => {
|
|
17093
|
+
init_quota_check();
|
|
17094
|
+
init_card_format();
|
|
17095
|
+
transientUpstreamSignals = [
|
|
17096
|
+
"not your usage limit",
|
|
17097
|
+
"not your account",
|
|
17098
|
+
"not your account's",
|
|
17099
|
+
"temporarily limiting requests",
|
|
17100
|
+
"temporarily rate",
|
|
17101
|
+
"server is temporarily",
|
|
17102
|
+
"would exceed your account\u2019s rate limit",
|
|
17103
|
+
"would exceed your account's rate limit"
|
|
17104
|
+
];
|
|
17105
|
+
});
|
|
17106
|
+
|
|
17065
17107
|
// tool-label-sidecar.ts
|
|
17066
17108
|
import { existsSync as existsSync3, readFileSync as readFileSync2, statSync as statSync3 } from "node:fs";
|
|
17067
17109
|
import { join as join3 } from "node:path";
|
|
@@ -17491,7 +17533,8 @@ function detectErrorInTranscriptLine(line) {
|
|
|
17491
17533
|
const status = typeof obj.apiErrorStatus === "number" ? obj.apiErrorStatus : null;
|
|
17492
17534
|
const errStr = typeof obj.error === "string" ? obj.error : "";
|
|
17493
17535
|
const text = extractAssistantText(obj);
|
|
17494
|
-
const kind2 = status === 429 ?
|
|
17536
|
+
const kind2 = status === 429 ? isTransientUpstreamSignal(`${text}
|
|
17537
|
+
${errStr}`) ? "rate-limited" : "quota-exhausted" : classifyClaudeError({ type: errStr, status, message: text });
|
|
17495
17538
|
return {
|
|
17496
17539
|
kind: kind2,
|
|
17497
17540
|
raw: obj,
|
|
@@ -17585,6 +17628,7 @@ function startSessionTail(config2) {
|
|
|
17585
17628
|
const onOperatorEvent = config2.onOperatorEvent;
|
|
17586
17629
|
log?.(`session-tail: projectsDir=${projectsDir}`);
|
|
17587
17630
|
const sidecars = new Map;
|
|
17631
|
+
const createSidecar = config2.createSidecar ?? createToolLabelSidecar;
|
|
17588
17632
|
const stateDirForSidecar = process.env.TELEGRAM_STATE_DIR ?? null;
|
|
17589
17633
|
function sessionIdForFile(file) {
|
|
17590
17634
|
if (!file)
|
|
@@ -17599,7 +17643,7 @@ function startSessionTail(config2) {
|
|
|
17599
17643
|
if (existing)
|
|
17600
17644
|
return existing;
|
|
17601
17645
|
try {
|
|
17602
|
-
const s =
|
|
17646
|
+
const s = createSidecar({ stateDir: stateDirForSidecar, sessionId });
|
|
17603
17647
|
sidecars.set(sessionId, s);
|
|
17604
17648
|
s.onLabel((toolUseId, label, toolName) => {
|
|
17605
17649
|
rawOnEvent({ kind: "tool_label", toolUseId, label, toolName });
|
|
@@ -17610,6 +17654,17 @@ function startSessionTail(config2) {
|
|
|
17610
17654
|
return null;
|
|
17611
17655
|
}
|
|
17612
17656
|
}
|
|
17657
|
+
function stopSidecar(sessionId) {
|
|
17658
|
+
if (!sessionId)
|
|
17659
|
+
return;
|
|
17660
|
+
const s = sidecars.get(sessionId);
|
|
17661
|
+
if (!s)
|
|
17662
|
+
return;
|
|
17663
|
+
try {
|
|
17664
|
+
s.stop();
|
|
17665
|
+
} catch {}
|
|
17666
|
+
sidecars.delete(sessionId);
|
|
17667
|
+
}
|
|
17613
17668
|
function decorate(ev, sessionId) {
|
|
17614
17669
|
if (!sessionId)
|
|
17615
17670
|
return ev;
|
|
@@ -17702,6 +17757,11 @@ function startSessionTail(config2) {
|
|
|
17702
17757
|
} catch {}
|
|
17703
17758
|
watcher = null;
|
|
17704
17759
|
}
|
|
17760
|
+
const rotatedAwaySid = sessionIdForFile(currentFile);
|
|
17761
|
+
const nextSid = sessionIdForFile(file);
|
|
17762
|
+
if (rotatedAwaySid != null && rotatedAwaySid !== nextSid) {
|
|
17763
|
+
stopSidecar(rotatedAwaySid);
|
|
17764
|
+
}
|
|
17705
17765
|
currentFile = file;
|
|
17706
17766
|
const prior = fileCursors.get(file);
|
|
17707
17767
|
if (prior != null) {
|
|
@@ -17734,7 +17794,7 @@ function startSessionTail(config2) {
|
|
|
17734
17794
|
const multiAgent = isMultiAgentEnabled();
|
|
17735
17795
|
const CAP_TOOL_USE_THRESHOLD = 30;
|
|
17736
17796
|
const subTails = new Map;
|
|
17737
|
-
const IDLE_FSWATCH_TTL_MS = 5 * 60 * 1000;
|
|
17797
|
+
const IDLE_FSWATCH_TTL_MS = Math.max(1000, config2.subTailIdleReapMs ?? 5 * 60 * 1000);
|
|
17738
17798
|
function readSub(t) {
|
|
17739
17799
|
if (stopped)
|
|
17740
17800
|
return;
|
|
@@ -17841,6 +17901,7 @@ function startSessionTail(config2) {
|
|
|
17841
17901
|
} catch {}
|
|
17842
17902
|
t.watcher = null;
|
|
17843
17903
|
}
|
|
17904
|
+
stopSidecar(sessionIdForFile(t.file));
|
|
17844
17905
|
subTails.delete(file);
|
|
17845
17906
|
log?.(`session-tail: reaped idle sub ${t.agentId} (${file})`);
|
|
17846
17907
|
}
|
|
@@ -17925,6 +17986,7 @@ function startSessionTail(config2) {
|
|
|
17925
17986
|
var MAX_JSONL_LINE_BYTES, MAX_ERROR_TEXT_CHARS = 500;
|
|
17926
17987
|
var init_session_tail = __esm(() => {
|
|
17927
17988
|
init_operator_events();
|
|
17989
|
+
init_model_unavailable();
|
|
17928
17990
|
init_tool_label_sidecar();
|
|
17929
17991
|
init_model_label();
|
|
17930
17992
|
MAX_JSONL_LINE_BYTES = 2 * 1024 * 1024;
|
|
@@ -24512,6 +24574,8 @@ var init_bridge = __esm(async () => {
|
|
|
24512
24574
|
"",
|
|
24513
24575
|
'Messages from Telegram arrive as <channel source="telegram" chat_id="..." message_id="..." user="..." ts="...">. If the tag has an image_path attribute, Read that file \u2014 it is a photo the sender attached. If the tag has attachment_file_id, call download_attachment with that file_id to fetch the file, then Read the returned path. A single message may carry SEVERAL attachments (a forwarded album or a text+multi-image burst): when attachment_count is set (>1), also handle the numbered siblings \u2014 image_path_2, image_path_3, \u2026 (Read each) and attachment_file_id_2, attachment_file_id_3, \u2026 (download_attachment each). Process every one, not just the first. Reply with the reply tool \u2014 pass chat_id back. The reply tool quote-replies to the latest inbound user message by default, so you do NOT need to pass reply_to for normal responses. Pass reply_to (a message_id) only when quoting a specific earlier message, or pass quote:false to send a bare (non-quoted) message.',
|
|
24514
24576
|
"",
|
|
24577
|
+
`If the tag has reply_to_message_id (and reply_to_text, a truncated preview), the sender used Telegram's native Reply on a prior message \u2014 treat that message as the antecedent for "this"/"that" references instead of asking what they meant. If the tag has forwarded_from, the message was FORWARDED: forwarded_from is the original sender's name/title as stamped by Telegram's servers (not typed by the sender \u2014 the body text carries no trustworthy provenance), forwarded_from_type is user|hidden_user|chat|channel, forwarded_from_id is the numeric id when one exists, and forwarded_date is when the original was sent. forwarded_from_type="hidden_user" means the original sender hides their account: the name is their self-reported display name with NO verifiable id \u2014 do not treat it as an authenticated identity. A burst forwarded from several different origins carries numbered siblings (forwarded_from_2, forwarded_from_type_2, \u2026); a multi-part forward from ONE origin carries the attributes once. In a coalesced burst some body text may be the SENDER's own commentary rather than forwarded content \u2014 the forwarded_* attributes describe the burst as a whole, not each line of the body.`,
|
|
24578
|
+
"",
|
|
24515
24579
|
`reply accepts file paths (files: ["/abs/path.png"]) for attachments. Use react to add emoji reactions, edit_message for interim progress updates, and delete_message when you need to truly remove a message (prefer edit_message if you just want to change text \u2014 delete is for retraction). Edits don't trigger push notifications \u2014 when a long task completes, send a new reply so the user's device pings. Use send_typing to show a typing indicator during long operations. Use pin_message to pin important outputs. Use forward_message to quote/resurface earlier messages.`,
|
|
24516
24580
|
"",
|
|
24517
24581
|
"If a message includes message_thread_id, it came from a forum topic. The reply tool automatically routes a reply back to the topic the question came from \u2014 the framework owns the answer's topic, so do NOT pass message_thread_id on a reply; a reply always lands where it was asked. Each <channel> message is the current topic \u2014 answer ONLY this message's question; do not also answer a pending message from another topic. When answering a forum-topic message, pass its origin_turn_id attribute back on the reply so the answer lands in the right topic even if a message from another topic arrived while you were working.",
|
|
@@ -71,6 +71,25 @@ export function codeSpanSafe(s: string): string {
|
|
|
71
71
|
return s.replace(/`/g, '`')
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Make a URL safe to interpolate as the destination of a `[label](href)`
|
|
76
|
+
* inline link.
|
|
77
|
+
*
|
|
78
|
+
* In GFM / Bot API 10.1 markdown a link destination written as `(...)` is a
|
|
79
|
+
* bare destination whose parentheses must be BALANCED, or every paren must be
|
|
80
|
+
* backslash-escaped. An href containing a literal `)` (Wikipedia
|
|
81
|
+
* `..._(disambiguation)` URLs, tracking params, generated deep links) can
|
|
82
|
+
* either truncate the URL (a lone `)` closing the destination) or, if we
|
|
83
|
+
* escape only `)`, unbalance the parens and leak a literal backslash into the
|
|
84
|
+
* decoded href. The destination honours C-style backslash escapes, so escape
|
|
85
|
+
* `\` first (so we never double-escape a following escape), then BOTH `(` and
|
|
86
|
+
* `)` — the whole URL is preserved balanced and micromark decodes it back to
|
|
87
|
+
* the original href on round-trip. Bot API 10.1 lists `(`/`)` as escapable.
|
|
88
|
+
*/
|
|
89
|
+
export function escapeLinkHref(href: string): string {
|
|
90
|
+
return href.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)')
|
|
91
|
+
}
|
|
92
|
+
|
|
74
93
|
/**
|
|
75
94
|
* Repair LLM-side JSON escape bungles.
|
|
76
95
|
*
|
|
@@ -268,12 +268,31 @@ export function createBlockedApprovalStore(
|
|
|
268
268
|
*
|
|
269
269
|
* This exists so the feature cannot silently no-op. If the shared bind is
|
|
270
270
|
* missing (a container predating the volume), or auto-created root-owned, the
|
|
271
|
-
* record still lands somewhere the operator and switchroom-web can read
|
|
272
|
-
*
|
|
271
|
+
* record still lands somewhere the operator and switchroom-web can read.
|
|
272
|
+
*
|
|
273
|
+
* **Reachable by mount is not the same as readable, and neither is the same as
|
|
274
|
+
* READ.** That elision is a bug this comment used to paper over: it claimed
|
|
275
|
+
* "web mounts all of ~/.switchroom, so both locations are reachable" — true,
|
|
276
|
+
* and irrelevant, because the reader only ever scanned the shared dir. The
|
|
277
|
+
* fallback was written to a file nobody read (#3109). Two things must hold, and
|
|
278
|
+
* both are load-bearing: if either regresses, the record exists, the agent is
|
|
279
|
+
* held, and the dashboard still prints "No agent is blocked".
|
|
280
|
+
*
|
|
281
|
+
* 1. **Mode.** The record is written 0644 into the 0775 agent dir. Verified
|
|
282
|
+
* live: `docker exec switchroom-web` reads a 0644 file under
|
|
283
|
+
* `~/.switchroom/agents/<agent>/` fine and gets EACCES only on the 0600
|
|
284
|
+
* files beside it. The FILE's mode locks web out, never the directory.
|
|
285
|
+
* 2. **The reader must LOOK here.** It now does —
|
|
286
|
+
* `src/web/blocked-approvals-read.ts` scans the shared dir AND
|
|
287
|
+
* `agents/<agent>/blocked-approval.json` (its `FALLBACK_RECORD_NAME`,
|
|
288
|
+
* which must stay in sync with the filename below).
|
|
273
289
|
*/
|
|
274
290
|
fallbackDir?: string,
|
|
275
291
|
): BlockedApprovalStore {
|
|
276
292
|
const primary = join(dir, `${agent}.json`)
|
|
293
|
+
// Keep in sync with FALLBACK_RECORD_NAME in src/web/blocked-approvals-read.ts —
|
|
294
|
+
// the reader matches this filename exactly. Pinned by the fallback-contract
|
|
295
|
+
// tests in src/web/blocked-approvals.test.ts.
|
|
277
296
|
const fallback = fallbackDir != null ? join(fallbackDir, 'blocked-approval.json') : null
|
|
278
297
|
|
|
279
298
|
/** Where the last successful write landed — `read`/`clear` must agree with it. */
|
|
@@ -28,6 +28,7 @@ export function createAuthBrokerClient(): {
|
|
|
28
28
|
listState: () => broker.listState(),
|
|
29
29
|
setActive: (label: string) => broker.setActive(label),
|
|
30
30
|
markExhausted: (until?: number) => broker.markExhausted(until),
|
|
31
|
+
markThrottled: (until: number) => broker.markThrottled(until),
|
|
31
32
|
rmAccount: (label: string) => broker.rmAccount(label),
|
|
32
33
|
refreshAccount: (label: string) => broker.refreshAccount(label),
|
|
33
34
|
setOverride: (agent: string, account: string | null) =>
|
|
@@ -301,6 +301,20 @@ export interface AuthBrokerClient {
|
|
|
301
301
|
* identity — so auto-fallback works from any agent.
|
|
302
302
|
*/
|
|
303
303
|
markExhausted(until?: number): Promise<{ account: string; rolled: string[]; rolledTo?: string | null }>
|
|
304
|
+
/**
|
|
305
|
+
* 429 throttle tier (broker `mark-throttled`). Records a transient
|
|
306
|
+
* per-account rate limit on the CALLER's own account — `throttled_until`
|
|
307
|
+
* in the quota ledger — WITHOUT rolling the fleet and WITHOUT touching
|
|
308
|
+
* eligibility. `escalated` is true when the broker's escalation guard
|
|
309
|
+
* (repeated hits corroborated by a live probe) converted it into the
|
|
310
|
+
* standard mark-exhausted + roll; `rolledTo` names the roll target then.
|
|
311
|
+
*/
|
|
312
|
+
markThrottled(until: number): Promise<{
|
|
313
|
+
account: string
|
|
314
|
+
throttled_until: number
|
|
315
|
+
escalated: boolean
|
|
316
|
+
rolledTo?: string | null
|
|
317
|
+
}>
|
|
304
318
|
rmAccount(label: string): Promise<{ label: string }>
|
|
305
319
|
refreshAccount(label: string): Promise<{ account: string; expiresAt?: number }>
|
|
306
320
|
setOverride(
|
|
@@ -2534,6 +2534,18 @@ async function handleOperatorEventCallback(ctx: Context, data: string): Promise<
|
|
|
2534
2534
|
// stub so any stale pinned message that fires an `auth:*` tap is
|
|
2535
2535
|
// silently dismissed instead of crashing the gateway.
|
|
2536
2536
|
async function handleAuthDashboardCallback(ctx: Context): Promise<void> {
|
|
2537
|
+
// Strict allowFrom gate, identical to every other mutating handler in
|
|
2538
|
+
// this file (handleOperatorEventCallback, the vra:/vrs:/vd:/vg: families).
|
|
2539
|
+
// Its absence was a security hole: `auth:use:<label>` drives
|
|
2540
|
+
// `client.setActive(label)` — a fleet-wide OAuth account swap — so an
|
|
2541
|
+
// ungated handler let any tapper (e.g. a member of an admin forum/
|
|
2542
|
+
// supergroup with an empty group allowFrom) swap the active account.
|
|
2543
|
+
const senderId = String(ctx.from?.id ?? '')
|
|
2544
|
+
const access = loadAccess()
|
|
2545
|
+
if (!access.allowFrom.includes(senderId)) {
|
|
2546
|
+
await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
|
|
2547
|
+
return
|
|
2548
|
+
}
|
|
2537
2549
|
const data = ctx.callbackQuery?.data ?? ''
|
|
2538
2550
|
const currentAgent = getMyAgentName()
|
|
2539
2551
|
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for forwarded-message origin metadata — kept out of
|
|
3
|
+
* `gateway.ts` (same pattern as `coalesce-attachments.ts`) so the parsing,
|
|
4
|
+
* escaping, and numbered-sibling logic can be unit-tested without the
|
|
5
|
+
* gateway's `loadAccess()` / IPC machinery.
|
|
6
|
+
*
|
|
7
|
+
* When a user forwards a message to the bot, Telegram stamps
|
|
8
|
+
* `message.forward_origin` SERVER-side (Bot API 7.0+). Unlike the message
|
|
9
|
+
* body — which the forwarding user fully controls — the origin record
|
|
10
|
+
* cannot be forged by typing, so it is the trusted lane for "who
|
|
11
|
+
* originally sent this". The gateway surfaces it to the agent as
|
|
12
|
+
* `forwarded_*` channel-meta ATTRIBUTES only; origin info is never
|
|
13
|
+
* injected into the body text, keeping the attacker-influenceable body
|
|
14
|
+
* and the server-stamped provenance on separate lanes.
|
|
15
|
+
*
|
|
16
|
+
* Caveat surfaced via `forwarded_from_type="hidden_user"`: a sender who
|
|
17
|
+
* enabled forward privacy yields only a self-reported display name with
|
|
18
|
+
* NO verifiable id — agents can see the name but must not treat it as an
|
|
19
|
+
* authenticated identity.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { MessageOrigin } from 'grammy/types'
|
|
23
|
+
import { escapeXmlAttribute } from '../steering.js'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Cap on the human-readable origin name/title. Names and titles are
|
|
27
|
+
* attacker-controlled (a hostile account can set a 4KB display name);
|
|
28
|
+
* mirror the REPLY_TO_TEXT_MAX truncate-then-escape pattern with a cap
|
|
29
|
+
* sized for names rather than message previews.
|
|
30
|
+
*/
|
|
31
|
+
export const FORWARDED_FROM_NAME_MAX = 100
|
|
32
|
+
|
|
33
|
+
export type ForwardOriginType = 'user' | 'hidden_user' | 'chat' | 'channel'
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Normalized origin record. `name` is RAW (truncated but unescaped) so it
|
|
37
|
+
* can be persisted verbatim to the SQLite history buffer — the XML
|
|
38
|
+
* escaping happens at the channel-meta boundary in
|
|
39
|
+
* `buildForwardOriginMeta`, same split as `replyToText` /
|
|
40
|
+
* `replyToTextEscaped` in the gateway inbound handler.
|
|
41
|
+
*/
|
|
42
|
+
export interface ForwardOriginInfo {
|
|
43
|
+
/** Human-readable name/title of the original sender (raw, truncated). */
|
|
44
|
+
name: string
|
|
45
|
+
type: ForwardOriginType
|
|
46
|
+
/** Numeric id when the origin shape exposes one (user id / chat id). */
|
|
47
|
+
id?: number
|
|
48
|
+
/** Unix seconds of the original message (`forward_origin.date`). */
|
|
49
|
+
date?: number
|
|
50
|
+
/** Channel origins only: the message id inside the origin channel. */
|
|
51
|
+
messageId?: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function truncateName(name: string): string {
|
|
55
|
+
return name.length > FORWARDED_FROM_NAME_MAX
|
|
56
|
+
? name.slice(0, FORWARDED_FROM_NAME_MAX - 1) + '…'
|
|
57
|
+
: name
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Join first/last name plus a trailing `(@username)` when present. */
|
|
61
|
+
function personName(parts: {
|
|
62
|
+
first_name?: string
|
|
63
|
+
last_name?: string
|
|
64
|
+
username?: string
|
|
65
|
+
}): string {
|
|
66
|
+
const name = [parts.first_name, parts.last_name]
|
|
67
|
+
.filter((p): p is string => typeof p === 'string' && p.length > 0)
|
|
68
|
+
.join(' ')
|
|
69
|
+
const handle = parts.username ? `(@${parts.username})` : ''
|
|
70
|
+
return [name, handle].filter((p) => p.length > 0).join(' ')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Chat/channel title plus a trailing `(@username)` when present. */
|
|
74
|
+
function chatTitle(chat: {
|
|
75
|
+
title?: string
|
|
76
|
+
username?: string
|
|
77
|
+
first_name?: string
|
|
78
|
+
last_name?: string
|
|
79
|
+
}): string {
|
|
80
|
+
// Defensive: `Chat` is a union — groups/channels carry `title`, a
|
|
81
|
+
// private chat carries first/last name instead. Fall through so a
|
|
82
|
+
// malformed/unexpected shape still yields whatever name exists.
|
|
83
|
+
if (chat.title && chat.title.length > 0) {
|
|
84
|
+
const handle = chat.username ? `(@${chat.username})` : ''
|
|
85
|
+
return [chat.title, handle].filter((p) => p.length > 0).join(' ')
|
|
86
|
+
}
|
|
87
|
+
return personName(chat)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Parse Telegram's `message.forward_origin` into a normalized record.
|
|
92
|
+
* Returns `undefined` for non-forwarded messages, unknown origin types,
|
|
93
|
+
* and records so malformed that no human-readable name can be recovered
|
|
94
|
+
* (the attrs are context, not a gate — degrading to "no origin metadata"
|
|
95
|
+
* is safe; inventing a name is not).
|
|
96
|
+
*
|
|
97
|
+
* Accepts `unknown`-ish input defensively: the origin arrives from the
|
|
98
|
+
* Telegram wire and future Bot API versions may add origin types this
|
|
99
|
+
* build doesn't know.
|
|
100
|
+
*/
|
|
101
|
+
export function parseForwardOrigin(
|
|
102
|
+
origin: MessageOrigin | undefined,
|
|
103
|
+
): ForwardOriginInfo | undefined {
|
|
104
|
+
if (origin == null || typeof origin !== 'object') return undefined
|
|
105
|
+
const date = typeof origin.date === 'number' ? origin.date : undefined
|
|
106
|
+
switch (origin.type) {
|
|
107
|
+
case 'user': {
|
|
108
|
+
const u = origin.sender_user
|
|
109
|
+
if (u == null || typeof u !== 'object') return undefined
|
|
110
|
+
const name = personName(u)
|
|
111
|
+
if (name.length === 0) {
|
|
112
|
+
// No printable name at all — fall back to the (unforgeable) id.
|
|
113
|
+
if (typeof u.id !== 'number') return undefined
|
|
114
|
+
return { name: String(u.id), type: 'user', id: u.id, date }
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
name: truncateName(name),
|
|
118
|
+
type: 'user',
|
|
119
|
+
...(typeof u.id === 'number' ? { id: u.id } : {}),
|
|
120
|
+
date,
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
case 'hidden_user': {
|
|
124
|
+
// Forward-privacy senders: `sender_user_name` is a SELF-REPORTED
|
|
125
|
+
// display name with no verifiable id. The `hidden_user` type marker
|
|
126
|
+
// is the agent's signal to treat the name as unauthenticated.
|
|
127
|
+
const name = typeof origin.sender_user_name === 'string'
|
|
128
|
+
? origin.sender_user_name
|
|
129
|
+
: ''
|
|
130
|
+
if (name.length === 0) return undefined
|
|
131
|
+
return { name: truncateName(name), type: 'hidden_user', date }
|
|
132
|
+
}
|
|
133
|
+
case 'chat': {
|
|
134
|
+
const c = origin.sender_chat
|
|
135
|
+
if (c == null || typeof c !== 'object') return undefined
|
|
136
|
+
const name = chatTitle(c)
|
|
137
|
+
if (name.length === 0) {
|
|
138
|
+
if (typeof c.id !== 'number') return undefined
|
|
139
|
+
return { name: String(c.id), type: 'chat', id: c.id, date }
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
name: truncateName(name),
|
|
143
|
+
type: 'chat',
|
|
144
|
+
...(typeof c.id === 'number' ? { id: c.id } : {}),
|
|
145
|
+
date,
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
case 'channel': {
|
|
149
|
+
const c = origin.chat
|
|
150
|
+
if (c == null || typeof c !== 'object') return undefined
|
|
151
|
+
const name = chatTitle(c)
|
|
152
|
+
const messageId = typeof origin.message_id === 'number' ? origin.message_id : undefined
|
|
153
|
+
if (name.length === 0) {
|
|
154
|
+
if (typeof c.id !== 'number') return undefined
|
|
155
|
+
return { name: String(c.id), type: 'channel', id: c.id, date, messageId }
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
name: truncateName(name),
|
|
159
|
+
type: 'channel',
|
|
160
|
+
...(typeof c.id === 'number' ? { id: c.id } : {}),
|
|
161
|
+
date,
|
|
162
|
+
messageId,
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
default:
|
|
166
|
+
// Unknown future origin type — no metadata beats wrong metadata.
|
|
167
|
+
return undefined
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Identity key for burst dedup: same-typed origins with the same id are
|
|
173
|
+
* one origin; id-less shapes (hidden_user) fall back to the name.
|
|
174
|
+
*/
|
|
175
|
+
export function forwardOriginKey(o: ForwardOriginInfo): string {
|
|
176
|
+
return o.id != null ? `${o.type}:${o.id}` : `${o.type}:${o.name}`
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Collapse the per-message origins of a coalesced burst into the DISTINCT
|
|
181
|
+
* origins in arrival order. A 10-part album forwarded from one channel
|
|
182
|
+
* yields ONE origin (attrs emitted once); a burst mixing forwards from two
|
|
183
|
+
* different senders yields two (the second gets `forwarded_from_2` etc.).
|
|
184
|
+
* Non-forwarded entries (`undefined`) are skipped. First occurrence wins,
|
|
185
|
+
* so the emitted `forwarded_date` is the first message's origin date.
|
|
186
|
+
*/
|
|
187
|
+
export function dedupeForwardOrigins(
|
|
188
|
+
origins: Array<ForwardOriginInfo | undefined>,
|
|
189
|
+
): ForwardOriginInfo[] {
|
|
190
|
+
const seen = new Set<string>()
|
|
191
|
+
const out: ForwardOriginInfo[] = []
|
|
192
|
+
for (const o of origins) {
|
|
193
|
+
if (o == null) continue
|
|
194
|
+
const key = forwardOriginKey(o)
|
|
195
|
+
if (seen.has(key)) continue
|
|
196
|
+
seen.add(key)
|
|
197
|
+
out.push(o)
|
|
198
|
+
}
|
|
199
|
+
return out
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Build the `forwarded_*` channel-meta fields. Fixed per-origin attribute
|
|
204
|
+
* order (documented here, tested in forward-origin.test.ts):
|
|
205
|
+
* forwarded_from, forwarded_from_type, forwarded_from_id, forwarded_date
|
|
206
|
+
* The primary field is the human-readable NAME; the numeric id is
|
|
207
|
+
* supplementary and follows it. The first origin gets the bare keys;
|
|
208
|
+
* subsequent distinct origins get `_2`, `_3`, … suffixes — the same
|
|
209
|
+
* numbered-sibling convention as `image_path_2` / `attachment_file_id_2`.
|
|
210
|
+
*
|
|
211
|
+
* Names/titles are attacker-controlled, so every value that can carry
|
|
212
|
+
* user text goes through `escapeXmlAttribute` (the same escaper
|
|
213
|
+
* `formatReplyToText` uses) before landing in the channel tag.
|
|
214
|
+
*/
|
|
215
|
+
export function buildForwardOriginMeta(
|
|
216
|
+
origins: ForwardOriginInfo[],
|
|
217
|
+
): Record<string, string> {
|
|
218
|
+
const out: Record<string, string> = {}
|
|
219
|
+
origins.forEach((o, i) => {
|
|
220
|
+
const suffix = i === 0 ? '' : `_${i + 1}`
|
|
221
|
+
out[`forwarded_from${suffix}`] = escapeXmlAttribute(truncateName(o.name))
|
|
222
|
+
out[`forwarded_from_type${suffix}`] = o.type
|
|
223
|
+
if (o.id != null) out[`forwarded_from_id${suffix}`] = String(o.id)
|
|
224
|
+
if (o.date != null) {
|
|
225
|
+
out[`forwarded_date${suffix}`] = new Date(o.date * 1000).toISOString()
|
|
226
|
+
}
|
|
227
|
+
})
|
|
228
|
+
return out
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** ISO form of an origin date for the SQLite history record. */
|
|
232
|
+
export function forwardOriginDateIso(o: ForwardOriginInfo | undefined): string | null {
|
|
233
|
+
if (o?.date == null) return null
|
|
234
|
+
return new Date(o.date * 1000).toISOString()
|
|
235
|
+
}
|