agent-messenger 2.14.1 → 2.15.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 +3 -0
- package/dist/package.json +1 -1
- package/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/client.js +15 -0
- package/dist/src/platforms/kakaotalk/client.js.map +1 -1
- package/dist/src/platforms/kakaotalk/index.d.ts +2 -2
- package/dist/src/platforms/kakaotalk/index.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/index.js +1 -1
- package/dist/src/platforms/kakaotalk/index.js.map +1 -1
- package/dist/src/platforms/kakaotalk/listener.d.ts +1 -1
- package/dist/src/platforms/kakaotalk/listener.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/listener.js +58 -6
- package/dist/src/platforms/kakaotalk/listener.js.map +1 -1
- package/dist/src/platforms/kakaotalk/types.d.ts +46 -1
- package/dist/src/platforms/kakaotalk/types.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/types.js +22 -0
- package/dist/src/platforms/kakaotalk/types.js.map +1 -1
- package/dist/src/platforms/slack/commands/auth.d.ts.map +1 -1
- package/dist/src/platforms/slack/commands/auth.js +6 -2
- package/dist/src/platforms/slack/commands/auth.js.map +1 -1
- package/dist/src/platforms/slack/ensure-auth.d.ts +8 -2
- package/dist/src/platforms/slack/ensure-auth.d.ts.map +1 -1
- package/dist/src/platforms/slack/ensure-auth.js +67 -10
- package/dist/src/platforms/slack/ensure-auth.js.map +1 -1
- package/docs/content/docs/cli/kakaotalk.mdx +4 -3
- package/docs/content/docs/sdk/kakaotalk.mdx +2 -0
- package/package.json +1 -1
- package/skills/agent-channeltalk/SKILL.md +1 -1
- package/skills/agent-channeltalkbot/SKILL.md +1 -1
- package/skills/agent-discord/SKILL.md +1 -1
- package/skills/agent-discordbot/SKILL.md +1 -1
- package/skills/agent-instagram/SKILL.md +1 -1
- package/skills/agent-kakaotalk/SKILL.md +5 -4
- package/skills/agent-line/SKILL.md +1 -1
- package/skills/agent-slack/SKILL.md +1 -1
- package/skills/agent-slackbot/SKILL.md +1 -1
- package/skills/agent-teams/SKILL.md +1 -1
- package/skills/agent-telegram/SKILL.md +1 -1
- package/skills/agent-telegrambot/SKILL.md +1 -1
- package/skills/agent-webex/SKILL.md +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/kakaotalk/client.test.ts +61 -0
- package/src/platforms/kakaotalk/client.ts +13 -0
- package/src/platforms/kakaotalk/index.ts +6 -0
- package/src/platforms/kakaotalk/listener.test.ts +383 -0
- package/src/platforms/kakaotalk/listener.ts +69 -12
- package/src/platforms/kakaotalk/types.ts +48 -0
- package/src/platforms/slack/commands/auth.ts +6 -4
- package/src/platforms/slack/ensure-auth.test.ts +223 -1
- package/src/platforms/slack/ensure-auth.ts +80 -11
|
@@ -10,10 +10,12 @@ let extractSpy: ReturnType<typeof spyOn>
|
|
|
10
10
|
let extractCookieSpy: ReturnType<typeof spyOn>
|
|
11
11
|
let getWorkspaceDomainsSpy: ReturnType<typeof spyOn>
|
|
12
12
|
let testAuthSpy: ReturnType<typeof spyOn>
|
|
13
|
+
let loginSpy: ReturnType<typeof spyOn>
|
|
13
14
|
let setWorkspaceSpy: ReturnType<typeof spyOn>
|
|
14
15
|
let loadSpy: ReturnType<typeof spyOn>
|
|
15
16
|
let setCurrentWorkspaceSpy: ReturnType<typeof spyOn>
|
|
16
17
|
let fetchSpy: ReturnType<typeof spyOn>
|
|
18
|
+
let activeToken: string | null = null
|
|
17
19
|
|
|
18
20
|
beforeEach(() => {
|
|
19
21
|
getWorkspaceSpy = spyOn(CredentialManager.prototype, 'getWorkspace').mockResolvedValue(null)
|
|
@@ -31,6 +33,15 @@ beforeEach(() => {
|
|
|
31
33
|
|
|
32
34
|
getWorkspaceDomainsSpy = spyOn(TokenExtractor.prototype, 'getWorkspaceDomains').mockReturnValue({})
|
|
33
35
|
|
|
36
|
+
activeToken = null
|
|
37
|
+
loginSpy = spyOn(SlackClient.prototype, 'login').mockImplementation(function (
|
|
38
|
+
this: SlackClient,
|
|
39
|
+
credentials?: { token: string; cookie: string },
|
|
40
|
+
) {
|
|
41
|
+
activeToken = credentials?.token ?? null
|
|
42
|
+
return Promise.resolve(this)
|
|
43
|
+
})
|
|
44
|
+
|
|
34
45
|
testAuthSpy = spyOn(SlackClient.prototype, 'testAuth').mockResolvedValue({
|
|
35
46
|
user_id: 'U123',
|
|
36
47
|
team_id: 'T123',
|
|
@@ -55,6 +66,7 @@ afterEach(() => {
|
|
|
55
66
|
extractSpy?.mockRestore()
|
|
56
67
|
extractCookieSpy?.mockRestore()
|
|
57
68
|
getWorkspaceDomainsSpy?.mockRestore()
|
|
69
|
+
loginSpy?.mockRestore()
|
|
58
70
|
testAuthSpy?.mockRestore()
|
|
59
71
|
setWorkspaceSpy?.mockRestore()
|
|
60
72
|
loadSpy?.mockRestore()
|
|
@@ -62,6 +74,16 @@ afterEach(() => {
|
|
|
62
74
|
fetchSpy?.mockRestore()
|
|
63
75
|
})
|
|
64
76
|
|
|
77
|
+
function authResponseByToken(map: Record<string, { team_id: string; team?: string } | Error>) {
|
|
78
|
+
testAuthSpy.mockImplementation(() => {
|
|
79
|
+
const token = activeToken ?? '<no-token>'
|
|
80
|
+
const result = map[token]
|
|
81
|
+
if (!result) throw new Error('invalid_auth')
|
|
82
|
+
if (result instanceof Error) throw result
|
|
83
|
+
return Promise.resolve({ user_id: 'U1', user: 'user', team: result.team, team_id: result.team_id })
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
|
|
65
87
|
describe('ensureSlackAuth', () => {
|
|
66
88
|
it('skips extraction when stored credentials are valid', async () => {
|
|
67
89
|
// given
|
|
@@ -278,7 +300,7 @@ describe('ensureSlackAuth', () => {
|
|
|
278
300
|
)
|
|
279
301
|
})
|
|
280
302
|
|
|
281
|
-
it('skips web refresh when
|
|
303
|
+
it('skips web refresh when domain map is empty', async () => {
|
|
282
304
|
// given — domain mapping is empty
|
|
283
305
|
extractSpy.mockResolvedValue([
|
|
284
306
|
{ workspace_id: 'T-stale', workspace_name: 'stale-ws', token: 'xoxc-stale', cookie: 'xoxd-valid' },
|
|
@@ -294,6 +316,206 @@ describe('ensureSlackAuth', () => {
|
|
|
294
316
|
expect(setWorkspaceSpy).not.toHaveBeenCalled()
|
|
295
317
|
})
|
|
296
318
|
|
|
319
|
+
it('falls back to other known domains when workspace_id has no domain mapping', async () => {
|
|
320
|
+
// given — workspace_id 'unknown' (extractor couldn't resolve team id); cookie is valid
|
|
321
|
+
// for the second candidate domain in root-state.json
|
|
322
|
+
extractSpy.mockResolvedValue([
|
|
323
|
+
{ workspace_id: 'unknown', workspace_name: 'unknown', token: 'xoxc-stale', cookie: 'xoxd-valid' },
|
|
324
|
+
])
|
|
325
|
+
getWorkspaceDomainsSpy.mockReturnValue({
|
|
326
|
+
T_OTHER_A: 'other-a',
|
|
327
|
+
T_OTHER_B: 'other-b',
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
fetchSpy.mockImplementation((url: string) => {
|
|
331
|
+
if (url.startsWith('https://other-a.slack.com/')) {
|
|
332
|
+
return Promise.resolve(new Response('<html>"api_token":"xoxc-fresh-a"</html>', { status: 200 }))
|
|
333
|
+
}
|
|
334
|
+
if (url.startsWith('https://other-b.slack.com/')) {
|
|
335
|
+
return Promise.resolve(new Response('<html>"api_token":"xoxc-fresh-b"</html>', { status: 200 }))
|
|
336
|
+
}
|
|
337
|
+
return Promise.resolve(new Response('', { status: 500 }))
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
authResponseByToken({
|
|
341
|
+
'xoxc-fresh-b': { team_id: 'T_OTHER_B', team: 'Other B' },
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
// when
|
|
345
|
+
await ensureSlackAuth()
|
|
346
|
+
|
|
347
|
+
// then — both candidate domains were tried; the workspace saves with the resolved team_id
|
|
348
|
+
expect(fetchSpy).toHaveBeenCalledWith(
|
|
349
|
+
'https://other-a.slack.com/ssb/redirect',
|
|
350
|
+
expect.objectContaining({ headers: { Cookie: 'd=xoxd-valid' } }),
|
|
351
|
+
)
|
|
352
|
+
expect(fetchSpy).toHaveBeenCalledWith(
|
|
353
|
+
'https://other-b.slack.com/ssb/redirect',
|
|
354
|
+
expect.objectContaining({ headers: { Cookie: 'd=xoxd-valid' } }),
|
|
355
|
+
)
|
|
356
|
+
expect(setWorkspaceSpy).toHaveBeenCalledWith(
|
|
357
|
+
expect.objectContaining({ workspace_id: 'T_OTHER_B', token: 'xoxc-fresh-b', workspace_name: 'Other B' }),
|
|
358
|
+
)
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
it('stops trying domains once a refresh+verify succeeds', async () => {
|
|
362
|
+
// given — exact-match domain succeeds on first attempt; the fallback domain must not be fetched
|
|
363
|
+
extractSpy.mockResolvedValue([
|
|
364
|
+
{ workspace_id: 'T_TARGET', workspace_name: 'target', token: 'xoxc-stale', cookie: 'xoxd-valid' },
|
|
365
|
+
])
|
|
366
|
+
getWorkspaceDomainsSpy.mockReturnValue({
|
|
367
|
+
T_TARGET: 'target-domain',
|
|
368
|
+
T_OTHER: 'other-domain',
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
fetchSpy.mockResolvedValue(new Response('<html>"api_token":"xoxc-fresh"</html>', { status: 200 }))
|
|
372
|
+
|
|
373
|
+
authResponseByToken({
|
|
374
|
+
'xoxc-fresh': { team_id: 'T_TARGET', team: 'Target' },
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
// when
|
|
378
|
+
await ensureSlackAuth()
|
|
379
|
+
|
|
380
|
+
// then — the exact-match domain is tried, fallback domain is not
|
|
381
|
+
expect(fetchSpy).toHaveBeenCalledWith(
|
|
382
|
+
'https://target-domain.slack.com/ssb/redirect',
|
|
383
|
+
expect.objectContaining({ headers: { Cookie: 'd=xoxd-valid' } }),
|
|
384
|
+
)
|
|
385
|
+
expect(fetchSpy).not.toHaveBeenCalledWith('https://other-domain.slack.com/ssb/redirect', expect.anything())
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
it('returns failure when no known domain validates the cookie', async () => {
|
|
389
|
+
// given — none of the domains in the map produce a valid token+cookie pair
|
|
390
|
+
extractSpy.mockResolvedValue([
|
|
391
|
+
{ workspace_id: 'unknown', workspace_name: 'unknown', token: 'xoxc-stale', cookie: 'xoxd-valid' },
|
|
392
|
+
])
|
|
393
|
+
getWorkspaceDomainsSpy.mockReturnValue({
|
|
394
|
+
T_A: 'a',
|
|
395
|
+
T_B: 'b',
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
fetchSpy.mockResolvedValue(new Response('<html>"api_token":"xoxc-fresh"</html>', { status: 200 }))
|
|
399
|
+
testAuthSpy.mockRejectedValue(new Error('invalid_auth'))
|
|
400
|
+
|
|
401
|
+
// when
|
|
402
|
+
await ensureSlackAuth()
|
|
403
|
+
|
|
404
|
+
// then — every candidate is tried, none save
|
|
405
|
+
expect(fetchSpy).toHaveBeenCalledWith('https://a.slack.com/ssb/redirect', expect.anything())
|
|
406
|
+
expect(fetchSpy).toHaveBeenCalledWith('https://b.slack.com/ssb/redirect', expect.anything())
|
|
407
|
+
expect(setWorkspaceSpy).not.toHaveBeenCalled()
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
it('skips domains with non-subdomain characters', async () => {
|
|
411
|
+
// given — a tampered root-state.json with a domain that contains a dot/slash/colon
|
|
412
|
+
extractSpy.mockResolvedValue([
|
|
413
|
+
{ workspace_id: 'unknown', workspace_name: 'unknown', token: 'xoxc-stale', cookie: 'xoxd-valid' },
|
|
414
|
+
])
|
|
415
|
+
getWorkspaceDomainsSpy.mockReturnValue({
|
|
416
|
+
T_EVIL: 'attacker.com#',
|
|
417
|
+
T_GOOD: 'good',
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
fetchSpy.mockResolvedValue(new Response('<html>"api_token":"xoxc-fresh"</html>', { status: 200 }))
|
|
421
|
+
authResponseByToken({ 'xoxc-fresh': { team_id: 'T_GOOD', team: 'Good' } })
|
|
422
|
+
|
|
423
|
+
// when
|
|
424
|
+
await ensureSlackAuth()
|
|
425
|
+
|
|
426
|
+
// then — the bad domain is never fetched; the good domain succeeds
|
|
427
|
+
expect(fetchSpy).not.toHaveBeenCalledWith(expect.stringContaining('attacker.com'), expect.anything())
|
|
428
|
+
expect(fetchSpy).toHaveBeenCalledWith('https://good.slack.com/ssb/redirect', expect.anything())
|
|
429
|
+
expect(setWorkspaceSpy).toHaveBeenCalledWith(expect.objectContaining({ workspace_id: 'T_GOOD' }))
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
it('rejects refresh result when testAuth returns empty team_id', async () => {
|
|
433
|
+
// given — the fresh token verifies but Slack returns no team_id
|
|
434
|
+
extractSpy.mockResolvedValue([
|
|
435
|
+
{ workspace_id: 'unknown', workspace_name: 'unknown', token: 'xoxc-stale', cookie: 'xoxd-valid' },
|
|
436
|
+
])
|
|
437
|
+
getWorkspaceDomainsSpy.mockReturnValue({ T_A: 'a' })
|
|
438
|
+
fetchSpy.mockResolvedValue(new Response('<html>"api_token":"xoxc-fresh"</html>', { status: 200 }))
|
|
439
|
+
testAuthSpy.mockResolvedValue({ user_id: 'U1', team_id: '', user: 'user', team: undefined })
|
|
440
|
+
|
|
441
|
+
// when
|
|
442
|
+
await ensureSlackAuth()
|
|
443
|
+
|
|
444
|
+
// then — nothing is saved despite successful refresh+login
|
|
445
|
+
expect(setWorkspaceSpy).not.toHaveBeenCalled()
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
it('does not resolve multiple unknown workspaces to the same team', async () => {
|
|
449
|
+
// given — two unknown tokens share a cookie that validates against the first candidate domain.
|
|
450
|
+
// Naive iteration would resolve both to T_A; the resolved-team-id tracker must prevent the
|
|
451
|
+
// second unknown from claiming an already-saved team.
|
|
452
|
+
extractSpy.mockResolvedValue([
|
|
453
|
+
{ workspace_id: 'unknown', workspace_name: 'unknown', token: 'xoxc-stale-1', cookie: 'xoxd-valid' },
|
|
454
|
+
{ workspace_id: 'unknown', workspace_name: 'unknown', token: 'xoxc-stale-2', cookie: 'xoxd-valid' },
|
|
455
|
+
])
|
|
456
|
+
getWorkspaceDomainsSpy.mockReturnValue({ T_A: 'a', T_B: 'b' })
|
|
457
|
+
|
|
458
|
+
fetchSpy.mockImplementation((url: string) => {
|
|
459
|
+
if (url.startsWith('https://a.slack.com/')) {
|
|
460
|
+
return Promise.resolve(new Response('<html>"api_token":"xoxc-fresh-a"</html>', { status: 200 }))
|
|
461
|
+
}
|
|
462
|
+
if (url.startsWith('https://b.slack.com/')) {
|
|
463
|
+
return Promise.resolve(new Response('<html>"api_token":"xoxc-fresh-b"</html>', { status: 200 }))
|
|
464
|
+
}
|
|
465
|
+
return Promise.resolve(new Response('', { status: 500 }))
|
|
466
|
+
})
|
|
467
|
+
authResponseByToken({
|
|
468
|
+
'xoxc-fresh-a': { team_id: 'T_A', team: 'A' },
|
|
469
|
+
'xoxc-fresh-b': { team_id: 'T_B', team: 'B' },
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
// when
|
|
473
|
+
await ensureSlackAuth()
|
|
474
|
+
|
|
475
|
+
// then — T_A and T_B each saved exactly once
|
|
476
|
+
const savedIds = setWorkspaceSpy.mock.calls.map((c) => (c[0] as { workspace_id: string }).workspace_id)
|
|
477
|
+
expect(savedIds.sort()).toEqual(['T_A', 'T_B'])
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
it('caches refresh attempts per (cookie, domain) within one extraction', async () => {
|
|
481
|
+
// given — two unknown tokens with the same cookie; both will iterate the same domains
|
|
482
|
+
extractSpy.mockResolvedValue([
|
|
483
|
+
{ workspace_id: 'unknown', workspace_name: 'unknown', token: 'xoxc-stale-1', cookie: 'xoxd-valid' },
|
|
484
|
+
{ workspace_id: 'unknown', workspace_name: 'unknown', token: 'xoxc-stale-2', cookie: 'xoxd-valid' },
|
|
485
|
+
])
|
|
486
|
+
getWorkspaceDomainsSpy.mockReturnValue({ T_A: 'a', T_B: 'b' })
|
|
487
|
+
|
|
488
|
+
fetchSpy.mockResolvedValue(new Response('', { status: 500 }))
|
|
489
|
+
testAuthSpy.mockRejectedValue(new Error('invalid_auth'))
|
|
490
|
+
|
|
491
|
+
// when
|
|
492
|
+
await ensureSlackAuth()
|
|
493
|
+
|
|
494
|
+
// then — each domain fetched at most once across both workspaces (2 total, not 4)
|
|
495
|
+
const refreshCalls = fetchSpy.mock.calls.filter((c) => String(c[0]).includes('/ssb/redirect'))
|
|
496
|
+
expect(refreshCalls.length).toBe(2)
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
it('caps domain attempts at MAX_DOMAIN_ATTEMPTS', async () => {
|
|
500
|
+
// given — 20 candidate domains; only the first 16 should be attempted
|
|
501
|
+
extractSpy.mockResolvedValue([
|
|
502
|
+
{ workspace_id: 'unknown', workspace_name: 'unknown', token: 'xoxc-stale', cookie: 'xoxd-valid' },
|
|
503
|
+
])
|
|
504
|
+
const manyDomains: Record<string, string> = {}
|
|
505
|
+
for (let i = 0; i < 20; i++) manyDomains[`T_${i}`] = `dom${i}`
|
|
506
|
+
getWorkspaceDomainsSpy.mockReturnValue(manyDomains)
|
|
507
|
+
|
|
508
|
+
fetchSpy.mockResolvedValue(new Response('', { status: 500 }))
|
|
509
|
+
testAuthSpy.mockRejectedValue(new Error('invalid_auth'))
|
|
510
|
+
|
|
511
|
+
// when
|
|
512
|
+
await ensureSlackAuth()
|
|
513
|
+
|
|
514
|
+
// then — exactly 16 refresh attempts
|
|
515
|
+
const refreshCalls = fetchSpy.mock.calls.filter((c) => String(c[0]).includes('/ssb/redirect'))
|
|
516
|
+
expect(refreshCalls.length).toBe(16)
|
|
517
|
+
})
|
|
518
|
+
|
|
297
519
|
it('skips web refresh when workspace has no cookie', async () => {
|
|
298
520
|
// given — cookie is empty
|
|
299
521
|
extractSpy.mockResolvedValue([
|
|
@@ -21,20 +21,29 @@ export async function ensureSlackAuth(): Promise<void> {
|
|
|
21
21
|
const workspaces = await extractor.extract()
|
|
22
22
|
const workspaceDomains = extractor.getWorkspaceDomains()
|
|
23
23
|
|
|
24
|
-
const validWorkspaces = []
|
|
24
|
+
const validWorkspaces: ExtractedWorkspace[] = []
|
|
25
|
+
const resolvedTeamIds = new Set<string>()
|
|
26
|
+
const refreshCache = new Map<string, RefreshResult | null>()
|
|
27
|
+
|
|
25
28
|
for (const ws of workspaces) {
|
|
26
29
|
try {
|
|
27
30
|
const client = await new SlackClient().login({ token: ws.token, cookie: ws.cookie })
|
|
28
31
|
const authInfo = await client.testAuth()
|
|
32
|
+
if (!authInfo.team_id) throw new Error('testAuth returned empty team_id')
|
|
29
33
|
ws.workspace_id = authInfo.team_id
|
|
30
34
|
ws.workspace_name = authInfo.team || ws.workspace_name
|
|
31
|
-
|
|
32
|
-
|
|
35
|
+
if (!resolvedTeamIds.has(ws.workspace_id)) {
|
|
36
|
+
resolvedTeamIds.add(ws.workspace_id)
|
|
37
|
+
await credManager.setWorkspace(ws)
|
|
38
|
+
validWorkspaces.push(ws)
|
|
39
|
+
}
|
|
33
40
|
} catch {
|
|
34
|
-
const refreshed = await tryWebTokenRefresh(ws, workspaceDomains)
|
|
35
|
-
if (refreshed) {
|
|
41
|
+
const refreshed = await tryWebTokenRefresh(ws, workspaceDomains, { resolvedTeamIds, refreshCache })
|
|
42
|
+
if (refreshed && !resolvedTeamIds.has(refreshed.workspace_id)) {
|
|
36
43
|
ws.token = refreshed.token
|
|
44
|
+
ws.workspace_id = refreshed.workspace_id
|
|
37
45
|
ws.workspace_name = refreshed.workspace_name
|
|
46
|
+
resolvedTeamIds.add(ws.workspace_id)
|
|
38
47
|
await credManager.setWorkspace(ws)
|
|
39
48
|
validWorkspaces.push(ws)
|
|
40
49
|
}
|
|
@@ -54,21 +63,81 @@ export async function ensureSlackAuth(): Promise<void> {
|
|
|
54
63
|
}
|
|
55
64
|
}
|
|
56
65
|
|
|
66
|
+
// Bound worst-case HTTP traffic for users with very large root-state.json workspace lists.
|
|
67
|
+
const MAX_DOMAIN_ATTEMPTS = 16
|
|
68
|
+
|
|
69
|
+
// Slack workspace subdomains match `^[a-z][a-z0-9-]*$` per signup rules; validating here
|
|
70
|
+
// prevents a tampered root-state.json from steering the cookie-bearing fetch to a non-Slack
|
|
71
|
+
// host (e.g. a domain value containing `.`, `/`, `:`, `@`, `#`, or `?`).
|
|
72
|
+
const SLACK_DOMAIN_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9-]*$/
|
|
73
|
+
|
|
74
|
+
export type RefreshResult = { token: string; workspace_id: string; workspace_name: string }
|
|
75
|
+
|
|
76
|
+
export type RefreshContext = {
|
|
77
|
+
resolvedTeamIds?: Set<string>
|
|
78
|
+
refreshCache?: Map<string, RefreshResult | null>
|
|
79
|
+
}
|
|
80
|
+
|
|
57
81
|
export async function tryWebTokenRefresh(
|
|
58
82
|
ws: ExtractedWorkspace,
|
|
59
83
|
workspaceDomains: Record<string, string>,
|
|
60
|
-
|
|
84
|
+
context: RefreshContext = {},
|
|
85
|
+
): Promise<RefreshResult | null> {
|
|
61
86
|
if (!ws.cookie) return null
|
|
62
|
-
const domain = workspaceDomains[ws.workspace_id]
|
|
63
|
-
if (!domain) return null
|
|
64
87
|
|
|
88
|
+
// Try the domain that maps to this workspace_id first (when known), then fall back to
|
|
89
|
+
// every other known domain. Slack tokens extracted from LevelDB blobs sometimes carry
|
|
90
|
+
// workspace_id="unknown" because the regex window around the xoxc- bytes does not
|
|
91
|
+
// contain the T... team id; in that case the cookie may still be valid for one of the
|
|
92
|
+
// other workspaces the user is signed into.
|
|
93
|
+
const candidates = orderCandidateDomains(ws.workspace_id, workspaceDomains)
|
|
94
|
+
if (candidates.length === 0) return null
|
|
95
|
+
|
|
96
|
+
for (const { domain } of candidates) {
|
|
97
|
+
if (!SLACK_DOMAIN_REGEX.test(domain)) continue
|
|
98
|
+
|
|
99
|
+
const cacheKey = `${ws.cookie}\u0000${domain}`
|
|
100
|
+
const cached = context.refreshCache?.get(cacheKey)
|
|
101
|
+
let result: RefreshResult | null
|
|
102
|
+
if (cached !== undefined) {
|
|
103
|
+
result = cached
|
|
104
|
+
} else {
|
|
105
|
+
result = await refreshAndVerify(ws.cookie, domain, ws.workspace_name)
|
|
106
|
+
context.refreshCache?.set(cacheKey, result)
|
|
107
|
+
}
|
|
108
|
+
if (!result) continue
|
|
109
|
+
if (context.resolvedTeamIds?.has(result.workspace_id)) continue
|
|
110
|
+
return result
|
|
111
|
+
}
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function orderCandidateDomains(
|
|
116
|
+
workspaceId: string,
|
|
117
|
+
workspaceDomains: Record<string, string>,
|
|
118
|
+
): Array<{ workspace_id: string; domain: string }> {
|
|
119
|
+
const entries = Object.entries(workspaceDomains).map(([workspace_id, domain]) => ({ workspace_id, domain }))
|
|
120
|
+
const exact = entries.findIndex((e) => e.workspace_id === workspaceId)
|
|
121
|
+
if (exact > 0) {
|
|
122
|
+
const [match] = entries.splice(exact, 1)
|
|
123
|
+
entries.unshift(match)
|
|
124
|
+
}
|
|
125
|
+
return entries.slice(0, MAX_DOMAIN_ATTEMPTS)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function refreshAndVerify(cookie: string, domain: string, fallbackName: string): Promise<RefreshResult | null> {
|
|
65
129
|
try {
|
|
66
|
-
const freshToken = await refreshTokenFromWeb(domain,
|
|
130
|
+
const freshToken = await refreshTokenFromWeb(domain, cookie)
|
|
67
131
|
if (!freshToken) return null
|
|
68
132
|
|
|
69
|
-
const client = await new SlackClient().login({ token: freshToken, cookie
|
|
133
|
+
const client = await new SlackClient().login({ token: freshToken, cookie })
|
|
70
134
|
const authInfo = await client.testAuth()
|
|
71
|
-
|
|
135
|
+
if (!authInfo.team_id) return null
|
|
136
|
+
return {
|
|
137
|
+
token: freshToken,
|
|
138
|
+
workspace_id: authInfo.team_id,
|
|
139
|
+
workspace_name: authInfo.team || fallbackName,
|
|
140
|
+
}
|
|
72
141
|
} catch {
|
|
73
142
|
return null
|
|
74
143
|
}
|