agent-messenger 2.15.1 → 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/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/telegrambot/cli.ts +0 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { detectImageDimensions } from './image-meta'
|
|
4
|
+
|
|
5
|
+
function buildPng(width: number, height: number, opts?: { skipTrailingMagic?: boolean }): Uint8Array {
|
|
6
|
+
const buf = new Uint8Array(24)
|
|
7
|
+
const sig = opts?.skipTrailingMagic
|
|
8
|
+
? [0x89, 0x50, 0x4e, 0x47, 0x00, 0x00, 0x00, 0x00]
|
|
9
|
+
: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
|
|
10
|
+
buf.set(sig, 0)
|
|
11
|
+
const view = new DataView(buf.buffer)
|
|
12
|
+
view.setUint32(16, width, false)
|
|
13
|
+
view.setUint32(20, height, false)
|
|
14
|
+
return buf
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function buildGif(version: '87a' | '89a' | 'XXa', width: number, height: number): Uint8Array {
|
|
18
|
+
const buf = new Uint8Array(10)
|
|
19
|
+
buf.set([0x47, 0x49, 0x46], 0)
|
|
20
|
+
if (version === '87a') buf.set([0x38, 0x37, 0x61], 3)
|
|
21
|
+
else if (version === '89a') buf.set([0x38, 0x39, 0x61], 3)
|
|
22
|
+
else buf.set([0x00, 0x00, 0x00], 3)
|
|
23
|
+
const view = new DataView(buf.buffer)
|
|
24
|
+
view.setUint16(6, width, true)
|
|
25
|
+
view.setUint16(8, height, true)
|
|
26
|
+
return buf
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function buildJpeg(opts: {
|
|
30
|
+
width: number
|
|
31
|
+
height: number
|
|
32
|
+
prefixFillBytes?: number
|
|
33
|
+
includeStandaloneMarkers?: boolean
|
|
34
|
+
}): Uint8Array {
|
|
35
|
+
const parts: number[] = [0xff, 0xd8]
|
|
36
|
+
if (opts.includeStandaloneMarkers) {
|
|
37
|
+
parts.push(0xff, 0xd0, 0xff, 0xd9)
|
|
38
|
+
}
|
|
39
|
+
for (let i = 0; i < (opts.prefixFillBytes ?? 0); i++) parts.push(0xff)
|
|
40
|
+
parts.push(0xff, 0xc0)
|
|
41
|
+
parts.push(0x00, 0x11)
|
|
42
|
+
parts.push(0x08)
|
|
43
|
+
parts.push((opts.height >> 8) & 0xff, opts.height & 0xff)
|
|
44
|
+
parts.push((opts.width >> 8) & 0xff, opts.width & 0xff)
|
|
45
|
+
for (let i = 0; i < 10; i++) parts.push(0x00)
|
|
46
|
+
return new Uint8Array(parts)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('detectImageDimensions', () => {
|
|
50
|
+
it('reads PNG dimensions when the full 8-byte signature is present', () => {
|
|
51
|
+
const dim = detectImageDimensions(buildPng(320, 240))
|
|
52
|
+
expect(dim).toEqual({ width: 320, height: 240, mimeType: 'image/png' })
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('rejects PNG-look-alikes that share only the first 4 bytes', () => {
|
|
56
|
+
expect(() => detectImageDimensions(buildPng(1, 1, { skipTrailingMagic: true }))).toThrow(/Unsupported/)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('reads GIF87a and GIF89a dimensions', () => {
|
|
60
|
+
expect(detectImageDimensions(buildGif('87a', 100, 200))).toEqual({
|
|
61
|
+
width: 100,
|
|
62
|
+
height: 200,
|
|
63
|
+
mimeType: 'image/gif',
|
|
64
|
+
})
|
|
65
|
+
expect(detectImageDimensions(buildGif('89a', 64, 32))).toEqual({
|
|
66
|
+
width: 64,
|
|
67
|
+
height: 32,
|
|
68
|
+
mimeType: 'image/gif',
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('rejects GIF-look-alikes with the wrong version bytes', () => {
|
|
73
|
+
expect(() => detectImageDimensions(buildGif('XXa', 100, 200))).toThrow(/Unsupported/)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('reads JPEG dimensions from a plain SOF0 marker', () => {
|
|
77
|
+
const dim = detectImageDimensions(buildJpeg({ width: 800, height: 600 }))
|
|
78
|
+
expect(dim).toEqual({ width: 800, height: 600, mimeType: 'image/jpeg' })
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('reads JPEG dimensions when 0xFF fill bytes precede the SOF marker', () => {
|
|
82
|
+
const dim = detectImageDimensions(buildJpeg({ width: 1280, height: 720, prefixFillBytes: 5 }))
|
|
83
|
+
expect(dim).toEqual({ width: 1280, height: 720, mimeType: 'image/jpeg' })
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('reads JPEG dimensions past standalone markers (RST/SOI/EOI) without desyncing', () => {
|
|
87
|
+
const dim = detectImageDimensions(buildJpeg({ width: 42, height: 99, includeStandaloneMarkers: true }))
|
|
88
|
+
expect(dim).toEqual({ width: 42, height: 99, mimeType: 'image/jpeg' })
|
|
89
|
+
})
|
|
90
|
+
})
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
export interface ImageDimensions {
|
|
2
|
+
width: number
|
|
3
|
+
height: number
|
|
4
|
+
mimeType: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
// Extracts width/height/mime by reading the file's magic bytes and headers
|
|
8
|
+
// directly. Avoids pulling in `image-size` or `sharp` so the SDK stays
|
|
9
|
+
// dependency-light. Supports JPEG, PNG, GIF, WebP — the formats KakaoTalk
|
|
10
|
+
// actually renders inline. Throws for anything else (including truncated
|
|
11
|
+
// headers whose magic matches but whose length is short of the dimension
|
|
12
|
+
// fields, to avoid an unhandled DataView RangeError); the caller should fall
|
|
13
|
+
// back to `sendFile` (type=18) for unknown types.
|
|
14
|
+
export function detectImageDimensions(buffer: Uint8Array): ImageDimensions {
|
|
15
|
+
const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength)
|
|
16
|
+
|
|
17
|
+
if (isPng(buffer)) {
|
|
18
|
+
if (buffer.length < 24) {
|
|
19
|
+
throw new Error('Truncated PNG: header is shorter than 24 bytes')
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
width: view.getUint32(16, false),
|
|
23
|
+
height: view.getUint32(20, false),
|
|
24
|
+
mimeType: 'image/png',
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (isJpeg(buffer)) {
|
|
29
|
+
const dim = readJpegDimensions(buffer)
|
|
30
|
+
return { ...dim, mimeType: 'image/jpeg' }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (isGif(buffer)) {
|
|
34
|
+
if (buffer.length < 10) {
|
|
35
|
+
throw new Error('Truncated GIF: header is shorter than 10 bytes')
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
width: view.getUint16(6, true),
|
|
39
|
+
height: view.getUint16(8, true),
|
|
40
|
+
mimeType: 'image/gif',
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (isWebp(buffer)) {
|
|
45
|
+
if (buffer.length < 16) {
|
|
46
|
+
throw new Error('Truncated WebP: header is shorter than 16 bytes')
|
|
47
|
+
}
|
|
48
|
+
return { ...readWebpDimensions(buffer), mimeType: 'image/webp' }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
throw new Error('Unsupported image format (expected JPEG, PNG, GIF, or WebP)')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Full PNG signature: 89 50 4E 47 0D 0A 1A 0A — checking only the first 4 bytes
|
|
55
|
+
// would let a file that happens to start with "\x89PNG" but isn't really a PNG
|
|
56
|
+
// reach the dimension reader and either crash or return garbage. The trailing
|
|
57
|
+
// CRLF/EOF/LF bytes pin it to a real PNG header.
|
|
58
|
+
function isPng(b: Uint8Array): boolean {
|
|
59
|
+
return (
|
|
60
|
+
b[0] === 0x89 &&
|
|
61
|
+
b[1] === 0x50 &&
|
|
62
|
+
b[2] === 0x4e &&
|
|
63
|
+
b[3] === 0x47 &&
|
|
64
|
+
b[4] === 0x0d &&
|
|
65
|
+
b[5] === 0x0a &&
|
|
66
|
+
b[6] === 0x1a &&
|
|
67
|
+
b[7] === 0x0a
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isJpeg(b: Uint8Array): boolean {
|
|
72
|
+
return b[0] === 0xff && b[1] === 0xd8
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// GIF signature is "GIF87a" or "GIF89a". Checking only "GIF" would match any
|
|
76
|
+
// payload that starts with those three bytes (other Compuserve formats, etc.).
|
|
77
|
+
function isGif(b: Uint8Array): boolean {
|
|
78
|
+
return (
|
|
79
|
+
b[0] === 0x47 &&
|
|
80
|
+
b[1] === 0x49 &&
|
|
81
|
+
b[2] === 0x46 &&
|
|
82
|
+
b[3] === 0x38 &&
|
|
83
|
+
(b[4] === 0x37 || b[4] === 0x39) &&
|
|
84
|
+
b[5] === 0x61
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isWebp(b: Uint8Array): boolean {
|
|
89
|
+
return (
|
|
90
|
+
b[0] === 0x52 &&
|
|
91
|
+
b[1] === 0x49 &&
|
|
92
|
+
b[2] === 0x46 &&
|
|
93
|
+
b[3] === 0x46 &&
|
|
94
|
+
b[8] === 0x57 &&
|
|
95
|
+
b[9] === 0x45 &&
|
|
96
|
+
b[10] === 0x42 &&
|
|
97
|
+
b[11] === 0x50
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// JPEG dimensions live in an SOF (Start Of Frame) marker (0xFFC0..0xFFCF except
|
|
102
|
+
// 0xFFC4, 0xFFC8, 0xFFCC which are not SOF). We scan markers until we hit one.
|
|
103
|
+
//
|
|
104
|
+
// The scan has to handle two JPEG quirks (ITU-T T.81 §B.1):
|
|
105
|
+
// 1. Fill bytes — any number of 0xFF bytes may precede a marker, so on a
|
|
106
|
+
// 0xFF byte we look at the next byte for the actual marker code and skip
|
|
107
|
+
// forward by 1 if it's another 0xFF (without trying to read a length).
|
|
108
|
+
// 2. Standalone markers — SOI/EOI/RST0-7/TEM (0xFFD0..0xFFD9, 0xFF01) have
|
|
109
|
+
// no length field, so we step past them with a fixed +2 instead of
|
|
110
|
+
// reading garbage as a segment length.
|
|
111
|
+
// Treating either case as a length-prefixed segment desynchronizes the scan
|
|
112
|
+
// and either skips past the SOF or runs off the end of the buffer.
|
|
113
|
+
function readJpegDimensions(buffer: Uint8Array): { width: number; height: number } {
|
|
114
|
+
const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength)
|
|
115
|
+
let i = 2
|
|
116
|
+
while (i < buffer.length - 9) {
|
|
117
|
+
if (buffer[i] !== 0xff) {
|
|
118
|
+
i++
|
|
119
|
+
continue
|
|
120
|
+
}
|
|
121
|
+
const marker = buffer[i + 1] ?? 0
|
|
122
|
+
if (marker === 0xff) {
|
|
123
|
+
i++
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
|
127
|
+
const height = view.getUint16(i + 5, false)
|
|
128
|
+
const width = view.getUint16(i + 7, false)
|
|
129
|
+
return { width, height }
|
|
130
|
+
}
|
|
131
|
+
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd9)) {
|
|
132
|
+
i += 2
|
|
133
|
+
continue
|
|
134
|
+
}
|
|
135
|
+
const segmentLength = view.getUint16(i + 2, false)
|
|
136
|
+
i += 2 + segmentLength
|
|
137
|
+
}
|
|
138
|
+
throw new Error('JPEG SOF marker not found')
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// WebP supports several variants: VP8 (lossy), VP8L (lossless), VP8X (extended).
|
|
142
|
+
// Dimensions live at different offsets per variant; we cover all three.
|
|
143
|
+
function readWebpDimensions(buffer: Uint8Array): { width: number; height: number } {
|
|
144
|
+
const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength)
|
|
145
|
+
const fourCC = String.fromCharCode(buffer[12]!, buffer[13]!, buffer[14]!, buffer[15]!)
|
|
146
|
+
|
|
147
|
+
if (fourCC === 'VP8 ') {
|
|
148
|
+
if (buffer.length < 30) {
|
|
149
|
+
throw new Error('Truncated WebP VP8: header is shorter than 30 bytes')
|
|
150
|
+
}
|
|
151
|
+
const width = view.getUint16(26, true) & 0x3fff
|
|
152
|
+
const height = view.getUint16(28, true) & 0x3fff
|
|
153
|
+
return { width, height }
|
|
154
|
+
}
|
|
155
|
+
if (fourCC === 'VP8L') {
|
|
156
|
+
if (buffer.length < 25) {
|
|
157
|
+
throw new Error('Truncated WebP VP8L: header is shorter than 25 bytes')
|
|
158
|
+
}
|
|
159
|
+
const b0 = buffer[21]!
|
|
160
|
+
const b1 = buffer[22]!
|
|
161
|
+
const b2 = buffer[23]!
|
|
162
|
+
const b3 = buffer[24]!
|
|
163
|
+
const width = 1 + (((b1 & 0x3f) << 8) | b0)
|
|
164
|
+
const height = 1 + (((b3 & 0x0f) << 10) | (b2 << 2) | ((b1 & 0xc0) >> 6))
|
|
165
|
+
return { width, height }
|
|
166
|
+
}
|
|
167
|
+
if (fourCC === 'VP8X') {
|
|
168
|
+
if (buffer.length < 31) {
|
|
169
|
+
throw new Error('Truncated WebP VP8X: header is shorter than 31 bytes')
|
|
170
|
+
}
|
|
171
|
+
const width = 1 + (view.getUint32(24, true) & 0xffffff)
|
|
172
|
+
const height = 1 + (view.getUint32(27, true) & 0xffffff)
|
|
173
|
+
return { width, height }
|
|
174
|
+
}
|
|
175
|
+
throw new Error(`Unsupported WebP variant: ${fourCC}`)
|
|
176
|
+
}
|
|
@@ -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
|
|
File without changes
|