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.
- 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 +107 -12
- 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 +572 -4
- package/src/platforms/kakaotalk/client.ts +142 -13
- 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
|
|
|
@@ -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
|
|
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' &&
|
|
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(
|
|
356
|
-
|
|
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, {
|
|
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
|
|
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
|
|
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)
|
|
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
|
-
|
|
969
|
-
|
|
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
|
-
|
|
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(),
|