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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +14 -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/client.d.ts +11 -2
- package/dist/src/platforms/discord/client.d.ts.map +1 -1
- package/dist/src/platforms/discord/client.js +109 -4
- package/dist/src/platforms/discord/client.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/mention.d.ts +5 -0
- package/dist/src/platforms/discord/commands/mention.d.ts.map +1 -1
- package/dist/src/platforms/discord/commands/mention.js +60 -4
- package/dist/src/platforms/discord/commands/mention.js.map +1 -1
- package/dist/src/platforms/discord/commands/reaction.js +1 -1
- package/dist/src/platforms/discord/commands/reaction.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/index.d.ts +1 -1
- package/dist/src/platforms/discord/index.d.ts.map +1 -1
- package/dist/src/platforms/discord/index.js.map +1 -1
- package/dist/src/platforms/discord/listener.d.ts +4 -1
- package/dist/src/platforms/discord/listener.d.ts.map +1 -1
- package/dist/src/platforms/discord/listener.js +28 -1
- package/dist/src/platforms/discord/listener.js.map +1 -1
- package/dist/src/platforms/discord/types.d.ts +60 -9
- package/dist/src/platforms/discord/types.d.ts.map +1 -1
- package/dist/src/platforms/discord/types.js +58 -7
- 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/docs/content/docs/cli/discord.mdx +7 -0
- package/docs/content/docs/sdk/discord.mdx +38 -0
- package/e2e/README.md +2 -2
- package/e2e/discord.e2e.test.ts +16 -2
- 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 +26 -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/client.test.ts +230 -9
- package/src/platforms/discord/client.ts +122 -5
- package/src/platforms/discord/commands/channel.test.ts +33 -23
- package/src/platforms/discord/commands/channel.ts +3 -2
- package/src/platforms/discord/commands/mention.test.ts +84 -0
- package/src/platforms/discord/commands/mention.ts +78 -15
- package/src/platforms/discord/commands/reaction.test.ts +1 -1
- package/src/platforms/discord/commands/reaction.ts +1 -1
- package/src/platforms/discord/commands/snapshot.test.ts +80 -2
- package/src/platforms/discord/commands/snapshot.ts +7 -6
- package/src/platforms/discord/index.ts +3 -0
- package/src/platforms/discord/listener.test.ts +69 -2
- package/src/platforms/discord/listener.ts +31 -2
- package/src/platforms/discord/types.test.ts +56 -0
- package/src/platforms/discord/types.ts +91 -10
- 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
|
@@ -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,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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
|
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
|
})
|