agent-messenger 2.15.0 → 2.16.0

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 (85) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +1 -1
  3. package/dist/package.json +1 -1
  4. package/dist/src/platforms/kakaotalk/attachment-router.d.ts +25 -0
  5. package/dist/src/platforms/kakaotalk/attachment-router.d.ts.map +1 -0
  6. package/dist/src/platforms/kakaotalk/attachment-router.js +29 -0
  7. package/dist/src/platforms/kakaotalk/attachment-router.js.map +1 -0
  8. package/dist/src/platforms/kakaotalk/client.d.ts +14 -1
  9. package/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
  10. package/dist/src/platforms/kakaotalk/client.js +216 -0
  11. package/dist/src/platforms/kakaotalk/client.js.map +1 -1
  12. package/dist/src/platforms/kakaotalk/commands/message.d.ts.map +1 -1
  13. package/dist/src/platforms/kakaotalk/commands/message.js +49 -0
  14. package/dist/src/platforms/kakaotalk/commands/message.js.map +1 -1
  15. package/dist/src/platforms/kakaotalk/image-meta.d.ts +7 -0
  16. package/dist/src/platforms/kakaotalk/image-meta.d.ts.map +1 -0
  17. package/dist/src/platforms/kakaotalk/image-meta.js +153 -0
  18. package/dist/src/platforms/kakaotalk/image-meta.js.map +1 -0
  19. package/dist/src/platforms/kakaotalk/index.d.ts +6 -2
  20. package/dist/src/platforms/kakaotalk/index.d.ts.map +1 -1
  21. package/dist/src/platforms/kakaotalk/index.js +4 -1
  22. package/dist/src/platforms/kakaotalk/index.js.map +1 -1
  23. package/dist/src/platforms/kakaotalk/media-upload.d.ts +3 -0
  24. package/dist/src/platforms/kakaotalk/media-upload.d.ts.map +1 -0
  25. package/dist/src/platforms/kakaotalk/media-upload.js +44 -0
  26. package/dist/src/platforms/kakaotalk/media-upload.js.map +1 -0
  27. package/dist/src/platforms/kakaotalk/protocol/connection.d.ts +1 -0
  28. package/dist/src/platforms/kakaotalk/protocol/connection.d.ts.map +1 -1
  29. package/dist/src/platforms/kakaotalk/protocol/connection.js +11 -0
  30. package/dist/src/platforms/kakaotalk/protocol/connection.js.map +1 -1
  31. package/dist/src/platforms/kakaotalk/protocol/media-uploader.d.ts +25 -0
  32. package/dist/src/platforms/kakaotalk/protocol/media-uploader.d.ts.map +1 -0
  33. package/dist/src/platforms/kakaotalk/protocol/media-uploader.js +99 -0
  34. package/dist/src/platforms/kakaotalk/protocol/media-uploader.js.map +1 -0
  35. package/dist/src/platforms/kakaotalk/protocol/session.d.ts +6 -0
  36. package/dist/src/platforms/kakaotalk/protocol/session.d.ts.map +1 -1
  37. package/dist/src/platforms/kakaotalk/protocol/session.js +61 -0
  38. package/dist/src/platforms/kakaotalk/protocol/session.js.map +1 -1
  39. package/dist/src/platforms/kakaotalk/types.d.ts +44 -0
  40. package/dist/src/platforms/kakaotalk/types.d.ts.map +1 -1
  41. package/dist/src/platforms/kakaotalk/types.js +9 -0
  42. package/dist/src/platforms/kakaotalk/types.js.map +1 -1
  43. package/dist/src/platforms/slack/commands/auth.d.ts.map +1 -1
  44. package/dist/src/platforms/slack/commands/auth.js +6 -2
  45. package/dist/src/platforms/slack/commands/auth.js.map +1 -1
  46. package/dist/src/platforms/slack/ensure-auth.d.ts +8 -2
  47. package/dist/src/platforms/slack/ensure-auth.d.ts.map +1 -1
  48. package/dist/src/platforms/slack/ensure-auth.js +67 -10
  49. package/dist/src/platforms/slack/ensure-auth.js.map +1 -1
  50. package/docs/content/docs/cli/kakaotalk.mdx +47 -2
  51. package/docs/content/docs/sdk/kakaotalk.mdx +32 -0
  52. package/package.json +1 -1
  53. package/skills/agent-channeltalk/SKILL.md +1 -1
  54. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  55. package/skills/agent-discord/SKILL.md +1 -1
  56. package/skills/agent-discordbot/SKILL.md +1 -1
  57. package/skills/agent-instagram/SKILL.md +1 -1
  58. package/skills/agent-kakaotalk/SKILL.md +62 -4
  59. package/skills/agent-kakaotalk/references/common-patterns.md +50 -11
  60. package/skills/agent-line/SKILL.md +1 -1
  61. package/skills/agent-slack/SKILL.md +1 -1
  62. package/skills/agent-slackbot/SKILL.md +1 -1
  63. package/skills/agent-teams/SKILL.md +1 -1
  64. package/skills/agent-telegram/SKILL.md +1 -1
  65. package/skills/agent-telegrambot/SKILL.md +1 -1
  66. package/skills/agent-webex/SKILL.md +1 -1
  67. package/skills/agent-wechatbot/SKILL.md +1 -1
  68. package/skills/agent-whatsapp/SKILL.md +1 -1
  69. package/skills/agent-whatsappbot/SKILL.md +1 -1
  70. package/src/platforms/kakaotalk/attachment-router.test.ts +102 -0
  71. package/src/platforms/kakaotalk/attachment-router.ts +50 -0
  72. package/src/platforms/kakaotalk/client.ts +315 -8
  73. package/src/platforms/kakaotalk/commands/message.ts +66 -0
  74. package/src/platforms/kakaotalk/image-meta.test.ts +90 -0
  75. package/src/platforms/kakaotalk/image-meta.ts +176 -0
  76. package/src/platforms/kakaotalk/index.ts +7 -0
  77. package/src/platforms/kakaotalk/media-upload.ts +44 -0
  78. package/src/platforms/kakaotalk/protocol/connection.ts +11 -0
  79. package/src/platforms/kakaotalk/protocol/media-uploader.ts +129 -0
  80. package/src/platforms/kakaotalk/protocol/session.ts +67 -0
  81. package/src/platforms/kakaotalk/types.ts +57 -0
  82. package/src/platforms/slack/commands/auth.ts +6 -4
  83. package/src/platforms/slack/ensure-auth.test.ts +223 -1
  84. package/src/platforms/slack/ensure-auth.ts +80 -11
  85. package/src/platforms/telegrambot/cli.ts +0 -0
