agent-messenger 2.12.1 → 2.13.0
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 +18 -0
- package/dist/src/platforms/kakaotalk/chat-classifier.d.ts.map +1 -0
- package/dist/src/platforms/kakaotalk/chat-classifier.js +29 -0
- package/dist/src/platforms/kakaotalk/chat-classifier.js.map +1 -0
- package/dist/src/platforms/kakaotalk/cli.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/cli.js +2 -1
- package/dist/src/platforms/kakaotalk/cli.js.map +1 -1
- package/dist/src/platforms/kakaotalk/client.d.ts +35 -1
- package/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/client.js +318 -15
- package/dist/src/platforms/kakaotalk/client.js.map +1 -1
- package/dist/src/platforms/kakaotalk/commands/chat.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/commands/chat.js +2 -1
- package/dist/src/platforms/kakaotalk/commands/chat.js.map +1 -1
- package/dist/src/platforms/kakaotalk/commands/index.d.ts +1 -0
- package/dist/src/platforms/kakaotalk/commands/index.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/commands/index.js +1 -0
- package/dist/src/platforms/kakaotalk/commands/index.js.map +1 -1
- package/dist/src/platforms/kakaotalk/commands/member.d.ts +3 -0
- package/dist/src/platforms/kakaotalk/commands/member.d.ts.map +1 -0
- package/dist/src/platforms/kakaotalk/commands/member.js +22 -0
- package/dist/src/platforms/kakaotalk/commands/member.js.map +1 -0
- package/dist/src/platforms/kakaotalk/index.d.ts +4 -2
- package/dist/src/platforms/kakaotalk/index.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/index.js +2 -1
- package/dist/src/platforms/kakaotalk/index.js.map +1 -1
- package/dist/src/platforms/kakaotalk/listener.d.ts +4 -7
- package/dist/src/platforms/kakaotalk/listener.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/listener.js +48 -74
- package/dist/src/platforms/kakaotalk/listener.js.map +1 -1
- package/dist/src/platforms/kakaotalk/protocol/session.d.ts +28 -0
- package/dist/src/platforms/kakaotalk/protocol/session.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/protocol/session.js +44 -0
- package/dist/src/platforms/kakaotalk/protocol/session.js.map +1 -1
- package/dist/src/platforms/kakaotalk/types.d.ts +37 -0
- package/dist/src/platforms/kakaotalk/types.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/types.js +17 -0
- package/dist/src/platforms/kakaotalk/types.js.map +1 -1
- package/dist/src/platforms/slackbot/client.d.ts +5 -0
- package/dist/src/platforms/slackbot/client.d.ts.map +1 -1
- package/dist/src/platforms/slackbot/client.js +5 -0
- package/dist/src/platforms/slackbot/client.js.map +1 -1
- package/dist/src/tui/adapters/kakaotalk-adapter.js +3 -3
- package/dist/src/tui/adapters/kakaotalk-adapter.js.map +1 -1
- package/docs/content/docs/cli/kakaotalk.mdx +26 -1
- package/docs/content/docs/sdk/kakaotalk.mdx +45 -13
- package/docs/content/docs/sdk/slackbot.mdx +11 -0
- package/package.json +1 -1
- package/scripts/kakao-loco-capture.ts +466 -0
- 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-instagram/SKILL.md +1 -1
- package/skills/agent-kakaotalk/SKILL.md +30 -3
- package/skills/agent-kakaotalk/references/common-patterns.md +49 -1
- package/skills/agent-line/SKILL.md +1 -1
- package/skills/agent-slack/SKILL.md +1 -1
- package/skills/agent-slackbot/SKILL.md +1 -2
- 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-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 +33 -0
- package/src/platforms/kakaotalk/chat-classifier.ts +31 -0
- package/src/platforms/kakaotalk/cli.ts +2 -1
- package/src/platforms/kakaotalk/client-listener-integration.test.ts +411 -0
- package/src/platforms/kakaotalk/client.test.ts +785 -1
- package/src/platforms/kakaotalk/client.ts +369 -18
- package/src/platforms/kakaotalk/commands/chat.ts +3 -1
- package/src/platforms/kakaotalk/commands/index.ts +1 -0
- package/src/platforms/kakaotalk/commands/member.test.ts +102 -0
- package/src/platforms/kakaotalk/commands/member.ts +32 -0
- package/src/platforms/kakaotalk/index.test.ts +5 -0
- package/src/platforms/kakaotalk/index.ts +4 -0
- package/src/platforms/kakaotalk/listener.test.ts +184 -149
- package/src/platforms/kakaotalk/listener.ts +51 -82
- package/src/platforms/kakaotalk/protocol/session.ts +44 -0
- package/src/platforms/kakaotalk/types.ts +39 -0
- package/src/platforms/slackbot/client.test.ts +67 -0
- package/src/platforms/slackbot/client.ts +17 -1
- package/src/tui/adapters/kakaotalk-adapter.ts +3 -3
|
@@ -9,8 +9,16 @@ import { warn } from '@/shared/utils/stderr'
|
|
|
9
9
|
|
|
10
10
|
import { LANG, PC_OS_NAME, getLocoDeviceConfig } from './protocol/config'
|
|
11
11
|
import { LocoSession } from './protocol/session'
|
|
12
|
-
import type { ChatListResponse, LoginListResponse, SyncState } from './protocol/types'
|
|
13
|
-
import type { KakaoChat, KakaoDeviceType, KakaoMessage, KakaoProfile, KakaoSendResult } from './types'
|
|
12
|
+
import type { ChatListResponse, LocoPacket, LoginListResponse, SyncState } from './protocol/types'
|
|
13
|
+
import type { KakaoChat, KakaoDeviceType, KakaoMember, KakaoMessage, KakaoProfile, KakaoSendResult } from './types'
|
|
14
|
+
|
|
15
|
+
export type KakaoSessionEvent =
|
|
16
|
+
| { type: 'connected'; userId: string }
|
|
17
|
+
| { type: 'disconnected' }
|
|
18
|
+
| { type: 'kicked'; reason: string }
|
|
19
|
+
|
|
20
|
+
export type KakaoPushHandler = (packet: LocoPacket) => void
|
|
21
|
+
export type KakaoSessionEventHandler = (event: KakaoSessionEvent) => void
|
|
14
22
|
|
|
15
23
|
export class KakaoTalkError extends Error {
|
|
16
24
|
code: string
|
|
@@ -29,6 +37,58 @@ interface SessionState {
|
|
|
29
37
|
loginResult: LoginListResponse
|
|
30
38
|
}
|
|
31
39
|
|
|
40
|
+
class MemberNameCache {
|
|
41
|
+
private byChatId = new Map<string, Map<number, string>>()
|
|
42
|
+
|
|
43
|
+
ingest(chatDatas: readonly ChatData[]): void {
|
|
44
|
+
for (const chat of chatDatas) {
|
|
45
|
+
const ids = chat.i as Array<{ low: number; high: number } | number> | undefined
|
|
46
|
+
const names = chat.k as string[] | undefined
|
|
47
|
+
if (!Array.isArray(ids) || !Array.isArray(names)) continue
|
|
48
|
+
|
|
49
|
+
const chatId = String(chat.c)
|
|
50
|
+
let map = this.byChatId.get(chatId)
|
|
51
|
+
if (!map) {
|
|
52
|
+
map = new Map()
|
|
53
|
+
this.byChatId.set(chatId, map)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const len = Math.min(ids.length, names.length)
|
|
57
|
+
for (let i = 0; i < len; i++) {
|
|
58
|
+
const numericId = toNumericUserId(ids[i])
|
|
59
|
+
if (numericId === null) continue
|
|
60
|
+
const name = names[i]
|
|
61
|
+
if (typeof name === 'string' && name.length > 0) {
|
|
62
|
+
map.set(numericId, name)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
lookup(chatId: string, userId: number): string | null {
|
|
69
|
+
return this.byChatId.get(chatId)?.get(userId) ?? null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
forget(chatId: string): void {
|
|
73
|
+
this.byChatId.delete(chatId)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
clear(): void {
|
|
77
|
+
this.byChatId.clear()
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function toNumericUserId(v: unknown): number | null {
|
|
82
|
+
if (typeof v === 'number') return Number.isFinite(v) ? v : null
|
|
83
|
+
if (v && typeof v === 'object' && 'low' in v && 'high' in v) {
|
|
84
|
+
const { low, high } = v as { low: number; high: number }
|
|
85
|
+
// chatDatas[].i entries are member user IDs. KakaoTalk user IDs fit in
|
|
86
|
+
// 53 bits — safe to flatten the BSON Long pair to a JS number for keying.
|
|
87
|
+
return (high >>> 0) * 0x100000000 + (low >>> 0)
|
|
88
|
+
}
|
|
89
|
+
return null
|
|
90
|
+
}
|
|
91
|
+
|
|
32
92
|
function bsonToLong(v: unknown): Long | undefined {
|
|
33
93
|
if (v && typeof v === 'object' && 'high' in v && 'low' in v) {
|
|
34
94
|
const { high, low } = v as { high: number; low: number }
|
|
@@ -52,20 +112,39 @@ function parseLong(s: string): Long {
|
|
|
52
112
|
return new Long(low, high)
|
|
53
113
|
}
|
|
54
114
|
|
|
55
|
-
function
|
|
115
|
+
function parseChatId(chatId: string): Long {
|
|
116
|
+
try {
|
|
117
|
+
return parseLong(chatId)
|
|
118
|
+
} catch (cause) {
|
|
119
|
+
throw new KakaoTalkError(`Invalid chatId: ${chatId}`, 'invalid_chat_id', { cause })
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function parseUserId(userId: string): Long {
|
|
124
|
+
try {
|
|
125
|
+
return parseLong(userId)
|
|
126
|
+
} catch (cause) {
|
|
127
|
+
throw new KakaoTalkError(`Invalid userId: ${userId}`, 'invalid_user_id', { cause })
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function formatChat(chat: ChatData, title: string | null, nameCache: MemberNameCache): KakaoChat {
|
|
56
132
|
const memberNames = (chat.k ?? []) as string[]
|
|
57
133
|
const lastLog = chat.l as Record<string, unknown> | null
|
|
58
134
|
const displayName = memberNames.join(', ') || null
|
|
135
|
+
const chatId = String(chat.c)
|
|
59
136
|
|
|
60
137
|
return {
|
|
61
|
-
chat_id:
|
|
138
|
+
chat_id: chatId,
|
|
62
139
|
type: chat.t as number,
|
|
63
140
|
display_name: displayName,
|
|
141
|
+
title,
|
|
64
142
|
active_members: chat.a as number,
|
|
65
143
|
unread_count: chat.n as number,
|
|
66
144
|
last_message: lastLog
|
|
67
145
|
? {
|
|
68
146
|
author_id: lastLog.authorId as number,
|
|
147
|
+
author_name: nameCache.lookup(chatId, lastLog.authorId as number),
|
|
69
148
|
message: lastLog.message as string,
|
|
70
149
|
sent_at: lastLog.sendAt as number,
|
|
71
150
|
}
|
|
@@ -73,6 +152,50 @@ function formatChat(chat: ChatData): KakaoChat {
|
|
|
73
152
|
}
|
|
74
153
|
}
|
|
75
154
|
|
|
155
|
+
const META_TYPE_TITLE = 3
|
|
156
|
+
|
|
157
|
+
interface ChannelMetaEntry {
|
|
158
|
+
type?: number
|
|
159
|
+
content?: string
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
interface ChannelInfoResponse {
|
|
163
|
+
chatInfo?: {
|
|
164
|
+
chatMetas?: ChannelMetaEntry[]
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function extractTitle(body: Record<string, unknown>): string | null {
|
|
169
|
+
const info = (body as ChannelInfoResponse).chatInfo
|
|
170
|
+
const metas = info?.chatMetas
|
|
171
|
+
if (!Array.isArray(metas)) return null
|
|
172
|
+
|
|
173
|
+
const titleMeta = metas.find((m) => m?.type === META_TYPE_TITLE)
|
|
174
|
+
const content = titleMeta?.content
|
|
175
|
+
return typeof content === 'string' && content.length > 0 ? content : null
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
interface OpenLinkInfoResponse {
|
|
179
|
+
ols?: Array<{ ln?: unknown }>
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function extractOpenLinkName(body: Record<string, unknown>): string | null {
|
|
183
|
+
const ols = (body as OpenLinkInfoResponse).ols
|
|
184
|
+
if (!Array.isArray(ols) || ols.length === 0) return null
|
|
185
|
+
const ln = ols[0]?.ln
|
|
186
|
+
return typeof ln === 'string' && ln.length > 0 ? ln : null
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const OPEN_CHAT_TYPES = new Set(['OM', 'OD'])
|
|
190
|
+
|
|
191
|
+
function isOpenChat(chat: ChatData): boolean {
|
|
192
|
+
return typeof chat.t === 'string' && OPEN_CHAT_TYPES.has(chat.t)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function getOpenLinkId(chat: ChatData): Long | null {
|
|
196
|
+
return bsonToLong(chat.li) ?? null
|
|
197
|
+
}
|
|
198
|
+
|
|
76
199
|
function matchesSearch(chat: ChatData, term: string): boolean {
|
|
77
200
|
const names = (chat.k ?? []) as string[]
|
|
78
201
|
const lower = term.toLowerCase()
|
|
@@ -214,13 +337,66 @@ function mergeSyncState(previous: SyncState | undefined, loginResult: LoginListR
|
|
|
214
337
|
return next
|
|
215
338
|
}
|
|
216
339
|
|
|
217
|
-
function
|
|
340
|
+
function nullableString(v: unknown): string | null {
|
|
341
|
+
return typeof v === 'string' && v.length > 0 ? v : null
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function nullableNumber(v: unknown): number | null {
|
|
345
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : null
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function isNonZeroLong(v: unknown): boolean {
|
|
349
|
+
if (typeof v === 'number') return v !== 0
|
|
350
|
+
if (v && typeof v === 'object' && 'low' in v && 'high' in v) {
|
|
351
|
+
const { low, high } = v as { low: number; high: number }
|
|
352
|
+
return low !== 0 || high !== 0
|
|
353
|
+
}
|
|
354
|
+
return v !== undefined && v !== null
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Reject synthetic LocoConnection close packets ({ statusCode: -1, body.error: 'connection closed' })
|
|
358
|
+
// and explicit body-level failures. Required for any SDK method whose response body has no
|
|
359
|
+
// caller-visible error channel (e.g. GETMEM/MEMBER return `[]` for both empty rooms and dead
|
|
360
|
+
// sockets). Throwing here lets executeWithReconnect detect session death and reconnect.
|
|
361
|
+
function assertLocoOk(response: LocoPacket, command: string): void {
|
|
362
|
+
if (response.statusCode !== 0) {
|
|
363
|
+
throw new Error(`${command} failed: statusCode=${response.statusCode}`)
|
|
364
|
+
}
|
|
365
|
+
const bodyStatus = response.body.status
|
|
366
|
+
if (typeof bodyStatus === 'number' && bodyStatus !== 0) {
|
|
367
|
+
throw new Error(`${command} failed: body.status=${bodyStatus}`)
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function formatMember(member: Record<string, unknown>): KakaoMember {
|
|
372
|
+
return {
|
|
373
|
+
user_id: longToString(member.userId),
|
|
374
|
+
nickname: typeof member.nickName === 'string' ? member.nickName : '',
|
|
375
|
+
profile_image_url: nullableString(member.profileImageUrl ?? member.pi),
|
|
376
|
+
full_profile_image_url: nullableString(member.fullProfileImageUrl ?? member.fpi),
|
|
377
|
+
original_profile_image_url: nullableString(member.originalProfileImageUrl ?? member.opi),
|
|
378
|
+
status_message: nullableString(member.statusMessage),
|
|
379
|
+
country_iso: nullableString(member.countryIso),
|
|
380
|
+
user_type: nullableNumber(member.type),
|
|
381
|
+
open_token: nullableNumber(member.opt),
|
|
382
|
+
open_profile_link_id: isNonZeroLong(member.pli) ? longToString(member.pli) : null,
|
|
383
|
+
open_permission: nullableNumber(member.mt),
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function formatMessages(
|
|
388
|
+
logs: Array<Record<string, unknown>>,
|
|
389
|
+
count: number,
|
|
390
|
+
chatId: string,
|
|
391
|
+
nameCache: MemberNameCache,
|
|
392
|
+
): KakaoMessage[] {
|
|
218
393
|
logs.sort((a, b) => (a.sendAt as number) - (b.sendAt as number))
|
|
219
394
|
|
|
220
395
|
return logs.slice(-count).map((log) => ({
|
|
221
396
|
log_id: longToString(log.logId),
|
|
222
397
|
type: log.type as number,
|
|
223
398
|
author_id: log.authorId as number,
|
|
399
|
+
author_name: nameCache.lookup(chatId, log.authorId as number),
|
|
224
400
|
message: log.message as string,
|
|
225
401
|
sent_at: log.sendAt as number,
|
|
226
402
|
}))
|
|
@@ -234,6 +410,9 @@ export class KakaoTalkClient {
|
|
|
234
410
|
private state: SessionState | null = null
|
|
235
411
|
private initPromise: Promise<SessionState> | null = null
|
|
236
412
|
private closed = false
|
|
413
|
+
private pushHandlers = new Set<KakaoPushHandler>()
|
|
414
|
+
private sessionEventHandlers = new Set<KakaoSessionEventHandler>()
|
|
415
|
+
private nameCache = new MemberNameCache()
|
|
237
416
|
|
|
238
417
|
async login(
|
|
239
418
|
credentials?: { oauthToken: string; userId: string; deviceUuid?: string; deviceType?: KakaoDeviceType },
|
|
@@ -280,6 +459,7 @@ export class KakaoTalkClient {
|
|
|
280
459
|
if (this.state) return this.state
|
|
281
460
|
|
|
282
461
|
// Guard against concurrent init — reuse the in-flight promise
|
|
462
|
+
const isOwner = !this.initPromise
|
|
283
463
|
if (!this.initPromise) {
|
|
284
464
|
this.initPromise = this.connect()
|
|
285
465
|
}
|
|
@@ -291,7 +471,11 @@ export class KakaoTalkClient {
|
|
|
291
471
|
state.session.close()
|
|
292
472
|
throw new KakaoTalkError('Client is closed', 'client_closed')
|
|
293
473
|
}
|
|
474
|
+
const wasNew = this.state !== state
|
|
294
475
|
this.state = state
|
|
476
|
+
if (isOwner && wasNew) {
|
|
477
|
+
this.emitSessionEvent({ type: 'connected', userId: this.userId! })
|
|
478
|
+
}
|
|
295
479
|
return state
|
|
296
480
|
} catch (error) {
|
|
297
481
|
// Reset so next call retries cleanly; connect() already wraps in KakaoTalkError
|
|
@@ -301,6 +485,29 @@ export class KakaoTalkClient {
|
|
|
301
485
|
}
|
|
302
486
|
}
|
|
303
487
|
|
|
488
|
+
async acquireSession(): Promise<LocoSession> {
|
|
489
|
+
const state = await this.ensureSession()
|
|
490
|
+
return state.session
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
onPush(handler: KakaoPushHandler): () => void {
|
|
494
|
+
this.pushHandlers.add(handler)
|
|
495
|
+
return () => {
|
|
496
|
+
this.pushHandlers.delete(handler)
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
onSessionEvent(handler: KakaoSessionEventHandler): () => void {
|
|
501
|
+
this.sessionEventHandlers.add(handler)
|
|
502
|
+
return () => {
|
|
503
|
+
this.sessionEventHandlers.delete(handler)
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
isConnected(): boolean {
|
|
508
|
+
return this.state !== null && !this.closed
|
|
509
|
+
}
|
|
510
|
+
|
|
304
511
|
private async executeWithReconnect<T>(operation: (state: SessionState) => Promise<T>): Promise<T> {
|
|
305
512
|
let state = await this.ensureSession()
|
|
306
513
|
try {
|
|
@@ -314,7 +521,10 @@ export class KakaoTalkClient {
|
|
|
314
521
|
try {
|
|
315
522
|
state.session.close()
|
|
316
523
|
} catch {}
|
|
317
|
-
|
|
524
|
+
// initPromise is intentionally NOT cleared here: a concurrent caller may already
|
|
525
|
+
// be awaiting an in-flight replacement, and starting a parallel one would send a
|
|
526
|
+
// second LOGINLIST with the same duuid — re-introducing the very self-eviction
|
|
527
|
+
// this layer prevents. Lifecycle paths (onClose / invalidateSession) own that field.
|
|
318
528
|
state = await this.ensureSession()
|
|
319
529
|
return operation(state)
|
|
320
530
|
}
|
|
@@ -322,6 +532,15 @@ export class KakaoTalkClient {
|
|
|
322
532
|
|
|
323
533
|
private async connect(): Promise<SessionState> {
|
|
324
534
|
const session = new LocoSession()
|
|
535
|
+
session.onPush((packet) => this.dispatchPush(session, packet))
|
|
536
|
+
session.onClose(() => {
|
|
537
|
+
if (this.state?.session === session) {
|
|
538
|
+
this.state = null
|
|
539
|
+
this.initPromise = null
|
|
540
|
+
this.emitSessionEvent({ type: 'disconnected' })
|
|
541
|
+
}
|
|
542
|
+
})
|
|
543
|
+
|
|
325
544
|
try {
|
|
326
545
|
const syncState = await loadSyncState(this.deviceUuid!)
|
|
327
546
|
const loginResult = await session.login(
|
|
@@ -335,12 +554,7 @@ export class KakaoTalkClient {
|
|
|
335
554
|
const newSyncState = mergeSyncState(syncState, loginResult)
|
|
336
555
|
await saveSyncState(this.deviceUuid!, newSyncState)
|
|
337
556
|
|
|
338
|
-
|
|
339
|
-
if (this.state?.session === session) {
|
|
340
|
-
this.state = null
|
|
341
|
-
this.initPromise = null
|
|
342
|
-
}
|
|
343
|
-
})
|
|
557
|
+
this.nameCache.ingest((loginResult.chatDatas ?? []) as ChatData[])
|
|
344
558
|
|
|
345
559
|
return { session, loginResult }
|
|
346
560
|
} catch (error) {
|
|
@@ -349,7 +563,61 @@ export class KakaoTalkClient {
|
|
|
349
563
|
}
|
|
350
564
|
}
|
|
351
565
|
|
|
352
|
-
|
|
566
|
+
private dispatchPush(session: LocoSession, packet: LocoPacket): void {
|
|
567
|
+
// Only fan out pushes from the currently adopted session. While state is null
|
|
568
|
+
// (pre-adoption during connect, or post-invalidation during reconnect) the
|
|
569
|
+
// packet is discarded — we never want a not-yet-adopted or already-dead session
|
|
570
|
+
// to reach subscribers and look "live".
|
|
571
|
+
if (this.state?.session !== session) return
|
|
572
|
+
|
|
573
|
+
if (packet.method === 'KICKOUT') {
|
|
574
|
+
this.emitSessionEvent({ type: 'kicked', reason: 'Session kicked — another device logged in' })
|
|
575
|
+
this.invalidateSession(session)
|
|
576
|
+
return
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if (packet.method === 'CHANGESVR') {
|
|
580
|
+
for (const handler of this.pushHandlers) {
|
|
581
|
+
try {
|
|
582
|
+
handler(packet)
|
|
583
|
+
} catch {}
|
|
584
|
+
}
|
|
585
|
+
this.invalidateSession(session)
|
|
586
|
+
this.emitSessionEvent({ type: 'disconnected' })
|
|
587
|
+
this.ensureSession().catch(() => {
|
|
588
|
+
// ensureSession already cleared state on failure; subsequent API calls will retry
|
|
589
|
+
// and surface the error. Listeners do not receive 'connected' until a reconnect
|
|
590
|
+
// succeeds, which is the correct outcome.
|
|
591
|
+
})
|
|
592
|
+
return
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
for (const handler of this.pushHandlers) {
|
|
596
|
+
try {
|
|
597
|
+
handler(packet)
|
|
598
|
+
} catch {}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
private invalidateSession(session: LocoSession): void {
|
|
603
|
+
if (this.state?.session === session) {
|
|
604
|
+
this.state = null
|
|
605
|
+
this.initPromise = null
|
|
606
|
+
}
|
|
607
|
+
try {
|
|
608
|
+
session.close()
|
|
609
|
+
} catch {}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
private emitSessionEvent(event: KakaoSessionEvent): void {
|
|
613
|
+
for (const handler of this.sessionEventHandlers) {
|
|
614
|
+
try {
|
|
615
|
+
handler(event)
|
|
616
|
+
} catch {}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
async getChats(options?: { all?: boolean; search?: string; resolveTitles?: boolean }): Promise<KakaoChat[]> {
|
|
353
621
|
return this.executeWithReconnect(async ({ session, loginResult }) => {
|
|
354
622
|
try {
|
|
355
623
|
const allChats: ChatData[] = []
|
|
@@ -383,6 +651,7 @@ export class KakaoTalkClient {
|
|
|
383
651
|
if (chatDatas.length === 0) break
|
|
384
652
|
|
|
385
653
|
collectChats(chatDatas, allChats, seenChatIds)
|
|
654
|
+
this.nameCache.ingest(chatDatas)
|
|
386
655
|
cursor = body
|
|
387
656
|
pages++
|
|
388
657
|
}
|
|
@@ -395,13 +664,58 @@ export class KakaoTalkClient {
|
|
|
395
664
|
results = allChats.filter((c) => matchesSearch(c, options.search!))
|
|
396
665
|
}
|
|
397
666
|
|
|
398
|
-
|
|
667
|
+
const titles = options?.resolveTitles
|
|
668
|
+
? await Promise.all(results.map((chat) => this.fetchChatTitle(session, parseLong(String(chat.c)), chat)))
|
|
669
|
+
: null
|
|
670
|
+
|
|
671
|
+
return results.map((chat, i) => formatChat(chat, titles ? titles[i] : null, this.nameCache))
|
|
399
672
|
} catch (error) {
|
|
400
673
|
throw wrapError(error, 'get_chats_failed')
|
|
401
674
|
}
|
|
402
675
|
})
|
|
403
676
|
}
|
|
404
677
|
|
|
678
|
+
/**
|
|
679
|
+
* Resolve the user-set room title via CHATINFO. Returns null on any error
|
|
680
|
+
* (network, malformed response, or no TITLE meta present). Designed to be
|
|
681
|
+
* fire-and-forget per chat — failures don't poison the whole `getChats` call.
|
|
682
|
+
*/
|
|
683
|
+
async getChatTitle(chatId: string): Promise<string | null> {
|
|
684
|
+
let parsed: Long
|
|
685
|
+
try {
|
|
686
|
+
parsed = parseLong(chatId)
|
|
687
|
+
} catch {
|
|
688
|
+
return null
|
|
689
|
+
}
|
|
690
|
+
return this.executeWithReconnect(async ({ session, loginResult }) => {
|
|
691
|
+
const chat = (loginResult.chatDatas ?? []).find((c) => String(c.c) === chatId)
|
|
692
|
+
return this.fetchChatTitle(session, parsed, chat)
|
|
693
|
+
})
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
private async fetchChatTitle(session: LocoSession, chatId: Long, chat: ChatData | undefined): Promise<string | null> {
|
|
697
|
+
let title: string | null = null
|
|
698
|
+
try {
|
|
699
|
+
const response = await session.getChannelInfo(chatId)
|
|
700
|
+
title = extractTitle(response.body as Record<string, unknown>)
|
|
701
|
+
} catch {
|
|
702
|
+
title = null
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
if (title) return title
|
|
706
|
+
if (!chat || !isOpenChat(chat)) return null
|
|
707
|
+
|
|
708
|
+
const linkId = getOpenLinkId(chat)
|
|
709
|
+
if (!linkId) return null
|
|
710
|
+
|
|
711
|
+
try {
|
|
712
|
+
const response = await session.getOpenLinkInfo([linkId])
|
|
713
|
+
return extractOpenLinkName(response.body as Record<string, unknown>)
|
|
714
|
+
} catch {
|
|
715
|
+
return null
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
405
719
|
async getMessages(chatId: string, options?: { count?: number; from?: string }): Promise<KakaoMessage[]> {
|
|
406
720
|
return this.executeWithReconnect(async ({ session }) => {
|
|
407
721
|
try {
|
|
@@ -426,7 +740,7 @@ export class KakaoTalkClient {
|
|
|
426
740
|
(log) => longToString(log.chatId) === chatId,
|
|
427
741
|
)
|
|
428
742
|
if (batch.length === 0) {
|
|
429
|
-
return formatMessages(allMessages, count)
|
|
743
|
+
return formatMessages(allMessages, count, chatId, this.nameCache)
|
|
430
744
|
}
|
|
431
745
|
|
|
432
746
|
for (const log of batch) {
|
|
@@ -439,7 +753,7 @@ export class KakaoTalkClient {
|
|
|
439
753
|
|
|
440
754
|
const maxLog = findMaxLogId(batch, 'logId')
|
|
441
755
|
if (!maxLog || maxLog.equals(cur) || response.body.eof) {
|
|
442
|
-
return formatMessages(allMessages, count)
|
|
756
|
+
return formatMessages(allMessages, count, chatId, this.nameCache)
|
|
443
757
|
}
|
|
444
758
|
|
|
445
759
|
cur = maxLog
|
|
@@ -452,7 +766,7 @@ export class KakaoTalkClient {
|
|
|
452
766
|
|
|
453
767
|
if (allMessages.length > 0) {
|
|
454
768
|
warn(`[agent-kakaotalk] Warning: message fetch capped at ${MAX_PAGES} pages. Results may be incomplete.`)
|
|
455
|
-
return formatMessages(allMessages, count)
|
|
769
|
+
return formatMessages(allMessages, count, chatId, this.nameCache)
|
|
456
770
|
}
|
|
457
771
|
|
|
458
772
|
// Fetch fresh lastLogId via CHATONROOM (not the stale login-time snapshot)
|
|
@@ -489,13 +803,43 @@ export class KakaoTalkClient {
|
|
|
489
803
|
warn(`[agent-kakaotalk] Warning: message fetch capped at ${MAX_PAGES} pages. Results may be incomplete.`)
|
|
490
804
|
}
|
|
491
805
|
|
|
492
|
-
return formatMessages(allMessages, count)
|
|
806
|
+
return formatMessages(allMessages, count, chatId, this.nameCache)
|
|
493
807
|
} catch (error) {
|
|
494
808
|
throw wrapError(error, 'get_messages_failed')
|
|
495
809
|
}
|
|
496
810
|
})
|
|
497
811
|
}
|
|
498
812
|
|
|
813
|
+
async getMembers(chatId: string): Promise<KakaoMember[]> {
|
|
814
|
+
const parsedChatId = parseChatId(chatId)
|
|
815
|
+
return this.executeWithReconnect(async ({ session }) => {
|
|
816
|
+
try {
|
|
817
|
+
const response = await session.getAllMembers(parsedChatId)
|
|
818
|
+
assertLocoOk(response, 'GETMEM')
|
|
819
|
+
const members = (response.body.members ?? []) as Array<Record<string, unknown>>
|
|
820
|
+
return members.map(formatMember)
|
|
821
|
+
} catch (error) {
|
|
822
|
+
throw wrapError(error, 'get_members_failed')
|
|
823
|
+
}
|
|
824
|
+
})
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
async getMembersByIds(chatId: string, userIds: string[]): Promise<KakaoMember[]> {
|
|
828
|
+
if (userIds.length === 0) return []
|
|
829
|
+
const parsedChatId = parseChatId(chatId)
|
|
830
|
+
const memberIds = userIds.map((id) => parseUserId(id))
|
|
831
|
+
return this.executeWithReconnect(async ({ session }) => {
|
|
832
|
+
try {
|
|
833
|
+
const response = await session.getMembersByIds(parsedChatId, memberIds)
|
|
834
|
+
assertLocoOk(response, 'MEMBER')
|
|
835
|
+
const members = (response.body.members ?? []) as Array<Record<string, unknown>>
|
|
836
|
+
return members.map(formatMember)
|
|
837
|
+
} catch (error) {
|
|
838
|
+
throw wrapError(error, 'get_members_failed')
|
|
839
|
+
}
|
|
840
|
+
})
|
|
841
|
+
}
|
|
842
|
+
|
|
499
843
|
async sendMessage(chatId: string, text: string): Promise<KakaoSendResult> {
|
|
500
844
|
return this.executeWithReconnect(async ({ session }) => {
|
|
501
845
|
try {
|
|
@@ -584,5 +928,12 @@ export class KakaoTalkClient {
|
|
|
584
928
|
}
|
|
585
929
|
this.state = null
|
|
586
930
|
this.initPromise = null
|
|
931
|
+
this.pushHandlers.clear()
|
|
932
|
+
this.sessionEventHandlers.clear()
|
|
933
|
+
this.nameCache.clear()
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
lookupAuthorName(chatId: string, authorId: number): string | null {
|
|
937
|
+
return this.nameCache.lookup(chatId, authorId)
|
|
587
938
|
}
|
|
588
939
|
}
|
|
@@ -9,11 +9,12 @@ async function listAction(options: {
|
|
|
9
9
|
account?: string
|
|
10
10
|
all?: boolean
|
|
11
11
|
search?: string
|
|
12
|
+
resolveTitles?: boolean
|
|
12
13
|
pretty?: boolean
|
|
13
14
|
}): Promise<void> {
|
|
14
15
|
try {
|
|
15
16
|
const chats = await withKakaoClient(options, (client) =>
|
|
16
|
-
client.getChats({ all: options.all, search: options.search }),
|
|
17
|
+
client.getChats({ all: options.all, search: options.search, resolveTitles: options.resolveTitles }),
|
|
17
18
|
)
|
|
18
19
|
console.log(formatOutput(chats, options.pretty))
|
|
19
20
|
} catch (error) {
|
|
@@ -29,6 +30,7 @@ export const chatCommand = new Command('chat')
|
|
|
29
30
|
.option('--account <id>', 'Use a specific KakaoTalk account')
|
|
30
31
|
.option('--all', 'Fetch all chats (paginate beyond login snapshot)')
|
|
31
32
|
.option('--search <name>', 'Search for a chat by display name')
|
|
33
|
+
.option('--resolve-titles', 'Fetch user-set room titles via CHATINFO (slower; one extra LOCO call per chat)')
|
|
32
34
|
.option('--pretty', 'Pretty print JSON output')
|
|
33
35
|
.action(listAction),
|
|
34
36
|
)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
const originalConsoleLog = console.log
|
|
4
|
+
|
|
5
|
+
const mockWithKakaoClient = mock(async (_options: unknown, fn: (client: unknown) => Promise<unknown>) => {
|
|
6
|
+
return fn(mockClient)
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
const mockGetMembers = mock(() =>
|
|
10
|
+
Promise.resolve([
|
|
11
|
+
{
|
|
12
|
+
user_id: '42',
|
|
13
|
+
nickname: 'Alice',
|
|
14
|
+
profile_image_url: null,
|
|
15
|
+
full_profile_image_url: null,
|
|
16
|
+
original_profile_image_url: null,
|
|
17
|
+
status_message: null,
|
|
18
|
+
country_iso: null,
|
|
19
|
+
user_type: 100,
|
|
20
|
+
open_token: null,
|
|
21
|
+
open_profile_link_id: null,
|
|
22
|
+
open_permission: null,
|
|
23
|
+
},
|
|
24
|
+
]),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
const mockClient = {
|
|
28
|
+
getMembers: mockGetMembers,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
mock.module('./shared', () => ({
|
|
32
|
+
withKakaoClient: mockWithKakaoClient,
|
|
33
|
+
}))
|
|
34
|
+
|
|
35
|
+
import { memberCommand } from './member'
|
|
36
|
+
|
|
37
|
+
describe('member commands', () => {
|
|
38
|
+
let consoleLogSpy: ReturnType<typeof mock>
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
mockWithKakaoClient.mockReset()
|
|
42
|
+
mockGetMembers.mockReset()
|
|
43
|
+
|
|
44
|
+
mockWithKakaoClient.mockImplementation(async (_options: unknown, fn: (client: unknown) => Promise<unknown>) => {
|
|
45
|
+
return fn(mockClient)
|
|
46
|
+
})
|
|
47
|
+
mockGetMembers.mockImplementation(() =>
|
|
48
|
+
Promise.resolve([
|
|
49
|
+
{
|
|
50
|
+
user_id: '42',
|
|
51
|
+
nickname: 'Alice',
|
|
52
|
+
profile_image_url: null,
|
|
53
|
+
full_profile_image_url: null,
|
|
54
|
+
original_profile_image_url: null,
|
|
55
|
+
status_message: null,
|
|
56
|
+
country_iso: null,
|
|
57
|
+
user_type: 100,
|
|
58
|
+
open_token: null,
|
|
59
|
+
open_profile_link_id: null,
|
|
60
|
+
open_permission: null,
|
|
61
|
+
},
|
|
62
|
+
]),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
consoleLogSpy = mock((..._args: unknown[]) => {})
|
|
66
|
+
console.log = consoleLogSpy
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
afterEach(() => {
|
|
70
|
+
console.log = originalConsoleLog
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('list', () => {
|
|
74
|
+
it('lists members of a chat room', async () => {
|
|
75
|
+
await memberCommand.parseAsync(['list', '9876543210'], { from: 'user' })
|
|
76
|
+
|
|
77
|
+
expect(mockGetMembers).toHaveBeenCalledWith('9876543210')
|
|
78
|
+
const output = JSON.parse(consoleLogSpy.mock.calls[0][0])
|
|
79
|
+
expect(output).toHaveLength(1)
|
|
80
|
+
expect(output[0].user_id).toBe('42')
|
|
81
|
+
expect(output[0].nickname).toBe('Alice')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('passes --account option to withKakaoClient', async () => {
|
|
85
|
+
await memberCommand.parseAsync(['list', '9876543210', '--account', 'my-account'], { from: 'user' })
|
|
86
|
+
|
|
87
|
+
expect(mockWithKakaoClient).toHaveBeenCalledWith(
|
|
88
|
+
expect.objectContaining({ account: 'my-account' }),
|
|
89
|
+
expect.any(Function),
|
|
90
|
+
)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('outputs empty array when chat has no members', async () => {
|
|
94
|
+
mockGetMembers.mockImplementation(() => Promise.resolve([]))
|
|
95
|
+
|
|
96
|
+
await memberCommand.parseAsync(['list', '9876543210'], { from: 'user' })
|
|
97
|
+
|
|
98
|
+
const output = JSON.parse(consoleLogSpy.mock.calls[0][0])
|
|
99
|
+
expect(output).toEqual([])
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
})
|