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
@@ -5,20 +5,40 @@ import { Long } from 'bson';
5
5
  import { getConfigDir } from '../../shared/utils/config-dir.js';
6
6
  import { warn } from '../../shared/utils/stderr.js';
7
7
  import { planAttachments } from './attachment-router.js';
8
+ import { isOpenKakaoChatType } from './chat-classifier.js';
8
9
  import { detectImageDimensions } from './image-meta.js';
9
10
  import { sha1Hex } from './media-upload.js';
10
11
  import { LANG, PC_OS_NAME, getLocoDeviceConfig } from './protocol/config.js';
12
+ import { isSyntheticConnectionClose } from './protocol/login-response.js';
11
13
  import { uploadMediaToLoco, uploadMultiMediaEntry } from './protocol/media-uploader.js';
12
14
  import { LocoSession } from './protocol/session.js';
13
15
  import { KAKAO_MESSAGE_TYPE, } from './types.js';
14
16
  export class KakaoTalkError extends Error {
15
17
  code;
16
18
  serverStatus;
19
+ responseFailureKind;
20
+ responseStatusSource;
21
+ getChatFailureReason;
17
22
  constructor(message, code, options) {
18
23
  super(message, options);
19
24
  this.name = 'KakaoTalkError';
20
25
  this.code = code;
21
26
  this.serverStatus = options?.serverStatus;
27
+ this.responseFailureKind = options?.responseFailureKind;
28
+ this.responseStatusSource = options?.responseStatusSource;
29
+ this.getChatFailureReason = options?.getChatFailureReason;
30
+ }
31
+ }
32
+ class LocoResponseError extends Error {
33
+ serverStatus;
34
+ responseFailureKind;
35
+ responseStatusSource;
36
+ constructor(message, responseFailureKind, options) {
37
+ super(message);
38
+ this.name = 'LocoResponseError';
39
+ this.serverStatus = options?.serverStatus;
40
+ this.responseFailureKind = responseFailureKind;
41
+ this.responseStatusSource = options?.responseStatusSource;
22
42
  }
23
43
  }
