agent-messenger 2.36.1 → 2.37.1
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/dist/package.json +1 -1
- package/dist/src/platforms/kakaotalk/chat-classifier.d.ts +5 -4
- package/dist/src/platforms/kakaotalk/chat-classifier.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/chat-classifier.js +15 -9
- package/dist/src/platforms/kakaotalk/chat-classifier.js.map +1 -1
- package/dist/src/platforms/kakaotalk/client.d.ts +33 -1
- package/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/client.js +377 -42
- package/dist/src/platforms/kakaotalk/client.js.map +1 -1
- package/dist/src/platforms/kakaotalk/index.d.ts +4 -3
- package/dist/src/platforms/kakaotalk/index.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/index.js +2 -2
- package/dist/src/platforms/kakaotalk/index.js.map +1 -1
- package/dist/src/platforms/kakaotalk/protocol/connection.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/protocol/connection.js +13 -2
- package/dist/src/platforms/kakaotalk/protocol/connection.js.map +1 -1
- package/dist/src/platforms/kakaotalk/protocol/login-response.d.ts +1 -0
- package/dist/src/platforms/kakaotalk/protocol/login-response.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/protocol/login-response.js +1 -1
- package/dist/src/platforms/kakaotalk/protocol/login-response.js.map +1 -1
- package/dist/src/platforms/kakaotalk/types.d.ts +50 -2
- package/dist/src/platforms/kakaotalk/types.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/types.js +18 -1
- package/dist/src/platforms/kakaotalk/types.js.map +1 -1
- package/docs/content/docs/cli/kakaotalk.mdx +7 -1
- package/docs/content/docs/sdk/kakaotalk.mdx +29 -1
- 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-imessage/SKILL.md +1 -1
- package/skills/agent-instagram/SKILL.md +1 -1
- package/skills/agent-kakaotalk/SKILL.md +1 -1
- package/skills/agent-kakaotalk/references/common-patterns.md +36 -0
- 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-webexbot/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/chat-classifier.test.ts +23 -1
- package/src/platforms/kakaotalk/chat-classifier.ts +16 -10
- package/src/platforms/kakaotalk/client.test.ts +1225 -88
- package/src/platforms/kakaotalk/client.ts +476 -45
- package/src/platforms/kakaotalk/index.test.ts +39 -0
- package/src/platforms/kakaotalk/index.ts +12 -1
- package/src/platforms/kakaotalk/protocol/connection.test.ts +87 -0
- package/src/platforms/kakaotalk/protocol/connection.ts +15 -2
- package/src/platforms/kakaotalk/protocol/login-response.test.ts +13 -2
- package/src/platforms/kakaotalk/protocol/login-response.ts +1 -1
- package/src/platforms/kakaotalk/types.ts +41 -2
|
@@ -8,9 +8,11 @@ import { getConfigDir } from '@/shared/utils/config-dir'
|
|
|
8
8
|
import { warn } from '@/shared/utils/stderr'
|
|
9
9
|
|
|
10
10
|
import { type AttachmentInput, type ResolvedAttachment, planAttachments } from './attachment-router'
|
|
11
|
+
import { isOpenKakaoChatType } from './chat-classifier'
|
|
11
12
|
import { detectImageDimensions } from './image-meta'
|
|
12
13
|
import { sha1Hex } from './media-upload'
|
|
13
14
|
import { LANG, PC_OS_NAME, getLocoDeviceConfig } from './protocol/config'
|
|
15
|
+
import { isSyntheticConnectionClose } from './protocol/login-response'
|
|
14
16
|
import { uploadMediaToLoco, uploadMultiMediaEntry } from './protocol/media-uploader'
|
|
15
17
|
import { LocoSession } from './protocol/session'
|
|
16
18
|
import type { ChatListResponse, LocoPacket, LoginListResponse, SyncState } from './protocol/types'
|
|
@@ -21,7 +23,9 @@ import {
|
|
|
21
23
|
type KakaoLeaveChatResult,
|
|
22
24
|
type KakaoMarkReadResult,
|
|
23
25
|
type KakaoMember,
|
|
26
|
+
type KakaoMemberSnapshot,
|
|
24
27
|
type KakaoMessage,
|
|
28
|
+
type KakaoMessagePage,
|
|
25
29
|
type KakaoMultiPhotoExtra,
|
|
26
30
|
type KakaoProfile,
|
|
27
31
|
type KakaoReplyExtra,
|
|
@@ -38,15 +42,57 @@ export type KakaoSessionEvent =
|
|
|
38
42
|
export type KakaoPushHandler = (packet: LocoPacket) => void
|
|
39
43
|
export type KakaoSessionEventHandler = (event: KakaoSessionEvent) => void
|
|
40
44
|
|
|
45
|
+
export type KakaoTalkResponseFailureKind = 'provider_rejection' | 'transient_or_unknown'
|
|
46
|
+
export type KakaoTalkResponseStatusSource = 'packet' | 'body'
|
|
47
|
+
export type KakaoTalkGetChatFailureReason =
|
|
48
|
+
| 'provider_rejection'
|
|
49
|
+
| 'synthetic_connection_close'
|
|
50
|
+
| 'chat_info_absent'
|
|
51
|
+
| 'transport_or_unknown'
|
|
52
|
+
|
|
41
53
|
export class KakaoTalkError extends Error {
|
|
42
54
|
code: string
|
|
43
55
|
readonly serverStatus?: number
|
|
44
|
-
|
|
45
|
-
|
|
56
|
+
readonly responseFailureKind?: KakaoTalkResponseFailureKind
|
|
57
|
+
readonly responseStatusSource?: KakaoTalkResponseStatusSource
|
|
58
|
+
readonly getChatFailureReason?: KakaoTalkGetChatFailureReason
|
|
59
|
+
|
|
60
|
+
constructor(
|
|
61
|
+
message: string,
|
|
62
|
+
code: string,
|
|
63
|
+
options?: {
|
|
64
|
+
cause?: unknown
|
|
65
|
+
serverStatus?: number
|
|
66
|
+
responseFailureKind?: KakaoTalkResponseFailureKind
|
|
67
|
+
responseStatusSource?: KakaoTalkResponseStatusSource
|
|
68
|
+
getChatFailureReason?: KakaoTalkGetChatFailureReason
|
|
69
|
+
},
|
|
70
|
+
) {
|
|
46
71
|
super(message, options)
|
|
47
72
|
this.name = 'KakaoTalkError'
|
|
48
73
|
this.code = code
|
|
49
74
|
this.serverStatus = options?.serverStatus
|
|
75
|
+
this.responseFailureKind = options?.responseFailureKind
|
|
76
|
+
this.responseStatusSource = options?.responseStatusSource
|
|
77
|
+
this.getChatFailureReason = options?.getChatFailureReason
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
class LocoResponseError extends Error {
|
|
82
|
+
readonly serverStatus?: number
|
|
83
|
+
readonly responseFailureKind: KakaoTalkResponseFailureKind
|
|
84
|
+
readonly responseStatusSource?: KakaoTalkResponseStatusSource
|
|
85
|
+
|
|
86
|
+
constructor(
|
|
87
|
+
message: string,
|
|
88
|
+
responseFailureKind: KakaoTalkResponseFailureKind,
|
|
89
|
+
options?: { serverStatus?: number; responseStatusSource?: KakaoTalkResponseStatusSource },
|
|
90
|
+
) {
|
|
91
|
+
super(message)
|
|
92
|
+
this.name = 'LocoResponseError'
|
|
93
|
+
this.serverStatus = options?.serverStatus
|
|
94
|
+
this.responseFailureKind = responseFailureKind
|
|
95
|
+
this.responseStatusSource = options?.responseStatusSource
|
|
50
96
|
}
|
|
51
97
|
}
|
|
52
98
|
|
|
@@ -184,7 +230,7 @@ function formatChat(chat: ChatData, title: string | null, nameCache: MemberNameC
|
|
|
184
230
|
|
|
185
231
|
return {
|
|
186
232
|
chat_id: chatId,
|
|
187
|
-
type: chat.t
|
|
233
|
+
type: requiredChatType(chat.t),
|
|
188
234
|
display_name: displayName,
|
|
189
235
|
title,
|
|
190
236
|
active_members: chat.a as number,
|
|
@@ -207,10 +253,22 @@ interface ChannelMetaEntry {
|
|
|
207
253
|
content?: string
|
|
208
254
|
}
|
|
209
255
|
|
|
256
|
+
interface ChannelInfoData {
|
|
257
|
+
chatId?: unknown
|
|
258
|
+
type?: unknown
|
|
259
|
+
activeMembersCount?: unknown
|
|
260
|
+
newMessageCount?: unknown
|
|
261
|
+
invalidNewMessageCount?: unknown
|
|
262
|
+
lastLogId?: unknown
|
|
263
|
+
lastSeenLogId?: unknown
|
|
264
|
+
lastChatLog?: Record<string, unknown> | null
|
|
265
|
+
displayMembers?: Array<Record<string, unknown>>
|
|
266
|
+
chatMetas?: ChannelMetaEntry[]
|
|
267
|
+
li?: unknown
|
|
268
|
+
}
|
|
269
|
+
|
|
210
270
|
interface ChannelInfoResponse {
|
|
211
|
-
chatInfo?:
|
|
212
|
-
chatMetas?: ChannelMetaEntry[]
|
|
213
|
-
}
|
|
271
|
+
chatInfo?: ChannelInfoData
|
|
214
272
|
}
|
|
215
273
|
|
|
216
274
|
function extractTitle(body: Record<string, unknown>): string | null {
|
|
@@ -223,6 +281,69 @@ function extractTitle(body: Record<string, unknown>): string | null {
|
|
|
223
281
|
return typeof content === 'string' && content.length > 0 ? content : null
|
|
224
282
|
}
|
|
225
283
|
|
|
284
|
+
function extractChannelInfo(body: Record<string, unknown>, expectedChatId: string): ChannelInfoData {
|
|
285
|
+
const info = (body as ChannelInfoResponse).chatInfo
|
|
286
|
+
if (!info || typeof info !== 'object') {
|
|
287
|
+
throw new Error('CHATINFO response missing chatInfo')
|
|
288
|
+
}
|
|
289
|
+
const responseChatId = longToString(info.chatId)
|
|
290
|
+
if (responseChatId !== expectedChatId) {
|
|
291
|
+
throw new Error('CHATINFO response chatId mismatch')
|
|
292
|
+
}
|
|
293
|
+
return info
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function requiredNonNegativeInteger(value: unknown, field: string): number {
|
|
297
|
+
if (!Number.isInteger(value) || (value as number) < 0) {
|
|
298
|
+
throw new Error(`CHATINFO response invalid ${field}`)
|
|
299
|
+
}
|
|
300
|
+
return value as number
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function requiredChatType(value: unknown): KakaoChat['type'] {
|
|
304
|
+
if (typeof value === 'string' && value.length > 0) {
|
|
305
|
+
return value
|
|
306
|
+
}
|
|
307
|
+
return requiredNonNegativeInteger(value, 'type')
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function channelInfoToChatData(body: Record<string, unknown>, expectedChatId: string): ChatData {
|
|
311
|
+
const info = extractChannelInfo(body, expectedChatId)
|
|
312
|
+
const type = requiredChatType(info.type)
|
|
313
|
+
const activeMembers = requiredNonNegativeInteger(info.activeMembersCount, 'activeMembersCount')
|
|
314
|
+
const unreadCount =
|
|
315
|
+
info.invalidNewMessageCount === true ? 0 : requiredNonNegativeInteger(info.newMessageCount, 'newMessageCount')
|
|
316
|
+
const displayMembers = Array.isArray(info.displayMembers) ? info.displayMembers : []
|
|
317
|
+
const memberIds: unknown[] = []
|
|
318
|
+
const memberNames: string[] = []
|
|
319
|
+
|
|
320
|
+
for (const member of displayMembers) {
|
|
321
|
+
if (!member || typeof member !== 'object') continue
|
|
322
|
+
if (member.userId === undefined || typeof member.nickName !== 'string') continue
|
|
323
|
+
memberIds.push(member.userId)
|
|
324
|
+
memberNames.push(member.nickName)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return {
|
|
328
|
+
c: info.chatId,
|
|
329
|
+
t: type,
|
|
330
|
+
a: activeMembers,
|
|
331
|
+
n: unreadCount,
|
|
332
|
+
l: info.lastChatLog ?? null,
|
|
333
|
+
ll: info.lastLogId,
|
|
334
|
+
i: memberIds,
|
|
335
|
+
k: memberNames,
|
|
336
|
+
li: info.li,
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function extractChannelMaxLogId(body: Record<string, unknown>, expectedChatId: string): Long {
|
|
341
|
+
const info = extractChannelInfo(body, expectedChatId)
|
|
342
|
+
const maxLogId = bsonToLong(info.lastLogId)
|
|
343
|
+
if (!maxLogId) throw new Error('CHATINFO response missing lastLogId')
|
|
344
|
+
return maxLogId
|
|
345
|
+
}
|
|
346
|
+
|
|
226
347
|
interface OpenLinkInfoResponse {
|
|
227
348
|
ols?: Array<{ ln?: unknown }>
|
|
228
349
|
}
|
|
@@ -234,10 +355,8 @@ function extractOpenLinkName(body: Record<string, unknown>): string | null {
|
|
|
234
355
|
return typeof ln === 'string' && ln.length > 0 ? ln : null
|
|
235
356
|
}
|
|
236
357
|
|
|
237
|
-
const OPEN_CHAT_TYPES = new Set(['OM', 'OD'])
|
|
238
|
-
|
|
239
358
|
function isOpenChat(chat: ChatData): boolean {
|
|
240
|
-
return
|
|
359
|
+
return isOpenKakaoChatType(chat.t)
|
|
241
360
|
}
|
|
242
361
|
|
|
243
362
|
function getOpenLinkId(chat: ChatData): Long | null {
|
|
@@ -276,10 +395,46 @@ function collectChats(chatDatas: ChatData[], into: ChatData[], seen: Set<string>
|
|
|
276
395
|
}
|
|
277
396
|
}
|
|
278
397
|
|
|
279
|
-
function wrapError(
|
|
280
|
-
|
|
398
|
+
function wrapError(
|
|
399
|
+
error: unknown,
|
|
400
|
+
code: string,
|
|
401
|
+
options?: { classifyResponseFailure?: boolean; classifyGetChatFailure?: boolean },
|
|
402
|
+
): KakaoTalkError {
|
|
403
|
+
if (error instanceof KakaoTalkError) {
|
|
404
|
+
const responseFailureKind =
|
|
405
|
+
error.responseFailureKind ?? (options?.classifyResponseFailure ? 'transient_or_unknown' : undefined)
|
|
406
|
+
const getChatFailureReason =
|
|
407
|
+
error.getChatFailureReason ?? (options?.classifyGetChatFailure ? 'transport_or_unknown' : undefined)
|
|
408
|
+
if (responseFailureKind === error.responseFailureKind && getChatFailureReason === error.getChatFailureReason) {
|
|
409
|
+
return error
|
|
410
|
+
}
|
|
411
|
+
return new KakaoTalkError(error.message, error.code, {
|
|
412
|
+
cause: error.cause ?? error,
|
|
413
|
+
serverStatus: error.serverStatus,
|
|
414
|
+
responseFailureKind,
|
|
415
|
+
responseStatusSource: error.responseStatusSource,
|
|
416
|
+
getChatFailureReason,
|
|
417
|
+
})
|
|
418
|
+
}
|
|
281
419
|
const message = error instanceof Error ? error.message : String(error)
|
|
282
|
-
return new KakaoTalkError(message, code, {
|
|
420
|
+
return new KakaoTalkError(message, code, {
|
|
421
|
+
cause: error,
|
|
422
|
+
serverStatus: error instanceof LocoResponseError ? error.serverStatus : undefined,
|
|
423
|
+
responseStatusSource: error instanceof LocoResponseError ? error.responseStatusSource : undefined,
|
|
424
|
+
responseFailureKind:
|
|
425
|
+
error instanceof LocoResponseError
|
|
426
|
+
? error.responseFailureKind
|
|
427
|
+
: options?.classifyResponseFailure
|
|
428
|
+
? 'transient_or_unknown'
|
|
429
|
+
: undefined,
|
|
430
|
+
getChatFailureReason: options?.classifyGetChatFailure
|
|
431
|
+
? error instanceof LocoResponseError && error.responseFailureKind === 'provider_rejection'
|
|
432
|
+
? 'provider_rejection'
|
|
433
|
+
: error instanceof LocoResponseError && error.responseStatusSource === 'packet' && error.serverStatus === -1
|
|
434
|
+
? 'synthetic_connection_close'
|
|
435
|
+
: 'transport_or_unknown'
|
|
436
|
+
: undefined,
|
|
437
|
+
})
|
|
283
438
|
}
|
|
284
439
|
|
|
285
440
|
function isLoginResponseError(
|
|
@@ -426,12 +581,27 @@ function isNonZeroLong(v: unknown): boolean {
|
|
|
426
581
|
// caller-visible error channel (e.g. GETMEM/MEMBER return `[]` for both empty rooms and dead
|
|
427
582
|
// sockets). Throwing here lets executeWithReconnect detect session death and reconnect.
|
|
428
583
|
function assertLocoOk(response: LocoPacket, command: string): void {
|
|
584
|
+
if (typeof response.statusCode !== 'number' || !Number.isFinite(response.statusCode)) {
|
|
585
|
+
throw new LocoResponseError(`${command} failed: response status unavailable`, 'transient_or_unknown')
|
|
586
|
+
}
|
|
587
|
+
if (isSyntheticConnectionClose(response)) {
|
|
588
|
+
throw new LocoResponseError(`${command} failed: connection closed`, 'transient_or_unknown', {
|
|
589
|
+
serverStatus: response.statusCode,
|
|
590
|
+
responseStatusSource: 'packet',
|
|
591
|
+
})
|
|
592
|
+
}
|
|
429
593
|
if (response.statusCode !== 0) {
|
|
430
|
-
throw new
|
|
594
|
+
throw new LocoResponseError(`${command} failed: statusCode=${response.statusCode}`, 'provider_rejection', {
|
|
595
|
+
serverStatus: response.statusCode,
|
|
596
|
+
responseStatusSource: 'packet',
|
|
597
|
+
})
|
|
431
598
|
}
|
|
432
599
|
const bodyStatus = response.body.status
|
|
433
600
|
if (typeof bodyStatus === 'number' && bodyStatus !== 0) {
|
|
434
|
-
throw new
|
|
601
|
+
throw new LocoResponseError(`${command} failed: body.status=${bodyStatus}`, 'provider_rejection', {
|
|
602
|
+
serverStatus: bodyStatus,
|
|
603
|
+
responseStatusSource: 'body',
|
|
604
|
+
})
|
|
435
605
|
}
|
|
436
606
|
}
|
|
437
607
|
|
|
@@ -452,9 +622,29 @@ function formatMember(member: Record<string, unknown>): KakaoMember {
|
|
|
452
622
|
}
|
|
453
623
|
|
|
454
624
|
function memberIdKey(member: Record<string, unknown>): string | null {
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
625
|
+
const value = member.userId
|
|
626
|
+
if (typeof value === 'number') {
|
|
627
|
+
return Number.isSafeInteger(value) && value > 0 ? String(value) : null
|
|
628
|
+
}
|
|
629
|
+
if (!value || typeof value !== 'object' || !('low' in value) || !('high' in value)) {
|
|
630
|
+
return null
|
|
631
|
+
}
|
|
632
|
+
const { low, high } = value as {
|
|
633
|
+
low: unknown
|
|
634
|
+
high: unknown
|
|
635
|
+
}
|
|
636
|
+
if (
|
|
637
|
+
!Number.isInteger(low) ||
|
|
638
|
+
!Number.isInteger(high) ||
|
|
639
|
+
(low as number) < -0x80000000 ||
|
|
640
|
+
(low as number) > 0xffffffff ||
|
|
641
|
+
(high as number) < -0x80000000 ||
|
|
642
|
+
(high as number) > 0xffffffff
|
|
643
|
+
) {
|
|
644
|
+
return null
|
|
645
|
+
}
|
|
646
|
+
const numeric = (BigInt((high as number) >>> 0) << 32n) | BigInt((low as number) >>> 0)
|
|
647
|
+
return numeric > 0n && numeric <= BigInt(Number.MAX_SAFE_INTEGER) ? numeric.toString() : null
|
|
458
648
|
}
|
|
459
649
|
|
|
460
650
|
function mergeMemberRecords(
|
|
@@ -484,19 +674,59 @@ function mergeMemberRecords(
|
|
|
484
674
|
return merged
|
|
485
675
|
}
|
|
486
676
|
|
|
487
|
-
function
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
677
|
+
function exactMemberRecords(
|
|
678
|
+
value: unknown,
|
|
679
|
+
source: string,
|
|
680
|
+
): {
|
|
681
|
+
records: Array<Record<string, unknown>>
|
|
682
|
+
ids: Set<string>
|
|
683
|
+
} {
|
|
684
|
+
if (!Array.isArray(value)) {
|
|
685
|
+
throw new Error(`${source} member snapshot missing`)
|
|
686
|
+
}
|
|
687
|
+
const records: Array<Record<string, unknown>> = []
|
|
688
|
+
const ids = new Set<string>()
|
|
689
|
+
for (const raw of value) {
|
|
690
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
691
|
+
throw new Error(`${source} member snapshot invalid`)
|
|
692
|
+
}
|
|
693
|
+
const record = raw as Record<string, unknown>
|
|
694
|
+
const id = memberIdKey(record)
|
|
695
|
+
if (!id || ids.has(id)) {
|
|
696
|
+
throw new Error(`${source} member snapshot invalid`)
|
|
697
|
+
}
|
|
698
|
+
ids.add(id)
|
|
699
|
+
records.push(record)
|
|
493
700
|
}
|
|
701
|
+
return { records, ids }
|
|
702
|
+
}
|
|
494
703
|
|
|
495
|
-
|
|
496
|
-
|
|
704
|
+
function sameIds(left: Set<string>, right: Set<string>): boolean {
|
|
705
|
+
return left.size === right.size && [...left].every((id) => right.has(id))
|
|
706
|
+
}
|
|
497
707
|
|
|
498
|
-
|
|
499
|
-
return
|
|
708
|
+
function subsetIds(subset: Set<string>, superset: Set<string>): boolean {
|
|
709
|
+
return [...subset].every((id) => superset.has(id))
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function channelMemberView(
|
|
713
|
+
body: Record<string, unknown>,
|
|
714
|
+
expectedChatId: string,
|
|
715
|
+
): {
|
|
716
|
+
activeMembers: number
|
|
717
|
+
records: Array<Record<string, unknown>>
|
|
718
|
+
ids: Set<string>
|
|
719
|
+
} {
|
|
720
|
+
const info = extractChannelInfo(body, expectedChatId)
|
|
721
|
+
const activeMembers = requiredNonNegativeInteger(info.activeMembersCount, 'activeMembersCount')
|
|
722
|
+
const displayMembers = exactMemberRecords(info.displayMembers ?? [], 'CHATINFO')
|
|
723
|
+
if (displayMembers.records.length > activeMembers) {
|
|
724
|
+
throw new Error('CHATINFO member snapshot invalid')
|
|
725
|
+
}
|
|
726
|
+
return {
|
|
727
|
+
activeMembers,
|
|
728
|
+
...displayMembers,
|
|
729
|
+
}
|
|
500
730
|
}
|
|
501
731
|
|
|
502
732
|
function formatMessages(
|
|
@@ -518,6 +748,50 @@ function formatMessages(
|
|
|
518
748
|
}))
|
|
519
749
|
}
|
|
520
750
|
|
|
751
|
+
function formatForwardMessage(log: Record<string, unknown>, chatId: string, nameCache: MemberNameCache): KakaoMessage {
|
|
752
|
+
return {
|
|
753
|
+
log_id: longToString(log.logId),
|
|
754
|
+
type: log.type as number,
|
|
755
|
+
author_id: log.authorId as number,
|
|
756
|
+
author_name: nameCache.lookup(chatId, log.authorId as number),
|
|
757
|
+
message: log.message as string,
|
|
758
|
+
attachment: parseAttachmentJson(log.attachment),
|
|
759
|
+
sent_at: log.sendAt as number,
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function buildMessagePage(
|
|
764
|
+
logs: Array<Record<string, unknown>>,
|
|
765
|
+
count: number,
|
|
766
|
+
cursor: Long,
|
|
767
|
+
protocolComplete: boolean,
|
|
768
|
+
chatId: string,
|
|
769
|
+
nameCache: MemberNameCache,
|
|
770
|
+
): KakaoMessagePage {
|
|
771
|
+
const cursorValue = BigInt(cursor.toString())
|
|
772
|
+
const unique = new Map<string, Record<string, unknown>>()
|
|
773
|
+
|
|
774
|
+
for (const log of logs) {
|
|
775
|
+
const logId = longToString(log.logId)
|
|
776
|
+
if (BigInt(logId) <= cursorValue) continue
|
|
777
|
+
if (!unique.has(logId)) unique.set(logId, log)
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
const ordered = [...unique.entries()].sort(([left], [right]) => {
|
|
781
|
+
const a = BigInt(left)
|
|
782
|
+
const b = BigInt(right)
|
|
783
|
+
return a < b ? -1 : a > b ? 1 : 0
|
|
784
|
+
})
|
|
785
|
+
const selected = ordered.slice(0, count)
|
|
786
|
+
const nextCursor = selected.at(-1)?.[0] ?? null
|
|
787
|
+
|
|
788
|
+
return {
|
|
789
|
+
messages: selected.map(([, log]) => formatForwardMessage(log, chatId, nameCache)),
|
|
790
|
+
next_cursor: nextCursor,
|
|
791
|
+
complete: protocolComplete && ordered.length <= count,
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
521
795
|
function buildReplyExtra(target: KakaoReplyTarget): KakaoReplyExtra {
|
|
522
796
|
return {
|
|
523
797
|
attach_only: false,
|
|
@@ -766,6 +1040,7 @@ export class KakaoTalkClient {
|
|
|
766
1040
|
if (options?.all || options?.search || snapshotEmpty) {
|
|
767
1041
|
let cursor: ChatListResponse = loginResult
|
|
768
1042
|
let pages = 0
|
|
1043
|
+
const requireCompleteList = options?.all === true
|
|
769
1044
|
|
|
770
1045
|
while (pages < MAX_PAGES) {
|
|
771
1046
|
// Trust eof only when the snapshot had data. When the snapshot was empty
|
|
@@ -778,15 +1053,28 @@ export class KakaoTalkClient {
|
|
|
778
1053
|
const lastChatId = bsonToLong(cursor.lastChatId)
|
|
779
1054
|
|
|
780
1055
|
const response = await session.getChatList(lastTokenId, lastChatId)
|
|
1056
|
+
if (requireCompleteList) assertLocoOk(response, 'LCHATLIST')
|
|
781
1057
|
const body = response.body as unknown as ChatListResponse
|
|
1058
|
+
if (requireCompleteList && !Array.isArray(body.chatDatas)) {
|
|
1059
|
+
throw new Error('LCHATLIST pagination incomplete: chatDatas unavailable')
|
|
1060
|
+
}
|
|
782
1061
|
const chatDatas = (body.chatDatas ?? []) as ChatData[]
|
|
1062
|
+
cursor = body
|
|
1063
|
+
pages++
|
|
783
1064
|
|
|
784
|
-
if (chatDatas.length === 0)
|
|
1065
|
+
if (chatDatas.length === 0) {
|
|
1066
|
+
if (requireCompleteList && body.eof !== true) {
|
|
1067
|
+
throw new Error('LCHATLIST pagination incomplete: empty non-EOF page')
|
|
1068
|
+
}
|
|
1069
|
+
break
|
|
1070
|
+
}
|
|
785
1071
|
|
|
786
1072
|
collectChats(chatDatas, allChats, seenChatIds)
|
|
787
1073
|
this.nameCache.ingest(chatDatas)
|
|
788
|
-
|
|
789
|
-
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
if (requireCompleteList && cursor.eof !== true) {
|
|
1077
|
+
throw new Error(`LCHATLIST pagination incomplete after ${pages} pages`)
|
|
790
1078
|
}
|
|
791
1079
|
}
|
|
792
1080
|
|
|
@@ -810,6 +1098,65 @@ export class KakaoTalkClient {
|
|
|
810
1098
|
})
|
|
811
1099
|
}
|
|
812
1100
|
|
|
1101
|
+
/**
|
|
1102
|
+
* Fetch one chat directly by id via read-only CHATINFO.
|
|
1103
|
+
*
|
|
1104
|
+
* Unlike `getChats()`, this does not depend on the login-time snapshot or
|
|
1105
|
+
* LCHATLIST pagination, so a room first observed from a live push can be
|
|
1106
|
+
* resolved without interpreting protocol fields outside this SDK.
|
|
1107
|
+
* The returned chat type preserves numeric values and non-empty protocol
|
|
1108
|
+
* strings, including DirectChat, MultiChat, PlusChat, MemoChat, OM, and OD;
|
|
1109
|
+
* unknown non-empty strings are preserved for forward compatibility.
|
|
1110
|
+
*/
|
|
1111
|
+
async getChat(chatId: string): Promise<KakaoChat> {
|
|
1112
|
+
const parsedChatId = parseChatId(chatId)
|
|
1113
|
+
const normalizedChatId = longToString(parsedChatId)
|
|
1114
|
+
return this.executeWithReconnect(async ({ session }) => {
|
|
1115
|
+
try {
|
|
1116
|
+
const response = await session.getChannelInfo(parsedChatId)
|
|
1117
|
+
assertLocoOk(response, 'CHATINFO')
|
|
1118
|
+
const body = response.body as Record<string, unknown>
|
|
1119
|
+
const chatInfo = body.chatInfo
|
|
1120
|
+
const isLeftChatInfo =
|
|
1121
|
+
chatInfo !== null &&
|
|
1122
|
+
typeof chatInfo === 'object' &&
|
|
1123
|
+
!Array.isArray(chatInfo) &&
|
|
1124
|
+
(chatInfo as Record<string, unknown>).left === true
|
|
1125
|
+
if (isLeftChatInfo) {
|
|
1126
|
+
extractChannelInfo(body, normalizedChatId)
|
|
1127
|
+
}
|
|
1128
|
+
if (chatInfo === undefined || chatInfo === null || isLeftChatInfo) {
|
|
1129
|
+
throw new KakaoTalkError('CHATINFO response missing chatInfo', 'get_chat_failed', {
|
|
1130
|
+
responseFailureKind: 'transient_or_unknown',
|
|
1131
|
+
getChatFailureReason: 'chat_info_absent',
|
|
1132
|
+
})
|
|
1133
|
+
}
|
|
1134
|
+
const chat = channelInfoToChatData(body, normalizedChatId)
|
|
1135
|
+
this.nameCache.ingest([chat])
|
|
1136
|
+
|
|
1137
|
+
let title = extractTitle(body)
|
|
1138
|
+
if (!title && isOpenChat(chat)) {
|
|
1139
|
+
const linkId = getOpenLinkId(chat)
|
|
1140
|
+
if (linkId) {
|
|
1141
|
+
try {
|
|
1142
|
+
const openLinkResponse = await session.getOpenLinkInfo([linkId])
|
|
1143
|
+
title = extractOpenLinkName(openLinkResponse.body as Record<string, unknown>)
|
|
1144
|
+
} catch {
|
|
1145
|
+
title = null
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
return formatChat(chat, title, this.nameCache)
|
|
1151
|
+
} catch (error) {
|
|
1152
|
+
throw wrapError(error, 'get_chat_failed', {
|
|
1153
|
+
classifyResponseFailure: true,
|
|
1154
|
+
classifyGetChatFailure: true,
|
|
1155
|
+
})
|
|
1156
|
+
}
|
|
1157
|
+
})
|
|
1158
|
+
}
|
|
1159
|
+
|
|
813
1160
|
/**
|
|
814
1161
|
* Resolve the user-set room title via CHATINFO. Returns null on any error
|
|
815
1162
|
* (network, malformed response, or no TITLE meta present). Designed to be
|
|
@@ -851,6 +1198,60 @@ export class KakaoTalkClient {
|
|
|
851
1198
|
}
|
|
852
1199
|
}
|
|
853
1200
|
|
|
1201
|
+
/**
|
|
1202
|
+
* Fetch one forward-only, lossless history page after `from`.
|
|
1203
|
+
*
|
|
1204
|
+
* The page never keeps only the newest `count` entries. If the protocol
|
|
1205
|
+
* returns more entries than requested, the oldest entries are returned and
|
|
1206
|
+
* `complete` remains false so callers can continue from `next_cursor`.
|
|
1207
|
+
*/
|
|
1208
|
+
async getMessagePage(chatId: string, options?: { count?: number; from?: string }): Promise<KakaoMessagePage> {
|
|
1209
|
+
const count = options?.count ?? 100
|
|
1210
|
+
if (!Number.isInteger(count) || count < 1 || count > 100) {
|
|
1211
|
+
throw new KakaoTalkError('Message page count must be an integer between 1 and 100', 'invalid_message_page_count')
|
|
1212
|
+
}
|
|
1213
|
+
const parsedChatId = parseChatId(chatId)
|
|
1214
|
+
const normalizedChatId = longToString(parsedChatId)
|
|
1215
|
+
const cursor = options?.from ? parseLogId(options.from) : Long.fromNumber(0)
|
|
1216
|
+
|
|
1217
|
+
return this.executeWithReconnect(async ({ session }) => {
|
|
1218
|
+
try {
|
|
1219
|
+
const response = await session.getChatLogs([parsedChatId], [cursor])
|
|
1220
|
+
assertLocoOk(response, 'MCHATLOGS')
|
|
1221
|
+
const batch = ((response.body.chatLogs ?? []) as Array<Record<string, unknown>>).filter(
|
|
1222
|
+
(log) => longToString(log.chatId) === normalizedChatId,
|
|
1223
|
+
)
|
|
1224
|
+
|
|
1225
|
+
if (batch.length > 0) {
|
|
1226
|
+
return buildMessagePage(batch, count, cursor, response.body.eof === true, normalizedChatId, this.nameCache)
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
// CHATINFO is read-only and supplies a fresh lastLogId even for rooms
|
|
1230
|
+
// absent from the login snapshot. This avoids CHATONROOM, which can
|
|
1231
|
+
// advance server-side unread state.
|
|
1232
|
+
const chatInfoResponse = await session.getChannelInfo(parsedChatId)
|
|
1233
|
+
assertLocoOk(chatInfoResponse, 'CHATINFO')
|
|
1234
|
+
const maxLogId = extractChannelMaxLogId(chatInfoResponse.body as Record<string, unknown>, normalizedChatId)
|
|
1235
|
+
if (maxLogId.lessThanOrEqual(cursor)) {
|
|
1236
|
+
return { messages: [], next_cursor: null, complete: true }
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
const syncResponse = await session.syncMessages(parsedChatId, Math.min(count, 80), cursor, maxLogId)
|
|
1240
|
+
assertLocoOk(syncResponse, 'SYNCMSG')
|
|
1241
|
+
const syncBatch = ((syncResponse.body.chatLogs ?? []) as Array<Record<string, unknown>>).filter(
|
|
1242
|
+
(log) => longToString(log.chatId) === normalizedChatId,
|
|
1243
|
+
)
|
|
1244
|
+
const maxReturned = findMaxLogId(syncBatch, 'logId')
|
|
1245
|
+
const protocolComplete =
|
|
1246
|
+
syncResponse.body.isOK === true || (maxReturned !== null && maxReturned.greaterThanOrEqual(maxLogId))
|
|
1247
|
+
|
|
1248
|
+
return buildMessagePage(syncBatch, count, cursor, protocolComplete, normalizedChatId, this.nameCache)
|
|
1249
|
+
} catch (error) {
|
|
1250
|
+
throw wrapError(error, 'get_message_page_failed')
|
|
1251
|
+
}
|
|
1252
|
+
})
|
|
1253
|
+
}
|
|
1254
|
+
|
|
854
1255
|
async getMessages(chatId: string, options?: { count?: number; from?: string }): Promise<KakaoMessage[]> {
|
|
855
1256
|
return this.executeWithReconnect(async ({ session, loginResult }) => {
|
|
856
1257
|
try {
|
|
@@ -949,30 +1350,60 @@ export class KakaoTalkClient {
|
|
|
949
1350
|
})
|
|
950
1351
|
}
|
|
951
1352
|
|
|
952
|
-
async
|
|
1353
|
+
async getMemberSnapshot(chatId: string): Promise<KakaoMemberSnapshot> {
|
|
953
1354
|
const parsedChatId = parseChatId(chatId)
|
|
1355
|
+
const normalizedChatId = longToString(parsedChatId)
|
|
954
1356
|
return this.executeWithReconnect(async ({ session }) => {
|
|
955
1357
|
try {
|
|
956
|
-
const
|
|
957
|
-
assertLocoOk(
|
|
958
|
-
const
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
1358
|
+
const firstChatResponse = await session.getChannelInfo(parsedChatId)
|
|
1359
|
+
assertLocoOk(firstChatResponse, 'CHATINFO')
|
|
1360
|
+
const firstChat = channelMemberView(firstChatResponse.body as Record<string, unknown>, normalizedChatId)
|
|
1361
|
+
const firstMemberResponse = await session.getAllMembers(parsedChatId)
|
|
1362
|
+
assertLocoOk(firstMemberResponse, 'GETMEM')
|
|
1363
|
+
const firstMembers = exactMemberRecords(firstMemberResponse.body.members ?? [], 'GETMEM')
|
|
1364
|
+
const secondChatResponse = await session.getChannelInfo(parsedChatId)
|
|
1365
|
+
assertLocoOk(secondChatResponse, 'CHATINFO')
|
|
1366
|
+
const secondChat = channelMemberView(secondChatResponse.body as Record<string, unknown>, normalizedChatId)
|
|
1367
|
+
const secondMemberResponse = await session.getAllMembers(parsedChatId)
|
|
1368
|
+
assertLocoOk(secondMemberResponse, 'GETMEM')
|
|
1369
|
+
const secondMembers = exactMemberRecords(secondMemberResponse.body.members ?? [], 'GETMEM')
|
|
1370
|
+
if (
|
|
1371
|
+
firstChat.activeMembers !== secondChat.activeMembers ||
|
|
1372
|
+
firstMembers.records.length !== firstChat.activeMembers ||
|
|
1373
|
+
secondMembers.records.length !== secondChat.activeMembers ||
|
|
1374
|
+
!sameIds(firstChat.ids, secondChat.ids) ||
|
|
1375
|
+
!sameIds(firstMembers.ids, secondMembers.ids) ||
|
|
1376
|
+
!subsetIds(secondChat.ids, secondMembers.ids)
|
|
1377
|
+
) {
|
|
1378
|
+
throw new Error('member_snapshot_changed_or_incomplete')
|
|
1379
|
+
}
|
|
1380
|
+
return {
|
|
1381
|
+
chat_id: normalizedChatId,
|
|
1382
|
+
active_members: secondChat.activeMembers,
|
|
1383
|
+
members: mergeMemberRecords(secondMembers.records, secondChat.records).map(formatMember),
|
|
1384
|
+
complete: true,
|
|
1385
|
+
consistency_basis: 'stable_double_read_chatinfo_getmem',
|
|
967
1386
|
}
|
|
968
|
-
|
|
969
|
-
return mergeMemberRecords(members, fallbackMembers).map(formatMember)
|
|
970
1387
|
} catch (error) {
|
|
971
|
-
throw wrapError(error, '
|
|
1388
|
+
throw wrapError(error, 'get_member_snapshot_failed')
|
|
972
1389
|
}
|
|
973
1390
|
})
|
|
974
1391
|
}
|
|
975
1392
|
|
|
1393
|
+
async getMembers(chatId: string): Promise<KakaoMember[]> {
|
|
1394
|
+
try {
|
|
1395
|
+
return (await this.getMemberSnapshot(chatId)).members
|
|
1396
|
+
} catch (error) {
|
|
1397
|
+
if (error instanceof KakaoTalkError && error.code === 'get_member_snapshot_failed') {
|
|
1398
|
+
throw new KakaoTalkError(error.message, 'get_members_failed', {
|
|
1399
|
+
cause: error,
|
|
1400
|
+
serverStatus: error.serverStatus,
|
|
1401
|
+
})
|
|
1402
|
+
}
|
|
1403
|
+
throw error
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
|
|
976
1407
|
async getMembersByIds(chatId: string, userIds: string[]): Promise<KakaoMember[]> {
|
|
977
1408
|
if (userIds.length === 0) return []
|
|
978
1409
|
const parsedChatId = parseChatId(chatId)
|