switchroom 0.18.13 → 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 (42) hide show
  1. package/dist/agent-scheduler/index.js +49 -9
  2. package/dist/auth-broker/index.js +111 -7
  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/notion-write-pretool.mjs +0 -1
  7. package/dist/cli/switchroom.js +35 -6
  8. package/dist/host-control/main.js +1 -2
  9. package/dist/vault/approvals/kernel-server.js +0 -1
  10. package/dist/vault/broker/server.js +0 -1
  11. package/package.json +1 -1
  12. package/profiles/coding/CLAUDE.md.hbs +2 -0
  13. package/profiles/default/CLAUDE.md.hbs +2 -0
  14. package/skills/switchroom-architecture/telegram.md +0 -1
  15. package/telegram-plugin/auth-snapshot-format.ts +37 -5
  16. package/telegram-plugin/auto-fallback-fleet.ts +29 -1
  17. package/telegram-plugin/bridge/bridge.ts +2 -0
  18. package/telegram-plugin/dist/bridge/bridge.js +2 -0
  19. package/telegram-plugin/dist/gateway/gateway.js +620 -67
  20. package/telegram-plugin/dist/server.js +2 -0
  21. package/telegram-plugin/gateway/auth-broker-client.ts +1 -0
  22. package/telegram-plugin/gateway/auth-command.ts +14 -0
  23. package/telegram-plugin/gateway/forward-origin.ts +235 -0
  24. package/telegram-plugin/gateway/gateway.ts +224 -10
  25. package/telegram-plugin/gateway/throttle-tier-wiring.ts +268 -0
  26. package/telegram-plugin/history.ts +55 -6
  27. package/telegram-plugin/model-unavailable.ts +20 -2
  28. package/telegram-plugin/render/rich-render.ts +40 -32
  29. package/telegram-plugin/stream-controller.ts +3 -2
  30. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +72 -0
  31. package/telegram-plugin/tests/forward-origin.test.ts +309 -0
  32. package/telegram-plugin/tests/history.test.ts +157 -0
  33. package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +6 -4
  34. package/telegram-plugin/tests/render/rich-render.test.ts +41 -22
  35. package/telegram-plugin/tests/single-mode-stream-reply.test.ts +5 -3
  36. package/telegram-plugin/tests/status-accent.test.ts +5 -3
  37. package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +20 -20
  38. package/telegram-plugin/tests/stream-reply-handler.test.ts +5 -2
  39. package/telegram-plugin/tests/throttle-tier-wiring.test.ts +290 -0
  40. package/telegram-plugin/tests/throttle-tier.test.ts +278 -0
  41. package/telegram-plugin/throttle-tier.ts +226 -0
  42. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +8 -7
@@ -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
+ '&lt;script&gt;&quot;a&quot;&amp;&apos;b&apos;&lt;/script&gt;',
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
+ })
@@ -2,6 +2,9 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
2
  import { mkdtempSync, statSync, rmSync, existsSync } from 'fs'
3
3
  import { tmpdir } from 'os'
4
4
  import { join } from 'path'
5
+ // bun-only (this file is vitest-excluded and runs under `bun test`): used to
6
+ // build an OLD-schema DB file for the additive-migration test.
7
+ import { Database } from 'bun:sqlite'
5
8
  import {
6
9
  initHistory,
7
10
  recordInbound,
@@ -715,3 +718,157 @@ describe('secret redaction at persistence (both directions)', () => {
715
718
  expect(query({ chat_id: '-100' })[0]!.text).toBe('hello, how are you?')
716
719
  })
717
720
  })