24
44
  class MemberNameCache {
@@ -141,7 +161,7 @@ function formatChat(chat, title, nameCache) {
141
161
  const chatId = longToString(chat.c);
142
162
  return {
143
163
  chat_id: chatId,
144
- type: chat.t,
164
+ type: requiredChatType(chat.t),
145
165
  display_name: displayName,
146
166
  title,
147
167
  active_members: chat.a,
@@ -166,6 +186,64 @@ function extractTitle(body) {
166
186
  const content = titleMeta?.content;
167
187
  return typeof content === 'string' && content.length > 0 ? content : null;
168
188
  }
189
+ function extractChannelInfo(body, expectedChatId) {
190
+ const info = body.chatInfo;
191
+ if (!info || typeof info !== 'object') {
192
+ throw new Error('CHATINFO response missing chatInfo');
193
+ }
194
+ const responseChatId = longToString(info.chatId);
195
+ if (responseChatId !== expectedChatId) {
196
+ throw new Error('CHATINFO response chatId mismatch');
197
+ }
198
+ return info;
199
+ }
200
+ function requiredNonNegativeInteger(value, field) {
201
+ if (!Number.isInteger(value) || value < 0) {
202
+ throw new Error(`CHATINFO response invalid ${field}`);
203
+ }
204
+ return value;
205
+ }
206
+ function requiredChatType(value) {
207
+ if (typeof value === 'string' && value.length > 0) {
208
+ return value;
209
+ }
210
+ return requiredNonNegativeInteger(value, 'type');
211
+ }
212
+ function channelInfoToChatData(body, expectedChatId) {
213
+ const info = extractChannelInfo(body, expectedChatId);
214
+ const type = requiredChatType(info.type);
215
+ const activeMembers = requiredNonNegativeInteger(info.activeMembersCount, 'activeMembersCount');
216
+ const unreadCount = info.invalidNewMessageCount === true ? 0 : requiredNonNegativeInteger(info.newMessageCount, 'newMessageCount');
217
+ const displayMembers = Array.isArray(info.displayMembers) ? info.displayMembers : [];
218
+ const memberIds = [];
219
+ const memberNames = [];
220
+ for (const member of displayMembers) {
221
+ if (!member || typeof member !== 'object')
222
+ continue;
223
+ if (member.userId === undefined || typeof member.nickName !== 'string')
224
+ continue;
225
+ memberIds.push(member.userId);
226
+ memberNames.push(member.nickName);
227
+ }
228
+ return {
229
+ c: info.chatId,
230
+ t: type,
231
+ a: activeMembers,
232
+ n: unreadCount,
233
+ l: info.lastChatLog ?? null,
234
+ ll: info.lastLogId,
235
+ i: memberIds,
236
+ k: memberNames,
237
+ li: info.li,
238
+ };
239
+ }
240
+ function extractChannelMaxLogId(body, expectedChatId) {
241
+ const info = extractChannelInfo(body, expectedChatId);
242
+ const maxLogId = bsonToLong(info.lastLogId);
243
+ if (!maxLogId)
244
+ throw new Error('CHATINFO response missing lastLogId');
245
+ return maxLogId;
246
+ }
169
247
  function extractOpenLinkName(body) {
170
248
  const ols = body.ols;
171
249
  if (!Array.isArray(ols) || ols.length === 0)
@@ -173,9 +251,8 @@ function extractOpenLinkName(body) {
173
251
  const ln = ols[0]?.ln;
174
252
  return typeof ln === 'string' && ln.length > 0 ? ln : null;
175
253
  }
176
- const OPEN_CHAT_TYPES = new Set(['OM', 'OD']);
177
254
  function isOpenChat(chat) {
178
- return typeof chat.t === 'string' && OPEN_CHAT_TYPES.has(chat.t);
255
+ return isOpenKakaoChatType(chat.t);
179
256
  }
180
257
  function getOpenLinkId(chat) {
181
258
  return bsonToLong(chat.li) ?? null;
@@ -209,11 +286,39 @@ function collectChats(chatDatas, into, seen) {
209
286
  }
210
287
  }
211
288
  }
212
- function wrapError(error, code) {
213
- if (error instanceof KakaoTalkError)
214
- return error;
289
+ function wrapError(error, code, options) {
290
+ if (error instanceof KakaoTalkError) {
291
+ const responseFailureKind = error.responseFailureKind ?? (options?.classifyResponseFailure ? 'transient_or_unknown' : undefined);
292
+ const getChatFailureReason = error.getChatFailureReason ?? (options?.classifyGetChatFailure ? 'transport_or_unknown' : undefined);
293
+ if (responseFailureKind === error.responseFailureKind && getChatFailureReason === error.getChatFailureReason) {
294
+ return error;
295
+ }
296
+ return new KakaoTalkError(error.message, error.code, {
297
+ cause: error.cause ?? error,
298
+ serverStatus: error.serverStatus,
299
+ responseFailureKind,
300
+ responseStatusSource: error.responseStatusSource,
301
+ getChatFailureReason,
302
+ });
303
+ }
215
304
  const message = error instanceof Error ? error.message : String(error);
216
- return new KakaoTalkError(message, code, { cause: error });
305
+ return new KakaoTalkError(message, code, {
306
+ cause: error,
307
+ serverStatus: error instanceof LocoResponseError ? error.serverStatus : undefined,
308
+ responseStatusSource: error instanceof LocoResponseError ? error.responseStatusSource : undefined,
309
+ responseFailureKind: error instanceof LocoResponseError
310
+ ? error.responseFailureKind
311
+ : options?.classifyResponseFailure
312
+ ? 'transient_or_unknown'
313
+ : undefined,
314
+ getChatFailureReason: options?.classifyGetChatFailure
315
+ ? error instanceof LocoResponseError && error.responseFailureKind === 'provider_rejection'
316
+ ? 'provider_rejection'
317
+ : error instanceof LocoResponseError && error.responseStatusSource === 'packet' && error.serverStatus === -1
318
+ ? 'synthetic_connection_close'
319
+ : 'transport_or_unknown'
320
+ : undefined,
321
+ });
217
322
  }
218
323
  function isLoginResponseError(error) {
219
324
  if (!(error instanceof Error))
@@ -335,12 +440,27 @@ function isNonZeroLong(v) {
335
440
  // caller-visible error channel (e.g. GETMEM/MEMBER return `[]` for both empty rooms and dead
336
441
  // sockets). Throwing here lets executeWithReconnect detect session death and reconnect.
337
442
  function assertLocoOk(response, command) {
443
+ if (typeof response.statusCode !== 'number' || !Number.isFinite(response.statusCode)) {
444
+ throw new LocoResponseError(`${command} failed: response status unavailable`, 'transient_or_unknown');
445
+ }
446
+ if (isSyntheticConnectionClose(response)) {
447
+ throw new LocoResponseError(`${command} failed: connection closed`, 'transient_or_unknown', {
448
+ serverStatus: response.statusCode,
449
+ responseStatusSource: 'packet',
450
+ });
451
+ }
338
452
  if (response.statusCode !== 0) {
339
- throw new Error(`${command} failed: statusCode=${response.statusCode}`);
453
+ throw new LocoResponseError(`${command} failed: statusCode=${response.statusCode}`, 'provider_rejection', {
454
+ serverStatus: response.statusCode,
455
+ responseStatusSource: 'packet',
456
+ });
340
457
  }
341
458
  const bodyStatus = response.body.status;
342
459
  if (typeof bodyStatus === 'number' && bodyStatus !== 0) {
343
- throw new Error(`${command} failed: body.status=${bodyStatus}`);
460
+ throw new LocoResponseError(`${command} failed: body.status=${bodyStatus}`, 'provider_rejection', {
461
+ serverStatus: bodyStatus,
462
+ responseStatusSource: 'body',
463
+ });
344
464
  }
345
465
  }
346
466
  function formatMember(member) {
@@ -359,10 +479,24 @@ function formatMember(member) {
359
479
  };
360
480
  }
361
481
  function memberIdKey(member) {
362
- if (member.userId === undefined || member.userId === null)
482
+ const value = member.userId;
483
+ if (typeof value === 'number') {
484
+ return Number.isSafeInteger(value) && value > 0 ? String(value) : null;
485
+ }
486
+ if (!value || typeof value !== 'object' || !('low' in value) || !('high' in value)) {
487
+ return null;
488
+ }
489
+ const { low, high } = value;
490
+ if (!Number.isInteger(low) ||
491
+ !Number.isInteger(high) ||
492
+ low < -0x80000000 ||
493
+ low > 0xffffffff ||
494
+ high < -0x80000000 ||
495
+ high > 0xffffffff) {
363
496
  return null;
364
- const key = longToString(member.userId);
365
- return key === '0' ? null : key;
497
+ }
498
+ const numeric = (BigInt(high >>> 0) << 32n) | BigInt(low >>> 0);
499
+ return numeric > 0n && numeric <= BigInt(Number.MAX_SAFE_INTEGER) ? numeric.toString() : null;
366
500
  }
367
501
  function mergeMemberRecords(primaryMembers, fallbackMembers) {
368
502
  const merged = [];
@@ -385,18 +519,43 @@ function mergeMemberRecords(primaryMembers, fallbackMembers) {
385
519
  upsert(member);
386
520
  return merged;
387
521
  }
388
- function extractChatInfoMembers(body) {
389
- if (!body || typeof body !== 'object')
390
- return [];
391
- const record = body;
392
- if (Array.isArray(record.m)) {
393
- return record.m;
394
- }
395
- const chatInfo = record.chatInfo;
396
- if (!chatInfo || typeof chatInfo !== 'object')
397
- return [];
398
- const displayMembers = chatInfo.displayMembers;
399
- return Array.isArray(displayMembers) ? displayMembers : [];
522
+ function exactMemberRecords(value, source) {
523
+ if (!Array.isArray(value)) {
524
+ throw new Error(`${source} member snapshot missing`);
525
+ }
526
+ const records = [];
527
+ const ids = new Set();
528
+ for (const raw of value) {
529
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
530
+ throw new Error(`${source} member snapshot invalid`);
531
+ }
532
+ const record = raw;
533
+ const id = memberIdKey(record);
534
+ if (!id || ids.has(id)) {
535
+ throw new Error(`${source} member snapshot invalid`);
536
+ }
537
+ ids.add(id);
538
+ records.push(record);
539
+ }
540
+ return { records, ids };
541
+ }
542
+ function sameIds(left, right) {
543
+ return left.size === right.size && [...left].every((id) => right.has(id));
544
+ }
545
+ function subsetIds(subset, superset) {
546
+ return [...subset].every((id) => superset.has(id));
547
+ }
548
+ function channelMemberView(body, expectedChatId) {
549
+ const info = extractChannelInfo(body, expectedChatId);
550
+ const activeMembers = requiredNonNegativeInteger(info.activeMembersCount, 'activeMembersCount');
551
+ const displayMembers = exactMemberRecords(info.displayMembers ?? [], 'CHATINFO');
552
+ if (displayMembers.records.length > activeMembers) {
553
+ throw new Error('CHATINFO member snapshot invalid');
554
+ }
555
+ return {
556
+ activeMembers,
557
+ ...displayMembers,
558
+ };
400
559
  }
401
560
  function formatMessages(logs, count, chatId, nameCache) {
402
561
  logs.sort((a, b) => a.sendAt - b.sendAt);
@@ -410,6 +569,40 @@ function formatMessages(logs, count, chatId, nameCache) {
410
569
  sent_at: log.sendAt,
411
570
  }));
412
571
  }
572
+ function formatForwardMessage(log, chatId, nameCache) {
573
+ return {
574
+ log_id: longToString(log.logId),
575
+ type: log.type,
576
+ author_id: log.authorId,
577
+ author_name: nameCache.lookup(chatId, log.authorId),
578
+ message: log.message,
579
+ attachment: parseAttachmentJson(log.attachment),
580
+ sent_at: log.sendAt,
581
+ };
582
+ }
583
+ function buildMessagePage(logs, count, cursor, protocolComplete, chatId, nameCache) {
584
+ const cursorValue = BigInt(cursor.toString());
585
+ const unique = new Map();
586
+ for (const log of logs) {
587
+ const logId = longToString(log.logId);
588
+ if (BigInt(logId) <= cursorValue)
589
+ continue;
590
+ if (!unique.has(logId))
591
+ unique.set(logId, log);
592
+ }
593
+ const ordered = [...unique.entries()].sort(([left], [right]) => {
594
+ const a = BigInt(left);
595
+ const b = BigInt(right);
596
+ return a < b ? -1 : a > b ? 1 : 0;
597
+ });
598
+ const selected = ordered.slice(0, count);
599
+ const nextCursor = selected.at(-1)?.[0] ?? null;
600
+ return {
601
+ messages: selected.map(([, log]) => formatForwardMessage(log, chatId, nameCache)),
602
+ next_cursor: nextCursor,
603
+ complete: protocolComplete && ordered.length <= count,
604
+ };
605
+ }
413
606
  function buildReplyExtra(target) {
414
607
  return {
415
608
  attach_only: false,
@@ -636,6 +829,7 @@ export class KakaoTalkClient {
636
829
  if (options?.all || options?.search || snapshotEmpty) {
637
830
  let cursor = loginResult;
638
831
  let pages = 0;
832
+ const requireCompleteList = options?.all === true;
639
833
  while (pages < MAX_PAGES) {
640
834
  // Trust eof only when the snapshot had data. When the snapshot was empty
641
835
  // (new device), ignore eof for the first iteration so we always attempt
@@ -647,14 +841,26 @@ export class KakaoTalkClient {
647
841
  const lastTokenId = bsonToLong(cursor.lastTokenId);
648
842
  const lastChatId = bsonToLong(cursor.lastChatId);
649
843
  const response = await session.getChatList(lastTokenId, lastChatId);
844
+ if (requireCompleteList)
845
+ assertLocoOk(response, 'LCHATLIST');
650
846
  const body = response.body;
847
+ if (requireCompleteList && !Array.isArray(body.chatDatas)) {
848
+ throw new Error('LCHATLIST pagination incomplete: chatDatas unavailable');
849
+ }
651
850
  const chatDatas = (body.chatDatas ?? []);
652
- if (chatDatas.length === 0)
851
+ cursor = body;
852
+ pages++;
853
+ if (chatDatas.length === 0) {
854
+ if (requireCompleteList && body.eof !== true) {
855
+ throw new Error('LCHATLIST pagination incomplete: empty non-EOF page');
856
+ }
653
857
  break;
858
+ }
654
859
  collectChats(chatDatas, allChats, seenChatIds);
655
860
  this.nameCache.ingest(chatDatas);
656
- cursor = body;
657
- pages++;
861
+ }
862
+ if (requireCompleteList && cursor.eof !== true) {
863
+ throw new Error(`LCHATLIST pagination incomplete after ${pages} pages`);
658
864
  }
659
865
  }
660
866
  allChats.sort((a, b) => (b.o ?? 0) - (a.o ?? 0));
@@ -672,6 +878,63 @@ export class KakaoTalkClient {
672
878
  }
673
879
  });
674
880
  }
881
+ /**
882
+ * Fetch one chat directly by id via read-only CHATINFO.
883
+ *
884
+ * Unlike `getChats()`, this does not depend on the login-time snapshot or
885
+ * LCHATLIST pagination, so a room first observed from a live push can be
886
+ * resolved without interpreting protocol fields outside this SDK.
887
+ * The returned chat type preserves numeric values and non-empty protocol
888
+ * strings, including DirectChat, MultiChat, PlusChat, MemoChat, OM, and OD;
889
+ * unknown non-empty strings are preserved for forward compatibility.
890
+ */
891
+ async getChat(chatId) {
892
+ const parsedChatId = parseChatId(chatId);
893
+ const normalizedChatId = longToString(parsedChatId);
894
+ return this.executeWithReconnect(async ({ session }) => {
895
+ try {
896
+ const response = await session.getChannelInfo(parsedChatId);
897
+ assertLocoOk(response, 'CHATINFO');
898
+ const body = response.body;
899
+ const chatInfo = body.chatInfo;
900
+ const isLeftChatInfo = chatInfo !== null &&
901
+ typeof chatInfo === 'object' &&
902
+ !Array.isArray(chatInfo) &&
903
+ chatInfo.left === true;
904
+ if (isLeftChatInfo) {
905
+ extractChannelInfo(body, normalizedChatId);
906
+ }
907
+ if (chatInfo === undefined || chatInfo === null || isLeftChatInfo) {
908
+ throw new KakaoTalkError('CHATINFO response missing chatInfo', 'get_chat_failed', {
909
+ responseFailureKind: 'transient_or_unknown',
910
+ getChatFailureReason: 'chat_info_absent',
911
+ });
912
+ }
913
+ const chat = channelInfoToChatData(body, normalizedChatId);
914
+ this.nameCache.ingest([chat]);
915
+ let title = extractTitle(body);
916
+ if (!title && isOpenChat(chat)) {
917
+ const linkId = getOpenLinkId(chat);
918
+ if (linkId) {
919
+ try {
920
+ const openLinkResponse = await session.getOpenLinkInfo([linkId]);
921
+ title = extractOpenLinkName(openLinkResponse.body);
922
+ }
923
+ catch {
924
+ title = null;
925
+ }
926
+ }
927
+ }
928
+ return formatChat(chat, title, this.nameCache);
929
+ }
930
+ catch (error) {
931
+ throw wrapError(error, 'get_chat_failed', {
932
+ classifyResponseFailure: true,
933
+ classifyGetChatFailure: true,
934
+ });
935
+ }
936
+ });
937
+ }
675
938
  /**
676
939
  * Resolve the user-set room title via CHATINFO. Returns null on any error
677
940
  * (network, malformed response, or no TITLE meta present). Designed to be
@@ -714,6 +977,50 @@ export class KakaoTalkClient {
714
977
  return null;
715
978
  }
716
979
  }
980
+ /**
981
+ * Fetch one forward-only, lossless history page after `from`.
982
+ *
983
+ * The page never keeps only the newest `count` entries. If the protocol
984
+ * returns more entries than requested, the oldest entries are returned and
985
+ * `complete` remains false so callers can continue from `next_cursor`.
986
+ */
987
+ async getMessagePage(chatId, options) {
988
+ const count = options?.count ?? 100;
989
+ if (!Number.isInteger(count) || count < 1 || count > 100) {
990
+ throw new KakaoTalkError('Message page count must be an integer between 1 and 100', 'invalid_message_page_count');
991
+ }
992
+ const parsedChatId = parseChatId(chatId);
993
+ const normalizedChatId = longToString(parsedChatId);
994
+ const cursor = options?.from ? parseLogId(options.from) : Long.fromNumber(0);
995
+ return this.executeWithReconnect(async ({ session }) => {
996
+ try {
997
+ const response = await session.getChatLogs([parsedChatId], [cursor]);
998
+ assertLocoOk(response, 'MCHATLOGS');
999
+ const batch = (response.body.chatLogs ?? []).filter((log) => longToString(log.chatId) === normalizedChatId);
1000
+ if (batch.length > 0) {
1001
+ return buildMessagePage(batch, count, cursor, response.body.eof === true, normalizedChatId, this.nameCache);
1002
+ }
1003
+ // CHATINFO is read-only and supplies a fresh lastLogId even for rooms
1004
+ // absent from the login snapshot. This avoids CHATONROOM, which can
1005
+ // advance server-side unread state.
1006
+ const chatInfoResponse = await session.getChannelInfo(parsedChatId);
1007
+ assertLocoOk(chatInfoResponse, 'CHATINFO');
1008
+ const maxLogId = extractChannelMaxLogId(chatInfoResponse.body, normalizedChatId);
1009
+ if (maxLogId.lessThanOrEqual(cursor)) {
1010
+ return { messages: [], next_cursor: null, complete: true };
1011
+ }
1012
+ const syncResponse = await session.syncMessages(parsedChatId, Math.min(count, 80), cursor, maxLogId);
1013
+ assertLocoOk(syncResponse, 'SYNCMSG');
1014
+ const syncBatch = (syncResponse.body.chatLogs ?? []).filter((log) => longToString(log.chatId) === normalizedChatId);
1015
+ const maxReturned = findMaxLogId(syncBatch, 'logId');
1016
+ const protocolComplete = syncResponse.body.isOK === true || (maxReturned !== null && maxReturned.greaterThanOrEqual(maxLogId));
1017
+ return buildMessagePage(syncBatch, count, cursor, protocolComplete, normalizedChatId, this.nameCache);
1018
+ }
1019
+ catch (error) {
1020
+ throw wrapError(error, 'get_message_page_failed');
1021
+ }
1022
+ });
1023
+ }
717
1024
  async getMessages(chatId, options) {
718
1025
  return this.executeWithReconnect(async ({ session, loginResult }) => {
719
1026
  try {
@@ -798,30 +1105,58 @@ export class KakaoTalkClient {
798
1105
  }
799
1106
  });
800
1107
  }
801
- async getMembers(chatId) {
1108
+ async getMemberSnapshot(chatId) {
802
1109
  const parsedChatId = parseChatId(chatId);
1110
+ const normalizedChatId = longToString(parsedChatId);
803
1111
  return this.executeWithReconnect(async ({ session }) => {
804
1112
  try {
805
- const response = await session.getAllMembers(parsedChatId);
806
- assertLocoOk(response, 'GETMEM');
807
- const members = (response.body.members ?? []);
808
- let fallbackMembers = [];
809
- try {
810
- // Some KakaoTalk rooms return only a subset from GETMEM even though
811
- // CHATONROOM carries the full active member list in `m`.
812
- const chatInfo = await session.getChatInfo(parsedChatId);
813
- fallbackMembers = extractChatInfoMembers(chatInfo.body);
1113
+ const firstChatResponse = await session.getChannelInfo(parsedChatId);
1114
+ assertLocoOk(firstChatResponse, 'CHATINFO');
1115
+ const firstChat = channelMemberView(firstChatResponse.body, normalizedChatId);
1116
+ const firstMemberResponse = await session.getAllMembers(parsedChatId);
1117
+ assertLocoOk(firstMemberResponse, 'GETMEM');
1118
+ const firstMembers = exactMemberRecords(firstMemberResponse.body.members ?? [], 'GETMEM');
1119
+ const secondChatResponse = await session.getChannelInfo(parsedChatId);
1120
+ assertLocoOk(secondChatResponse, 'CHATINFO');
1121
+ const secondChat = channelMemberView(secondChatResponse.body, normalizedChatId);
1122
+ const secondMemberResponse = await session.getAllMembers(parsedChatId);
1123
+ assertLocoOk(secondMemberResponse, 'GETMEM');
1124
+ const secondMembers = exactMemberRecords(secondMemberResponse.body.members ?? [], 'GETMEM');
1125
+ if (firstChat.activeMembers !== secondChat.activeMembers ||
1126
+ firstMembers.records.length !== firstChat.activeMembers ||
1127
+ secondMembers.records.length !== secondChat.activeMembers ||
1128
+ !sameIds(firstChat.ids, secondChat.ids) ||
1129
+ !sameIds(firstMembers.ids, secondMembers.ids) ||
1130
+ !subsetIds(secondChat.ids, secondMembers.ids)) {
1131
+ throw new Error('member_snapshot_changed_or_incomplete');
814
1132
  }
815
- catch {
816
- fallbackMembers = [];
817
- }
818
- return mergeMemberRecords(members, fallbackMembers).map(formatMember);
1133
+ return {
1134
+ chat_id: normalizedChatId,
1135
+ active_members: secondChat.activeMembers,
1136
+ members: mergeMemberRecords(secondMembers.records, secondChat.records).map(formatMember),
1137
+ complete: true,
1138
+ consistency_basis: 'stable_double_read_chatinfo_getmem',
1139
+ };
819
1140
  }
820
1141
  catch (error) {
821
- throw wrapError(error, 'get_members_failed');
1142
+ throw wrapError(error, 'get_member_snapshot_failed');
822
1143
  }
823
1144
  });
824
1145
  }
1146
+ async getMembers(chatId) {
1147
+ try {
1148
+ return (await this.getMemberSnapshot(chatId)).members;
1149
+ }
1150
+ catch (error) {
1151
+ if (error instanceof KakaoTalkError && error.code === 'get_member_snapshot_failed') {
1152
+ throw new KakaoTalkError(error.message, 'get_members_failed', {
1153
+ cause: error,
1154
+ serverStatus: error.serverStatus,
1155
+ });
1156
+ }
1157
+ throw error;
1158
+ }
1159
+ }
825
1160
  async getMembersByIds(chatId, userIds) {
826
1161
  if (userIds.length === 0)
827
1162
  return [];