agent-messenger 2.12.2 → 2.13.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/discordbot/index.d.ts +2 -1
- package/dist/src/platforms/discordbot/index.d.ts.map +1 -1
- package/dist/src/platforms/discordbot/index.js.map +1 -1
- package/dist/src/platforms/discordbot/listener.d.ts +4 -3
- package/dist/src/platforms/discordbot/listener.d.ts.map +1 -1
- package/dist/src/platforms/discordbot/listener.js.map +1 -1
- package/dist/src/platforms/discordbot/types.d.ts +21 -0
- package/dist/src/platforms/discordbot/types.d.ts.map +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/platforms/slackbot/index.d.ts +2 -2
- package/dist/src/platforms/slackbot/index.d.ts.map +1 -1
- package/dist/src/platforms/slackbot/index.js +1 -1
- package/dist/src/platforms/slackbot/index.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/discordbot/index.test.ts +76 -0
- package/src/platforms/discordbot/index.ts +3 -0
- package/src/platforms/discordbot/listener.test.ts +41 -0
- package/src/platforms/discordbot/listener.ts +5 -1
- package/src/platforms/discordbot/types.ts +23 -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/platforms/slackbot/index.test.ts +10 -0
- package/src/platforms/slackbot/index.ts +4 -0
- package/src/tui/adapters/kakaotalk-adapter.ts +3 -3
|
@@ -44,6 +44,15 @@ const allChats = await client.getChats({ all: true })
|
|
|
44
44
|
|
|
45
45
|
// Search chats by display name
|
|
46
46
|
const results = await client.getChats({ search: 'Alice' })
|
|
47
|
+
|
|
48
|
+
// Resolve user-set room titles via CHATINFO (one extra LOCO call per chat).
|
|
49
|
+
// Each KakaoChat gets a `title: string | null` matching the in-app room name.
|
|
50
|
+
// For open chats with no user-set title, the SDK falls back to the OpenLink
|
|
51
|
+
// link name via an additional INFOLINK call (only for OM / OD chats).
|
|
52
|
+
const titled = await client.getChats({ resolveTitles: true })
|
|
53
|
+
|
|
54
|
+
// Resolve a single chat's title directly (returns null on error or missing title)
|
|
55
|
+
const title = await client.getChatTitle('9876543210')
|
|
47
56
|
```
|
|
48
57
|
|
|
49
58
|
### Messages
|
|
@@ -60,6 +69,21 @@ const more = await client.getMessages('9876543210', { count: 100 })
|
|
|
60
69
|
const newer = await client.getMessages('9876543210', { from: '123456789' })
|
|
61
70
|
```
|
|
62
71
|
|
|
72
|
+
Each `KakaoMessage` carries an `author_name: string | null`. The SDK auto-populates this from the same chat-list response that already arrives at login (no extra LOCO calls). It resolves for "display members" — the small set of nicknames KakaoTalk includes in the chat list — and is `null` for everyone else.
|
|
73
|
+
|
|
74
|
+
### Members
|
|
75
|
+
|
|
76
|
+
```typescript
|
|
77
|
+
// List all members of a chat (LOCO GETMEM)
|
|
78
|
+
const members = await client.getMembers('9876543210')
|
|
79
|
+
// → KakaoMember[]
|
|
80
|
+
|
|
81
|
+
// Resolve a specific subset by user_id (LOCO MEMBER) — useful for >100-member rooms
|
|
82
|
+
const subset = await client.getMembersByIds('9876543210', ['1234567890', '9876543210'])
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Each `KakaoMember` includes `user_id`, `nickname`, profile image URLs, `status_message`, `country_iso`, `user_type` (nullable; `null` when the server omits the field), and open-chat-only fields (`open_token`, `open_profile_link_id`, `open_permission` — the last is the OpenChannelUserPerm bitfield: `1`=OWNER, `2`=NONE, `4`=MANAGER, `8`=BOT).
|
|
86
|
+
|
|
63
87
|
### Sending
|
|
64
88
|
|
|
65
89
|
```typescript
|
|
@@ -79,7 +103,7 @@ Once closed, any subsequent method call throws a `KakaoTalkError` with code `cli
|
|
|
79
103
|
|
|
80
104
|
## KakaoTalkListener
|
|
81
105
|
|
|
82
|
-
Real-time event listener that receives push events from KakaoTalk via the LOCO protocol.
|
|
106
|
+
Real-time event listener that receives push events from KakaoTalk via the LOCO protocol. Subscribes to the underlying client's LOCO session.
|
|
83
107
|
|
|
84
108
|
```typescript
|
|
85
109
|
import { KakaoTalkClient, KakaoTalkListener } from 'agent-messenger/kakaotalk'
|
|
@@ -107,7 +131,7 @@ listener.on('read', (event) => {
|
|
|
107
131
|
console.log(`User ${event.user_id} read ${event.chat_id} up to ${event.watermark}`)
|
|
108
132
|
})
|
|
109
133
|
|
|
110
|
-
listener.on('disconnected', () => console.log('
|
|
134
|
+
listener.on('disconnected', () => console.log('LOCO session dropped'))
|
|
111
135
|
listener.on('error', (err) => console.error(err))
|
|
112
136
|
|
|
113
137
|
await listener.start()
|
|
@@ -116,20 +140,26 @@ await listener.start()
|
|
|
116
140
|
|
|
117
141
|
### Events
|
|
118
142
|
|
|
119
|
-
| Event | Payload | Description
|
|
120
|
-
| ----------------- | --------------------------- |
|
|
121
|
-
| `message` | `KakaoTalkPushMessageEvent` | New message received
|
|
122
|
-
| `member_joined` | `KakaoTalkPushMemberEvent` | Member joined a chat
|
|
123
|
-
| `member_left` | `KakaoTalkPushMemberEvent` | Member left a chat
|
|
124
|
-
| `read` | `KakaoTalkPushReadEvent` | Read receipt (unread count decreased)
|
|
125
|
-
| `kakaotalk_event` | `KakaoTalkPushGenericEvent` | Catch-all for all push events
|
|
126
|
-
| `connected` | `{ userId: string }` | Connected to LOCO server
|
|
127
|
-
| `disconnected` | — |
|
|
128
|
-
| `error` | `Error` | Connection or protocol error
|
|
143
|
+
| Event | Payload | Description |
|
|
144
|
+
| ----------------- | --------------------------- | --------------------------------------- |
|
|
145
|
+
| `message` | `KakaoTalkPushMessageEvent` | New message received |
|
|
146
|
+
| `member_joined` | `KakaoTalkPushMemberEvent` | Member joined a chat |
|
|
147
|
+
| `member_left` | `KakaoTalkPushMemberEvent` | Member left a chat |
|
|
148
|
+
| `read` | `KakaoTalkPushReadEvent` | Read receipt (unread count decreased) |
|
|
149
|
+
| `kakaotalk_event` | `KakaoTalkPushGenericEvent` | Catch-all for all push events |
|
|
150
|
+
| `connected` | `{ userId: string }` | Connected to LOCO server |
|
|
151
|
+
| `disconnected` | — | LOCO session dropped (see Reconnection) |
|
|
152
|
+
| `error` | `Error` | Connection or protocol error |
|
|
129
153
|
|
|
130
154
|
### Reconnection
|
|
131
155
|
|
|
132
|
-
The listener
|
|
156
|
+
The listener does not implement a reconnection loop. Behavior on session drop:
|
|
157
|
+
|
|
158
|
+
- **Server-requested migration (`CHANGESVR`)** — The client invalidates the session, emits `disconnected`, and immediately establishes a new session in the background. Subscribers receive `connected` again once it succeeds. No action required.
|
|
159
|
+
- **Network drop or peer close** — The client emits `disconnected` and clears its session state. The next SDK API call (`getChats`, `getMessages`, `sendMessage`) will lazily re-establish the session via `executeWithReconnect`. The listener itself stays passive — to resume push delivery after a drop, call `await client.acquireSession()` (or any API call) from your `disconnected` handler.
|
|
160
|
+
- **Kicked by another device (`KICKOUT`)** — The listener stops and emits `error`. This is terminal; you must `login()` a new client to recover.
|
|
161
|
+
|
|
162
|
+
If you need a long-running listener that survives network drops, wrap `acquireSession()` in your own retry loop driven by the `disconnected` event.
|
|
133
163
|
|
|
134
164
|
## KakaoCredentialManager
|
|
135
165
|
|
|
@@ -172,6 +202,7 @@ await manager.setCurrentAccount('1234567890')
|
|
|
172
202
|
```typescript
|
|
173
203
|
import type {
|
|
174
204
|
KakaoChat,
|
|
205
|
+
KakaoMember,
|
|
175
206
|
KakaoMessage,
|
|
176
207
|
KakaoSendResult,
|
|
177
208
|
KakaoAccountCredentials,
|
|
@@ -188,6 +219,7 @@ Runtime-validated schemas are also exported for parsing API responses:
|
|
|
188
219
|
```typescript
|
|
189
220
|
import {
|
|
190
221
|
KakaoChatSchema,
|
|
222
|
+
KakaoMemberSchema,
|
|
191
223
|
KakaoMessageSchema,
|
|
192
224
|
KakaoSendResultSchema,
|
|
193
225
|
KakaoAccountCredentialsSchema,
|
|
@@ -58,6 +58,17 @@ const msg = await client.postMessage('C0ACZKTDDC0', 'Hello world')
|
|
|
58
58
|
const reply = await client.postMessage('C0ACZKTDDC0', 'Reply', { thread_ts: '1234567890.123456' })
|
|
59
59
|
// → SlackMessage { ts, text, type, user?, thread_ts? }
|
|
60
60
|
|
|
61
|
+
// Send a Block Kit message — `text` is used as the fallback/notification text
|
|
62
|
+
await client.postMessage('C0ACZKTDDC0', 'Build finished', {
|
|
63
|
+
blocks: [{ type: 'section', text: { type: 'mrkdwn', text: '*Build finished*' } }],
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
// Other supported pass-through options (forwarded as-is to chat.postMessage):
|
|
67
|
+
// attachments?: unknown[]
|
|
68
|
+
// unfurl_links?: boolean
|
|
69
|
+
// unfurl_media?: boolean
|
|
70
|
+
// mrkdwn?: boolean
|
|
71
|
+
|
|
61
72
|
// Read a channel's recent messages (default limit: 20, supports cursor pagination)
|
|
62
73
|
const history = await client.getConversationHistory('C0ACZKTDDC0')
|
|
63
74
|
const limited = await client.getConversationHistory('C0ACZKTDDC0', { limit: 50 })
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-messenger",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.13.1",
|
|
4
4
|
"description": "Multi-platform messaging CLI for AI agents (Slack, Discord, Teams, Webex, Telegram, Telegram Bot, WhatsApp, LINE, Instagram, KakaoTalk, Channel Talk)",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* KakaoTalk LOCO wire-format capture (diagnostic only).
|
|
4
|
+
*
|
|
5
|
+
* Issues a small, throttled set of LOCO requests against a real account and
|
|
6
|
+
* writes the BSON response bodies to a temp file for offline schema inspection.
|
|
7
|
+
* PII is redacted (user IDs hashed, profile URLs scrubbed, message content
|
|
8
|
+
* elided, member nicknames replaced) before anything is written to disk.
|
|
9
|
+
*
|
|
10
|
+
* Defaults are intentionally conservative: 3 chats max, 1500ms between LOCO
|
|
11
|
+
* calls, dry-run unless `--confirm` is passed. The goal is to avoid bursting
|
|
12
|
+
* KakaoTalk's servers from a real account during local investigation.
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* bun scripts/kakao-loco-capture.ts # Dry-run plan (no LOCO calls)
|
|
16
|
+
* bun scripts/kakao-loco-capture.ts --confirm # Execute with defaults
|
|
17
|
+
* bun scripts/kakao-loco-capture.ts --chat-id 123 --confirm
|
|
18
|
+
* bun scripts/kakao-loco-capture.ts --commands CHATINFO,CHATONROOM --confirm
|
|
19
|
+
*
|
|
20
|
+
* NOT shipped to npm consumers: this is a developer tool under scripts/, never
|
|
21
|
+
* imported by published code.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { createHash } from 'node:crypto'
|
|
25
|
+
import { writeFileSync } from 'node:fs'
|
|
26
|
+
import { tmpdir } from 'node:os'
|
|
27
|
+
import { join } from 'node:path'
|
|
28
|
+
|
|
29
|
+
import { Long } from 'bson'
|
|
30
|
+
|
|
31
|
+
import { KakaoTalkClient } from '../src/platforms/kakaotalk/client'
|
|
32
|
+
import type { LocoSession } from '../src/platforms/kakaotalk/protocol/session'
|
|
33
|
+
|
|
34
|
+
const SUPPORTED_COMMANDS = ['CHATINFO', 'CHATONROOM', 'MEMBER', 'GETMEM', 'INFOLINK', 'LCHATLIST'] as const
|
|
35
|
+
type Command = (typeof SUPPORTED_COMMANDS)[number]
|
|
36
|
+
|
|
37
|
+
interface Args {
|
|
38
|
+
chatId: string | null
|
|
39
|
+
maxChats: number
|
|
40
|
+
delayMs: number
|
|
41
|
+
commands: Command[]
|
|
42
|
+
confirm: boolean
|
|
43
|
+
account: string | null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseArgs(argv: string[]): Args {
|
|
47
|
+
const args: Args = {
|
|
48
|
+
chatId: null,
|
|
49
|
+
maxChats: 3,
|
|
50
|
+
delayMs: 1500,
|
|
51
|
+
commands: [...SUPPORTED_COMMANDS],
|
|
52
|
+
confirm: false,
|
|
53
|
+
account: null,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
for (let i = 0; i < argv.length; i++) {
|
|
57
|
+
const arg = argv[i]
|
|
58
|
+
if (arg === '--confirm') {
|
|
59
|
+
args.confirm = true
|
|
60
|
+
continue
|
|
61
|
+
}
|
|
62
|
+
const next = argv[i + 1]
|
|
63
|
+
if (arg === '--chat-id' && next) {
|
|
64
|
+
args.chatId = next
|
|
65
|
+
i++
|
|
66
|
+
} else if (arg === '--max-chats' && next) {
|
|
67
|
+
args.maxChats = Math.max(1, Number.parseInt(next, 10) || 1)
|
|
68
|
+
i++
|
|
69
|
+
} else if (arg === '--delay-ms' && next) {
|
|
70
|
+
args.delayMs = Math.max(0, Number.parseInt(next, 10) || 0)
|
|
71
|
+
i++
|
|
72
|
+
} else if (arg === '--commands' && next) {
|
|
73
|
+
const requested = next.split(',').map((s) => s.trim().toUpperCase())
|
|
74
|
+
const unknown = requested.filter((c) => !SUPPORTED_COMMANDS.includes(c as Command))
|
|
75
|
+
if (unknown.length > 0) {
|
|
76
|
+
throw new Error(`Unknown commands: ${unknown.join(', ')}. Supported: ${SUPPORTED_COMMANDS.join(', ')}`)
|
|
77
|
+
}
|
|
78
|
+
args.commands = requested as Command[]
|
|
79
|
+
i++
|
|
80
|
+
} else if (arg === '--account' && next) {
|
|
81
|
+
args.account = next
|
|
82
|
+
i++
|
|
83
|
+
} else if (arg === '--help' || arg === '-h') {
|
|
84
|
+
printHelp()
|
|
85
|
+
process.exit(0)
|
|
86
|
+
} else if (arg.startsWith('--')) {
|
|
87
|
+
throw new Error(`Unknown flag: ${arg}`)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return args
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function printHelp(): void {
|
|
95
|
+
console.log(`
|
|
96
|
+
kakao-loco-capture — diagnostic LOCO wire dumper (developer tool)
|
|
97
|
+
|
|
98
|
+
Usage:
|
|
99
|
+
bun scripts/kakao-loco-capture.ts [options]
|
|
100
|
+
|
|
101
|
+
Options:
|
|
102
|
+
--chat-id <id> Probe only this chat (skips --max-chats limit for the chosen chat)
|
|
103
|
+
--max-chats <n> Number of chats to probe from your login snapshot (default: 3)
|
|
104
|
+
--delay-ms <n> Delay between LOCO calls in ms (default: 1500)
|
|
105
|
+
--commands <list> Comma-separated subset of: ${SUPPORTED_COMMANDS.join(', ')}
|
|
106
|
+
(default: all)
|
|
107
|
+
--account <id> Use a specific KakaoTalk account
|
|
108
|
+
--confirm Actually issue LOCO calls. Without this, prints the plan only.
|
|
109
|
+
--help, -h Show this help
|
|
110
|
+
|
|
111
|
+
Output: <tmpdir>/kakao-loco-capture-<timestamp>.json (PII redacted)
|
|
112
|
+
`)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function sleep(ms: number): Promise<void> {
|
|
116
|
+
if (ms <= 0) return
|
|
117
|
+
await new Promise((resolve) => setTimeout(resolve, ms))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function hashUserId(id: unknown): string {
|
|
121
|
+
if (id === null || id === undefined) return '<null>'
|
|
122
|
+
return `user_${createHash('sha256').update(String(id)).digest('hex').slice(0, 8)}`
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function isLong(v: unknown): v is { low: number; high: number } {
|
|
126
|
+
return typeof v === 'object' && v !== null && 'low' in v && 'high' in v
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const URL_PATTERN = /https?:\/\/[^\s"'<>]+/gi
|
|
130
|
+
const USERID_KEYS = new Set(['userId', 'authorId', 'uid', 'i', 'mid', 'mids', 'memberIds', 'memberId', 'olu', 'opt'])
|
|
131
|
+
const CONTENT_KEYS = new Set(['message', 'msg', 'content', 'statusMessage', 'desc', 'description'])
|
|
132
|
+
// Keys whose string values are display names / nicknames. Replaced with a
|
|
133
|
+
// length-tagged placeholder so wire-shape inspection stays useful while the
|
|
134
|
+
// real names never reach disk.
|
|
135
|
+
const NAME_KEYS = new Set(['nickName', 'name', 'fullName', 'authorName', 'author_name', 'k', 'ln'])
|
|
136
|
+
|
|
137
|
+
function redactName(value: string): string {
|
|
138
|
+
return value.length > 0 ? `<name:${value.length}chars>` : '<name>'
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function redact(value: unknown, key?: string): unknown {
|
|
142
|
+
if (value === null || value === undefined) return value
|
|
143
|
+
if (isLong(value)) {
|
|
144
|
+
if (key && USERID_KEYS.has(key)) return hashUserId(`${value.high}_${value.low}`)
|
|
145
|
+
return { __long: true, low: value.low, high: value.high }
|
|
146
|
+
}
|
|
147
|
+
if (typeof value === 'number') {
|
|
148
|
+
if (key && USERID_KEYS.has(key)) return hashUserId(value)
|
|
149
|
+
return value
|
|
150
|
+
}
|
|
151
|
+
if (typeof value === 'string') {
|
|
152
|
+
if (key && CONTENT_KEYS.has(key)) {
|
|
153
|
+
return value.length > 32 ? `<redacted:${value.length}chars>` : '<redacted>'
|
|
154
|
+
}
|
|
155
|
+
if (key && NAME_KEYS.has(key)) {
|
|
156
|
+
return redactName(value)
|
|
157
|
+
}
|
|
158
|
+
return value.replace(URL_PATTERN, '<url>')
|
|
159
|
+
}
|
|
160
|
+
if (typeof value === 'boolean') return value
|
|
161
|
+
if (Array.isArray(value)) {
|
|
162
|
+
// For NAME_KEYS that hold arrays of nicknames (notably `k`), redact each
|
|
163
|
+
// element as a name regardless of its position.
|
|
164
|
+
if (key && NAME_KEYS.has(key)) {
|
|
165
|
+
return value.map((v) => (typeof v === 'string' ? redactName(v) : redact(v, key)))
|
|
166
|
+
}
|
|
167
|
+
return value.map((v) => redact(v, key))
|
|
168
|
+
}
|
|
169
|
+
if (typeof value === 'object') {
|
|
170
|
+
const out: Record<string, unknown> = {}
|
|
171
|
+
for (const [k, v] of Object.entries(value)) {
|
|
172
|
+
out[k] = redact(v, k)
|
|
173
|
+
}
|
|
174
|
+
return out
|
|
175
|
+
}
|
|
176
|
+
return value
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function parseLong(s: string): Long {
|
|
180
|
+
const big = BigInt(s)
|
|
181
|
+
const low = Number(big & 0xffffffffn)
|
|
182
|
+
const high = Number((big >> 32n) & 0xffffffffn)
|
|
183
|
+
return new Long(low, high)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function toLong(v: unknown): Long | null {
|
|
187
|
+
if (v && typeof v === 'object' && 'low' in v && 'high' in v) {
|
|
188
|
+
const { low, high } = v as { low: number; high: number }
|
|
189
|
+
return new Long(low, high)
|
|
190
|
+
}
|
|
191
|
+
if (typeof v === 'number' && Number.isFinite(v)) {
|
|
192
|
+
return Long.fromNumber(v)
|
|
193
|
+
}
|
|
194
|
+
return null
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
interface CaptureEntry {
|
|
198
|
+
command: Command
|
|
199
|
+
chatId: string
|
|
200
|
+
status: 'ok' | 'error'
|
|
201
|
+
status_code?: number
|
|
202
|
+
body?: unknown
|
|
203
|
+
error?: string
|
|
204
|
+
duration_ms: number
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function probe(
|
|
208
|
+
session: LocoSession,
|
|
209
|
+
command: Command,
|
|
210
|
+
chat: Record<string, unknown>,
|
|
211
|
+
delayMs: number,
|
|
212
|
+
): Promise<CaptureEntry> {
|
|
213
|
+
const chatId = parseLong(String(chat.c))
|
|
214
|
+
const chatIdStr = String(chat.c)
|
|
215
|
+
const start = Date.now()
|
|
216
|
+
|
|
217
|
+
await sleep(delayMs)
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
let body: Record<string, unknown>
|
|
221
|
+
let statusCode = 0
|
|
222
|
+
|
|
223
|
+
switch (command) {
|
|
224
|
+
case 'CHATINFO': {
|
|
225
|
+
const res = await session.getChannelInfo(chatId)
|
|
226
|
+
body = res.body
|
|
227
|
+
statusCode = res.statusCode
|
|
228
|
+
break
|
|
229
|
+
}
|
|
230
|
+
case 'CHATONROOM': {
|
|
231
|
+
const res = await session.getChatInfo(chatId)
|
|
232
|
+
body = res.body
|
|
233
|
+
statusCode = res.statusCode
|
|
234
|
+
break
|
|
235
|
+
}
|
|
236
|
+
case 'LCHATLIST': {
|
|
237
|
+
const res = await session.getChatList()
|
|
238
|
+
body = res.body
|
|
239
|
+
statusCode = res.statusCode
|
|
240
|
+
break
|
|
241
|
+
}
|
|
242
|
+
case 'MEMBER': {
|
|
243
|
+
const rawIds = (chat.i as Array<unknown> | undefined) ?? []
|
|
244
|
+
const sample: Long[] = []
|
|
245
|
+
for (const raw of rawIds.slice(0, 3)) {
|
|
246
|
+
const id = toLong(raw)
|
|
247
|
+
if (id) sample.push(id)
|
|
248
|
+
}
|
|
249
|
+
if (sample.length === 0) {
|
|
250
|
+
return {
|
|
251
|
+
command,
|
|
252
|
+
chatId: chatIdStr,
|
|
253
|
+
status: 'error',
|
|
254
|
+
error: 'no member ids available in chat data — skipping',
|
|
255
|
+
duration_ms: Date.now() - start,
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
const res = await session.getMembersByIds(chatId, sample)
|
|
259
|
+
body = res.body
|
|
260
|
+
statusCode = res.statusCode
|
|
261
|
+
break
|
|
262
|
+
}
|
|
263
|
+
case 'GETMEM': {
|
|
264
|
+
const res = await session.getAllMembers(chatId)
|
|
265
|
+
body = res.body
|
|
266
|
+
statusCode = res.statusCode
|
|
267
|
+
break
|
|
268
|
+
}
|
|
269
|
+
case 'INFOLINK': {
|
|
270
|
+
const linkId = toLong(chat.li)
|
|
271
|
+
if (!linkId) {
|
|
272
|
+
return {
|
|
273
|
+
command,
|
|
274
|
+
chatId: chatIdStr,
|
|
275
|
+
status: 'error',
|
|
276
|
+
error: 'not an open chat (no `li` field) — skipping',
|
|
277
|
+
duration_ms: Date.now() - start,
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const res = await session.getOpenLinkInfo([linkId])
|
|
281
|
+
body = res.body
|
|
282
|
+
statusCode = res.statusCode
|
|
283
|
+
break
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
command,
|
|
289
|
+
chatId: chatIdStr,
|
|
290
|
+
status: 'ok',
|
|
291
|
+
status_code: statusCode,
|
|
292
|
+
body: redact(body),
|
|
293
|
+
duration_ms: Date.now() - start,
|
|
294
|
+
}
|
|
295
|
+
} catch (error) {
|
|
296
|
+
return {
|
|
297
|
+
command,
|
|
298
|
+
chatId: chatIdStr,
|
|
299
|
+
status: 'error',
|
|
300
|
+
error: error instanceof Error ? error.message : String(error),
|
|
301
|
+
duration_ms: Date.now() - start,
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function selfCheckRedact(): void {
|
|
307
|
+
// Defensive: any change that breaks redaction would silently leak PII into the
|
|
308
|
+
// capture file. Verify on every run against a known fixture before any LOCO
|
|
309
|
+
// calls happen. Fail loud if the contract changes.
|
|
310
|
+
const fixture = {
|
|
311
|
+
chatInfo: {
|
|
312
|
+
type: 'MultiChat',
|
|
313
|
+
chatMetas: [
|
|
314
|
+
{ type: 3, content: 'My Group' },
|
|
315
|
+
{ type: 1, content: 'Pinned message body' },
|
|
316
|
+
],
|
|
317
|
+
displayMembers: [{ userId: 42, nickName: 'Alice', profileImageUrl: 'https://kakao.com/p/alice.jpg' }],
|
|
318
|
+
},
|
|
319
|
+
authorId: 1234567890,
|
|
320
|
+
message: 'secret hi',
|
|
321
|
+
k: ['Alice', 'Bob', 'Charlie'],
|
|
322
|
+
ln: 'My OpenChat Room',
|
|
323
|
+
name: 'Display Name',
|
|
324
|
+
fullName: 'Real Name',
|
|
325
|
+
l: { low: 999, high: 0 },
|
|
326
|
+
}
|
|
327
|
+
const out = redact(fixture) as Record<string, unknown>
|
|
328
|
+
const info = out.chatInfo as Record<string, unknown>
|
|
329
|
+
const display = (info.displayMembers as Array<Record<string, unknown>>)[0]
|
|
330
|
+
const metas = info.chatMetas as Array<Record<string, unknown>>
|
|
331
|
+
|
|
332
|
+
const failures: string[] = []
|
|
333
|
+
if (out.authorId === 1234567890) failures.push('authorId not hashed')
|
|
334
|
+
if (display.userId === 42) failures.push('displayMembers[].userId not hashed')
|
|
335
|
+
if ((display.profileImageUrl as string) !== '<url>') failures.push('profileImageUrl not scrubbed')
|
|
336
|
+
if ((display.nickName as string) === 'Alice') failures.push('displayMembers[].nickName not redacted')
|
|
337
|
+
if (out.message !== '<redacted>') failures.push('message content not redacted')
|
|
338
|
+
if (metas[0].content !== '<redacted>') failures.push('chatMetas content not redacted')
|
|
339
|
+
const memberNames = out.k as unknown[]
|
|
340
|
+
if (!Array.isArray(memberNames) || memberNames.some((n) => typeof n === 'string' && n === 'Alice')) {
|
|
341
|
+
failures.push('member name array (k) not redacted')
|
|
342
|
+
}
|
|
343
|
+
if (out.ln === 'My OpenChat Room') failures.push('open-link name (ln) not redacted')
|
|
344
|
+
if (out.name === 'Display Name') failures.push('display name not redacted')
|
|
345
|
+
if (out.fullName === 'Real Name') failures.push('fullName not redacted')
|
|
346
|
+
if (typeof out.l !== 'object' || (out.l as Record<string, unknown>).__long !== true) {
|
|
347
|
+
failures.push('non-user Long not preserved as tagged shape')
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (failures.length > 0) {
|
|
351
|
+
throw new Error(`redact() self-check failed:\n - ${failures.join('\n - ')}`)
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function main(): Promise<void> {
|
|
356
|
+
selfCheckRedact()
|
|
357
|
+
|
|
358
|
+
const args = parseArgs(process.argv.slice(2))
|
|
359
|
+
|
|
360
|
+
console.log('kakao-loco-capture — plan:')
|
|
361
|
+
console.log(` max_chats: ${args.maxChats}`)
|
|
362
|
+
console.log(` delay_ms: ${args.delayMs}`)
|
|
363
|
+
console.log(` commands: ${args.commands.join(', ')}`)
|
|
364
|
+
console.log(` chat_id: ${args.chatId ?? '<from login snapshot>'}`)
|
|
365
|
+
console.log(` account: ${args.account ? '<set>' : '<current>'}`)
|
|
366
|
+
console.log(` confirm: ${args.confirm}`)
|
|
367
|
+
console.log()
|
|
368
|
+
|
|
369
|
+
if (!args.confirm) {
|
|
370
|
+
console.log('Dry run (no --confirm). Re-run with --confirm to actually issue LOCO calls.')
|
|
371
|
+
return
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const client = new KakaoTalkClient()
|
|
375
|
+
|
|
376
|
+
// Signal handler: `finally` does not run on SIGINT/SIGTERM, so wire explicit
|
|
377
|
+
// cleanup. Without this, Ctrl-C during a long sleep() leaks the LOCO socket
|
|
378
|
+
// and ping timer.
|
|
379
|
+
let cleaningUp = false
|
|
380
|
+
const cleanup = (signal: string) => {
|
|
381
|
+
if (cleaningUp) return
|
|
382
|
+
cleaningUp = true
|
|
383
|
+
console.error(`\nReceived ${signal}, closing LOCO session...`)
|
|
384
|
+
try {
|
|
385
|
+
client.close()
|
|
386
|
+
} catch {}
|
|
387
|
+
process.exit(130)
|
|
388
|
+
}
|
|
389
|
+
process.on('SIGINT', () => cleanup('SIGINT'))
|
|
390
|
+
process.on('SIGTERM', () => cleanup('SIGTERM'))
|
|
391
|
+
|
|
392
|
+
await client.login(undefined, args.account ?? undefined)
|
|
393
|
+
|
|
394
|
+
try {
|
|
395
|
+
const allChats = await client.getChats()
|
|
396
|
+
if (allChats.length === 0) {
|
|
397
|
+
console.error('No chats in login snapshot. Aborting.')
|
|
398
|
+
return
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const session = await client.acquireSession()
|
|
402
|
+
|
|
403
|
+
const sessionState = (
|
|
404
|
+
client as unknown as { state: { loginResult: { chatDatas?: Array<Record<string, unknown>> } } }
|
|
405
|
+
).state
|
|
406
|
+
const chatDatas = sessionState?.loginResult?.chatDatas ?? []
|
|
407
|
+
|
|
408
|
+
let targets: Array<Record<string, unknown>>
|
|
409
|
+
if (args.chatId) {
|
|
410
|
+
const match = chatDatas.find((c) => String(c.c) === args.chatId)
|
|
411
|
+
if (!match) {
|
|
412
|
+
console.error(`chat ${args.chatId} not in login snapshot`)
|
|
413
|
+
return
|
|
414
|
+
}
|
|
415
|
+
targets = [match]
|
|
416
|
+
} else {
|
|
417
|
+
targets = chatDatas.slice(0, args.maxChats)
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
console.log(`Probing ${targets.length} chat(s) with ${args.commands.length} command(s) each...`)
|
|
421
|
+
console.log(`Estimated wire calls: ${targets.length * args.commands.length}`)
|
|
422
|
+
console.log(`Estimated duration: ~${Math.ceil((targets.length * args.commands.length * args.delayMs) / 1000)}s`)
|
|
423
|
+
console.log()
|
|
424
|
+
|
|
425
|
+
const entries: CaptureEntry[] = []
|
|
426
|
+
|
|
427
|
+
if (args.commands.includes('LCHATLIST')) {
|
|
428
|
+
entries.push({
|
|
429
|
+
command: 'LCHATLIST',
|
|
430
|
+
chatId: '<login_snapshot>',
|
|
431
|
+
status: 'ok',
|
|
432
|
+
body: redact({ chatDatas: chatDatas.slice(0, args.maxChats) }),
|
|
433
|
+
duration_ms: 0,
|
|
434
|
+
})
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
for (const chat of targets) {
|
|
438
|
+
for (const command of args.commands) {
|
|
439
|
+
if (command === 'LCHATLIST') continue
|
|
440
|
+
const result = await probe(session, command, chat, args.delayMs)
|
|
441
|
+
const tag = result.status === 'ok' ? 'ok ' : 'ERR'
|
|
442
|
+
console.log(
|
|
443
|
+
` [${tag}] ${command.padEnd(10)} chat=${result.chatId} ${result.duration_ms}ms${result.error ? `: ${result.error}` : ''}`,
|
|
444
|
+
)
|
|
445
|
+
entries.push(result)
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const outPath = join(tmpdir(), `kakao-loco-capture-${new Date().toISOString().replace(/[:.]/g, '-')}.json`)
|
|
450
|
+
const payload = {
|
|
451
|
+
captured_at: new Date().toISOString(),
|
|
452
|
+
args: { ...args, account: args.account ? '<set>' : null, chatId: args.chatId ? '<set>' : null },
|
|
453
|
+
entries,
|
|
454
|
+
}
|
|
455
|
+
writeFileSync(outPath, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 })
|
|
456
|
+
console.log()
|
|
457
|
+
console.log(`Wrote ${entries.length} entries to ${outPath}`)
|
|
458
|
+
} finally {
|
|
459
|
+
client.close()
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
main().catch((error) => {
|
|
464
|
+
console.error('kakao-loco-capture failed:', error instanceof Error ? error.message : error)
|
|
465
|
+
process.exit(1)
|
|
466
|
+
})
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: agent-channeltalk
|
|
3
3
|
description: Interact with Channel Talk using extracted desktop app or browser credentials - read chats, send messages, search messages, manage groups
|
|
4
|
-
version: 2.
|
|
4
|
+
version: 2.13.1
|
|
5
5
|
allowed-tools: Bash(agent-channeltalk:*)
|
|
6
6
|
metadata:
|
|
7
7
|
openclaw:
|