721
+
722
+ describe('forwarded-message origin columns', () => {
723
+ it('round-trips forwarded_* fields on an inbound row', () => {
724
+ initHistory(stateDir, 30)
725
+ recordInbound({
726
+ chat_id: '-100',
727
+ thread_id: null,
728
+ message_id: 7,
729
+ user: 'alice',
730
+ user_id: '111',
731
+ ts: 1000,
732
+ text: 'fwd: look at this',
733
+ forwarded_from: 'Release Notes (@relnotes)',
734
+ forwarded_from_type: 'channel',
735
+ forwarded_from_id: '-100400500',
736
+ forwarded_date: '2026-06-15T13:46:40.000Z',
737
+ forwarded_message_id: 555,
738
+ })
739
+ const row = query({ chat_id: '-100' })[0]!
740
+ expect(row).toMatchObject({
741
+ forwarded_from: 'Release Notes (@relnotes)',
742
+ forwarded_from_type: 'channel',
743
+ forwarded_from_id: '-100400500',
744
+ forwarded_date: '2026-06-15T13:46:40.000Z',
745
+ forwarded_message_id: 555,
746
+ })
747
+ })
748
+
749
+ it('non-forwarded inbound stores NULL origin fields', () => {
750
+ initHistory(stateDir, 30)
751
+ recordInbound({
752
+ chat_id: '-100',
753
+ thread_id: null,
754
+ message_id: 8,
755
+ user: 'alice',
756
+ user_id: '111',
757
+ ts: 1000,
758
+ text: 'plain message',
759
+ })
760
+ const row = query({ chat_id: '-100' })[0]!
761
+ expect(row.forwarded_from).toBeNull()
762
+ expect(row.forwarded_from_type).toBeNull()
763
+ expect(row.forwarded_from_id).toBeNull()
764
+ expect(row.forwarded_date).toBeNull()
765
+ expect(row.forwarded_message_id).toBeNull()
766
+ })
767
+
768
+ it('migrates additively: pre-existing DB without the columns gains them and round-trips', () => {
769
+ // Build an OLD-schema DB file the way a pre-forward-origin build would
770
+ // have left it: messages table without any forwarded_* columns, one row
771
+ // already stored.
772
+ const dbPath = join(stateDir, 'history.db')
773
+ const old = new Database(dbPath, { create: true })
774
+ old.exec(`
775
+ CREATE TABLE IF NOT EXISTS messages (
776
+ chat_id TEXT NOT NULL,
777
+ thread_id INTEGER,
778
+ message_id INTEGER NOT NULL,
779
+ role TEXT NOT NULL,
780
+ user TEXT,
781
+ user_id TEXT,
782
+ ts INTEGER NOT NULL,
783
+ text TEXT NOT NULL,
784
+ attachment_kind TEXT,
785
+ group_id INTEGER,
786
+ reply_to_message_id INTEGER,
787
+ reply_to_text TEXT,
788
+ PRIMARY KEY (chat_id, thread_id, message_id)
789
+ )
790
+ `)
791
+ // Recent ts — initHistory's retention sweep deletes rows older than the
792
+ // cutoff, and this test is about migration, not retention.
793
+ const now = Math.floor(Date.now() / 1000)
794
+ old.exec(
795
+ `INSERT INTO messages (chat_id, thread_id, message_id, role, user, user_id, ts, text) ` +
796
+ `VALUES ('-100', NULL, 1, 'user', 'alice', '111', ${now - 60}, 'pre-migration row')`,
797
+ )
798
+ old.close()
799
+
800
+ // Re-open through the real init path — the additive ALTER TABLE loop
801
+ // must add the forwarded_* columns without touching existing rows.
802
+ initHistory(stateDir, 30)
803
+
804
+ const oldRow = query({ chat_id: '-100' })[0]!
805
+ expect(oldRow.text).toBe('pre-migration row')
806
+ expect(oldRow.forwarded_from).toBeNull()
807
+
808
+ recordInbound({
809
+ chat_id: '-100',
810
+ thread_id: null,
811
+ message_id: 2,
812
+ user: 'alice',
813
+ user_id: '111',
814
+ ts: now,
815
+ text: 'forwarded after migration',
816
+ forwarded_from: 'Ada Lovelace (@adalove)',
817
+ forwarded_from_type: 'user',
818
+ forwarded_from_id: '42',
819
+ forwarded_date: '2026-06-15T13:46:40.000Z',
820
+ })
821
+ const rows = query({ chat_id: '-100' })
822
+ expect(rows).toHaveLength(2)
823
+ const fwd = rows.find((r) => r.message_id === 2)!
824
+ expect(fwd.forwarded_from).toBe('Ada Lovelace (@adalove)')
825
+ expect(fwd.forwarded_from_type).toBe('user')
826
+ expect(fwd.forwarded_from_id).toBe('42')
827
+ expect(fwd.forwarded_date).toBe('2026-06-15T13:46:40.000Z')
828
+ expect(fwd.forwarded_message_id).toBeNull()
829
+ })
830
+
831
+ it('hostile origin name is stored raw (XML metachars belong to the meta lane)', () => {
832
+ initHistory(stateDir, 30)
833
+ // Raw XML metacharacters are EXPECTED here — escaping belongs to the
834
+ // channel-meta lane, the history buffer stores what the user saw.
835
+ recordInbound({
836
+ chat_id: '-100',
837
+ thread_id: null,
838
+ message_id: 9,
839
+ user: 'alice',
840
+ user_id: '111',
841
+ ts: 1000,
842
+ text: 'fwd',
843
+ forwarded_from: '<b>"Bob"&\'friends\'</b>',
844
+ forwarded_from_type: 'user',
845
+ forwarded_from_id: '42',
846
+ })
847
+ expect(query({ chat_id: '-100' })[0]!.forwarded_from).toBe('<b>"Bob"&\'friends\'</b>')
848
+ })
849
+
850
+ it('masks a secret-shaped origin name before it is stored (redaction backstop)', () => {
851
+ initHistory(stateDir, 30)
852
+ // Built by concatenation so the source never holds a contiguous
853
+ // secret-shaped literal (repo Push Protection / no-pii lint). Same
854
+ // pattern as the text/reply_to_text redaction tests above — a display
855
+ // name is user-controlled text and rides the same redact() backstop.
856
+ const GH_PAT = `ghp_${'F6g7H8i9J0'.repeat(3)}` // ghp_<30 base62>
857
+ recordInbound({
858
+ chat_id: '-100',
859
+ thread_id: null,
860
+ message_id: 10,
861
+ user: 'alice',
862
+ user_id: '111',
863
+ ts: 1000,
864
+ text: 'fwd',
865
+ forwarded_from: `Bob ${GH_PAT}`,
866
+ forwarded_from_type: 'user',
867
+ forwarded_from_id: '42',
868
+ })
869
+ const stored = query({ chat_id: '-100' })[0]!.forwarded_from as string
870
+ expect(stored).not.toContain(GH_PAT)
871
+ expect(stored).toContain('[REDACTED')
872
+ expect(stored).toContain('Bob') // surrounding name preserved
873
+ })
874
+ })
@@ -17,8 +17,10 @@ import { describe, it, expect } from "vitest";
17
17
  import { renderOutboundChunks, PLAIN_TEXT_MAX_CHARS } from "../../render/rich-render.js";
