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
@@ -47,6 +47,23 @@ function makeLong(n: number): { low: number; high: number } {
47
47
  return { low: n, high: 0 }
48
48
  }
49
49
 
50
+ function memberChannelInfo(
51
+ activeMembersCount: number,
52
+ displayMembers: Array<Record<string, unknown>> = [],
53
+ chatId = 100,
54
+ ) {
55
+ return {
56
+ statusCode: 0,
57
+ body: {
58
+ chatInfo: {
59
+ chatId: makeLong(chatId),
60
+ activeMembersCount,
61
+ displayMembers,
62
+ },
63
+ },
64
+ }
65
+ }
66
+
50
67
  function resetAllMocks() {
51
68
  mockLogin.mockReset()
52
69
  mockGetChatList.mockReset()
@@ -111,6 +128,7 @@ describe('KakaoTalkClient', () => {
111
128
  })
112
129
 
113
130
  afterEach(() => {
131
+ expect(mockGetChatInfo).not.toHaveBeenCalled()
114
132
  resetAllMocks()
115
133
  })
116
134
 
@@ -224,6 +242,42 @@ describe('KakaoTalkClient', () => {
224
242
  client.close()
225
243
  })
226
244
 
245
+ it('preserves an unknown non-empty string type from the login snapshot', async () => {
246
+ mockLogin.mockResolvedValue({
247
+ ...DEFAULT_LOGIN_RESULT,
248
+ chatDatas: [{ ...DEFAULT_LOGIN_RESULT.chatDatas[0], t: 'FutureChat' }],
249
+ })
250
+
251
+ const client = await new KakaoTalkClient().login({
252
+ oauthToken: 'token',
253
+ userId: 'user1',
254
+ deviceUuid: 'device1',
255
+ })
256
+
257
+ await expect(client.getChats()).resolves.toMatchObject([{ type: 'FutureChat' }])
258
+
259
+ client.close()
260
+ })
261
+
262
+ for (const invalidType of ['', true] as const) {
263
+ it(`rejects invalid ${JSON.stringify(invalidType)} type from the login snapshot`, async () => {
264
+ mockLogin.mockResolvedValue({
265
+ ...DEFAULT_LOGIN_RESULT,
266
+ chatDatas: [{ ...DEFAULT_LOGIN_RESULT.chatDatas[0], t: invalidType }],
267
+ })
268
+
269
+ const client = await new KakaoTalkClient().login({
270
+ oauthToken: 'token',
271
+ userId: 'user1',
272
+ deviceUuid: 'device1',
273
+ })
274
+
275
+ await expect(client.getChats()).rejects.toMatchObject({ code: 'get_chats_failed' })
276
+
277
+ client.close()
278
+ })
279
+ }
280
+
227
281
  it('populates last_message.author_name from paired chat.i / chat.k arrays', async () => {
228
282
  const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
229
283
  const chats = await client.getChats()
@@ -482,7 +536,9 @@ describe('KakaoTalkClient', () => {
482
536
  mockLogin.mockResolvedValue(emptyLoginResult)
483
537
 
484
538
  mockGetChatList.mockResolvedValueOnce({
539
+ statusCode: 0,
485
540
  body: {
541
+ status: 0,
486
542
  chatDatas: [
487
543
  {
488
544
  c: 100,
@@ -541,7 +597,9 @@ describe('KakaoTalkClient', () => {
541
597
  mockLogin.mockResolvedValue(loginResult)
542
598
 
543
599
  mockGetChatList.mockResolvedValueOnce({
600
+ statusCode: 0,
544
601
  body: {
602
+ status: 0,
545
603
  chatDatas: [
546
604
  {
547
605
  c: 300,
@@ -578,7 +636,9 @@ describe('KakaoTalkClient', () => {
578
636
 
579
637
  // Return a chat with same ID as login result
580
638
  mockGetChatList.mockResolvedValueOnce({
639
+ statusCode: 0,
581
640
  body: {
641
+ status: 0,
582
642
  chatDatas: [
583
643
  {
584
644
  c: 100, // Same as first login chat
@@ -612,7 +672,9 @@ describe('KakaoTalkClient', () => {
612
672
  mockLogin.mockResolvedValue(loginResult)
613
673
 
614
674
  mockGetChatList.mockResolvedValueOnce({
675
+ statusCode: 0,
615
676
  body: {
677
+ status: 0,
616
678
  chatDatas: [
617
679
  {
618
680
  c: makeLong(300),
@@ -644,6 +706,148 @@ describe('KakaoTalkClient', () => {
644
706
  client.close()
645
707
  })
646
708
 
709
+ it('collects every LCHATLIST page until an explicit EOF', async () => {
710
+ mockLogin.mockResolvedValue({ ...DEFAULT_LOGIN_RESULT, eof: false })
711
+ mockGetChatList
712
+ .mockResolvedValueOnce({
713
+ statusCode: 0,
714
+ body: {
715
+ status: 0,
716
+ chatDatas: [
717
+ {
718
+ c: 300,
719
+ t: 1,
720
+ k: ['Dave'],
721
+ i: [4],
722
+ a: 1,
723
+ n: 0,
724
+ o: 1698000000,
725
+ l: null,
726
+ ll: makeLong(100),
727
+ },
728
+ ],
729
+ lastTokenId: makeLong(1),
730
+ lastChatId: makeLong(300),
731
+ eof: false,
732
+ },
733
+ })
734
+ .mockResolvedValueOnce({
735
+ statusCode: 0,
736
+ body: {
737
+ status: 0,
738
+ chatDatas: [
739
+ {
740
+ c: 400,
741
+ t: 1,
742
+ k: ['Erin'],
743
+ i: [5],
744
+ a: 1,
745
+ n: 0,
746
+ o: 1697000000,
747
+ l: null,
748
+ ll: makeLong(90),
749
+ },
750
+ ],
751
+ lastTokenId: makeLong(2),
752
+ lastChatId: makeLong(400),
753
+ eof: true,
754
+ },
755
+ })
756
+
757
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
758
+ const chats = await client.getChats({ all: true })
759
+
760
+ expect(chats.map((chat) => chat.chat_id)).toEqual(['100', '200', '300', '400'])
761
+ expect(mockGetChatList).toHaveBeenCalledTimes(2)
762
+
763
+ client.close()
764
+ })
765
+
766
+ it('fails closed when LCHATLIST has a nonzero response status', async () => {
767
+ mockLogin.mockResolvedValue({ ...DEFAULT_LOGIN_RESULT, eof: false })
768
+ mockGetChatList.mockResolvedValueOnce({
769
+ statusCode: 503,
770
+ body: { status: 0, chatDatas: [], lastTokenId: makeLong(1), lastChatId: makeLong(300), eof: true },
771
+ })
772
+
773
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
774
+
775
+ await expect(client.getChats({ all: true })).rejects.toMatchObject({
776
+ code: 'get_chats_failed',
777
+ responseFailureKind: 'provider_rejection',
778
+ serverStatus: 503,
779
+ })
780
+
781
+ client.close()
782
+ })
783
+
784
+ it('fails closed on an empty non-EOF LCHATLIST page', async () => {
785
+ mockLogin.mockResolvedValue({ ...DEFAULT_LOGIN_RESULT, eof: false })
786
+ mockGetChatList.mockResolvedValueOnce({
787
+ statusCode: 0,
788
+ body: { status: 0, chatDatas: [], lastTokenId: makeLong(1), lastChatId: makeLong(300), eof: false },
789
+ })
790
+
791
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
792
+
793
+ await expect(client.getChats({ all: true })).rejects.toMatchObject({ code: 'get_chats_failed' })
794
+ expect(mockGetChatList).toHaveBeenCalledTimes(1)
795
+
796
+ client.close()
797
+ })
798
+
799
+ it('fails closed when LCHATLIST reaches the page cap without EOF', async () => {
800
+ mockLogin.mockResolvedValue({ ...DEFAULT_LOGIN_RESULT, eof: false })
801
+ let page = 0
802
+ mockGetChatList.mockImplementation(async () => {
803
+ page++
804
+ return {
805
+ statusCode: 0,
806
+ body: {
807
+ status: 0,
808
+ chatDatas: [
809
+ {
810
+ c: 1000 + page,
811
+ t: 1,
812
+ k: [`Room ${page}`],
813
+ i: [1000 + page],
814
+ a: 1,
815
+ n: 0,
816
+ o: 1600000000 - page,
817
+ l: null,
818
+ ll: makeLong(page),
819
+ },
820
+ ],
821
+ lastTokenId: makeLong(page),
822
+ lastChatId: makeLong(1000 + page),
823
+ eof: false,
824
+ },
825
+ }
826
+ })
827
+
828
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
829
+
830
+ await expect(client.getChats({ all: true })).rejects.toMatchObject({ code: 'get_chats_failed' })
831
+ expect(mockGetChatList).toHaveBeenCalledTimes(50)
832
+
833
+ client.close()
834
+ })
835
+
836
+ it('preserves non-all search compatibility on an empty non-EOF page', async () => {
837
+ mockLogin.mockResolvedValue({ ...DEFAULT_LOGIN_RESULT, eof: false })
838
+ mockGetChatList.mockResolvedValueOnce({
839
+ body: { chatDatas: [], lastTokenId: makeLong(1), lastChatId: makeLong(300), eof: false },
840
+ })
841
+
842
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
843
+ const chats = await client.getChats({ search: 'Alice' })
844
+
845
+ expect(chats.map((chat) => chat.chat_id)).toEqual(['100'])
846
+ expect(mockGetChatList).toHaveBeenCalledTimes(1)
847
+
848
+ client.close()
849
+ })
850
+
647
851
  it('wraps errors as KakaoTalkError', async () => {
648
852
  mockLogin.mockRejectedValue(new Error('Connection refused'))
649
853
 
@@ -769,96 +973,836 @@ describe('KakaoTalkClient', () => {
769
973
  })
770
974
 
771
975
  const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
772
- const title = await client.getChatTitle('500')
976
+ const title = await client.getChatTitle('500')
977
+
978
+ expect(title).toBe('User Set Title')
979
+ expect(mockGetOpenLinkInfo).not.toHaveBeenCalled()
980
+
981
+ client.close()
982
+ })
983
+
984
+ it('returns null when INFOLINK fails for open chats', async () => {
985
+ const openChatLogin = {
986
+ chatDatas: [
987
+ {
988
+ c: 500,
989
+ t: 'OD',
990
+ li: makeLong(7777),
991
+ k: [],
992
+ i: [],
993
+ a: 1,
994
+ n: 0,
995
+ o: 1700000000,
996
+ l: null,
997
+ ll: makeLong(1),
998
+ },
999
+ ],
1000
+ lastTokenId: makeLong(0),
1001
+ lastChatId: makeLong(0),
1002
+ eof: true,
1003
+ }
1004
+ mockLogin.mockResolvedValue(openChatLogin)
1005
+ mockGetChannelInfo.mockResolvedValueOnce({ body: { chatInfo: { chatMetas: [] } } })
1006
+ mockGetOpenLinkInfo.mockRejectedValueOnce(new Error('INFOLINK timeout'))
1007
+
1008
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1009
+ const title = await client.getChatTitle('500')
1010
+
1011
+ expect(title).toBeNull()
1012
+
1013
+ client.close()
1014
+ })
1015
+
1016
+ it('returns null when open chat has no link id (li missing)', async () => {
1017
+ const openChatLogin = {
1018
+ chatDatas: [
1019
+ {
1020
+ c: 500,
1021
+ t: 'OM',
1022
+ k: [],
1023
+ i: [],
1024
+ a: 1,
1025
+ n: 0,
1026
+ o: 1700000000,
1027
+ l: null,
1028
+ ll: makeLong(1),
1029
+ },
1030
+ ],
1031
+ lastTokenId: makeLong(0),
1032
+ lastChatId: makeLong(0),
1033
+ eof: true,
1034
+ }
1035
+ mockLogin.mockResolvedValue(openChatLogin)
1036
+ mockGetChannelInfo.mockResolvedValueOnce({ body: { chatInfo: { chatMetas: [] } } })
1037
+
1038
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1039
+ const title = await client.getChatTitle('500')
1040
+
1041
+ expect(title).toBeNull()
1042
+ expect(mockGetOpenLinkInfo).not.toHaveBeenCalled()
1043
+
1044
+ client.close()
1045
+ })
1046
+
1047
+ it('wraps getChatList failure as KakaoTalkError with code get_chats_failed', async () => {
1048
+ const loginResult = { ...DEFAULT_LOGIN_RESULT, eof: false }
1049
+ mockLogin.mockResolvedValue(loginResult)
1050
+ mockGetChatList.mockRejectedValue(new Error('Network error'))
1051
+
1052
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1053
+ try {
1054
+ await client.getChats({ all: true })
1055
+ expect.unreachable('should have thrown')
1056
+ } catch (e) {
1057
+ expect(e).toBeInstanceOf(KakaoTalkError)
1058
+ expect((e as KakaoTalkError).code).toBe('get_chats_failed')
1059
+ }
1060
+
1061
+ client.close()
1062
+ })
1063
+ })
1064
+
1065
+ describe('getChat', () => {
1066
+ it('returns one fully normalized late chat from read-only CHATINFO', async () => {
1067
+ mockGetChannelInfo.mockResolvedValueOnce({
1068
+ statusCode: 0,
1069
+ body: {
1070
+ chatInfo: {
1071
+ chatId: makeLong(300),
1072
+ type: 11,
1073
+ left: false,
1074
+ activeMembersCount: 2,
1075
+ newMessageCount: 4,
1076
+ invalidNewMessageCount: false,
1077
+ lastLogId: makeLong(77),
1078
+ lastSeenLogId: makeLong(73),
1079
+ lastChatLog: {
1080
+ logId: makeLong(77),
1081
+ authorId: 42,
1082
+ type: 1,
1083
+ message: 'latest',
1084
+ sendAt: 1700000077,
1085
+ },
1086
+ displayMembers: [
1087
+ { userId: makeLong(42), nickName: 'Alice' },
1088
+ { userId: makeLong(43), nickName: 'Bob' },
1089
+ ],
1090
+ chatMetas: [{ type: 3, content: 'Late room' }],
1091
+ pushAlert: true,
1092
+ },
1093
+ },
1094
+ })
1095
+
1096
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1097
+ const chat = await client.getChat('300')
1098
+
1099
+ expect(chat).toEqual({
1100
+ chat_id: '300',
1101
+ type: 11,
1102
+ display_name: 'Alice, Bob',
1103
+ title: 'Late room',
1104
+ active_members: 2,
1105
+ unread_count: 4,
1106
+ last_message: {
1107
+ author_id: 42,
1108
+ author_name: 'Alice',
1109
+ message: 'latest',
1110
+ sent_at: 1700000077,
1111
+ },
1112
+ })
1113
+
1114
+ client.close()
1115
+ })
1116
+
1117
+ it('classifies an explicit CHATINFO header rejection without exposing response contents', async () => {
1118
+ mockGetChannelInfo.mockResolvedValueOnce({ statusCode: 403, body: {} })
1119
+
1120
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1121
+
1122
+ await expect(client.getChat('300')).rejects.toMatchObject({
1123
+ code: 'get_chat_failed',
1124
+ getChatFailureReason: 'provider_rejection',
1125
+ responseFailureKind: 'provider_rejection',
1126
+ responseStatusSource: 'packet',
1127
+ serverStatus: 403,
1128
+ })
1129
+
1130
+ client.close()
1131
+ })
1132
+
1133
+ it('classifies an explicit CHATINFO body rejection and preserves its numeric status', async () => {
1134
+ mockGetChannelInfo.mockResolvedValueOnce({ statusCode: 0, body: { status: -404 } })
1135
+
1136
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1137
+
1138
+ await expect(client.getChat('300')).rejects.toMatchObject({
1139
+ code: 'get_chat_failed',
1140
+ getChatFailureReason: 'provider_rejection',
1141
+ responseFailureKind: 'provider_rejection',
1142
+ responseStatusSource: 'body',
1143
+ serverStatus: -404,
1144
+ })
1145
+
1146
+ client.close()
1147
+ })
1148
+
1149
+ it('does not expose rejected CHATINFO body contents through public error metadata', async () => {
1150
+ const secretSentinel = 'secret-sentinel-do-not-emit'
1151
+ mockGetChannelInfo.mockResolvedValueOnce({
1152
+ statusCode: 0,
1153
+ body: { status: -404, secret: secretSentinel },
1154
+ })
1155
+
1156
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1157
+ let error: unknown
1158
+ try {
1159
+ await client.getChat('300')
1160
+ } catch (cause) {
1161
+ error = cause
1162
+ }
1163
+
1164
+ expect(error).toBeInstanceOf(KakaoTalkError)
1165
+ const publicError = error as KakaoTalkError
1166
+ expect(publicError).toMatchObject({
1167
+ code: 'get_chat_failed',
1168
+ getChatFailureReason: 'provider_rejection',
1169
+ responseFailureKind: 'provider_rejection',
1170
+ responseStatusSource: 'body',
1171
+ serverStatus: -404,
1172
+ })
1173
+ expect(publicError.message).not.toContain(secretSentinel)
1174
+ expect(JSON.stringify(publicError)).not.toContain(secretSentinel)
1175
+ expect(JSON.stringify(publicError.cause)).not.toContain(secretSentinel)
1176
+
1177
+ client.close()
1178
+ })
1179
+
1180
+ it('classifies synthetic connection-close status -1 as transient rather than provider rejection', async () => {
1181
+ mockGetChannelInfo.mockResolvedValueOnce({ statusCode: -1, body: { error: 'connection closed' } })
1182
+
1183
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1184
+
1185
+ await expect(client.getChat('300')).rejects.toMatchObject({
1186
+ code: 'get_chat_failed',
1187
+ getChatFailureReason: 'synthetic_connection_close',
1188
+ responseFailureKind: 'transient_or_unknown',
1189
+ responseStatusSource: 'packet',
1190
+ serverStatus: -1,
1191
+ })
1192
+
1193
+ client.close()
1194
+ })
1195
+
1196
+ it('does not classify status -1 with a nonmatching body as a synthetic connection close', async () => {
1197
+ mockGetChannelInfo.mockResolvedValueOnce({ statusCode: -1, body: { error: 'provider rejected' } })
1198
+
1199
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1200
+
1201
+ await expect(client.getChat('300')).rejects.toMatchObject({
1202
+ code: 'get_chat_failed',
1203
+ getChatFailureReason: 'provider_rejection',
1204
+ responseFailureKind: 'provider_rejection',
1205
+ responseStatusSource: 'packet',
1206
+ serverStatus: -1,
1207
+ })
1208
+
1209
+ client.close()
1210
+ })
1211
+
1212
+ it('classifies a thrown CHATINFO transport failure as transient or unknown', async () => {
1213
+ const secretSentinel = 'secret-transport-body-do-not-emit'
1214
+ mockGetChannelInfo.mockRejectedValueOnce(
1215
+ Object.assign(new Error('socket unavailable'), { body: { secret: secretSentinel } }),
1216
+ )
1217
+
1218
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1219
+
1220
+ let error: unknown
1221
+ try {
1222
+ await client.getChat('300')
1223
+ } catch (cause) {
1224
+ error = cause
1225
+ }
1226
+
1227
+ expect(error).toBeInstanceOf(KakaoTalkError)
1228
+ expect(error).toMatchObject({
1229
+ code: 'get_chat_failed',
1230
+ getChatFailureReason: 'transport_or_unknown',
1231
+ responseFailureKind: 'transient_or_unknown',
1232
+ })
1233
+ const serialized = JSON.stringify(error)
1234
+ expect(serialized).not.toContain(secretSentinel)
1235
+ expect(serialized).not.toContain('body')
1236
+ expect(serialized).not.toContain('message')
1237
+ expect(serialized).not.toContain('cause')
1238
+
1239
+ client.close()
1240
+ })
1241
+
1242
+ it('classifies a successful CHATINFO response without chatInfo as structurally absent', async () => {
1243
+ const secretSentinel = 'secret-chat-body-do-not-emit'
1244
+ mockGetChannelInfo.mockResolvedValueOnce({ statusCode: 0, body: { secret: secretSentinel } })
1245
+
1246
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1247
+
1248
+ let error: unknown
1249
+ try {
1250
+ await client.getChat('300')
1251
+ } catch (cause) {
1252
+ error = cause
1253
+ }
1254
+
1255
+ expect(error).toBeInstanceOf(KakaoTalkError)
1256
+ expect(error).toMatchObject({
1257
+ code: 'get_chat_failed',
1258
+ getChatFailureReason: 'chat_info_absent',
1259
+ responseFailureKind: 'transient_or_unknown',
1260
+ })
1261
+ const serialized = JSON.stringify(error)
1262
+ expect(serialized).not.toContain(secretSentinel)
1263
+ expect(serialized).not.toContain('body')
1264
+ expect(serialized).not.toContain('message')
1265
+ expect(serialized).not.toContain('cause')
1266
+
1267
+ client.close()
1268
+ })
1269
+
1270
+ it('classifies a successful CHATINFO response with null chatInfo as structurally absent', async () => {
1271
+ const secretSentinel = 'secret-null-chat-body-do-not-emit'
1272
+ const chatIdSentinel = '987654321012345678'
1273
+ mockGetChannelInfo.mockResolvedValueOnce({
1274
+ statusCode: 0,
1275
+ body: { chatInfo: null, secret: secretSentinel },
1276
+ })
1277
+
1278
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1279
+
1280
+ let error: unknown
1281
+ try {
1282
+ await client.getChat(chatIdSentinel)
1283
+ } catch (cause) {
1284
+ error = cause
1285
+ }
1286
+
1287
+ expect(error).toBeInstanceOf(KakaoTalkError)
1288
+ expect(error).toMatchObject({
1289
+ code: 'get_chat_failed',
1290
+ getChatFailureReason: 'chat_info_absent',
1291
+ responseFailureKind: 'transient_or_unknown',
1292
+ })
1293
+ const serialized = JSON.stringify(error)
1294
+ expect(serialized).not.toContain(secretSentinel)
1295
+ expect(serialized).not.toContain('body')
1296
+ expect(serialized).not.toContain('message')
1297
+ expect(serialized).not.toContain('cause')
1298
+ expect(serialized).not.toContain('chatId')
1299
+ expect(serialized).not.toContain(chatIdSentinel)
1300
+
1301
+ client.close()
1302
+ })
1303
+
1304
+ it('classifies exact CHATINFO left=true as structurally absent without exposing response contents', async () => {
1305
+ const secretSentinel = 'secret-left-chat-body-do-not-emit'
1306
+ const nativeChatIdSentinel = '987654321'
1307
+ mockGetChannelInfo.mockResolvedValueOnce({
1308
+ statusCode: 0,
1309
+ body: {
1310
+ chatInfo: {
1311
+ left: true,
1312
+ chatId: makeLong(Number(nativeChatIdSentinel)),
1313
+ secret: secretSentinel,
1314
+ },
1315
+ },
1316
+ })
1317
+
1318
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1319
+
1320
+ let error: unknown
1321
+ try {
1322
+ await client.getChat(nativeChatIdSentinel)
1323
+ } catch (cause) {
1324
+ error = cause
1325
+ }
1326
+
1327
+ expect(error).toBeInstanceOf(KakaoTalkError)
1328
+ expect(error).toMatchObject({
1329
+ code: 'get_chat_failed',
1330
+ getChatFailureReason: 'chat_info_absent',
1331
+ responseFailureKind: 'transient_or_unknown',
1332
+ })
1333
+ const publicError = error as KakaoTalkError
1334
+ const serialized = JSON.stringify(publicError)
1335
+ expect(publicError.message).not.toContain(secretSentinel)
1336
+ expect(publicError.message).not.toContain(nativeChatIdSentinel)
1337
+ expect(serialized).not.toContain(secretSentinel)
1338
+ expect(serialized).not.toContain(nativeChatIdSentinel)
1339
+
1340
+ client.close()
1341
+ })
1342
+
1343
+ for (const [label, chatInfo] of [
1344
+ ['mismatched chatId', { left: true, chatId: makeLong(301) }],
1345
+ ['missing chatId', { left: true }],
1346
+ ['invalid chatId', { left: true, chatId: { invalid: true } }],
1347
+ ] as const) {
1348
+ it(`does not classify CHATINFO left=true with ${label} as structurally absent`, async () => {
1349
+ mockGetChannelInfo.mockResolvedValueOnce({
1350
+ statusCode: 0,
1351
+ body: { chatInfo },
1352
+ })
1353
+
1354
+ const client = await new KakaoTalkClient().login({
1355
+ oauthToken: 'token',
1356
+ userId: 'user1',
1357
+ deviceUuid: 'device1',
1358
+ })
1359
+
1360
+ await expect(client.getChat('300')).rejects.toMatchObject({
1361
+ code: 'get_chat_failed',
1362
+ getChatFailureReason: 'transport_or_unknown',
1363
+ responseFailureKind: 'transient_or_unknown',
1364
+ })
1365
+
1366
+ client.close()
1367
+ })
1368
+ }
1369
+
1370
+ for (const [label, chatInfo] of [
1371
+ ['left=false', { left: false }],
1372
+ ['left missing', {}],
1373
+ ['left nonboolean', { left: 'true' }],
1374
+ ] as const) {
1375
+ it(`does not classify malformed successful CHATINFO with ${label} as structurally absent`, async () => {
1376
+ mockGetChannelInfo.mockResolvedValueOnce({
1377
+ statusCode: 0,
1378
+ body: { chatInfo },
1379
+ })
1380
+
1381
+ const client = await new KakaoTalkClient().login({
1382
+ oauthToken: 'token',
1383
+ userId: 'user1',
1384
+ deviceUuid: 'device1',
1385
+ })
1386
+
1387
+ await expect(client.getChat('300')).rejects.toMatchObject({
1388
+ code: 'get_chat_failed',
1389
+ getChatFailureReason: 'transport_or_unknown',
1390
+ responseFailureKind: 'transient_or_unknown',
1391
+ })
1392
+
1393
+ client.close()
1394
+ })
1395
+ }
1396
+
1397
+ it('classifies malformed successful CHATINFO chatInfo as transport or unknown', async () => {
1398
+ mockGetChannelInfo.mockResolvedValueOnce({
1399
+ statusCode: 0,
1400
+ body: { chatInfo: { chatId: makeLong(300) } },
1401
+ })
1402
+
1403
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1404
+
1405
+ await expect(client.getChat('300')).rejects.toMatchObject({
1406
+ code: 'get_chat_failed',
1407
+ getChatFailureReason: 'transport_or_unknown',
1408
+ responseFailureKind: 'transient_or_unknown',
1409
+ })
1410
+
1411
+ client.close()
1412
+ })
1413
+
1414
+ for (const openChatType of ['OM', 'OD'] as const) {
1415
+ it(`preserves ${openChatType} and falls back to INFOLINK when CHATINFO has no title`, async () => {
1416
+ mockGetChannelInfo.mockResolvedValueOnce({
1417
+ statusCode: 0,
1418
+ body: {
1419
+ chatInfo: {
1420
+ chatId: makeLong(300),
1421
+ type: openChatType,
1422
+ activeMembersCount: 2,
1423
+ newMessageCount: 0,
1424
+ invalidNewMessageCount: false,
1425
+ lastLogId: makeLong(77),
1426
+ lastSeenLogId: makeLong(73),
1427
+ lastChatLog: null,
1428
+ displayMembers: [
1429
+ { userId: makeLong(42), nickName: 'Alice' },
1430
+ { userId: makeLong(43), nickName: 'Bob' },
1431
+ ],
1432
+ chatMetas: [],
1433
+ li: makeLong(7777),
1434
+ pushAlert: true,
1435
+ },
1436
+ },
1437
+ })
1438
+ mockGetOpenLinkInfo.mockResolvedValueOnce({ body: { ols: [{ ln: 'Open Group Title' }] } })
1439
+
1440
+ const client = await new KakaoTalkClient().login({
1441
+ oauthToken: 'token',
1442
+ userId: 'user1',
1443
+ deviceUuid: 'device1',
1444
+ })
1445
+ const chat = await client.getChat('300')
1446
+
1447
+ expect(chat).toEqual({
1448
+ chat_id: '300',
1449
+ type: openChatType,
1450
+ display_name: 'Alice, Bob',
1451
+ title: 'Open Group Title',
1452
+ active_members: 2,
1453
+ unread_count: 0,
1454
+ last_message: null,
1455
+ })
1456
+ expect(mockGetOpenLinkInfo).toHaveBeenCalledTimes(1)
1457
+
1458
+ client.close()
1459
+ })
1460
+ }
1461
+
1462
+ it('falls back to INFOLINK for a numeric open-chat code', async () => {
1463
+ mockGetChannelInfo.mockResolvedValueOnce({
1464
+ statusCode: 0,
1465
+ body: {
1466
+ chatInfo: {
1467
+ chatId: makeLong(300),
1468
+ type: 13,
1469
+ activeMembersCount: 2,
1470
+ newMessageCount: 0,
1471
+ invalidNewMessageCount: false,
1472
+ lastLogId: makeLong(77),
1473
+ lastSeenLogId: makeLong(73),
1474
+ lastChatLog: null,
1475
+ displayMembers: [],
1476
+ chatMetas: [],
1477
+ li: makeLong(7777),
1478
+ pushAlert: true,
1479
+ },
1480
+ },
1481
+ })
1482
+ mockGetOpenLinkInfo.mockResolvedValueOnce({ body: { ols: [{ ln: 'Numeric Open Title' }] } })
1483
+
1484
+ const client = await new KakaoTalkClient().login({
1485
+ oauthToken: 'token',
1486
+ userId: 'user1',
1487
+ deviceUuid: 'device1',
1488
+ })
1489
+ const chat = await client.getChat('300')
1490
+
1491
+ expect(chat.type).toBe(13)
1492
+ expect(chat.title).toBe('Numeric Open Title')
1493
+ expect(mockGetOpenLinkInfo).toHaveBeenCalledTimes(1)
1494
+
1495
+ client.close()
1496
+ })
1497
+
1498
+ for (const normalChatType of ['DirectChat', 'MultiChat', 'PlusChat', 'MemoChat'] as const) {
1499
+ it(`preserves donor-defined ${normalChatType} without contacting INFOLINK`, async () => {
1500
+ mockGetChannelInfo.mockResolvedValueOnce({
1501
+ statusCode: 0,
1502
+ body: {
1503
+ chatInfo: {
1504
+ chatId: makeLong(300),
1505
+ type: normalChatType,
1506
+ activeMembersCount: 2,
1507
+ newMessageCount: 0,
1508
+ invalidNewMessageCount: false,
1509
+ lastLogId: makeLong(1),
1510
+ lastSeenLogId: makeLong(0),
1511
+ displayMembers: [],
1512
+ chatMetas: [],
1513
+ li: makeLong(7777),
1514
+ pushAlert: true,
1515
+ },
1516
+ },
1517
+ })
1518
+
1519
+ const client = await new KakaoTalkClient().login({
1520
+ oauthToken: 'token',
1521
+ userId: 'user1',
1522
+ deviceUuid: 'device1',
1523
+ })
1524
+
1525
+ await expect(client.getChat('300')).resolves.toEqual({
1526
+ chat_id: '300',
1527
+ type: normalChatType,
1528
+ display_name: null,
1529
+ title: null,
1530
+ active_members: 2,
1531
+ unread_count: 0,
1532
+ last_message: null,
1533
+ })
1534
+ expect(mockGetOpenLinkInfo).not.toHaveBeenCalled()
1535
+
1536
+ client.close()
1537
+ })
1538
+ }
1539
+
1540
+ it('rejects an empty string CHATINFO type without contacting INFOLINK', async () => {
1541
+ mockGetChannelInfo.mockResolvedValueOnce({
1542
+ statusCode: 0,
1543
+ body: {
1544
+ chatInfo: {
1545
+ chatId: makeLong(300),
1546
+ type: '',
1547
+ activeMembersCount: 2,
1548
+ newMessageCount: 0,
1549
+ invalidNewMessageCount: false,
1550
+ lastLogId: makeLong(1),
1551
+ lastSeenLogId: makeLong(0),
1552
+ displayMembers: [],
1553
+ chatMetas: [],
1554
+ pushAlert: true,
1555
+ },
1556
+ },
1557
+ })
1558
+
1559
+ const client = await new KakaoTalkClient().login({
1560
+ oauthToken: 'token',
1561
+ userId: 'user1',
1562
+ deviceUuid: 'device1',
1563
+ })
1564
+
1565
+ await expect(client.getChat('300')).rejects.toMatchObject({
1566
+ code: 'get_chat_failed',
1567
+ getChatFailureReason: 'transport_or_unknown',
1568
+ responseFailureKind: 'transient_or_unknown',
1569
+ })
1570
+ expect(mockGetOpenLinkInfo).not.toHaveBeenCalled()
1571
+
1572
+ client.close()
1573
+ })
1574
+
1575
+ it('preserves an unknown non-empty string CHATINFO type without contacting INFOLINK', async () => {
1576
+ mockGetChannelInfo.mockResolvedValueOnce({
1577
+ statusCode: 0,
1578
+ body: {
1579
+ chatInfo: {
1580
+ chatId: makeLong(300),
1581
+ type: 'UnknownChat',
1582
+ activeMembersCount: 2,
1583
+ newMessageCount: 0,
1584
+ invalidNewMessageCount: false,
1585
+ lastLogId: makeLong(1),
1586
+ lastSeenLogId: makeLong(0),
1587
+ displayMembers: [],
1588
+ chatMetas: [],
1589
+ pushAlert: true,
1590
+ },
1591
+ },
1592
+ })
1593
+
1594
+ const client = await new KakaoTalkClient().login({
1595
+ oauthToken: 'token',
1596
+ userId: 'user1',
1597
+ deviceUuid: 'device1',
1598
+ })
1599
+
1600
+ await expect(client.getChat('300')).resolves.toEqual({
1601
+ chat_id: '300',
1602
+ type: 'UnknownChat',
1603
+ display_name: null,
1604
+ title: null,
1605
+ active_members: 2,
1606
+ unread_count: 0,
1607
+ last_message: null,
1608
+ })
1609
+ expect(mockGetOpenLinkInfo).not.toHaveBeenCalled()
1610
+
1611
+ client.close()
1612
+ })
1613
+
1614
+ it('rejects a CHATINFO response bound to a different chat id', async () => {
1615
+ mockGetChannelInfo.mockResolvedValueOnce({
1616
+ statusCode: 0,
1617
+ body: {
1618
+ chatInfo: {
1619
+ chatId: makeLong(999),
1620
+ type: 11,
1621
+ activeMembersCount: 2,
1622
+ newMessageCount: 0,
1623
+ invalidNewMessageCount: false,
1624
+ lastLogId: makeLong(1),
1625
+ lastSeenLogId: makeLong(0),
1626
+ displayMembers: [],
1627
+ chatMetas: [],
1628
+ pushAlert: true,
1629
+ },
1630
+ },
1631
+ })
1632
+
1633
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1634
+
1635
+ await expect(client.getChat('300')).rejects.toMatchObject({ code: 'get_chat_failed' })
1636
+
1637
+ client.close()
1638
+ })
1639
+
1640
+ it('rejects an invalid chat id before opening a LOCO session', async () => {
1641
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1642
+
1643
+ await expect(client.getChat('not-a-number')).rejects.toMatchObject({ code: 'invalid_chat_id' })
1644
+ expect(mockLogin).not.toHaveBeenCalled()
1645
+ expect(mockGetChannelInfo).not.toHaveBeenCalled()
1646
+
1647
+ client.close()
1648
+ })
1649
+ })
1650
+
1651
+ describe('getMessages', () => {
1652
+ it('returns a lossless forward page without discarding the oldest messages', async () => {
1653
+ mockGetChatLogs.mockResolvedValueOnce({
1654
+ statusCode: 0,
1655
+ body: {
1656
+ status: 0,
1657
+ chatLogs: Array.from({ length: 5 }, (_, index) => ({
1658
+ logId: makeLong(index + 1),
1659
+ chatId: 100,
1660
+ type: 1,
1661
+ authorId: 42,
1662
+ message: `message-${index + 1}`,
1663
+ sendAt: 1700000000 + index,
1664
+ })),
1665
+ eof: true,
1666
+ },
1667
+ })
1668
+
1669
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1670
+ const page = await client.getMessagePage('100', { count: 2 })
1671
+
1672
+ expect(page.messages.map((message) => message.log_id)).toEqual(['1', '2'])
1673
+ expect(page.next_cursor).toBe('2')
1674
+ expect(page.complete).toBe(false)
1675
+
1676
+ client.close()
1677
+ })
1678
+
1679
+ it('returns a complete forward page when the protocol page is exhausted', async () => {
1680
+ mockGetChatLogs.mockResolvedValueOnce({
1681
+ statusCode: 0,
1682
+ body: {
1683
+ status: 0,
1684
+ chatLogs: [
1685
+ { logId: makeLong(11), chatId: 100, type: 1, authorId: 42, message: 'eleven', sendAt: 11 },
1686
+ { logId: makeLong(10), chatId: 100, type: 1, authorId: 42, message: 'ten', sendAt: 10 },
1687
+ ],
1688
+ eof: true,
1689
+ },
1690
+ })
1691
+
1692
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1693
+ const page = await client.getMessagePage('100', { from: '9', count: 10 })
773
1694
 
774
- expect(title).toBe('User Set Title')
775
- expect(mockGetOpenLinkInfo).not.toHaveBeenCalled()
1695
+ expect(page.messages.map((message) => message.log_id)).toEqual(['10', '11'])
1696
+ expect(page.next_cursor).toBe('11')
1697
+ expect(page.complete).toBe(true)
776
1698
 
777
1699
  client.close()
778
1700
  })
779
1701
 
780
- it('returns null when INFOLINK fails for open chats', async () => {
781
- const openChatLogin = {
782
- chatDatas: [
783
- {
784
- c: 500,
785
- t: 'OD',
786
- li: makeLong(7777),
787
- k: [],
788
- i: [],
789
- a: 1,
790
- n: 0,
791
- o: 1700000000,
792
- l: null,
793
- ll: makeLong(1),
794
- },
795
- ],
796
- lastTokenId: makeLong(0),
797
- lastChatId: makeLong(0),
798
- eof: true,
799
- }
800
- mockLogin.mockResolvedValue(openChatLogin)
801
- mockGetChannelInfo.mockResolvedValueOnce({ body: { chatInfo: { chatMetas: [] } } })
802
- mockGetOpenLinkInfo.mockRejectedValueOnce(new Error('INFOLINK timeout'))
1702
+ it('continues exclusively after the supplied cursor when the server repeats it', async () => {
1703
+ mockGetChatLogs.mockResolvedValueOnce({
1704
+ statusCode: 0,
1705
+ body: {
1706
+ status: 0,
1707
+ chatLogs: [
1708
+ { logId: makeLong(2), chatId: 100, type: 1, authorId: 42, message: 'repeat', sendAt: 2 },
1709
+ { logId: makeLong(3), chatId: 100, type: 1, authorId: 42, message: 'three', sendAt: 3 },
1710
+ { logId: makeLong(4), chatId: 100, type: 1, authorId: 42, message: 'four', sendAt: 4 },
1711
+ ],
1712
+ eof: true,
1713
+ },
1714
+ })
803
1715
 
804
1716
  const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
805
- const title = await client.getChatTitle('500')
1717
+ const page = await client.getMessagePage('100', { from: '2', count: 10 })
806
1718
 
807
- expect(title).toBeNull()
1719
+ expect(page.messages.map((message) => message.log_id)).toEqual(['3', '4'])
1720
+ expect(page.next_cursor).toBe('4')
1721
+ expect(page.complete).toBe(true)
808
1722
 
809
1723
  client.close()
810
1724
  })
811
1725
 
812
- it('returns null when open chat has no link id (li missing)', async () => {
813
- const openChatLogin = {
814
- chatDatas: [
815
- {
816
- c: 500,
817
- t: 'OM',
818
- k: [],
819
- i: [],
820
- a: 1,
821
- n: 0,
822
- o: 1700000000,
823
- l: null,
824
- ll: makeLong(1),
1726
+ it('does not report completion when the protocol makes no cursor progress', async () => {
1727
+ mockGetChatLogs.mockResolvedValueOnce({
1728
+ statusCode: 0,
1729
+ body: { status: 0, chatLogs: [], eof: false },
1730
+ })
1731
+ mockGetChannelInfo.mockResolvedValueOnce({
1732
+ statusCode: 0,
1733
+ body: {
1734
+ chatInfo: {
1735
+ chatId: makeLong(100),
1736
+ type: 11,
1737
+ activeMembersCount: 2,
1738
+ newMessageCount: 0,
1739
+ invalidNewMessageCount: false,
1740
+ lastLogId: makeLong(999),
1741
+ lastSeenLogId: makeLong(0),
1742
+ displayMembers: [],
1743
+ chatMetas: [],
1744
+ pushAlert: true,
825
1745
  },
826
- ],
827
- lastTokenId: makeLong(0),
828
- lastChatId: makeLong(0),
829
- eof: true,
830
- }
831
- mockLogin.mockResolvedValue(openChatLogin)
832
- mockGetChannelInfo.mockResolvedValueOnce({ body: { chatInfo: { chatMetas: [] } } })
1746
+ },
1747
+ })
1748
+ mockSyncMessages.mockResolvedValueOnce({
1749
+ statusCode: 0,
1750
+ body: { status: 0, chatLogs: [], isOK: false },
1751
+ })
833
1752
 
834
1753
  const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
835
- const title = await client.getChatTitle('500')
1754
+ const page = await client.getMessagePage('100', { from: '9', count: 10 })
836
1755
 
837
- expect(title).toBeNull()
838
- expect(mockGetOpenLinkInfo).not.toHaveBeenCalled()
1756
+ expect(page).toEqual({ messages: [], next_cursor: null, complete: false })
839
1757
 
840
1758
  client.close()
841
1759
  })
842
1760
 
843
- it('wraps getChatList failure as KakaoTalkError with code get_chats_failed', async () => {
844
- const loginResult = { ...DEFAULT_LOGIN_RESULT, eof: false }
845
- mockLogin.mockResolvedValue(loginResult)
846
- mockGetChatList.mockRejectedValue(new Error('Network error'))
1761
+ it('uses read-only CHATINFO to paginate a late room missing from the login snapshot', async () => {
1762
+ mockLogin.mockResolvedValueOnce({ ...structuredClone(DEFAULT_LOGIN_RESULT), chatDatas: [] })
1763
+ mockGetChatLogs.mockResolvedValueOnce({
1764
+ statusCode: 0,
1765
+ body: { status: 0, chatLogs: [], eof: true },
1766
+ })
1767
+ mockGetChannelInfo.mockResolvedValueOnce({
1768
+ statusCode: 0,
1769
+ body: {
1770
+ chatInfo: {
1771
+ chatId: makeLong(300),
1772
+ type: 11,
1773
+ activeMembersCount: 2,
1774
+ newMessageCount: 1,
1775
+ invalidNewMessageCount: false,
1776
+ lastLogId: makeLong(12),
1777
+ lastSeenLogId: makeLong(0),
1778
+ displayMembers: [],
1779
+ chatMetas: [],
1780
+ pushAlert: true,
1781
+ },
1782
+ },
1783
+ })
1784
+ mockSyncMessages.mockResolvedValueOnce({
1785
+ statusCode: 0,
1786
+ body: {
1787
+ status: 0,
1788
+ isOK: true,
1789
+ chatLogs: [{ logId: makeLong(12), chatId: 300, type: 1, authorId: 42, message: 'late', sendAt: 12 }],
1790
+ },
1791
+ })
847
1792
 
848
1793
  const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
849
- try {
850
- await client.getChats({ all: true })
851
- expect.unreachable('should have thrown')
852
- } catch (e) {
853
- expect(e).toBeInstanceOf(KakaoTalkError)
854
- expect((e as KakaoTalkError).code).toBe('get_chats_failed')
855
- }
1794
+ const page = await client.getMessagePage('300', { from: '11', count: 10 })
1795
+
1796
+ expect(page.messages.map((message) => message.log_id)).toEqual(['12'])
1797
+ expect(page.next_cursor).toBe('12')
1798
+ expect(page.complete).toBe(true)
1799
+ expect(mockGetChatInfo).not.toHaveBeenCalled()
1800
+ expect(mockSyncMessages.mock.calls[0]?.[2]?.toString()).toBe('11')
1801
+ expect(mockSyncMessages.mock.calls[0]?.[3]?.toString()).toBe('12')
856
1802
 
857
1803
  client.close()
858
1804
  })
859
- })
860
1805
 
861
- describe('getMessages', () => {
862
1806
  it('falls back to SYNCMSG when MCHATLOGS succeeds with an empty result', async () => {
863
1807
  const loginResult = structuredClone(DEFAULT_LOGIN_RESULT)
864
1808
  loginResult.chatDatas[0]!.ll = makeLong(10)
@@ -1610,7 +2554,8 @@ describe('KakaoTalkClient', () => {
1610
2554
 
1611
2555
  describe('getMembers / getMembersByIds', () => {
1612
2556
  it('returns formatted members from GETMEM with normalized fields', async () => {
1613
- mockGetAllMembers.mockResolvedValueOnce({
2557
+ mockGetChannelInfo.mockResolvedValueOnce(memberChannelInfo(2)).mockResolvedValueOnce(memberChannelInfo(2))
2558
+ mockGetAllMembers.mockResolvedValue({
1614
2559
  statusCode: 0,
1615
2560
  body: {
1616
2561
  members: [
@@ -1670,12 +2615,14 @@ describe('KakaoTalkClient', () => {
1670
2615
  open_profile_link_id: '99',
1671
2616
  open_permission: 4,
1672
2617
  })
2618
+ expect(mockGetChatInfo).not.toHaveBeenCalled()
1673
2619
 
1674
2620
  client.close()
1675
2621
  })
1676
2622
 
1677
- it('merges CHATONROOM members when GETMEM returns a partial member list', async () => {
1678
- mockGetAllMembers.mockResolvedValueOnce({
2623
+ it('rejects a partial GETMEM snapshot without entering the room', async () => {
2624
+ mockGetChannelInfo.mockResolvedValueOnce(memberChannelInfo(3)).mockResolvedValueOnce(memberChannelInfo(3))
2625
+ mockGetAllMembers.mockResolvedValue({
1679
2626
  statusCode: 0,
1680
2627
  body: {
1681
2628
  members: [
@@ -1689,43 +2636,223 @@ describe('KakaoTalkClient', () => {
1689
2636
  token: 0,
1690
2637
  },
1691
2638
  })
1692
- mockGetChatInfo.mockResolvedValueOnce({
2639
+
2640
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
2641
+ await expect(client.getMembers('100')).rejects.toMatchObject({
2642
+ code: 'get_members_failed',
2643
+ })
2644
+ expect(mockGetChatInfo).not.toHaveBeenCalled()
2645
+
2646
+ client.close()
2647
+ })
2648
+
2649
+ it('returns a stable empty snapshot for a zero-member room without CHATONROOM', async () => {
2650
+ mockGetChannelInfo.mockResolvedValue(memberChannelInfo(0))
2651
+ mockGetAllMembers.mockResolvedValue({
2652
+ statusCode: 0,
2653
+ body: { members: [] },
2654
+ })
2655
+
2656
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
2657
+ const snapshot = await client.getMemberSnapshot('100')
2658
+
2659
+ expect(snapshot).toMatchObject({
2660
+ chat_id: '100',
2661
+ active_members: 0,
2662
+ complete: true,
2663
+ consistency_basis: 'stable_double_read_chatinfo_getmem',
2664
+ })
2665
+ expect(snapshot.members).toEqual([])
2666
+ expect(mockGetAllMembers).toHaveBeenCalledTimes(2)
2667
+ expect(mockGetChatInfo).not.toHaveBeenCalled()
2668
+
2669
+ client.close()
2670
+ })
2671
+
2672
+ it('returns a stable CHATINFO GETMEM snapshot without CHATONROOM', async () => {
2673
+ const visible = [
2674
+ {
2675
+ userId: makeLong(42),
2676
+ nickName: 'Alice',
2677
+ type: 100,
2678
+ },
2679
+ ]
2680
+ mockGetChannelInfo
2681
+ .mockResolvedValueOnce(memberChannelInfo(2, visible))
2682
+ .mockResolvedValueOnce(memberChannelInfo(2, visible))
2683
+ mockGetAllMembers.mockResolvedValue({
2684
+ statusCode: 0,
2685
+ body: {
2686
+ members: [
2687
+ visible[0],
2688
+ {
2689
+ userId: makeLong(43),
2690
+ nickName: 'Bob',
2691
+ type: 100,
2692
+ },
2693
+ ],
2694
+ },
2695
+ })
2696
+
2697
+ const client = await new KakaoTalkClient().login({
2698
+ oauthToken: 'token',
2699
+ userId: 'user1',
2700
+ deviceUuid: 'device1',
2701
+ })
2702
+ const snapshot = await client.getMemberSnapshot('100')
2703
+
2704
+ expect(snapshot).toMatchObject({
2705
+ chat_id: '100',
2706
+ active_members: 2,
2707
+ complete: true,
2708
+ consistency_basis: 'stable_double_read_chatinfo_getmem',
2709
+ })
2710
+ expect(snapshot.members.map((member) => member.user_id)).toEqual(['42', '43'])
2711
+ expect(mockGetAllMembers).toHaveBeenCalledTimes(2)
2712
+ expect(mockGetChannelInfo).toHaveBeenCalledTimes(2)
2713
+ expect(mockGetChatInfo).not.toHaveBeenCalled()
2714
+
2715
+ client.close()
2716
+ })
2717
+
2718
+ it('fails closed when active membership changes across the snapshot', async () => {
2719
+ mockGetChannelInfo.mockResolvedValueOnce(memberChannelInfo(2)).mockResolvedValueOnce(memberChannelInfo(3))
2720
+ mockGetAllMembers.mockResolvedValue({
1693
2721
  statusCode: 0,
1694
2722
  body: {
1695
- status: 0,
1696
- m: [
1697
- { userId: makeLong(42), nickName: 'Alice From Room', type: 100 },
1698
- { userId: makeLong(43), nickName: 'Bob', type: 100 },
1699
- { userId: makeLong(44), nickName: 'Carol', type: 100 },
2723
+ members: [
2724
+ { userId: makeLong(42), nickName: 'Alice' },
2725
+ { userId: makeLong(43), nickName: 'Bob' },
1700
2726
  ],
1701
2727
  },
1702
2728
  })
1703
2729
 
1704
- const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1705
- const members = await client.getMembers('100')
2730
+ const client = await new KakaoTalkClient().login({
2731
+ oauthToken: 'token',
2732
+ userId: 'user1',
2733
+ deviceUuid: 'device1',
2734
+ })
2735
+ await expect(client.getMemberSnapshot('100')).rejects.toMatchObject({
2736
+ code: 'get_member_snapshot_failed',
2737
+ })
2738
+ expect(mockGetChatInfo).not.toHaveBeenCalled()
1706
2739
 
1707
- expect(members.map((member) => member.user_id)).toEqual(['42', '43', '44'])
1708
- expect(members[0].nickname).toBe('Alice')
1709
- expect(members[0].profile_image_url).toBe('https://kakao.com/p/alice.jpg')
1710
- expect(members[1].nickname).toBe('Bob')
1711
- expect(members[2].nickname).toBe('Carol')
2740
+ client.close()
2741
+ })
2742
+
2743
+ it('fails closed on a same-cardinality hidden member swap', async () => {
2744
+ const visible = [{ userId: makeLong(42), nickName: 'Visible' }]
2745
+ mockGetChannelInfo
2746
+ .mockResolvedValueOnce(memberChannelInfo(2, visible))
2747
+ .mockResolvedValueOnce(memberChannelInfo(2, visible))
2748
+ mockGetAllMembers
2749
+ .mockResolvedValueOnce({
2750
+ statusCode: 0,
2751
+ body: {
2752
+ members: [visible[0], { userId: makeLong(43), nickName: 'Departed' }],
2753
+ },
2754
+ })
2755
+ .mockResolvedValueOnce({
2756
+ statusCode: 0,
2757
+ body: {
2758
+ members: [visible[0], { userId: makeLong(44), nickName: 'Joined' }],
2759
+ },
2760
+ })
2761
+
2762
+ const client = await new KakaoTalkClient().login({
2763
+ oauthToken: 'token',
2764
+ userId: 'user1',
2765
+ deviceUuid: 'device1',
2766
+ })
2767
+ await expect(client.getMemberSnapshot('100')).rejects.toMatchObject({
2768
+ code: 'get_member_snapshot_failed',
2769
+ })
1712
2770
 
1713
2771
  client.close()
1714
2772
  })
1715
2773
 
1716
- it('returns empty array when GETMEM returns no members', async () => {
1717
- mockGetAllMembers.mockResolvedValueOnce({ statusCode: 0, body: {} })
2774
+ it('fails closed on malformed CHATINFO member identity', async () => {
2775
+ mockGetChannelInfo.mockResolvedValueOnce(memberChannelInfo(1, [{ userId: {}, nickName: 'Malformed' }]))
1718
2776
 
1719
- const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1720
- const members = await client.getMembers('100')
2777
+ const client = await new KakaoTalkClient().login({
2778
+ oauthToken: 'token',
2779
+ userId: 'user1',
2780
+ deviceUuid: 'device1',
2781
+ })
2782
+ await expect(client.getMemberSnapshot('100')).rejects.toMatchObject({
2783
+ code: 'get_member_snapshot_failed',
2784
+ })
2785
+ expect(mockGetAllMembers).not.toHaveBeenCalled()
2786
+
2787
+ client.close()
2788
+ })
2789
+
2790
+ it('fails closed on malformed GETMEM member identity', async () => {
2791
+ mockGetChannelInfo.mockResolvedValueOnce(memberChannelInfo(1))
2792
+ mockGetAllMembers.mockResolvedValueOnce({
2793
+ statusCode: 0,
2794
+ body: {
2795
+ members: [{ userId: '42', nickName: 'Malformed string id' }],
2796
+ },
2797
+ })
2798
+
2799
+ const client = await new KakaoTalkClient().login({
2800
+ oauthToken: 'token',
2801
+ userId: 'user1',
2802
+ deviceUuid: 'device1',
2803
+ })
2804
+ await expect(client.getMemberSnapshot('100')).rejects.toMatchObject({
2805
+ code: 'get_member_snapshot_failed',
2806
+ })
2807
+
2808
+ client.close()
2809
+ })
2810
+
2811
+ it('fails closed on duplicate GETMEM identity without CHATONROOM', async () => {
2812
+ mockGetChannelInfo.mockResolvedValueOnce(memberChannelInfo(2))
2813
+ mockGetAllMembers.mockResolvedValue({
2814
+ statusCode: 0,
2815
+ body: {
2816
+ members: [
2817
+ { userId: makeLong(42), nickName: 'Alice' },
2818
+ { userId: makeLong(42), nickName: 'Duplicate' },
2819
+ ],
2820
+ },
2821
+ })
2822
+
2823
+ const client = await new KakaoTalkClient().login({
2824
+ oauthToken: 'token',
2825
+ userId: 'user1',
2826
+ deviceUuid: 'device1',
2827
+ })
2828
+ await expect(client.getMemberSnapshot('100')).rejects.toMatchObject({
2829
+ code: 'get_member_snapshot_failed',
2830
+ })
2831
+ expect(mockGetChatInfo).not.toHaveBeenCalled()
2832
+
2833
+ client.close()
2834
+ })
2835
+
2836
+ it('fails closed on CHATINFO chat id mismatch before GETMEM', async () => {
2837
+ mockGetChannelInfo.mockResolvedValueOnce(memberChannelInfo(1, [], 101))
1721
2838
 
1722
- expect(members).toEqual([])
2839
+ const client = await new KakaoTalkClient().login({
2840
+ oauthToken: 'token',
2841
+ userId: 'user1',
2842
+ deviceUuid: 'device1',
2843
+ })
2844
+ await expect(client.getMemberSnapshot('100')).rejects.toMatchObject({
2845
+ code: 'get_member_snapshot_failed',
2846
+ })
2847
+ expect(mockGetAllMembers).not.toHaveBeenCalled()
2848
+ expect(mockGetChatInfo).not.toHaveBeenCalled()
1723
2849
 
1724
2850
  client.close()
1725
2851
  })
1726
2852
 
1727
2853
  it('normalizes missing user_type to null and treats pli=0 as absent', async () => {
1728
- mockGetAllMembers.mockResolvedValueOnce({
2854
+ mockGetChannelInfo.mockResolvedValueOnce(memberChannelInfo(3)).mockResolvedValueOnce(memberChannelInfo(3))
2855
+ mockGetAllMembers.mockResolvedValue({
1729
2856
  statusCode: 0,
1730
2857
  body: {
1731
2858
  members: [
@@ -1749,6 +2876,7 @@ describe('KakaoTalkClient', () => {
1749
2876
  })
1750
2877
 
1751
2878
  it('wraps GETMEM failures as KakaoTalkError get_members_failed', async () => {
2879
+ mockGetChannelInfo.mockResolvedValueOnce(memberChannelInfo(1))
1752
2880
  mockGetAllMembers.mockRejectedValueOnce(new Error('Network error'))
1753
2881
 
1754
2882
  const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
@@ -1764,6 +2892,7 @@ describe('KakaoTalkClient', () => {
1764
2892
  })
1765
2893
 
1766
2894
  it('throws on synthetic disconnect packet from GETMEM (statusCode != 0)', async () => {
2895
+ mockGetChannelInfo.mockResolvedValueOnce(memberChannelInfo(1))
1767
2896
  mockGetAllMembers.mockResolvedValueOnce({
1768
2897
  statusCode: -1,
1769
2898
  body: { error: 'connection closed' },
@@ -1782,6 +2911,7 @@ describe('KakaoTalkClient', () => {
1782
2911
  })
1783
2912
 
1784
2913
  it('throws on GETMEM body.status nonzero', async () => {
2914
+ mockGetChannelInfo.mockResolvedValueOnce(memberChannelInfo(1))
1785
2915
  mockGetAllMembers.mockResolvedValueOnce({
1786
2916
  statusCode: 0,
1787
2917
  body: { status: -500 },
@@ -1893,6 +3023,8 @@ describe('KakaoTalkClient', () => {
1893
3023
  expect((e as KakaoTalkError).code).toBe('invalid_chat_id')
1894
3024
  }
1895
3025
  expect(mockGetAllMembers).not.toHaveBeenCalled()
3026
+ expect(mockGetChannelInfo).not.toHaveBeenCalled()
3027
+ expect(mockGetChatInfo).not.toHaveBeenCalled()
1896
3028
  expect(mockLogin).not.toHaveBeenCalled()
1897
3029
 
1898
3030
  client.close()
@@ -1959,17 +3091,22 @@ describe('KakaoTalkClient', () => {
1959
3091
  if (handler) handler()
1960
3092
  return Promise.resolve({ statusCode: -1, body: { error: 'connection closed' } })
1961
3093
  })
1962
- mockGetAllMembers.mockResolvedValueOnce({
3094
+ mockGetAllMembers.mockResolvedValue({
1963
3095
  statusCode: 0,
1964
3096
  body: { members: [{ userId: makeLong(42), nickName: 'Alice', type: 100 }] },
1965
3097
  })
3098
+ mockGetChannelInfo
3099
+ .mockResolvedValueOnce(memberChannelInfo(1))
3100
+ .mockResolvedValueOnce(memberChannelInfo(1))
3101
+ .mockResolvedValueOnce(memberChannelInfo(1))
1966
3102
 
1967
3103
  const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1968
3104
  const members = await client.getMembers('100')
1969
3105
 
1970
3106
  expect(members).toHaveLength(1)
1971
3107
  expect(members[0].nickname).toBe('Alice')
1972
- expect(mockGetAllMembers).toHaveBeenCalledTimes(2)
3108
+ expect(mockGetAllMembers).toHaveBeenCalledTimes(3)
3109
+ expect(mockGetChatInfo).not.toHaveBeenCalled()
1973
3110
  // Login fires twice: once for the initial connect, once for the reconnect
1974
3111
  // after the captured onClose handler invalidated this.state.
1975
3112
  expect(mockLogin).toHaveBeenCalledTimes(2)