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.
- package/.claude-plugin/plugin.json +1 -1
- package/dist/package.json +1 -1
- package/dist/src/platforms/kakaotalk/chat-classifier.d.ts +4 -4
- package/dist/src/platforms/kakaotalk/chat-classifier.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/chat-classifier.js +7 -3
- package/dist/src/platforms/kakaotalk/chat-classifier.js.map +1 -1
- package/dist/src/platforms/kakaotalk/client.d.ts +12 -0
- package/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/client.js +122 -14
- package/dist/src/platforms/kakaotalk/client.js.map +1 -1
- package/dist/src/platforms/kakaotalk/index.d.ts +1 -0
- package/dist/src/platforms/kakaotalk/index.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/index.js.map +1 -1
- package/dist/src/platforms/kakaotalk/protocol/connection.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/protocol/connection.js +13 -2
- package/dist/src/platforms/kakaotalk/protocol/connection.js.map +1 -1
- package/dist/src/platforms/kakaotalk/protocol/login-response.d.ts +1 -0
- package/dist/src/platforms/kakaotalk/protocol/login-response.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/protocol/login-response.js +1 -1
- package/dist/src/platforms/kakaotalk/protocol/login-response.js.map +1 -1
- package/dist/src/platforms/kakaotalk/types.d.ts +4 -2
- package/dist/src/platforms/kakaotalk/types.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/types.js +6 -1
- package/dist/src/platforms/kakaotalk/types.js.map +1 -1
- package/package.json +1 -1
- package/skills/agent-channeltalk/SKILL.md +1 -1
- package/skills/agent-channeltalkbot/SKILL.md +1 -1
- package/skills/agent-discord/SKILL.md +1 -1
- package/skills/agent-discordbot/SKILL.md +1 -1
- package/skills/agent-imessage/SKILL.md +1 -1
- package/skills/agent-instagram/SKILL.md +1 -1
- package/skills/agent-kakaotalk/SKILL.md +1 -1
- package/skills/agent-line/SKILL.md +1 -1
- package/skills/agent-slack/SKILL.md +1 -1
- package/skills/agent-slackbot/SKILL.md +1 -1
- package/skills/agent-teams/SKILL.md +1 -1
- package/skills/agent-telegram/SKILL.md +1 -1
- package/skills/agent-telegrambot/SKILL.md +1 -1
- package/skills/agent-webex/SKILL.md +1 -1
- package/skills/agent-webexbot/SKILL.md +1 -1
- package/skills/agent-wechatbot/SKILL.md +1 -1
- package/skills/agent-whatsapp/SKILL.md +1 -1
- package/skills/agent-whatsappbot/SKILL.md +1 -1
- package/src/platforms/kakaotalk/chat-classifier.test.ts +22 -0
- package/src/platforms/kakaotalk/chat-classifier.ts +7 -4
- package/src/platforms/kakaotalk/client.test.ts +638 -4
- package/src/platforms/kakaotalk/client.ts +156 -15
- package/src/platforms/kakaotalk/index.test.ts +27 -0
- package/src/platforms/kakaotalk/index.ts +5 -0
- package/src/platforms/kakaotalk/protocol/connection.test.ts +87 -0
- package/src/platforms/kakaotalk/protocol/connection.ts +15 -2
- package/src/platforms/kakaotalk/protocol/login-response.test.ts +13 -2
- package/src/platforms/kakaotalk/protocol/login-response.ts +1 -1
- 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
|
-
|
|
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
|
|
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' &&
|
|
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(
|
|
356
|
-
|
|
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, {
|
|
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
|
|
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
|
|
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 =
|
|
958
|
-
const 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)
|
|
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
|
-
|
|
969
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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(),
|