agent-messenger 2.24.0 → 2.25.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/.claude-plugin/plugin.json +1 -1
- package/.github/workflows/ci.yml +4 -0
- package/bun.lock +0 -71
- package/dist/package.json +4 -4
- package/dist/src/platforms/kakaotalk/token-extractor.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/token-extractor.js +11 -38
- package/dist/src/platforms/kakaotalk/token-extractor.js.map +1 -1
- package/dist/src/platforms/slack/commands/auth.d.ts.map +1 -1
- package/dist/src/platforms/slack/commands/auth.js +32 -5
- package/dist/src/platforms/slack/commands/auth.js.map +1 -1
- package/dist/src/platforms/slack/ensure-auth.d.ts +1 -0
- package/dist/src/platforms/slack/ensure-auth.d.ts.map +1 -1
- package/dist/src/platforms/slack/ensure-auth.js +47 -0
- package/dist/src/platforms/slack/ensure-auth.js.map +1 -1
- package/dist/src/platforms/slack/token-extractor.d.ts.map +1 -1
- package/dist/src/platforms/slack/token-extractor.js +5 -11
- package/dist/src/platforms/slack/token-extractor.js.map +1 -1
- package/dist/src/platforms/webex/client.d.ts +1 -0
- package/dist/src/platforms/webex/client.d.ts.map +1 -1
- package/dist/src/platforms/webex/client.js +17 -1
- package/dist/src/platforms/webex/client.js.map +1 -1
- package/dist/src/platforms/webex/commands/message.d.ts +4 -0
- package/dist/src/platforms/webex/commands/message.d.ts.map +1 -1
- package/dist/src/platforms/webex/commands/message.js +16 -1
- package/dist/src/platforms/webex/commands/message.js.map +1 -1
- package/dist/src/shared/chromium/cookie-reader.d.ts.map +1 -1
- package/dist/src/shared/chromium/cookie-reader.js +4 -19
- package/dist/src/shared/chromium/cookie-reader.js.map +1 -1
- package/dist/src/shared/sqlite.d.ts +10 -0
- package/dist/src/shared/sqlite.d.ts.map +1 -0
- package/dist/src/shared/sqlite.js +46 -0
- package/dist/src/shared/sqlite.js.map +1 -0
- package/docs/content/docs/cli/webex.mdx +7 -0
- package/docs/content/docs/sdk/webex.mdx +7 -0
- package/package.json +4 -4
- package/skills/agent-channeltalk/SKILL.md +1 -1
- package/skills/agent-channeltalkbot/SKILL.md +1 -1
- package/skills/agent-discord/SKILL.md +1 -1
- package/skills/agent-discordbot/SKILL.md +1 -1
- package/skills/agent-instagram/SKILL.md +1 -1
- package/skills/agent-kakaotalk/SKILL.md +1 -1
- package/skills/agent-line/SKILL.md +1 -1
- package/skills/agent-slack/SKILL.md +1 -1
- package/skills/agent-slackbot/SKILL.md +1 -1
- package/skills/agent-teams/SKILL.md +1 -1
- package/skills/agent-telegram/SKILL.md +1 -1
- package/skills/agent-telegrambot/SKILL.md +1 -1
- package/skills/agent-webex/SKILL.md +6 -1
- package/skills/agent-webexbot/SKILL.md +1 -1
- package/skills/agent-wechatbot/SKILL.md +1 -1
- package/skills/agent-whatsapp/SKILL.md +1 -1
- package/skills/agent-whatsappbot/SKILL.md +1 -1
- package/src/platforms/channeltalk/token-extractor.test.ts +2 -2
- package/src/platforms/kakaotalk/token-extractor.ts +13 -36
- package/src/platforms/slack/commands/auth.ts +33 -5
- package/src/platforms/slack/ensure-auth.test.ts +130 -19
- package/src/platforms/slack/ensure-auth.ts +51 -0
- package/src/platforms/slack/token-extractor-node-test.ts +5 -3
- package/src/platforms/slack/token-extractor.ts +4 -11
- package/src/platforms/webex/client.test.ts +74 -0
- package/src/platforms/webex/client.ts +20 -1
- package/src/platforms/webex/commands/message.test.ts +29 -1
- package/src/platforms/webex/commands/message.ts +18 -0
- package/src/shared/chromium/cookie-reader-node-test.ts +70 -0
- package/src/shared/chromium/cookie-reader-node.test.ts +10 -0
- package/src/shared/chromium/cookie-reader.ts +4 -21
- package/src/shared/sqlite.ts +61 -0
|
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, spyOn, it } from 'bun:test'
|
|
|
2
2
|
|
|
3
3
|
import { SlackClient } from './client'
|
|
4
4
|
import { CredentialManager } from './credential-manager'
|
|
5
|
-
import { ensureSlackAuth, refreshTokenFromWeb } from './ensure-auth'
|
|
5
|
+
import { ensureSlackAuth, refreshKnownWorkspaceDomains, refreshTokenFromWeb, tryWebTokenRefresh } from './ensure-auth'
|
|
6
6
|
import { TokenExtractor } from './token-extractor'
|
|
7
7
|
|
|
8
8
|
let getWorkspaceSpy: ReturnType<typeof spyOn>
|
|
@@ -358,8 +358,8 @@ describe('ensureSlackAuth', () => {
|
|
|
358
358
|
)
|
|
359
359
|
})
|
|
360
360
|
|
|
361
|
-
it('
|
|
362
|
-
// given —
|
|
361
|
+
it('enumerates all known domains to recover every authenticatable workspace', async () => {
|
|
362
|
+
// given — one stale extracted token, but the cookie is valid for two workspaces
|
|
363
363
|
extractSpy.mockResolvedValue([
|
|
364
364
|
{ workspace_id: 'T_TARGET', workspace_name: 'target', token: 'xoxc-stale', cookie: 'xoxd-valid' },
|
|
365
365
|
])
|
|
@@ -368,21 +368,27 @@ describe('ensureSlackAuth', () => {
|
|
|
368
368
|
T_OTHER: 'other-domain',
|
|
369
369
|
})
|
|
370
370
|
|
|
371
|
-
fetchSpy.
|
|
371
|
+
fetchSpy.mockImplementation((url: string) => {
|
|
372
|
+
if (url.startsWith('https://target-domain.slack.com/')) {
|
|
373
|
+
return Promise.resolve(new Response('<html>"api_token":"xoxc-fresh-target"</html>', { status: 200 }))
|
|
374
|
+
}
|
|
375
|
+
if (url.startsWith('https://other-domain.slack.com/')) {
|
|
376
|
+
return Promise.resolve(new Response('<html>"api_token":"xoxc-fresh-other"</html>', { status: 200 }))
|
|
377
|
+
}
|
|
378
|
+
return Promise.resolve(new Response('', { status: 500 }))
|
|
379
|
+
})
|
|
372
380
|
|
|
373
381
|
authResponseByToken({
|
|
374
|
-
'xoxc-fresh': { team_id: 'T_TARGET', team: 'Target' },
|
|
382
|
+
'xoxc-fresh-target': { team_id: 'T_TARGET', team: 'Target' },
|
|
383
|
+
'xoxc-fresh-other': { team_id: 'T_OTHER', team: 'Other' },
|
|
375
384
|
})
|
|
376
385
|
|
|
377
386
|
// when
|
|
378
387
|
await ensureSlackAuth()
|
|
379
388
|
|
|
380
|
-
// then —
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
expect.objectContaining({ headers: { Cookie: 'd=xoxd-valid' } }),
|
|
384
|
-
)
|
|
385
|
-
expect(fetchSpy).not.toHaveBeenCalledWith('https://other-domain.slack.com/ssb/redirect', expect.anything())
|
|
389
|
+
// then — both workspaces are recovered from the single cookie, each saved once
|
|
390
|
+
const savedIds = setWorkspaceSpy.mock.calls.map((c) => (c[0] as { workspace_id: string }).workspace_id)
|
|
391
|
+
expect(savedIds.sort()).toEqual(['T_OTHER', 'T_TARGET'])
|
|
386
392
|
})
|
|
387
393
|
|
|
388
394
|
it('returns failure when no known domain validates the cookie', async () => {
|
|
@@ -496,26 +502,131 @@ describe('ensureSlackAuth', () => {
|
|
|
496
502
|
expect(refreshCalls.length).toBe(2)
|
|
497
503
|
})
|
|
498
504
|
|
|
499
|
-
it('caps
|
|
500
|
-
// given — 20 candidate domains; only the first 16
|
|
501
|
-
extractSpy.mockResolvedValue([
|
|
502
|
-
{ workspace_id: 'unknown', workspace_name: 'unknown', token: 'xoxc-stale', cookie: 'xoxd-valid' },
|
|
503
|
-
])
|
|
505
|
+
it('caps single-candidate fallback at MAX_DOMAIN_ATTEMPTS', async () => {
|
|
506
|
+
// given — 20 candidate domains; the per-token fallback only probes the first 16
|
|
504
507
|
const manyDomains: Record<string, string> = {}
|
|
505
508
|
for (let i = 0; i < 20; i++) manyDomains[`T_${i}`] = `dom${i}`
|
|
506
|
-
getWorkspaceDomainsSpy.mockReturnValue(manyDomains)
|
|
507
|
-
|
|
508
509
|
fetchSpy.mockResolvedValue(new Response('', { status: 500 }))
|
|
509
510
|
testAuthSpy.mockRejectedValue(new Error('invalid_auth'))
|
|
510
511
|
|
|
511
512
|
// when
|
|
512
|
-
await
|
|
513
|
+
await tryWebTokenRefresh(
|
|
514
|
+
{ workspace_id: 'unknown', workspace_name: 'unknown', token: 'xoxc-stale', cookie: 'xoxd-valid' },
|
|
515
|
+
manyDomains,
|
|
516
|
+
)
|
|
513
517
|
|
|
514
518
|
// then — exactly 16 refresh attempts
|
|
515
519
|
const refreshCalls = fetchSpy.mock.calls.filter((c) => String(c[0]).includes('/ssb/redirect'))
|
|
516
520
|
expect(refreshCalls.length).toBe(16)
|
|
517
521
|
})
|
|
518
522
|
|
|
523
|
+
it('enumerates ALL known domains without the MAX_DOMAIN_ATTEMPTS cap', async () => {
|
|
524
|
+
// given — 20 candidate domains; enumeration must probe every one
|
|
525
|
+
const manyDomains: Record<string, string> = {}
|
|
526
|
+
for (let i = 0; i < 20; i++) manyDomains[`T_${i}`] = `dom${i}`
|
|
527
|
+
fetchSpy.mockResolvedValue(new Response('', { status: 500 }))
|
|
528
|
+
testAuthSpy.mockRejectedValue(new Error('invalid_auth'))
|
|
529
|
+
|
|
530
|
+
// when
|
|
531
|
+
await refreshKnownWorkspaceDomains('xoxd-valid', manyDomains)
|
|
532
|
+
|
|
533
|
+
// then — all 20 domains attempted
|
|
534
|
+
const refreshCalls = fetchSpy.mock.calls.filter((c) => String(c[0]).includes('/ssb/redirect'))
|
|
535
|
+
expect(refreshCalls.length).toBe(20)
|
|
536
|
+
})
|
|
537
|
+
|
|
538
|
+
it('dedupes by verified team_id even when no context is supplied', async () => {
|
|
539
|
+
// given — two domains whose cookie verifies to the same team_id, called without a context set
|
|
540
|
+
getWorkspaceDomainsSpy.mockReturnValue({})
|
|
541
|
+
fetchSpy.mockImplementation((url: string) => {
|
|
542
|
+
if (url.startsWith('https://primary.slack.com/')) {
|
|
543
|
+
return Promise.resolve(new Response('<html>"api_token":"xoxc-fresh-primary"</html>', { status: 200 }))
|
|
544
|
+
}
|
|
545
|
+
if (url.startsWith('https://alias.slack.com/')) {
|
|
546
|
+
return Promise.resolve(new Response('<html>"api_token":"xoxc-fresh-alias"</html>', { status: 200 }))
|
|
547
|
+
}
|
|
548
|
+
return Promise.resolve(new Response('', { status: 500 }))
|
|
549
|
+
})
|
|
550
|
+
authResponseByToken({
|
|
551
|
+
'xoxc-fresh-primary': { team_id: 'T_SAME', team: 'Same' },
|
|
552
|
+
'xoxc-fresh-alias': { team_id: 'T_SAME', team: 'Same' },
|
|
553
|
+
})
|
|
554
|
+
|
|
555
|
+
// when
|
|
556
|
+
const results = await refreshKnownWorkspaceDomains('xoxd-valid', { T_A: 'primary', T_B: 'alias' })
|
|
557
|
+
|
|
558
|
+
// then — the duplicate team is returned only once
|
|
559
|
+
expect(results.map((r) => r.workspace_id)).toEqual(['T_SAME'])
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
it('recovers all workspaces from one cookie when only one token is extracted', async () => {
|
|
563
|
+
// given — extractor yields one token deduped into two entries (one with a team id, one unknown),
|
|
564
|
+
// both sharing a single cookie that is valid for five workspaces
|
|
565
|
+
extractSpy.mockResolvedValue([
|
|
566
|
+
{ workspace_id: 'T1', workspace_name: 'unknown', token: 'xoxc-shared', cookie: 'xoxd-valid' },
|
|
567
|
+
{ workspace_id: 'unknown', workspace_name: 'unknown', token: 'xoxc-shared', cookie: 'xoxd-valid' },
|
|
568
|
+
])
|
|
569
|
+
getWorkspaceDomainsSpy.mockReturnValue({
|
|
570
|
+
T1: 'one',
|
|
571
|
+
T2: 'two',
|
|
572
|
+
T3: 'three',
|
|
573
|
+
T4: 'four',
|
|
574
|
+
T5: 'five',
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
const domainToToken: Record<string, string> = {
|
|
578
|
+
one: 'xoxc-fresh-1',
|
|
579
|
+
two: 'xoxc-fresh-2',
|
|
580
|
+
three: 'xoxc-fresh-3',
|
|
581
|
+
four: 'xoxc-fresh-4',
|
|
582
|
+
five: 'xoxc-fresh-5',
|
|
583
|
+
}
|
|
584
|
+
fetchSpy.mockImplementation((url: string) => {
|
|
585
|
+
const domain = String(url).match(/https:\/\/([^.]+)\.slack\.com/)?.[1] ?? ''
|
|
586
|
+
const token = domainToToken[domain]
|
|
587
|
+
return Promise.resolve(
|
|
588
|
+
token
|
|
589
|
+
? new Response(`<html>"api_token":"${token}"</html>`, { status: 200 })
|
|
590
|
+
: new Response('', { status: 500 }),
|
|
591
|
+
)
|
|
592
|
+
})
|
|
593
|
+
authResponseByToken({
|
|
594
|
+
'xoxc-shared': new Error('invalid_auth'),
|
|
595
|
+
'xoxc-fresh-1': { team_id: 'T1', team: 'one' },
|
|
596
|
+
'xoxc-fresh-2': { team_id: 'T2', team: 'two' },
|
|
597
|
+
'xoxc-fresh-3': { team_id: 'T3', team: 'three' },
|
|
598
|
+
'xoxc-fresh-4': { team_id: 'T4', team: 'four' },
|
|
599
|
+
'xoxc-fresh-5': { team_id: 'T5', team: 'five' },
|
|
600
|
+
})
|
|
601
|
+
|
|
602
|
+
// when
|
|
603
|
+
await ensureSlackAuth()
|
|
604
|
+
|
|
605
|
+
// then — all five teams recovered, each saved exactly once
|
|
606
|
+
const savedIds = setWorkspaceSpy.mock.calls.map((c) => (c[0] as { workspace_id: string }).workspace_id)
|
|
607
|
+
expect(savedIds.sort()).toEqual(['T1', 'T2', 'T3', 'T4', 'T5'])
|
|
608
|
+
})
|
|
609
|
+
|
|
610
|
+
it('keeps a valid extracted token without overwriting it via enumeration', async () => {
|
|
611
|
+
// given — a single extracted token that authenticates directly
|
|
612
|
+
extractSpy.mockResolvedValue([
|
|
613
|
+
{ workspace_id: 'T1', workspace_name: 'ws1', token: 'xoxc-good', cookie: 'xoxd-valid' },
|
|
614
|
+
])
|
|
615
|
+
getWorkspaceDomainsSpy.mockReturnValue({ T1: 'one' })
|
|
616
|
+
authResponseByToken({
|
|
617
|
+
'xoxc-good': { team_id: 'T1', team: 'ws1' },
|
|
618
|
+
'xoxc-fresh-1': { team_id: 'T1', team: 'ws1' },
|
|
619
|
+
})
|
|
620
|
+
fetchSpy.mockResolvedValue(new Response('<html>"api_token":"xoxc-fresh-1"</html>', { status: 200 }))
|
|
621
|
+
|
|
622
|
+
// when
|
|
623
|
+
await ensureSlackAuth()
|
|
624
|
+
|
|
625
|
+
// then — saved once with the original valid token, not the web-refreshed one
|
|
626
|
+
expect(setWorkspaceSpy).toHaveBeenCalledTimes(1)
|
|
627
|
+
expect(setWorkspaceSpy).toHaveBeenCalledWith(expect.objectContaining({ workspace_id: 'T1', token: 'xoxc-good' }))
|
|
628
|
+
})
|
|
629
|
+
|
|
519
630
|
it('skips web refresh when workspace has no cookie', async () => {
|
|
520
631
|
// given — cookie is empty
|
|
521
632
|
extractSpy.mockResolvedValue([
|
|
@@ -50,6 +50,23 @@ export async function ensureSlackAuth(): Promise<void> {
|
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
for (const cookie of new Set(workspaces.map((ws) => ws.cookie).filter(Boolean))) {
|
|
54
|
+
const enumerated = await refreshKnownWorkspaceDomains(cookie, workspaceDomains, {
|
|
55
|
+
resolvedTeamIds,
|
|
56
|
+
refreshCache,
|
|
57
|
+
})
|
|
58
|
+
for (const result of enumerated) {
|
|
59
|
+
const ws: ExtractedWorkspace = {
|
|
60
|
+
workspace_id: result.workspace_id,
|
|
61
|
+
workspace_name: result.workspace_name,
|
|
62
|
+
token: result.token,
|
|
63
|
+
cookie,
|
|
64
|
+
}
|
|
65
|
+
await credManager.setWorkspace(ws)
|
|
66
|
+
validWorkspaces.push(ws)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
53
70
|
const config = await credManager.load()
|
|
54
71
|
if (!config.current_workspace && validWorkspaces.length > 0) {
|
|
55
72
|
await credManager.setCurrentWorkspace(validWorkspaces[0].workspace_id)
|
|
@@ -78,6 +95,40 @@ export type RefreshContext = {
|
|
|
78
95
|
refreshCache?: Map<string, RefreshResult | null>
|
|
79
96
|
}
|
|
80
97
|
|
|
98
|
+
// A single valid Slack `d` cookie can authenticate every workspace the user is signed into.
|
|
99
|
+
// Given the workspace->domain map from root-state.json, enumerate ALL known domains via web
|
|
100
|
+
// refresh so that workspaces without their own extracted xoxc token are still recovered. The
|
|
101
|
+
// number of recoverable workspaces must not be bounded by the number of extracted token blobs.
|
|
102
|
+
export async function refreshKnownWorkspaceDomains(
|
|
103
|
+
cookie: string,
|
|
104
|
+
workspaceDomains: Record<string, string>,
|
|
105
|
+
context: RefreshContext = {},
|
|
106
|
+
): Promise<RefreshResult[]> {
|
|
107
|
+
if (!cookie) return []
|
|
108
|
+
|
|
109
|
+
const resolvedTeamIds = context.resolvedTeamIds ?? new Set<string>()
|
|
110
|
+
const results: RefreshResult[] = []
|
|
111
|
+
for (const domain of Object.values(workspaceDomains)) {
|
|
112
|
+
if (!SLACK_DOMAIN_REGEX.test(domain)) continue
|
|
113
|
+
|
|
114
|
+
const cacheKey = `${cookie}\u0000${domain}`
|
|
115
|
+
let result: RefreshResult | null
|
|
116
|
+
const cached = context.refreshCache?.get(cacheKey)
|
|
117
|
+
if (cached !== undefined) {
|
|
118
|
+
result = cached
|
|
119
|
+
} else {
|
|
120
|
+
result = await refreshAndVerify(cookie, domain, domain)
|
|
121
|
+
context.refreshCache?.set(cacheKey, result)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!result) continue
|
|
125
|
+
if (resolvedTeamIds.has(result.workspace_id)) continue
|
|
126
|
+
resolvedTeamIds.add(result.workspace_id)
|
|
127
|
+
results.push(result)
|
|
128
|
+
}
|
|
129
|
+
return results
|
|
130
|
+
}
|
|
131
|
+
|
|
81
132
|
export async function tryWebTokenRefresh(
|
|
82
133
|
ws: ExtractedWorkspace,
|
|
83
134
|
workspaceDomains: Record<string, string>,
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'
|
|
2
|
+
import { createRequire } from 'node:module'
|
|
2
3
|
import { tmpdir } from 'node:os'
|
|
3
4
|
import { join } from 'node:path'
|
|
4
5
|
|
|
5
|
-
import Database from 'better-sqlite3'
|
|
6
|
-
|
|
7
6
|
import { TokenExtractor } from './token-extractor'
|
|
8
7
|
|
|
8
|
+
const require = createRequire(import.meta.url)
|
|
9
|
+
const { DatabaseSync } = require('node:sqlite')
|
|
10
|
+
|
|
9
11
|
const tempDir = mkdtempSync(join(tmpdir(), 'token-extractor-test-'))
|
|
10
12
|
const slackDir = join(tempDir, 'Slack')
|
|
11
13
|
mkdirSync(slackDir)
|
|
@@ -13,7 +15,7 @@ mkdirSync(slackDir)
|
|
|
13
15
|
const dbPath = join(slackDir, 'Cookies')
|
|
14
16
|
|
|
15
17
|
try {
|
|
16
|
-
const db = new
|
|
18
|
+
const db = new DatabaseSync(dbPath)
|
|
17
19
|
db.exec(`
|
|
18
20
|
CREATE TABLE cookies (
|
|
19
21
|
name TEXT,
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { execSync } from 'node:child_process'
|
|
2
2
|
import { createDecipheriv, pbkdf2Sync } from 'node:crypto'
|
|
3
3
|
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs'
|
|
4
|
-
import { createRequire } from 'node:module'
|
|
5
4
|
import { homedir, tmpdir } from 'node:os'
|
|
6
5
|
import { join } from 'node:path'
|
|
7
6
|
|
|
@@ -16,11 +15,10 @@ import {
|
|
|
16
15
|
getBrowserBasePath,
|
|
17
16
|
getAgentBrowserProfileDirs,
|
|
18
17
|
} from '@/shared/chromium'
|
|
18
|
+
import { openReadonlyDatabase } from '@/shared/sqlite'
|
|
19
19
|
import { DerivedKeyCache } from '@/shared/utils/derived-key-cache'
|
|
20
20
|
import { lookupLinuxKeyringPassword } from '@/shared/utils/linux-keyring'
|
|
21
21
|
|
|
22
|
-
const require = createRequire(import.meta.url)
|
|
23
|
-
|
|
24
22
|
export interface ExtractedWorkspace {
|
|
25
23
|
workspace_id: string
|
|
26
24
|
workspace_name: string
|
|
@@ -872,16 +870,11 @@ export class TokenExtractor {
|
|
|
872
870
|
encrypted_value?: Uint8Array | Buffer
|
|
873
871
|
} | null
|
|
874
872
|
|
|
873
|
+
const db = openReadonlyDatabase(tempDbPath)
|
|
875
874
|
let row: CookieRow
|
|
876
|
-
|
|
877
|
-
const { Database } = require('bun:sqlite')
|
|
878
|
-
const db = new Database(tempDbPath, { readonly: true })
|
|
879
|
-
row = db.query(sql).get() as CookieRow
|
|
880
|
-
db.close()
|
|
881
|
-
} else {
|
|
882
|
-
const Database = require('better-sqlite3')
|
|
883
|
-
const db = new Database(tempDbPath, { readonly: true })
|
|
875
|
+
try {
|
|
884
876
|
row = db.prepare(sql).get() as CookieRow
|
|
877
|
+
} finally {
|
|
885
878
|
db.close()
|
|
886
879
|
}
|
|
887
880
|
|
|
@@ -760,6 +760,54 @@ describe('WebexClient', () => {
|
|
|
760
760
|
})
|
|
761
761
|
})
|
|
762
762
|
|
|
763
|
+
describe('setTyping', () => {
|
|
764
|
+
it('sends start_typing via internal API for extracted token', async () => {
|
|
765
|
+
// given
|
|
766
|
+
mockResponse(null, 204)
|
|
767
|
+
const client = await createExtractedClient()
|
|
768
|
+
|
|
769
|
+
// when
|
|
770
|
+
await client.setTyping(TEST_ROOM_ID)
|
|
771
|
+
|
|
772
|
+
// then
|
|
773
|
+
expect(fetchCalls).toHaveLength(1)
|
|
774
|
+
expect(fetchCalls[0].url).toContain('conv-r.wbx2.com/conversation/api/v1/conversations/')
|
|
775
|
+
expect(fetchCalls[0].url).toContain('/status/typing')
|
|
776
|
+
expect(fetchCalls[0].options?.method).toBe('POST')
|
|
777
|
+
const body = JSON.parse(fetchCalls[0].options?.body as string)
|
|
778
|
+
expect(body).toEqual({
|
|
779
|
+
conversationId: TEST_CONV_UUID,
|
|
780
|
+
eventType: 'status.start_typing',
|
|
781
|
+
})
|
|
782
|
+
})
|
|
783
|
+
|
|
784
|
+
it('sends stop_typing when typing is false', async () => {
|
|
785
|
+
// given
|
|
786
|
+
mockResponse(null, 204)
|
|
787
|
+
const client = await createExtractedClient()
|
|
788
|
+
|
|
789
|
+
// when
|
|
790
|
+
await client.setTyping(TEST_ROOM_ID, false)
|
|
791
|
+
|
|
792
|
+
// then
|
|
793
|
+
const body = JSON.parse(fetchCalls[0].options?.body as string)
|
|
794
|
+
expect(body.eventType).toBe('status.stop_typing')
|
|
795
|
+
expect(body.conversationId).toBe(TEST_CONV_UUID)
|
|
796
|
+
})
|
|
797
|
+
|
|
798
|
+
it('throws for non-internal token', async () => {
|
|
799
|
+
// given
|
|
800
|
+
const client = await new WebexClient().login({ token: 'plain-token' })
|
|
801
|
+
|
|
802
|
+
// when / then
|
|
803
|
+
await expect(client.setTyping('roomId')).rejects.toThrow(WebexError)
|
|
804
|
+
await expect(client.setTyping('roomId')).rejects.toThrow(
|
|
805
|
+
'Typing indicator requires an extracted or password token with a device URL',
|
|
806
|
+
)
|
|
807
|
+
expect(fetchCalls).toHaveLength(0)
|
|
808
|
+
})
|
|
809
|
+
})
|
|
810
|
+
|
|
763
811
|
describe('listMessages', () => {
|
|
764
812
|
it('calls GET on conversations endpoint with activitiesLimit and participantsLimit', async () => {
|
|
765
813
|
mockResponse(mockConversation([mockActivity('Hello')]))
|
|
@@ -1209,6 +1257,32 @@ describe('WebexClient', () => {
|
|
|
1209
1257
|
|
|
1210
1258
|
await expect(client.uploadFile(TEST_ROOM_ID, file())).resolves.toBeDefined()
|
|
1211
1259
|
})
|
|
1260
|
+
|
|
1261
|
+
it('accepts webexcontent.com upload urls returned by the server', async () => {
|
|
1262
|
+
mockResponse({ id: TEST_CONV_UUID })
|
|
1263
|
+
mockResponse({ spaceUrl: 'https://files-prod-us-west-2.webexcontent.com/spaces/sp1' })
|
|
1264
|
+
mockResponse({
|
|
1265
|
+
uploadUrl: 'https://files-prod-us-west-2.webexcontent.com/upload/sess1',
|
|
1266
|
+
finishUploadUrl: 'https://files-prod-us-west-2.webexcontent.com/upload/sess1/finish',
|
|
1267
|
+
})
|
|
1268
|
+
mockResponse({}, 200)
|
|
1269
|
+
mockResponse({ downloadUrl: 'https://files-prod-us-west-2.webexcontent.com/files/f1' })
|
|
1270
|
+
mockResponse({ ...mockActivity(''), verb: 'share' })
|
|
1271
|
+
|
|
1272
|
+
const client = await createExtractedClient()
|
|
1273
|
+
|
|
1274
|
+
await expect(client.uploadFile(TEST_ROOM_ID, file())).resolves.toBeDefined()
|
|
1275
|
+
})
|
|
1276
|
+
|
|
1277
|
+
it('refuses non-webex upload hosts that merely contain a trusted host', async () => {
|
|
1278
|
+
mockResponse({ id: TEST_CONV_UUID })
|
|
1279
|
+
mockResponse({ spaceUrl: 'https://webexcontent.com.evil.example/spaces/sp1' })
|
|
1280
|
+
|
|
1281
|
+
const client = await createExtractedClient()
|
|
1282
|
+
|
|
1283
|
+
await expect(client.uploadFile(TEST_ROOM_ID, file())).rejects.toThrow('untrusted host')
|
|
1284
|
+
expect(fetchCalls.every((c) => !c.url.includes('evil.example'))).toBe(true)
|
|
1285
|
+
})
|
|
1212
1286
|
})
|
|
1213
1287
|
|
|
1214
1288
|
describe('error handling', () => {
|
|
@@ -363,6 +363,23 @@ export class WebexClient {
|
|
|
363
363
|
return normalizeSdkMessage(await this.request<WebexMessage>('POST', '/messages', body))
|
|
364
364
|
}
|
|
365
365
|
|
|
366
|
+
async setTyping(roomId: string, typing: boolean = true): Promise<void> {
|
|
367
|
+
if (!this.useInternalAPI) {
|
|
368
|
+
throw new WebexError('Typing indicator requires an extracted or password token with a device URL', 'unsupported')
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const resolvedRoomId = await this.resolveRoomId(roomId)
|
|
372
|
+
const convUuid = this.decodeConvUuid(resolvedRoomId)
|
|
373
|
+
|
|
374
|
+
await this.internalRequest<void>(`/conversations/${convUuid}/status/typing`, {
|
|
375
|
+
method: 'POST',
|
|
376
|
+
body: JSON.stringify({
|
|
377
|
+
conversationId: convUuid,
|
|
378
|
+
eventType: typing ? 'status.start_typing' : 'status.stop_typing',
|
|
379
|
+
}),
|
|
380
|
+
})
|
|
381
|
+
}
|
|
382
|
+
|
|
366
383
|
private get useInternalAPI(): boolean {
|
|
367
384
|
return (this.tokenType === 'extracted' || this.tokenType === 'password') && this.deviceUrl !== null
|
|
368
385
|
}
|
|
@@ -980,7 +997,9 @@ function isTrustedWebexHost(host: string): boolean {
|
|
|
980
997
|
host === 'wbx2.com' ||
|
|
981
998
|
host.endsWith('.wbx2.com') ||
|
|
982
999
|
host === 'ciscospark.com' ||
|
|
983
|
-
host.endsWith('.ciscospark.com')
|
|
1000
|
+
host.endsWith('.ciscospark.com') ||
|
|
1001
|
+
host === 'webexcontent.com' ||
|
|
1002
|
+
host.endsWith('.webexcontent.com')
|
|
984
1003
|
)
|
|
985
1004
|
}
|
|
986
1005
|
|
|
@@ -37,7 +37,7 @@ const mockMessage2 = {
|
|
|
37
37
|
created: '2025-01-29T10:01:00.000Z',
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
import { deleteAction, dmAction, editAction, getAction, listAction, sendAction } from './message'
|
|
40
|
+
import { deleteAction, dmAction, editAction, getAction, listAction, sendAction, typingAction } from './message'
|
|
41
41
|
|
|
42
42
|
let mockSendMessage: ReturnType<typeof spyOn>
|
|
43
43
|
let mockSendDirectMessage: ReturnType<typeof spyOn>
|
|
@@ -45,6 +45,7 @@ let mockListMessages: ReturnType<typeof spyOn>
|
|
|
45
45
|
let mockGetMessage: ReturnType<typeof spyOn>
|
|
46
46
|
let mockDeleteMessage: ReturnType<typeof spyOn>
|
|
47
47
|
let mockEditMessage: ReturnType<typeof spyOn>
|
|
48
|
+
let mockSetTyping: ReturnType<typeof spyOn>
|
|
48
49
|
let mockLogin: ReturnType<typeof spyOn>
|
|
49
50
|
let mockDispose: ReturnType<typeof spyOn>
|
|
50
51
|
let consoleLogSpy: ReturnType<typeof spyOn>
|
|
@@ -69,6 +70,7 @@ beforeEach(() => {
|
|
|
69
70
|
mockGetMessage = protoSpy('getMessage').mockResolvedValue(mockMessage)
|
|
70
71
|
mockDeleteMessage = protoSpy('deleteMessage').mockResolvedValue(undefined)
|
|
71
72
|
mockEditMessage = protoSpy('editMessage').mockResolvedValue({ ...mockMessage, text: 'Updated message' })
|
|
73
|
+
mockSetTyping = protoSpy('setTyping').mockResolvedValue(undefined)
|
|
72
74
|
|
|
73
75
|
consoleLogSpy = spyOn(console, 'log').mockImplementation(() => {})
|
|
74
76
|
consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {})
|
|
@@ -205,3 +207,29 @@ it('passes markdown option to editMessage when --markdown flag is set', async ()
|
|
|
205
207
|
markdown: true,
|
|
206
208
|
})
|
|
207
209
|
})
|
|
210
|
+
|
|
211
|
+
it('calls setTyping with start-typing by default and outputs result', async () => {
|
|
212
|
+
await typingAction('space_456', { pretty: false })
|
|
213
|
+
|
|
214
|
+
expect(mockSetTyping).toHaveBeenCalledWith('space_456', true)
|
|
215
|
+
expect(consoleLogSpy).toHaveBeenCalled()
|
|
216
|
+
const output = JSON.parse(consoleLogSpy.mock.calls[0][0] as string)
|
|
217
|
+
expect(output).toEqual({ spaceId: 'space_456', typing: true })
|
|
218
|
+
expect(mockDispose).toHaveBeenCalled()
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('calls setTyping with stop-typing when --stop flag is set', async () => {
|
|
222
|
+
await typingAction('space_456', { stop: true, pretty: false })
|
|
223
|
+
|
|
224
|
+
expect(mockSetTyping).toHaveBeenCalledWith('space_456', false)
|
|
225
|
+
const output = JSON.parse(consoleLogSpy.mock.calls[0][0] as string)
|
|
226
|
+
expect(output).toEqual({ spaceId: 'space_456', typing: false })
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('disposes the client when setTyping fails', async () => {
|
|
230
|
+
mockSetTyping.mockRejectedValue(new WebexError('Typing failed', 'unsupported'))
|
|
231
|
+
|
|
232
|
+
await typingAction('space_456', { pretty: false })
|
|
233
|
+
|
|
234
|
+
expect(mockDispose).toHaveBeenCalled()
|
|
235
|
+
})
|
|
@@ -116,6 +116,16 @@ export async function dmAction(
|
|
|
116
116
|
}
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
export async function typingAction(spaceId: string, options: { stop?: boolean; pretty?: boolean }): Promise<void> {
|
|
120
|
+
try {
|
|
121
|
+
await withWebexClient((client) => client.setTyping(spaceId, !options.stop))
|
|
122
|
+
|
|
123
|
+
console.log(formatOutput({ spaceId, typing: !options.stop }, options.pretty))
|
|
124
|
+
} catch (error) {
|
|
125
|
+
handleError(error as Error)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
119
129
|
export const messageCommand = new Command('message')
|
|
120
130
|
.description('Message commands')
|
|
121
131
|
.addCommand(
|
|
@@ -174,3 +184,11 @@ export const messageCommand = new Command('message')
|
|
|
174
184
|
.option('--pretty', 'Pretty print JSON output')
|
|
175
185
|
.action(editAction),
|
|
176
186
|
)
|
|
187
|
+
.addCommand(
|
|
188
|
+
new Command('typing')
|
|
189
|
+
.description('Send a typing indicator to a space')
|
|
190
|
+
.argument('<space-id>', 'Space/Room ID')
|
|
191
|
+
.option('--stop', 'Send stop-typing instead of start-typing')
|
|
192
|
+
.option('--pretty', 'Pretty print JSON output')
|
|
193
|
+
.action(typingAction),
|
|
194
|
+
)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
2
|
+
import { createRequire } from 'node:module'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
import { ChromiumCookieReader } from '@/shared/chromium'
|
|
7
|
+
|
|
8
|
+
const require = createRequire(import.meta.url)
|
|
9
|
+
const { DatabaseSync } = require('node:sqlite')
|
|
10
|
+
|
|
11
|
+
function fail(message: string): never {
|
|
12
|
+
console.error(message)
|
|
13
|
+
process.exit(1)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const tempDir = mkdtempSync(join(tmpdir(), 'cookie-reader-node-test-'))
|
|
17
|
+
const dbPath = join(tempDir, 'Cookies')
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const db = new DatabaseSync(dbPath)
|
|
21
|
+
db.exec('CREATE TABLE cookies (name TEXT, value TEXT, encrypted_value BLOB, host_key TEXT, last_access_utc INTEGER)')
|
|
22
|
+
const insert = db.prepare(
|
|
23
|
+
'INSERT INTO cookies (name, value, encrypted_value, host_key, last_access_utc) VALUES (?, ?, ?, ?, ?)',
|
|
24
|
+
)
|
|
25
|
+
// Chromium *_utc columns are microseconds since 1601; real values exceed
|
|
26
|
+
// Number.MAX_SAFE_INTEGER, which node:sqlite throws on when SELECTed.
|
|
27
|
+
insert.run('d', 'xoxd-newer', null, '.slack.com', 13380000000000200n)
|
|
28
|
+
insert.run('d', 'xoxd-older', null, '.slack.com', 13380000000000100n)
|
|
29
|
+
insert.run('other', 'ignored', null, '.example.com', 13380000000000300n)
|
|
30
|
+
insert.run('enc', '', Buffer.from('v10-secret-bytes'), '.slack.com', 13380000000000100n)
|
|
31
|
+
db.close()
|
|
32
|
+
|
|
33
|
+
if (typeof (globalThis as { Bun?: unknown }).Bun !== 'undefined') {
|
|
34
|
+
fail('Expected Node.js runtime (node:sqlite), but Bun was detected')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const reader = new ChromiumCookieReader()
|
|
38
|
+
|
|
39
|
+
const first = await reader.queryFirst<{ value: string }>(
|
|
40
|
+
dbPath,
|
|
41
|
+
"SELECT value FROM cookies WHERE name = 'd' AND host_key LIKE ? ORDER BY last_access_utc DESC LIMIT 1",
|
|
42
|
+
['%slack.com%'],
|
|
43
|
+
)
|
|
44
|
+
if (first?.value !== 'xoxd-newer') fail(`queryFirst expected xoxd-newer, got ${String(first?.value)}`)
|
|
45
|
+
|
|
46
|
+
const all = await reader.queryAll<{ value: string }>(
|
|
47
|
+
dbPath,
|
|
48
|
+
"SELECT value FROM cookies WHERE name = 'd' ORDER BY last_access_utc DESC",
|
|
49
|
+
)
|
|
50
|
+
if (all.length !== 2) fail(`queryAll expected 2 rows, got ${all.length}`)
|
|
51
|
+
if (all[0]?.value !== 'xoxd-newer') fail('queryAll expected newest-first ordering')
|
|
52
|
+
|
|
53
|
+
const none = await reader.queryFirst(dbPath, "SELECT value FROM cookies WHERE host_key = 'no.match'")
|
|
54
|
+
if (none !== null) fail(`no-row queryFirst expected null, got ${String(none)}`)
|
|
55
|
+
|
|
56
|
+
// node:sqlite returns BLOBs as Uint8Array (not Buffer); Buffer.from must
|
|
57
|
+
// preserve the bytes so cookie decryption keeps working
|
|
58
|
+
const encrypted = await reader.queryFirst<{ encrypted_value?: Uint8Array }>(
|
|
59
|
+
dbPath,
|
|
60
|
+
"SELECT encrypted_value FROM cookies WHERE name = 'enc' LIMIT 1",
|
|
61
|
+
)
|
|
62
|
+
if (!(encrypted?.encrypted_value instanceof Uint8Array)) fail('encrypted_value expected Uint8Array')
|
|
63
|
+
if (Buffer.from(encrypted.encrypted_value).toString('utf8') !== 'v10-secret-bytes') {
|
|
64
|
+
fail('Buffer.from(BLOB) did not round-trip the original bytes')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
console.log('ok')
|
|
68
|
+
} finally {
|
|
69
|
+
rmSync(tempDir, { recursive: true })
|
|
70
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { expect, it } from 'bun:test'
|
|
2
|
+
import { execSync } from 'node:child_process'
|
|
3
|
+
|
|
4
|
+
it('ChromiumCookieReader works in Node.js (node:sqlite)', () => {
|
|
5
|
+
const result = execSync('bun tsx src/shared/chromium/cookie-reader-node-test.ts', {
|
|
6
|
+
cwd: process.cwd(),
|
|
7
|
+
encoding: 'utf-8',
|
|
8
|
+
})
|
|
9
|
+
expect(result.trim()).toBe('ok')
|
|
10
|
+
})
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { copyFileSync, existsSync, rmSync } from 'node:fs'
|
|
2
|
-
import { createRequire } from 'node:module'
|
|
3
2
|
import { tmpdir } from 'node:os'
|
|
4
3
|
import { join } from 'node:path'
|
|
5
4
|
|
|
6
|
-
|
|
5
|
+
import { openReadonlyDatabase } from '@/shared/sqlite'
|
|
7
6
|
|
|
8
7
|
/**
|
|
9
8
|
* Reads Chromium SQLite cookie databases with Bun/Node dual-runtime support.
|
|
@@ -70,24 +69,7 @@ export class ChromiumCookieReader {
|
|
|
70
69
|
params: unknown[] | undefined,
|
|
71
70
|
mode: 'all' | 'first',
|
|
72
71
|
): T[] | T | null {
|
|
73
|
-
|
|
74
|
-
const { Database } = require('bun:sqlite')
|
|
75
|
-
const db = new Database(tempPath, { readonly: true })
|
|
76
|
-
|
|
77
|
-
try {
|
|
78
|
-
const stmt = db.query(sql)
|
|
79
|
-
if (mode === 'all') {
|
|
80
|
-
return (params ? stmt.all(...params) : stmt.all()) as T[]
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
return (params ? stmt.get(...params) : stmt.get()) as T | null
|
|
84
|
-
} finally {
|
|
85
|
-
db.close()
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
const Database = require('better-sqlite3')
|
|
90
|
-
const db = new Database(tempPath, { readonly: true })
|
|
72
|
+
const db = openReadonlyDatabase(tempPath)
|
|
91
73
|
|
|
92
74
|
try {
|
|
93
75
|
const stmt = db.prepare(sql)
|
|
@@ -95,7 +77,8 @@ export class ChromiumCookieReader {
|
|
|
95
77
|
return (params ? stmt.all(...params) : stmt.all()) as T[]
|
|
96
78
|
}
|
|
97
79
|
|
|
98
|
-
|
|
80
|
+
const row = params ? stmt.get(...params) : stmt.get()
|
|
81
|
+
return (row ?? null) as T | null
|
|
99
82
|
} finally {
|
|
100
83
|
db.close()
|
|
101
84
|
}
|