agent-messenger 2.18.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 (75) 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/kakaotalk/client.d.ts +4 -2
  13. package/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
  14. package/dist/src/platforms/kakaotalk/client.js +16 -2
  15. package/dist/src/platforms/kakaotalk/client.js.map +1 -1
  16. package/dist/src/platforms/kakaotalk/commands/message.d.ts.map +1 -1
  17. package/dist/src/platforms/kakaotalk/commands/message.js +23 -1
  18. package/dist/src/platforms/kakaotalk/commands/message.js.map +1 -1
  19. package/dist/src/platforms/kakaotalk/index.d.ts +1 -1
  20. package/dist/src/platforms/kakaotalk/index.d.ts.map +1 -1
  21. package/dist/src/platforms/kakaotalk/index.js.map +1 -1
  22. package/dist/src/platforms/kakaotalk/protocol/session.d.ts +2 -1
  23. package/dist/src/platforms/kakaotalk/protocol/session.d.ts.map +1 -1
  24. package/dist/src/platforms/kakaotalk/protocol/session.js +15 -0
  25. package/dist/src/platforms/kakaotalk/protocol/session.js.map +1 -1
  26. package/dist/src/platforms/kakaotalk/types.d.ts +18 -0
  27. package/dist/src/platforms/kakaotalk/types.d.ts.map +1 -1
  28. package/dist/src/platforms/kakaotalk/types.js +1 -0
  29. package/dist/src/platforms/kakaotalk/types.js.map +1 -1
  30. package/dist/src/shared/chromium/browsers.js +1 -1
  31. package/dist/src/shared/chromium/browsers.js.map +1 -1
  32. package/docs/content/docs/cli/discordbot.mdx +6 -0
  33. package/docs/content/docs/sdk/discordbot.mdx +4 -0
  34. package/package.json +1 -1
  35. package/skills/agent-channeltalk/SKILL.md +1 -1
  36. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  37. package/skills/agent-discord/SKILL.md +1 -1
  38. package/skills/agent-discordbot/SKILL.md +9 -1
  39. package/skills/agent-instagram/SKILL.md +1 -1
  40. package/skills/agent-kakaotalk/SKILL.md +24 -1
  41. package/skills/agent-line/SKILL.md +1 -1
  42. package/skills/agent-slack/SKILL.md +1 -1
  43. package/skills/agent-slackbot/SKILL.md +1 -1
  44. package/skills/agent-teams/SKILL.md +1 -1
  45. package/skills/agent-telegram/SKILL.md +1 -1
  46. package/skills/agent-telegrambot/SKILL.md +1 -1
  47. package/skills/agent-webex/SKILL.md +1 -1
  48. package/skills/agent-wechatbot/SKILL.md +1 -1
  49. package/skills/agent-whatsapp/SKILL.md +1 -1
  50. package/skills/agent-whatsappbot/SKILL.md +1 -1
  51. package/src/platforms/discord/commands/dm.test.ts +28 -20
  52. package/src/platforms/discord/commands/reaction.test.ts +12 -7
  53. package/src/platforms/discordbot/client.test.ts +17 -0
  54. package/src/platforms/discordbot/client.ts +9 -2
  55. package/src/platforms/discordbot/commands/message.test.ts +28 -11
  56. package/src/platforms/discordbot/commands/message.ts +4 -2
  57. package/src/platforms/instagram/commands/auth.test.ts +11 -9
  58. package/src/platforms/instagram/commands/chat.test.ts +8 -6
  59. package/src/platforms/instagram/commands/message.test.ts +8 -6
  60. package/src/platforms/kakaotalk/client.test.ts +57 -0
  61. package/src/platforms/kakaotalk/client.ts +23 -2
  62. package/src/platforms/kakaotalk/commands/message.test.ts +42 -0
  63. package/src/platforms/kakaotalk/commands/message.ts +33 -2
  64. package/src/platforms/kakaotalk/index.ts +2 -0
  65. package/src/platforms/kakaotalk/protocol/session.ts +15 -1
  66. package/src/platforms/kakaotalk/types.ts +24 -0
  67. package/src/platforms/webex/commands/auth.test.ts +24 -14
  68. package/src/platforms/webex/commands/member.test.ts +14 -20
  69. package/src/platforms/webex/commands/message.test.ts +11 -20
  70. package/src/platforms/webex/commands/snapshot.test.ts +11 -20
  71. package/src/platforms/webex/commands/space.test.ts +15 -23
  72. package/src/platforms/webex/commands/whoami.test.ts +8 -22
  73. package/src/shared/chromium/browsers.ts +1 -1
  74. package/src/test-setup.ts +5 -0
  75. package/tsconfig.json +1 -1
@@ -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
 
@@ -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,
@@ -112,6 +112,48 @@ describe('message commands', () => {
112
112
  expect.any(Function),
113
113
  )
114
114
  })
115
+
116
+ it('resolves --reply-to from chat history and sends a quoted reply', async () => {
117
+ // given
118
+ mockGetMessages.mockImplementation(() =>
119
+ Promise.resolve([
120
+ { log_id: '10', type: 1, author_id: 5, author_name: null, message: 'earlier', attachment: null, sent_at: 1 },
121
+ { log_id: '42', type: 2, author_id: 7, author_name: null, message: 'target', attachment: null, sent_at: 2 },
122
+ ]),
123
+ )
124
+
125
+ // when
126
+ await messageCommand.parseAsync(['send', 'chat-123', 'replying', '--reply-to', '42'], { from: 'user' })
127
+
128
+ // then
129
+ expect(mockGetMessages).toHaveBeenCalledWith('chat-123', { count: 100 })
130
+ expect(mockSendMessage).toHaveBeenCalledWith('chat-123', 'replying', {
131
+ replyTo: { log_id: '42', author_id: 7, message: 'target', type: 2 },
132
+ })
133
+ })
134
+
135
+ it('errors when --reply-to log-id is not found in recent history', async () => {
136
+ // given
137
+ mockGetMessages.mockImplementation(() =>
138
+ Promise.resolve([
139
+ { log_id: '10', type: 1, author_id: 5, author_name: null, message: 'earlier', attachment: null, sent_at: 1 },
140
+ ]),
141
+ )
142
+ const exitSpy = mock((_code?: number): never => {
143
+ throw new Error('process.exit called')
144
+ })
145
+ process.exit = exitSpy as unknown as typeof process.exit
146
+
147
+ // when / then
148
+ try {
149
+ await messageCommand.parseAsync(['send', 'chat-123', 'replying', '--reply-to', '999'], { from: 'user' })
150
+ } catch {
151
+ // process.exit stub throws to abort the action
152
+ }
153
+
154
+ expect(mockSendMessage).not.toHaveBeenCalled()
155
+ expect(exitSpy).toHaveBeenCalled()
156
+ })
115
157
  })
116
158
 
