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.
Files changed (76) hide show
  1. package/dist/agent-scheduler/index.js +57 -9
  2. package/dist/auth-broker/index.js +174 -72
  3. package/dist/cli/autoaccept-poll.js +23 -0
  4. package/dist/cli/drive-write-pretool.mjs +24 -1
  5. package/dist/cli/foreground-hog-pretool.mjs +264 -0
  6. package/dist/cli/ms-365-write-pretool.mjs +31 -8
  7. package/dist/cli/notion-write-pretool.mjs +9 -2
  8. package/dist/cli/skill-validate-pretool.mjs +144 -2847
  9. package/dist/cli/switchroom.js +986 -3131
  10. package/dist/host-control/main.js +216 -2863
  11. package/dist/vault/approvals/kernel-server.js +67 -1
  12. package/dist/vault/broker/server.js +98 -45
  13. package/package.json +1 -1
  14. package/profiles/coding/CLAUDE.md.hbs +2 -0
  15. package/profiles/default/CLAUDE.md.hbs +2 -0
  16. package/skills/switchroom-architecture/telegram.md +0 -1
  17. package/telegram-plugin/auth-snapshot-format.ts +37 -5
  18. package/telegram-plugin/auto-fallback-fleet.ts +29 -1
  19. package/telegram-plugin/bridge/bridge.ts +2 -0
  20. package/telegram-plugin/dist/bridge/bridge.js +51 -3
  21. package/telegram-plugin/dist/gateway/gateway.js +1251 -2368
  22. package/telegram-plugin/dist/server.js +67 -3
  23. package/telegram-plugin/format.ts +19 -0
  24. package/telegram-plugin/gateway/approval-hold.ts +21 -2
  25. package/telegram-plugin/gateway/auth-broker-client.ts +1 -0
  26. package/telegram-plugin/gateway/auth-command.ts +14 -0
  27. package/telegram-plugin/gateway/callback-query-handlers.ts +12 -0
  28. package/telegram-plugin/gateway/forward-origin.ts +235 -0
  29. package/telegram-plugin/gateway/gateway.ts +445 -83
  30. package/telegram-plugin/gateway/throttle-tier-wiring.ts +268 -0
  31. package/telegram-plugin/history.ts +106 -6
  32. package/telegram-plugin/inline-keyboard-callbacks.ts +94 -0
  33. package/telegram-plugin/model-unavailable.ts +61 -13
  34. package/telegram-plugin/outbound-field-redact.ts +69 -0
  35. package/telegram-plugin/render/render.ts +32 -14
  36. package/telegram-plugin/render/rich-render.ts +40 -32
  37. package/telegram-plugin/scoped-approval.ts +11 -2
  38. package/telegram-plugin/secret-detect/chunker.ts +18 -4
  39. package/telegram-plugin/secret-detect/index.ts +12 -56
  40. package/telegram-plugin/send-gate-degraded.test.ts +131 -0
  41. package/telegram-plugin/send-gate.test.ts +25 -6
  42. package/telegram-plugin/send-gate.ts +82 -8
  43. package/telegram-plugin/session-tail.ts +82 -7
  44. package/telegram-plugin/stream-controller.ts +3 -2
  45. package/telegram-plugin/subagent-watcher.ts +71 -16
  46. package/telegram-plugin/tests/approval-hold-outcome.test.ts +36 -5
  47. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +72 -0
  48. package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
  49. package/telegram-plugin/tests/forward-origin.test.ts +309 -0
  50. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
  51. package/telegram-plugin/tests/history.test.ts +272 -0
  52. package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
  53. package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +164 -0
  54. package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
  55. package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
  56. package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
  57. package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +6 -4
  58. package/telegram-plugin/tests/render/render.test.ts +88 -0
  59. package/telegram-plugin/tests/render/rich-render.test.ts +41 -22
  60. package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
  61. package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
  62. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
  63. package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
  64. package/telegram-plugin/tests/single-mode-stream-reply.test.ts +5 -3
  65. package/telegram-plugin/tests/status-accent.test.ts +5 -3
  66. package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +20 -20
  67. package/telegram-plugin/tests/stream-reply-handler.test.ts +5 -2
  68. package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
  69. package/telegram-plugin/tests/throttle-tier-wiring.test.ts +290 -0
  70. package/telegram-plugin/tests/throttle-tier.test.ts +278 -0
  71. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
  72. package/telegram-plugin/throttle-tier.ts +226 -0
  73. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +8 -7
  74. package/telegram-plugin/worktree-watch-cwds.ts +194 -5
  75. package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
  76. 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 ? "quota-exhausted" : classifyClaudeError({ type: errStr, status, message: text });
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 = createToolLabelSidecar({ stateDir: stateDirForSidecar, sessionId });
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
- * web mounts all of `~/.switchroom`, so both locations are reachable.
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
+ }