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,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
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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')
|