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.
- package/dist/agent-scheduler/index.js +49 -9
- package/dist/auth-broker/index.js +111 -7
- 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 +35 -6
- package/dist/host-control/main.js +1 -2
- package/dist/vault/approvals/kernel-server.js +0 -1
- package/dist/vault/broker/server.js +0 -1
- 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 +2 -0
- package/telegram-plugin/dist/gateway/gateway.js +620 -67
- package/telegram-plugin/dist/server.js +2 -0
- 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 +224 -10
- package/telegram-plugin/gateway/throttle-tier-wiring.ts +268 -0
- package/telegram-plugin/history.ts +55 -6
- package/telegram-plugin/model-unavailable.ts +20 -2
- package/telegram-plugin/render/rich-render.ts +40 -32
- 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/render/render-outbound-chunks.test.ts +6 -4
- package/telegram-plugin/tests/render/rich-render.test.ts +41 -22
- 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 +278 -0
- package/telegram-plugin/throttle-tier.ts +226 -0
- 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
|
+
'<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
|
+
})
|
|
@@ -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
|
-
|
|
21
|
-
|
|
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("
|
|
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("
|
|
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
|
|
11
|
-
expect(parseRichRenderEnabled(undefined)).toBe(
|
|
10
|
+
it("defaults ON when unset (escape hatch, not opt-in)", () => {
|
|
11
|
+
expect(parseRichRenderEnabled(undefined)).toBe(true);
|
|
12
12
|
});
|
|
13
|
-
it("
|
|
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("
|
|
19
|
-
for (const v of ["
|
|
20
|
-
expect(parseRichRenderEnabled(v)).toBe(
|
|
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
|
|
27
|
-
expect(richRenderEnabled({} as NodeJS.ProcessEnv)).toBe(
|
|
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("
|
|
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, {
|
|
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("
|
|
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("
|
|
52
|
-
const r = maybeRenderOutbound(
|
|
53
|
-
|
|
54
|
-
|
|
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("
|
|
59
|
-
const on = {
|
|
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("
|
|
74
|
+
it("disabled (=0) passes new constructs through untouched too", () => {
|
|
66
75
|
const raw = "__u__ and ||s|| and ==m==";
|
|
67
|
-
const r = maybeRenderOutbound(raw, {
|
|
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
|
|
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
|
-
//
|
|
81
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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('🔵
|
|
181
|
+
expect(editedMarkdown(bot)).toBe('🔵 *In progress…*\n\nPart one Part two')
|
|
180
182
|
})
|
|
181
183
|
})
|