switchroom 0.11.1 → 0.12.0

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 (59) hide show
  1. package/README.md +7 -6
  2. package/dist/agent-scheduler/index.js +216 -97
  3. package/dist/auth-broker/index.js +175 -96
  4. package/dist/cli/drive-write-pretool.mjs +26 -11
  5. package/dist/cli/switchroom.js +45153 -42663
  6. package/dist/cli/ui/index.html +1281 -0
  7. package/dist/host-control/main.js +3628 -309
  8. package/dist/vault/approvals/kernel-server.js +207 -98
  9. package/dist/vault/broker/server.js +218 -97
  10. package/examples/personal-google-workspace-mcp/README.md +8 -3
  11. package/examples/switchroom.yaml +91 -42
  12. package/package.json +2 -2
  13. package/profiles/_base/start.sh.hbs +76 -36
  14. package/profiles/default/CLAUDE.md.hbs +4 -2
  15. package/skills/file-bug/SKILL.md +6 -4
  16. package/skills/switchroom-cli/SKILL.md +20 -4
  17. package/skills/switchroom-install/SKILL.md +3 -3
  18. package/telegram-plugin/auth-snapshot-format.ts +4 -4
  19. package/telegram-plugin/card-format.ts +3 -3
  20. package/telegram-plugin/dist/bridge/bridge.js +112 -112
  21. package/telegram-plugin/dist/gateway/gateway.js +795 -410
  22. package/telegram-plugin/dist/server.js +162 -161
  23. package/telegram-plugin/format.ts +71 -0
  24. package/telegram-plugin/gateway/approval-card.test.ts +18 -18
  25. package/telegram-plugin/gateway/approval-card.ts +1 -1
  26. package/telegram-plugin/gateway/auth-command.ts +2 -2
  27. package/telegram-plugin/gateway/boot-card.ts +40 -3
  28. package/telegram-plugin/gateway/boot-probes.ts +71 -27
  29. package/telegram-plugin/gateway/diff-preview-card.test.ts +15 -15
  30. package/telegram-plugin/gateway/diff-preview-card.ts +1 -1
  31. package/telegram-plugin/gateway/drive-write-approval.test.ts +2 -2
  32. package/telegram-plugin/gateway/gateway.ts +193 -22
  33. package/telegram-plugin/gateway/update-announce.ts +167 -0
  34. package/telegram-plugin/quota-check.ts +0 -195
  35. package/telegram-plugin/retry-api-call.ts +24 -0
  36. package/telegram-plugin/server.ts +8 -5
  37. package/telegram-plugin/tests/auth-add-flow.test.ts +31 -2
  38. package/telegram-plugin/tests/boot-probes.test.ts +53 -0
  39. package/telegram-plugin/tests/bot-runtime.test.ts +23 -1
  40. package/telegram-plugin/tests/quota-check.test.ts +0 -409
  41. package/telegram-plugin/tests/retry-api-call.test.ts +76 -0
  42. package/telegram-plugin/tests/telegram-format.test.ts +84 -1
  43. package/telegram-plugin/tests/update-announce.test.ts +154 -0
  44. package/telegram-plugin/welcome-text.ts +1 -8
  45. package/profiles/default/CLAUDE.md +0 -192
  46. package/skills/docx/scripts/office/validators/__pycache__/__init__.cpython-313.pyc +0 -0
  47. package/skills/docx/scripts/office/validators/__pycache__/base.cpython-313.pyc +0 -0
  48. package/skills/skill-creator/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
  49. package/skills/skill-creator/scripts/__pycache__/generate_report.cpython-313.pyc +0 -0
  50. package/skills/skill-creator/scripts/__pycache__/improve_description.cpython-313.pyc +0 -0
  51. package/skills/skill-creator/scripts/__pycache__/run_eval.cpython-313.pyc +0 -0
  52. package/skills/skill-creator/scripts/__pycache__/run_loop.cpython-313.pyc +0 -0
  53. package/skills/skill-creator/scripts/__pycache__/utils.cpython-313.pyc +0 -0
  54. package/telegram-plugin/first-paint.ts +0 -225
  55. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
  56. package/telegram-plugin/server.js +0 -41795
  57. package/telegram-plugin/tests/html-balanced.ts +0 -63
  58. package/telegram-plugin/tests/snapshot-serializer.ts +0 -79
  59. package/telegram-plugin/tool-error-filter.ts +0 -89
