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.
Files changed (67) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/bun.lock +7 -0
  3. package/dist/package.json +2 -1
  4. package/dist/src/platforms/channeltalk/token-extractor.d.ts.map +1 -1
  5. package/dist/src/platforms/channeltalk/token-extractor.js +67 -18
  6. package/dist/src/platforms/channeltalk/token-extractor.js.map +1 -1
  7. package/dist/src/platforms/discord/commands/channel.d.ts.map +1 -1
  8. package/dist/src/platforms/discord/commands/channel.js +3 -2
  9. package/dist/src/platforms/discord/commands/channel.js.map +1 -1
  10. package/dist/src/platforms/discord/commands/snapshot.d.ts.map +1 -1
  11. package/dist/src/platforms/discord/commands/snapshot.js +7 -6
  12. package/dist/src/platforms/discord/commands/snapshot.js.map +1 -1
  13. package/dist/src/platforms/discord/types.d.ts +17 -0
  14. package/dist/src/platforms/discord/types.d.ts.map +1 -1
  15. package/dist/src/platforms/discord/types.js +36 -0
  16. package/dist/src/platforms/discord/types.js.map +1 -1
  17. package/dist/src/platforms/slack/index.d.ts +1 -0
  18. package/dist/src/platforms/slack/index.d.ts.map +1 -1
  19. package/dist/src/platforms/slack/index.js.map +1 -1
  20. package/dist/src/platforms/slack/qr-confirmation.d.ts +19 -0
  21. package/dist/src/platforms/slack/qr-confirmation.d.ts.map +1 -0
  22. package/dist/src/platforms/slack/qr-confirmation.js +68 -0
  23. package/dist/src/platforms/slack/qr-confirmation.js.map +1 -0
  24. package/dist/src/platforms/slack/qr-cookie-jar.d.ts +7 -0
  25. package/dist/src/platforms/slack/qr-cookie-jar.d.ts.map +1 -0
  26. package/dist/src/platforms/slack/qr-cookie-jar.js +48 -0
  27. package/dist/src/platforms/slack/qr-cookie-jar.js.map +1 -0
  28. package/dist/src/platforms/slack/qr-http-login.d.ts +2 -1
  29. package/dist/src/platforms/slack/qr-http-login.d.ts.map +1 -1
  30. package/dist/src/platforms/slack/qr-http-login.js +88 -53
  31. package/dist/src/platforms/slack/qr-http-login.js.map +1 -1
  32. package/docs/content/docs/cli/channeltalk.mdx +4 -4
  33. package/package.json +2 -1
  34. package/skills/agent-channeltalk/SKILL.md +5 -5
  35. package/skills/agent-channeltalk/references/authentication.md +9 -5
  36. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  37. package/skills/agent-discord/SKILL.md +8 -4
  38. package/skills/agent-discord/references/common-patterns.md +7 -3
  39. package/skills/agent-discordbot/SKILL.md +1 -1
  40. package/skills/agent-imessage/SKILL.md +1 -1
  41. package/skills/agent-instagram/SKILL.md +1 -1
  42. package/skills/agent-kakaotalk/SKILL.md +1 -1
  43. package/skills/agent-line/SKILL.md +1 -1
  44. package/skills/agent-slack/SKILL.md +25 -2
  45. package/skills/agent-slackbot/SKILL.md +1 -1
  46. package/skills/agent-teams/SKILL.md +1 -1
  47. package/skills/agent-telegram/SKILL.md +1 -1
  48. package/skills/agent-telegrambot/SKILL.md +1 -1
  49. package/skills/agent-webex/SKILL.md +1 -1
  50. package/skills/agent-webexbot/SKILL.md +1 -1
  51. package/skills/agent-wechatbot/SKILL.md +1 -1
  52. package/skills/agent-whatsapp/SKILL.md +1 -1
  53. package/skills/agent-whatsappbot/SKILL.md +1 -1
  54. package/src/platforms/channeltalk/token-extractor.test.ts +235 -0
  55. package/src/platforms/channeltalk/token-extractor.ts +81 -22
  56. package/src/platforms/discord/commands/channel.test.ts +33 -23
  57. package/src/platforms/discord/commands/channel.ts +3 -2
  58. package/src/platforms/discord/commands/snapshot.test.ts +80 -2
  59. package/src/platforms/discord/commands/snapshot.ts +7 -6
  60. package/src/platforms/discord/types.test.ts +45 -0
  61. package/src/platforms/discord/types.ts +41 -0
  62. package/src/platforms/slack/index.ts +1 -0
  63. package/src/platforms/slack/qr-confirmation.ts +86 -0
  64. package/src/platforms/slack/qr-cookie-jar.ts +54 -0
  65. package/src/platforms/slack/qr-http-login-2fa.test.ts +398 -0
  66. package/src/platforms/slack/qr-http-login.test.ts +10 -18
  67. package/src/platforms/slack/qr-http-login.ts +109 -52
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-teams
3
3
  description: Interact with Microsoft Teams - send messages, read channels, manage reactions
