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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -1
- package/dist/package.json +1 -1
- package/dist/src/platforms/kakaotalk/attachment-router.d.ts +25 -0
- package/dist/src/platforms/kakaotalk/attachment-router.d.ts.map +1 -0
- package/dist/src/platforms/kakaotalk/attachment-router.js +29 -0
- package/dist/src/platforms/kakaotalk/attachment-router.js.map +1 -0
- package/dist/src/platforms/kakaotalk/client.d.ts +14 -1
- package/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/client.js +216 -0
- package/dist/src/platforms/kakaotalk/client.js.map +1 -1
- package/dist/src/platforms/kakaotalk/commands/message.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/commands/message.js +49 -0
- package/dist/src/platforms/kakaotalk/commands/message.js.map +1 -1
- package/dist/src/platforms/kakaotalk/image-meta.d.ts +7 -0
- package/dist/src/platforms/kakaotalk/image-meta.d.ts.map +1 -0
- package/dist/src/platforms/kakaotalk/image-meta.js +153 -0
- package/dist/src/platforms/kakaotalk/image-meta.js.map +1 -0
- package/dist/src/platforms/kakaotalk/index.d.ts +6 -2
- package/dist/src/platforms/kakaotalk/index.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/index.js +4 -1
- package/dist/src/platforms/kakaotalk/index.js.map +1 -1
- package/dist/src/platforms/kakaotalk/media-upload.d.ts +3 -0
- package/dist/src/platforms/kakaotalk/media-upload.d.ts.map +1 -0
- package/dist/src/platforms/kakaotalk/media-upload.js +44 -0
- package/dist/src/platforms/kakaotalk/media-upload.js.map +1 -0
- package/dist/src/platforms/kakaotalk/protocol/connection.d.ts +1 -0
- package/dist/src/platforms/kakaotalk/protocol/connection.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/protocol/connection.js +11 -0
- package/dist/src/platforms/kakaotalk/protocol/connection.js.map +1 -1
- package/dist/src/platforms/kakaotalk/protocol/media-uploader.d.ts +25 -0
- package/dist/src/platforms/kakaotalk/protocol/media-uploader.d.ts.map +1 -0
- package/dist/src/platforms/kakaotalk/protocol/media-uploader.js +99 -0
- package/dist/src/platforms/kakaotalk/protocol/media-uploader.js.map +1 -0
- package/dist/src/platforms/kakaotalk/protocol/session.d.ts +6 -0
- package/dist/src/platforms/kakaotalk/protocol/session.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/protocol/session.js +61 -0
- package/dist/src/platforms/kakaotalk/protocol/session.js.map +1 -1
- package/dist/src/platforms/kakaotalk/types.d.ts +44 -0
- package/dist/src/platforms/kakaotalk/types.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/types.js +9 -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 +47 -2
- package/docs/content/docs/sdk/kakaotalk.mdx +32 -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 +62 -4
- package/skills/agent-kakaotalk/references/common-patterns.md +50 -11
- 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/attachment-router.test.ts +102 -0
- package/src/platforms/kakaotalk/attachment-router.ts +50 -0
- package/src/platforms/kakaotalk/client.ts +315 -8
- package/src/platforms/kakaotalk/commands/message.ts +66 -0
- package/src/platforms/kakaotalk/image-meta.test.ts +90 -0
- package/src/platforms/kakaotalk/image-meta.ts +176 -0
- package/src/platforms/kakaotalk/index.ts +7 -0
- package/src/platforms/kakaotalk/media-upload.ts +44 -0
- package/src/platforms/kakaotalk/protocol/connection.ts +11 -0
- package/src/platforms/kakaotalk/protocol/media-uploader.ts +129 -0
- package/src/platforms/kakaotalk/protocol/session.ts +67 -0
- package/src/platforms/kakaotalk/types.ts +57 -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
- 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
|
-
|
|
110
|
-
|
|
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
|
|
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([
|