agent-messenger 2.31.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 (98) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +14 -1
  3. package/bun.lock +7 -0
  4. package/dist/package.json +2 -1
  5. package/dist/src/platforms/channeltalk/token-extractor.d.ts.map +1 -1
  6. package/dist/src/platforms/channeltalk/token-extractor.js +67 -18
  7. package/dist/src/platforms/channeltalk/token-extractor.js.map +1 -1
  8. package/dist/src/platforms/discord/client.d.ts +11 -2
  9. package/dist/src/platforms/discord/client.d.ts.map +1 -1
  10. package/dist/src/platforms/discord/client.js +109 -4
  11. package/dist/src/platforms/discord/client.js.map +1 -1
  12. package/dist/src/platforms/discord/commands/channel.d.ts.map +1 -1
  13. package/dist/src/platforms/discord/commands/channel.js +3 -2
  14. package/dist/src/platforms/discord/commands/channel.js.map +1 -1
  15. package/dist/src/platforms/discord/commands/mention.d.ts +5 -0
  16. package/dist/src/platforms/discord/commands/mention.d.ts.map +1 -1
  17. package/dist/src/platforms/discord/commands/mention.js +60 -4
  18. package/dist/src/platforms/discord/commands/mention.js.map +1 -1
  19. package/dist/src/platforms/discord/commands/reaction.js +1 -1
  20. package/dist/src/platforms/discord/commands/reaction.js.map +1 -1
  21. package/dist/src/platforms/discord/commands/snapshot.d.ts.map +1 -1
  22. package/dist/src/platforms/discord/commands/snapshot.js +7 -6
  23. package/dist/src/platforms/discord/commands/snapshot.js.map +1 -1
  24. package/dist/src/platforms/discord/index.d.ts +1 -1
  25. package/dist/src/platforms/discord/index.d.ts.map +1 -1
  26. package/dist/src/platforms/discord/index.js.map +1 -1
  27. package/dist/src/platforms/discord/listener.d.ts +4 -1
  28. package/dist/src/platforms/discord/listener.d.ts.map +1 -1
  29. package/dist/src/platforms/discord/listener.js +28 -1
  30. package/dist/src/platforms/discord/listener.js.map +1 -1
  31. package/dist/src/platforms/discord/types.d.ts +60 -9
  32. package/dist/src/platforms/discord/types.d.ts.map +1 -1
  33. package/dist/src/platforms/discord/types.js +58 -7
  34. package/dist/src/platforms/discord/types.js.map +1 -1
  35. package/dist/src/platforms/slack/index.d.ts +1 -0
  36. package/dist/src/platforms/slack/index.d.ts.map +1 -1
  37. package/dist/src/platforms/slack/index.js.map +1 -1
  38. package/dist/src/platforms/slack/qr-confirmation.d.ts +19 -0
  39. package/dist/src/platforms/slack/qr-confirmation.d.ts.map +1 -0
  40. package/dist/src/platforms/slack/qr-confirmation.js +68 -0
  41. package/dist/src/platforms/slack/qr-confirmation.js.map +1 -0
  42. package/dist/src/platforms/slack/qr-cookie-jar.d.ts +7 -0
  43. package/dist/src/platforms/slack/qr-cookie-jar.d.ts.map +1 -0
  44. package/dist/src/platforms/slack/qr-cookie-jar.js +48 -0
  45. package/dist/src/platforms/slack/qr-cookie-jar.js.map +1 -0
  46. package/dist/src/platforms/slack/qr-http-login.d.ts +2 -1
  47. package/dist/src/platforms/slack/qr-http-login.d.ts.map +1 -1
  48. package/dist/src/platforms/slack/qr-http-login.js +88 -53
  49. package/dist/src/platforms/slack/qr-http-login.js.map +1 -1
  50. package/docs/content/docs/cli/channeltalk.mdx +4 -4
  51. package/docs/content/docs/cli/discord.mdx +7 -0
  52. package/docs/content/docs/sdk/discord.mdx +38 -0
  53. package/e2e/README.md +2 -2
  54. package/e2e/discord.e2e.test.ts +16 -2
  55. package/package.json +2 -1
  56. package/skills/agent-channeltalk/SKILL.md +5 -5
  57. package/skills/agent-channeltalk/references/authentication.md +9 -5
  58. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  59. package/skills/agent-discord/SKILL.md +26 -4
  60. package/skills/agent-discord/references/common-patterns.md +7 -3
  61. package/skills/agent-discordbot/SKILL.md +1 -1
  62. package/skills/agent-imessage/SKILL.md +1 -1
  63. package/skills/agent-instagram/SKILL.md +1 -1
  64. package/skills/agent-kakaotalk/SKILL.md +1 -1
  65. package/skills/agent-line/SKILL.md +1 -1
  66. package/skills/agent-slack/SKILL.md +25 -2
  67. package/skills/agent-slackbot/SKILL.md +1 -1
  68. package/skills/agent-teams/SKILL.md +1 -1
  69. package/skills/agent-telegram/SKILL.md +1 -1
  70. package/skills/agent-telegrambot/SKILL.md +1 -1
  71. package/skills/agent-webex/SKILL.md +1 -1
  72. package/skills/agent-webexbot/SKILL.md +1 -1
  73. package/skills/agent-wechatbot/SKILL.md +1 -1
  74. package/skills/agent-whatsapp/SKILL.md +1 -1
  75. package/skills/agent-whatsappbot/SKILL.md +1 -1
  76. package/src/platforms/channeltalk/token-extractor.test.ts +235 -0
  77. package/src/platforms/channeltalk/token-extractor.ts +81 -22
  78. package/src/platforms/discord/client.test.ts +230 -9
  79. package/src/platforms/discord/client.ts +122 -5
  80. package/src/platforms/discord/commands/channel.test.ts +33 -23
  81. package/src/platforms/discord/commands/channel.ts +3 -2
  82. package/src/platforms/discord/commands/mention.test.ts +84 -0
  83. package/src/platforms/discord/commands/mention.ts +78 -15
  84. package/src/platforms/discord/commands/reaction.test.ts +1 -1
  85. package/src/platforms/discord/commands/reaction.ts +1 -1
  86. package/src/platforms/discord/commands/snapshot.test.ts +80 -2
  87. package/src/platforms/discord/commands/snapshot.ts +7 -6
  88. package/src/platforms/discord/index.ts +3 -0
  89. package/src/platforms/discord/listener.test.ts +69 -2
  90. package/src/platforms/discord/listener.ts +31 -2
  91. package/src/platforms/discord/types.test.ts +56 -0
  92. package/src/platforms/discord/types.ts +91 -10
  93. package/src/platforms/slack/index.ts +1 -0
  94. package/src/platforms/slack/qr-confirmation.ts +86 -0
  95. package/src/platforms/slack/qr-cookie-jar.ts +54 -0
  96. package/src/platforms/slack/qr-http-login-2fa.test.ts +398 -0
  97. package/src/platforms/slack/qr-http-login.test.ts +10 -18
  98. package/src/platforms/slack/qr-http-login.ts +109 -52
