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
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-webex
3
3
  description: Interact with Cisco Webex - send messages, read spaces, manage memberships
4
- version: 2.15.1
4
+ version: 2.16.0
5
5
  allowed-tools: Bash(agent-webex:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-wechatbot
3
3
  description: Interact with WeChat Official Account using API credentials - send messages, manage templates, list followers
4
- version: 2.15.1
4
+ version: 2.16.0
5
5
  allowed-tools: Bash(agent-wechatbot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-whatsapp
3
3
  description: Interact with WhatsApp - send messages, read chats, manage conversations
4
- version: 2.15.1
4
+ version: 2.16.0
5
5
  allowed-tools: Bash(agent-whatsapp:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-whatsappbot
3
3
  description: Interact with WhatsApp using Cloud API credentials - send messages, manage templates
4
- version: 2.15.1
4
+ version: 2.16.0
5
5
  allowed-tools: Bash(agent-whatsappbot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -0,0 +1,102 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import { planAttachments, resolveAttachment } from './attachment-router'
4
+
5
+ const bytes = new Uint8Array([0])
6
+
7
+ describe('resolveAttachment', () => {
8
+ it('classifies image MIME as photo', () => {
9
+ expect(resolveAttachment({ data: bytes, filename: 'x.jpg' }).kind).toBe('photo')
10
+ expect(resolveAttachment({ data: bytes, filename: 'x.png', mime: 'image/png' }).kind).toBe('photo')
11
+ })
12
+
13
+ it('classifies video MIME as video', () => {
14
+ expect(resolveAttachment({ data: bytes, filename: 'x.mp4' }).kind).toBe('video')
15
+ })
16
+
17
+ it('classifies audio MIME as audio', () => {
18
+ expect(resolveAttachment({ data: bytes, filename: 'x.m4a' }).kind).toBe('audio')
19
+ })
20
+
21
+ it('classifies everything else as file', () => {
22
+ expect(resolveAttachment({ data: bytes, filename: 'x.pdf' }).kind).toBe('file')
23
+ expect(resolveAttachment({ data: bytes, filename: 'x.zip' }).kind).toBe('file')
24
+ expect(resolveAttachment({ data: bytes, filename: 'x.unknown-ext' }).kind).toBe('file')
25
+ })
26
+
27
+ it('uses explicit mime override over filename inference', () => {
28
+ const r = resolveAttachment({ data: bytes, filename: 'x.pdf', mime: 'image/png' })
29
+ expect(r.kind).toBe('photo')
30
+ expect(r.mime).toBe('image/png')
31
+ })
32
+
33
+ it('routes upper-case and mixed-case MIME overrides the same as lower-case', () => {
34
+ expect(resolveAttachment({ data: bytes, filename: 'x.bin', mime: 'IMAGE/JPEG' }).kind).toBe('photo')
35
+ expect(resolveAttachment({ data: bytes, filename: 'x.bin', mime: 'Video/MP4' }).kind).toBe('video')
36
+ expect(resolveAttachment({ data: bytes, filename: 'x.bin', mime: 'Audio/MPEG' }).kind).toBe('audio')
37
+ expect(resolveAttachment({ data: bytes, filename: 'x.bin', mime: 'IMAGE/PNG' }).mime).toBe('image/png')
38
+ })
39
+
40
+ it('preserves data and filename verbatim', () => {
41
+ const r = resolveAttachment({ data: bytes, filename: 'cat picture.jpg' })
42
+ expect(r.data).toBe(bytes)
43
+ expect(r.filename).toBe('cat picture.jpg')
44
+ })
45
+ })
46
+
47
+ describe('planAttachments', () => {
48
+ it('throws on empty array', () => {
49
+ expect(() => planAttachments([])).toThrow(/empty/i)
50
+ })
51
+
52
+ it('returns single for a one-element array', () => {
53
+ const plan = planAttachments([{ data: bytes, filename: 'x.jpg' }])
54
+ expect(plan.kind).toBe('single')
55
+ if (plan.kind !== 'single') return
56
+ expect(plan.resolved.kind).toBe('photo')
57
+ })
58
+
59
+ it('returns single (not multiphoto) for a one-photo array', () => {
60
+ const plan = planAttachments([{ data: bytes, filename: 'a.png' }])
61
+ expect(plan.kind).toBe('single')
62
+ })
63
+
64
+ it('returns multiphoto when every item resolves to photo', () => {
65
+ const plan = planAttachments([
66
+ { data: bytes, filename: 'a.jpg' },
67
+ { data: bytes, filename: 'b.png' },
68
+ { data: bytes, filename: 'c.webp' },
69
+ ])
70
+ expect(plan.kind).toBe('multiphoto')
71
+ if (plan.kind !== 'multiphoto') return
72
+ expect(plan.items.length).toBe(3)
73
+ })
74
+
75
+ it('returns sequential for mixed kinds (image + file)', () => {
76
+ const plan = planAttachments([
77
+ { data: bytes, filename: 'photo.jpg' },
78
+ { data: bytes, filename: 'spec.pdf' },
79
+ ])
80
+ expect(plan.kind).toBe('sequential')
81
+ if (plan.kind !== 'sequential') return
82
+ expect(plan.resolved.map((r) => r.kind)).toEqual(['photo', 'file'])
83
+ })
84
+
85
+ it('returns sequential for all-video (multiphoto is image-only)', () => {
86
+ const plan = planAttachments([
87
+ { data: bytes, filename: 'a.mp4' },
88
+ { data: bytes, filename: 'b.mp4' },
89
+ ])
90
+ expect(plan.kind).toBe('sequential')
91
+ if (plan.kind !== 'sequential') return
92
+ expect(plan.resolved.every((r) => r.kind === 'video')).toBe(true)
93
+ })
94
+
95
+ it('honors explicit mime overrides when classifying for the photo gate', () => {
96
+ const plan = planAttachments([
97
+ { data: bytes, filename: 'a.pdf', mime: 'image/jpeg' },
98
+ { data: bytes, filename: 'b.bin', mime: 'image/png' },
99
+ ])
100
+ expect(plan.kind).toBe('multiphoto')
101
+ })
102
+ })
@@ -0,0 +1,50 @@
1
+ import { guessMimeFromFilename } from './media-upload'
2
+
3
+ export type AttachmentInput = {
4
+ data: Uint8Array | Buffer
5
+ filename: string
6
+ mime?: string
7
+ }
8
+
9
+ export type SingleAttachmentKind = 'photo' | 'video' | 'audio' | 'file'
10
+
11
+ export type ResolvedAttachment = {
12
+ kind: SingleAttachmentKind
13
+ mime: string
14
+ data: Uint8Array | Buffer
15
+ filename: string
16
+ }
17
+
18
+ export type AttachmentPlan =
19
+ | { kind: 'single'; resolved: ResolvedAttachment }
20
+ | { kind: 'multiphoto'; items: readonly AttachmentInput[] }
21
+ | { kind: 'sequential'; resolved: readonly ResolvedAttachment[] }
22
+
23
+ export function resolveAttachment(input: AttachmentInput): ResolvedAttachment {
24
+ // MIME types are case-insensitive per RFC 2045 §5.1; normalize so an `Image/JPEG`
25
+ // override still routes to `photo` instead of falling through to `file`.
26
+ const mime = (input.mime ?? guessMimeFromFilename(input.filename)).toLowerCase()
27
+ const kind: SingleAttachmentKind = mime.startsWith('image/')
28
+ ? 'photo'
29
+ : mime.startsWith('video/')
30
+ ? 'video'
31
+ : mime.startsWith('audio/')
32
+ ? 'audio'
33
+ : 'file'
34
+ return { kind, mime, data: input.data, filename: input.filename }
35
+ }
36
+
37
+ export function planAttachments(items: readonly AttachmentInput[]): AttachmentPlan {
38
+ if (items.length === 0) {
39
+ throw new Error('sendAttachment received an empty attachments array')
40
+ }
41
+ if (items.length === 1) {
42
+ return { kind: 'single', resolved: resolveAttachment(items[0]!) }
43
+ }
44
+ const resolved = items.map(resolveAttachment)
45
+ // MULTIPHOTO (message_type 27) is image-only by KakaoTalk's wire protocol.
46
+ if (resolved.every((r) => r.kind === 'photo')) {
47
+ return { kind: 'multiphoto', items: items.slice() }
48
+ }
49
+ return { kind: 'sequential', resolved }
50
+ }
@@ -7,17 +7,23 @@ import { Long } from 'bson'
7
7
  import { getConfigDir } from '@/shared/utils/config-dir'
8
8
  import { warn } from '@/shared/utils/stderr'
9
9
 
10
+ import { type AttachmentInput, type ResolvedAttachment, planAttachments } from './attachment-router'
11
+ import { detectImageDimensions } from './image-meta'
12
+ import { sha1Hex } from './media-upload'
10
13
  import { LANG, PC_OS_NAME, getLocoDeviceConfig } from './protocol/config'
14
+ import { uploadMediaToLoco, uploadMultiMediaEntry } from './protocol/media-uploader'
11
15
  import { LocoSession } from './protocol/session'
12
16
  import type { ChatListResponse, LocoPacket, LoginListResponse, SyncState } from './protocol/types'
13
- import type {
14
- KakaoChat,
15
- KakaoDeviceType,
16
- KakaoMarkReadResult,
17
- KakaoMember,
18
- KakaoMessage,
19
- KakaoProfile,
20
- KakaoSendResult,
17
+ import {
18
+ KAKAO_MESSAGE_TYPE,
19
+ type KakaoChat,
20
+ type KakaoDeviceType,
21
+ type KakaoMarkReadResult,
22
+ type KakaoMember,
23
+ type KakaoMessage,
24
+ type KakaoMultiPhotoExtra,
25
+ type KakaoProfile,
26
+ type KakaoSendResult,
21
27
  } from './types'
22
28
 
23
29
  export type KakaoSessionEvent =
@@ -895,6 +901,307 @@ export class KakaoTalkClient {
895
901
  })
896
902
  }
897
903
 
904
+ async sendAttachment(
905
+ chatId: string,
906
+ data: Uint8Array | Buffer,
907
+ filename: string,
908
+ mimeType?: string,
909
+ ): Promise<KakaoSendResult>
910
+ async sendAttachment(chatId: string, attachments: ReadonlyArray<AttachmentInput>): Promise<KakaoSendResult>
911
+ async sendAttachment(
912
+ chatId: string,
913
+ dataOrAttachments: Uint8Array | Buffer | ReadonlyArray<AttachmentInput>,
914
+ filename?: string,
915
+ mimeType?: string,
916
+ ): Promise<KakaoSendResult> {
917
+ const inputs: ReadonlyArray<AttachmentInput> = Array.isArray(dataOrAttachments)
918
+ ? dataOrAttachments
919
+ : [{ data: dataOrAttachments, filename: filename!, mime: mimeType }]
920
+ const plan = planAttachments(inputs)
921
+ switch (plan.kind) {
922
+ case 'single':
923
+ return this.dispatchSingleAttachment(chatId, plan.resolved)
924
+ case 'multiphoto':
925
+ return this.sendMultiPhoto(
926
+ chatId,
927
+ plan.items.map((it) => ({ data: it.data, filename: it.filename })),
928
+ )
929
+ case 'sequential': {
930
+ let last: KakaoSendResult | null = null
931
+ for (const r of plan.resolved) {
932
+ const result = await this.dispatchSingleAttachment(chatId, r)
933
+ if (!result.success) return result
934
+ last = result
935
+ }
936
+ return last!
937
+ }
938
+ }
939
+ }
940
+
941
+ private dispatchSingleAttachment(chatId: string, r: ResolvedAttachment): Promise<KakaoSendResult> {
942
+ switch (r.kind) {
943
+ case 'photo':
944
+ return this.sendPhoto(chatId, r.data, r.filename)
945
+ case 'video':
946
+ return this.sendVideo(chatId, r.data, r.filename)
947
+ case 'audio':
948
+ return this.sendAudio(chatId, r.data, r.filename)
949
+ case 'file':
950
+ return this.sendFile(chatId, r.data, r.filename, r.mime)
951
+ }
952
+ }
953
+
954
+ async sendPhoto(chatId: string, photo: Uint8Array | Buffer, filename = 'image.jpg'): Promise<KakaoSendResult> {
955
+ this.ensureAuth()
956
+ const data = photo instanceof Uint8Array ? photo : new Uint8Array(photo)
957
+ const dim = detectImageDimensions(data)
958
+ const checksum = await sha1Hex(data)
959
+ const ext = filename.includes('.') ? filename.split('.').pop()! : 'jpg'
960
+
961
+ return this.sendMediaViaLoco({
962
+ chatId,
963
+ data,
964
+ msgType: KAKAO_MESSAGE_TYPE.PHOTO,
965
+ filename,
966
+ checksum,
967
+ extension: ext,
968
+ width: dim.width,
969
+ height: dim.height,
970
+ errorCode: 'send_photo_failed',
971
+ })
972
+ }
973
+
974
+ async sendVideo(chatId: string, video: Uint8Array | Buffer, filename = 'video.mp4'): Promise<KakaoSendResult> {
975
+ this.ensureAuth()
976
+ const data = video instanceof Uint8Array ? video : new Uint8Array(video)
977
+ const checksum = await sha1Hex(data)
978
+ const ext = filename.includes('.') ? filename.split('.').pop()! : 'mp4'
979
+
980
+ return this.sendMediaViaLoco({
981
+ chatId,
982
+ data,
983
+ msgType: KAKAO_MESSAGE_TYPE.VIDEO,
984
+ filename,
985
+ checksum,
986
+ extension: ext,
987
+ errorCode: 'send_video_failed',
988
+ })
989
+ }
990
+
991
+ async sendAudio(chatId: string, audio: Uint8Array | Buffer, filename = 'audio.m4a'): Promise<KakaoSendResult> {
992
+ this.ensureAuth()
993
+ const data = audio instanceof Uint8Array ? audio : new Uint8Array(audio)
994
+ const checksum = await sha1Hex(data)
995
+ const ext = filename.includes('.') ? filename.split('.').pop()! : 'm4a'
996
+
997
+ return this.sendMediaViaLoco({
998
+ chatId,
999
+ data,
1000
+ msgType: KAKAO_MESSAGE_TYPE.AUDIO,
1001
+ filename,
1002
+ checksum,
1003
+ extension: ext,
1004
+ errorCode: 'send_audio_failed',
1005
+ })
1006
+ }
1007
+
1008
+ async sendFile(
1009
+ chatId: string,
1010
+ file: Uint8Array | Buffer,
1011
+ filename: string,
1012
+ mimeType = 'application/octet-stream',
1013
+ ): Promise<KakaoSendResult> {
1014
+ void mimeType
1015
+ this.ensureAuth()
1016
+ const data = file instanceof Uint8Array ? file : new Uint8Array(file)
1017
+ const checksum = await sha1Hex(data)
1018
+ const ext = filename.includes('.') ? filename.split('.').pop()! : ''
1019
+
1020
+ return this.sendMediaViaLoco({
1021
+ chatId,
1022
+ data,
1023
+ msgType: KAKAO_MESSAGE_TYPE.FILE,
1024
+ filename,
1025
+ checksum,
1026
+ extension: ext,
1027
+ errorCode: 'send_file_failed',
1028
+ })
1029
+ }
1030
+
1031
+ async sendMultiPhoto(
1032
+ chatId: string,
1033
+ photos: Array<{ data: Uint8Array | Buffer; filename?: string }>,
1034
+ ): Promise<KakaoSendResult> {
1035
+ this.ensureAuth()
1036
+ if (photos.length < 2) {
1037
+ throw new KakaoTalkError(
1038
+ 'sendMultiPhoto requires at least 2 photos; use sendPhoto for a single image',
1039
+ 'send_multi_photo_failed',
1040
+ )
1041
+ }
1042
+
1043
+ const prepared = await Promise.all(
1044
+ photos.map(async (p, i) => {
1045
+ const bytes = p.data instanceof Uint8Array ? p.data : new Uint8Array(p.data)
1046
+ const filename = p.filename ?? `image-${i + 1}.jpg`
1047
+ const dim = detectImageDimensions(bytes)
1048
+ const checksum = (await sha1Hex(bytes)).toLowerCase()
1049
+ const ext = filename.includes('.') ? filename.split('.').pop()! : 'jpg'
1050
+ return { bytes, filename, dim, checksum, ext }
1051
+ }),
1052
+ )
1053
+
1054
+ const parsedChatId = parseChatId(chatId)
1055
+
1056
+ return this.executeWithReconnect(async ({ session }) => {
1057
+ try {
1058
+ const mshipResp = await session.shipMultiMedia(
1059
+ parsedChatId,
1060
+ KAKAO_MESSAGE_TYPE.MULTIPHOTO,
1061
+ prepared.map((p) => p.bytes.byteLength),
1062
+ prepared.map((p) => p.checksum),
1063
+ prepared.map((p) => p.ext),
1064
+ )
1065
+
1066
+ if (mshipResp.statusCode !== 0) {
1067
+ throw new KakaoTalkError(`MSHIP rejected (status ${mshipResp.statusCode})`, 'send_multi_photo_failed')
1068
+ }
1069
+
1070
+ const body = mshipResp.body as Record<string, unknown>
1071
+ const kl = body.kl as string[] | undefined
1072
+ const vhl = body.vhl as string[] | undefined
1073
+ const pl = body.pl as number[] | undefined
1074
+ if (
1075
+ !kl ||
1076
+ !vhl ||
1077
+ !pl ||
1078
+ kl.length !== prepared.length ||
1079
+ vhl.length !== prepared.length ||
1080
+ pl.length !== prepared.length
1081
+ ) {
1082
+ throw new KakaoTalkError(
1083
+ `MSHIP response arrays do not match prepared.length=${prepared.length}: ` +
1084
+ `kl=${kl?.length} vhl=${vhl?.length} pl=${pl?.length}`,
1085
+ 'send_multi_photo_failed',
1086
+ )
1087
+ }
1088
+
1089
+ await Promise.all(
1090
+ prepared.map((p, i) =>
1091
+ uploadMultiMediaEntry({
1092
+ shipToken: kl[i]!,
1093
+ shipHost: vhl[i]!,
1094
+ shipPort: pl[i]!,
1095
+ chatId: parsedChatId,
1096
+ msgType: KAKAO_MESSAGE_TYPE.MULTIPHOTO,
1097
+ userId: this.userId!,
1098
+ filename: p.filename,
1099
+ data: p.bytes,
1100
+ width: p.dim.width,
1101
+ height: p.dim.height,
1102
+ deviceType: this.deviceType,
1103
+ }),
1104
+ ),
1105
+ )
1106
+
1107
+ const extra: KakaoMultiPhotoExtra = {
1108
+ kl,
1109
+ wl: prepared.map((p) => p.dim.width),
1110
+ hl: prepared.map((p) => p.dim.height),
1111
+ mtl: prepared.map((p) => p.dim.mimeType),
1112
+ sl: prepared.map((p) => p.bytes.byteLength),
1113
+ csl: prepared.map((p) => p.checksum),
1114
+ cmtl: prepared.map(() => ''),
1115
+ }
1116
+
1117
+ const forwardResp = await session.forwardChat(
1118
+ parsedChatId,
1119
+ KAKAO_MESSAGE_TYPE.MULTIPHOTO,
1120
+ extra as unknown as Record<string, unknown>,
1121
+ )
1122
+
1123
+ return {
1124
+ success: forwardResp.statusCode === 0,
1125
+ status_code: forwardResp.statusCode,
1126
+ chat_id: chatId,
1127
+ log_id: longToString((forwardResp.body as Record<string, unknown>).logId),
1128
+ sent_at: ((forwardResp.body as Record<string, unknown>).sendAt as number | undefined) ?? 0,
1129
+ }
1130
+ } catch (error) {
1131
+ throw wrapError(error, 'send_multi_photo_failed')
1132
+ }
1133
+ })
1134
+ }
1135
+
1136
+ private async sendMediaViaLoco(opts: {
1137
+ chatId: string
1138
+ data: Uint8Array
1139
+ msgType: number
1140
+ filename: string
1141
+ checksum: string
1142
+ extension: string
1143
+ width?: number
1144
+ height?: number
1145
+ errorCode: string
1146
+ }): Promise<KakaoSendResult> {
1147
+ const parsedChatId = parseChatId(opts.chatId)
1148
+ return this.executeWithReconnect(async ({ session }) => {
1149
+ try {
1150
+ const shipResp = await session.shipMedia(
1151
+ parsedChatId,
1152
+ opts.msgType,
1153
+ opts.data.byteLength,
1154
+ opts.checksum,
1155
+ opts.extension,
1156
+ )
1157
+
1158
+ if (shipResp.statusCode !== 0) {
1159
+ throw new KakaoTalkError(`SHIP rejected (status ${shipResp.statusCode})`, opts.errorCode)
1160
+ }
1161
+
1162
+ const body = shipResp.body as Record<string, unknown>
1163
+ const shipToken = body.k as string | undefined
1164
+ const shipHost = body.vh as string | undefined
1165
+ const shipPort = body.p as number | undefined
1166
+
1167
+ if (typeof shipToken !== 'string' || typeof shipHost !== 'string' || typeof shipPort !== 'number') {
1168
+ throw new KakaoTalkError(
1169
+ `SHIP response missing fields: k=${shipToken} vh=${shipHost} p=${shipPort}`,
1170
+ opts.errorCode,
1171
+ )
1172
+ }
1173
+
1174
+ const uploadRes = await uploadMediaToLoco({
1175
+ shipToken,
1176
+ shipHost,
1177
+ shipPort,
1178
+ chatId: parsedChatId,
1179
+ msgType: opts.msgType,
1180
+ userId: this.userId!,
1181
+ filename: opts.filename,
1182
+ data: opts.data,
1183
+ width: opts.width,
1184
+ height: opts.height,
1185
+ deviceType: this.deviceType,
1186
+ })
1187
+
1188
+ const completeBody = uploadRes.completePacket?.body as Record<string, unknown> | undefined
1189
+ const chatLog = completeBody?.chatLog as Record<string, unknown> | undefined
1190
+ const logId = chatLog?.logId
1191
+
1192
+ return {
1193
+ success: uploadRes.completePacket !== null && uploadRes.postStatusCode === 0,
1194
+ status_code: uploadRes.postStatusCode,
1195
+ chat_id: opts.chatId,
1196
+ log_id: longToString(logId),
1197
+ sent_at: (chatLog?.sendAt as number | undefined) ?? 0,
1198
+ }
1199
+ } catch (error) {
1200
+ throw wrapError(error, opts.errorCode)
1201
+ }
1202
+ })
1203
+ }
1204
+
898
1205
  /**
899
1206
  * Advance the read watermark for `chatId` up to and including `logId`.
900
1207
  * The caller decides open vs normal: pass `opts.linkId` for open chats
@@ -1,3 +1,6 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { basename, resolve } from 'node:path'
3
+
1
4
  import { Command } from 'commander'
2
5
 
3
6
  import { handleError } from '@/shared/utils/error-handler'
@@ -33,6 +36,56 @@ async function sendAction(
33
36
  }
34
37
  }
35
38
 
39
+ type UploadKind = 'auto' | 'photo' | 'video' | 'audio' | 'file' | 'multi'
40
+
41
+ async function uploadAction(
42
+ chatId: string,
43
+ filePaths: string[],
44
+ options: { account?: string; pretty?: boolean; as?: UploadKind; mime?: string },
45
+ ): Promise<void> {
46
+ try {
47
+ const kind: UploadKind = options.as ?? (filePaths.length > 1 ? 'multi' : 'auto')
48
+
49
+ const result = await withKakaoClient(options, async (client) => {
50
+ if (kind === 'multi') {
51
+ if (filePaths.length < 2) {
52
+ throw new Error('--as=multi requires 2 or more files')
53
+ }
54
+ return client.sendMultiPhoto(
55
+ chatId,
56
+ filePaths.map((p) => ({ data: readFileSync(resolve(p)), filename: basename(p) })),
57
+ )
58
+ }
59
+
60
+ if (filePaths.length !== 1) {
61
+ throw new Error(`--as=${kind} accepts exactly one file path`)
62
+ }
63
+ const path = resolve(filePaths[0]!)
64
+ const data = readFileSync(path)
65
+ const filename = basename(path)
66
+
67
+ switch (kind) {
68
+ case 'auto':
69
+ return client.sendAttachment(chatId, data, filename, options.mime)
70
+ case 'photo':
71
+ return client.sendPhoto(chatId, data, filename)
72
+ case 'video':
73
+ return client.sendVideo(chatId, data, filename)
74
+ case 'audio':
75
+ return client.sendAudio(chatId, data, filename)
76
+ case 'file':
77
+ return client.sendFile(chatId, data, filename, options.mime ?? 'application/octet-stream')
78
+ }
79
+ })
80
+ console.log(formatOutput(result, options.pretty))
81
+ if (!result.success) {
82
+ process.exit(1)
83
+ }
84
+ } catch (error) {
85
+ handleError(error as Error)
86
+ }
87
+ }
88
+
36
89
  async function markReadAction(
37
90
  chatId: string,
38
91
  logId: string,
@@ -72,6 +125,19 @@ export const messageCommand = new Command('message')
72
125
  .option('--pretty', 'Pretty print JSON output')
73
126
  .action(sendAction),
74
127
  )
128
+ .addCommand(
129
+ new Command('upload')
130
+ .description(
131
+ 'Send one or more files to a chat. MIME is sniffed from the filename (or --mime) and dispatched to the matching KakaoTalk message_type. Pass 2+ files (or --as=multi) for a multi-photo gallery.',
132
+ )
133
+ .argument('<chat-id>', 'Chat room ID')
134
+ .argument('<file-paths...>', 'One or more file paths')
135
+ .option('--account <id>', 'Use a specific KakaoTalk account')
136
+ .option('--as <kind>', 'Force a specific kind: auto | photo | video | audio | file | multi')
137
+ .option('--mime <type>', 'Override MIME type (otherwise inferred from filename)')
138
+ .option('--pretty', 'Pretty print JSON output')
139
+ .action(uploadAction),
140
+ )
75
141
  .addCommand(
76
142
  new Command('mark-read')
77
143
  .description('Mark messages in a chat room as read up to a given log ID')