117
159
  describe('mark-read', () => {
@@ -6,6 +6,7 @@ import { Command } from 'commander'
6
6
  import { handleError } from '@/shared/utils/error-handler'
7
7
  import { formatOutput } from '@/shared/utils/output'
8
8
 
9
+ import type { KakaoMessage, KakaoReplyTarget } from '../types'
9
10
  import { withKakaoClient } from './shared'
10
11
 
11
12
  async function listAction(
@@ -26,16 +27,45 @@ async function listAction(
26
27
  async function sendAction(
27
28
  chatId: string,
28
29
  text: string,
29
- options: { account?: string; pretty?: boolean },
30
+ options: { account?: string; pretty?: boolean; replyTo?: string },
30
31
  ): Promise<void> {
31
32
  try {
32
- const result = await withKakaoClient(options, (client) => client.sendMessage(chatId, text))
33
+ const result = await withKakaoClient(options, async (client) => {
34
+ if (options.replyTo === undefined) {
35
+ return client.sendMessage(chatId, text)
36
+ }
37
+
38
+ const target = await resolveReplyTarget(client, chatId, options.replyTo)
39
+ return client.sendMessage(chatId, text, { replyTo: target })
40
+ })
33
41
  console.log(formatOutput(result, options.pretty))
34
42
  } catch (error) {
35
43
  handleError(error as Error)
36
44
  }
37
45
  }
38
46
 
47
+ // A reply attachment needs the source message's author, text, and type — not
48
+ // just its log_id — so we look it up in the chat's recent history.
49
+ async function resolveReplyTarget(
50
+ client: {
51
+ getMessages: (chatId: string, opts: { count: number }) => Promise<KakaoMessage[]>
52
+ },
53
+ chatId: string,
54
+ logId: string,
55
+ ): Promise<KakaoReplyTarget> {
56
+ const messages = await client.getMessages(chatId, { count: 100 })
57
+ const source = messages.find((m) => m.log_id === logId)
58
+ if (!source) {
59
+ throw new Error(`Reply target log-id ${logId} not found in the latest 100 messages of chat ${chatId}`)
60
+ }
61
+ return {
62
+ log_id: source.log_id,
63
+ author_id: source.author_id,
64
+ message: source.message,
65
+ type: source.type,
66
+ }
67
+ }
68
+
39
69
  type UploadKind = 'auto' | 'photo' | 'video' | 'audio' | 'file' | 'multi'
40
70
 
41
71
  async function uploadAction(
@@ -122,6 +152,7 @@ export const messageCommand = new Command('message')
122
152
  .argument('<chat-id>', 'Chat room ID')
123
153
  .argument('<text>', 'Message text')
124
154
  .option('--account <id>', 'Use a specific KakaoTalk account')
155
+ .option('--reply-to <log-id>', 'Send as a quoted reply to this message log ID')
125
156
  .option('--pretty', 'Pretty print JSON output')
126
157
  .action(sendAction),
127
158
  )
@@ -20,6 +20,8 @@ export type {
20
20
  KakaoMultiPhotoExtra,
21
21
  KakaoPhotoExtra,
22
22
  KakaoProfile,
23
+ KakaoReplyExtra,
24
+ KakaoReplyTarget,
23
25
  KakaoSendResult,
24
26
  KakaoTalkListenerEventMap,
25
27
  KakaoTalkPushEmoticonEvent,
@@ -1,6 +1,6 @@
1
1
  import { Binary, Long } from 'bson'
2
2
 
3
- import type { KakaoDeviceType } from '../types'
3
+ import { KAKAO_MESSAGE_TYPE, type KakaoDeviceType } from '../types'
4
4
  import {
5
5
  BOOKING_HOST,
6
6
  BOOKING_PORT,
@@ -126,6 +126,20 @@ export class LocoSession {
126
126
  })
127
127
  }
128
128
 
129
+ // Quoted reply — a WRITE with message_type 26 (REPLY) whose `extra` JSON
130
+ // carries the source-message reference. The reply semantics ride entirely on
131
+ // `type` + `extra`; no extra top-level WRITE fields are needed.
132
+ async sendReply(chatId: Long, text: string, extra: Record<string, unknown>): Promise<LocoPacket> {
133
+ if (!this.connection) throw new Error('Not connected')
134
+ return this.connection.sendPacket('WRITE', {
135
+ chatId,
136
+ msg: text,
137
+ type: KAKAO_MESSAGE_TYPE.REPLY,
138
+ noSeen: false,
139
+ extra: JSON.stringify(extra),
140
+ })
141
+ }
142
+
129
143
  // Sends a WRITE with non-text message_type plus the JSON-stringified `extra`
130
144
  // payload that KakaoTalk clients render as the attachment (photo, file, etc).
131
145
  // See types.ts → KakaoPhotoExtra / KakaoFileExtra for the per-type shape.
@@ -126,6 +126,7 @@ export const KAKAO_MESSAGE_TYPE = {
126
126
  VIDEO: 3,
127
127
  AUDIO: 5,
128
128
  FILE: 18,
129
+ REPLY: 26,
129
130
  MULTIPHOTO: 27,
130
131
  } as const
131
132
 
@@ -176,6 +177,29 @@ export interface KakaoMultiPhotoExtra {
176
177
  expire?: number
177
178
  }
178
179
 
180
+ // REPLY (type 26) extra. Field names verified against storycraft/node-kakao
181
+ // (src/chat/attachment/reply.ts) and jhleekr/kakao.py: `Id` fields are
182
+ // camelCase (src_logId, NOT src_log_id), and src_mentions/mentions are
183
+ // required even when empty.
184
+ export interface KakaoReplyExtra {
185
+ attach_only: boolean
186
+ attach_type: number
187
+ src_logId: string
188
+ src_userId: number
189
+ src_message: string
190
+ src_type: number
191
+ src_mentions: unknown[]
192
+ mentions: unknown[]
193
+ src_linkId?: string
194
+ }
195
+
196
+ export interface KakaoReplyTarget {
197
+ log_id: string
198
+ author_id: number
199
+ message: string
200
+ type: number
201
+ }
202
+
179
203
  export interface KakaoMarkReadResult {
180
204
  success: boolean
181
205
  status_code: number
@@ -7,6 +7,12 @@ import { WebexTokenExtractor } from '../token-extractor'
7
7
  import { WebexError } from '../types'
8
8
  import { extractAction, loginAction, logoutAction, statusAction } from './auth'
9
9
 
10
+ class ProcessExit extends Error {
11
+ constructor(readonly code?: string | number | null) {
12
+ super(`process.exit(${code})`)
13
+ }
14
+ }
15
+
10
16
  describe('auth commands', () => {
11
17
  let consoleSpy: ReturnType<typeof spyOn>
12
18
  let consoleErrorSpy: ReturnType<typeof spyOn>
@@ -441,15 +447,17 @@ describe('auth commands', () => {
441
447
  protoSpy(WebexCredentialManager.prototype, 'refreshToken').mockResolvedValue(null)
442
448
  protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
443
449
  protoSpy(WebexClient.prototype, 'testAuth').mockRejectedValue(new Error('Network error'))
444
- protoSpy(process, 'exit').mockImplementation(() => undefined as never)
450
+ const stderrWriteSpy = protoSpy(process.stderr, 'write').mockImplementation(() => true)
451
+ const exitSpy = protoSpy(process, 'exit').mockImplementation((code?: string | number | null) => {
452
+ throw new ProcessExit(code)
453
+ })
445
454
 
446
- await extractAction({ pretty: false })
455
+ await expect(extractAction({ pretty: false })).rejects.toThrow(ProcessExit)
447
456
 
448
- const lastCall = consoleErrorSpy.mock.calls[consoleErrorSpy.mock.calls.length - 1]?.[0] as string | undefined
449
- if (lastCall) {
450
- const output = JSON.parse(lastCall)
451
- expect(output.error).toContain('Network error')
452
- }
457
+ const lastCall = stderrWriteSpy.mock.calls[stderrWriteSpy.mock.calls.length - 1][0] as string
458
+ const output = JSON.parse(lastCall)
459
+ expect(output.error).toContain('Network error')
460
+ expect(exitSpy).toHaveBeenCalledWith(1)
453
461
  })
454
462
 
455
463
  it('rethrows non-expiry auth errors', async () => {
@@ -459,15 +467,17 @@ describe('auth commands', () => {
459
467
  })
460
468
  protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
461
469
  protoSpy(WebexClient.prototype, 'testAuth').mockRejectedValue(new Error('Network error'))
462
- protoSpy(process, 'exit').mockImplementation(() => undefined as never)
470
+ const stderrWriteSpy = protoSpy(process.stderr, 'write').mockImplementation(() => true)
471
+ const exitSpy = protoSpy(process, 'exit').mockImplementation((code?: string | number | null) => {
472
+ throw new ProcessExit(code)
473
+ })
463
474
 
464
- await extractAction({ pretty: false })
475
+ await expect(extractAction({ pretty: false })).rejects.toThrow(ProcessExit)
465
476
 
466
- const lastCall = consoleErrorSpy.mock.calls[consoleErrorSpy.mock.calls.length - 1]?.[0] as string | undefined
467
- if (lastCall) {
468
- const output = JSON.parse(lastCall)
469
- expect(output.error).toContain('Network error')
470
- }
477
+ const lastCall = stderrWriteSpy.mock.calls[stderrWriteSpy.mock.calls.length - 1][0] as string
478
+ const output = JSON.parse(lastCall)
479
+ expect(output.error).toContain('Network error')
480
+ expect(exitSpy).toHaveBeenCalledWith(1)
471
481
  })
472
482
 
473
483
  it('outputs no token found when extract returns null', async () => {