18
18
  import { RICH_MESSAGE_MAX_CHARS } from "../../format.js";
19
19
 
20
- const ON = { SWITCHROOM_RICH_RENDER: "1" } as NodeJS.ProcessEnv;
21
- const OFF = {} as NodeJS.ProcessEnv;
20
+ // Rendering is ON BY DEFAULT (escape hatch, not opt-in — mirrors the send
21
+ // gate): an empty env exercises the real default; "0" is the kill-switch.
22
+ const ON = {} as NodeJS.ProcessEnv;
23
+ const OFF = { SWITCHROOM_RICH_RENDER: "0" } as NodeJS.ProcessEnv;
22
24
 
23
25
  /** Count fenced-code delimiter lines (```) in a body. A piece that bisects a
24
26
  * fenced block has an ODD count. */
@@ -27,7 +29,7 @@ function fenceCount(s: string): number {
27
29
  }
28
30
 
29
31
  describe("renderOutboundChunks", () => {
30
- it("flag OFF is a single passthrough piece (byte-for-byte)", () => {
32
+ it("disabled (=0) is a single passthrough piece (byte-for-byte)", () => {
31
33
  const raw = "**bold** and _italic_ | a | table |";
32
34
  const pieces = renderOutboundChunks(raw, OFF);
33
35
  expect(pieces).toHaveLength(1);
@@ -35,7 +37,7 @@ describe("renderOutboundChunks", () => {
35
37
  expect(pieces[0].mode).toBe("markdown");
36
38
  });
37
39
 
38
- it("flag ON, body that fits is a single piece (common case)", () => {
40
+ it("default (env unset), body that fits is a single piece (common case)", () => {
39
41
  const pieces = renderOutboundChunks("just some plain prose", ON);
40
42
  expect(pieces).toHaveLength(1);
41
43
  expect(pieces[0].text.length).toBeLessThanOrEqual(RICH_MESSAGE_MAX_CHARS);
@@ -7,24 +7,32 @@ import {
7
7
  } from "../../render/rich-render.js";
8
8
 
9
9
  describe("parseRichRenderEnabled", () => {
10
- it("defaults OFF when unset", () => {
11
- expect(parseRichRenderEnabled(undefined)).toBe(false);
10
+ it("defaults ON when unset (escape hatch, not opt-in)", () => {
11
+ expect(parseRichRenderEnabled(undefined)).toBe(true);
12
12
  });
13
- it("accepts the truthy tokens", () => {
13
+ it("disabled only by the explicit off tokens (case-insensitive, trimmed)", () => {
14
+ for (const v of ["0", "false", "off", "no", "FALSE", " Off ", "NO"]) {
15
+ expect(parseRichRenderEnabled(v)).toBe(false);
16
+ }
17
+ });
18
+ it("truthy tokens stay ON", () => {
14
19
  for (const v of ["1", "true", "on", "yes", "TRUE", " On "]) {
15
20
  expect(parseRichRenderEnabled(v)).toBe(true);
16
21
  }
17
22
  });
18
- it("treats everything else as OFF", () => {
19
- for (const v of ["0", "false", "off", "no", "", "maybe"]) {
20
- expect(parseRichRenderEnabled(v)).toBe(false);
23
+ it("empty / unrecognised values stay ON (fail-open to the default)", () => {
24
+ for (const v of ["", " ", "maybe", "2", "disable"]) {
25
+ expect(parseRichRenderEnabled(v)).toBe(true);
21
26
  }
22
27
  });
23
28
  });
24
29
 
25
30
  describe("richRenderEnabled", () => {
26
- it("reads SWITCHROOM_RICH_RENDER, default OFF", () => {
27
- expect(richRenderEnabled({} as NodeJS.ProcessEnv)).toBe(false);
31
+ it("reads SWITCHROOM_RICH_RENDER, default ON", () => {
32
+ expect(richRenderEnabled({} as NodeJS.ProcessEnv)).toBe(true);
33
+ expect(
34
+ richRenderEnabled({ SWITCHROOM_RICH_RENDER: "0" } as NodeJS.ProcessEnv),
35
+ ).toBe(false);
28
36
  expect(
29
37
  richRenderEnabled({ SWITCHROOM_RICH_RENDER: "1" } as NodeJS.ProcessEnv),
30
38
  ).toBe(true);
@@ -32,41 +40,52 @@ describe("richRenderEnabled", () => {
32
40
  });
33
41
 
34
42
  describe("maybeRenderOutbound", () => {
35
- it("flag OFF is a byte-for-byte passthrough (no behavioural change)", () => {
43
+ it("disabled (=0) is a byte-for-byte passthrough (the escape hatch)", () => {
36
44
  const raw = "**bold** and a\n\n**> collapsible quote\n> second line";
37
- const r = maybeRenderOutbound(raw, {} as NodeJS.ProcessEnv);
45
+ const r = maybeRenderOutbound(raw, {
46
+ SWITCHROOM_RICH_RENDER: "0",
47
+ } as NodeJS.ProcessEnv);
38
48
  expect(r.mode).toBe("markdown");
39
49
  expect(r.text).toBe(raw);
40
50
  });
41
51
 
42
- it("flag ON routes through parse -> renderSafe", () => {
43
- const r = maybeRenderOutbound("**> collapsible", {
44
- SWITCHROOM_RICH_RENDER: "1",
45
- } as NodeJS.ProcessEnv);
52
+ it("default (env unset) routes through parse -> renderSafe", () => {
53
+ const r = maybeRenderOutbound("**> collapsible", {} as NodeJS.ProcessEnv);
46
54
  expect(r.mode).toBe("markdown");
47
55
  // The expandable blockquote round-trips back to the `**> ` marker.
48
56
  expect(r.text).toContain("**> ");
49
57
  });
50
58
 
51
- it("flag ON preserves plain prose through the round-trip", () => {
52
- const r = maybeRenderOutbound("just some plain text", {
53
- SWITCHROOM_RICH_RENDER: "1",
54
- } as NodeJS.ProcessEnv);
59
+ it("default preserves plain prose through the round-trip", () => {
60
+ const r = maybeRenderOutbound(
61
+ "just some plain text",
62
+ {} as NodeJS.ProcessEnv,
63
+ );
55
64
  expect(r.text).toContain("just some plain text");
56
65
  });
57
66
 
58
- it("flag ON round-trips underline / spoiler / highlight", () => {
59
- const on = { SWITCHROOM_RICH_RENDER: "1" } as NodeJS.ProcessEnv;
67
+ it("default round-trips underline / spoiler / highlight", () => {
68
+ const on = {} as NodeJS.ProcessEnv;
60
69
  expect(maybeRenderOutbound("__u__", on).text).toBe("__u__");
61
70
  expect(maybeRenderOutbound("a ||s|| b", on).text).toBe("a ||s|| b");
62
71
  expect(maybeRenderOutbound("a ==m== b", on).text).toBe("a ==m== b");
63
72
  });
64
73
 
65
- it("flag OFF passes new constructs through untouched too", () => {
74
+ it("disabled (=0) passes new constructs through untouched too", () => {
66
75
  const raw = "__u__ and ||s|| and ==m==";
67
- const r = maybeRenderOutbound(raw, {} as NodeJS.ProcessEnv);
76
+ const r = maybeRenderOutbound(raw, {
77
+ SWITCHROOM_RICH_RENDER: "0",
78
+ } as NodeJS.ProcessEnv);
68
79
  expect(r.text).toBe(raw);
69
80
  });
81
+
82
+ it("a junk env value still renders (fail-open to the default)", () => {
83
+ const r = maybeRenderOutbound("**> collapsible", {
84
+ SWITCHROOM_RICH_RENDER: "maybe",
85
+ } as NodeJS.ProcessEnv);
86
+ expect(r.mode).toBe("markdown");
87
+ expect(r.text).toContain("**> ");
88
+ });
70
89
  });
71
90
 
72
91
  describe("renderOutbound (flag-independent)", () => {
@@ -69,7 +69,7 @@ describe('stream-reply single-mode reuse (#2669)', () => {
69
69
  expect(bot.api.editMessageText).toHaveBeenCalled()
70
70
  })
71
71
 
72
- it('the rich path ships raw GFM markdown unescaped via sendRichMessage', async () => {
72
+ it('the rich path ships GFM markdown unescaped via sendRichMessage', async () => {
73
73
  const state = makeState()
74
74
  const deps = makeDeps(bot)
75
75
 
@@ -77,8 +77,10 @@ describe('stream-reply single-mode reuse (#2669)', () => {
77
77
 
78
78
  expect(bot.state.sent).toHaveLength(1)
79
79
  expect(bot.state.sent[0].rich).toBe(true)
80
- // Raw markdown is the wire payload — no HTML, no MarkdownV2 escaping.
81
- expect(bot.state.sent[0].text).toBe('**bold** and _italic_')
80
+ // GFM markdown is the wire payload — no HTML, no MarkdownV2 escaping.
81
+ // The default-on rich renderer normalises the italic marker
82
+ // (`_italic_` -> `*italic*`, same wire entity).
83
+ expect(bot.state.sent[0].text).toBe('**bold** and *italic*')
82
84
  expect(bot.state.sent[0].parse_mode).toBeUndefined()
83
85
  })
84
86
 
@@ -91,8 +91,10 @@ describe('handleStreamReply accent integration', () => {
91
91
  await pending
92
92
 
93
93
  const sent = sentMarkdown(bot)
94
- expect(sent).toMatch(/^🔵 _In progress…_\n\n/)
95
- expect(sent).toBe('🔵 _In progress…_\n\nStill working...')
94
+ // The default-on rich renderer normalises the italic marker
95
+ // (`_In progress…_` -> `*In progress…*`, same wire entity).
96
+ expect(sent).toMatch(/^🔵 \*In progress…\*\n\n/)
97
+ expect(sent).toBe('🔵 *In progress…*\n\nStill working...')
96
98
  })
97
99
 
98
100
  it("accent='done' prepends the checkmark markdown header before the body", async () => {
@@ -176,6 +178,6 @@ describe('handleStreamReply accent integration', () => {
176
178
  await microtaskFlush()
177
179
  await p2
178
180
 
179
- expect(editedMarkdown(bot)).toBe('🔵 _In progress…_\n\nPart one Part two')
181
+ expect(editedMarkdown(bot)).toBe('🔵 *In progress…*\n\nPart one Part two')
180
182
  })
181
183
  })