@@ -12,8 +12,10 @@ export type {
12
12
  KakaoDeviceType,
13
13
  KakaoEmoticonKind,
14
14
  KakaoEmoticonMessageType,
15
+ KakaoFileExtra,
15
16
  KakaoMember,
16
17
  KakaoMessage,
18
+ KakaoPhotoExtra,
17
19
  KakaoProfile,
18
20
  KakaoSendResult,
19
21
  KakaoTalkListenerEventMap,
@@ -27,6 +29,7 @@ export type {
27
29
  export {
28
30
  KAKAO_EMOTICON_KIND_BY_TYPE,
29
31
  KAKAO_EMOTICON_MESSAGE_TYPES,
32
+ KAKAO_MESSAGE_TYPE,
30
33
  KakaoAccountCredentialsSchema,
31
34
  KakaoChatSchema,
32
35
  KakaoConfigSchema,
@@ -39,3 +42,7 @@ export {
39
42
  KakaoTalkPushMessageEventSchema,
40
43
  KakaoTalkPushReadEventSchema,
41
44
  } from './types'
45
+ export { sha1Hex } from './media-upload'
46
+ export { detectImageDimensions } from './image-meta'
47
+ export type { AttachmentInput, AttachmentPlan, ResolvedAttachment, SingleAttachmentKind } from './attachment-router'
48
+ export { planAttachments, resolveAttachment } from './attachment-router'
@@ -0,0 +1,44 @@
1
+ // SHA-1 hex (uppercase, 40 chars) — required as the `cs` (checksum) field
2
+ // on SHIP requests and on the inbound photo `extra` JSON. Verified by inbound
3
+ // capture of a real KakaoTalk client photo (2026-05).
4
+ export async function sha1Hex(data: Uint8Array): Promise<string> {
5
+ const hashBuf = await crypto.subtle.digest('SHA-1', data)
6
+ return Array.from(new Uint8Array(hashBuf))
7
+ .map((b) => b.toString(16).padStart(2, '0').toUpperCase())
8
+ .join('')
9
+ }
10
+
11
+ const MIME_BY_EXT: Record<string, string> = {
12
+ jpg: 'image/jpeg',
13
+ jpeg: 'image/jpeg',
14
+ png: 'image/png',
15
+ gif: 'image/gif',
16
+ webp: 'image/webp',
17
+ mp4: 'video/mp4',
18
+ mov: 'video/quicktime',
19
+ webm: 'video/webm',
20
+ mkv: 'video/x-matroska',
21
+ m4v: 'video/x-m4v',
22
+ m4a: 'audio/m4a',
23
+ mp3: 'audio/mpeg',
24
+ wav: 'audio/wav',
25
+ ogg: 'audio/ogg',
26
+ pdf: 'application/pdf',
27
+ csv: 'text/csv',
28
+ txt: 'text/plain',
29
+ json: 'application/json',
30
+ zip: 'application/zip',
31
+ doc: 'application/msword',
32
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
33
+ xls: 'application/vnd.ms-excel',
34
+ xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
35
+ ppt: 'application/vnd.ms-powerpoint',
36
+ pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
37
+ }
38
+
39
+ export function guessMimeFromFilename(filename: string): string {
40
+ const dot = filename.lastIndexOf('.')
41
+ if (dot < 0 || dot === filename.length - 1) return 'application/octet-stream'
42
+ const ext = filename.slice(dot + 1).toLowerCase()
43
+ return MIME_BY_EXT[ext] ?? 'application/octet-stream'
44
+ }
@@ -50,6 +50,17 @@ export class LocoConnection {
50
50
  await this.write(handshakePacket)
51
51
  }
52
52
 
53
+ // Writes raw bytes onto the LOCO stream after applying the same AES-128-GCM
54
+ // framing as encrypted packets — used by the media-upload POST step where
55
+ // the protocol expects the file payload to follow the POST request inside
56
+ // the same encrypted channel (chunked into N frames automatically by the
57
+ // crypto layer's 4-byte-size + nonce + ciphertext + tag wrapping).
58
+ async writeRaw(data: Buffer): Promise<void> {
59
+ if (!this.crypto) throw new Error('crypto not initialised')
60
+ const encrypted = this.crypto.encrypt(data)
61
+ await this.write(encrypted)
62
+ }
63
+
53
64
  async sendPacket(method: string, body: Record<string, unknown> = {}): Promise<LocoPacket> {
54
65
  const packetId = ++this.packetIdCounter
55
66
  const packet: LocoPacket = {
@@ -0,0 +1,129 @@
1
+ import { Long } from 'bson'
2
+
3
+ import type { KakaoDeviceType } from '../types'
4
+ import { MCCMNC, getLocoDeviceConfig } from './config'
5
+ import { LocoConnection } from './connection'
6
+ import type { LocoPacket } from './types'
7
+
8
+ export interface UploadToLocoOptions {
9
+ shipToken: string
10
+ shipHost: string
11
+ shipPort: number
12
+ chatId: Long
13
+ msgType: number
14
+ userId: string
15
+ filename: string
16
+ data: Uint8Array
17
+ width?: number
18
+ height?: number
19
+ deviceType: KakaoDeviceType
20
+ onProgress?: (sent: number, total: number) => void
21
+ }
22
+
23
+ export interface UploadToLocoResult {
24
+ completePacket: LocoPacket | null
25
+ postStatusCode: number
26
+ postOffset: number
27
+ }
28
+
29
+ const DEFAULT_NETWORK_TYPE = 0
30
+ const DEFAULT_COMPLETE_TIMEOUT_MS = 60_000
31
+
32
+ // Drives the SHIP → connect → POST → stream → COMPLETE pipeline that KakaoTalk
33
+ // uses for chat-media uploads. The caller has already done SHIP on the main
34
+ // session and received {k, vh, p}; this opens a dedicated TCP+LOCO connection
35
+ // to (vh, p), sends POST with the ticket + chat metadata, streams the raw file
36
+ // bytes, and waits for the server's COMPLETE push that triggers the actual
37
+ // chat-message registration.
38
+ //
39
+ // Field names (u/k/t/s/c/mid/w/h/mm/nt/os/av/f/ns) match the APK's
40
+ // `SR/g0.java` (PostJob) verbatim. The COMPLETE handshake is a server-pushed
41
+ // packet whose body contains the resulting chatLog struct.
42
+ export async function uploadMediaToLoco(opts: UploadToLocoOptions): Promise<UploadToLocoResult> {
43
+ return runPostStreamComplete(opts, 'POST')
44
+ }
45
+
46
+ // Single-entry MPOST upload — runs the same connect → POST → stream → COMPLETE
47
+ // pipeline as uploadMediaToLoco but with MPOST as the opcode (used after MSHIP
48
+ // for each entry in a multi-photo batch). Callers fan-out across all entries
49
+ // in parallel and then issue one FORWARD to register the gallery message.
50
+ export async function uploadMultiMediaEntry(opts: UploadToLocoOptions): Promise<UploadToLocoResult> {
51
+ return runPostStreamComplete(opts, 'MPOST')
52
+ }
53
+
54
+ async function runPostStreamComplete(opts: UploadToLocoOptions, opcode: 'POST' | 'MPOST'): Promise<UploadToLocoResult> {
55
+ const device = getLocoDeviceConfig(opts.deviceType)
56
+
57
+ const conn = new LocoConnection()
58
+ await conn.connectSecure(opts.shipHost, opts.shipPort)
59
+
60
+ const totalSize = opts.data.byteLength
61
+ let completeResolve: ((p: LocoPacket | null) => void) | null = null
62
+ let completeTimeout: ReturnType<typeof setTimeout> | null = null
63
+ const completePromise = new Promise<LocoPacket | null>((resolve) => {
64
+ completeResolve = resolve
65
+ completeTimeout = setTimeout(() => {
66
+ completeTimeout = null
67
+ resolve(null)
68
+ }, DEFAULT_COMPLETE_TIMEOUT_MS)
69
+ })
70
+ conn.onPush((push) => {
71
+ if (push.method === 'COMPLETE' && completeResolve) {
72
+ if (completeTimeout) {
73
+ clearTimeout(completeTimeout)
74
+ completeTimeout = null
75
+ }
76
+ completeResolve(push)
77
+ completeResolve = null
78
+ }
79
+ })
80
+
81
+ try {
82
+ const postBody: Record<string, unknown> = {
83
+ k: opts.shipToken,
84
+ s: totalSize,
85
+ t: opts.msgType,
86
+ u: Long.fromString(opts.userId),
87
+ os: device.os,
88
+ av: device.appVersion,
89
+ nt: DEFAULT_NETWORK_TYPE,
90
+ mm: MCCMNC,
91
+ }
92
+ if (opcode === 'POST') {
93
+ postBody.f = opts.filename
94
+ postBody.c = opts.chatId
95
+ postBody.mid = Long.ONE
96
+ postBody.ns = true
97
+ if (typeof opts.width === 'number') postBody.w = opts.width
98
+ if (typeof opts.height === 'number') postBody.h = opts.height
99
+ } else {
100
+ postBody.dt = 0
101
+ postBody.scp = 0
102
+ }
103
+
104
+ const postResp = await conn.sendPacket(opcode, postBody)
105
+ const postOffsetRaw = (postResp.body as Record<string, unknown>).o
106
+ const postOffset = typeof postOffsetRaw === 'number' ? postOffsetRaw : 0
107
+
108
+ const bytesToSend = opts.data.subarray(postOffset)
109
+ if (bytesToSend.length > 0) {
110
+ await conn.writeRaw(Buffer.from(bytesToSend))
111
+ opts.onProgress?.(totalSize, totalSize)
112
+ }
113
+
114
+ const completePacket = await completePromise
115
+ const postStatusCode = postResp.statusCode
116
+
117
+ return {
118
+ completePacket,
119
+ postStatusCode,
120
+ postOffset,
121
+ }
122
+ } finally {
123
+ if (completeTimeout) {
124
+ clearTimeout(completeTimeout)
125
+ completeTimeout = null
126
+ }
127
+ conn.close()
128
+ }
129
+ }
@@ -126,6 +126,73 @@ export class LocoSession {
126
126
  })
127
127
  }
128
128
 
129
+ // Sends a WRITE with non-text message_type plus the JSON-stringified `extra`
130
+ // payload that KakaoTalk clients render as the attachment (photo, file, etc).
131
+ // See types.ts → KakaoPhotoExtra / KakaoFileExtra for the per-type shape.
132
+ async sendAttachment(chatId: Long, type: number, extra: Record<string, unknown>, caption = ''): Promise<LocoPacket> {
133
+ if (!this.connection) throw new Error('Not connected')
134
+ return this.connection.sendPacket('WRITE', {
135
+ chatId,
136
+ msg: caption,
137
+ type,
138
+ noSeen: false,
139
+ extra: JSON.stringify(extra),
140
+ })
141
+ }
142
+
143
+ // SHIP — request a media-upload ticket. Reserves a slot on a media LOCO
144
+ // server and returns the token (k), host (vh), and port (p) the client must
145
+ // connect to next. Sent on the main session.
146
+ async shipMedia(chatId: Long, type: number, size: number, checksum: string, extension: string): Promise<LocoPacket> {
147
+ if (!this.connection) throw new Error('Not connected')
148
+ const body: Record<string, unknown> = {
149
+ c: chatId,
150
+ t: type,
151
+ s: Long.fromNumber(size),
152
+ cs: checksum,
153
+ }
154
+ if (extension.length > 0) body.e = extension
155
+ return this.connection.sendPacket('SHIP', body)
156
+ }
157
+
158
+ // MSHIP — multi-file equivalent of SHIP. Per-file fields become parallel
159
+ // arrays (sl/csl/el) and the response carries kl/vhl/pl arrays the caller
160
+ // must fan out across — one MPOST connection per entry.
161
+ async shipMultiMedia(
162
+ chatId: Long,
163
+ type: number,
164
+ sizes: number[],
165
+ checksums: string[],
166
+ extensions: string[],
167
+ ): Promise<LocoPacket> {
168
+ if (!this.connection) throw new Error('Not connected')
169
+ return this.connection.sendPacket('MSHIP', {
170
+ c: chatId,
171
+ t: type,
172
+ sl: sizes.map((s) => Long.fromNumber(s)),
173
+ csl: checksums,
174
+ el: extensions,
175
+ })
176
+ }
177
+
178
+ // FORWARD — used after MPOST: registers a multi-attachment chatlog as one
179
+ // message. Same shape as WRITE but the server routes the attachment to
180
+ // multi-media rendering (galleries, multi-photo posts).
181
+ async forwardChat(chatId: Long, type: number, extra: Record<string, unknown>, caption = ''): Promise<LocoPacket> {
182
+ if (!this.connection) throw new Error('Not connected')
183
+ return this.connection.sendPacket('FORWARD', {
184
+ chatId,
185
+ msg: caption,
186
+ type,
187
+ noSeen: false,
188
+ extra: JSON.stringify(extra),
189
+ })
190
+ }
191
+
192
+ getConnection(): LocoConnection | null {
193
+ return this.connection
194
+ }
195
+
129
196
  async syncMessages(chatId: Long, count = 20, cursor?: Long, maxLogId?: Long): Promise<LocoPacket> {
130
197
  if (!this.connection) throw new Error('Not connected')
131
198
  return this.connection.sendPacket('SYNCMSG', {
@@ -119,6 +119,63 @@ export interface KakaoSendResult {
119
119
  sent_at: number
120
120
  }
121
121
 
122
+ // LOCO message_type values. Source: KakaoTalk APK 26.4.2 + typeclaw inbound parser.
123
+ export const KAKAO_MESSAGE_TYPE = {
124
+ TEXT: 1,
125
+ PHOTO: 2,
126
+ VIDEO: 3,
127
+ AUDIO: 5,
128
+ FILE: 18,
129
+ MULTIPHOTO: 27,
130
+ } as const
131
+
132
+ // PHOTO `extra` keys verified by inbound capture of a real KakaoTalk client
133
+ // photo message (2026-05). The bare minimum the receiver needs to render
134
+ // the inline preview is k/s/w/h/mt/cs — url and thumbnailUrl are server-issued
135
+ // pre-signed URLs that the recipient regenerates locally from k, so we don't
136
+ // supply them on outbound.
137
+ export interface KakaoPhotoExtra {
138
+ k: string
139
+ s: number
140
+ w: number
141
+ h: number
142
+ mt: string
143
+ cs: string
144
+ url?: string
145
+ thumbnailUrl?: string
146
+ thumbnailWidth?: number
147
+ thumbnailHeight?: number
148
+ expire?: number
149
+ }
150
+
151
+ export interface KakaoFileExtra {
152
+ k: string
153
+ s: number
154
+ name: string
155
+ mt: string
156
+ cs: string
157
+ expire?: number
158
+ url?: string
159
+ }
160
+
161
+ // MULTIPHOTO extra — each per-file field of KakaoPhotoExtra becomes a
162
+ // parallel array. Verified by inbound capture of a real multi-photo message
163
+ // (2026-05). csl uses lowercase hex (unlike PHOTO's cs which is uppercase).
164
+ export interface KakaoMultiPhotoExtra {
165
+ kl: string[]
166
+ wl: number[]
167
+ hl: number[]
168
+ mtl: string[]
169
+ sl: number[]
170
+ csl: string[]
171
+ cmtl?: string[]
172
+ imageUrls?: string[]
173
+ thumbnailUrls?: string[]
174
+ thumbnailWidths?: number[]
175
+ thumbnailHeights?: number[]
176
+ expire?: number
177
+ }
178
+
122
179
  export interface KakaoMarkReadResult {
123
180
  success: boolean
124
181
  status_code: number
@@ -106,19 +106,21 @@ async function extractAction(options: {
106
106
 
107
107
  if (options.debug) {
108
108
  const domain = workspaceDomains[ws.workspace_id]
109
- debug(
110
- `[debug] Attempting web token refresh for ${ws.workspace_id}${domain ? ` (${domain}.slack.com)` : ''}...`,
111
- )
109
+ const target = domain
110
+ ? `${ws.workspace_id} (${domain}.slack.com)`
111
+ : `${ws.workspace_id} (trying all known domains)`
112
+ debug(`[debug] Attempting web token refresh for ${target}...`)
112
113
  }
113
114
  const refreshed = await tryWebTokenRefresh(ws, workspaceDomains)
114
115
  if (refreshed) {
115
116
  ws.token = refreshed.token
117
+ ws.workspace_id = refreshed.workspace_id
116
118
  ws.workspace_name = refreshed.workspace_name
117
119
  validWorkspaces.push(ws)
118
120
  await credManager.setWorkspace(ws)
119
121
 
120
122
  if (options.debug) {
121
- debug(`[debug] ✓ Web refresh succeeded: ${refreshed.workspace_name}`)
123
+ debug(`[debug] ✓ Web refresh succeeded: ${refreshed.workspace_id}/${refreshed.workspace_name}`)
122
124
  }
123
125
  } else if (options.debug) {
124
126
  debug('[debug] ✗ Web refresh failed')
@@ -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 no domain is known for workspace', async () => {
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([