agent-messenger 2.37.0 → 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 (54) 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 +4 -4
  4. package/dist/src/platforms/kakaotalk/chat-classifier.d.ts.map +1 -1
  5. package/dist/src/platforms/kakaotalk/chat-classifier.js +7 -3
  6. package/dist/src/platforms/kakaotalk/chat-classifier.js.map +1 -1
  7. package/dist/src/platforms/kakaotalk/client.d.ts +12 -0
  8. package/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
  9. package/dist/src/platforms/kakaotalk/client.js +107 -12
  10. package/dist/src/platforms/kakaotalk/client.js.map +1 -1
  11. package/dist/src/platforms/kakaotalk/index.d.ts +1 -0
  12. package/dist/src/platforms/kakaotalk/index.d.ts.map +1 -1
  13. package/dist/src/platforms/kakaotalk/index.js.map +1 -1
  14. package/dist/src/platforms/kakaotalk/protocol/connection.d.ts.map +1 -1
  15. package/dist/src/platforms/kakaotalk/protocol/connection.js +13 -2
  16. package/dist/src/platforms/kakaotalk/protocol/connection.js.map +1 -1
  17. package/dist/src/platforms/kakaotalk/protocol/login-response.d.ts +1 -0
  18. package/dist/src/platforms/kakaotalk/protocol/login-response.d.ts.map +1 -1
  19. package/dist/src/platforms/kakaotalk/protocol/login-response.js +1 -1
  20. package/dist/src/platforms/kakaotalk/protocol/login-response.js.map +1 -1
  21. package/dist/src/platforms/kakaotalk/types.d.ts +4 -2
  22. package/dist/src/platforms/kakaotalk/types.d.ts.map +1 -1
  23. package/dist/src/platforms/kakaotalk/types.js +6 -1
  24. package/dist/src/platforms/kakaotalk/types.js.map +1 -1
  25. package/package.json +1 -1
  26. package/skills/agent-channeltalk/SKILL.md +1 -1
  27. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  28. package/skills/agent-discord/SKILL.md +1 -1
  29. package/skills/agent-discordbot/SKILL.md +1 -1
  30. package/skills/agent-imessage/SKILL.md +1 -1
  31. package/skills/agent-instagram/SKILL.md +1 -1
  32. package/skills/agent-kakaotalk/SKILL.md +1 -1
  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 +22 -0
  45. package/src/platforms/kakaotalk/chat-classifier.ts +7 -4
  46. package/src/platforms/kakaotalk/client.test.ts +572 -4
  47. package/src/platforms/kakaotalk/client.ts +142 -13
  48. package/src/platforms/kakaotalk/index.test.ts +27 -0
  49. package/src/platforms/kakaotalk/index.ts +5 -0
  50. package/src/platforms/kakaotalk/protocol/connection.test.ts +87 -0
  51. package/src/platforms/kakaotalk/protocol/connection.ts +15 -2
  52. package/src/platforms/kakaotalk/protocol/login-response.test.ts +13 -2
  53. package/src/platforms/kakaotalk/protocol/login-response.ts +1 -1
  54. package/src/platforms/kakaotalk/types.ts +10 -2
@@ -242,6 +242,42 @@ describe('KakaoTalkClient', () => {
242
242
  client.close()
243
243
  })
244
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
+
245
281
  it('populates last_message.author_name from paired chat.i / chat.k arrays', async () => {
246
282
  const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
247
283
  const chats = await client.getChats()
@@ -500,7 +536,9 @@ describe('KakaoTalkClient', () => {
500
536
  mockLogin.mockResolvedValue(emptyLoginResult)
501
537
 
502
538
  mockGetChatList.mockResolvedValueOnce({
539
+ statusCode: 0,
503
540
  body: {
541
+ status: 0,
504
542
  chatDatas: [
505
543
  {
506
544
  c: 100,
@@ -559,7 +597,9 @@ describe('KakaoTalkClient', () => {
559
597
  mockLogin.mockResolvedValue(loginResult)
560
598
 
561
599
  mockGetChatList.mockResolvedValueOnce({
600
+ statusCode: 0,
562
601
  body: {
602
+ status: 0,
563
603
  chatDatas: [
564
604
  {
565
605
  c: 300,
@@ -596,7 +636,9 @@ describe('KakaoTalkClient', () => {
596
636
 
597
637
  // Return a chat with same ID as login result
598
638
  mockGetChatList.mockResolvedValueOnce({
639
+ statusCode: 0,
599
640
  body: {
641
+ status: 0,
600
642
  chatDatas: [
601
643
  {
602
644
  c: 100, // Same as first login chat
@@ -630,7 +672,9 @@ describe('KakaoTalkClient', () => {
630
672
  mockLogin.mockResolvedValue(loginResult)
631
673
 
632
674
  mockGetChatList.mockResolvedValueOnce({
675
+ statusCode: 0,
633
676
  body: {
677
+ status: 0,
634
678
  chatDatas: [
635
679
  {
636
680
  c: makeLong(300),
@@ -662,6 +706,148 @@ describe('KakaoTalkClient', () => {
662
706
  client.close()
663
707
  })
664
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
+
665
851
  it('wraps errors as KakaoTalkError', async () => {
666
852
  mockLogin.mockRejectedValue(new Error('Connection refused'))
667
853
 
@@ -884,6 +1070,7 @@ describe('KakaoTalkClient', () => {
884
1070
  chatInfo: {
885
1071
  chatId: makeLong(300),
886
1072
  type: 11,
1073
+ left: false,
887
1074
  activeMembersCount: 2,
888
1075
  newMessageCount: 4,
889
1076
  invalidNewMessageCount: false,
@@ -927,6 +1114,303 @@ describe('KakaoTalkClient', () => {
927
1114
  client.close()
928
1115
  })
929
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
+
930
1414
  for (const openChatType of ['OM', 'OD'] as const) {
931
1415
  it(`preserves ${openChatType} and falls back to INFOLINK when CHATINFO has no title`, async () => {
932
1416
  mockGetChannelInfo.mockResolvedValueOnce({
@@ -1011,13 +1495,55 @@ describe('KakaoTalkClient', () => {
1011
1495
  client.close()
1012
1496
  })
1013
1497
 
1014
- it('rejects an unknown string CHATINFO type without contacting INFOLINK', async () => {
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 () => {
1015
1541
  mockGetChannelInfo.mockResolvedValueOnce({
1016
1542
  statusCode: 0,
1017
1543
  body: {
1018
1544
  chatInfo: {
1019
1545
  chatId: makeLong(300),
1020
- type: 'UNKNOWN',
1546
+ type: '',
1021
1547
  activeMembersCount: 2,
1022
1548
  newMessageCount: 0,
1023
1549
  invalidNewMessageCount: false,
@@ -1025,7 +1551,6 @@ describe('KakaoTalkClient', () => {
1025
1551
  lastSeenLogId: makeLong(0),
1026
1552
  displayMembers: [],
1027
1553
  chatMetas: [],
1028
- li: makeLong(7777),
1029
1554
  pushAlert: true,
1030
1555
  },
1031
1556
  },
@@ -1037,7 +1562,50 @@ describe('KakaoTalkClient', () => {
1037
1562
  deviceUuid: 'device1',
1038
1563
  })
1039
1564
 
1040
- await expect(client.getChat('300')).rejects.toMatchObject({ code: 'get_chat_failed' })
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
+ })
1041
1609
  expect(mockGetOpenLinkInfo).not.toHaveBeenCalled()
1042
1610
 
1043
1611
  client.close()