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
@@ -12,6 +12,7 @@ import { isOpenKakaoChatType } from './chat-classifier'
12
12
  import { detectImageDimensions } from './image-meta'
13
13
  import { sha1Hex } from './media-upload'
14
14
  import { LANG, PC_OS_NAME, getLocoDeviceConfig } from './protocol/config'
15
+ import { isSyntheticConnectionClose } from './protocol/login-response'
15
16
  import { uploadMediaToLoco, uploadMultiMediaEntry } from './protocol/media-uploader'
16
17
  import { LocoSession } from './protocol/session'
17
18
  import type { ChatListResponse, LocoPacket, LoginListResponse, SyncState } from './protocol/types'
@@ -41,15 +42,57 @@ export type KakaoSessionEvent =
41
42
  export type KakaoPushHandler = (packet: LocoPacket) => void
42
43
  export type KakaoSessionEventHandler = (event: KakaoSessionEvent) => void
43
44
 
45
+ export type KakaoTalkResponseFailureKind = 'provider_rejection' | 'transient_or_unknown'
46
+ export type KakaoTalkResponseStatusSource = 'packet' | 'body'
47
+ export type KakaoTalkGetChatFailureReason =
48
+ | 'provider_rejection'
49
+ | 'synthetic_connection_close'
50
+ | 'chat_info_absent'
51
+ | 'transport_or_unknown'
52
+
44
53
  export class KakaoTalkError extends Error {
45
54
  code: string
46
55
  readonly serverStatus?: number
47
-
48
- constructor(message: string, code: string, options?: { cause?: unknown; serverStatus?: number }) {
56
+ readonly responseFailureKind?: KakaoTalkResponseFailureKind
57
+ readonly responseStatusSource?: KakaoTalkResponseStatusSource
58
+ readonly getChatFailureReason?: KakaoTalkGetChatFailureReason
59
+
60
+ constructor(
61
+ message: string,
62
+ code: string,
63
+ options?: {
64
+ cause?: unknown
65
+ serverStatus?: number
66
+ responseFailureKind?: KakaoTalkResponseFailureKind
67
+ responseStatusSource?: KakaoTalkResponseStatusSource
68
+ getChatFailureReason?: KakaoTalkGetChatFailureReason
69
+ },
70
+ ) {
49
71
  super(message, options)
50
72
  this.name = 'KakaoTalkError'
51
73
  this.code = code
52
74
  this.serverStatus = options?.serverStatus
75
+ this.responseFailureKind = options?.responseFailureKind
76
+ this.responseStatusSource = options?.responseStatusSource
77
+ this.getChatFailureReason = options?.getChatFailureReason
78
+ }
79
+ }
80
+
81
+ class LocoResponseError extends Error {
82
+ readonly serverStatus?: number
83
+ readonly responseFailureKind: KakaoTalkResponseFailureKind
84
+ readonly responseStatusSource?: KakaoTalkResponseStatusSource
85
+
86
+ constructor(
87
+ message: string,
88
+ responseFailureKind: KakaoTalkResponseFailureKind,
89
+ options?: { serverStatus?: number; responseStatusSource?: KakaoTalkResponseStatusSource },
90
+ ) {
91
+ super(message)
92
+ this.name = 'LocoResponseError'
93
+ this.serverStatus = options?.serverStatus
94
+ this.responseFailureKind = responseFailureKind
95
+ this.responseStatusSource = options?.responseStatusSource
53
96
  }
54
97
  }
55
98
 
@@ -187,7 +230,7 @@ function formatChat(chat: ChatData, title: string | null, nameCache: MemberNameC
187
230
 
188
231
  return {
189
232
  chat_id: chatId,
190
- type: chat.t as KakaoChat['type'],
233
+ type: requiredChatType(chat.t),
191
234
  display_name: displayName,
192
235
  title,
193
236
  active_members: chat.a as number,
@@ -258,7 +301,7 @@ function requiredNonNegativeInteger(value: unknown, field: string): number {
258
301
  }
259
302
 
260
303
  function requiredChatType(value: unknown): KakaoChat['type'] {
261
- if (typeof value === 'string' && isOpenKakaoChatType(value)) {
304
+ if (typeof value === 'string' && value.length > 0) {
262
305
  return value
263
306
  }
264
307
  return requiredNonNegativeInteger(value, 'type')
@@ -352,10 +395,46 @@ function collectChats(chatDatas: ChatData[], into: ChatData[], seen: Set<string>
352
395
  }
353
396
  }
354
397
 
355
- function wrapError(error: unknown, code: string): KakaoTalkError {
356
- if (error instanceof KakaoTalkError) return error
398
+ function wrapError(
399
+ error: unknown,
400
+ code: string,
401
+ options?: { classifyResponseFailure?: boolean; classifyGetChatFailure?: boolean },
402
+ ): KakaoTalkError {
403
+ if (error instanceof KakaoTalkError) {
404
+ const responseFailureKind =
405
+ error.responseFailureKind ?? (options?.classifyResponseFailure ? 'transient_or_unknown' : undefined)
406
+ const getChatFailureReason =
407
+ error.getChatFailureReason ?? (options?.classifyGetChatFailure ? 'transport_or_unknown' : undefined)
408
+ if (responseFailureKind === error.responseFailureKind && getChatFailureReason === error.getChatFailureReason) {
409
+ return error
410
+ }
411
+ return new KakaoTalkError(error.message, error.code, {
412
+ cause: error.cause ?? error,
413
+ serverStatus: error.serverStatus,
414
+ responseFailureKind,
415
+ responseStatusSource: error.responseStatusSource,
416
+ getChatFailureReason,
417
+ })
418
+ }
357
419
  const message = error instanceof Error ? error.message : String(error)
358
- return new KakaoTalkError(message, code, { cause: error })
420
+ return new KakaoTalkError(message, code, {
421
+ cause: error,
422
+ serverStatus: error instanceof LocoResponseError ? error.serverStatus : undefined,
423
+ responseStatusSource: error instanceof LocoResponseError ? error.responseStatusSource : undefined,
424
+ responseFailureKind:
425
+ error instanceof LocoResponseError
426
+ ? error.responseFailureKind
427
+ : options?.classifyResponseFailure
428
+ ? 'transient_or_unknown'
429
+ : undefined,
430
+ getChatFailureReason: options?.classifyGetChatFailure
431
+ ? error instanceof LocoResponseError && error.responseFailureKind === 'provider_rejection'
432
+ ? 'provider_rejection'
433
+ : error instanceof LocoResponseError && error.responseStatusSource === 'packet' && error.serverStatus === -1
434
+ ? 'synthetic_connection_close'
435
+ : 'transport_or_unknown'
436
+ : undefined,
437
+ })
359
438
  }
360
439
 
361
440
  function isLoginResponseError(
@@ -502,12 +581,27 @@ function isNonZeroLong(v: unknown): boolean {
502
581
  // caller-visible error channel (e.g. GETMEM/MEMBER return `[]` for both empty rooms and dead
503
582
  // sockets). Throwing here lets executeWithReconnect detect session death and reconnect.
504
583
  function assertLocoOk(response: LocoPacket, command: string): void {
584
+ if (typeof response.statusCode !== 'number' || !Number.isFinite(response.statusCode)) {
585
+ throw new LocoResponseError(`${command} failed: response status unavailable`, 'transient_or_unknown')
586
+ }
587
+ if (isSyntheticConnectionClose(response)) {
588
+ throw new LocoResponseError(`${command} failed: connection closed`, 'transient_or_unknown', {
589
+ serverStatus: response.statusCode,
590
+ responseStatusSource: 'packet',
591
+ })
592
+ }
505
593
  if (response.statusCode !== 0) {
506
- throw new Error(`${command} failed: statusCode=${response.statusCode}`)
594
+ throw new LocoResponseError(`${command} failed: statusCode=${response.statusCode}`, 'provider_rejection', {
595
+ serverStatus: response.statusCode,
596
+ responseStatusSource: 'packet',
597
+ })
507
598
  }
508
599
  const bodyStatus = response.body.status
509
600
  if (typeof bodyStatus === 'number' && bodyStatus !== 0) {
510
- throw new Error(`${command} failed: body.status=${bodyStatus}`)
601
+ throw new LocoResponseError(`${command} failed: body.status=${bodyStatus}`, 'provider_rejection', {
602
+ serverStatus: bodyStatus,
603
+ responseStatusSource: 'body',
604
+ })
511
605
  }
512
606
  }
513
607
 
@@ -946,6 +1040,7 @@ export class KakaoTalkClient {
946
1040
  if (options?.all || options?.search || snapshotEmpty) {
947
1041
  let cursor: ChatListResponse = loginResult
948
1042
  let pages = 0
1043
+ const requireCompleteList = options?.all === true
949
1044
 
950
1045
  while (pages < MAX_PAGES) {
951
1046
  // Trust eof only when the snapshot had data. When the snapshot was empty
@@ -958,15 +1053,28 @@ export class KakaoTalkClient {
958
1053
  const lastChatId = bsonToLong(cursor.lastChatId)
959
1054
 
960
1055
  const response = await session.getChatList(lastTokenId, lastChatId)
1056
+ if (requireCompleteList) assertLocoOk(response, 'LCHATLIST')
961
1057
  const body = response.body as unknown as ChatListResponse
1058
+ if (requireCompleteList && !Array.isArray(body.chatDatas)) {
1059
+ throw new Error('LCHATLIST pagination incomplete: chatDatas unavailable')
1060
+ }
962
1061
  const chatDatas = (body.chatDatas ?? []) as ChatData[]
1062
+ cursor = body
1063
+ pages++
963
1064
 
964
- if (chatDatas.length === 0) break
1065
+ if (chatDatas.length === 0) {
1066
+ if (requireCompleteList && body.eof !== true) {
1067
+ throw new Error('LCHATLIST pagination incomplete: empty non-EOF page')
1068
+ }
1069
+ break
1070
+ }
965
1071
 
966
1072
  collectChats(chatDatas, allChats, seenChatIds)
967
1073
  this.nameCache.ingest(chatDatas)
968
- cursor = body
969
- pages++
1074
+ }
1075
+
1076
+ if (requireCompleteList && cursor.eof !== true) {
1077
+ throw new Error(`LCHATLIST pagination incomplete after ${pages} pages`)
970
1078
  }
971
1079
  }
972
1080
 
@@ -996,6 +1104,9 @@ export class KakaoTalkClient {
996
1104
  * Unlike `getChats()`, this does not depend on the login-time snapshot or
997
1105
  * LCHATLIST pagination, so a room first observed from a live push can be
998
1106
  * resolved without interpreting protocol fields outside this SDK.
1107
+ * The returned chat type preserves numeric values and non-empty protocol
1108
+ * strings, including DirectChat, MultiChat, PlusChat, MemoChat, OM, and OD;
1109
+ * unknown non-empty strings are preserved for forward compatibility.
999
1110
  */
1000
1111
  async getChat(chatId: string): Promise<KakaoChat> {
1001
1112
  const parsedChatId = parseChatId(chatId)
@@ -1005,6 +1116,21 @@ export class KakaoTalkClient {
1005
1116
  const response = await session.getChannelInfo(parsedChatId)
1006
1117
  assertLocoOk(response, 'CHATINFO')
1007
1118
  const body = response.body as Record<string, unknown>
1119
+ const chatInfo = body.chatInfo
1120
+ const isLeftChatInfo =
1121
+ chatInfo !== null &&
1122
+ typeof chatInfo === 'object' &&
1123
+ !Array.isArray(chatInfo) &&
1124
+ (chatInfo as Record<string, unknown>).left === true
1125
+ if (isLeftChatInfo) {
1126
+ extractChannelInfo(body, normalizedChatId)
1127
+ }
1128
+ if (chatInfo === undefined || chatInfo === null || isLeftChatInfo) {
1129
+ throw new KakaoTalkError('CHATINFO response missing chatInfo', 'get_chat_failed', {
1130
+ responseFailureKind: 'transient_or_unknown',
1131
+ getChatFailureReason: 'chat_info_absent',
1132
+ })
1133
+ }
1008
1134
  const chat = channelInfoToChatData(body, normalizedChatId)
1009
1135
  this.nameCache.ingest([chat])
1010
1136
 
@@ -1023,7 +1149,10 @@ export class KakaoTalkClient {
1023
1149
 
1024
1150
  return formatChat(chat, title, this.nameCache)
1025
1151
  } catch (error) {
1026
- throw wrapError(error, 'get_chat_failed')
1152
+ throw wrapError(error, 'get_chat_failed', {
1153
+ classifyResponseFailure: true,
1154
+ classifyGetChatFailure: true,
1155
+ })
1027
1156
  }
1028
1157
  })
1029
1158
  }
@@ -49,6 +49,33 @@ it('KakaoChatSchema is exported from barrel', () => {
49
49
  expect(typeof KakaoChatSchema.parse).toBe('function')
50
50
  })
51
51
 
52
+ it('KakaoChatSchema preserves a non-empty string channel type', () => {
53
+ for (const channelType of ['DirectChat', 'MultiChat', 'PlusChat', 'MemoChat', 'OM', 'OD', 'FutureChat']) {
54
+ expect(
55
+ KakaoChatSchema.parse({
56
+ chat_id: '300',
57
+ type: channelType,
58
+ display_name: null,
59
+ title: null,
60
+ active_members: 2,
61
+ unread_count: 0,
62
+ last_message: null,
63
+ }).type,
64
+ ).toBe(channelType)
65
+ }
66
+ expect(() =>
67
+ KakaoChatSchema.parse({
68
+ chat_id: '300',
69
+ type: '',
70
+ display_name: null,
71
+ title: null,
72
+ active_members: 2,
73
+ unread_count: 0,
74
+ last_message: null,
75
+ }),
76
+ ).toThrow()
77
+ })
78
+
52
79
  it('KakaoMessageSchema is exported from barrel', () => {
53
80
  expect(typeof KakaoMessageSchema.parse).toBe('function')
54
81
  })
@@ -1,4 +1,9 @@
1
1
  export { KakaoTalkClient, KakaoTalkError } from './client'
2
+ export type {
3
+ KakaoTalkGetChatFailureReason,
4
+ KakaoTalkResponseFailureKind,
5
+ KakaoTalkResponseStatusSource,
6
+ } from './client'
2
7
  export { classifyKakaoChat, isOpenKakaoChatType } from './chat-classifier'
3
8
  export type { KakaoChatKind } from './chat-classifier'
4
9
  export { KakaoCredentialManager, CredentialManager } from './credential-manager'
@@ -0,0 +1,87 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import { LocoConnection } from './connection'
4
+ import { encodePacket } from './packet'
5
+ import type { LocoPacket } from './types'
6
+
7
+ interface ConnectionInternals {
8
+ socket: {
9
+ write: (data: Buffer, callback: (error?: Error) => void) => boolean
10
+ } | null
11
+ pendingResolvers: Map<number, unknown>
12
+ timedOutIds: Set<number>
13
+ onData: (chunk: Buffer) => void
14
+ }
15
+
16
+ function internals(connection: LocoConnection): ConnectionInternals {
17
+ return connection as unknown as ConnectionInternals
18
+ }
19
+
20
+ describe('LocoConnection.sendPacket', () => {
21
+ it('resolves a response delivered synchronously during write without double settlement', async () => {
22
+ const connection = new LocoConnection()
23
+ const owned = internals(connection)
24
+ const response: LocoPacket = {
25
+ packetId: 1,
26
+ statusCode: 0,
27
+ method: 'FAST',
28
+ bodyType: 0,
29
+ body: { accepted: true },
30
+ }
31
+ const pushed: LocoPacket[] = []
32
+ connection.onPush((packet) => pushed.push(packet))
33
+ owned.socket = {
34
+ write: (_data, callback) => {
35
+ owned.onData(encodePacket(response))
36
+ callback(new Error('late write callback failure'))
37
+ return false
38
+ },
39
+ }
40
+
41
+ await expect(connection.sendPacket('FAST')).resolves.toEqual(response)
42
+ expect(pushed).toEqual([])
43
+ expect(owned.pendingResolvers.size).toBe(0)
44
+ expect(owned.timedOutIds.size).toBe(0)
45
+ })
46
+
47
+ it('rejects a write failure and clears only its pending entry', async () => {
48
+ const connection = new LocoConnection()
49
+ const owned = internals(connection)
50
+ const pushed: LocoPacket[] = []
51
+ const sentinelTimer = setTimeout(() => {}, 60_000)
52
+ const sentinel = { resolve: () => {}, timer: sentinelTimer }
53
+ owned.pendingResolvers.set(99, sentinel)
54
+ connection.onPush((packet) => pushed.push(packet))
55
+ owned.socket = {
56
+ write: (_data, callback) => {
57
+ callback(new Error('socket write failed'))
58
+ return false
59
+ },
60
+ }
61
+
62
+ try {
63
+ await expect(connection.sendPacket('FAIL')).rejects.toThrow('socket write failed')
64
+ expect(owned.pendingResolvers.size).toBe(1)
65
+ expect(owned.pendingResolvers.get(99)).toBe(sentinel)
66
+ expect(owned.timedOutIds.has(1)).toBe(true)
67
+
68
+ owned.onData(
69
+ encodePacket({
70
+ packetId: 1,
71
+ statusCode: 0,
72
+ method: 'FAIL',
73
+ bodyType: 0,
74
+ body: { late: true },
75
+ }),
76
+ )
77
+
78
+ expect(pushed).toEqual([])
79
+ expect(owned.pendingResolvers.size).toBe(1)
80
+ expect(owned.pendingResolvers.get(99)).toBe(sentinel)
81
+ expect(owned.timedOutIds.size).toBe(0)
82
+ } finally {
83
+ clearTimeout(sentinelTimer)
84
+ owned.pendingResolvers.delete(99)
85
+ }
86
+ })
87
+ })
@@ -73,15 +73,28 @@ export class LocoConnection {
73
73
 
74
74
  const raw = encodePacket(packet)
75
75
  const data = this.crypto ? this.crypto.encrypt(raw) : raw
76
- await this.write(data)
77
76
 
78
77
  return new Promise((resolve, reject) => {
78
+ let entry: {
79
+ resolve: (packet: LocoPacket) => void
80
+ timer: ReturnType<typeof setTimeout>
81
+ }
79
82
  const timer = setTimeout(() => {
83
+ if (this.pendingResolvers.get(packetId) !== entry) return
80
84
  this.pendingResolvers.delete(packetId)
81
85
  this.timedOutIds.add(packetId)
82
86
  reject(new Error(`LOCO packet timeout: ${method} (${DEFAULT_SEND_TIMEOUT_MS}ms)`))
83
87
  }, DEFAULT_SEND_TIMEOUT_MS)
84
- this.pendingResolvers.set(packetId, { resolve, timer })
88
+ entry = { resolve, timer }
89
+ this.pendingResolvers.set(packetId, entry)
90
+
91
+ void this.write(data).catch((error: unknown) => {
92
+ if (this.pendingResolvers.get(packetId) !== entry) return
93
+ clearTimeout(entry.timer)
94
+ this.pendingResolvers.delete(packetId)
95
+ this.timedOutIds.add(packetId)
96
+ reject(error)
97
+ })
85
98
  })
86
99
  }
87
100
 
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from 'bun:test'
2
2
 
3
- import { validateLoginListResponse } from './login-response'
3
+ import { isSyntheticConnectionClose, validateLoginListResponse } from './login-response'
4
4
  import type { LocoPacket } from './types'
5
5
 
6
6
  function packet(statusCode: number, body: Record<string, unknown>): LocoPacket {
@@ -54,11 +54,22 @@ describe('LOGINLIST response classification', () => {
54
54
  })
55
55
 
56
56
  it('keeps a synthetic socket close on the transport-error path', () => {
57
- const error = captureError(() => validateLoginListResponse(packet(-1, { error: 'connection closed' })))
57
+ const response = packet(-1, { error: 'connection closed' })
58
+ const error = captureError(() => validateLoginListResponse(response))
58
59
 
60
+ expect(isSyntheticConnectionClose(response)).toBe(true)
59
61
  expect(error).toBeInstanceOf(Error)
60
62
  expect(error).toMatchObject({ message: 'KakaoTalk LOGINLIST transport failed: connection closed' })
61
63
  expect(error).not.toHaveProperty('code')
62
64
  expect(error).not.toHaveProperty('serverStatus')
63
65
  })
66
+
67
+ it('does not classify status -1 with a nonmatching body as a synthetic socket close', () => {
68
+ const response = packet(-1, { error: 'provider rejected' })
69
+
70
+ expect(isSyntheticConnectionClose(response)).toBe(false)
71
+ expect(() => validateLoginListResponse(response)).toThrow(
72
+ expect.objectContaining({ code: 'login_rejected', serverStatus: -1 }),
73
+ )
74
+ })
64
75
  })
@@ -17,7 +17,7 @@ export class KakaoLoginResponseError extends Error {
17
17
  }
18
18
  }
19
19
 
20
- function isSyntheticConnectionClose(response: LocoPacket): boolean {
20
+ export function isSyntheticConnectionClose(response: LocoPacket): boolean {
21
21
  return response.statusCode === CONNECTION_CLOSED_STATUS && response.body.error === 'connection closed'
22
22
  }
23
23
 
@@ -71,7 +71,15 @@ export const KAKAO_NEXT_ACTIONS: Record<string, { next_action: string; message:
71
71
  }
72
72
 
73
73
  export type KakaoOpenChatType = 2 | 13 | 14 | 15 | 16 | 'OM' | 'OD'
74
- export type KakaoChatType = number | Extract<KakaoOpenChatType, string>
74
+ export const KNOWN_KAKAO_CHAT_STRING_TYPES = ['DirectChat', 'MultiChat', 'PlusChat', 'MemoChat', 'OM', 'OD'] as const
75
+ export type KakaoChatType = number | string
76
+
77
+ export function isKnownKakaoChatStringType(value: unknown): value is (typeof KNOWN_KAKAO_CHAT_STRING_TYPES)[number] {
78
+ return (
79
+ typeof value === 'string' &&
80
+ KNOWN_KAKAO_CHAT_STRING_TYPES.includes(value as (typeof KNOWN_KAKAO_CHAT_STRING_TYPES)[number])
81
+ )
82
+ }
75
83
 
76
84
  export interface KakaoChat {
77
85
  chat_id: string
@@ -239,7 +247,7 @@ export interface KakaoTypingResult {
239
247
 
240
248
  export const KakaoChatSchema = z.object({
241
249
  chat_id: z.string(),
242
- type: z.union([z.number(), z.literal('OM'), z.literal('OD')]),
250
+ type: z.union([z.number(), z.string().min(1)]),
243
251
  display_name: z.string().nullable(),
244
252
  title: z.string().nullable(),
245
253
  active_members: z.number(),