agent-messenger 2.17.0 → 2.19.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.
Files changed (112) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/bunfig.toml +1 -0
  3. package/dist/package.json +1 -1
  4. package/dist/src/platforms/discordbot/client.d.ts +1 -0
  5. package/dist/src/platforms/discordbot/client.d.ts.map +1 -1
  6. package/dist/src/platforms/discordbot/client.js +3 -0
  7. package/dist/src/platforms/discordbot/client.js.map +1 -1
  8. package/dist/src/platforms/discordbot/commands/message.d.ts +1 -0
  9. package/dist/src/platforms/discordbot/commands/message.d.ts.map +1 -1
  10. package/dist/src/platforms/discordbot/commands/message.js +2 -0
  11. package/dist/src/platforms/discordbot/commands/message.js.map +1 -1
  12. package/dist/src/platforms/instagram/commands/auth.d.ts.map +1 -1
  13. package/dist/src/platforms/instagram/commands/auth.js +1 -3
  14. package/dist/src/platforms/instagram/commands/auth.js.map +1 -1
  15. package/dist/src/platforms/kakaotalk/client.d.ts +4 -2
  16. package/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
  17. package/dist/src/platforms/kakaotalk/client.js +16 -2
  18. package/dist/src/platforms/kakaotalk/client.js.map +1 -1
  19. package/dist/src/platforms/kakaotalk/commands/auth.d.ts.map +1 -1
  20. package/dist/src/platforms/kakaotalk/commands/auth.js +2 -17
  21. package/dist/src/platforms/kakaotalk/commands/auth.js.map +1 -1
  22. package/dist/src/platforms/kakaotalk/commands/message.d.ts.map +1 -1
  23. package/dist/src/platforms/kakaotalk/commands/message.js +23 -1
  24. package/dist/src/platforms/kakaotalk/commands/message.js.map +1 -1
  25. package/dist/src/platforms/kakaotalk/index.d.ts +1 -1
  26. package/dist/src/platforms/kakaotalk/index.d.ts.map +1 -1
  27. package/dist/src/platforms/kakaotalk/index.js.map +1 -1
  28. package/dist/src/platforms/kakaotalk/protocol/session.d.ts +2 -1
  29. package/dist/src/platforms/kakaotalk/protocol/session.d.ts.map +1 -1
  30. package/dist/src/platforms/kakaotalk/protocol/session.js +15 -0
  31. package/dist/src/platforms/kakaotalk/protocol/session.js.map +1 -1
  32. package/dist/src/platforms/kakaotalk/types.d.ts +18 -0
  33. package/dist/src/platforms/kakaotalk/types.d.ts.map +1 -1
  34. package/dist/src/platforms/kakaotalk/types.js +1 -0
  35. package/dist/src/platforms/kakaotalk/types.js.map +1 -1
  36. package/dist/src/platforms/line/commands/auth.d.ts.map +1 -1
  37. package/dist/src/platforms/line/commands/auth.js +2 -4
  38. package/dist/src/platforms/line/commands/auth.js.map +1 -1
  39. package/dist/src/platforms/telegram/commands/auth.d.ts.map +1 -1
  40. package/dist/src/platforms/telegram/commands/auth.js +5 -7
  41. package/dist/src/platforms/telegram/commands/auth.js.map +1 -1
  42. package/dist/src/platforms/webex/commands/auth.d.ts +5 -2
  43. package/dist/src/platforms/webex/commands/auth.d.ts.map +1 -1
  44. package/dist/src/platforms/webex/commands/auth.js +59 -1
  45. package/dist/src/platforms/webex/commands/auth.js.map +1 -1
  46. package/dist/src/platforms/webex/credential-manager.d.ts +11 -0
  47. package/dist/src/platforms/webex/credential-manager.d.ts.map +1 -1
  48. package/dist/src/platforms/webex/credential-manager.js +37 -0
  49. package/dist/src/platforms/webex/credential-manager.js.map +1 -1
  50. package/dist/src/platforms/whatsapp/commands/auth.d.ts.map +1 -1
  51. package/dist/src/platforms/whatsapp/commands/auth.js +2 -4
  52. package/dist/src/platforms/whatsapp/commands/auth.js.map +1 -1
  53. package/dist/src/shared/chromium/browsers.js +1 -1
  54. package/dist/src/shared/chromium/browsers.js.map +1 -1
  55. package/dist/src/shared/utils/interactive.d.ts +3 -0
  56. package/dist/src/shared/utils/interactive.d.ts.map +1 -0
  57. package/dist/src/shared/utils/interactive.js +16 -0
  58. package/dist/src/shared/utils/interactive.js.map +1 -0
  59. package/docs/content/docs/cli/discordbot.mdx +6 -0
  60. package/docs/content/docs/sdk/discordbot.mdx +4 -0
  61. package/package.json +1 -1
  62. package/skills/agent-channeltalk/SKILL.md +1 -1
  63. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  64. package/skills/agent-discord/SKILL.md +1 -1
  65. package/skills/agent-discordbot/SKILL.md +9 -1
  66. package/skills/agent-instagram/SKILL.md +1 -1
  67. package/skills/agent-kakaotalk/SKILL.md +24 -1
  68. package/skills/agent-line/SKILL.md +1 -1
  69. package/skills/agent-slack/SKILL.md +1 -1
  70. package/skills/agent-slackbot/SKILL.md +1 -1
  71. package/skills/agent-teams/SKILL.md +1 -1
  72. package/skills/agent-telegram/SKILL.md +1 -1
  73. package/skills/agent-telegrambot/SKILL.md +1 -1
  74. package/skills/agent-webex/SKILL.md +32 -1
  75. package/skills/agent-wechatbot/SKILL.md +1 -1
  76. package/skills/agent-whatsapp/SKILL.md +1 -1
  77. package/skills/agent-whatsappbot/SKILL.md +1 -1
  78. package/src/platforms/discord/commands/dm.test.ts +28 -20
  79. package/src/platforms/discord/commands/reaction.test.ts +12 -7
  80. package/src/platforms/discordbot/client.test.ts +17 -0
  81. package/src/platforms/discordbot/client.ts +9 -2
  82. package/src/platforms/discordbot/commands/message.test.ts +28 -11
  83. package/src/platforms/discordbot/commands/message.ts +4 -2
  84. package/src/platforms/instagram/commands/auth.test.ts +11 -9
  85. package/src/platforms/instagram/commands/auth.ts +1 -4
  86. package/src/platforms/instagram/commands/chat.test.ts +8 -6
  87. package/src/platforms/instagram/commands/message.test.ts +8 -6
  88. package/src/platforms/kakaotalk/client.test.ts +57 -0
  89. package/src/platforms/kakaotalk/client.ts +23 -2
  90. package/src/platforms/kakaotalk/commands/auth.ts +2 -18
  91. package/src/platforms/kakaotalk/commands/message.test.ts +42 -0
  92. package/src/platforms/kakaotalk/commands/message.ts +33 -2
  93. package/src/platforms/kakaotalk/index.ts +2 -0
  94. package/src/platforms/kakaotalk/protocol/session.ts +15 -1
  95. package/src/platforms/kakaotalk/types.ts +24 -0
  96. package/src/platforms/line/commands/auth.ts +2 -5
  97. package/src/platforms/telegram/commands/auth.ts +5 -8
  98. package/src/platforms/webex/commands/auth.test.ts +178 -14
  99. package/src/platforms/webex/commands/auth.ts +102 -3
  100. package/src/platforms/webex/commands/member.test.ts +14 -20
  101. package/src/platforms/webex/commands/message.test.ts +11 -20
  102. package/src/platforms/webex/commands/snapshot.test.ts +11 -20
  103. package/src/platforms/webex/commands/space.test.ts +15 -23
  104. package/src/platforms/webex/commands/whoami.test.ts +8 -22
  105. package/src/platforms/webex/credential-manager.test.ts +78 -0
  106. package/src/platforms/webex/credential-manager.ts +59 -0
  107. package/src/platforms/whatsapp/commands/auth.ts +2 -5
  108. package/src/shared/chromium/browsers.ts +1 -1
  109. package/src/shared/utils/interactive.test.ts +55 -0
  110. package/src/shared/utils/interactive.ts +15 -0
  111. package/src/test-setup.ts +5 -0
  112. package/tsconfig.json +1 -1
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-telegrambot
3
3
  description: Interact with Telegram using bot tokens - send messages, read chats, manage reactions