@@ -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,6 +1,7 @@
1
- import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
1
+ import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'
2
2
 
3
3
  import { DiscordClient, DiscordError } from './client'
4
+ import type { DiscordReadState } from './types'
4
5
 
5
6
  describe('DiscordClient', () => {
6
7
  const originalFetch = globalThis.fetch
@@ -226,23 +227,50 @@ describe('DiscordClient', () => {
226
227
 
227
228
  expect(fetchCalls[0].url).toBe('https://discord.com/api/v10/channels/ch1/messages?limit=50')
228
229
  })
230
+
231
+ it('gets messages around a specific message', async () => {
232
+ mockResponse([])
233
+
234
+ const client = await new DiscordClient().login({ token: 'test-token' })
235
+ await client.getMessages('ch1', 1, { around: 'msg/1' })
236
+
237
+ expect(fetchCalls[0].url).toBe('https://discord.com/api/v10/channels/ch1/messages?around=msg%2F1&limit=1')
238
+ })
229
239
  })
230
240
 
231
241
  describe('getMessage', () => {
232
242
  it('returns single message', async () => {
233
- mockResponse({
234
- id: 'msg1',
235
- channel_id: 'ch1',
236
- author: { id: '123', username: 'user1' },
237
- content: 'Message 1',
238
- timestamp: '2024-01-01T00:00:00.000Z',
239
- })
243
+ mockResponse([
244
+ {
245
+ id: 'msg1',
246
+ channel_id: 'ch1',
247
+ author: { id: '123', username: 'user1' },
248
+ content: 'Message 1',
249
+ timestamp: '2024-01-01T00:00:00.000Z',
250
+ },
251
+ ])
240
252
 
241
253
  const client = await new DiscordClient().login({ token: 'test-token' })
242
254
  const message = await client.getMessage('ch1', 'msg1')
243
255
 
244
256
  expect(message.id).toBe('msg1')
245
- expect(fetchCalls[0].url).toBe('https://discord.com/api/v10/channels/ch1/messages/msg1')
257
+ expect(fetchCalls[0].url).toBe('https://discord.com/api/v10/channels/ch1/messages?around=msg1&limit=1')
258
+ })
259
+
260
+ it('throws when the requested message is not returned', async () => {
261
+ mockResponse([
262
+ {
263
+ id: 'neighbor',
264
+ channel_id: 'ch1',
265
+ author: { id: '123', username: 'user1' },
266
+ content: 'Nearby message',
267
+ timestamp: '2024-01-01T00:00:00.000Z',
268
+ },
269
+ ])
270
+
271
+ const client = await new DiscordClient().login({ token: 'test-token' })
272
+
273
+ await expect(client.getMessage('ch1', 'missing')).rejects.toEqual(new DiscordError('Message not found', '10008'))
246
274
  })
247
275
  })
