agent-messenger 2.15.1 → 2.17.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 +8 -2
- package/dist/src/platforms/kakaotalk/index.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/index.js +5 -1
- package/dist/src/platforms/kakaotalk/index.js.map +1 -1
- package/dist/src/platforms/kakaotalk/media-upload.d.ts +3 -0
- package/dist/src/platforms/kakaotalk/media-upload.d.ts.map +1 -0
- package/dist/src/platforms/kakaotalk/media-upload.js +44 -0
- package/dist/src/platforms/kakaotalk/media-upload.js.map +1 -0
- package/dist/src/platforms/kakaotalk/protocol/connection.d.ts +1 -0
- package/dist/src/platforms/kakaotalk/protocol/connection.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/protocol/connection.js +11 -0
- package/dist/src/platforms/kakaotalk/protocol/connection.js.map +1 -1
- package/dist/src/platforms/kakaotalk/protocol/media-uploader.d.ts +25 -0
- package/dist/src/platforms/kakaotalk/protocol/media-uploader.d.ts.map +1 -0
- package/dist/src/platforms/kakaotalk/protocol/media-uploader.js +99 -0
- package/dist/src/platforms/kakaotalk/protocol/media-uploader.js.map +1 -0
- package/dist/src/platforms/kakaotalk/protocol/session.d.ts +6 -0
- package/dist/src/platforms/kakaotalk/protocol/session.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/protocol/session.js +61 -0
- package/dist/src/platforms/kakaotalk/protocol/session.js.map +1 -1
- package/dist/src/platforms/kakaotalk/types.d.ts +44 -0
- package/dist/src/platforms/kakaotalk/types.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/types.js +9 -0
- package/dist/src/platforms/kakaotalk/types.js.map +1 -1
- package/dist/src/platforms/slackbot/types.d.ts +4 -0
- package/dist/src/platforms/slackbot/types.d.ts.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 +13 -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/slackbot/types.ts +16 -0
- package/src/platforms/telegrambot/cli.ts +0 -0
|
@@ -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')
|
|
@@ -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
|
+
}
|