agent-messenger 2.32.0 → 2.32.1
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/bun.lock +7 -0
- package/dist/package.json +2 -1
- package/dist/src/platforms/channeltalk/token-extractor.d.ts.map +1 -1
- package/dist/src/platforms/channeltalk/token-extractor.js +67 -18
- package/dist/src/platforms/channeltalk/token-extractor.js.map +1 -1
- package/dist/src/platforms/discord/commands/channel.d.ts.map +1 -1
- package/dist/src/platforms/discord/commands/channel.js +3 -2
- package/dist/src/platforms/discord/commands/channel.js.map +1 -1
- package/dist/src/platforms/discord/commands/snapshot.d.ts.map +1 -1
- package/dist/src/platforms/discord/commands/snapshot.js +7 -6
- package/dist/src/platforms/discord/commands/snapshot.js.map +1 -1
- package/dist/src/platforms/discord/types.d.ts +17 -0
- package/dist/src/platforms/discord/types.d.ts.map +1 -1
- package/dist/src/platforms/discord/types.js +36 -0
- package/dist/src/platforms/discord/types.js.map +1 -1
- package/dist/src/platforms/slack/index.d.ts +1 -0
- package/dist/src/platforms/slack/index.d.ts.map +1 -1
- package/dist/src/platforms/slack/index.js.map +1 -1
- package/dist/src/platforms/slack/qr-confirmation.d.ts +19 -0
- package/dist/src/platforms/slack/qr-confirmation.d.ts.map +1 -0
- package/dist/src/platforms/slack/qr-confirmation.js +68 -0
- package/dist/src/platforms/slack/qr-confirmation.js.map +1 -0
- package/dist/src/platforms/slack/qr-cookie-jar.d.ts +7 -0
- package/dist/src/platforms/slack/qr-cookie-jar.d.ts.map +1 -0
- package/dist/src/platforms/slack/qr-cookie-jar.js +48 -0
- package/dist/src/platforms/slack/qr-cookie-jar.js.map +1 -0
- package/dist/src/platforms/slack/qr-http-login.d.ts +2 -1
- package/dist/src/platforms/slack/qr-http-login.d.ts.map +1 -1
- package/dist/src/platforms/slack/qr-http-login.js +88 -53
- package/dist/src/platforms/slack/qr-http-login.js.map +1 -1
- package/docs/content/docs/cli/channeltalk.mdx +4 -4
- package/package.json +2 -1
- package/skills/agent-channeltalk/SKILL.md +5 -5
- package/skills/agent-channeltalk/references/authentication.md +9 -5
- package/skills/agent-channeltalkbot/SKILL.md +1 -1
- package/skills/agent-discord/SKILL.md +8 -4
- package/skills/agent-discord/references/common-patterns.md +7 -3
- package/skills/agent-discordbot/SKILL.md +1 -1
- package/skills/agent-imessage/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 +25 -2
- 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 +1 -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 +235 -0
- package/src/platforms/channeltalk/token-extractor.ts +81 -22
- package/src/platforms/discord/commands/channel.test.ts +33 -23
- package/src/platforms/discord/commands/channel.ts +3 -2
- package/src/platforms/discord/commands/snapshot.test.ts +80 -2
- package/src/platforms/discord/commands/snapshot.ts +7 -6
- package/src/platforms/discord/types.test.ts +45 -0
- package/src/platforms/discord/types.ts +41 -0
- package/src/platforms/slack/index.ts +1 -0
- package/src/platforms/slack/qr-confirmation.ts +86 -0
- package/src/platforms/slack/qr-cookie-jar.ts +54 -0
- package/src/platforms/slack/qr-http-login-2fa.test.ts +398 -0
- package/src/platforms/slack/qr-http-login.test.ts +10 -18
- package/src/platforms/slack/qr-http-login.ts +109 -52
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: agent-webexbot
|
|
3
3
|
description: Interact with Cisco Webex using bot tokens - send messages, reply in threads, upload and download files, look up people, read spaces, manage memberships, stream real-time events
|
|
4
|
-
version: 2.32.
|
|
4
|
+
version: 2.32.1
|
|
5
5
|
allowed-tools: Bash(agent-webexbot:*)
|
|
6
6
|
metadata:
|
|
7
7
|
openclaw:
|
|
@@ -18,10 +18,60 @@ describe('ChannelTokenExtractor', () => {
|
|
|
18
18
|
})
|
|
19
19
|
|
|
20
20
|
describe('getAppDataDir', () => {
|
|
21
|
+
const originalAppData = process.env.APPDATA
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
if (originalAppData === undefined) delete process.env.APPDATA
|
|
25
|
+
else process.env.APPDATA = originalAppData
|
|
26
|
+
})
|
|
27
|
+
|
|
21
28
|
it('returns null for unsupported platform', () => {
|
|
22
29
|
const extractor = new ChannelTokenExtractor('freebsd' as NodeJS.Platform)
|
|
23
30
|
expect(extractor.getAppDataDir()).toBeNull()
|
|
24
31
|
})
|
|
32
|
+
|
|
33
|
+
it('resolves the rebranded Channel Works directory', () => {
|
|
34
|
+
// given
|
|
35
|
+
const tempDir = mkdtempSync(join(tmpdir(), 'channel-appdata-'))
|
|
36
|
+
tempDirs.push(tempDir)
|
|
37
|
+
mkdirSync(join(tempDir, 'Channel Works'), { recursive: true })
|
|
38
|
+
process.env.APPDATA = tempDir
|
|
39
|
+
|
|
40
|
+
// when
|
|
41
|
+
const extractor = new ChannelTokenExtractor('win32')
|
|
42
|
+
|
|
43
|
+
// then
|
|
44
|
+
expect(extractor.getAppDataDir()).toBe(join(tempDir, 'Channel Works'))
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('falls back to the legacy Channel Talk directory', () => {
|
|
48
|
+
// given
|
|
49
|
+
const tempDir = mkdtempSync(join(tmpdir(), 'channel-appdata-'))
|
|
50
|
+
tempDirs.push(tempDir)
|
|
51
|
+
mkdirSync(join(tempDir, 'Channel Talk'), { recursive: true })
|
|
52
|
+
process.env.APPDATA = tempDir
|
|
53
|
+
|
|
54
|
+
// when
|
|
55
|
+
const extractor = new ChannelTokenExtractor('win32')
|
|
56
|
+
|
|
57
|
+
// then
|
|
58
|
+
expect(extractor.getAppDataDir()).toBe(join(tempDir, 'Channel Talk'))
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('prefers Channel Works when an upgraded install keeps both directories', () => {
|
|
62
|
+
// given
|
|
63
|
+
const tempDir = mkdtempSync(join(tmpdir(), 'channel-appdata-'))
|
|
64
|
+
tempDirs.push(tempDir)
|
|
65
|
+
mkdirSync(join(tempDir, 'Channel Talk'), { recursive: true })
|
|
66
|
+
mkdirSync(join(tempDir, 'Channel Works'), { recursive: true })
|
|
67
|
+
process.env.APPDATA = tempDir
|
|
68
|
+
|
|
69
|
+
// when
|
|
70
|
+
const extractor = new ChannelTokenExtractor('win32')
|
|
71
|
+
|
|
72
|
+
// then
|
|
73
|
+
expect(extractor.getAppDataDir()).toBe(join(tempDir, 'Channel Works'))
|
|
74
|
+
})
|
|
25
75
|
})
|
|
26
76
|
|
|
27
77
|
describe('getCookiesPath', () => {
|
|
@@ -173,6 +223,191 @@ describe('ChannelTokenExtractor', () => {
|
|
|
173
223
|
])
|
|
174
224
|
})
|
|
175
225
|
|
|
226
|
+
it('ignores look-alike hosts that merely contain a channel domain', async () => {
|
|
227
|
+
const tempDir = mkdtempSync(join(tmpdir(), 'channel-cookie-db-'))
|
|
228
|
+
tempDirs.push(tempDir)
|
|
229
|
+
const dbPath = join(tempDir, 'Cookies')
|
|
230
|
+
// These hosts all contain a channel domain as a substring but are not the domain nor a
|
|
231
|
+
// subdomain of it, so nobody who controls one may speak for Channel. They are inserted
|
|
232
|
+
// first so a substring match would let them shadow the real cookies below.
|
|
233
|
+
await createCookieDatabase(dbPath, [
|
|
234
|
+
{
|
|
235
|
+
name: 'x-account',
|
|
236
|
+
value: 'imposter-account',
|
|
237
|
+
encrypted_value: Buffer.alloc(0),
|
|
238
|
+
host_key: '.channel.works.example.com',
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
name: 'ch-session-1',
|
|
242
|
+
value: 'imposter-session',
|
|
243
|
+
encrypted_value: Buffer.alloc(0),
|
|
244
|
+
host_key: '.channel.works.example.com',
|
|
245
|
+
},
|
|
246
|
+
{ name: 'x-account', value: 'imposter-io', encrypted_value: Buffer.alloc(0), host_key: '.channel.io.evil.net' },
|
|
247
|
+
{ name: 'x-account', value: 'real-account', encrypted_value: Buffer.alloc(0), host_key: '.channel.works' },
|
|
248
|
+
{ name: 'ch-session-1', value: 'real-session', encrypted_value: Buffer.alloc(0), host_key: '.channel.works' },
|
|
249
|
+
])
|
|
250
|
+
|
|
251
|
+
class TestExtractor extends ChannelTokenExtractor {
|
|
252
|
+
constructor(private dbPath: string) {
|
|
253
|
+
super('darwin')
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
override getCookiesPath(): string | null {
|
|
257
|
+
return this.dbPath
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const extractor = new TestExtractor(dbPath)
|
|
262
|
+
const browserSpy = spyOn(extractor as any, 'extractAllFromBrowserPaths').mockResolvedValue([])
|
|
263
|
+
|
|
264
|
+
expect(await extractor.extract()).toEqual([
|
|
265
|
+
{
|
|
266
|
+
accountCookie: 'real-account',
|
|
267
|
+
sessionCookie: 'real-session',
|
|
268
|
+
},
|
|
269
|
+
])
|
|
270
|
+
|
|
271
|
+
browserSpy.mockRestore()
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('extracts cookies from a subdomain of a channel domain', async () => {
|
|
275
|
+
const tempDir = mkdtempSync(join(tmpdir(), 'channel-cookie-db-'))
|
|
276
|
+
tempDirs.push(tempDir)
|
|
277
|
+
const dbPath = join(tempDir, 'Cookies')
|
|
278
|
+
// The boundary check must not be so strict that it drops legitimate subdomains.
|
|
279
|
+
await createCookieDatabase(dbPath, [
|
|
280
|
+
{ name: 'x-account', value: 'account-jwt', encrypted_value: Buffer.alloc(0), host_key: '.desk.channel.works' },
|
|
281
|
+
{ name: 'ch-session-1', value: 'session-jwt', encrypted_value: Buffer.alloc(0), host_key: 'channel.works' },
|
|
282
|
+
])
|
|
283
|
+
|
|
284
|
+
class TestExtractor extends ChannelTokenExtractor {
|
|
285
|
+
constructor(private dbPath: string) {
|
|
286
|
+
super('darwin')
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
override getCookiesPath(): string | null {
|
|
290
|
+
return this.dbPath
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const extractor = new TestExtractor(dbPath)
|
|
295
|
+
const browserSpy = spyOn(extractor as any, 'extractAllFromBrowserPaths').mockResolvedValue([])
|
|
296
|
+
|
|
297
|
+
expect(await extractor.extract()).toEqual([
|
|
298
|
+
{
|
|
299
|
+
accountCookie: 'account-jwt',
|
|
300
|
+
sessionCookie: 'session-jwt',
|
|
301
|
+
},
|
|
302
|
+
])
|
|
303
|
+
|
|
304
|
+
browserSpy.mockRestore()
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it('prefers channel.works cookies over stale legacy channel.io cookies', async () => {
|
|
308
|
+
const tempDir = mkdtempSync(join(tmpdir(), 'channel-cookie-db-'))
|
|
309
|
+
tempDirs.push(tempDir)
|
|
310
|
+
const dbPath = join(tempDir, 'Cookies')
|
|
311
|
+
// Legacy rows are inserted first so they win a naive find() that ignores the domain.
|
|
312
|
+
await createCookieDatabase(dbPath, [
|
|
313
|
+
{ name: 'x-account', value: 'stale-account', encrypted_value: Buffer.alloc(0), host_key: '.channel.io' },
|
|
314
|
+
{ name: 'ch-session-1', value: 'stale-session', encrypted_value: Buffer.alloc(0), host_key: '.channel.io' },
|
|
315
|
+
{ name: 'x-account', value: 'live-account', encrypted_value: Buffer.alloc(0), host_key: '.channel.works' },
|
|
316
|
+
{ name: 'ch-session-1', value: 'live-session', encrypted_value: Buffer.alloc(0), host_key: '.channel.works' },
|
|
317
|
+
])
|
|
318
|
+
|
|
319
|
+
class TestExtractor extends ChannelTokenExtractor {
|
|
320
|
+
constructor(private dbPath: string) {
|
|
321
|
+
super('darwin')
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
override getCookiesPath(): string | null {
|
|
325
|
+
return this.dbPath
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const extractor = new TestExtractor(dbPath)
|
|
330
|
+
const browserSpy = spyOn(extractor as any, 'extractAllFromBrowserPaths').mockResolvedValue([])
|
|
331
|
+
|
|
332
|
+
expect(await extractor.extract()).toEqual([
|
|
333
|
+
{
|
|
334
|
+
accountCookie: 'live-account',
|
|
335
|
+
sessionCookie: 'live-session',
|
|
336
|
+
},
|
|
337
|
+
])
|
|
338
|
+
|
|
339
|
+
browserSpy.mockRestore()
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
it('never pairs an x-account with a session cookie from the other domain', async () => {
|
|
343
|
+
const tempDir = mkdtempSync(join(tmpdir(), 'channel-cookie-db-'))
|
|
344
|
+
tempDirs.push(tempDir)
|
|
345
|
+
const dbPath = join(tempDir, 'Cookies')
|
|
346
|
+
// channel.works holds an identity but no session; the only session belongs to channel.io.
|
|
347
|
+
// The live identity must be returned session-less rather than borrowing the legacy session,
|
|
348
|
+
// which belongs to a different domain and would not authenticate it.
|
|
349
|
+
await createCookieDatabase(dbPath, [
|
|
350
|
+
{ name: 'x-account', value: 'works-account', encrypted_value: Buffer.alloc(0), host_key: '.channel.works' },
|
|
351
|
+
{ name: 'x-account', value: 'io-account', encrypted_value: Buffer.alloc(0), host_key: '.channel.io' },
|
|
352
|
+
{ name: 'ch-session-1', value: 'io-session', encrypted_value: Buffer.alloc(0), host_key: '.channel.io' },
|
|
353
|
+
])
|
|
354
|
+
|
|
355
|
+
class TestExtractor extends ChannelTokenExtractor {
|
|
356
|
+
constructor(private dbPath: string) {
|
|
357
|
+
super('darwin')
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
override getCookiesPath(): string | null {
|
|
361
|
+
return this.dbPath
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const extractor = new TestExtractor(dbPath)
|
|
366
|
+
const browserSpy = spyOn(extractor as any, 'extractAllFromBrowserPaths').mockResolvedValue([])
|
|
367
|
+
|
|
368
|
+
expect(await extractor.extract()).toEqual([
|
|
369
|
+
{
|
|
370
|
+
accountCookie: 'works-account',
|
|
371
|
+
sessionCookie: undefined,
|
|
372
|
+
},
|
|
373
|
+
])
|
|
374
|
+
|
|
375
|
+
browserSpy.mockRestore()
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
it('extracts cookies stored on the rebranded channel.works domain', async () => {
|
|
379
|
+
const tempDir = mkdtempSync(join(tmpdir(), 'channel-cookie-db-'))
|
|
380
|
+
tempDirs.push(tempDir)
|
|
381
|
+
const dbPath = join(tempDir, 'Cookies')
|
|
382
|
+
await createCookieDatabase(dbPath, [
|
|
383
|
+
{ name: 'x-account', value: 'account-jwt', encrypted_value: Buffer.alloc(0), host_key: '.channel.works' },
|
|
384
|
+
{ name: 'ch-session-1', value: 'session-jwt', encrypted_value: Buffer.alloc(0), host_key: '.channel.works' },
|
|
385
|
+
])
|
|
386
|
+
|
|
387
|
+
class TestExtractor extends ChannelTokenExtractor {
|
|
388
|
+
constructor(private dbPath: string) {
|
|
389
|
+
super('darwin')
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
override getCookiesPath(): string | null {
|
|
393
|
+
return this.dbPath
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const extractor = new TestExtractor(dbPath)
|
|
398
|
+
// Browser profiles are stubbed out so a real logged-in browser on the host cannot leak in.
|
|
399
|
+
const browserSpy = spyOn(extractor as any, 'extractAllFromBrowserPaths').mockResolvedValue([])
|
|
400
|
+
|
|
401
|
+
expect(await extractor.extract()).toEqual([
|
|
402
|
+
{
|
|
403
|
+
accountCookie: 'account-jwt',
|
|
404
|
+
sessionCookie: 'session-jwt',
|
|
405
|
+
},
|
|
406
|
+
])
|
|
407
|
+
|
|
408
|
+
browserSpy.mockRestore()
|
|
409
|
+
})
|
|
410
|
+
|
|
176
411
|
it('returns token with undefined sessionCookie when only x-account is present', async () => {
|
|
177
412
|
const tempDir = mkdtempSync(join(tmpdir(), 'channel-cookie-db-'))
|
|
178
413
|
tempDirs.push(tempDir)
|
|
@@ -18,14 +18,66 @@ import type { KeychainVariant } from '@/shared/chromium'
|
|
|
18
18
|
|
|
19
19
|
import type { ExtractedChannelToken } from './types'
|
|
20
20
|
|
|
21
|
-
type CookieRow = { name: string; value: string; encrypted_value: Uint8Array | Buffer }
|
|
21
|
+
type CookieRow = { name: string; value: string; encrypted_value: Uint8Array | Buffer; host_key: string }
|
|
22
|
+
|
|
23
|
+
// Channel Talk was rebranded to Channel Works: the desktop app now stores its cookies under a
|
|
24
|
+
// "Channel Works" directory. Both names are probed, newest first, so upgraded and legacy
|
|
25
|
+
// installs (which keep the old directory around) both resolve.
|
|
26
|
+
const APP_DIR_NAMES = ['Channel Works', 'Channel Talk']
|
|
27
|
+
|
|
28
|
+
// Cookies for the rebranded channel.works domain and the legacy channel.io domain can coexist in
|
|
29
|
+
// one profile, most likely on a browser that was signed in before and after the rebrand.
|
|
30
|
+
const COOKIE_DOMAINS = ['channel.works', 'channel.io']
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Match a cookie's host key against a domain on an exact label boundary: the domain itself, or a
|
|
34
|
+
* subdomain of it.
|
|
35
|
+
*
|
|
36
|
+
* Substring matching is not good enough here. `host_key LIKE '%.channel.works%'` also admits an
|
|
37
|
+
* unrelated host such as `.channel.works.example.com`, so a cookie planted by anyone who controls
|
|
38
|
+
* a name of that shape could shadow the real one and be sent to the Channel API as our identity.
|
|
39
|
+
*/
|
|
40
|
+
function hostMatchesDomain(hostKey: string, domain: string): boolean {
|
|
41
|
+
const host = hostKey.startsWith('.') ? hostKey.slice(1) : hostKey
|
|
42
|
+
return host === domain || host.endsWith(`.${domain}`)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Mirrors hostMatchesDomain in SQL so the database does the same boundary-aware narrowing. The
|
|
46
|
+
// domains are hardcoded constants, never caller input, so interpolating them is safe.
|
|
47
|
+
const COOKIE_HOST_FILTER = COOKIE_DOMAINS.map((domain) => `host_key = '${domain}' OR host_key LIKE '%.${domain}'`).join(
|
|
48
|
+
' OR ',
|
|
49
|
+
)
|
|
22
50
|
|
|
23
51
|
const COOKIE_QUERY = `
|
|
24
|
-
SELECT name, value, encrypted_value FROM cookies
|
|
52
|
+
SELECT name, value, encrypted_value, host_key FROM cookies
|
|
25
53
|
WHERE name IN ('x-account', 'ch-session-1', 'ch-session')
|
|
26
|
-
AND
|
|
54
|
+
AND (${COOKIE_HOST_FILTER})
|
|
27
55
|
`
|
|
28
56
|
|
|
57
|
+
function findExistingAppDir(bases: string[]): string | null {
|
|
58
|
+
for (const base of bases) {
|
|
59
|
+
for (const name of APP_DIR_NAMES) {
|
|
60
|
+
const appDir = join(base, name)
|
|
61
|
+
if (existsSync(appDir)) return appDir
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Split cookie rows into one group per domain, newest domain first, dropping domains with no rows.
|
|
69
|
+
*
|
|
70
|
+
* Callers resolve a credential pair from a single group rather than from all rows at once. That
|
|
71
|
+
* keeps `x-account` and its session cookie from being read off different domains, which would pair
|
|
72
|
+
* an identity with a session that does not belong to it, and it stops a leftover legacy cookie from
|
|
73
|
+
* shadowing a live channel.works one.
|
|
74
|
+
*/
|
|
75
|
+
function groupRowsByDomain(rows: CookieRow[]): CookieRow[][] {
|
|
76
|
+
return COOKIE_DOMAINS.map((domain) => rows.filter((row) => hostMatchesDomain(row.host_key, domain))).filter(
|
|
77
|
+
(group) => group.length > 0,
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
29
81
|
export class ChannelTokenExtractor {
|
|
30
82
|
private platform: NodeJS.Platform
|
|
31
83
|
private decryptor: ChromiumCookieDecryptor
|
|
@@ -42,7 +94,7 @@ export class ChannelTokenExtractor {
|
|
|
42
94
|
getAppDataDir(): string | null {
|
|
43
95
|
switch (this.platform) {
|
|
44
96
|
case 'darwin': {
|
|
45
|
-
const
|
|
97
|
+
const sandboxedBase = join(
|
|
46
98
|
homedir(),
|
|
47
99
|
'Library',
|
|
48
100
|
'Containers',
|
|
@@ -50,18 +102,13 @@ export class ChannelTokenExtractor {
|
|
|
50
102
|
'Data',
|
|
51
103
|
'Library',
|
|
52
104
|
'Application Support',
|
|
53
|
-
'Channel Talk',
|
|
54
105
|
)
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
58
|
-
const directPath = join(homedir(), 'Library', 'Application Support', 'Channel Talk')
|
|
59
|
-
return existsSync(directPath) ? directPath : null
|
|
106
|
+
const directBase = join(homedir(), 'Library', 'Application Support')
|
|
107
|
+
return findExistingAppDir([sandboxedBase, directBase])
|
|
60
108
|
}
|
|
61
109
|
case 'win32': {
|
|
62
110
|
const appdata = process.env.APPDATA || join(homedir(), 'AppData', 'Roaming')
|
|
63
|
-
|
|
64
|
-
return existsSync(appDir) ? appDir : null
|
|
111
|
+
return findExistingAppDir([appdata])
|
|
65
112
|
}
|
|
66
113
|
default:
|
|
67
114
|
return null
|
|
@@ -221,12 +268,18 @@ export class ChannelTokenExtractor {
|
|
|
221
268
|
if (rows.length === 0) return null
|
|
222
269
|
|
|
223
270
|
const localStatePath = this.getLocalStatePath() ?? undefined
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
this.getDesktopCookieValue(rows, 'ch-session', localStatePath)
|
|
271
|
+
for (const group of groupRowsByDomain(rows)) {
|
|
272
|
+
const accountCookie = this.getDesktopCookieValue(group, 'x-account', localStatePath)
|
|
273
|
+
if (!accountCookie) continue
|
|
228
274
|
|
|
229
|
-
|
|
275
|
+
const sessionCookie =
|
|
276
|
+
this.getDesktopCookieValue(group, 'ch-session-1', localStatePath) ??
|
|
277
|
+
this.getDesktopCookieValue(group, 'ch-session', localStatePath)
|
|
278
|
+
|
|
279
|
+
return { accountCookie, sessionCookie }
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return null
|
|
230
283
|
}
|
|
231
284
|
|
|
232
285
|
private async extractFromBrowserCookiePath(cookiePath: string): Promise<ExtractedChannelToken | null> {
|
|
@@ -234,12 +287,18 @@ export class ChannelTokenExtractor {
|
|
|
234
287
|
if (rows.length === 0) return null
|
|
235
288
|
|
|
236
289
|
const localStatePath = findLocalStatePath(cookiePath) ?? undefined
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
this.getBrowserCookieValue(rows, 'ch-session', localStatePath)
|
|
290
|
+
for (const group of groupRowsByDomain(rows)) {
|
|
291
|
+
const accountCookie = this.getBrowserCookieValue(group, 'x-account', localStatePath)
|
|
292
|
+
if (!accountCookie) continue
|
|
241
293
|
|
|
242
|
-
|
|
294
|
+
const sessionCookie =
|
|
295
|
+
this.getBrowserCookieValue(group, 'ch-session-1', localStatePath) ??
|
|
296
|
+
this.getBrowserCookieValue(group, 'ch-session', localStatePath)
|
|
297
|
+
|
|
298
|
+
return { accountCookie, sessionCookie }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return null
|
|
243
302
|
}
|
|
244
303
|
|
|
245
304
|
private getDesktopCookieValue(rows: CookieRow[], name: string, localStatePath?: string): string | undefined {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { afterEach, beforeEach, expect, spyOn, it } from 'bun:test'
|
|
1
|
+
import { afterEach, beforeEach, expect, mock, spyOn, it } from 'bun:test'
|
|
2
2
|
|
|
3
3
|
import { DiscordClient } from '../client'
|
|
4
4
|
import { DiscordCredentialManager } from '../credential-manager'
|
|
5
|
+
import { listAction } from './channel'
|
|
5
6
|
|
|
6
7
|
let clientListChannelsSpy: ReturnType<typeof spyOn>
|
|
7
8
|
let clientGetChannelSpy: ReturnType<typeof spyOn>
|
|
@@ -12,8 +13,10 @@ beforeEach(() => {
|
|
|
12
13
|
// Spy on DiscordClient.prototype methods
|
|
13
14
|
clientListChannelsSpy = spyOn(DiscordClient.prototype, 'listChannels').mockResolvedValue([
|
|
14
15
|
{ id: 'ch-1', guild_id: 'guild-1', name: 'general', type: 0, topic: 'General discussion' },
|
|
15
|
-
{ id: 'ch-2', guild_id: 'guild-1', name: 'announcements', type:
|
|
16
|
+
{ id: 'ch-2', guild_id: 'guild-1', name: 'announcements', type: 5, topic: 'Announcements' },
|
|
16
17
|
{ id: 'ch-3', guild_id: 'guild-1', name: 'voice-channel', type: 2, topic: undefined },
|
|
18
|
+
{ id: 'ch-4', guild_id: 'guild-1', name: 'Text Channels', type: 4, topic: undefined },
|
|
19
|
+
{ id: 'ch-5', guild_id: 'guild-1', name: 'a-thread', type: 11, topic: undefined },
|
|
17
20
|
])
|
|
18
21
|
|
|
19
22
|
clientGetChannelSpy = spyOn(DiscordClient.prototype, 'getChannel').mockImplementation(async (channelId: string) => {
|
|
@@ -72,30 +75,37 @@ afterEach(() => {
|
|
|
72
75
|
credManagerLoadSpy?.mockRestore()
|
|
73
76
|
})
|
|
74
77
|
|
|
75
|
-
it('list:
|
|
76
|
-
// given:
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
78
|
+
it('list: includes text, announcement, and voice channels but excludes categories and threads', async () => {
|
|
79
|
+
// given: a server with mixed channel types
|
|
80
|
+
const consoleSpy = mock(() => {})
|
|
81
|
+
const originalLog = console.log
|
|
82
|
+
console.log = consoleSpy
|
|
83
|
+
|
|
84
|
+
// when: listing channels
|
|
85
|
+
await listAction({ pretty: false })
|
|
86
|
+
console.log = originalLog
|
|
87
|
+
|
|
88
|
+
// then: only listable channels are output
|
|
89
|
+
const output = JSON.parse(consoleSpy.mock.calls[0][0] as string) as Array<{ name: string; type: number }>
|
|
90
|
+
const names = output.map((ch) => ch.name)
|
|
91
|
+
expect(names).toEqual(['general', 'announcements', 'voice-channel'])
|
|
92
|
+
expect(names).not.toContain('Text Channels')
|
|
93
|
+
expect(names).not.toContain('a-thread')
|
|
87
94
|
})
|
|
88
95
|
|
|
89
96
|
it('list: includes channel metadata', async () => {
|
|
90
|
-
// given:
|
|
91
|
-
const
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
// when:
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
97
|
+
// given: a server with channels
|
|
98
|
+
const consoleSpy = mock(() => {})
|
|
99
|
+
const originalLog = console.log
|
|
100
|
+
console.log = consoleSpy
|
|
101
|
+
|
|
102
|
+
// when: listing channels
|
|
103
|
+
await listAction({ pretty: false })
|
|
104
|
+
console.log = originalLog
|
|
105
|
+
|
|
106
|
+
// then: each channel carries id, name, type, parent_id, topic
|
|
107
|
+
const output = JSON.parse(consoleSpy.mock.calls[0][0] as string) as Array<Record<string, unknown>>
|
|
108
|
+
const channel = output[0]
|
|
99
109
|
expect(channel.id).toBeDefined()
|
|
100
110
|
expect(channel.name).toBeDefined()
|
|
101
111
|
expect(channel.type).toBe(0)
|
|
@@ -5,6 +5,7 @@ import { formatOutput } from '@/shared/utils/output'
|
|
|
5
5
|
|
|
6
6
|
import { DiscordClient } from '../client'
|
|
7
7
|
import { DiscordCredentialManager } from '../credential-manager'
|
|
8
|
+
import { isListableChannel } from '../types'
|
|
8
9
|
|
|
9
10
|
export async function listAction(options: { pretty?: boolean }): Promise<void> {
|
|
10
11
|
try {
|
|
@@ -19,9 +20,9 @@ export async function listAction(options: { pretty?: boolean }): Promise<void> {
|
|
|
19
20
|
const client = await new DiscordClient().login({ token: config.token })
|
|
20
21
|
const channels = await client.listChannels(config.current_server)
|
|
21
22
|
|
|
22
|
-
const
|
|
23
|
+
const listableChannels = channels.filter(isListableChannel)
|
|
23
24
|
|
|
24
|
-
const output =
|
|
25
|
+
const output = listableChannels.map((ch) => ({
|
|
25
26
|
id: ch.id,
|
|
26
27
|
name: ch.name,
|
|
27
28
|
type: ch.type,
|
|
@@ -1,6 +1,84 @@
|
|
|
1
|
-
import { expect, it } from 'bun:test'
|
|
1
|
+
import { afterEach, beforeEach, expect, spyOn, it } from 'bun:test'
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { DiscordClient } from '../client'
|
|
4
|
+
import { DiscordCredentialManager } from '../credential-manager'
|
|
5
|
+
import { DiscordChannelType } from '../types'
|
|
6
|
+
import { snapshotAction, snapshotCommand } from './snapshot'
|
|
7
|
+
|
|
8
|
+
let listChannelsSpy: ReturnType<typeof spyOn>
|
|
9
|
+
let getMessagesSpy: ReturnType<typeof spyOn>
|
|
10
|
+
let getServerSpy: ReturnType<typeof spyOn>
|
|
11
|
+
let listUsersSpy: ReturnType<typeof spyOn>
|
|
12
|
+
let credManagerLoadSpy: ReturnType<typeof spyOn>
|
|
13
|
+
|
|
14
|
+
const mixedChannels = [
|
|
15
|
+
{ id: 'text', guild_id: 'g1', name: 'general', type: DiscordChannelType.GUILD_TEXT },
|
|
16
|
+
{ id: 'announce', guild_id: 'g1', name: 'news', type: DiscordChannelType.GUILD_ANNOUNCEMENT },
|
|
17
|
+
{ id: 'voice', guild_id: 'g1', name: 'lounge', type: DiscordChannelType.GUILD_VOICE },
|
|
18
|
+
{ id: 'forum', guild_id: 'g1', name: 'help', type: DiscordChannelType.GUILD_FORUM },
|
|
19
|
+
{ id: 'directory', guild_id: 'g1', name: 'hub', type: DiscordChannelType.GUILD_DIRECTORY },
|
|
20
|
+
{ id: 'category', guild_id: 'g1', name: 'Section', type: DiscordChannelType.GUILD_CATEGORY },
|
|
21
|
+
{ id: 'thread', guild_id: 'g1', name: 'a-thread', type: DiscordChannelType.PUBLIC_THREAD },
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
function parseSnapshot(consoleSpy: ReturnType<typeof spyOn>): Record<string, any> {
|
|
25
|
+
const lastCall = consoleSpy.mock.calls.at(-1)
|
|
26
|
+
return JSON.parse(lastCall![0] as string)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
listChannelsSpy = spyOn(DiscordClient.prototype, 'listChannels').mockResolvedValue(mixedChannels)
|
|
31
|
+
getMessagesSpy = spyOn(DiscordClient.prototype, 'getMessages').mockResolvedValue([])
|
|
32
|
+
getServerSpy = spyOn(DiscordClient.prototype, 'getServer').mockResolvedValue({ id: 'g1', name: 'Guild One' })
|
|
33
|
+
listUsersSpy = spyOn(DiscordClient.prototype, 'listUsers').mockResolvedValue([])
|
|
34
|
+
credManagerLoadSpy = spyOn(DiscordCredentialManager.prototype, 'load').mockResolvedValue({
|
|
35
|
+
token: 'test-token',
|
|
36
|
+
current_server: 'g1',
|
|
37
|
+
servers: { g1: { server_id: 'g1', server_name: 'Guild One' } },
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
listChannelsSpy?.mockRestore()
|
|
43
|
+
getMessagesSpy?.mockRestore()
|
|
44
|
+
getServerSpy?.mockRestore()
|
|
45
|
+
listUsersSpy?.mockRestore()
|
|
46
|
+
credManagerLoadSpy?.mockRestore()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('snapshot brief: lists listable channels and excludes categories and threads', async () => {
|
|
50
|
+
// given: a server with mixed channel types
|
|
51
|
+
const consoleSpy = spyOn(console, 'log').mockImplementation(() => {})
|
|
52
|
+
|
|
53
|
+
// when: running a brief snapshot
|
|
54
|
+
await snapshotAction({ pretty: false })
|
|
55
|
+
const snapshot = parseSnapshot(consoleSpy)
|
|
56
|
+
consoleSpy.mockRestore()
|
|
57
|
+
|
|
58
|
+
// then: only listable channels appear, no messages are fetched
|
|
59
|
+
const names = snapshot.channels.map((ch: { name: string }) => ch.name)
|
|
60
|
+
expect(names).toEqual(['general', 'news', 'lounge', 'help', 'hub'])
|
|
61
|
+
expect(getMessagesSpy).not.toHaveBeenCalled()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('snapshot full: fetches messages only from message-readable channels', async () => {
|
|
65
|
+
// given: a server with mixed channel types
|
|
66
|
+
const consoleSpy = spyOn(console, 'log').mockImplementation(() => {})
|
|
67
|
+
|
|
68
|
+
// when: running a full snapshot
|
|
69
|
+
await snapshotAction({ full: true, pretty: false })
|
|
70
|
+
const snapshot = parseSnapshot(consoleSpy)
|
|
71
|
+
consoleSpy.mockRestore()
|
|
72
|
+
|
|
73
|
+
// then: listing includes containers but getMessages is called only for readable IDs
|
|
74
|
+
const listedNames = snapshot.channels.map((ch: { name: string }) => ch.name)
|
|
75
|
+
expect(listedNames).toEqual(['general', 'news', 'lounge', 'help', 'hub'])
|
|
76
|
+
|
|
77
|
+
const fetchedIds = getMessagesSpy.mock.calls.map((call) => call[0])
|
|
78
|
+
expect(fetchedIds.sort()).toEqual(['announce', 'text', 'voice'])
|
|
79
|
+
expect(fetchedIds).not.toContain('forum')
|
|
80
|
+
expect(fetchedIds).not.toContain('directory')
|
|
81
|
+
})
|
|
4
82
|
|
|
5
83
|
it('snapshot: command is defined', () => {
|
|
6
84
|
expect(snapshotCommand).toBeDefined()
|