agent-messenger 2.36.0 → 2.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) 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 +2 -1
  4. package/dist/src/platforms/kakaotalk/chat-classifier.d.ts.map +1 -1
  5. package/dist/src/platforms/kakaotalk/chat-classifier.js +8 -6
  6. package/dist/src/platforms/kakaotalk/chat-classifier.js.map +1 -1
  7. package/dist/src/platforms/kakaotalk/client.d.ts +21 -1
  8. package/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
  9. package/dist/src/platforms/kakaotalk/client.js +272 -32
  10. package/dist/src/platforms/kakaotalk/client.js.map +1 -1
  11. package/dist/src/platforms/kakaotalk/index.d.ts +3 -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/types.d.ts +48 -2
  16. package/dist/src/platforms/kakaotalk/types.d.ts.map +1 -1
  17. package/dist/src/platforms/kakaotalk/types.js +13 -1
  18. package/dist/src/platforms/kakaotalk/types.js.map +1 -1
  19. package/dist/src/platforms/telegram/tdlib.d.ts.map +1 -1
  20. package/dist/src/platforms/telegram/tdlib.js +5 -2
  21. package/dist/src/platforms/telegram/tdlib.js.map +1 -1
  22. package/docs/content/docs/cli/kakaotalk.mdx +7 -1
  23. package/docs/content/docs/sdk/kakaotalk.mdx +29 -1
  24. package/package.json +1 -1
  25. package/skills/agent-channeltalk/SKILL.md +1 -1
  26. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  27. package/skills/agent-discord/SKILL.md +1 -1
  28. package/skills/agent-discordbot/SKILL.md +1 -1
  29. package/skills/agent-imessage/SKILL.md +1 -1
  30. package/skills/agent-instagram/SKILL.md +1 -1
  31. package/skills/agent-kakaotalk/SKILL.md +1 -1
  32. package/skills/agent-kakaotalk/references/common-patterns.md +36 -0
  33. package/skills/agent-line/SKILL.md +1 -1
  34. package/skills/agent-slack/SKILL.md +1 -1
  35. package/skills/agent-slackbot/SKILL.md +1 -1
  36. package/skills/agent-teams/SKILL.md +1 -1
  37. package/skills/agent-telegram/SKILL.md +1 -1
  38. package/skills/agent-telegrambot/SKILL.md +1 -1
  39. package/skills/agent-webex/SKILL.md +1 -1
  40. package/skills/agent-webexbot/SKILL.md +1 -1
  41. package/skills/agent-wechatbot/SKILL.md +1 -1
  42. package/skills/agent-whatsapp/SKILL.md +1 -1
  43. package/skills/agent-whatsappbot/SKILL.md +1 -1
  44. package/src/platforms/kakaotalk/chat-classifier.test.ts +1 -1
  45. package/src/platforms/kakaotalk/chat-classifier.ts +10 -7
  46. package/src/platforms/kakaotalk/client.test.ts +593 -24
  47. package/src/platforms/kakaotalk/client.ts +337 -35
  48. package/src/platforms/kakaotalk/index.test.ts +12 -0
  49. package/src/platforms/kakaotalk/index.ts +7 -1
  50. package/src/platforms/kakaotalk/types.ts +33 -2
  51. package/src/platforms/telegram/tdlib-node-test.ts +19 -0
  52. package/src/platforms/telegram/tdlib-node.test.ts +11 -0
  53. package/src/platforms/telegram/tdlib.ts +6 -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
 
@@ -858,7 +876,365 @@ describe('KakaoTalkClient', () => {
858
876
  })
859
877
  })
860
878
 