4
- version: 2.17.0
4
+ version: 2.19.0
5
5
  allowed-tools: Bash(agent-telegrambot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-webex
3
3
  description: Interact with Cisco Webex - send messages, read spaces, manage memberships
4
- version: 2.17.0
4
+ version: 2.19.0
5
5
  allowed-tools: Bash(agent-webex:*)
6
6
  metadata:
7
7
  openclaw:
@@ -76,6 +76,37 @@ Note: Messages sent via OAuth Device Grant show "via agent-messenger" because th
76
76
 
77
77
  Optionally, pass `--token <bot-token>` for bot token auth. Or pass `--client-id <id> --client-secret <secret>` to use your own Webex Integration credentials instead of the built-in ones.
78
78
 
79
+ **For AI agents (non-TTY)**: `agent-webex auth login` exposes the OAuth Device Grant flow as a stateless two-call sequence — no hangs, no polling loops, no on-disk state. Just structured JSON every time.
80
+
81
+ **Call 1** (no `--device-code` passed): the command requests a device code from Webex and returns immediately:
82
+
83
+ ```json
84
+ {
85
+ "next_action": "authorize_in_browser",
86
+ "verification_uri": "https://login.webex.com/verify",
87
+ "verification_uri_complete": "https://login.webex.com/verify?userCode=ABC123",
88
+ "user_code": "ABC123",
89
+ "device_code": "d8eb0eca-2fee-428e-a59e-5e6d487b33ba",
90
+ "expires_at": 1779786537203,
91
+ "message": "Show the user `verification_uri` and `user_code` ..."
92
+ }
93
+ ```
94
+
95
+ Show the user `verification_uri_complete` (or `verification_uri` + `user_code`) in chat. **Remember the `device_code` value** — you will pass it back on the second call. Ask the user to confirm once they have approved access in any browser, on any device.
96
+
97
+ **Call 2** (`--device-code <device_code>`): pass the `device_code` from Call 1's response. The command makes one polling call to Webex:
98
+
99
+ - **Success** — returns `{ "authenticated": true, "user": { ... } }`, exit 0.
100
+ - **Still pending** — returns `{ "next_action": "still_pending", "device_code": "...", ... }`, exit 0. The user has not approved yet; confirm with them and retry with the same `--device-code` value.
101
+ - **Expired / failed** — returns `{ "next_action": "restart", "error": "..." }`, exit 1. The device code is no longer usable; start over with another `agent-webex auth login` (no flags) to get a fresh one.
102
+
103
+ If you passed `--client-id` / `--client-secret` (custom Webex Integration) on Call 1, pass them again on Call 2.
104
+
105
+ Alternatives that skip the Device Grant flow entirely:
106
+
107
+ - `agent-webex auth login --token <bot-or-personal-access-token>` — fully unattended, no human required.
108
+ - `agent-webex auth extract` — read an existing browser session token (no auth flow at all).
109
+
79
110
  Env vars `AGENT_WEBEX_CLIENT_ID` / `AGENT_WEBEX_CLIENT_SECRET` can also override the built-in credentials.
80
111
 
81
112
  ```bash
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-wechatbot
3
3
  description: Interact with WeChat Official Account using API credentials - send messages, manage templates, list followers
4
- version: 2.17.0
4
+ version: 2.19.0
5
5
  allowed-tools: Bash(agent-wechatbot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-whatsapp
3
3
  description: Interact with WhatsApp - send messages, read chats, manage conversations
4
- version: 2.17.0
4
+ version: 2.19.0
5
5
  allowed-tools: Bash(agent-whatsapp:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-whatsappbot
3
3
  description: Interact with WhatsApp using Cloud API credentials - send messages, manage templates
4
- version: 2.17.0
4
+ version: 2.19.0
5
5
  allowed-tools: Bash(agent-whatsappbot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -5,6 +5,12 @@ import { DiscordCredentialManager } from '../credential-manager'
5
5
  import type { DiscordDMChannel } from '../types'
6
6
  import { createAction, listAction } from './dm'
7
7
 
8
+ class ProcessExit extends Error {
9
+ constructor(readonly code?: string | number | null) {
10
+ super(`process.exit(${code})`)
11
+ }
12
+ }
13
+
8
14
  let clientListDMChannelsSpy: ReturnType<typeof spyOn>
9
15
  let clientCreateDMSpy: ReturnType<typeof spyOn>
10
16
  let credManagerLoadSpy: ReturnType<typeof spyOn>
@@ -86,21 +92,22 @@ describe('dm commands', () => {
86
92
  servers: {},
87
93
  })
88
94
 
89
- const exitSpy = mock(() => {})
90
- const originalExit = process.exit
91
- process.exit = exitSpy as any
95
+ const exitSpy = spyOn(process, 'exit').mockImplementation((code?: string | number | null) => {
96
+ throw new ProcessExit(code)
97
+ })
92
98
 
93
99
  const consoleSpy = mock(() => {})
94
100
  const originalLog = console.log
95
101
  console.log = consoleSpy
96
102
 
97
- await listAction({ pretty: false })
98
-
99
- console.log = originalLog
100
- process.exit = originalExit
101
-
102
- expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Not authenticated'))
103
- expect(exitSpy).toHaveBeenCalledWith(1)
103
+ try {
104
+ await expect(listAction({ pretty: false })).rejects.toThrow(ProcessExit)
105
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Not authenticated'))
106
+ expect(exitSpy).toHaveBeenCalledWith(1)
107
+ } finally {
108
+ console.log = originalLog
109
+ exitSpy.mockRestore()
110
+ }
104
111
  })
105
112
  })
106
113
 
@@ -125,21 +132,22 @@ describe('dm commands', () => {
125
132
  servers: {},
126
133
  })
127
134
 
128
- const exitSpy = mock(() => {})
129
- const originalExit = process.exit
130
- process.exit = exitSpy as any
135
+ const exitSpy = spyOn(process, 'exit').mockImplementation((code?: string | number | null) => {
136
+ throw new ProcessExit(code)
137
+ })
131
138
 
132
139
  const consoleSpy = mock(() => {})
133
140
  const originalLog = console.log
134
141
  console.log = consoleSpy
135
142
 
136
- await createAction('456', { pretty: false })
137
-
138
- console.log = originalLog
139
- process.exit = originalExit
140
-
141
- expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Not authenticated'))
142
- expect(exitSpy).toHaveBeenCalledWith(1)
143
+ try {
144
+ await expect(createAction('456', { pretty: false })).rejects.toThrow(ProcessExit)
145
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Not authenticated'))
146
+ expect(exitSpy).toHaveBeenCalledWith(1)
147
+ } finally {
148
+ console.log = originalLog
149
+ exitSpy.mockRestore()
150
+ }
143
151
  })
144
152
  })
145
153
  })
@@ -4,6 +4,12 @@ import { DiscordClient } from '../client'
4
4
  import { DiscordCredentialManager } from '../credential-manager'
5
5
  import { addAction, listAction, removeAction } from './reaction'
6
6
 
7
+ class ProcessExit extends Error {
8
+ constructor(readonly code?: string | number | null) {
9
+ super(`process.exit(${code})`)
10
+ }
11
+ }
12
+
7
13
  let clientAddReactionSpy: ReturnType<typeof spyOn>
8
14
  let clientRemoveReactionSpy: ReturnType<typeof spyOn>
9
15
  let clientGetMessageSpy: ReturnType<typeof spyOn>
@@ -114,21 +120,20 @@ it('add: handles missing token gracefully', async () => {
114
120
 
115
121
  const consoleSpy = mock((_msg: string) => {})
116
122
  const originalLog = console.log
117
- const originalExit = process.exit
118
- let _exitCode = 0
119
- process.exit = mock((code: number) => {
120
- _exitCode = code
121
- }) as any
123
+ const exitSpy = spyOn(process, 'exit').mockImplementation((code?: string | number | null) => {
124
+ throw new ProcessExit(code)
125
+ })
122
126
 
123
127
  console.log = consoleSpy
124
128
 
125
129
  try {
126
- await addAction('ch123', 'msg123', 'thumbsup', { pretty: false })
130
+ await expect(addAction('ch123', 'msg123', 'thumbsup', { pretty: false })).rejects.toThrow(ProcessExit)
127
131
  expect(consoleSpy).toHaveBeenCalled()
128
132
  const output = JSON.parse(consoleSpy.mock.calls[0][0])
129
133
  expect(output.error).toBeDefined()
134
+ expect(exitSpy).toHaveBeenCalledWith(1)
130
135
  } finally {
131
136
  console.log = originalLog
132
- process.exit = originalExit
137
+ exitSpy.mockRestore()
133
138
  }
134
139
  })
@@ -167,6 +167,23 @@ describe('DiscordBotClient', () => {
167
167
 
168
168
  expect(fetchCalls[0].options?.body).toBe(JSON.stringify({ content: 'Thread reply', thread_id: 'thread123' }))
169
169
  })
170
+
171
+ it('includes message_reference when reply_to is provided', async () => {
172
+ mockResponse({
173
+ id: 'msg1',
174
+ channel_id: 'ch1',
175
+ author: { id: '123', username: 'bot' },
176
+ content: 'Reply text',
177
+ timestamp: '2024-01-01T00:00:00.000Z',
178
+ })
179
+
180
+ const client = await new DiscordBotClient().login({ token: 'bot-token' })
181
+ await client.sendMessage('ch1', 'Reply text', { reply_to: 'parent123' })
182
+
183
+ expect(fetchCalls[0].options?.body).toBe(
184
+ JSON.stringify({ content: 'Reply text', message_reference: { message_id: 'parent123' } }),
185
+ )
186
+ })
170
187
  })
171
188
 
172
189
  describe('editMessage', () => {
@@ -249,11 +249,18 @@ export class DiscordBotClient {
249
249
  return this.request<DiscordChannel>('GET', `/channels/${channelId}`)
250
250
  }
251
251
 
252
- async sendMessage(channelId: string, content: string, options?: { thread_id?: string }): Promise<DiscordMessage> {
253
- const body: Record<string, string> = { content }
252
+ async sendMessage(
253
+ channelId: string,
254
+ content: string,
255
+ options?: { thread_id?: string; reply_to?: string },
256
+ ): Promise<DiscordMessage> {
257
+ const body: Record<string, unknown> = { content }
254
258
  if (options?.thread_id) {
255
259
  body.thread_id = options.thread_id
256
260
  }
261
+ if (options?.reply_to) {
262
+ body.message_reference = { message_id: options.reply_to }
263
+ }
257
264
  return this.request<DiscordMessage>('POST', `/channels/${channelId}/messages`, body)
258
265
  }
259
266
 
@@ -4,14 +4,15 @@ import { mkdir } from 'node:fs/promises'
4
4
  import { tmpdir } from 'node:os'
5
5
  import { join } from 'node:path'
6
6
 
7
- const mockSendMessage = mock((_channelId: string, content: string, _options?: { thread_id?: string }) =>
8
- Promise.resolve({
9
- id: 'msg1',
10
- channel_id: 'ch1',
11
- content,
12
- author: { id: 'bot1', username: 'testbot' },
13
- timestamp: '2025-01-01T00:00:00.000Z',
14
- }),
7
+ const mockSendMessage = mock(
8
+ (_channelId: string, content: string, _options?: { thread_id?: string; reply_to?: string }) =>
9
+ Promise.resolve({
10
+ id: 'msg1',
11
+ channel_id: 'ch1',
12
+ content,
13
+ author: { id: 'bot1', username: 'testbot' },
14
+ timestamp: '2025-01-01T00:00:00.000Z',
15
+ }),
15
16
  )
16
17
 
17
18
  const mockGetMessages = mock((_channelId: string, _limit?: number) =>
@@ -127,7 +128,7 @@ describe('message commands', () => {
127
128
  expect(result.content).toBe('hello world')
128
129
  expect(result.author).toBe('testbot')
129
130
  expect(mockResolveChannel).toHaveBeenCalledWith('guild1', 'general')
130
- expect(mockSendMessage).toHaveBeenCalledWith('ch1', 'hello world', { thread_id: undefined })
131
+ expect(mockSendMessage).toHaveBeenCalledWith('ch1', 'hello world', { thread_id: undefined, reply_to: undefined })
131
132
  })
132
133
 
133
134
  it('sends message to channel by ID', async () => {
@@ -135,7 +136,7 @@ describe('message commands', () => {
135
136
 
136
137
  expect(result.id).toBe('msg1')
137
138
  expect(mockResolveChannel).toHaveBeenCalledWith('guild1', '123456')
138
- expect(mockSendMessage).toHaveBeenCalledWith('123456', 'hi', { thread_id: undefined })
139
+ expect(mockSendMessage).toHaveBeenCalledWith('123456', 'hi', { thread_id: undefined, reply_to: undefined })
139
140
  })
140
141
 
141
142
  it('sends message to thread', async () => {
@@ -145,7 +146,23 @@ describe('message commands', () => {
145
146
  })
146
147
 
147
148
  expect(result.id).toBe('msg1')
148
- expect(mockSendMessage).toHaveBeenCalledWith('ch1', 'thread reply', { thread_id: 'thread123' })
149
+ expect(mockSendMessage).toHaveBeenCalledWith('ch1', 'thread reply', {
150
+ thread_id: 'thread123',
151
+ reply_to: undefined,
152
+ })
153
+ })
154
+
155
+ it('replies to a message', async () => {
156
+ const result = await sendAction('general', 'reply text', {
157
+ _credManager: manager,
158
+ reply: 'parent123',
159
+ })
160
+
161
+ expect(result.id).toBe('msg1')
162
+ expect(mockSendMessage).toHaveBeenCalledWith('ch1', 'reply text', {
163
+ thread_id: undefined,
164
+ reply_to: 'parent123',
165
+ })
149
166
  })
150
167
 
151
168
  it('returns error on channel not found', async () => {
@@ -28,7 +28,7 @@ interface MessageResult {
28
28
  export async function sendAction(
29
29
  channel: string,
30
30
  text: string,
31
- options: BotOption & { thread?: string },
31
+ options: BotOption & { thread?: string; reply?: string },
32
32
  ): Promise<MessageResult> {
33
33
  try {
34
34
  const client = await getClient(options)
@@ -36,6 +36,7 @@ export async function sendAction(
36
36
  const channelId = await client.resolveChannel(serverId, channel)
37
37
  const message = await client.sendMessage(channelId, text, {
38
38
  thread_id: options.thread,
39
+ reply_to: options.reply,
39
40
  })
40
41
 
41
42
  return {
@@ -173,10 +174,11 @@ export const messageCommand = new Command('message')
173
174
  .argument('<channel>', 'Channel ID or name')
174
175
  .argument('<text>', 'Message text')
175
176
  .option('--thread <id>', 'Thread ID for replies')
177
+ .option('--reply <message-id>', 'Reply to a message by ID')
176
178
  .option('--bot <id>', 'Use specific bot')
177
179
  .option('--server <id>', 'Server ID')
178
180
  .option('--pretty', 'Pretty print JSON output')
179
- .action(async (channel: string, text: string, opts: BotOption & { thread?: string }) => {
181
+ .action(async (channel: string, text: string, opts: BotOption & { thread?: string; reply?: string }) => {
180
182
  cliOutput(await sendAction(channel, text, opts), opts.pretty)
181
183
  }),
182
184
  )
@@ -3,20 +3,13 @@ import { afterEach, beforeEach, describe, expect, mock, spyOn, it } from 'bun:te
3
3
  const originalConsoleLog = console.log
4
4
  import type { Command } from 'commander'
5
5
 
6
+ import { InstagramCredentialManager } from '../credential-manager'
7
+
6
8
  const mockGetAccount = mock(() => Promise.resolve(null))
7
9
  const mockListAccounts = mock(() => Promise.resolve([]))
8
10
  const mockSetCurrent = mock(() => Promise.resolve(true))
9
11
  const mockRemoveAccount = mock(() => Promise.resolve(true))
10
12
 
11
- mock.module('../credential-manager', () => ({
12
- InstagramCredentialManager: class {
13
- getAccount = mockGetAccount
14
- listAccounts = mockListAccounts
15
- setCurrent = mockSetCurrent
16
- removeAccount = mockRemoveAccount
17
- },
18
- }))
19
-
20
13
  import { authCommand } from './auth'
21
14
 
22
15
  function resetCommandState(cmd: Command): void {
@@ -33,6 +26,7 @@ function resetCommandState(cmd: Command): void {
33
26
  describe('auth commands', () => {
34
27
  let consoleLogSpy: ReturnType<typeof mock>
35
28
  let processExitSpy: ReturnType<typeof spyOn>
29
+ const managerSpies: ReturnType<typeof spyOn>[] = []
36
30
 
37
31
  beforeEach(() => {
38
32
  resetCommandState(authCommand)
@@ -42,6 +36,13 @@ describe('auth commands', () => {
42
36
  mockSetCurrent.mockReset()
43
37
  mockRemoveAccount.mockReset()
44
38
 
39
+ managerSpies.push(
40
+ spyOn(InstagramCredentialManager.prototype, 'getAccount').mockImplementation(mockGetAccount),
41
+ spyOn(InstagramCredentialManager.prototype, 'listAccounts').mockImplementation(mockListAccounts),
42
+ spyOn(InstagramCredentialManager.prototype, 'setCurrent').mockImplementation(mockSetCurrent),
43
+ spyOn(InstagramCredentialManager.prototype, 'removeAccount').mockImplementation(mockRemoveAccount),
44
+ )
45
+
45
46
  consoleLogSpy = mock((..._args: unknown[]) => {})
46
47
  console.log = consoleLogSpy
47
48
  processExitSpy = spyOn(process, 'exit').mockImplementation(() => {
@@ -51,6 +52,7 @@ describe('auth commands', () => {
51
52
 
52
53
  afterEach(() => {
53
54
  console.log = originalConsoleLog
55
+ for (const spy of managerSpies.splice(0)) spy.mockRestore()
54
56
  processExitSpy.mockRestore()
55
57
  })
56
58
 
@@ -6,6 +6,7 @@ import { Command } from 'commander'
6
6
 
7
7
  import { collectBrowserProfileOption } from '@/shared/chromium'
8
8
  import { handleError } from '@/shared/utils/error-handler'
9
+ import { isInteractive } from '@/shared/utils/interactive'
9
10
  import { formatOutput } from '@/shared/utils/output'
10
11
  import { info, warn, error as stderrError, debug } from '@/shared/utils/stderr'
11
12
 
@@ -14,10 +15,6 @@ import { InstagramCredentialManager } from '../credential-manager'
14
15
  import { InstagramTokenExtractor } from '../token-extractor'
15
16
  import { createAccountId } from '../types'
16
17
 
17
- function isInteractive(): boolean {
18
- return Boolean(process.stdin.isTTY && process.stdout.isTTY)
19
- }
20
-
21
18
  async function promptText(message: string): Promise<string | undefined> {
22
19
  const { createInterface } = await import('node:readline/promises')
23
20
  const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true })
@@ -3,6 +3,9 @@ import { afterEach, beforeEach, describe, expect, mock, spyOn, it } from 'bun:te
3
3
  const originalConsoleLog = console.log
4
4
  import type { Command } from 'commander'
5
5
 
6
+ import { InstagramClient } from '../client'
7
+ import * as sharedModule from './shared'
8
+
6
9
  const mockListChats = mock(() =>
7
10
  Promise.resolve([
8
11
  { id: 'thread-1', title: 'Alice', last_message: 'Hi' },
@@ -17,12 +20,6 @@ const mockClient = {
17
20
  searchChats: mockSearchChats,
18
21
  }
19
22
 
20
- mock.module('./shared', () => ({
21
- withInstagramClient: async (_options: unknown, fn: (client: typeof mockClient) => Promise<unknown>) => {
22
- return fn(mockClient)
23
- },
24
- }))
25
-
26
23
  import { chatCommand } from './chat'
27
24
 
28
25
  function resetCommandState(cmd: Command): void {
@@ -39,6 +36,7 @@ function resetCommandState(cmd: Command): void {
39
36
  describe('chat commands', () => {
40
37
  let consoleLogSpy: ReturnType<typeof mock>
41
38
  let processExitSpy: ReturnType<typeof spyOn>
39
+ let withInstagramClientSpy: ReturnType<typeof spyOn>
42
40
 
43
41
  beforeEach(() => {
44
42
  resetCommandState(chatCommand)
@@ -56,6 +54,9 @@ describe('chat commands', () => {
56
54
 
57
55
  consoleLogSpy = mock((..._args: unknown[]) => {})
58
56
  console.log = consoleLogSpy
57
+ withInstagramClientSpy = spyOn(sharedModule, 'withInstagramClient').mockImplementation(async (_options, fn) => {
58
+ return fn(Object.assign(Object.create(InstagramClient.prototype), mockClient) as InstagramClient)
59
+ })
59
60
  processExitSpy = spyOn(process, 'exit').mockImplementation(() => {
60
61
  throw new Error('process.exit called')
61
62
  })
@@ -63,6 +64,7 @@ describe('chat commands', () => {
63
64
 
64
65
  afterEach(() => {
65
66
  console.log = originalConsoleLog
67
+ withInstagramClientSpy.mockRestore()
66
68
  processExitSpy.mockRestore()
67
69
  })
68
70
 
@@ -3,6 +3,9 @@ import { afterEach, beforeEach, describe, expect, mock, spyOn, it } from 'bun:te
3
3
  const originalConsoleLog = console.log
4
4
  import type { Command } from 'commander'
5
5
 
6
+ import { InstagramClient } from '../client'
7
+ import * as sharedModule from './shared'
8
+
6
9
  const mockGetMessages = mock(() => Promise.resolve([{ id: 'msg-1', text: 'Hello' }]))
7
10
  const mockSendMessage = mock(() => Promise.resolve({ id: 'msg-2', text: 'Sent' }))
8
11
  const mockSendMessageToUser = mock(() => Promise.resolve({ id: 'msg-3', text: 'Sent to user' }))
@@ -17,12 +20,6 @@ const mockClient = {
17
20
  searchUsers: mockSearchUsers,
18
21
  }
19
22
 
20
- mock.module('./shared', () => ({
21
- withInstagramClient: async (_options: unknown, fn: (client: typeof mockClient) => Promise<unknown>) => {
22
- return fn(mockClient)
23
- },
24
- }))
25
-
26
23
  import { messageCommand } from './message'
27
24
 
28
25
  function resetCommandState(cmd: Command): void {
@@ -39,6 +36,7 @@ function resetCommandState(cmd: Command): void {
39
36
  describe('message commands', () => {
40
37
  let consoleLogSpy: ReturnType<typeof mock>
41
38
  let processExitSpy: ReturnType<typeof spyOn>
39
+ let withInstagramClientSpy: ReturnType<typeof spyOn>
42
40
 
43
41
  beforeEach(() => {
44
42
  resetCommandState(messageCommand)
@@ -57,6 +55,9 @@ describe('message commands', () => {
57
55
 
58
56
  consoleLogSpy = mock((..._args: unknown[]) => {})
59
57
  console.log = consoleLogSpy
58
+ withInstagramClientSpy = spyOn(sharedModule, 'withInstagramClient').mockImplementation(async (_options, fn) => {
59
+ return fn(Object.assign(Object.create(InstagramClient.prototype), mockClient) as InstagramClient)
60
+ })
60
61
  processExitSpy = spyOn(process, 'exit').mockImplementation(() => {
61
62
  throw new Error('process.exit called')
62
63
  })
@@ -64,6 +65,7 @@ describe('message commands', () => {
64
65
 
65
66
  afterEach(() => {
66
67
  console.log = originalConsoleLog
68
+ withInstagramClientSpy.mockRestore()
67
69
  processExitSpy.mockRestore()
68
70
  })
69
71
 
@@ -13,6 +13,7 @@ const mockGetAllMembers = mock(() => Promise.resolve({}))
13
13
  const mockGetMembersByIds = mock(() => Promise.resolve({}))
14
14
  const mockSyncMessages = mock(() => Promise.resolve({}))
15
15
  const mockSendMessage = mock(() => Promise.resolve({}))
16
+ const mockSendReply = mock(() => Promise.resolve({}))
16
17
  const mockMarkRead = mock(() => Promise.resolve({}))
17
18
  const mockClose = mock(() => {})
18
19
  const mockOnClose = mock((_handler: () => void) => {})
@@ -30,6 +31,7 @@ mock.module('./protocol/session', () => ({
30
31
  getMembersByIds = mockGetMembersByIds
31
32
  syncMessages = mockSyncMessages
32
33
  sendMessage = mockSendMessage
34
+ sendReply = mockSendReply
33
35
  markRead = mockMarkRead
34
36
  close = mockClose
35
37
  onClose = mockOnClose
@@ -52,6 +54,7 @@ function resetAllMocks() {
52
54
  mockGetMembersByIds.mockReset()
53
55
  mockSyncMessages.mockReset()
54
56
  mockSendMessage.mockReset()
57
+ mockSendReply.mockReset()
55
58
  mockMarkRead.mockReset()
56
59
  mockClose.mockReset()
57
60
  mockOnClose.mockReset()
@@ -972,6 +975,60 @@ describe('KakaoTalkClient', () => {
972
975
 
973
976
  client.close()
974
977
  })
978
+
979
+ it('routes to sendReply with a built reply extra when replyTo is given', async () => {
980
+ // given
981
+ mockSendReply.mockResolvedValueOnce({ statusCode: 0, body: { logId: makeLong(50), sendAt: 1700000100 } })
982
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
983
+
984
+ // when
985
+ const result = await client.sendMessage('100', 'replying', {
986
+ replyTo: { log_id: '42', author_id: 7, message: 'original', type: 1 },
987
+ })
988
+
989
+ // then
990
+ expect(mockSendMessage).not.toHaveBeenCalled()
991
+ expect(mockSendReply).toHaveBeenCalledTimes(1)
992
+ const [, text, extra] = mockSendReply.mock.calls[0] as [unknown, string, Record<string, unknown>]
993
+ expect(text).toBe('replying')
994
+ expect(extra).toEqual({
995
+ attach_only: false,
996
+ attach_type: 1,
997
+ src_logId: '42',
998
+ src_userId: 7,
999
+ src_message: 'original',
1000
+ src_type: 1,
1001
+ src_mentions: [],
1002
+ mentions: [],
1003
+ })
1004
+ expect(result).toEqual({
1005
+ success: true,
1006
+ status_code: 0,
1007
+ chat_id: '100',
1008
+ log_id: '50',
1009
+ sent_at: 1700000100,
1010
+ })
1011
+
1012
+ client.close()
1013
+ })
1014
+
1015
+ it('mirrors the source message type into attach_type/src_type', async () => {
1016
+ // given
1017
+ mockSendReply.mockResolvedValueOnce({ statusCode: 0, body: { logId: makeLong(51), sendAt: 1700000101 } })
1018
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1019
+
1020
+ // when — replying to a photo (type 2)
1021
+ await client.sendMessage('100', 'nice pic', {
1022
+ replyTo: { log_id: '99', author_id: 3, message: 'photo', type: 2 },
1023
+ })
1024
+
1025
+ // then
1026
+ const [, , extra] = mockSendReply.mock.calls[0] as [unknown, string, Record<string, unknown>]
1027
+ expect(extra.attach_type).toBe(2)
1028
+ expect(extra.src_type).toBe(2)
1029
+
1030
+ client.close()
1031
+ })
975
1032
  })
976
1033
 
977
1034
  describe('markRead', () => {
@@ -23,6 +23,8 @@ import {
23
23
  type KakaoMessage,
24
24
  type KakaoMultiPhotoExtra,
25
25
  type KakaoProfile,
26
+ type KakaoReplyExtra,
27
+ type KakaoReplyTarget,
26
28
  type KakaoSendResult,
27
29
  } from './types'
28
30
 
@@ -445,6 +447,19 @@ function formatMessages(
445
447
  }))
446
448
  }
447
449
 
450
+ function buildReplyExtra(target: KakaoReplyTarget): KakaoReplyExtra {
451
+ return {
452
+ attach_only: false,
453
+ attach_type: target.type,
454
+ src_logId: target.log_id,
455
+ src_userId: target.author_id,
456
+ src_message: target.message,
457
+ src_type: target.type,
458
+ src_mentions: [],
459
+ mentions: [],
460
+ }
461
+ }
462
+
448
463
  export class KakaoTalkClient {
449
464
  private oauthToken: string | null = null
450
465
  private userId: string | null = null
@@ -883,10 +898,16 @@ export class KakaoTalkClient {
883
898
  })
884
899
  }
885
900
 
886
- async sendMessage(chatId: string, text: string): Promise<KakaoSendResult> {
901
+ async sendMessage(chatId: string, text: string, options?: { replyTo?: KakaoReplyTarget }): Promise<KakaoSendResult> {
887
902
  return this.executeWithReconnect(async ({ session }) => {
888
903
  try {
889
- const response = await session.sendMessage(parseLong(chatId), text)
904
+ const response = options?.replyTo
905
+ ? await session.sendReply(
906
+ parseLong(chatId),
907
+ text,
908
+ buildReplyExtra(options.replyTo) as unknown as Record<string, unknown>,
909
+ )
910
+ : await session.sendMessage(parseLong(chatId), text)
890
911
 
891
912
  return {
892
913
  success: response.statusCode === 0,