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
@@ -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
 
@@ -120,6 +163,18 @@ function bsonToLong(v: unknown): Long | undefined {
120
163
  return undefined
121
164
  }
122
165
 
166
+ // LCHATLIST cursor fields (lastTokenId/lastChatId) arrive as plain numbers when the
167
+ // value fits in a safe integer, because bson promotes them, and as { low, high }
168
+ // otherwise. bsonToLong only accepts the object form, so a numeric cursor became
169
+ // undefined and getChatList() re-requested the first page until MAX_PAGES.
170
+ // Kept separate from bsonToLong on purpose: its other callers (log ids, open link
171
+ // ids) rely on a numeric 0 staying absent rather than becoming a Long.
172
+ function cursorToLong(v: unknown): Long | undefined {
173
+ if (typeof v === 'number' && Number.isSafeInteger(v)) return Long.fromNumber(v)
174
+ if (typeof v === 'bigint') return Long.fromBigInt(v)
175
+ return bsonToLong(v)
176
+ }
177
+
123
178
  function longToString(v: unknown): string {
124
179
  if (v && typeof v === 'object' && 'high' in v && 'low' in v) {
125
180
  const { high, low } = v as { high: number; low: number }
@@ -187,7 +242,7 @@ function formatChat(chat: ChatData, title: string | null, nameCache: MemberNameC
187
242
 
188
243
  return {
189
244
  chat_id: chatId,
190
- type: chat.t as KakaoChat['type'],
245
+ type: requiredChatType(chat.t),
191
246
  display_name: displayName,
192
247
  title,
193
248
  active_members: chat.a as number,
@@ -258,7 +313,7 @@ function requiredNonNegativeInteger(value: unknown, field: string): number {
258
313
  }
259
314
 
260
315
  function requiredChatType(value: unknown): KakaoChat['type'] {
261
- if (typeof value === 'string' && isOpenKakaoChatType(value)) {
316
+ if (typeof value === 'string' && value.length > 0) {
262
317
  return value
263
318
  }
264
319
  return requiredNonNegativeInteger(value, 'type')
@@ -352,10 +407,46 @@ function collectChats(chatDatas: ChatData[], into: ChatData[], seen: Set<string>
352
407
  }
353
408
  }
354
409
 
355
- function wrapError(error: unknown, code: string): KakaoTalkError {
356
- if (error instanceof KakaoTalkError) return error
410
+ function wrapError(
411
+ error: unknown,
412
+ code: string,
413
+ options?: { classifyResponseFailure?: boolean; classifyGetChatFailure?: boolean },
414
+ ): KakaoTalkError {
415
+ if (error instanceof KakaoTalkError) {
416
+ const responseFailureKind =
417
+ error.responseFailureKind ?? (options?.classifyResponseFailure ? 'transient_or_unknown' : undefined)
418
+ const getChatFailureReason =
419
+ error.getChatFailureReason ?? (options?.classifyGetChatFailure ? 'transport_or_unknown' : undefined)
420
+ if (responseFailureKind === error.responseFailureKind && getChatFailureReason === error.getChatFailureReason) {
421
+ return error
422
+ }
423
+ return new KakaoTalkError(error.message, error.code, {
424
+ cause: error.cause ?? error,
425
+ serverStatus: error.serverStatus,
426
+ responseFailureKind,
427
+ responseStatusSource: error.responseStatusSource,
428
+ getChatFailureReason,
429
+ })
430
+ }
357
431
  const message = error instanceof Error ? error.message : String(error)
358
- return new KakaoTalkError(message, code, { cause: error })
432
+ return new KakaoTalkError(message, code, {
433
+ cause: error,
434
+ serverStatus: error instanceof LocoResponseError ? error.serverStatus : undefined,
435
+ responseStatusSource: error instanceof LocoResponseError ? error.responseStatusSource : undefined,
436
+ responseFailureKind:
437
+ error instanceof LocoResponseError
438
+ ? error.responseFailureKind
439
+ : options?.classifyResponseFailure
440
+ ? 'transient_or_unknown'
441
+ : undefined,
442
+ getChatFailureReason: options?.classifyGetChatFailure
443
+ ? error instanceof LocoResponseError && error.responseFailureKind === 'provider_rejection'
444
+ ? 'provider_rejection'
445
+ : error instanceof LocoResponseError && error.responseStatusSource === 'packet' && error.serverStatus === -1
446
+ ? 'synthetic_connection_close'
447
+ : 'transport_or_unknown'
448
+ : undefined,
449
+ })
359
450
  }
360
451
 
361
452
  function isLoginResponseError(
@@ -502,12 +593,27 @@ function isNonZeroLong(v: unknown): boolean {
502
593
  // caller-visible error channel (e.g. GETMEM/MEMBER return `[]` for both empty rooms and dead
503
594
  // sockets). Throwing here lets executeWithReconnect detect session death and reconnect.
504
595
  function assertLocoOk(response: LocoPacket, command: string): void {
596
+ if (typeof response.statusCode !== 'number' || !Number.isFinite(response.statusCode)) {
597
+ throw new LocoResponseError(`${command} failed: response status unavailable`, 'transient_or_unknown')
598
+ }
599
+ if (isSyntheticConnectionClose(response)) {
600
+ throw new LocoResponseError(`${command} failed: connection closed`, 'transient_or_unknown', {
601
+ serverStatus: response.statusCode,
602
+ responseStatusSource: 'packet',
603
+ })
604
+ }
505
605
  if (response.statusCode !== 0) {
506
- throw new Error(`${command} failed: statusCode=${response.statusCode}`)
606
+ throw new LocoResponseError(`${command} failed: statusCode=${response.statusCode}`, 'provider_rejection', {
607
+ serverStatus: response.statusCode,
608
+ responseStatusSource: 'packet',
609
+ })
507
610
  }
508
611
  const bodyStatus = response.body.status
509
612
  if (typeof bodyStatus === 'number' && bodyStatus !== 0) {
510
- throw new Error(`${command} failed: body.status=${bodyStatus}`)
613
+ throw new LocoResponseError(`${command} failed: body.status=${bodyStatus}`, 'provider_rejection', {
614
+ serverStatus: bodyStatus,
615
+ responseStatusSource: 'body',
616
+ })
511
617
  }
512
618
  }
513
619
 
@@ -946,6 +1052,7 @@ export class KakaoTalkClient {
946
1052
  if (options?.all || options?.search || snapshotEmpty) {
947
1053
  let cursor: ChatListResponse = loginResult
948
1054
  let pages = 0
1055
+ const requireCompleteList = options?.all === true
949
1056
 
950
1057
  while (pages < MAX_PAGES) {
951
1058
  // Trust eof only when the snapshot had data. When the snapshot was empty
@@ -954,19 +1061,32 @@ export class KakaoTalkClient {
954
1061
  if (cursor.eof && !snapshotEmpty) break
955
1062
  if (cursor.eof && snapshotEmpty && pages > 0) break
956
1063
 
957
- const lastTokenId = bsonToLong(cursor.lastTokenId)
958
- const lastChatId = bsonToLong(cursor.lastChatId)
1064
+ const lastTokenId = cursorToLong(cursor.lastTokenId)
1065
+ const lastChatId = cursorToLong(cursor.lastChatId)
959
1066
 
960
1067
  const response = await session.getChatList(lastTokenId, lastChatId)
1068
+ if (requireCompleteList) assertLocoOk(response, 'LCHATLIST')
961
1069
  const body = response.body as unknown as ChatListResponse
1070
+ if (requireCompleteList && !Array.isArray(body.chatDatas)) {
1071
+ throw new Error('LCHATLIST pagination incomplete: chatDatas unavailable')
1072
+ }
962
1073
  const chatDatas = (body.chatDatas ?? []) as ChatData[]
1074
+ cursor = body
1075
+ pages++
963
1076
 
964
- if (chatDatas.length === 0) break
1077
+ if (chatDatas.length === 0) {
1078
+ if (requireCompleteList && body.eof !== true) {
1079
+ throw new Error('LCHATLIST pagination incomplete: empty non-EOF page')
1080
+ }
1081
+ break
1082
+ }
965
1083
 
966
1084
  collectChats(chatDatas, allChats, seenChatIds)
967
1085
  this.nameCache.ingest(chatDatas)
968
- cursor = body
969
- pages++
1086
+ }
1087
+
1088
+ if (requireCompleteList && cursor.eof !== true) {
1089
+ throw new Error(`LCHATLIST pagination incomplete after ${pages} pages`)
970
1090
  }
971
1091
  }
972
1092
 
@@ -996,6 +1116,9 @@ export class KakaoTalkClient {
996
1116
  * Unlike `getChats()`, this does not depend on the login-time snapshot or
997
1117
  * LCHATLIST pagination, so a room first observed from a live push can be
998
1118
  * resolved without interpreting protocol fields outside this SDK.
1119
+ * The returned chat type preserves numeric values and non-empty protocol
1120
+ * strings, including DirectChat, MultiChat, PlusChat, MemoChat, OM, and OD;
1121
+ * unknown non-empty strings are preserved for forward compatibility.
999
1122
  */
1000
1123
  async getChat(chatId: string): Promise<KakaoChat> {
1001
1124
  const parsedChatId = parseChatId(chatId)
@@ -1005,6 +1128,21 @@ export class KakaoTalkClient {
1005
1128
  const response = await session.getChannelInfo(parsedChatId)
1006
1129
  assertLocoOk(response, 'CHATINFO')
1007
1130
  const body = response.body as Record<string, unknown>
1131
+ const chatInfo = body.chatInfo
1132
+ const isLeftChatInfo =
1133
+ chatInfo !== null &&
1134
+ typeof chatInfo === 'object' &&
1135
+ !Array.isArray(chatInfo) &&
1136
+ (chatInfo as Record<string, unknown>).left === true
1137
+ if (isLeftChatInfo) {
1138
+ extractChannelInfo(body, normalizedChatId)
1139
+ }
1140
+ if (chatInfo === undefined || chatInfo === null || isLeftChatInfo) {
1141
+ throw new KakaoTalkError('CHATINFO response missing chatInfo', 'get_chat_failed', {
1142
+ responseFailureKind: 'transient_or_unknown',
1143
+ getChatFailureReason: 'chat_info_absent',
1144
+ })
1145
+ }
1008
1146
  const chat = channelInfoToChatData(body, normalizedChatId)
1009
1147
  this.nameCache.ingest([chat])
1010
1148
 
@@ -1023,7 +1161,10 @@ export class KakaoTalkClient {
1023
1161
 
1024
1162
  return formatChat(chat, title, this.nameCache)
1025
1163
  } catch (error) {
1026
- throw wrapError(error, 'get_chat_failed')
1164
+ throw wrapError(error, 'get_chat_failed', {
1165
+ classifyResponseFailure: true,
1166
+ classifyGetChatFailure: true,
1167
+ })
1027
1168
  }
1028
1169
  })
1029
1170
  }
@@ -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(),