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.
Files changed (104) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/dist/package.json +1 -1
  3. package/dist/src/platforms/discordbot/index.d.ts +2 -1
  4. package/dist/src/platforms/discordbot/index.d.ts.map +1 -1
  5. package/dist/src/platforms/discordbot/index.js.map +1 -1
  6. package/dist/src/platforms/discordbot/listener.d.ts +4 -3
  7. package/dist/src/platforms/discordbot/listener.d.ts.map +1 -1
  8. package/dist/src/platforms/discordbot/listener.js.map +1 -1
  9. package/dist/src/platforms/discordbot/types.d.ts +21 -0
  10. package/dist/src/platforms/discordbot/types.d.ts.map +1 -1
  11. package/dist/src/platforms/kakaotalk/chat-classifier.d.ts +18 -0
  12. package/dist/src/platforms/kakaotalk/chat-classifier.d.ts.map +1 -0
  13. package/dist/src/platforms/kakaotalk/chat-classifier.js +29 -0
  14. package/dist/src/platforms/kakaotalk/chat-classifier.js.map +1 -0
  15. package/dist/src/platforms/kakaotalk/cli.d.ts.map +1 -1
  16. package/dist/src/platforms/kakaotalk/cli.js +2 -1
  17. package/dist/src/platforms/kakaotalk/cli.js.map +1 -1
  18. package/dist/src/platforms/kakaotalk/client.d.ts +13 -1
  19. package/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
  20. package/dist/src/platforms/kakaotalk/client.js +225 -8
  21. package/dist/src/platforms/kakaotalk/client.js.map +1 -1
  22. package/dist/src/platforms/kakaotalk/commands/chat.d.ts.map +1 -1
  23. package/dist/src/platforms/kakaotalk/commands/chat.js +2 -1
  24. package/dist/src/platforms/kakaotalk/commands/chat.js.map +1 -1
  25. package/dist/src/platforms/kakaotalk/commands/index.d.ts +1 -0
  26. package/dist/src/platforms/kakaotalk/commands/index.d.ts.map +1 -1
  27. package/dist/src/platforms/kakaotalk/commands/index.js +1 -0
  28. package/dist/src/platforms/kakaotalk/commands/index.js.map +1 -1
  29. package/dist/src/platforms/kakaotalk/commands/member.d.ts +3 -0
  30. package/dist/src/platforms/kakaotalk/commands/member.d.ts.map +1 -0
  31. package/dist/src/platforms/kakaotalk/commands/member.js +22 -0
  32. package/dist/src/platforms/kakaotalk/commands/member.js.map +1 -0
  33. package/dist/src/platforms/kakaotalk/index.d.ts +4 -2
  34. package/dist/src/platforms/kakaotalk/index.d.ts.map +1 -1
  35. package/dist/src/platforms/kakaotalk/index.js +2 -1
  36. package/dist/src/platforms/kakaotalk/index.js.map +1 -1
  37. package/dist/src/platforms/kakaotalk/listener.d.ts.map +1 -1
  38. package/dist/src/platforms/kakaotalk/listener.js +5 -2
  39. package/dist/src/platforms/kakaotalk/listener.js.map +1 -1
  40. package/dist/src/platforms/kakaotalk/protocol/session.d.ts +28 -0
  41. package/dist/src/platforms/kakaotalk/protocol/session.d.ts.map +1 -1
  42. package/dist/src/platforms/kakaotalk/protocol/session.js +44 -0
  43. package/dist/src/platforms/kakaotalk/protocol/session.js.map +1 -1
  44. package/dist/src/platforms/kakaotalk/types.d.ts +37 -0
  45. package/dist/src/platforms/kakaotalk/types.d.ts.map +1 -1
  46. package/dist/src/platforms/kakaotalk/types.js +17 -0
  47. package/dist/src/platforms/kakaotalk/types.js.map +1 -1
  48. package/dist/src/platforms/slackbot/client.d.ts +5 -0
  49. package/dist/src/platforms/slackbot/client.d.ts.map +1 -1
  50. package/dist/src/platforms/slackbot/client.js +5 -0
  51. package/dist/src/platforms/slackbot/client.js.map +1 -1
  52. package/dist/src/platforms/slackbot/index.d.ts +2 -2
  53. package/dist/src/platforms/slackbot/index.d.ts.map +1 -1
  54. package/dist/src/platforms/slackbot/index.js +1 -1
  55. package/dist/src/platforms/slackbot/index.js.map +1 -1
  56. package/dist/src/tui/adapters/kakaotalk-adapter.js +3 -3
  57. package/dist/src/tui/adapters/kakaotalk-adapter.js.map +1 -1
  58. package/docs/content/docs/cli/kakaotalk.mdx +26 -1
  59. package/docs/content/docs/sdk/kakaotalk.mdx +45 -13
  60. package/docs/content/docs/sdk/slackbot.mdx +11 -0
  61. package/package.json +1 -1
  62. package/scripts/kakao-loco-capture.ts +466 -0
  63. package/skills/agent-channeltalk/SKILL.md +1 -1
  64. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  65. package/skills/agent-discord/SKILL.md +1 -1
  66. package/skills/agent-discordbot/SKILL.md +1 -1
  67. package/skills/agent-instagram/SKILL.md +1 -1
  68. package/skills/agent-kakaotalk/SKILL.md +30 -3
  69. package/skills/agent-kakaotalk/references/common-patterns.md +49 -1
  70. package/skills/agent-line/SKILL.md +1 -1
  71. package/skills/agent-slack/SKILL.md +1 -1
  72. package/skills/agent-slackbot/SKILL.md +1 -2
  73. package/skills/agent-teams/SKILL.md +1 -1
  74. package/skills/agent-telegram/SKILL.md +1 -1
  75. package/skills/agent-telegrambot/SKILL.md +1 -1
  76. package/skills/agent-webex/SKILL.md +1 -1
  77. package/skills/agent-wechatbot/SKILL.md +1 -1
  78. package/skills/agent-whatsapp/SKILL.md +1 -1
  79. package/skills/agent-whatsappbot/SKILL.md +1 -1
  80. package/src/platforms/discordbot/index.test.ts +76 -0
  81. package/src/platforms/discordbot/index.ts +3 -0
  82. package/src/platforms/discordbot/listener.test.ts +41 -0
  83. package/src/platforms/discordbot/listener.ts +5 -1
  84. package/src/platforms/discordbot/types.ts +23 -1
  85. package/src/platforms/kakaotalk/chat-classifier.test.ts +33 -0
  86. package/src/platforms/kakaotalk/chat-classifier.ts +31 -0
  87. package/src/platforms/kakaotalk/cli.ts +2 -1
  88. package/src/platforms/kakaotalk/client.test.ts +782 -1
  89. package/src/platforms/kakaotalk/client.ts +262 -10
  90. package/src/platforms/kakaotalk/commands/chat.ts +3 -1
  91. package/src/platforms/kakaotalk/commands/index.ts +1 -0
  92. package/src/platforms/kakaotalk/commands/member.test.ts +102 -0
  93. package/src/platforms/kakaotalk/commands/member.ts +32 -0
  94. package/src/platforms/kakaotalk/index.test.ts +5 -0
  95. package/src/platforms/kakaotalk/index.ts +4 -0
  96. package/src/platforms/kakaotalk/listener.test.ts +29 -0
  97. package/src/platforms/kakaotalk/listener.ts +5 -2
  98. package/src/platforms/kakaotalk/protocol/session.ts +44 -0
  99. package/src/platforms/kakaotalk/types.ts +39 -0
  100. package/src/platforms/slackbot/client.test.ts +67 -0
  101. package/src/platforms/slackbot/client.ts +17 -1
  102. package/src/platforms/slackbot/index.test.ts +10 -0
  103. package/src/platforms/slackbot/index.ts +4 -0
  104. 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. Manages its own LOCO session with automatic reconnection.
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('Reconnecting...'))
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` | — | Disconnected (will auto-reconnect) |
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 automatically reconnects with exponential backoff (1s → 2s → 4s → ... → 30s max) when the connection drops. Server-requested migrations (`CHANGESVR`) trigger immediate reconnection. If the session is kicked by another device (`KICKOUT`), the listener stops and emits an error.
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.12.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.12.2
4
+ version: 2.13.1
5
5
  allowed-tools: Bash(agent-channeltalk:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-channeltalkbot
3
3
  description: Interact with Channel Talk workspaces using API credentials - send messages, read chats, manage groups and bots
4
- version: 2.12.2
4
+ version: 2.13.1
5
5
  allowed-tools: Bash(agent-channeltalkbot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-discord
3
3
  description: Interact with Discord servers - send messages, read channels, manage reactions
4
- version: 2.12.2
4
+ version: 2.13.1
5
5
  allowed-tools: Bash(agent-discord:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-discordbot
3
3
  description: Interact with Discord servers using bot tokens - send messages, read channels, manage reactions
4
- version: 2.12.2
4
+ version: 2.13.1
5
5
  allowed-tools: Bash(agent-discordbot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-instagram
3
3
  description: Interact with Instagram DMs - send messages, read conversations, manage accounts
4
- version: 2.12.2
4
+ version: 2.13.1
5
5
  allowed-tools: Bash(agent-instagram:*)
6
6
  metadata:
7
7
  openclaw: