agent-messenger 2.37.0 → 2.37.2

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 +122 -14
  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 +638 -4
  47. package/src/platforms/kakaotalk/client.ts +156 -15
  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,214 @@ 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('advances LCHATLIST pagination when cursor fields are plain numbers', async () => {
767
+ // LOCO sends lastTokenId/lastChatId as plain numbers when they fit in a safe
768
+ // integer (bson promotes them). The mocked session ignores its arguments, so
769
+ // asserting only on the collected chats would pass even while every request
770
+ // re-sent cursor 0. Assert on the cursor that actually reaches getChatList.
771
+ mockLogin.mockResolvedValue({ ...DEFAULT_LOGIN_RESULT, lastTokenId: 7, lastChatId: 200, eof: false })
772
+ mockGetChatList
773
+ .mockResolvedValueOnce({
774
+ statusCode: 0,
775
+ body: {
776
+ status: 0,
777
+ chatDatas: [
778
+ {
779
+ c: 300,
780
+ t: 1,
781
+ k: ['Dave'],
782
+ i: [4],
783
+ a: 1,
784
+ n: 0,
785
+ o: 1698000000,
786
+ l: null,
787
+ ll: makeLong(100),
788
+ },
789
+ ],
790
+ lastTokenId: 8,
791
+ lastChatId: 300,
792
+ eof: false,
793
+ },
794
+ })
795
+ .mockResolvedValueOnce({
796
+ statusCode: 0,
797
+ body: {
798
+ status: 0,
799
+ chatDatas: [
800
+ {
801
+ c: 400,
802
+ t: 1,
803
+ k: ['Erin'],
804
+ i: [5],
805
+ a: 1,
806
+ n: 0,
807
+ o: 1697000000,
808
+ l: null,
809
+ ll: makeLong(90),
810
+ },
811
+ ],
812
+ lastTokenId: 9,
813
+ lastChatId: 400,
814
+ eof: true,
815
+ },
816
+ })
817
+
818
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
819
+ const chats = await client.getChats({ all: true })
820
+
821
+ expect(chats.map((chat) => chat.chat_id)).toEqual(['100', '200', '300', '400'])
822
+ expect(mockGetChatList).toHaveBeenCalledTimes(2)
823
+ const cursors = mockGetChatList.mock.calls.map((call) => (call as unknown[]).map((v) => String(v)))
824
+ expect(cursors).toEqual([
825
+ ['7', '200'],
826
+ ['8', '300'],
827
+ ])
828
+
829
+ client.close()
830
+ })
831
+
832
+ it('fails closed when LCHATLIST has a nonzero response status', async () => {
833
+ mockLogin.mockResolvedValue({ ...DEFAULT_LOGIN_RESULT, eof: false })
834
+ mockGetChatList.mockResolvedValueOnce({
835
+ statusCode: 503,
836
+ body: { status: 0, chatDatas: [], lastTokenId: makeLong(1), lastChatId: makeLong(300), eof: true },
837
+ })
838
+
839
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
840
+
841
+ await expect(client.getChats({ all: true })).rejects.toMatchObject({
842
+ code: 'get_chats_failed',
843
+ responseFailureKind: 'provider_rejection',
844
+ serverStatus: 503,
845
+ })
846
+
847
+ client.close()
848
+ })
849
+
850
+ it('fails closed on an empty non-EOF LCHATLIST page', async () => {
851
+ mockLogin.mockResolvedValue({ ...DEFAULT_LOGIN_RESULT, eof: false })
852
+ mockGetChatList.mockResolvedValueOnce({
853
+ statusCode: 0,
854
+ body: { status: 0, chatDatas: [], lastTokenId: makeLong(1), lastChatId: makeLong(300), eof: false },
855
+ })
856
+
857
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
858
+
859
+ await expect(client.getChats({ all: true })).rejects.toMatchObject({ code: 'get_chats_failed' })
860
+ expect(mockGetChatList).toHaveBeenCalledTimes(1)
861
+
862
+ client.close()
863
+ })
864
+
865
+ it('fails closed when LCHATLIST reaches the page cap without EOF', async () => {
866
+ mockLogin.mockResolvedValue({ ...DEFAULT_LOGIN_RESULT, eof: false })
867
+ let page = 0
868
+ mockGetChatList.mockImplementation(async () => {
869
+ page++
870
+ return {
871
+ statusCode: 0,
872
+ body: {
873
+ status: 0,
874
+ chatDatas: [
875
+ {
876
+ c: 1000 + page,
877
+ t: 1,
878
+ k: [`Room ${page}`],
879
+ i: [1000 + page],
880
+ a: 1,
881
+ n: 0,
882
+ o: 1600000000 - page,
883
+ l: null,
884
+ ll: makeLong(page),
885
+ },
886
+ ],
887
+ lastTokenId: makeLong(page),
888
+ lastChatId: makeLong(1000 + page),
889
+ eof: false,
890
+ },
891
+ }
892
+ })
893
+
894
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
895
+
896
+ await expect(client.getChats({ all: true })).rejects.toMatchObject({ code: 'get_chats_failed' })
897
+ expect(mockGetChatList).toHaveBeenCalledTimes(50)
898
+
899
+ client.close()
900
+ })
901
+
902
+ it('preserves non-all search compatibility on an empty non-EOF page', async () => {
903
+ mockLogin.mockResolvedValue({ ...DEFAULT_LOGIN_RESULT, eof: false })
904
+ mockGetChatList.mockResolvedValueOnce({
905
+ body: { chatDatas: [], lastTokenId: makeLong(1), lastChatId: makeLong(300), eof: false },
906
+ })
907
+
908
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
909
+ const chats = await client.getChats({ search: 'Alice' })
910
+
911
+ expect(chats.map((chat) => chat.chat_id)).toEqual(['100'])
912
+ expect(mockGetChatList).toHaveBeenCalledTimes(1)
913
+
914
+ client.close()
915
+ })
916
+
665
917
  it('wraps errors as KakaoTalkError', async () => {
666
918
  mockLogin.mockRejectedValue(new Error('Connection refused'))
667
919
 
@@ -884,6 +1136,7 @@ describe('KakaoTalkClient', () => {
884
1136
  chatInfo: {
885
1137
  chatId: makeLong(300),
886
1138
  type: 11,
1139
+ left: false,
887
1140
  activeMembersCount: 2,
888
1141
  newMessageCount: 4,
889
1142
  invalidNewMessageCount: false,
@@ -927,6 +1180,303 @@ describe('KakaoTalkClient', () => {
927
1180
  client.close()
928
1181
  })
929
1182
 
1183
+ it('classifies an explicit CHATINFO header rejection without exposing response contents', async () => {
1184
+ mockGetChannelInfo.mockResolvedValueOnce({ statusCode: 403, body: {} })
1185
+
1186
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1187
+
1188
+ await expect(client.getChat('300')).rejects.toMatchObject({
1189
+ code: 'get_chat_failed',
1190
+ getChatFailureReason: 'provider_rejection',
1191
+ responseFailureKind: 'provider_rejection',
1192
+ responseStatusSource: 'packet',
1193
+ serverStatus: 403,
1194
+ })
1195
+
1196
+ client.close()
1197
+ })
1198
+
1199
+ it('classifies an explicit CHATINFO body rejection and preserves its numeric status', async () => {
1200
+ mockGetChannelInfo.mockResolvedValueOnce({ statusCode: 0, body: { status: -404 } })
1201
+
1202
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1203
+
1204
+ await expect(client.getChat('300')).rejects.toMatchObject({
1205
+ code: 'get_chat_failed',
1206
+ getChatFailureReason: 'provider_rejection',
1207
+ responseFailureKind: 'provider_rejection',
1208
+ responseStatusSource: 'body',
1209
+ serverStatus: -404,
1210
+ })
1211
+
1212
+ client.close()
1213
+ })
1214
+
1215
+ it('does not expose rejected CHATINFO body contents through public error metadata', async () => {
1216
+ const secretSentinel = 'secret-sentinel-do-not-emit'
1217
+ mockGetChannelInfo.mockResolvedValueOnce({
1218
+ statusCode: 0,
1219
+ body: { status: -404, secret: secretSentinel },
1220
+ })
1221
+
1222
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1223
+ let error: unknown
1224
+ try {
1225
+ await client.getChat('300')
1226
+ } catch (cause) {
1227
+ error = cause
1228
+ }
1229
+
1230
+ expect(error).toBeInstanceOf(KakaoTalkError)
1231
+ const publicError = error as KakaoTalkError
1232
+ expect(publicError).toMatchObject({
1233
+ code: 'get_chat_failed',
1234
+ getChatFailureReason: 'provider_rejection',
1235
+ responseFailureKind: 'provider_rejection',
1236
+ responseStatusSource: 'body',
1237
+ serverStatus: -404,
1238
+ })
1239
+ expect(publicError.message).not.toContain(secretSentinel)
1240
+ expect(JSON.stringify(publicError)).not.toContain(secretSentinel)
1241
+ expect(JSON.stringify(publicError.cause)).not.toContain(secretSentinel)
1242
+
1243
+ client.close()
1244
+ })
1245
+
1246
+ it('classifies synthetic connection-close status -1 as transient rather than provider rejection', async () => {
1247
+ mockGetChannelInfo.mockResolvedValueOnce({ statusCode: -1, body: { error: 'connection closed' } })
1248
+
1249
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1250
+
1251
+ await expect(client.getChat('300')).rejects.toMatchObject({
1252
+ code: 'get_chat_failed',
1253
+ getChatFailureReason: 'synthetic_connection_close',
1254
+ responseFailureKind: 'transient_or_unknown',
1255
+ responseStatusSource: 'packet',
1256
+ serverStatus: -1,
1257
+ })
1258
+
1259
+ client.close()
1260
+ })
1261
+
1262
+ it('does not classify status -1 with a nonmatching body as a synthetic connection close', async () => {
1263
+ mockGetChannelInfo.mockResolvedValueOnce({ statusCode: -1, body: { error: 'provider rejected' } })
1264
+
1265
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1266
+
1267
+ await expect(client.getChat('300')).rejects.toMatchObject({
1268
+ code: 'get_chat_failed',
1269
+ getChatFailureReason: 'provider_rejection',
1270
+ responseFailureKind: 'provider_rejection',
1271
+ responseStatusSource: 'packet',
1272
+ serverStatus: -1,
1273
+ })
1274
+
1275
+ client.close()
1276
+ })
1277
+
1278
+ it('classifies a thrown CHATINFO transport failure as transient or unknown', async () => {
1279
+ const secretSentinel = 'secret-transport-body-do-not-emit'
1280
+ mockGetChannelInfo.mockRejectedValueOnce(
1281
+ Object.assign(new Error('socket unavailable'), { body: { secret: secretSentinel } }),
1282
+ )
1283
+
1284
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1285
+
1286
+ let error: unknown
1287
+ try {
1288
+ await client.getChat('300')
1289
+ } catch (cause) {
1290
+ error = cause
1291
+ }
1292
+
1293
+ expect(error).toBeInstanceOf(KakaoTalkError)
1294
+ expect(error).toMatchObject({
1295
+ code: 'get_chat_failed',
1296
+ getChatFailureReason: 'transport_or_unknown',
1297
+ responseFailureKind: 'transient_or_unknown',
1298
+ })
1299
+ const serialized = JSON.stringify(error)
1300
+ expect(serialized).not.toContain(secretSentinel)
1301
+ expect(serialized).not.toContain('body')
1302
+ expect(serialized).not.toContain('message')
1303
+ expect(serialized).not.toContain('cause')
1304
+
1305
+ client.close()
1306
+ })
1307
+
1308
+ it('classifies a successful CHATINFO response without chatInfo as structurally absent', async () => {
1309
+ const secretSentinel = 'secret-chat-body-do-not-emit'
1310
+ mockGetChannelInfo.mockResolvedValueOnce({ statusCode: 0, body: { secret: secretSentinel } })
1311
+
1312
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1313
+
1314
+ let error: unknown
1315
+ try {
1316
+ await client.getChat('300')
1317
+ } catch (cause) {
1318
+ error = cause
1319
+ }
1320
+
1321
+ expect(error).toBeInstanceOf(KakaoTalkError)
1322
+ expect(error).toMatchObject({
1323
+ code: 'get_chat_failed',
1324
+ getChatFailureReason: 'chat_info_absent',
1325
+ responseFailureKind: 'transient_or_unknown',
1326
+ })
1327
+ const serialized = JSON.stringify(error)
1328
+ expect(serialized).not.toContain(secretSentinel)
1329
+ expect(serialized).not.toContain('body')
1330
+ expect(serialized).not.toContain('message')
1331
+ expect(serialized).not.toContain('cause')
1332
+
1333
+ client.close()
1334
+ })
1335
+
1336
+ it('classifies a successful CHATINFO response with null chatInfo as structurally absent', async () => {
1337
+ const secretSentinel = 'secret-null-chat-body-do-not-emit'
1338
+ const chatIdSentinel = '987654321012345678'
1339
+ mockGetChannelInfo.mockResolvedValueOnce({
1340
+ statusCode: 0,
1341
+ body: { chatInfo: null, secret: secretSentinel },
1342
+ })
1343
+
1344
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1345
+
1346
+ let error: unknown
1347
+ try {
1348
+ await client.getChat(chatIdSentinel)
1349
+ } catch (cause) {
1350
+ error = cause
1351
+ }
1352
+
1353
+ expect(error).toBeInstanceOf(KakaoTalkError)
1354
+ expect(error).toMatchObject({
1355
+ code: 'get_chat_failed',
1356
+ getChatFailureReason: 'chat_info_absent',
1357
+ responseFailureKind: 'transient_or_unknown',
1358
+ })
1359
+ const serialized = JSON.stringify(error)
1360
+ expect(serialized).not.toContain(secretSentinel)
1361
+ expect(serialized).not.toContain('body')
1362
+ expect(serialized).not.toContain('message')
1363
+ expect(serialized).not.toContain('cause')
1364
+ expect(serialized).not.toContain('chatId')
1365
+ expect(serialized).not.toContain(chatIdSentinel)
1366
+
1367
+ client.close()
1368
+ })
1369
+
1370
+ it('classifies exact CHATINFO left=true as structurally absent without exposing response contents', async () => {
1371
+ const secretSentinel = 'secret-left-chat-body-do-not-emit'
1372
+ const nativeChatIdSentinel = '987654321'
1373
+ mockGetChannelInfo.mockResolvedValueOnce({
1374
+ statusCode: 0,
1375
+ body: {
1376
+ chatInfo: {
1377
+ left: true,
1378
+ chatId: makeLong(Number(nativeChatIdSentinel)),
1379
+ secret: secretSentinel,
1380
+ },
1381
+ },
1382
+ })
1383
+
1384
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1385
+
1386
+ let error: unknown
1387
+ try {
1388
+ await client.getChat(nativeChatIdSentinel)
1389
+ } catch (cause) {
1390
+ error = cause
1391
+ }
1392
+
1393
+ expect(error).toBeInstanceOf(KakaoTalkError)
1394
+ expect(error).toMatchObject({
1395
+ code: 'get_chat_failed',
1396
+ getChatFailureReason: 'chat_info_absent',
1397
+ responseFailureKind: 'transient_or_unknown',
1398
+ })
1399
+ const publicError = error as KakaoTalkError
1400
+ const serialized = JSON.stringify(publicError)
1401
+ expect(publicError.message).not.toContain(secretSentinel)
1402
+ expect(publicError.message).not.toContain(nativeChatIdSentinel)
1403
+ expect(serialized).not.toContain(secretSentinel)
1404
+ expect(serialized).not.toContain(nativeChatIdSentinel)
1405
+
1406
+ client.close()
1407
+ })
1408
+
1409
+ for (const [label, chatInfo] of [
1410
+ ['mismatched chatId', { left: true, chatId: makeLong(301) }],
1411
+ ['missing chatId', { left: true }],
1412
+ ['invalid chatId', { left: true, chatId: { invalid: true } }],
1413
+ ] as const) {
1414
+ it(`does not classify CHATINFO left=true with ${label} as structurally absent`, async () => {
1415
+ mockGetChannelInfo.mockResolvedValueOnce({
1416
+ statusCode: 0,
1417
+ body: { chatInfo },
1418
+ })
1419
+
1420
+ const client = await new KakaoTalkClient().login({
1421
+ oauthToken: 'token',
1422
+ userId: 'user1',
1423
+ deviceUuid: 'device1',
1424
+ })
1425
+
1426
+ await expect(client.getChat('300')).rejects.toMatchObject({
1427
+ code: 'get_chat_failed',
1428
+ getChatFailureReason: 'transport_or_unknown',
1429
+ responseFailureKind: 'transient_or_unknown',
1430
+ })
1431
+
1432
+ client.close()
1433
+ })
1434
+ }
1435
+
1436
+ for (const [label, chatInfo] of [
1437
+ ['left=false', { left: false }],
1438
+ ['left missing', {}],
1439
+ ['left nonboolean', { left: 'true' }],
1440
+ ] as const) {
1441
+ it(`does not classify malformed successful CHATINFO with ${label} as structurally absent`, async () => {
1442
+ mockGetChannelInfo.mockResolvedValueOnce({
1443
+ statusCode: 0,
1444
+ body: { chatInfo },
1445
+ })
1446
+
1447
+ const client = await new KakaoTalkClient().login({
1448
+ oauthToken: 'token',
1449
+ userId: 'user1',
1450
+ deviceUuid: 'device1',
1451
+ })
1452
+
1453
+ await expect(client.getChat('300')).rejects.toMatchObject({
1454
+ code: 'get_chat_failed',
1455
+ getChatFailureReason: 'transport_or_unknown',
1456
+ responseFailureKind: 'transient_or_unknown',
1457
+ })
1458
+
1459
+ client.close()
1460
+ })
1461
+ }
1462
+
1463
+ it('classifies malformed successful CHATINFO chatInfo as transport or unknown', async () => {
1464
+ mockGetChannelInfo.mockResolvedValueOnce({
1465
+ statusCode: 0,
1466
+ body: { chatInfo: { chatId: makeLong(300) } },
1467
+ })
1468
+
1469
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1470
+
1471
+ await expect(client.getChat('300')).rejects.toMatchObject({
1472
+ code: 'get_chat_failed',
1473
+ getChatFailureReason: 'transport_or_unknown',
1474
+ responseFailureKind: 'transient_or_unknown',
1475
+ })
1476
+
1477
+ client.close()
1478
+ })
1479
+
930
1480
  for (const openChatType of ['OM', 'OD'] as const) {
931
1481
  it(`preserves ${openChatType} and falls back to INFOLINK when CHATINFO has no title`, async () => {
932
1482
  mockGetChannelInfo.mockResolvedValueOnce({
@@ -1011,13 +1561,90 @@ describe('KakaoTalkClient', () => {
1011
1561
  client.close()
1012
1562
  })
1013
1563
 
1014
- it('rejects an unknown string CHATINFO type without contacting INFOLINK', async () => {
1564
+ for (const normalChatType of ['DirectChat', 'MultiChat', 'PlusChat', 'MemoChat'] as const) {
1565
+ it(`preserves donor-defined ${normalChatType} without contacting INFOLINK`, async () => {
1566
+ mockGetChannelInfo.mockResolvedValueOnce({
1567
+ statusCode: 0,
1568
+ body: {
1569
+ chatInfo: {
1570
+ chatId: makeLong(300),
1571
+ type: normalChatType,
1572
+ activeMembersCount: 2,
1573
+ newMessageCount: 0,
1574
+ invalidNewMessageCount: false,
1575
+ lastLogId: makeLong(1),
1576
+ lastSeenLogId: makeLong(0),
1577
+ displayMembers: [],
1578
+ chatMetas: [],
1579
+ li: makeLong(7777),
1580
+ pushAlert: true,
1581
+ },
1582
+ },
1583
+ })
1584
+
1585
+ const client = await new KakaoTalkClient().login({
1586
+ oauthToken: 'token',
1587
+ userId: 'user1',
1588
+ deviceUuid: 'device1',
1589
+ })
1590
+
1591
+ await expect(client.getChat('300')).resolves.toEqual({
1592
+ chat_id: '300',
1593
+ type: normalChatType,
1594
+ display_name: null,
1595
+ title: null,
1596
+ active_members: 2,
1597
+ unread_count: 0,
1598
+ last_message: null,
1599
+ })
1600
+ expect(mockGetOpenLinkInfo).not.toHaveBeenCalled()
1601
+
1602
+ client.close()
1603
+ })
1604
+ }
1605
+
1606
+ it('rejects an empty string CHATINFO type without contacting INFOLINK', async () => {
1607
+ mockGetChannelInfo.mockResolvedValueOnce({
1608
+ statusCode: 0,
1609
+ body: {
1610
+ chatInfo: {
1611
+ chatId: makeLong(300),
1612
+ type: '',
1613
+ activeMembersCount: 2,
1614
+ newMessageCount: 0,
1615
+ invalidNewMessageCount: false,
1616
+ lastLogId: makeLong(1),
1617
+ lastSeenLogId: makeLong(0),
1618
+ displayMembers: [],
1619
+ chatMetas: [],
1620
+ pushAlert: true,
1621
+ },
1622
+ },
1623
+ })
1624
+
1625
+ const client = await new KakaoTalkClient().login({
1626
+ oauthToken: 'token',
1627
+ userId: 'user1',
1628
+ deviceUuid: 'device1',
1629
+ })
1630
+
1631
+ await expect(client.getChat('300')).rejects.toMatchObject({
1632
+ code: 'get_chat_failed',
1633
+ getChatFailureReason: 'transport_or_unknown',
1634
+ responseFailureKind: 'transient_or_unknown',
1635
+ })
1636
+ expect(mockGetOpenLinkInfo).not.toHaveBeenCalled()
1637
+
1638
+ client.close()
1639
+ })
1640
+
1641
+ it('preserves an unknown non-empty string CHATINFO type without contacting INFOLINK', async () => {
1015
1642
  mockGetChannelInfo.mockResolvedValueOnce({
1016
1643
  statusCode: 0,
1017
1644
  body: {
1018
1645
  chatInfo: {
1019
1646
  chatId: makeLong(300),
1020
- type: 'UNKNOWN',
1647
+ type: 'UnknownChat',
1021
1648
  activeMembersCount: 2,
1022
1649
  newMessageCount: 0,
1023
1650
  invalidNewMessageCount: false,
@@ -1025,7 +1652,6 @@ describe('KakaoTalkClient', () => {
1025
1652
  lastSeenLogId: makeLong(0),
1026
1653
  displayMembers: [],
1027
1654
  chatMetas: [],
1028
- li: makeLong(7777),
1029
1655
  pushAlert: true,
1030
1656
  },
1031
1657
  },
@@ -1037,7 +1663,15 @@ describe('KakaoTalkClient', () => {
1037
1663
  deviceUuid: 'device1',
1038
1664
  })
1039
1665
 
1040
- await expect(client.getChat('300')).rejects.toMatchObject({ code: 'get_chat_failed' })
1666
+ await expect(client.getChat('300')).resolves.toEqual({
1667
+ chat_id: '300',
1668
+ type: 'UnknownChat',
1669
+ display_name: null,
1670
+ title: null,
1671
+ active_members: 2,
1672
+ unread_count: 0,
1673
+ last_message: null,
1674
+ })
1041
1675
  expect(mockGetOpenLinkInfo).not.toHaveBeenCalled()
1042
1676
 
1043
1677
  client.close()