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.
Files changed (75) 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/docs/content/docs/cli/kakaotalk.mdx +47 -2
  44. package/docs/content/docs/sdk/kakaotalk.mdx +32 -0
  45. package/package.json +1 -1
  46. package/skills/agent-channeltalk/SKILL.md +1 -1
  47. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  48. package/skills/agent-discord/SKILL.md +1 -1
  49. package/skills/agent-discordbot/SKILL.md +1 -1
  50. package/skills/agent-instagram/SKILL.md +1 -1
  51. package/skills/agent-kakaotalk/SKILL.md +62 -4
  52. package/skills/agent-kakaotalk/references/common-patterns.md +50 -11
  53. package/skills/agent-line/SKILL.md +1 -1
  54. package/skills/agent-slack/SKILL.md +1 -1
  55. package/skills/agent-slackbot/SKILL.md +1 -1
  56. package/skills/agent-teams/SKILL.md +1 -1
  57. package/skills/agent-telegram/SKILL.md +1 -1
  58. package/skills/agent-telegrambot/SKILL.md +1 -1
  59. package/skills/agent-webex/SKILL.md +1 -1
  60. package/skills/agent-wechatbot/SKILL.md +1 -1
  61. package/skills/agent-whatsapp/SKILL.md +1 -1
  62. package/skills/agent-whatsappbot/SKILL.md +1 -1
  63. package/src/platforms/kakaotalk/attachment-router.test.ts +102 -0
  64. package/src/platforms/kakaotalk/attachment-router.ts +50 -0
  65. package/src/platforms/kakaotalk/client.ts +315 -8
  66. package/src/platforms/kakaotalk/commands/message.ts +66 -0
  67. package/src/platforms/kakaotalk/image-meta.test.ts +90 -0
  68. package/src/platforms/kakaotalk/image-meta.ts +176 -0
  69. package/src/platforms/kakaotalk/index.ts +7 -0
  70. package/src/platforms/kakaotalk/media-upload.ts +44 -0
  71. package/src/platforms/kakaotalk/protocol/connection.ts +11 -0
  72. package/src/platforms/kakaotalk/protocol/media-uploader.ts +129 -0
  73. package/src/platforms/kakaotalk/protocol/session.ts +67 -0
  74. package/src/platforms/kakaotalk/types.ts +57 -0
  75. 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