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.
- package/README.md +7 -6
- package/dist/agent-scheduler/index.js +216 -97
- package/dist/auth-broker/index.js +175 -96
- package/dist/cli/drive-write-pretool.mjs +26 -11
- package/dist/cli/switchroom.js +45153 -42663
- package/dist/cli/ui/index.html +1281 -0
- package/dist/host-control/main.js +3628 -309
- package/dist/vault/approvals/kernel-server.js +207 -98
- package/dist/vault/broker/server.js +218 -97
- package/examples/personal-google-workspace-mcp/README.md +8 -3
- package/examples/switchroom.yaml +91 -42
- package/package.json +2 -2
- package/profiles/_base/start.sh.hbs +76 -36
- package/profiles/default/CLAUDE.md.hbs +4 -2
- package/skills/file-bug/SKILL.md +6 -4
- package/skills/switchroom-cli/SKILL.md +20 -4
- package/skills/switchroom-install/SKILL.md +3 -3
- package/telegram-plugin/auth-snapshot-format.ts +4 -4
- package/telegram-plugin/card-format.ts +3 -3
- package/telegram-plugin/dist/bridge/bridge.js +112 -112
- package/telegram-plugin/dist/gateway/gateway.js +795 -410
- package/telegram-plugin/dist/server.js +162 -161
- package/telegram-plugin/format.ts +71 -0
- package/telegram-plugin/gateway/approval-card.test.ts +18 -18
- package/telegram-plugin/gateway/approval-card.ts +1 -1
- package/telegram-plugin/gateway/auth-command.ts +2 -2
- package/telegram-plugin/gateway/boot-card.ts +40 -3
- package/telegram-plugin/gateway/boot-probes.ts +71 -27
- package/telegram-plugin/gateway/diff-preview-card.test.ts +15 -15
- package/telegram-plugin/gateway/diff-preview-card.ts +1 -1
- package/telegram-plugin/gateway/drive-write-approval.test.ts +2 -2
- package/telegram-plugin/gateway/gateway.ts +193 -22
- package/telegram-plugin/gateway/update-announce.ts +167 -0
- package/telegram-plugin/quota-check.ts +0 -195
- package/telegram-plugin/retry-api-call.ts +24 -0
- package/telegram-plugin/server.ts +8 -5
- package/telegram-plugin/tests/auth-add-flow.test.ts +31 -2
- package/telegram-plugin/tests/boot-probes.test.ts +53 -0
- package/telegram-plugin/tests/bot-runtime.test.ts +23 -1
- package/telegram-plugin/tests/quota-check.test.ts +0 -409
- package/telegram-plugin/tests/retry-api-call.test.ts +76 -0
- package/telegram-plugin/tests/telegram-format.test.ts +84 -1
- package/telegram-plugin/tests/update-announce.test.ts +154 -0
- package/telegram-plugin/welcome-text.ts +1 -8
- package/profiles/default/CLAUDE.md +0 -192
- package/skills/docx/scripts/office/validators/__pycache__/__init__.cpython-313.pyc +0 -0
- package/skills/docx/scripts/office/validators/__pycache__/base.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/generate_report.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/improve_description.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/run_eval.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/run_loop.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/utils.cpython-313.pyc +0 -0
- package/telegram-plugin/first-paint.ts +0 -225
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
- package/telegram-plugin/server.js +0 -41795
- package/telegram-plugin/tests/html-balanced.ts +0 -63
- package/telegram-plugin/tests/snapshot-serializer.ts +0 -79
- 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 & b <tag> "q" 's' 5 €')
|
|
1122
|
+
expect(out).toBe('a & b <tag> "q" \'s\' 5 €')
|
|
1123
|
+
})
|
|
1124
|
+
|
|
1125
|
+
test('numeric + hex char references decode', () => {
|
|
1126
|
+
expect(telegramHtmlToPlainText('→ →')).toBe('→ →')
|
|
1127
|
+
})
|
|
1128
|
+
|
|
1129
|
+
test('out-of-range / malformed char refs are left literal', () => {
|
|
1130
|
+
expect(telegramHtmlToPlainText('� � &#xZZ;')).toBe('� � &#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 < b && c > d')
|
|
1149
|
+
expect(out).toBe('a < b && c > d')
|
|
1150
|
+
expect(out).not.toContain('<')
|
|
1151
|
+
expect(out).not.toContain('&')
|
|
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
|
+
})
|