879
+ describe('getChat', () => {
880
+ it('returns one fully normalized late chat from read-only CHATINFO', async () => {
881
+ mockGetChannelInfo.mockResolvedValueOnce({
882
+ statusCode: 0,
883
+ body: {
884
+ chatInfo: {
885
+ chatId: makeLong(300),
886
+ type: 11,
887
+ activeMembersCount: 2,
888
+ newMessageCount: 4,
889
+ invalidNewMessageCount: false,
890
+ lastLogId: makeLong(77),
891
+ lastSeenLogId: makeLong(73),
892
+ lastChatLog: {
893
+ logId: makeLong(77),
894
+ authorId: 42,
895
+ type: 1,
896
+ message: 'latest',
897
+ sendAt: 1700000077,
898
+ },
899
+ displayMembers: [
900
+ { userId: makeLong(42), nickName: 'Alice' },
901
+ { userId: makeLong(43), nickName: 'Bob' },
902
+ ],
903
+ chatMetas: [{ type: 3, content: 'Late room' }],
904
+ pushAlert: true,
905
+ },
906
+ },
907
+ })
908
+
909
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
910
+ const chat = await client.getChat('300')
911
+
912
+ expect(chat).toEqual({
913
+ chat_id: '300',
914
+ type: 11,
915
+ display_name: 'Alice, Bob',
916
+ title: 'Late room',
917
+ active_members: 2,
918
+ unread_count: 4,
919
+ last_message: {
920
+ author_id: 42,
921
+ author_name: 'Alice',
922
+ message: 'latest',
923
+ sent_at: 1700000077,
924
+ },
925
+ })
926
+
927
+ client.close()
928
+ })
929
+
930
+ for (const openChatType of ['OM', 'OD'] as const) {
931
+ it(`preserves ${openChatType} and falls back to INFOLINK when CHATINFO has no title`, async () => {
932
+ mockGetChannelInfo.mockResolvedValueOnce({
933
+ statusCode: 0,
934
+ body: {
935
+ chatInfo: {
936
+ chatId: makeLong(300),
937
+ type: openChatType,
938
+ activeMembersCount: 2,
939
+ newMessageCount: 0,
940
+ invalidNewMessageCount: false,
941
+ lastLogId: makeLong(77),
942
+ lastSeenLogId: makeLong(73),
943
+ lastChatLog: null,
944
+ displayMembers: [
945
+ { userId: makeLong(42), nickName: 'Alice' },
946
+ { userId: makeLong(43), nickName: 'Bob' },
947
+ ],
948
+ chatMetas: [],
949
+ li: makeLong(7777),
950
+ pushAlert: true,
951
+ },
952
+ },
953
+ })
954
+ mockGetOpenLinkInfo.mockResolvedValueOnce({ body: { ols: [{ ln: 'Open Group Title' }] } })
955
+
956
+ const client = await new KakaoTalkClient().login({
957
+ oauthToken: 'token',
958
+ userId: 'user1',
959
+ deviceUuid: 'device1',
960
+ })
961
+ const chat = await client.getChat('300')
962
+
963
+ expect(chat).toEqual({
964
+ chat_id: '300',
965
+ type: openChatType,
966
+ display_name: 'Alice, Bob',
967
+ title: 'Open Group Title',
968
+ active_members: 2,
969
+ unread_count: 0,
970
+ last_message: null,
971
+ })
972
+ expect(mockGetOpenLinkInfo).toHaveBeenCalledTimes(1)
973
+
974
+ client.close()
975
+ })
976
+ }
977
+
978
+ it('falls back to INFOLINK for a numeric open-chat code', async () => {
979
+ mockGetChannelInfo.mockResolvedValueOnce({
980
+ statusCode: 0,
981
+ body: {
982
+ chatInfo: {
983
+ chatId: makeLong(300),
984
+ type: 13,
985
+ activeMembersCount: 2,
986
+ newMessageCount: 0,
987
+ invalidNewMessageCount: false,
988
+ lastLogId: makeLong(77),
989
+ lastSeenLogId: makeLong(73),
990
+ lastChatLog: null,
991
+ displayMembers: [],
992
+ chatMetas: [],
993
+ li: makeLong(7777),
994
+ pushAlert: true,
995
+ },
996
+ },
997
+ })
998
+ mockGetOpenLinkInfo.mockResolvedValueOnce({ body: { ols: [{ ln: 'Numeric Open Title' }] } })
999
+
1000
+ const client = await new KakaoTalkClient().login({
1001
+ oauthToken: 'token',
1002
+ userId: 'user1',
1003
+ deviceUuid: 'device1',
1004
+ })
1005
+ const chat = await client.getChat('300')
1006
+
1007
+ expect(chat.type).toBe(13)
1008
+ expect(chat.title).toBe('Numeric Open Title')
1009
+ expect(mockGetOpenLinkInfo).toHaveBeenCalledTimes(1)
1010
+
1011
+ client.close()
1012
+ })
1013
+
1014
+ it('rejects an unknown string CHATINFO type without contacting INFOLINK', async () => {
1015
+ mockGetChannelInfo.mockResolvedValueOnce({
1016
+ statusCode: 0,
1017
+ body: {
1018
+ chatInfo: {
1019
+ chatId: makeLong(300),
1020
+ type: 'UNKNOWN',
1021
+ activeMembersCount: 2,
1022
+ newMessageCount: 0,
1023
+ invalidNewMessageCount: false,
1024
+ lastLogId: makeLong(1),
1025
+ lastSeenLogId: makeLong(0),
1026
+ displayMembers: [],
1027
+ chatMetas: [],
1028
+ li: makeLong(7777),
1029
+ pushAlert: true,
1030
+ },
1031
+ },
1032
+ })
1033
+
1034
+ const client = await new KakaoTalkClient().login({
1035
+ oauthToken: 'token',
1036
+ userId: 'user1',
1037
+ deviceUuid: 'device1',
1038
+ })
1039
+
1040
+ await expect(client.getChat('300')).rejects.toMatchObject({ code: 'get_chat_failed' })
1041
+ expect(mockGetOpenLinkInfo).not.toHaveBeenCalled()
1042
+
1043
+ client.close()
1044
+ })
1045
+
1046
+ it('rejects a CHATINFO response bound to a different chat id', async () => {
1047
+ mockGetChannelInfo.mockResolvedValueOnce({
1048
+ statusCode: 0,
1049
+ body: {
1050
+ chatInfo: {
1051
+ chatId: makeLong(999),
1052
+ type: 11,
1053
+ activeMembersCount: 2,
1054
+ newMessageCount: 0,
1055
+ invalidNewMessageCount: false,
1056
+ lastLogId: makeLong(1),
1057
+ lastSeenLogId: makeLong(0),
1058
+ displayMembers: [],
1059
+ chatMetas: [],
1060
+ pushAlert: true,
1061
+ },
1062
+ },
1063
+ })
1064
+
1065
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1066
+
1067
+ await expect(client.getChat('300')).rejects.toMatchObject({ code: 'get_chat_failed' })
1068
+
1069
+ client.close()
1070
+ })
1071
+
1072
+ it('rejects an invalid chat id before opening a LOCO session', async () => {
1073
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1074
+
1075
+ await expect(client.getChat('not-a-number')).rejects.toMatchObject({ code: 'invalid_chat_id' })
1076
+ expect(mockLogin).not.toHaveBeenCalled()
1077
+ expect(mockGetChannelInfo).not.toHaveBeenCalled()
1078
+
1079
+ client.close()
1080
+ })
1081
+ })
1082
+
861
1083
  describe('getMessages', () => {
1084
+ it('returns a lossless forward page without discarding the oldest messages', async () => {
1085
+ mockGetChatLogs.mockResolvedValueOnce({
1086
+ statusCode: 0,
1087
+ body: {
1088
+ status: 0,
1089
+ chatLogs: Array.from({ length: 5 }, (_, index) => ({
1090
+ logId: makeLong(index + 1),
1091
+ chatId: 100,
1092
+ type: 1,
1093
+ authorId: 42,
1094
+ message: `message-${index + 1}`,
1095
+ sendAt: 1700000000 + index,
1096
+ })),
1097
+ eof: true,
1098
+ },
1099
+ })
1100
+
1101
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1102
+ const page = await client.getMessagePage('100', { count: 2 })
1103
+
1104
+ expect(page.messages.map((message) => message.log_id)).toEqual(['1', '2'])
1105
+ expect(page.next_cursor).toBe('2')
1106
+ expect(page.complete).toBe(false)
1107
+
1108
+ client.close()
1109
+ })
1110
+
1111
+ it('returns a complete forward page when the protocol page is exhausted', async () => {
1112
+ mockGetChatLogs.mockResolvedValueOnce({
1113
+ statusCode: 0,
1114
+ body: {
1115
+ status: 0,
1116
+ chatLogs: [
1117
+ { logId: makeLong(11), chatId: 100, type: 1, authorId: 42, message: 'eleven', sendAt: 11 },
1118
+ { logId: makeLong(10), chatId: 100, type: 1, authorId: 42, message: 'ten', sendAt: 10 },
1119
+ ],
1120
+ eof: true,
1121
+ },
1122
+ })
1123
+
1124
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1125
+ const page = await client.getMessagePage('100', { from: '9', count: 10 })
1126
+
1127
+ expect(page.messages.map((message) => message.log_id)).toEqual(['10', '11'])
1128
+ expect(page.next_cursor).toBe('11')
1129
+ expect(page.complete).toBe(true)
1130
+
1131
+ client.close()
1132
+ })
1133
+
1134
+ it('continues exclusively after the supplied cursor when the server repeats it', async () => {
1135
+ mockGetChatLogs.mockResolvedValueOnce({
1136
+ statusCode: 0,
1137
+ body: {
1138
+ status: 0,
1139
+ chatLogs: [
1140
+ { logId: makeLong(2), chatId: 100, type: 1, authorId: 42, message: 'repeat', sendAt: 2 },
1141
+ { logId: makeLong(3), chatId: 100, type: 1, authorId: 42, message: 'three', sendAt: 3 },
1142
+ { logId: makeLong(4), chatId: 100, type: 1, authorId: 42, message: 'four', sendAt: 4 },
1143
+ ],
1144
+ eof: true,
1145
+ },
1146
+ })
1147
+
1148
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1149
+ const page = await client.getMessagePage('100', { from: '2', count: 10 })
1150
+
1151
+ expect(page.messages.map((message) => message.log_id)).toEqual(['3', '4'])
1152
+ expect(page.next_cursor).toBe('4')
1153
+ expect(page.complete).toBe(true)
1154
+
1155
+ client.close()
1156
+ })
1157
+
1158
+ it('does not report completion when the protocol makes no cursor progress', async () => {
1159
+ mockGetChatLogs.mockResolvedValueOnce({
1160
+ statusCode: 0,
1161
+ body: { status: 0, chatLogs: [], eof: false },
1162
+ })
1163
+ mockGetChannelInfo.mockResolvedValueOnce({
1164
+ statusCode: 0,
1165
+ body: {
1166
+ chatInfo: {
1167
+ chatId: makeLong(100),
1168
+ type: 11,
1169
+ activeMembersCount: 2,
1170
+ newMessageCount: 0,
1171
+ invalidNewMessageCount: false,
1172
+ lastLogId: makeLong(999),
1173
+ lastSeenLogId: makeLong(0),
1174
+ displayMembers: [],
1175
+ chatMetas: [],
1176
+ pushAlert: true,
1177
+ },
1178
+ },
1179
+ })
1180
+ mockSyncMessages.mockResolvedValueOnce({
1181
+ statusCode: 0,
1182
+ body: { status: 0, chatLogs: [], isOK: false },
1183
+ })
1184
+
1185
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1186
+ const page = await client.getMessagePage('100', { from: '9', count: 10 })
1187
+
1188
+ expect(page).toEqual({ messages: [], next_cursor: null, complete: false })
1189
+
1190
+ client.close()
1191
+ })
1192
+
1193
+ it('uses read-only CHATINFO to paginate a late room missing from the login snapshot', async () => {
1194
+ mockLogin.mockResolvedValueOnce({ ...structuredClone(DEFAULT_LOGIN_RESULT), chatDatas: [] })
1195
+ mockGetChatLogs.mockResolvedValueOnce({
1196
+ statusCode: 0,
1197
+ body: { status: 0, chatLogs: [], eof: true },
1198
+ })
1199
+ mockGetChannelInfo.mockResolvedValueOnce({
1200
+ statusCode: 0,
1201
+ body: {
1202
+ chatInfo: {
1203
+ chatId: makeLong(300),
1204
+ type: 11,
1205
+ activeMembersCount: 2,
1206
+ newMessageCount: 1,
1207
+ invalidNewMessageCount: false,
1208
+ lastLogId: makeLong(12),
1209
+ lastSeenLogId: makeLong(0),
1210
+ displayMembers: [],
1211
+ chatMetas: [],
1212
+ pushAlert: true,
1213
+ },
1214
+ },
1215
+ })
1216
+ mockSyncMessages.mockResolvedValueOnce({
1217
+ statusCode: 0,
1218
+ body: {
1219
+ status: 0,
1220
+ isOK: true,
1221
+ chatLogs: [{ logId: makeLong(12), chatId: 300, type: 1, authorId: 42, message: 'late', sendAt: 12 }],
1222
+ },
1223
+ })
1224
+
1225
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1226
+ const page = await client.getMessagePage('300', { from: '11', count: 10 })
1227
+
1228
+ expect(page.messages.map((message) => message.log_id)).toEqual(['12'])
1229
+ expect(page.next_cursor).toBe('12')
1230
+ expect(page.complete).toBe(true)
1231
+ expect(mockGetChatInfo).not.toHaveBeenCalled()
1232
+ expect(mockSyncMessages.mock.calls[0]?.[2]?.toString()).toBe('11')
1233
+ expect(mockSyncMessages.mock.calls[0]?.[3]?.toString()).toBe('12')
1234
+
1235
+ client.close()
1236
+ })
1237
+
862
1238
  it('falls back to SYNCMSG when MCHATLOGS succeeds with an empty result', async () => {
863
1239
  const loginResult = structuredClone(DEFAULT_LOGIN_RESULT)
864
1240
  loginResult.chatDatas[0]!.ll = makeLong(10)
@@ -1610,7 +1986,8 @@ describe('KakaoTalkClient', () => {
1610
1986
 
1611
1987
  describe('getMembers / getMembersByIds', () => {
1612
1988
  it('returns formatted members from GETMEM with normalized fields', async () => {
1613
- mockGetAllMembers.mockResolvedValueOnce({
1989
+ mockGetChannelInfo.mockResolvedValueOnce(memberChannelInfo(2)).mockResolvedValueOnce(memberChannelInfo(2))
1990
+ mockGetAllMembers.mockResolvedValue({
1614
1991
  statusCode: 0,
1615
1992
  body: {
1616
1993
  members: [
@@ -1670,12 +2047,14 @@ describe('KakaoTalkClient', () => {
1670
2047
  open_profile_link_id: '99',
1671
2048
  open_permission: 4,
1672
2049
  })
2050
+ expect(mockGetChatInfo).not.toHaveBeenCalled()
1673
2051
 
1674
2052
  client.close()
1675
2053
  })
1676
2054
 
1677
- it('merges CHATONROOM members when GETMEM returns a partial member list', async () => {
1678
- mockGetAllMembers.mockResolvedValueOnce({
2055
+ it('rejects a partial GETMEM snapshot without entering the room', async () => {
2056
+ mockGetChannelInfo.mockResolvedValueOnce(memberChannelInfo(3)).mockResolvedValueOnce(memberChannelInfo(3))
2057
+ mockGetAllMembers.mockResolvedValue({
1679
2058
  statusCode: 0,
1680
2059
  body: {
1681
2060
  members: [
@@ -1689,43 +2068,223 @@ describe('KakaoTalkClient', () => {
1689
2068
  token: 0,
1690
2069
  },
1691
2070
  })
1692
- mockGetChatInfo.mockResolvedValueOnce({
2071
+
2072
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
2073
+ await expect(client.getMembers('100')).rejects.toMatchObject({
2074
+ code: 'get_members_failed',
2075
+ })
2076
+ expect(mockGetChatInfo).not.toHaveBeenCalled()
2077
+
2078
+ client.close()
2079
+ })
2080
+
2081
+ it('returns a stable empty snapshot for a zero-member room without CHATONROOM', async () => {
2082
+ mockGetChannelInfo.mockResolvedValue(memberChannelInfo(0))
2083
+ mockGetAllMembers.mockResolvedValue({
2084
+ statusCode: 0,
2085
+ body: { members: [] },
2086
+ })
2087
+
2088
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
2089
+ const snapshot = await client.getMemberSnapshot('100')
2090
+
2091
+ expect(snapshot).toMatchObject({
2092
+ chat_id: '100',
2093
+ active_members: 0,
2094
+ complete: true,
2095
+ consistency_basis: 'stable_double_read_chatinfo_getmem',
2096
+ })
2097
+ expect(snapshot.members).toEqual([])
2098
+ expect(mockGetAllMembers).toHaveBeenCalledTimes(2)
2099
+ expect(mockGetChatInfo).not.toHaveBeenCalled()
2100
+
2101
+ client.close()
2102
+ })
2103
+
2104
+ it('returns a stable CHATINFO GETMEM snapshot without CHATONROOM', async () => {
2105
+ const visible = [
2106
+ {
2107
+ userId: makeLong(42),
2108
+ nickName: 'Alice',
2109
+ type: 100,
2110
+ },
2111
+ ]
2112
+ mockGetChannelInfo
2113
+ .mockResolvedValueOnce(memberChannelInfo(2, visible))
2114
+ .mockResolvedValueOnce(memberChannelInfo(2, visible))
2115
+ mockGetAllMembers.mockResolvedValue({
1693
2116
  statusCode: 0,
1694
2117
  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 },
2118
+ members: [
2119
+ visible[0],
2120
+ {
2121
+ userId: makeLong(43),
2122
+ nickName: 'Bob',
2123
+ type: 100,
2124
+ },
1700
2125
  ],
1701
2126
  },
1702
2127
  })
1703
2128
 
1704
- const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1705
- const members = await client.getMembers('100')
2129
+ const client = await new KakaoTalkClient().login({
2130
+ oauthToken: 'token',
2131
+ userId: 'user1',
2132
+ deviceUuid: 'device1',
2133
+ })
2134
+ const snapshot = await client.getMemberSnapshot('100')
1706
2135
 
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')
2136
+ expect(snapshot).toMatchObject({
2137
+ chat_id: '100',
2138
+ active_members: 2,
2139
+ complete: true,
2140
+ consistency_basis: 'stable_double_read_chatinfo_getmem',
2141
+ })
2142
+ expect(snapshot.members.map((member) => member.user_id)).toEqual(['42', '43'])
2143
+ expect(mockGetAllMembers).toHaveBeenCalledTimes(2)
2144
+ expect(mockGetChannelInfo).toHaveBeenCalledTimes(2)
2145
+ expect(mockGetChatInfo).not.toHaveBeenCalled()
1712
2146
 
1713
2147
  client.close()
1714
2148
  })
1715
2149
 
1716
- it('returns empty array when GETMEM returns no members', async () => {
1717
- mockGetAllMembers.mockResolvedValueOnce({ statusCode: 0, body: {} })
2150
+ it('fails closed when active membership changes across the snapshot', async () => {
2151
+ mockGetChannelInfo.mockResolvedValueOnce(memberChannelInfo(2)).mockResolvedValueOnce(memberChannelInfo(3))
2152
+ mockGetAllMembers.mockResolvedValue({
2153
+ statusCode: 0,
2154
+ body: {
2155
+ members: [
2156
+ { userId: makeLong(42), nickName: 'Alice' },
2157
+ { userId: makeLong(43), nickName: 'Bob' },
2158
+ ],
2159
+ },
2160
+ })
1718
2161
 
1719
- const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1720
- const members = await client.getMembers('100')
2162
+ const client = await new KakaoTalkClient().login({
2163
+ oauthToken: 'token',
2164
+ userId: 'user1',
2165
+ deviceUuid: 'device1',
2166
+ })
2167
+ await expect(client.getMemberSnapshot('100')).rejects.toMatchObject({
2168
+ code: 'get_member_snapshot_failed',
2169
+ })
2170
+ expect(mockGetChatInfo).not.toHaveBeenCalled()
1721
2171
 
1722
- expect(members).toEqual([])
2172
+ client.close()
2173
+ })
2174
+
2175
+ it('fails closed on a same-cardinality hidden member swap', async () => {
2176
+ const visible = [{ userId: makeLong(42), nickName: 'Visible' }]
2177
+ mockGetChannelInfo
2178
+ .mockResolvedValueOnce(memberChannelInfo(2, visible))
2179
+ .mockResolvedValueOnce(memberChannelInfo(2, visible))
2180
+ mockGetAllMembers
2181
+ .mockResolvedValueOnce({
2182
+ statusCode: 0,
2183
+ body: {
2184
+ members: [visible[0], { userId: makeLong(43), nickName: 'Departed' }],
2185
+ },
2186
+ })
2187
+ .mockResolvedValueOnce({
2188
+ statusCode: 0,
2189
+ body: {
2190
+ members: [visible[0], { userId: makeLong(44), nickName: 'Joined' }],
2191
+ },
2192
+ })
2193
+
2194
+ const client = await new KakaoTalkClient().login({
2195
+ oauthToken: 'token',
2196
+ userId: 'user1',
2197
+ deviceUuid: 'device1',
2198
+ })
2199
+ await expect(client.getMemberSnapshot('100')).rejects.toMatchObject({
2200
+ code: 'get_member_snapshot_failed',
2201
+ })
1723
2202
 
1724
2203
  client.close()
1725
2204
  })
1726
2205
 
1727
- it('normalizes missing user_type to null and treats pli=0 as absent', async () => {
2206
+ it('fails closed on malformed CHATINFO member identity', async () => {
2207
+ mockGetChannelInfo.mockResolvedValueOnce(memberChannelInfo(1, [{ userId: {}, nickName: 'Malformed' }]))
2208
+
2209
+ const client = await new KakaoTalkClient().login({
2210
+ oauthToken: 'token',
2211
+ userId: 'user1',
2212
+ deviceUuid: 'device1',
2213
+ })
2214
+ await expect(client.getMemberSnapshot('100')).rejects.toMatchObject({
2215
+ code: 'get_member_snapshot_failed',
2216
+ })
2217
+ expect(mockGetAllMembers).not.toHaveBeenCalled()
2218
+
2219
+ client.close()
2220
+ })
2221
+
2222
+ it('fails closed on malformed GETMEM member identity', async () => {
2223
+ mockGetChannelInfo.mockResolvedValueOnce(memberChannelInfo(1))
1728
2224
  mockGetAllMembers.mockResolvedValueOnce({
2225
+ statusCode: 0,
2226
+ body: {
2227
+ members: [{ userId: '42', nickName: 'Malformed string id' }],
2228
+ },
2229
+ })
2230
+
2231
+ const client = await new KakaoTalkClient().login({
2232
+ oauthToken: 'token',
2233
+ userId: 'user1',
2234
+ deviceUuid: 'device1',
2235
+ })
2236
+ await expect(client.getMemberSnapshot('100')).rejects.toMatchObject({
2237
+ code: 'get_member_snapshot_failed',
2238
+ })
2239
+
2240
+ client.close()
2241
+ })
2242
+
2243
+ it('fails closed on duplicate GETMEM identity without CHATONROOM', async () => {
2244
+ mockGetChannelInfo.mockResolvedValueOnce(memberChannelInfo(2))
2245
+ mockGetAllMembers.mockResolvedValue({
2246
+ statusCode: 0,
2247
+ body: {
2248
+ members: [
2249
+ { userId: makeLong(42), nickName: 'Alice' },
2250
+ { userId: makeLong(42), nickName: 'Duplicate' },
2251
+ ],
2252
+ },
2253
+ })
2254
+
2255
+ const client = await new KakaoTalkClient().login({
2256
+ oauthToken: 'token',
2257
+ userId: 'user1',
2258
+ deviceUuid: 'device1',
2259
+ })
2260
+ await expect(client.getMemberSnapshot('100')).rejects.toMatchObject({
2261
+ code: 'get_member_snapshot_failed',
2262
+ })
2263
+ expect(mockGetChatInfo).not.toHaveBeenCalled()
2264
+
2265
+ client.close()
2266
+ })
2267
+
2268
+ it('fails closed on CHATINFO chat id mismatch before GETMEM', async () => {
2269
+ mockGetChannelInfo.mockResolvedValueOnce(memberChannelInfo(1, [], 101))
2270
+
2271
+ const client = await new KakaoTalkClient().login({
2272
+ oauthToken: 'token',
2273
+ userId: 'user1',
2274
+ deviceUuid: 'device1',
2275
+ })
2276
+ await expect(client.getMemberSnapshot('100')).rejects.toMatchObject({
2277
+ code: 'get_member_snapshot_failed',
2278
+ })
2279
+ expect(mockGetAllMembers).not.toHaveBeenCalled()
2280
+ expect(mockGetChatInfo).not.toHaveBeenCalled()
2281
+
2282
+ client.close()
2283
+ })
2284
+
2285
+ it('normalizes missing user_type to null and treats pli=0 as absent', async () => {
2286
+ mockGetChannelInfo.mockResolvedValueOnce(memberChannelInfo(3)).mockResolvedValueOnce(memberChannelInfo(3))
2287
+ mockGetAllMembers.mockResolvedValue({
1729
2288
  statusCode: 0,
1730
2289
  body: {
1731
2290
  members: [
@@ -1749,6 +2308,7 @@ describe('KakaoTalkClient', () => {
1749
2308
  })
1750
2309
 
1751
2310
  it('wraps GETMEM failures as KakaoTalkError get_members_failed', async () => {
2311
+ mockGetChannelInfo.mockResolvedValueOnce(memberChannelInfo(1))
1752
2312
  mockGetAllMembers.mockRejectedValueOnce(new Error('Network error'))
1753
2313
 
1754
2314
  const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
@@ -1764,6 +2324,7 @@ describe('KakaoTalkClient', () => {
1764
2324
  })
1765
2325
 
1766
2326
  it('throws on synthetic disconnect packet from GETMEM (statusCode != 0)', async () => {
2327
+ mockGetChannelInfo.mockResolvedValueOnce(memberChannelInfo(1))
1767
2328
  mockGetAllMembers.mockResolvedValueOnce({
1768
2329
  statusCode: -1,
1769
2330
  body: { error: 'connection closed' },
@@ -1782,6 +2343,7 @@ describe('KakaoTalkClient', () => {
1782
2343
  })
1783
2344
 
1784
2345
  it('throws on GETMEM body.status nonzero', async () => {
2346
+ mockGetChannelInfo.mockResolvedValueOnce(memberChannelInfo(1))
1785
2347
  mockGetAllMembers.mockResolvedValueOnce({
1786
2348
  statusCode: 0,
1787
2349
  body: { status: -500 },
@@ -1893,6 +2455,8 @@ describe('KakaoTalkClient', () => {
1893
2455
  expect((e as KakaoTalkError).code).toBe('invalid_chat_id')
1894
2456
  }
1895
2457
  expect(mockGetAllMembers).not.toHaveBeenCalled()
2458
+ expect(mockGetChannelInfo).not.toHaveBeenCalled()
2459
+ expect(mockGetChatInfo).not.toHaveBeenCalled()
1896
2460
  expect(mockLogin).not.toHaveBeenCalled()
1897
2461
 
1898
2462
  client.close()
@@ -1959,17 +2523,22 @@ describe('KakaoTalkClient', () => {
1959
2523
  if (handler) handler()
1960
2524
  return Promise.resolve({ statusCode: -1, body: { error: 'connection closed' } })
1961
2525
  })
1962
- mockGetAllMembers.mockResolvedValueOnce({
2526
+ mockGetAllMembers.mockResolvedValue({
1963
2527
  statusCode: 0,
1964
2528
  body: { members: [{ userId: makeLong(42), nickName: 'Alice', type: 100 }] },
1965
2529
  })
2530
+ mockGetChannelInfo
2531
+ .mockResolvedValueOnce(memberChannelInfo(1))
2532
+ .mockResolvedValueOnce(memberChannelInfo(1))
2533
+ .mockResolvedValueOnce(memberChannelInfo(1))
1966
2534
 
1967
2535
  const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1968
2536
  const members = await client.getMembers('100')
1969
2537
 
1970
2538
  expect(members).toHaveLength(1)
1971
2539
  expect(members[0].nickname).toBe('Alice')
1972
- expect(mockGetAllMembers).toHaveBeenCalledTimes(2)
2540
+ expect(mockGetAllMembers).toHaveBeenCalledTimes(3)
2541
+ expect(mockGetChatInfo).not.toHaveBeenCalled()
1973
2542
  // Login fires twice: once for the initial connect, once for the reconnect
1974
2543
  // after the captured onClose handler invalidated this.state.
1975
2544
  expect(mockLogin).toHaveBeenCalledTimes(2)