248
276
 
@@ -674,5 +702,198 @@ describe('DiscordClient', () => {
674
702
 
675
703
  expect(fetchCalls.length).toBe(2)
676
704
  })
705
+
706
+ it('normalizes query strings for the same channel route', async () => {
707
+ mockResponse([], 200, {
708
+ 'X-RateLimit-Remaining': '0',
709
+ 'X-RateLimit-Reset': String(Date.now() / 1000 + 0.1),
710
+ 'X-RateLimit-Bucket': 'messages-bucket',
711
+ })
712
+ mockResponse([])
713
+
714
+ const client = await new DiscordClient().login({ token: 'test-token' })
715
+ await client.getMessages('123456789', 1, { around: 'message-1' })
716
+
717
+ const startTime = Date.now()
718
+ await client.getMessages('123456789', 1, { around: 'message-2' })
719
+ const elapsed = Date.now() - startTime
720
+
721
+ expect(elapsed).toBeGreaterThanOrEqual(50)
722
+ expect(fetchCalls.length).toBe(2)
723
+ })
724
+ })
725
+
726
+ describe('getUnreadMentions', () => {
727
+ const mention = (id: string, channelId: string, guildId?: string) => ({
728
+ id,
729
+ channel_id: channelId,
730
+ author: { id: 'a1', username: 'alice' },
731
+ content: `mention ${id}`,
732
+ timestamp: '2024-01-29T10:00:00Z',
733
+ mention_everyone: false,
734
+ mentions: [{ id: 'me', username: 'me' }],
735
+ ...(guildId ? { guild_id: guildId } : {}),
736
+ })
737
+
738
+ const stubReadState = (client: DiscordClient, readState: DiscordReadState[]) =>
739
+ spyOn(client, 'fetchReadState').mockResolvedValue(readState)
740
+
741
+ it('keeps mentions newer than the channel read marker', async () => {
742
+ // given: one channel with an unread mention (200 > marker 100) and one already read (50 < 100)
743
+ const client = await new DiscordClient().login({ token: 'test-token' })
744
+ stubReadState(client, [{ channelId: 'c1', lastMessageId: '100', mentionCount: 1 }])
745
+ mockResponse([mention('200', 'c1'), mention('50', 'c1')])
746
+
747
+ const result = await client.getUnreadMentions()
748
+
749
+ // then: only the newer message is unread
750
+ expect(result.count).toBe(1)
751
+ expect(result.mentions[0].id).toBe('200')
752
+ expect(result.mentions[0].mention_count).toBe(1)
753
+ })
754
+
755
+ it('treats an equal snowflake as read', async () => {
756
+ const client = await new DiscordClient().login({ token: 'test-token' })
757
+ stubReadState(client, [{ channelId: 'c1', lastMessageId: '100', mentionCount: 1 }])
758
+ mockResponse([mention('100', 'c1')])
759
+
760
+ const result = await client.getUnreadMentions()
761
+
762
+ expect(result.count).toBe(0)
763
+ })
764
+
765
+ it('omits mentions for channels with no read-state entry', async () => {
766
+ const client = await new DiscordClient().login({ token: 'test-token' })
767
+ stubReadState(client, [])
768
+ mockResponse([mention('200', 'c1')])
769
+
770
+ const result = await client.getUnreadMentions()
771
+
772
+ expect(result.count).toBe(0)
773
+ })
774
+
775
+ it('omits channels whose mention_count is zero', async () => {
776
+ const client = await new DiscordClient().login({ token: 'test-token' })
777
+ stubReadState(client, [{ channelId: 'c1', lastMessageId: '100', mentionCount: 0 }])
778
+ mockResponse([mention('200', 'c1')])
779
+
780
+ const result = await client.getUnreadMentions()
781
+
782
+ expect(result.count).toBe(0)
783
+ })
784
+
785
+ it('includes all mentions when the read marker is null', async () => {
786
+ const client = await new DiscordClient().login({ token: 'test-token' })
787
+ stubReadState(client, [{ channelId: 'c1', lastMessageId: null, mentionCount: 2 }])
788
+ mockResponse([mention('200', 'c1'), mention('50', 'c1')])
789
+
790
+ const result = await client.getUnreadMentions()
791
+
792
+ expect(result.count).toBe(2)
793
+ })
794
+
795
+ it('reports badgeCount from summed read-state mention counts', async () => {
796
+ const client = await new DiscordClient().login({ token: 'test-token' })
797
+ stubReadState(client, [
798
+ { channelId: 'c1', lastMessageId: '100', mentionCount: 2 },
799
+ { channelId: 'c2', lastMessageId: '100', mentionCount: 3 },
800
+ ])
801
+ mockResponse([mention('200', 'c1')])
802
+
803
+ const result = await client.getUnreadMentions()
804
+
805
+ expect(result.badgeCount).toBe(5)
806
+ expect(result.count).toBe(1)
807
+ })
808
+
809
+ it('paginates mention history with a before cursor', async () => {
810
+ const client = await new DiscordClient().login({ token: 'test-token' })
811
+ stubReadState(client, [{ channelId: 'c1', lastMessageId: '0', mentionCount: 200 }])
812
+
813
+ const firstPage = Array.from({ length: 100 }, (_, i) => mention(String(1000 - i), 'c1'))
814
+ const secondPage = [mention('900', 'c1')]
815
+ mockResponse(firstPage)
816
+ mockResponse(secondPage)
817
+
818
+ const result = await client.getUnreadMentions()
819
+
820
+ expect(fetchCalls.length).toBe(2)
821
+ expect(fetchCalls[1].url).toContain('before=901')
822
+ expect(result.count).toBe(101)
823
+ })
824
+
825
+ it('deduplicates mentions repeated across pages', async () => {
826
+ const client = await new DiscordClient().login({ token: 'test-token' })
827
+ stubReadState(client, [{ channelId: 'c1', lastMessageId: '0', mentionCount: 100 }])
828
+
829
+ const page = Array.from({ length: 100 }, (_, i) => mention(String(1000 - i), 'c1'))
830
+ const overlap = [mention('901', 'c1'), mention('800', 'c1')]
831
+ mockResponse(page)
832
+ mockResponse(overlap)
833
+
834
+ const result = await client.getUnreadMentions()
835
+
836
+ const ids = result.mentions.map((m) => m.id)
837
+ expect(new Set(ids).size).toBe(ids.length)
838
+ })
839
+
840
+ it('caps the scan at limit 1 with a single bounded request', async () => {
841
+ const client = await new DiscordClient().login({ token: 'test-token' })
842
+ stubReadState(client, [{ channelId: 'c1', lastMessageId: '0', mentionCount: 5 }])
843
+ mockResponse([mention('1000', 'c1')])
844
+
845
+ const result = await client.getUnreadMentions({ limit: 1 })
846
+
847
+ // then: exactly one page requested, capped to the limit, incomplete scan
848
+ expect(fetchCalls.length).toBe(1)
849
+ expect(fetchCalls[0].url).toContain('limit=1')
850
+ expect(result.count).toBe(1)
851
+ expect(result.complete).toBe(false)
852
+ })
853
+
854
+ it('caps the scan at a custom limit that spans multiple pages', async () => {
855
+ const client = await new DiscordClient().login({ token: 'test-token' })
856
+ stubReadState(client, [{ channelId: 'c1', lastMessageId: '0', mentionCount: 200 }])
857
+
858
+ const firstPage = Array.from({ length: 100 }, (_, i) => mention(String(1000 - i), 'c1'))
859
+ const secondPage = Array.from({ length: 50 }, (_, i) => mention(String(900 - i), 'c1'))
860
+ mockResponse(firstPage)
861
+ mockResponse(secondPage)
862
+
863
+ const result = await client.getUnreadMentions({ limit: 150 })
864
+
865
+ // then: second page bounded to remaining 50, scan stops at the cap and is incomplete
866
+ expect(fetchCalls.length).toBe(2)
867
+ expect(fetchCalls[1].url).toContain('limit=50')
868
+ expect(result.count).toBe(150)
869
+ expect(result.complete).toBe(false)
870
+ })
871
+
872
+ it('marks the scan complete when a short page ends history', async () => {
873
+ const client = await new DiscordClient().login({ token: 'test-token' })
874
+ stubReadState(client, [{ channelId: 'c1', lastMessageId: '0', mentionCount: 3 }])
875
+ mockResponse([mention('1000', 'c1'), mention('999', 'c1')])
876
+
877
+ const result = await client.getUnreadMentions()
878
+
879
+ expect(result.complete).toBe(true)
880
+ expect(result.count).toBe(2)
881
+ })
882
+
883
+ it('stops and reports incomplete when the cursor fails to advance', async () => {
884
+ const client = await new DiscordClient().login({ token: 'test-token' })
885
+ stubReadState(client, [{ channelId: 'c1', lastMessageId: '0', mentionCount: 200 }])
886
+
887
+ const fullPage = Array.from({ length: 100 }, (_, i) => mention(String(1000 - i), 'c1'))
888
+ const repeatedPage = Array.from({ length: 100 }, (_, i) => mention(String(1000 - i), 'c1'))
889
+ mockResponse(fullPage)
890
+ mockResponse(repeatedPage)
891
+
892
+ const result = await client.getUnreadMentions()
893
+
894
+ // then: the repeated page adds nothing new, so the guard stops the scan as incomplete
895
+ expect(result.complete).toBe(false)
896
+ expect(result.count).toBe(100)
897
+ })
677
898
  })
678
899
  })