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.
Files changed (58) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/dist/package.json +1 -1
  3. package/dist/src/platforms/kakaotalk/chat-classifier.d.ts +5 -4
  4. package/dist/src/platforms/kakaotalk/chat-classifier.d.ts.map +1 -1
  5. package/dist/src/platforms/kakaotalk/chat-classifier.js +15 -9
  6. package/dist/src/platforms/kakaotalk/chat-classifier.js.map +1 -1
  7. package/dist/src/platforms/kakaotalk/client.d.ts +33 -1
  8. package/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
  9. package/dist/src/platforms/kakaotalk/client.js +377 -42
  10. package/dist/src/platforms/kakaotalk/client.js.map +1 -1
  11. package/dist/src/platforms/kakaotalk/index.d.ts +4 -3
  12. package/dist/src/platforms/kakaotalk/index.d.ts.map +1 -1
  13. package/dist/src/platforms/kakaotalk/index.js +2 -2
  14. package/dist/src/platforms/kakaotalk/index.js.map +1 -1
  15. package/dist/src/platforms/kakaotalk/protocol/connection.d.ts.map +1 -1
  16. package/dist/src/platforms/kakaotalk/protocol/connection.js +13 -2
  17. package/dist/src/platforms/kakaotalk/protocol/connection.js.map +1 -1
  18. package/dist/src/platforms/kakaotalk/protocol/login-response.d.ts +1 -0
  19. package/dist/src/platforms/kakaotalk/protocol/login-response.d.ts.map +1 -1
  20. package/dist/src/platforms/kakaotalk/protocol/login-response.js +1 -1
  21. package/dist/src/platforms/kakaotalk/protocol/login-response.js.map +1 -1
  22. package/dist/src/platforms/kakaotalk/types.d.ts +50 -2
  23. package/dist/src/platforms/kakaotalk/types.d.ts.map +1 -1
  24. package/dist/src/platforms/kakaotalk/types.js +18 -1
  25. package/dist/src/platforms/kakaotalk/types.js.map +1 -1
  26. package/docs/content/docs/cli/kakaotalk.mdx +7 -1
  27. package/docs/content/docs/sdk/kakaotalk.mdx +29 -1
  28. package/package.json +1 -1
  29. package/skills/agent-channeltalk/SKILL.md +1 -1
  30. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  31. package/skills/agent-discord/SKILL.md +1 -1
  32. package/skills/agent-discordbot/SKILL.md +1 -1
  33. package/skills/agent-imessage/SKILL.md +1 -1
  34. package/skills/agent-instagram/SKILL.md +1 -1
  35. package/skills/agent-kakaotalk/SKILL.md +1 -1
  36. package/skills/agent-kakaotalk/references/common-patterns.md +36 -0
  37. package/skills/agent-line/SKILL.md +1 -1
  38. package/skills/agent-slack/SKILL.md +1 -1
  39. package/skills/agent-slackbot/SKILL.md +1 -1
  40. package/skills/agent-teams/SKILL.md +1 -1
  41. package/skills/agent-telegram/SKILL.md +1 -1
  42. package/skills/agent-telegrambot/SKILL.md +1 -1
  43. package/skills/agent-webex/SKILL.md +1 -1
  44. package/skills/agent-webexbot/SKILL.md +1 -1
  45. package/skills/agent-wechatbot/SKILL.md +1 -1
  46. package/skills/agent-whatsapp/SKILL.md +1 -1
  47. package/skills/agent-whatsappbot/SKILL.md +1 -1
  48. package/src/platforms/kakaotalk/chat-classifier.test.ts +23 -1
  49. package/src/platforms/kakaotalk/chat-classifier.ts +16 -10
  50. package/src/platforms/kakaotalk/client.test.ts +1225 -88
  51. package/src/platforms/kakaotalk/client.ts +476 -45
  52. package/src/platforms/kakaotalk/index.test.ts +39 -0
  53. package/src/platforms/kakaotalk/index.ts +12 -1
  54. package/src/platforms/kakaotalk/protocol/connection.test.ts +87 -0
  55. package/src/platforms/kakaotalk/protocol/connection.ts +15 -2
  56. package/src/platforms/kakaotalk/protocol/login-response.test.ts +13 -2
  57. package/src/platforms/kakaotalk/protocol/login-response.ts +1 -1
  58. 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
- constructor(message: string, code: string, options?: { cause?: unknown; serverStatus?: number }) {
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 as number,
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 typeof chat.t === 'string' && OPEN_CHAT_TYPES.has(chat.t)
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(error: unknown, code: string): KakaoTalkError {
280
- if (error instanceof KakaoTalkError) return error
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, { cause: error })
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 Error(`${command} failed: statusCode=${response.statusCode}`)
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 Error(`${command} failed: body.status=${bodyStatus}`)
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
- if (member.userId === undefined || member.userId === null) return null
456
- const key = longToString(member.userId)
457
- return key === '0' ? null : key
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 extractChatInfoMembers(body: unknown): Array<Record<string, unknown>> {
488
- if (!body || typeof body !== 'object') return []
489
-
490
- const record = body as Record<string, unknown>
491
- if (Array.isArray(record.m)) {
492
- return record.m as Array<Record<string, unknown>>
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
- const chatInfo = record.chatInfo
496
- if (!chatInfo || typeof chatInfo !== 'object') return []
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
- const displayMembers = (chatInfo as Record<string, unknown>).displayMembers
499
- return Array.isArray(displayMembers) ? (displayMembers as Array<Record<string, unknown>>) : []
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) break
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
- cursor = body
789
- pages++
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 getMembers(chatId: string): Promise<KakaoMember[]> {
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 response = await session.getAllMembers(parsedChatId)
957
- assertLocoOk(response, 'GETMEM')
958
- const members = (response.body.members ?? []) as Array<Record<string, unknown>>
959
- let fallbackMembers: Array<Record<string, unknown>> = []
960
- try {
961
- // Some KakaoTalk rooms return only a subset from GETMEM even though
962
- // CHATONROOM carries the full active member list in `m`.
963
- const chatInfo = await session.getChatInfo(parsedChatId)
964
- fallbackMembers = extractChatInfoMembers(chatInfo.body)
965
- } catch {
966
- fallbackMembers = []
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, 'get_members_failed')
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)