4
- version: 2.32.0
4
+ version: 2.32.1
5
5
  allowed-tools: Bash(agent-teams:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-telegram
3
3
  description: Interact with Telegram through TDLib - authenticate, inspect chats, and send messages
4
- version: 2.32.0
4
+ version: 2.32.1
5
5
  allowed-tools: Bash(agent-telegram:*)
6
6
  ---
7
7
 
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-telegrambot
3
3
  description: Interact with Telegram using bot tokens - send messages, read chats, manage reactions
4
- version: 2.32.0
4
+ version: 2.32.1
5
5
  allowed-tools: Bash(agent-telegrambot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-webex
3
3
  description: Interact with Cisco Webex - send messages, read spaces, manage memberships
4
- version: 2.32.0
4
+ version: 2.32.1
5
5
  allowed-tools: Bash(agent-webex:*)
6
6
  metadata:
7
7
  openclaw:
@@ -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.0
4
+ version: 2.32.1
5
5
  allowed-tools: Bash(agent-webexbot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-wechatbot
3
3
  description: Interact with WeChat Official Account using API credentials - send messages, manage templates, list followers
4
- version: 2.32.0
4
+ version: 2.32.1
5
5
  allowed-tools: Bash(agent-wechatbot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-whatsapp
3
3
  description: Interact with WhatsApp - send messages, read chats, manage conversations
4
- version: 2.32.0
4
+ version: 2.32.1
5
5
  allowed-tools: Bash(agent-whatsapp:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-whatsappbot
3
3
  description: Interact with WhatsApp using Cloud API credentials - send messages, manage templates
4
- version: 2.32.0
4
+ version: 2.32.1
5
5
  allowed-tools: Bash(agent-whatsappbot:*)
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 host_key LIKE '%.channel.io%'
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 sandboxedPath = join(
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
- if (existsSync(sandboxedPath)) {
56
- return sandboxedPath
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
- const appDir = join(appdata, 'Channel Talk')
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 accountCookie = this.getDesktopCookieValue(rows, 'x-account', localStatePath)
225
- const sessionCookie =
226
- this.getDesktopCookieValue(rows, 'ch-session-1', localStatePath) ??
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
- return accountCookie ? { accountCookie, sessionCookie } : null
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 accountCookie = this.getBrowserCookieValue(rows, 'x-account', localStatePath)
238
- const sessionCookie =
239
- this.getBrowserCookieValue(rows, 'ch-session-1', localStatePath) ??
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
- return accountCookie ? { accountCookie, sessionCookie } : null
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: 0, topic: 'Announcements' },
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: returns text channels (type=0) from server', async () => {
76
- // given: discord client with channels
77
- const client = await new DiscordClient().login({ token: 'test-token' })
78
- const channels = await client.listChannels('server-1')
79
-
80
- // when: filtering text channels
81
- const textChannels = channels.filter((ch) => ch.type === 0)
82
-
83
- // then: only text channels are returned
84
- expect(textChannels).toHaveLength(2)
85
- expect(textChannels[0].name).toBe('general')
86
- expect(textChannels[1].name).toBe('announcements')
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: discord client with channels
91
- const client = await new DiscordClient().login({ token: 'test-token' })
92
- const channels = await client.listChannels('server-1')
93
- const textChannels = channels.filter((ch) => ch.type === 0)
94
-
95
- // when: checking channel properties
96
- const channel = textChannels[0]
97
-
98
- // then: channel has id, name, type, topic
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 textChannels = channels.filter((ch) => ch.type === 0)
23
+ const listableChannels = channels.filter(isListableChannel)
23
24
 
24
- const output = textChannels.map((ch) => ({
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 { snapshotCommand } from './snapshot'
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()