agent-messenger 2.12.2 → 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 +13 -1
- package/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/client.js +225 -8
- 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.map +1 -1
- package/dist/src/platforms/kakaotalk/listener.js +5 -2
- 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.test.ts +782 -1
- package/src/platforms/kakaotalk/client.ts +262 -10
- 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 +29 -0
- package/src/platforms/kakaotalk/listener.ts +5 -2
- 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
|
@@ -10,7 +10,7 @@ import { warn } from '@/shared/utils/stderr'
|
|
|
10
10
|
import { LANG, PC_OS_NAME, getLocoDeviceConfig } from './protocol/config'
|
|
11
11
|
import { LocoSession } from './protocol/session'
|
|
12
12
|
import type { ChatListResponse, LocoPacket, LoginListResponse, SyncState } from './protocol/types'
|
|
13
|
-
import type { KakaoChat, KakaoDeviceType, KakaoMessage, KakaoProfile, KakaoSendResult } from './types'
|
|
13
|
+
import type { KakaoChat, KakaoDeviceType, KakaoMember, KakaoMessage, KakaoProfile, KakaoSendResult } from './types'
|
|
14
14
|
|
|
15
15
|
export type KakaoSessionEvent =
|
|
16
16
|
| { type: 'connected'; userId: string }
|
|
@@ -37,6 +37,58 @@ interface SessionState {
|
|
|
37
37
|
loginResult: LoginListResponse
|
|
38
38
|
}
|
|
39
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
|
+
|
|
40
92
|
function bsonToLong(v: unknown): Long | undefined {
|
|
41
93
|
if (v && typeof v === 'object' && 'high' in v && 'low' in v) {
|
|
42
94
|
const { high, low } = v as { high: number; low: number }
|
|
@@ -60,20 +112,39 @@ function parseLong(s: string): Long {
|
|
|
60
112
|
return new Long(low, high)
|
|
61
113
|
}
|
|
62
114
|
|
|
63
|
-
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 {
|
|
64
132
|
const memberNames = (chat.k ?? []) as string[]
|
|
65
133
|
const lastLog = chat.l as Record<string, unknown> | null
|
|
66
134
|
const displayName = memberNames.join(', ') || null
|
|
135
|
+
const chatId = String(chat.c)
|
|
67
136
|
|
|
68
137
|
return {
|
|
69
|
-
chat_id:
|
|
138
|
+
chat_id: chatId,
|
|
70
139
|
type: chat.t as number,
|
|
71
140
|
display_name: displayName,
|
|
141
|
+
title,
|
|
72
142
|
active_members: chat.a as number,
|
|
73
143
|
unread_count: chat.n as number,
|
|
74
144
|
last_message: lastLog
|
|
75
145
|
? {
|
|
76
146
|
author_id: lastLog.authorId as number,
|
|
147
|
+
author_name: nameCache.lookup(chatId, lastLog.authorId as number),
|
|
77
148
|
message: lastLog.message as string,
|
|
78
149
|
sent_at: lastLog.sendAt as number,
|
|
79
150
|
}
|
|
@@ -81,6 +152,50 @@ function formatChat(chat: ChatData): KakaoChat {
|
|
|
81
152
|
}
|
|
82
153
|
}
|
|
83
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
|
+
|
|
84
199
|
function matchesSearch(chat: ChatData, term: string): boolean {
|
|
85
200
|
const names = (chat.k ?? []) as string[]
|
|
86
201
|
const lower = term.toLowerCase()
|
|
@@ -222,13 +337,66 @@ function mergeSyncState(previous: SyncState | undefined, loginResult: LoginListR
|
|
|
222
337
|
return next
|
|
223
338
|
}
|
|
224
339
|
|
|
225
|
-
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[] {
|
|
226
393
|
logs.sort((a, b) => (a.sendAt as number) - (b.sendAt as number))
|
|
227
394
|
|
|
228
395
|
return logs.slice(-count).map((log) => ({
|
|
229
396
|
log_id: longToString(log.logId),
|
|
230
397
|
type: log.type as number,
|
|
231
398
|
author_id: log.authorId as number,
|
|
399
|
+
author_name: nameCache.lookup(chatId, log.authorId as number),
|
|
232
400
|
message: log.message as string,
|
|
233
401
|
sent_at: log.sendAt as number,
|
|
234
402
|
}))
|
|
@@ -244,6 +412,7 @@ export class KakaoTalkClient {
|
|
|
244
412
|
private closed = false
|
|
245
413
|
private pushHandlers = new Set<KakaoPushHandler>()
|
|
246
414
|
private sessionEventHandlers = new Set<KakaoSessionEventHandler>()
|
|
415
|
+
private nameCache = new MemberNameCache()
|
|
247
416
|
|
|
248
417
|
async login(
|
|
249
418
|
credentials?: { oauthToken: string; userId: string; deviceUuid?: string; deviceType?: KakaoDeviceType },
|
|
@@ -385,6 +554,8 @@ export class KakaoTalkClient {
|
|
|
385
554
|
const newSyncState = mergeSyncState(syncState, loginResult)
|
|
386
555
|
await saveSyncState(this.deviceUuid!, newSyncState)
|
|
387
556
|
|
|
557
|
+
this.nameCache.ingest((loginResult.chatDatas ?? []) as ChatData[])
|
|
558
|
+
|
|
388
559
|
return { session, loginResult }
|
|
389
560
|
} catch (error) {
|
|
390
561
|
session.close()
|
|
@@ -446,7 +617,7 @@ export class KakaoTalkClient {
|
|
|
446
617
|
}
|
|
447
618
|
}
|
|
448
619
|
|
|
449
|
-
async getChats(options?: { all?: boolean; search?: string }): Promise<KakaoChat[]> {
|
|
620
|
+
async getChats(options?: { all?: boolean; search?: string; resolveTitles?: boolean }): Promise<KakaoChat[]> {
|
|
450
621
|
return this.executeWithReconnect(async ({ session, loginResult }) => {
|
|
451
622
|
try {
|
|
452
623
|
const allChats: ChatData[] = []
|
|
@@ -480,6 +651,7 @@ export class KakaoTalkClient {
|
|
|
480
651
|
if (chatDatas.length === 0) break
|
|
481
652
|
|
|
482
653
|
collectChats(chatDatas, allChats, seenChatIds)
|
|
654
|
+
this.nameCache.ingest(chatDatas)
|
|
483
655
|
cursor = body
|
|
484
656
|
pages++
|
|
485
657
|
}
|
|
@@ -492,13 +664,58 @@ export class KakaoTalkClient {
|
|
|
492
664
|
results = allChats.filter((c) => matchesSearch(c, options.search!))
|
|
493
665
|
}
|
|
494
666
|
|
|
495
|
-
|
|
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))
|
|
496
672
|
} catch (error) {
|
|
497
673
|
throw wrapError(error, 'get_chats_failed')
|
|
498
674
|
}
|
|
499
675
|
})
|
|
500
676
|
}
|
|
501
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
|
+
|
|
502
719
|
async getMessages(chatId: string, options?: { count?: number; from?: string }): Promise<KakaoMessage[]> {
|
|
503
720
|
return this.executeWithReconnect(async ({ session }) => {
|
|
504
721
|
try {
|
|
@@ -523,7 +740,7 @@ export class KakaoTalkClient {
|
|
|
523
740
|
(log) => longToString(log.chatId) === chatId,
|
|
524
741
|
)
|
|
525
742
|
if (batch.length === 0) {
|
|
526
|
-
return formatMessages(allMessages, count)
|
|
743
|
+
return formatMessages(allMessages, count, chatId, this.nameCache)
|
|
527
744
|
}
|
|
528
745
|
|
|
529
746
|
for (const log of batch) {
|
|
@@ -536,7 +753,7 @@ export class KakaoTalkClient {
|
|
|
536
753
|
|
|
537
754
|
const maxLog = findMaxLogId(batch, 'logId')
|
|
538
755
|
if (!maxLog || maxLog.equals(cur) || response.body.eof) {
|
|
539
|
-
return formatMessages(allMessages, count)
|
|
756
|
+
return formatMessages(allMessages, count, chatId, this.nameCache)
|
|
540
757
|
}
|
|
541
758
|
|
|
542
759
|
cur = maxLog
|
|
@@ -549,7 +766,7 @@ export class KakaoTalkClient {
|
|
|
549
766
|
|
|
550
767
|
if (allMessages.length > 0) {
|
|
551
768
|
warn(`[agent-kakaotalk] Warning: message fetch capped at ${MAX_PAGES} pages. Results may be incomplete.`)
|
|
552
|
-
return formatMessages(allMessages, count)
|
|
769
|
+
return formatMessages(allMessages, count, chatId, this.nameCache)
|
|
553
770
|
}
|
|
554
771
|
|
|
555
772
|
// Fetch fresh lastLogId via CHATONROOM (not the stale login-time snapshot)
|
|
@@ -586,13 +803,43 @@ export class KakaoTalkClient {
|
|
|
586
803
|
warn(`[agent-kakaotalk] Warning: message fetch capped at ${MAX_PAGES} pages. Results may be incomplete.`)
|
|
587
804
|
}
|
|
588
805
|
|
|
589
|
-
return formatMessages(allMessages, count)
|
|
806
|
+
return formatMessages(allMessages, count, chatId, this.nameCache)
|
|
590
807
|
} catch (error) {
|
|
591
808
|
throw wrapError(error, 'get_messages_failed')
|
|
592
809
|
}
|
|
593
810
|
})
|
|
594
811
|
}
|
|
595
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
|
+
|
|
596
843
|
async sendMessage(chatId: string, text: string): Promise<KakaoSendResult> {
|
|
597
844
|
return this.executeWithReconnect(async ({ session }) => {
|
|
598
845
|
try {
|
|
@@ -683,5 +930,10 @@ export class KakaoTalkClient {
|
|
|
683
930
|
this.initPromise = null
|
|
684
931
|
this.pushHandlers.clear()
|
|
685
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)
|
|
686
938
|
}
|
|
687
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
|
+
})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Command } from 'commander'
|
|
2
|
+
|
|
3
|
+
import { handleError } from '@/shared/utils/error-handler'
|
|
4
|
+
import { formatOutput } from '@/shared/utils/output'
|
|
5
|
+
|
|
6
|
+
import { withKakaoClient } from './shared'
|
|
7
|
+
|
|
8
|
+
async function listAction(
|
|
9
|
+
chatId: string,
|
|
10
|
+
options: {
|
|
11
|
+
account?: string
|
|
12
|
+
pretty?: boolean
|
|
13
|
+
},
|
|
14
|
+
): Promise<void> {
|
|
15
|
+
try {
|
|
16
|
+
const members = await withKakaoClient(options, (client) => client.getMembers(chatId))
|
|
17
|
+
console.log(formatOutput(members, options.pretty))
|
|
18
|
+
} catch (error) {
|
|
19
|
+
handleError(error as Error)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const memberCommand = new Command('member')
|
|
24
|
+
.description('KakaoTalk member commands')
|
|
25
|
+
.addCommand(
|
|
26
|
+
new Command('list')
|
|
27
|
+
.description('List members of a chat room')
|
|
28
|
+
.argument('<chat-id>', 'Chat room ID')
|
|
29
|
+
.option('--account <id>', 'Use a specific KakaoTalk account')
|
|
30
|
+
.option('--pretty', 'Pretty print JSON output')
|
|
31
|
+
.action(listAction),
|
|
32
|
+
)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { expect, it } from 'bun:test'
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
+
classifyKakaoChat,
|
|
4
5
|
CredentialManager,
|
|
5
6
|
KakaoAccountCredentialsSchema,
|
|
6
7
|
KakaoCredentialManager,
|
|
@@ -72,3 +73,7 @@ it('KakaoTalkPushReadEventSchema is exported from barrel', () => {
|
|
|
72
73
|
it('KakaoProfileSchema is exported from barrel', () => {
|
|
73
74
|
expect(typeof KakaoProfileSchema.parse).toBe('function')
|
|
74
75
|
})
|
|
76
|
+
|
|
77
|
+
it('classifyKakaoChat is exported from barrel', () => {
|
|
78
|
+
expect(typeof classifyKakaoChat).toBe('function')
|
|
79
|
+
})
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export { KakaoTalkClient, KakaoTalkError } from './client'
|
|
2
|
+
export { classifyKakaoChat } from './chat-classifier'
|
|
3
|
+
export type { KakaoChatKind } from './chat-classifier'
|
|
2
4
|
export { KakaoCredentialManager, CredentialManager } from './credential-manager'
|
|
3
5
|
export { KakaoTalkListener } from './listener'
|
|
4
6
|
export type { PendingLoginState } from './credential-manager'
|
|
@@ -8,6 +10,7 @@ export type {
|
|
|
8
10
|
KakaoChat,
|
|
9
11
|
KakaoConfig,
|
|
10
12
|
KakaoDeviceType,
|
|
13
|
+
KakaoMember,
|
|
11
14
|
KakaoMessage,
|
|
12
15
|
KakaoProfile,
|
|
13
16
|
KakaoSendResult,
|
|
@@ -22,6 +25,7 @@ export {
|
|
|
22
25
|
KakaoAccountCredentialsSchema,
|
|
23
26
|
KakaoChatSchema,
|
|
24
27
|
KakaoConfigSchema,
|
|
28
|
+
KakaoMemberSchema,
|
|
25
29
|
KakaoMessageSchema,
|
|
26
30
|
KakaoProfileSchema,
|
|
27
31
|
KakaoSendResultSchema,
|
|
@@ -16,6 +16,7 @@ class FakeClient {
|
|
|
16
16
|
sessionHandlers = new Set<KakaoSessionEventHandler>()
|
|
17
17
|
acquireImpl: () => Promise<void> = async () => {}
|
|
18
18
|
connected = false
|
|
19
|
+
lookupAuthorName: (chatId: string, authorId: number) => string | null = () => null
|
|
19
20
|
|
|
20
21
|
async acquireSession(): Promise<unknown> {
|
|
21
22
|
this.acquireCalls++
|
|
@@ -141,10 +142,38 @@ describe('KakaoTalkListener', () => {
|
|
|
141
142
|
expect(messages[0].chat_id).toBe('100')
|
|
142
143
|
expect(messages[0].log_id).toBe('200')
|
|
143
144
|
expect(messages[0].author_id).toBe(42)
|
|
145
|
+
expect(messages[0].author_name).toBeNull()
|
|
144
146
|
expect(messages[0].message).toBe('hello world')
|
|
145
147
|
expect(messages[0].message_type).toBe(1)
|
|
146
148
|
expect(messages[0].sent_at).toBe(1700000000)
|
|
147
149
|
})
|
|
150
|
+
|
|
151
|
+
it('resolves author_name from client.lookupAuthorName when available', async () => {
|
|
152
|
+
const { listener: l, client } = createListener()
|
|
153
|
+
listener = l
|
|
154
|
+
|
|
155
|
+
client.lookupAuthorName = (chatId: string, authorId: number) => {
|
|
156
|
+
if (chatId === '100' && authorId === 42) return 'Alice'
|
|
157
|
+
return null
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const messages: KakaoTalkPushMessageEvent[] = []
|
|
161
|
+
listener.on('message', (event) => messages.push(event))
|
|
162
|
+
|
|
163
|
+
await listener.start()
|
|
164
|
+
client.emitPush('MSG', {
|
|
165
|
+
chatId: { high: 0, low: 100 },
|
|
166
|
+
chatLog: {
|
|
167
|
+
logId: { high: 0, low: 200 },
|
|
168
|
+
authorId: 42,
|
|
169
|
+
message: 'hello',
|
|
170
|
+
type: 1,
|
|
171
|
+
sendAt: 1700000000,
|
|
172
|
+
},
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
expect(messages[0].author_name).toBe('Alice')
|
|
176
|
+
})
|
|
148
177
|
})
|
|
149
178
|
|
|
150
179
|
describe('member events', () => {
|
|
@@ -109,11 +109,14 @@ export class KakaoTalkListener {
|
|
|
109
109
|
switch (method) {
|
|
110
110
|
case 'MSG': {
|
|
111
111
|
const chatLog = body.chatLog as Record<string, unknown>
|
|
112
|
+
const chatId = longToString(body.chatId)
|
|
113
|
+
const authorId = chatLog.authorId as number
|
|
112
114
|
const event: KakaoTalkPushMessageEvent = {
|
|
113
115
|
type: 'MSG',
|
|
114
|
-
chat_id:
|
|
116
|
+
chat_id: chatId,
|
|
115
117
|
log_id: longToString(chatLog.logId),
|
|
116
|
-
author_id:
|
|
118
|
+
author_id: authorId,
|
|
119
|
+
author_name: this.client.lookupAuthorName?.(chatId, authorId) ?? null,
|
|
117
120
|
message: chatLog.message as string,
|
|
118
121
|
message_type: chatLog.type as number,
|
|
119
122
|
sent_at: chatLog.sendAt as number,
|