switchroom 0.18.13 → 0.18.15
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 +49 -9
- package/dist/auth-broker/index.js +152 -46
- 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/notion-write-pretool.mjs +0 -1
- package/dist/cli/switchroom.js +1185 -1072
- package/dist/host-control/main.js +53 -52
- package/dist/vault/approvals/kernel-server.js +16 -13
- package/dist/vault/broker/server.js +672 -669
- 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 +23 -0
- package/telegram-plugin/dist/gateway/gateway.js +765 -67
- package/telegram-plugin/dist/server.js +24 -1
- package/telegram-plugin/gateway/auth-broker-client.ts +1 -0
- package/telegram-plugin/gateway/auth-command.ts +14 -0
- package/telegram-plugin/gateway/forward-origin.ts +235 -0
- package/telegram-plugin/gateway/gateway.ts +270 -10
- package/telegram-plugin/gateway/throttle-tier-wiring.ts +268 -0
- package/telegram-plugin/history.ts +55 -6
- package/telegram-plugin/model-unavailable.ts +234 -2
- package/telegram-plugin/render/rich-render.ts +40 -32
- package/telegram-plugin/runtime-metrics.ts +31 -0
- package/telegram-plugin/session-tail.ts +14 -2
- package/telegram-plugin/stream-controller.ts +3 -2
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +72 -0
- package/telegram-plugin/tests/forward-origin.test.ts +309 -0
- package/telegram-plugin/tests/history.test.ts +157 -0
- package/telegram-plugin/tests/model-unavailable.test.ts +187 -0
- package/telegram-plugin/tests/operator-events-session-tail.test.ts +55 -0
- package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +6 -4
- package/telegram-plugin/tests/render/rich-render.test.ts +41 -22
- package/telegram-plugin/tests/runtime-metrics.test.ts +24 -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/throttle-tier-wiring.test.ts +290 -0
- package/telegram-plugin/tests/throttle-tier.test.ts +454 -0
- package/telegram-plugin/throttle-tier.ts +323 -0
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +8 -7
|
@@ -1,21 +1,24 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
1
|
+
// Wiring for the Bot API 10.1 rich renderer (parse.ts + render.ts) into the
|
|
2
|
+
// live outbound send path.
|
|
3
3
|
//
|
|
4
4
|
// The renderer (`render/render.ts`, PR #2930) and parser (`render/parse.ts`)
|
|
5
5
|
// have full unit coverage but were, until this module, wired into NOTHING —
|
|
6
|
-
// no outbound message ever flowed through them. This module is the single
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
6
|
+
// no outbound message ever flowed through them. This module is the single
|
|
7
|
+
// bridge: the gateway's rich send path (stream-controller.ts) runs the
|
|
8
|
+
// assistant's raw markdown through `parse → renderSafe` before it reaches
|
|
9
|
+
// `sendRichMessage`, unless the escape hatch below disables it.
|
|
10
10
|
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
11
|
+
// Escape hatch — `SWITCHROOM_RICH_RENDER`, default ON:
|
|
12
|
+
// ON BY DEFAULT in every install — an escape hatch, not an opt-in feature,
|
|
13
|
+
// mirroring the send gate's convention (`sendGateEnabledFromEnv` in
|
|
14
|
+
// `send-gate.ts`, default-on since #3153; also `midTurnFloorEnabled`,
|
|
15
|
+
// `SWITCHROOM_RATE_LIMIT_OVERAGE=0`). Enabled unless the var is explicitly
|
|
16
|
+
// set to a falsey/off value (`0`/`false`/`off`/`no`, case-insensitive,
|
|
17
|
+
// trimmed) — per agent via the `env:` block in `switchroom.yaml`
|
|
18
|
+
// (propagated into the container `environment:` by
|
|
19
|
+
// `src/agents/compose.ts`). When disabled, `maybeRenderOutbound` returns
|
|
20
|
+
// the input untouched, so the live send path is byte-for-byte the
|
|
21
|
+
// pre-renderer behaviour (raw transcript markdown to `sendRichMessage`).
|
|
19
22
|
//
|
|
20
23
|
// Why route through `renderSafe` and not bare `render`:
|
|
21
24
|
// `renderSafe` guarantees the returned body is never a rich-markdown string
|
|
@@ -38,17 +41,19 @@ import { RICH_MESSAGE_MAX_CHARS, splitMarkdownChunks } from "../format.js";
|
|
|
38
41
|
*/
|
|
39
42
|
export const PLAIN_TEXT_MAX_CHARS = 4096;
|
|
40
43
|
|
|
41
|
-
/** Parse the `SWITCHROOM_RICH_RENDER`
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
+
/** Parse the `SWITCHROOM_RICH_RENDER` kill-switch value. Default ON; disabled
|
|
45
|
+
* only by an explicit falsey/off token (`0`/`false`/`off`/`no`,
|
|
46
|
+
* case-insensitive, trimmed) — the same disable vocabulary as
|
|
47
|
+
* `sendGateEnabledFromEnv`. Unset, empty, or unrecognised values → ON. Pure
|
|
48
|
+
* so the default + parsing are unit-testable. */
|
|
44
49
|
export function parseRichRenderEnabled(raw: string | undefined): boolean {
|
|
45
|
-
if (raw == null) return
|
|
50
|
+
if (raw == null) return true;
|
|
46
51
|
const v = raw.trim().toLowerCase();
|
|
47
|
-
return v === "
|
|
52
|
+
return !(v === "0" || v === "false" || v === "off" || v === "no");
|
|
48
53
|
}
|
|
49
54
|
|
|
50
55
|
/** Is the rich renderer enabled in this process? Reads the env flag live so a
|
|
51
|
-
* test can set/unset it per-case; defaults
|
|
56
|
+
* test can set/unset it per-case; defaults ON (escape hatch, not opt-in). */
|
|
52
57
|
export function richRenderEnabled(
|
|
53
58
|
env: NodeJS.ProcessEnv = process.env,
|
|
54
59
|
): boolean {
|
|
@@ -65,13 +70,14 @@ export function renderOutbound(
|
|
|
65
70
|
}
|
|
66
71
|
|
|
67
72
|
/**
|
|
68
|
-
*
|
|
73
|
+
* Kill-switch-gated transform for the live send path.
|
|
69
74
|
*
|
|
70
|
-
* -
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
* -
|
|
74
|
-
*
|
|
75
|
+
* - enabled (default): returns `parse → renderSafe` output. `mode: "plain"`
|
|
76
|
+
* signals the caller to send WITHOUT the rich wrapper (oversized/unsafe
|
|
77
|
+
* content).
|
|
78
|
+
* - disabled (`SWITCHROOM_RICH_RENDER=0`): returns the input untouched,
|
|
79
|
+
* `mode: "markdown"` — identical to the pre-renderer behaviour (raw
|
|
80
|
+
* transcript markdown sent straight to `sendRichMessage`).
|
|
75
81
|
*/
|
|
76
82
|
export function maybeRenderOutbound(
|
|
77
83
|
text: string,
|
|
@@ -83,7 +89,7 @@ export function maybeRenderOutbound(
|
|
|
83
89
|
}
|
|
84
90
|
|
|
85
91
|
/**
|
|
86
|
-
*
|
|
92
|
+
* Kill-switch-gated, CAP-ENFORCING transform for the live send path.
|
|
87
93
|
*
|
|
88
94
|
* `maybeRenderOutbound` returns ONE `RenderResult` and can only ever fit a
|
|
89
95
|
* body into a single wire message. But `renderSafe`'s markdown re-escaping
|
|
@@ -104,11 +110,13 @@ export function maybeRenderOutbound(
|
|
|
104
110
|
* plain degradation would have thrown away: the smaller pieces individually
|
|
105
111
|
* escape under `maxLen` and come back as `markdown`.
|
|
106
112
|
*
|
|
107
|
-
* -
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
113
|
+
* - disabled (`SWITCHROOM_RICH_RENDER=0`):
|
|
114
|
+
* `[{ text, mode: "markdown", degradations: [] }]` — a single passthrough
|
|
115
|
+
* piece, identical to `maybeRenderOutbound`.
|
|
116
|
+
* - enabled (default), body fits: `[renderSafe(...)]` — a single piece,
|
|
117
|
+
* identical to `maybeRenderOutbound` (byte-for-byte for the common case).
|
|
118
|
+
* - enabled (default), body oversize: 2+ cap-respecting pieces in send
|
|
119
|
+
* order.
|
|
112
120
|
*/
|
|
113
121
|
export function renderOutboundChunks(
|
|
114
122
|
text: string,
|
|
@@ -164,6 +164,37 @@ export type RuntimeMetricEvent =
|
|
|
164
164
|
agent: string
|
|
165
165
|
prompt_key: string
|
|
166
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* Every terminal rate-limit-family operator event (429 burst / 529
|
|
169
|
+
* overload), classified by origin BEFORE the gateway acts on it — fires
|
|
170
|
+
* even when the user-facing card is cooldown-suppressed, so the count is
|
|
171
|
+
* honest. `classification` says where the limit lives (see
|
|
172
|
+
* `RateLimit429Classification` in throttle-tier.ts): `account-scoped` =
|
|
173
|
+
* Anthropic throttled the account (throttle tier ran), `litellm-local` =
|
|
174
|
+
* the LiteLLM proxy's own tpm/rpm/router limiter tripped (calm path, no
|
|
175
|
+
* account attribution), `generic-transient` = other server-side 429/529
|
|
176
|
+
* wording. `action` is what the gateway DECIDED (throttle / failover /
|
|
177
|
+
* calm), emitted PRE-execution: the actual failover fire is dedup-gated
|
|
178
|
+
* downstream (fleetFallbackGate), so N long-reset 429s inside one dedup
|
|
179
|
+
* window emit N `action: 'failover'` metrics for ONE real roll — count
|
|
180
|
+
* decisions here, count rolls via the broker/fallback announcements.
|
|
181
|
+
* Correlating `account-scoped` fires against fleet token throughput is
|
|
182
|
+
* the operator's evidence base for setting LiteLLM `tpm_limit` caps; the
|
|
183
|
+
* `litellm-local` count then shows those caps actually absorbing load.
|
|
184
|
+
* Limit/reset fields are best-effort parses of the error body (null when
|
|
185
|
+
* absent).
|
|
186
|
+
*/
|
|
187
|
+
| {
|
|
188
|
+
kind: 'rate_limit_429_classified'
|
|
189
|
+
agent: string
|
|
190
|
+
classification: 'account-scoped' | 'litellm-local' | 'generic-transient'
|
|
191
|
+
action: 'throttle' | 'failover' | 'calm'
|
|
192
|
+
reset_at_ms: number | null
|
|
193
|
+
reset_in_ms: number | null
|
|
194
|
+
limit_type: string | null
|
|
195
|
+
limit: number | null
|
|
196
|
+
current_usage: number | null
|
|
197
|
+
}
|
|
167
198
|
|
|
168
199
|
/**
|
|
169
200
|
* The JSONL sink lives under the runtime state dir so it's per-agent
|
|
@@ -41,7 +41,7 @@ function isMultiAgentEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
|
41
41
|
return env.PROGRESS_CARD_MULTI_AGENT !== '0'
|
|
42
42
|
}
|
|
43
43
|
import { classifyClaudeError, type OperatorEventKind } from './operator-events.js'
|
|
44
|
-
import { isTransientUpstreamSignal } from './model-unavailable.js'
|
|
44
|
+
import { isLitellmProxyLocal429, isTransientUpstreamSignal } from './model-unavailable.js'
|
|
45
45
|
import { createToolLabelSidecar, type ToolLabelSidecar, type SidecarOptions } from './tool-label-sidecar.js'
|
|
46
46
|
import { isModelSentinel } from './model-label.js'
|
|
47
47
|
|
|
@@ -684,9 +684,21 @@ export function detectErrorInTranscriptLine(
|
|
|
684
684
|
// model-unavailable.ts) — an ambiguous 429 that merely says "limit" stays
|
|
685
685
|
// quota-exhausted, biasing toward surfacing a real wall. Other statuses fall
|
|
686
686
|
// through to the shared classifier.
|
|
687
|
+
//
|
|
688
|
+
// A 429 carrying LiteLLM-proxy-LOCAL limiter wording ("Deployment over
|
|
689
|
+
// user-defined ratelimit", "Rate limit exceeded for api_key: …" — the
|
|
690
|
+
// canonical `litellmProxyLocal429Signals` list) is the proxy's own
|
|
691
|
+
// `tpm_limit`/`rpm_limit` cap tripping BEFORE the request reached
|
|
692
|
+
// Anthropic. Nothing about the account is exhausted — blanket-labeling it
|
|
693
|
+
// quota-exhausted would fire the model-unavailable card + mark-exhausted +
|
|
694
|
+
// fleet failover for a purely proxy-local condition. It is rate-limited
|
|
695
|
+
// (calm path). Precedence when BOTH wordings appear is owned by
|
|
696
|
+
// `classify429Detail` gateway-side; here both branches yield the same
|
|
697
|
+
// 'rate-limited' kind, so order is immaterial.
|
|
687
698
|
const kind: OperatorEventKind =
|
|
688
699
|
status === 429
|
|
689
|
-
? isTransientUpstreamSignal(`${text}\n${errStr}`)
|
|
700
|
+
? isTransientUpstreamSignal(`${text}\n${errStr}`) ||
|
|
701
|
+
isLitellmProxyLocal429(`${text}\n${errStr}`)
|
|
690
702
|
? 'rate-limited'
|
|
691
703
|
: 'quota-exhausted'
|
|
692
704
|
: classifyClaudeError({ type: errStr, status, message: text })
|
|
@@ -228,8 +228,9 @@ export function createStreamController(cfg: StreamControllerConfig): DraftStream
|
|
|
228
228
|
// `maybeRenderOutbound` path; a body whose markdown-escaping pushed the
|
|
229
229
|
// rendered form past the cap splits into several pieces, each of which fits
|
|
230
230
|
// its own wire cap and never bisects a fenced block / table row (see
|
|
231
|
-
// `renderOutboundChunks`).
|
|
232
|
-
// stream both yield a single passthrough
|
|
231
|
+
// `renderOutboundChunks`). Rendering disabled (SWITCHROOM_RICH_RENDER=0)
|
|
232
|
+
// and a literal `format:'text'` stream both yield a single passthrough
|
|
233
|
+
// piece — byte-for-byte the pre-renderer send path.
|
|
233
234
|
const renderPieces = (text: string): { text: string; rich: boolean }[] => {
|
|
234
235
|
if (literalText) return [{ text, rich: false }]
|
|
235
236
|
// A `plain`-mode piece (oversized/unsafe content renderSafe declined to
|
|
@@ -118,6 +118,78 @@ describe('runFleetAutoFallback', () => {
|
|
|
118
118
|
}
|
|
119
119
|
});
|
|
120
120
|
|
|
121
|
+
it('parsedResetAt (429 throttle tier) names the recovery when the old probe carried no reset', async () => {
|
|
122
|
+
const failover = vi.fn(async () => ({ rolledTo: 'you@x', rolled: ['ken@x'] }));
|
|
123
|
+
const out = await runFleetAutoFallback({
|
|
124
|
+
state: state('ken@x', ['ken@x', 'you@x']),
|
|
125
|
+
quotas: [
|
|
126
|
+
// ken: walled, but the probe carried NO reset time — pre-fix the
|
|
127
|
+
// announcement's recovery line was silently dropped.
|
|
128
|
+
qOk({ fiveHourUtilizationPct: 100, representativeClaim: 'five_hour' }),
|
|
129
|
+
qOk({ fiveHourUtilizationPct: 8, sevenDayUtilizationPct: 20 }),
|
|
130
|
+
],
|
|
131
|
+
failover,
|
|
132
|
+
triggerAgent: 'carrie',
|
|
133
|
+
now: NOW,
|
|
134
|
+
tz: 'UTC',
|
|
135
|
+
// Parsed from the error prose ("resets 5:50am") by the gateway.
|
|
136
|
+
parsedResetAt: new Date('2026-05-15T05:50:00Z'),
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
expect(out.kind).toBe('switched');
|
|
140
|
+
if (out.kind === 'switched') {
|
|
141
|
+
expect(out.announcement).toContain('recovers');
|
|
142
|
+
expect(out.announcement).toContain('in 4h 57m');
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('rateLimitTrigger: swaps even when the old account probes HEALTHY (the >threshold leg must execute)', async () => {
|
|
147
|
+
// A terminal transient 429 NEGATES the usage-limit reading, so healthy
|
|
148
|
+
// utilization is the EXPECTED state for the rate-limited account. The
|
|
149
|
+
// healthy-idempotency guard must not self-cancel this swap into a
|
|
150
|
+
// "probed healthy / Stale event?" no-op.
|
|
151
|
+
const failover = vi.fn(async () => ({ rolledTo: 'you@x', rolled: ['ken@x'] }));
|
|
152
|
+
const out = await runFleetAutoFallback({
|
|
153
|
+
state: state('ken@x', ['ken@x', 'you@x']),
|
|
154
|
+
quotas: [
|
|
155
|
+
qOk({ fiveHourUtilizationPct: 12, sevenDayUtilizationPct: 30 }), // healthy!
|
|
156
|
+
qOk({ fiveHourUtilizationPct: 8, sevenDayUtilizationPct: 20 }),
|
|
157
|
+
],
|
|
158
|
+
failover,
|
|
159
|
+
triggerAgent: 'carrie',
|
|
160
|
+
now: NOW,
|
|
161
|
+
tz: 'UTC',
|
|
162
|
+
parsedResetAt: new Date('2026-05-15T02:53:00Z'),
|
|
163
|
+
rateLimitTrigger: true,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
expect(out.kind).toBe('switched');
|
|
167
|
+
expect(failover).toHaveBeenCalledTimes(1);
|
|
168
|
+
if (out.kind === 'switched') {
|
|
169
|
+
// Honest headline: a rate limit, not a utilization-derived window cap.
|
|
170
|
+
expect(out.announcement).toContain('rate limit on ken@x');
|
|
171
|
+
expect(out.announcement).not.toContain('5-hour limit');
|
|
172
|
+
// Recovery line carries the parsed reset (no window was maxed).
|
|
173
|
+
expect(out.announcement).toContain('recovers');
|
|
174
|
+
expect(out.announcement).toContain('in 2h');
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('rateLimitTrigger stays subject to the broker outcome (all-blocked passes through)', async () => {
|
|
179
|
+
const failover = vi.fn(async () => ({ rolledTo: null, rolled: [] }));
|
|
180
|
+
const out = await runFleetAutoFallback({
|
|
181
|
+
state: state('ken@x', ['ken@x']),
|
|
182
|
+
quotas: [qOk({ fiveHourUtilizationPct: 12, sevenDayUtilizationPct: 30 })],
|
|
183
|
+
failover,
|
|
184
|
+
triggerAgent: 'carrie',
|
|
185
|
+
now: NOW,
|
|
186
|
+
tz: 'UTC',
|
|
187
|
+
rateLimitTrigger: true,
|
|
188
|
+
});
|
|
189
|
+
expect(out.kind).toBe('all-blocked');
|
|
190
|
+
expect(failover).toHaveBeenCalledTimes(1);
|
|
191
|
+
});
|
|
192
|
+
|
|
121
193
|
it('idempotency: skips the swap WITHOUT calling failover when active probes healthy', async () => {
|
|
122
194
|
const failover = vi.fn();
|
|
123
195
|
const out = await runFleetAutoFallback({
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the forwarded-message origin helpers
|
|
3
|
+
* (telegram-plugin/gateway/forward-origin.ts).
|
|
4
|
+
*
|
|
5
|
+
* These pin the pure pieces of the forward-origin metadata path that live
|
|
6
|
+
* outside gateway.ts so they can be exercised without loadAccess()/IPC:
|
|
7
|
+
* 1. parseForwardOrigin — all four Bot API 7.0 origin shapes, missing
|
|
8
|
+
* fields, hostile (attacker-controlled) names, hidden_user marking.
|
|
9
|
+
* 2. buildForwardOriginMeta — fixed attribute order, XML escaping at the
|
|
10
|
+
* channel-meta boundary, numbered `_2..` siblings for multi-origin
|
|
11
|
+
* bursts (same convention as image_path_2 / attachment_file_id_2).
|
|
12
|
+
* 3. dedupeForwardOrigins — the coalescer's burst collapse: a
|
|
13
|
+
* single-origin album emits one attr set; distinct origins keep
|
|
14
|
+
* arrival order and get numbered.
|
|
15
|
+
*
|
|
16
|
+
* Trust model under test: origin info rides the ATTRS lane only, names are
|
|
17
|
+
* truncated + escaped (they're attacker-controlled), and hidden_user is
|
|
18
|
+
* explicitly marked so agents don't treat a self-reported display name as
|
|
19
|
+
* an authenticated identity.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { describe, expect, it } from 'vitest'
|
|
23
|
+
import type { MessageOrigin } from 'grammy/types'
|
|
24
|
+
import {
|
|
25
|
+
parseForwardOrigin,
|
|
26
|
+
buildForwardOriginMeta,
|
|
27
|
+
dedupeForwardOrigins,
|
|
28
|
+
forwardOriginKey,
|
|
29
|
+
forwardOriginDateIso,
|
|
30
|
+
FORWARDED_FROM_NAME_MAX,
|
|
31
|
+
type ForwardOriginInfo,
|
|
32
|
+
} from '../gateway/forward-origin.js'
|
|
33
|
+
|
|
34
|
+
// Synthetic fixtures only — no real Telegram ids/names (check-no-pii-secrets).
|
|
35
|
+
const DATE = 1750000000 // unix seconds
|
|
36
|
+
const DATE_ISO = new Date(DATE * 1000).toISOString()
|
|
37
|
+
|
|
38
|
+
function userOrigin(overrides: Partial<{
|
|
39
|
+
first_name: string
|
|
40
|
+
last_name?: string
|
|
41
|
+
username?: string
|
|
42
|
+
id: number
|
|
43
|
+
}> = {}): MessageOrigin {
|
|
44
|
+
return {
|
|
45
|
+
type: 'user',
|
|
46
|
+
date: DATE,
|
|
47
|
+
sender_user: {
|
|
48
|
+
id: 42,
|
|
49
|
+
is_bot: false,
|
|
50
|
+
first_name: 'Ada',
|
|
51
|
+
last_name: 'Lovelace',
|
|
52
|
+
username: 'adalove',
|
|
53
|
+
...overrides,
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('parseForwardOrigin — the four origin shapes', () => {
|
|
59
|
+
it('user: first + last name plus (@username), id and date captured', () => {
|
|
60
|
+
const info = parseForwardOrigin(userOrigin())
|
|
61
|
+
expect(info).toEqual({
|
|
62
|
+
name: 'Ada Lovelace (@adalove)',
|
|
63
|
+
type: 'user',
|
|
64
|
+
id: 42,
|
|
65
|
+
date: DATE,
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('user: first name only, no username', () => {
|
|
70
|
+
const info = parseForwardOrigin(userOrigin({ last_name: undefined, username: undefined }))
|
|
71
|
+
expect(info?.name).toBe('Ada')
|
|
72
|
+
expect(info?.id).toBe(42)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('hidden_user: self-reported name, marked hidden_user, NO id', () => {
|
|
76
|
+
const info = parseForwardOrigin({
|
|
77
|
+
type: 'hidden_user',
|
|
78
|
+
date: DATE,
|
|
79
|
+
sender_user_name: 'Mystery Sender',
|
|
80
|
+
})
|
|
81
|
+
expect(info).toEqual({
|
|
82
|
+
name: 'Mystery Sender',
|
|
83
|
+
type: 'hidden_user',
|
|
84
|
+
date: DATE,
|
|
85
|
+
})
|
|
86
|
+
// The whole point of the type marker: no verifiable id exists.
|
|
87
|
+
expect(info?.id).toBeUndefined()
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('chat: sender_chat title plus (@username)', () => {
|
|
91
|
+
const info = parseForwardOrigin({
|
|
92
|
+
type: 'chat',
|
|
93
|
+
date: DATE,
|
|
94
|
+
sender_chat: { id: -100200300, type: 'supergroup', title: 'Ops Room', username: 'opsroom' } as never,
|
|
95
|
+
})
|
|
96
|
+
expect(info).toEqual({
|
|
97
|
+
name: 'Ops Room (@opsroom)',
|
|
98
|
+
type: 'chat',
|
|
99
|
+
id: -100200300,
|
|
100
|
+
date: DATE,
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('channel: chat.title plus (@username), message_id captured', () => {
|
|
105
|
+
const info = parseForwardOrigin({
|
|
106
|
+
type: 'channel',
|
|
107
|
+
date: DATE,
|
|
108
|
+
message_id: 555,
|
|
109
|
+
chat: { id: -100400500, type: 'channel', title: 'Release Notes', username: 'relnotes' } as never,
|
|
110
|
+
})
|
|
111
|
+
expect(info).toEqual({
|
|
112
|
+
name: 'Release Notes (@relnotes)',
|
|
113
|
+
type: 'channel',
|
|
114
|
+
id: -100400500,
|
|
115
|
+
date: DATE,
|
|
116
|
+
messageId: 555,
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
describe('parseForwardOrigin — missing / malformed fields', () => {
|
|
122
|
+
it('returns undefined for a non-forwarded message (no origin)', () => {
|
|
123
|
+
expect(parseForwardOrigin(undefined)).toBeUndefined()
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('returns undefined for an unknown future origin type', () => {
|
|
127
|
+
expect(parseForwardOrigin({ type: 'giveaway', date: DATE } as never)).toBeUndefined()
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('user origin without sender_user is dropped', () => {
|
|
131
|
+
expect(parseForwardOrigin({ type: 'user', date: DATE } as never)).toBeUndefined()
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('hidden_user with an empty name is dropped (nothing to show)', () => {
|
|
135
|
+
expect(
|
|
136
|
+
parseForwardOrigin({ type: 'hidden_user', date: DATE, sender_user_name: '' }),
|
|
137
|
+
).toBeUndefined()
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('user with no printable name falls back to the numeric id as name', () => {
|
|
141
|
+
const info = parseForwardOrigin({
|
|
142
|
+
type: 'user',
|
|
143
|
+
date: DATE,
|
|
144
|
+
sender_user: { id: 42, is_bot: false, first_name: '' },
|
|
145
|
+
})
|
|
146
|
+
expect(info).toEqual({ name: '42', type: 'user', id: 42, date: DATE })
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('missing date yields no date (and later no forwarded_date attr)', () => {
|
|
150
|
+
const info = parseForwardOrigin({
|
|
151
|
+
type: 'hidden_user',
|
|
152
|
+
sender_user_name: 'Mystery Sender',
|
|
153
|
+
} as never)
|
|
154
|
+
expect(info?.date).toBeUndefined()
|
|
155
|
+
expect(buildForwardOriginMeta([info!]).forwarded_date).toBeUndefined()
|
|
156
|
+
expect(forwardOriginDateIso(info)).toBeNull()
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('channel origin without a title falls back to first/last name shape, then id', () => {
|
|
160
|
+
const noName = parseForwardOrigin({
|
|
161
|
+
type: 'channel',
|
|
162
|
+
date: DATE,
|
|
163
|
+
message_id: 9,
|
|
164
|
+
chat: { id: -100777888, type: 'channel' } as never,
|
|
165
|
+
})
|
|
166
|
+
expect(noName).toEqual({
|
|
167
|
+
name: '-100777888',
|
|
168
|
+
type: 'channel',
|
|
169
|
+
id: -100777888,
|
|
170
|
+
date: DATE,
|
|
171
|
+
messageId: 9,
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
describe('parseForwardOrigin — hostile names (attacker-controlled)', () => {
|
|
177
|
+
it('truncates a 4KB name to the cap (raw, ellipsis-terminated)', () => {
|
|
178
|
+
const bomb = 'x'.repeat(4096)
|
|
179
|
+
const info = parseForwardOrigin(userOrigin({ first_name: bomb, last_name: undefined, username: undefined }))
|
|
180
|
+
expect(info?.name).toHaveLength(FORWARDED_FROM_NAME_MAX)
|
|
181
|
+
expect(info?.name?.endsWith('…')).toBe(true)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('keeps XML metacharacters RAW in the parsed record (SQLite lane)', () => {
|
|
185
|
+
const info = parseForwardOrigin(
|
|
186
|
+
userOrigin({ first_name: '<b>"Bob"&\'friends\'</b>', last_name: undefined, username: undefined }),
|
|
187
|
+
)
|
|
188
|
+
// Escaping happens at the channel-meta boundary, not at parse time —
|
|
189
|
+
// the history buffer stores what the user actually saw.
|
|
190
|
+
expect(info?.name).toBe('<b>"Bob"&\'friends\'</b>')
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
describe('buildForwardOriginMeta — channel-tag attrs', () => {
|
|
195
|
+
it('single origin: bare keys in the fixed order name, type, id, date', () => {
|
|
196
|
+
const meta = buildForwardOriginMeta([parseForwardOrigin(userOrigin())!])
|
|
197
|
+
expect(Object.keys(meta)).toEqual([
|
|
198
|
+
'forwarded_from',
|
|
199
|
+
'forwarded_from_type',
|
|
200
|
+
'forwarded_from_id',
|
|
201
|
+
'forwarded_date',
|
|
202
|
+
])
|
|
203
|
+
expect(meta).toEqual({
|
|
204
|
+
forwarded_from: 'Ada Lovelace (@adalove)',
|
|
205
|
+
forwarded_from_type: 'user',
|
|
206
|
+
forwarded_from_id: '42',
|
|
207
|
+
forwarded_date: DATE_ISO,
|
|
208
|
+
})
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('id-less origin (hidden_user) omits forwarded_from_id entirely', () => {
|
|
212
|
+
const meta = buildForwardOriginMeta([
|
|
213
|
+
{ name: 'Mystery Sender', type: 'hidden_user', date: DATE },
|
|
214
|
+
])
|
|
215
|
+
expect(Object.keys(meta)).toEqual([
|
|
216
|
+
'forwarded_from',
|
|
217
|
+
'forwarded_from_type',
|
|
218
|
+
'forwarded_date',
|
|
219
|
+
])
|
|
220
|
+
expect(meta.forwarded_from_type).toBe('hidden_user')
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('XML-escapes hostile names — the rendered attribute value cannot break out', () => {
|
|
224
|
+
const meta = buildForwardOriginMeta([
|
|
225
|
+
{ name: '<script>"a"&\'b\'</script>', type: 'user', id: 42, date: DATE },
|
|
226
|
+
])
|
|
227
|
+
expect(meta.forwarded_from).toBe(
|
|
228
|
+
'<script>"a"&'b'</script>',
|
|
229
|
+
)
|
|
230
|
+
// Outcome assertion: rendered into a channel tag, the value carries no
|
|
231
|
+
// raw quote/angle characters that could terminate the attribute.
|
|
232
|
+
const rendered = `<channel source="telegram" forwarded_from="${meta.forwarded_from}">`
|
|
233
|
+
expect(rendered).not.toMatch(/forwarded_from="[^"]*[<>][^"]*"/)
|
|
234
|
+
expect((rendered.match(/"/g) ?? []).length).toBe(4) // only the delimiters
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('defense in depth: a name that skipped parse-time truncation is capped here too', () => {
|
|
238
|
+
const meta = buildForwardOriginMeta([
|
|
239
|
+
{ name: 'y'.repeat(4096), type: 'user', id: 42, date: DATE },
|
|
240
|
+
])
|
|
241
|
+
// 99 chars + '…' — no 4KB payload reaches the tag.
|
|
242
|
+
expect(meta.forwarded_from).toHaveLength(FORWARDED_FROM_NAME_MAX)
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('no origins → empty record (no attrs on a normal message)', () => {
|
|
246
|
+
expect(buildForwardOriginMeta([])).toEqual({})
|
|
247
|
+
})
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
describe('coalesced bursts — dedupe + numbered siblings', () => {
|
|
251
|
+
const alice: ForwardOriginInfo = { name: 'Alice Q (@aliceq)', type: 'user', id: 42, date: DATE }
|
|
252
|
+
const relnotes: ForwardOriginInfo = {
|
|
253
|
+
name: 'Release Notes (@relnotes)',
|
|
254
|
+
type: 'channel',
|
|
255
|
+
id: -100400500,
|
|
256
|
+
date: DATE + 60,
|
|
257
|
+
messageId: 7,
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
it('single-origin album (N parts, one sender) collapses to ONE attr set', () => {
|
|
261
|
+
const origins = dedupeForwardOrigins([alice, { ...alice, date: DATE + 5 }, { ...alice, date: DATE + 9 }])
|
|
262
|
+
expect(origins).toHaveLength(1)
|
|
263
|
+
const meta = buildForwardOriginMeta(origins)
|
|
264
|
+
expect(meta.forwarded_from).toBe('Alice Q (@aliceq)')
|
|
265
|
+
expect(meta.forwarded_from_2).toBeUndefined()
|
|
266
|
+
// First occurrence wins — the emitted date is the first part's.
|
|
267
|
+
expect(meta.forwarded_date).toBe(DATE_ISO)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it('multi-origin burst: first origin bare, second gets _2 keys in order', () => {
|
|
271
|
+
const meta = buildForwardOriginMeta(dedupeForwardOrigins([alice, relnotes]))
|
|
272
|
+
expect(Object.keys(meta)).toEqual([
|
|
273
|
+
'forwarded_from',
|
|
274
|
+
'forwarded_from_type',
|
|
275
|
+
'forwarded_from_id',
|
|
276
|
+
'forwarded_date',
|
|
277
|
+
'forwarded_from_2',
|
|
278
|
+
'forwarded_from_type_2',
|
|
279
|
+
'forwarded_from_id_2',
|
|
280
|
+
'forwarded_date_2',
|
|
281
|
+
])
|
|
282
|
+
expect(meta.forwarded_from_2).toBe('Release Notes (@relnotes)')
|
|
283
|
+
expect(meta.forwarded_from_type_2).toBe('channel')
|
|
284
|
+
expect(meta.forwarded_from_id_2).toBe('-100400500')
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it('non-forwarded entries in the burst are skipped', () => {
|
|
288
|
+
expect(dedupeForwardOrigins([undefined, alice, undefined])).toEqual([alice])
|
|
289
|
+
expect(dedupeForwardOrigins([undefined, undefined])).toEqual([])
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
it('id-less origins dedupe by name; same type+id dedupes despite name drift', () => {
|
|
293
|
+
const hidden: ForwardOriginInfo = { name: 'Mystery Sender', type: 'hidden_user', date: DATE }
|
|
294
|
+
expect(dedupeForwardOrigins([hidden, { ...hidden, date: DATE + 3 }])).toHaveLength(1)
|
|
295
|
+
// Same user id, renamed mid-burst — still one origin.
|
|
296
|
+
expect(dedupeForwardOrigins([alice, { ...alice, name: 'Alice Renamed' }])).toHaveLength(1)
|
|
297
|
+
// Same numeric id but different type stays distinct.
|
|
298
|
+
expect(forwardOriginKey(alice)).not.toBe(forwardOriginKey({ ...alice, type: 'chat' }))
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
it('arrival order is preserved across three distinct origins', () => {
|
|
302
|
+
const hidden: ForwardOriginInfo = { name: 'Mystery Sender', type: 'hidden_user', date: DATE }
|
|
303
|
+
const meta = buildForwardOriginMeta(dedupeForwardOrigins([relnotes, hidden, alice]))
|
|
304
|
+
expect(meta.forwarded_from).toBe('Release Notes (@relnotes)')
|
|
305
|
+
expect(meta.forwarded_from_2).toBe('Mystery Sender')
|
|
306
|
+
expect(meta.forwarded_from_3).toBe('Alice Q (@aliceq)')
|
|
307
|
+
expect(meta.forwarded_from_type_3).toBe('user')
|
|
308
|
+
})
|
|
309
|
+
})
|