@@ -7,12 +7,6 @@ import {
7
7
  formatQuotaBlock,
8
8
  formatQuotaLine,
9
9
  parseQuotaHeaders,
10
- readAccountAccessToken,
11
- fetchAccountQuota,
12
- getCachedAccountQuota,
13
- prefetchAccountQuotaIfStale,
14
- clearAccountQuotaCache,
15
- ACCOUNT_QUOTA_CACHE_TTL_MS,
16
10
  } from '../quota-check.js'
17
11
 
18
12
  function makeTempClaudeDir(token: string | null): string {
@@ -189,409 +183,6 @@ describe('fetchQuota', () => {
189
183
  })
190
184
  })
191
185
 
192
- // ─── Account-level helpers ────────────────────────────────────────────
193
-
194
- /** Build a fake $HOME with `~/.switchroom/accounts/<label>/credentials.json`. */
195
- function makeAccountHome(
196
- accounts: Record<string, { accessToken?: string }>,
197
- ): string {
198
- const home = mkdtempSync(join(tmpdir(), 'quota-acct-test-'))
199
- for (const [label, creds] of Object.entries(accounts)) {
200
- const dir = join(home, '.switchroom', 'accounts', label)
201
- mkdirSync(dir, { recursive: true })
202
- if (creds.accessToken !== undefined) {
203
- writeFileSync(
204
- join(dir, 'credentials.json'),
205
- JSON.stringify({ claudeAiOauth: { accessToken: creds.accessToken } }),
206
- )
207
- }
208
- }
209
- return home
210
- }
211
-
212
- describe('readAccountAccessToken', () => {
213
- it('returns the access token from credentials.json', () => {
214
- const home = makeAccountHome({
215
- 'pixsoul@gmail.com': { accessToken: 'sk-ant-oat01-fake' },
216
- })
217
- try {
218
- expect(readAccountAccessToken('pixsoul@gmail.com', home)).toBe(
219
- 'sk-ant-oat01-fake',
220
- )
221
- } finally {
222
- rmSync(home, { recursive: true, force: true })
223
- }
224
- })
225
-
226
- it('returns null when the account dir is missing', () => {
227
- const home = makeAccountHome({})
228
- try {
229
- expect(readAccountAccessToken('absent', home)).toBeNull()
230
- } finally {
231
- rmSync(home, { recursive: true, force: true })
232
- }
233
- })
234
-
235
- it('returns null when accessToken is empty', () => {
236
- const home = makeAccountHome({
237
- 'empty@example.com': { accessToken: '' },
238
- })
239
- try {
240
- expect(readAccountAccessToken('empty@example.com', home)).toBeNull()
241
- } finally {
242
- rmSync(home, { recursive: true, force: true })
243
- }
244
- })
245
-
246
- it('returns null when credentials.json is malformed', () => {
247
- const home = mkdtempSync(join(tmpdir(), 'quota-acct-bad-'))
248
- const dir = join(home, '.switchroom', 'accounts', 'broken')
249
- mkdirSync(dir, { recursive: true })
250
- writeFileSync(join(dir, 'credentials.json'), '{not json')
251
- try {
252
- expect(readAccountAccessToken('broken', home)).toBeNull()
253
- } finally {
254
- rmSync(home, { recursive: true, force: true })
255
- }
256
- })
257
- })
258
-
259
- describe('fetchAccountQuota — cache + token resolution', () => {
260
- beforeEach(() => {
261
- clearAccountQuotaCache()
262
- })
263
-
264
- it('fetches once, returns cached on subsequent calls within TTL', async () => {
265
- const home = makeAccountHome({
266
- 'work@example.com': { accessToken: 'tok' },
267
- })
268
- let callCount = 0
269
- const fakeFetch = async () => {
270
- callCount++
271
- return new Response('{}', {
272
- status: 200,
273
- headers: {
274
- 'anthropic-ratelimit-unified-5h-utilization': '0.42',
275
- 'anthropic-ratelimit-unified-7d-utilization': '0.17',
276
- },
277
- })
278
- }
279
- try {
280
- const r1 = await fetchAccountQuota('work@example.com', {
281
- home,
282
- fetchImpl: fakeFetch as typeof fetch,
283
- })
284
- const r2 = await fetchAccountQuota('work@example.com', {
285
- home,
286
- fetchImpl: fakeFetch as typeof fetch,
287
- })
288
- expect(r1.ok).toBe(true)
289
- expect(r2.ok).toBe(true)
290
- expect(callCount).toBe(1) // cache hit on the second call
291
- } finally {
292
- rmSync(home, { recursive: true, force: true })
293
- }
294
- })
295
-
296
- it('force=true bypasses the cache', async () => {
297
- const home = makeAccountHome({
298
- 'work@example.com': { accessToken: 'tok' },
299
- })
300
- let callCount = 0
301
- const fakeFetch = async () => {
302
- callCount++
303
- return new Response('{}', {
304
- status: 200,
305
- headers: {
306
- 'anthropic-ratelimit-unified-5h-utilization': '0.5',
307
- 'anthropic-ratelimit-unified-7d-utilization': '0.5',
308
- },
309
- })
310
- }
311
- try {
312
- await fetchAccountQuota('work@example.com', {
313
- home,
314
- fetchImpl: fakeFetch as typeof fetch,
315
- })
316
- await fetchAccountQuota('work@example.com', {
317
- home,
318
- fetchImpl: fakeFetch as typeof fetch,
319
- force: true,
320
- })
321
- expect(callCount).toBe(2)
322
- } finally {
323
- rmSync(home, { recursive: true, force: true })
324
- }
325
- })
326
-
327
- it('caches missing-credentials failures so the API is not pinged', async () => {
328
- const home = makeAccountHome({})
329
- let callCount = 0
330
- const fakeFetch = async () => {
331
- callCount++
332
- return new Response('{}')
333
- }
334
- const r1 = await fetchAccountQuota('absent', {
335
- home,
336
- fetchImpl: fakeFetch as typeof fetch,
337
- })
338
- const r2 = await fetchAccountQuota('absent', {
339
- home,
340
- fetchImpl: fakeFetch as typeof fetch,
341
- })
342
- expect(r1.ok).toBe(false)
343
- expect(r2.ok).toBe(false)
344
- expect(callCount).toBe(0) // never reached fetch — token resolution failed first
345
- })
346
-
347
- it('cache miss after TTL triggers a fresh fetch', async () => {
348
- const home = makeAccountHome({
349
- 'work@example.com': { accessToken: 'tok' },
350
- })
351
- let callCount = 0
352
- const fakeFetch = async () => {
353
- callCount++
354
- return new Response('{}', {
355
- status: 200,
356
- headers: {
357
- 'anthropic-ratelimit-unified-5h-utilization': '0.3',
358
- 'anthropic-ratelimit-unified-7d-utilization': '0.3',
359
- },
360
- })
361
- }
362
- let nowVal = 1_000_000
363
- const now = () => nowVal
364
- try {
365
- await fetchAccountQuota('work@example.com', {
366
- home,
367
- fetchImpl: fakeFetch as typeof fetch,
368
- now,
369
- })
370
- // Step time past the TTL.
371
- nowVal += ACCOUNT_QUOTA_CACHE_TTL_MS + 1
372
- await fetchAccountQuota('work@example.com', {
373
- home,
374
- fetchImpl: fakeFetch as typeof fetch,
375
- now,
376
- })
377
- expect(callCount).toBe(2)
378
- } finally {
379
- rmSync(home, { recursive: true, force: true })
380
- }
381
- })
382
-
383
- // Removed in RFC H: per-account quota.json disk persistence is gone.
384
- // switchroom-auth-broker holds canonical quota state and exposes it
385
- // via list-state; the gateway's in-process cache is enough between
386
- // restarts (and the broker survives gateway restarts, so the state
387
- // is preserved at the broker side anyway).
388
- })
389
-
390
- describe('getCachedAccountQuota + prefetchAccountQuotaIfStale', () => {
391
- beforeEach(() => {
392
- clearAccountQuotaCache()
393
- })
394
-
395
- it('returns null on a cold cache, populates after a fetch', async () => {
396
- const home = makeAccountHome({
397
- 'work@example.com': { accessToken: 'tok' },
398
- })
399
- try {
400
- expect(getCachedAccountQuota('work@example.com')).toBeNull()
401
- await fetchAccountQuota('work@example.com', {
402
- home,
403
- fetchImpl: (async () =>
404
- new Response('{}', {
405
- status: 200,
406
- headers: {
407
- 'anthropic-ratelimit-unified-5h-utilization': '0.42',
408
- 'anthropic-ratelimit-unified-7d-utilization': '0.17',
409
- },
410
- })) as typeof fetch,
411
- })
412
- const cached = getCachedAccountQuota('work@example.com')
413
- expect(cached?.ok).toBe(true)
414
- if (cached?.ok) {
415
- expect(Math.round(cached.data.fiveHourUtilizationPct)).toBe(42)
416
- }
417
- } finally {
418
- rmSync(home, { recursive: true, force: true })
419
- }
420
- })
421
-
422
- it("returns stale entries verbatim — staleness is the prefetch path's concern, not the read path's (v0.6.11)", async () => {
423
- // The dashboard renders sync. Pre-v0.6.11 this function treated
424
- // stale cache as a miss → the boot-warmed cache vanished after
425
- // 30s and the operator saw empty quota rows on the first /auth
426
- // tap of any session past that window. Now stale-but-present
427
- // entries are returned; the background prefetch keeps the cache
428
- // fresh across renders.
429
- const home = makeAccountHome({
430
- 'work@example.com': { accessToken: 'tok' },
431
- })
432
- try {
433
- const nowVal = 1_000_000
434
- await fetchAccountQuota('work@example.com', {
435
- home,
436
- fetchImpl: (async () =>
437
- new Response('{}', {
438
- status: 200,
439
- headers: {
440
- 'anthropic-ratelimit-unified-5h-utilization': '0.42',
441
- 'anthropic-ratelimit-unified-7d-utilization': '0.17',
442
- },
443
- })) as typeof fetch,
444
- now: () => nowVal,
445
- })
446
- // Within TTL — cached.
447
- const fresh = getCachedAccountQuota('work@example.com', nowVal)
448
- expect(fresh).not.toBeNull()
449
- // Past TTL — STILL returned, identical to the within-TTL read.
450
- const after = nowVal + ACCOUNT_QUOTA_CACHE_TTL_MS + 1
451
- const stale = getCachedAccountQuota('work@example.com', after)
452
- expect(stale).not.toBeNull()
453
- expect(stale).toEqual(fresh)
454
- } finally {
455
- rmSync(home, { recursive: true, force: true })
456
- }
457
- })
458
-
459
- it('returns null when the label has never been probed', async () => {
460
- // The only "no data" path: the cache map has no entry. After
461
- // the first probe the entry persists for the lifetime of the
462
- // gateway process, regardless of staleness.
463
- expect(getCachedAccountQuota('never-probed@example.com')).toBeNull()
464
- })
465
-
466
- it('prefetchAccountQuotaIfStale is a noop when cache is fresh', async () => {
467
- const home = makeAccountHome({
468
- 'work@example.com': { accessToken: 'tok' },
469
- })
470
- let callCount = 0
471
- const fakeFetch = (async () => {
472
- callCount++
473
- return new Response('{}', {
474
- status: 200,
475
- headers: {
476
- 'anthropic-ratelimit-unified-5h-utilization': '0.42',
477
- 'anthropic-ratelimit-unified-7d-utilization': '0.17',
478
- },
479
- })
480
- }) as typeof fetch
481
- try {
482
- await fetchAccountQuota('work@example.com', { home, fetchImpl: fakeFetch })
483
- expect(callCount).toBe(1)
484
- // Fresh cache — prefetch should not fire.
485
- prefetchAccountQuotaIfStale('work@example.com', { home, fetchImpl: fakeFetch })
486
- // Yield once to let any spurious microtasks settle.
487
- await Promise.resolve()
488
- expect(callCount).toBe(1)
489
- } finally {
490
- rmSync(home, { recursive: true, force: true })
491
- }
492
- })
493
- })
494
-
495
- describe('regression: boot-warm + delayed sync-read (v0.6.11)', () => {
496
- // The bug: gateway boot-warm fills the cache; cache TTL elapses;
497
- // dashboard's sync read returns null; operator sees empty quota
498
- // rows on first /auth tap of the session past TTL. Fix: sync read
499
- // returns last-known data regardless of staleness; prefetch path
500
- // owns the freshness contract. Pin both legs so a future TTL
501
- // tweak can't silently re-introduce the bug.
502
- it('returns last-known data even after multiple TTL windows have elapsed', async () => {
503
- clearAccountQuotaCache()
504
- const home = makeAccountHome({
505
- 'pixsoul@gmail.com': { accessToken: 'tok' },
506
- })
507
- try {
508
- const t0 = 1_000_000
509
- // Boot-warm: probe completes at t0.
510
- await fetchAccountQuota('pixsoul@gmail.com', {
511
- home,
512
- fetchImpl: (async () =>
513
- new Response('{}', {
514
- status: 200,
515
- headers: {
516
- 'anthropic-ratelimit-unified-5h-utilization': '0.04',
517
- 'anthropic-ratelimit-unified-7d-utilization': '0.78',
518
- },
519
- })) as typeof fetch,
520
- now: () => t0,
521
- })
522
- // 8.5 minutes later (the screenshot-reproduction window): the
523
- // dashboard fetches state. Sync read returns the boot-warmed
524
- // values rather than null.
525
- const tDashboard = t0 + 8.5 * 60_000
526
- const cached = getCachedAccountQuota('pixsoul@gmail.com', tDashboard)
527
- expect(cached).not.toBeNull()
528
- if (cached?.ok) {
529
- expect(cached.data.fiveHourUtilizationPct).toBe(4)
530
- expect(cached.data.sevenDayUtilizationPct).toBe(78)
531
- } else {
532
- throw new Error('expected ok=true cached entry')
533
- }
534
- } finally {
535
- rmSync(home, { recursive: true, force: true })
536
- }
537
- })
538
-
539
- it('prefetchAccountQuotaIfStale re-probes once the TTL has elapsed', async () => {
540
- clearAccountQuotaCache()
541
- const home = makeAccountHome({
542
- 'work@example.com': { accessToken: 'tok' },
543
- })
544
- try {
545
- const t0 = 1_000_000
546
- let fetchCount = 0
547
- const counterFetch: typeof fetch = async () => {
548
- fetchCount++
549
- return new Response('{}', {
550
- status: 200,
551
- headers: {
552
- 'anthropic-ratelimit-unified-5h-utilization': '0.10',
553
- 'anthropic-ratelimit-unified-7d-utilization': '0.20',
554
- },
555
- })
556
- }
557
- // First probe seeds the cache.
558
- await fetchAccountQuota('work@example.com', {
559
- home,
560
- fetchImpl: counterFetch,
561
- now: () => t0,
562
- })
563
- expect(fetchCount).toBe(1)
564
- // Within TTL: prefetch is a no-op.
565
- prefetchAccountQuotaIfStale('work@example.com', {
566
- home,
567
- fetchImpl: counterFetch,
568
- now: () => t0 + 60_000,
569
- })
570
- // Give microtask queue a chance — should still be 1.
571
- await new Promise((r) => setTimeout(r, 5))
572
- expect(fetchCount).toBe(1)
573
- // Past TTL: prefetch fires a fresh probe.
574
- prefetchAccountQuotaIfStale('work@example.com', {
575
- home,
576
- fetchImpl: counterFetch,
577
- now: () => t0 + ACCOUNT_QUOTA_CACHE_TTL_MS + 1,
578
- })
579
- // Wait for the fire-and-forget probe to complete.
580
- await new Promise((r) => setTimeout(r, 20))
581
- expect(fetchCount).toBe(2)
582
- } finally {
583
- rmSync(home, { recursive: true, force: true })
584
- }
585
- })
586
-
587
- it('cache TTL is at least 1 minute — short TTLs cause empty-row regressions', () => {
588
- // Pre-v0.6.11 was 30s, which made the boot-warm useless. If a
589
- // future PR drops it below 60s, this test catches it before the
590
- // empty-row regression hits production.
591
- expect(ACCOUNT_QUOTA_CACHE_TTL_MS).toBeGreaterThanOrEqual(60_000)
592
- })
593
- })
594
-
595
186
  describe('fetchQuota — accessToken parameter', () => {
596
187
  it('accepts a direct accessToken instead of a config dir', async () => {
597
188
  const fakeFetch = async (_url: unknown, init?: RequestInit) => {
@@ -14,6 +14,7 @@ import {
14
14
  createRetryApiCall,
15
15
  createSwallowingRetryApiCall,
16
16
  retryWithThreadFallback,
17
+ isHtmlParseRejectError,
17
18
  type RetryObserver,
18
19
  } from '../retry-api-call.js'
19
20
  import { errors, makeGrammyError } from './fake-bot-api.js'
@@ -436,3 +437,78 @@ describe('retryWithThreadFallback (#1075)', () => {
436
437
  expect(result).toBe(true)
437
438
  })
438
439
  })
440
+
441
+ describe('isHtmlParseRejectError', () => {
442
+ it('matches the real Telegram parse-failure 400 descriptions', () => {
443
+ const descriptions = [
444
+ "can't parse entities: Unsupported start tag \"h2\" at byte offset 12",
445
+ 'Bad Request: unsupported start tag "span"',
446
+ "can't find end of the entity starting at byte offset 40",
447
+ 'Bad Request: unclosed start tag at byte offset 5',
448
+ 'Bad Request: unexpected end tag at byte offset 9',
449
+ "Bad Request: can't parse entities: expected end tag",
450
+ ]
451
+ for (const d of descriptions) {
452
+ expect(
453
+ isHtmlParseRejectError(
454
+ makeGrammyError({ error_code: 400, description: d, method: 'sendMessage' }),
455
+ ),
456
+ ).toBe(true)
457
+ }
458
+ })
459
+
460
+ it('does NOT match other 400s (those have their own handling)', () => {
461
+ for (const d of [
462
+ 'Bad Request: message is not modified',
463
+ 'Bad Request: message to edit not found',
464
+ 'Bad Request: message thread not found',
465
+ 'Bad Request: chat not found',
466
+ ]) {
467
+ expect(
468
+ isHtmlParseRejectError(
469
+ makeGrammyError({ error_code: 400, description: d, method: 'sendMessage' }),
470
+ ),
471
+ ).toBe(false)
472
+ }
473
+ })
474
+
475
+ it('does not match non-400 GrammyErrors', () => {
476
+ expect(
477
+ isHtmlParseRejectError(
478
+ makeGrammyError({
479
+ error_code: 429,
480
+ description: "can't parse entities",
481
+ method: 'sendMessage',
482
+ }),
483
+ ),
484
+ ).toBe(false)
485
+ expect(
486
+ isHtmlParseRejectError(
487
+ makeGrammyError({
488
+ error_code: 403,
489
+ description: 'Forbidden: bot was blocked',
490
+ method: 'sendMessage',
491
+ }),
492
+ ),
493
+ ).toBe(false)
494
+ })
495
+
496
+ it('does not match plain Errors or non-error values', () => {
497
+ expect(isHtmlParseRejectError(new Error("can't parse entities"))).toBe(false)
498
+ expect(isHtmlParseRejectError('string')).toBe(false)
499
+ expect(isHtmlParseRejectError(null)).toBe(false)
500
+ expect(isHtmlParseRejectError(undefined)).toBe(false)
501
+ })
502
+
503
+ it('matches the curly-apostrophe variant Telegram sometimes emits', () => {
504
+ expect(
505
+ isHtmlParseRejectError(
506
+ makeGrammyError({
507
+ error_code: 400,
508
+ description: 'Bad Request: can’t parse entities: bad tag',
509
+ method: 'sendMessage',
510
+ }),
511
+ ),
512
+ ).toBe(true)
513
+ })
514
+ })
@@ -6,7 +6,7 @@ import { describe, test, expect } from 'vitest'
6
6
 
7
7
  // Import from the side-effect-free format module so tests don't trigger
8
8
  // server.ts's startup (env load, token check, grammy init).
9
- import { markdownToHtml, splitHtmlChunks, isLikelyTelegramHtml, repairEscapedWhitespace, sanitizeForTelegram } from '../format.js'
9
+ import { markdownToHtml, splitHtmlChunks, isLikelyTelegramHtml, repairEscapedWhitespace, sanitizeForTelegram, telegramHtmlToPlainText } from '../format.js'
10
10
 
11
11
  // ---------------------------------------------------------------------------
12
12
  // markdownToHtml
@@ -1091,3 +1091,86 @@ describe('markdownToHtml — markdown table rendering', () => {
1091
1091
  expect(result).toContain('• <b>OR</b>')
1092
1092
  })
1093
1093
  })
1094
+
1095
+ describe('telegramHtmlToPlainText (HTML parse-reject fallback)', () => {
1096
+ test('strips supported formatting tags, keeps the text', () => {
1097
+ const out = telegramHtmlToPlainText('<b>Bold</b> and <i>italic</i> and <code>x=1</code>')
1098
+ expect(out).toBe('Bold and italic and x=1')
1099
+ })
1100
+
1101
+ test('anchors become "label (href)"', () => {
1102
+ const out = telegramHtmlToPlainText('see <a href="https://example.com/x">the docs</a> now')
1103
+ expect(out).toBe('see the docs (https://example.com/x) now')
1104
+ })
1105
+
1106
+ test('anchor with label equal to href collapses to the bare url', () => {
1107
+ const out = telegramHtmlToPlainText('<a href="https://example.com">https://example.com</a>')
1108
+ expect(out).toBe('https://example.com')
1109
+ })
1110
+
1111
+ test('anchor with empty label yields just the href', () => {
1112
+ expect(telegramHtmlToPlainText('<a href="https://e.com"></a>')).toBe('https://e.com')
1113
+ })
1114
+
1115
+ test('single-quoted and unquoted href forms are handled', () => {
1116
+ expect(telegramHtmlToPlainText("<a href='https://a.co'>A</a>")).toBe('A (https://a.co)')
1117
+ expect(telegramHtmlToPlainText('<a href=https://b.co>B</a>')).toBe('B (https://b.co)')
1118
+ })
1119
+
1120
+ test('decodes the standard HTML entities (no double-decode of the result)', () => {
1121
+ const out = telegramHtmlToPlainText('a &amp; b &lt;tag&gt; &quot;q&quot; &#39;s&#39; 5 &nbsp;€')
1122
+ expect(out).toBe('a & b <tag> "q" \'s\' 5 €')
1123
+ })
1124
+
1125
+ test('numeric + hex char references decode', () => {
1126
+ expect(telegramHtmlToPlainText('&#8594; &#x2192;')).toBe('→ →')
1127
+ })
1128
+
1129
+ test('out-of-range / malformed char refs are left literal', () => {
1130
+ expect(telegramHtmlToPlainText('&#0; &#1114112; &#xZZ;')).toBe('&#0; &#1114112; &#xZZ;')
1131
+ })
1132
+
1133
+ test('block/break boundaries become newlines', () => {
1134
+ const out = telegramHtmlToPlainText('one<br>two<br/>three</p>four</blockquote>five')
1135
+ expect(out).toBe('one\ntwo\nthree\nfour\nfive')
1136
+ })
1137
+
1138
+ test('unsupported / malformed tags (the actual reject cause) are stripped, not escaped', () => {
1139
+ // A markdown→HTML slip that emitted an unsupported tag is exactly
1140
+ // what triggers Telegram's 400; the fallback must yield clean text.
1141
+ const out = telegramHtmlToPlainText('<h2>Title</h2><span class=x>body </span><unknowntag>tail')
1142
+ expect(out).toBe('Title\nbody tail')
1143
+ })
1144
+
1145
+ test('result is literal (parse_mode unset) — no re-escaping of < > &', () => {
1146
+ // We resend with parse_mode UNSET, so the output must be the raw
1147
+ // characters, not HTML entities.
1148
+ const out = telegramHtmlToPlainText('a &lt; b &amp;&amp; c &gt; d')
1149
+ expect(out).toBe('a < b && c > d')
1150
+ expect(out).not.toContain('&lt;')
1151
+ expect(out).not.toContain('&amp;')
1152
+ })
1153
+
1154
+ test('collapses 3+ blank lines and trims trailing line whitespace', () => {
1155
+ const out = telegramHtmlToPlainText('a \n\n\n\n\nb')
1156
+ expect(out).toBe('a\n\nb')
1157
+ })
1158
+
1159
+ test('nested formatting inside an anchor label is flattened', () => {
1160
+ const out = telegramHtmlToPlainText('<a href="https://x.io"><b>Big</b> link</a>')
1161
+ expect(out).toBe('Big link (https://x.io)')
1162
+ })
1163
+
1164
+ test('empty / whitespace input is safe', () => {
1165
+ expect(telegramHtmlToPlainText('')).toBe('')
1166
+ expect(telegramHtmlToPlainText(' \n ')).toBe('')
1167
+ })
1168
+
1169
+ test('pure-markup chunk collapses to empty (gateway substitutes a placeholder)', () => {
1170
+ // Documents the trigger for the empty-string guard in
1171
+ // gateway.ts:sendChunkPlainText — a chunk with no text content
1172
+ // strips to '', so the send path must substitute rather than
1173
+ // post an empty message (Telegram 400 "message text is empty").
1174
+ expect(telegramHtmlToPlainText('<b></b><i></i><br><span></span>')).toBe('')
1175
+ })
1176
+ })