agent-messenger 2.18.0 → 2.19.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 (79) 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 +67 -3
  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 +96 -0
  61. package/src/platforms/kakaotalk/client.ts +82 -3
  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 +30 -14
  68. package/src/platforms/webex/commands/member.test.ts +30 -34
  69. package/src/platforms/webex/commands/message.test.ts +37 -48
  70. package/src/platforms/webex/commands/snapshot.test.ts +26 -36
  71. package/src/platforms/webex/commands/space.test.ts +32 -38
  72. package/src/platforms/webex/commands/whoami.test.ts +10 -22
  73. package/src/platforms/webex/credential-manager.test.ts +3 -0
  74. package/src/platforms/whatsapp/commands/auth.test.ts +14 -20
  75. package/src/platforms/whatsapp/commands/chat.test.ts +17 -24
  76. package/src/platforms/whatsapp/commands/message.test.ts +31 -41
  77. package/src/shared/chromium/browsers.ts +1 -1
  78. package/src/test-setup.ts +5 -0
  79. package/tsconfig.json +1 -1
@@ -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
 
@@ -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', () => {
@@ -1208,6 +1265,45 @@ describe('KakaoTalkClient', () => {
1208
1265
  client.close()
1209
1266
  })
1210
1267
 
1268
+ it('merges CHATONROOM members when GETMEM returns a partial member list', async () => {
1269
+ mockGetAllMembers.mockResolvedValueOnce({
1270
+ statusCode: 0,
1271
+ body: {
1272
+ members: [
1273
+ {
1274
+ userId: makeLong(42),
1275
+ nickName: 'Alice',
1276
+ type: 100,
1277
+ profileImageUrl: 'https://kakao.com/p/alice.jpg',
1278
+ },
1279
+ ],
1280
+ token: 0,
1281
+ },
1282
+ })
1283
+ mockGetChatInfo.mockResolvedValueOnce({
1284
+ statusCode: 0,
1285
+ body: {
1286
+ status: 0,
1287
+ m: [
1288
+ { userId: makeLong(42), nickName: 'Alice From Room', type: 100 },
1289
+ { userId: makeLong(43), nickName: 'Bob', type: 100 },
1290
+ { userId: makeLong(44), nickName: 'Carol', type: 100 },
1291
+ ],
1292
+ },
1293
+ })
1294
+
1295
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1296
+ const members = await client.getMembers('100')
1297
+
1298
+ expect(members.map((member) => member.user_id)).toEqual(['42', '43', '44'])
1299
+ expect(members[0].nickname).toBe('Alice')
1300
+ expect(members[0].profile_image_url).toBe('https://kakao.com/p/alice.jpg')
1301
+ expect(members[1].nickname).toBe('Bob')
1302
+ expect(members[2].nickname).toBe('Carol')
1303
+
1304
+ client.close()
1305
+ })
1306
+
1211
1307
  it('returns empty array when GETMEM returns no members', async () => {
1212
1308
  mockGetAllMembers.mockResolvedValueOnce({ statusCode: 0, body: {} })
1213
1309
 
@@ -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
 
@@ -426,6 +428,54 @@ function formatMember(member: Record<string, unknown>): KakaoMember {
426
428
  }
427
429
  }
428
430
 
431
+ function memberIdKey(member: Record<string, unknown>): string | null {
432
+ if (member.userId === undefined || member.userId === null) return null
433
+ const key = longToString(member.userId)
434
+ return key === '0' ? null : key
435
+ }
436
+
437
+ function mergeMemberRecords(
438
+ primaryMembers: Array<Record<string, unknown>>,
439
+ fallbackMembers: Array<Record<string, unknown>>,
440
+ ): Array<Record<string, unknown>> {
441
+ const merged: Array<Record<string, unknown>> = []
442
+ const indexByUserId = new Map<string, number>()
443
+
444
+ const upsert = (member: Record<string, unknown>) => {
445
+ const userId = memberIdKey(member)
446
+ if (!userId) return
447
+
448
+ const existingIndex = indexByUserId.get(userId)
449
+ if (existingIndex === undefined) {
450
+ indexByUserId.set(userId, merged.length)
451
+ merged.push(member)
452
+ return
453
+ }
454
+
455
+ merged[existingIndex] = { ...member, ...merged[existingIndex] }
456
+ }
457
+
458
+ for (const member of primaryMembers) upsert(member)
459
+ for (const member of fallbackMembers) upsert(member)
460
+
461
+ return merged
462
+ }
463
+
464
+ function extractChatInfoMembers(body: unknown): Array<Record<string, unknown>> {
465
+ if (!body || typeof body !== 'object') return []
466
+
467
+ const record = body as Record<string, unknown>
468
+ if (Array.isArray(record.m)) {
469
+ return record.m as Array<Record<string, unknown>>
470
+ }
471
+
472
+ const chatInfo = record.chatInfo
473
+ if (!chatInfo || typeof chatInfo !== 'object') return []
474
+
475
+ const displayMembers = (chatInfo as Record<string, unknown>).displayMembers
476
+ return Array.isArray(displayMembers) ? (displayMembers as Array<Record<string, unknown>>) : []
477
+ }
478
+
429
479
  function formatMessages(
430
480
  logs: Array<Record<string, unknown>>,
431
481
  count: number,
@@ -445,6 +495,19 @@ function formatMessages(
445
495
  }))
446
496
  }
447
497
 
498
+ function buildReplyExtra(target: KakaoReplyTarget): KakaoReplyExtra {
499
+ return {
500
+ attach_only: false,
501
+ attach_type: target.type,
502
+ src_logId: target.log_id,
503
+ src_userId: target.author_id,
504
+ src_message: target.message,
505
+ src_type: target.type,
506
+ src_mentions: [],
507
+ mentions: [],
508
+ }
509
+ }
510
+
448
511
  export class KakaoTalkClient {
449
512
  private oauthToken: string | null = null
450
513
  private userId: string | null = null
@@ -860,7 +923,17 @@ export class KakaoTalkClient {
860
923
  const response = await session.getAllMembers(parsedChatId)
861
924
  assertLocoOk(response, 'GETMEM')
862
925
  const members = (response.body.members ?? []) as Array<Record<string, unknown>>
863
- return members.map(formatMember)
926
+ let fallbackMembers: Array<Record<string, unknown>> = []
927
+ try {
928
+ // Some KakaoTalk rooms return only a subset from GETMEM even though
929
+ // CHATONROOM carries the full active member list in `m`.
930
+ const chatInfo = await session.getChatInfo(parsedChatId)
931
+ fallbackMembers = extractChatInfoMembers(chatInfo.body)
932
+ } catch {
933
+ fallbackMembers = []
934
+ }
935
+
936
+ return mergeMemberRecords(members, fallbackMembers).map(formatMember)
864
937
  } catch (error) {
865
938
  throw wrapError(error, 'get_members_failed')
866
939
  }
@@ -883,10 +956,16 @@ export class KakaoTalkClient {
883
956
  })
884
957
  }
885
958
 
886
- async sendMessage(chatId: string, text: string): Promise<KakaoSendResult> {
959
+ async sendMessage(chatId: string, text: string, options?: { replyTo?: KakaoReplyTarget }): Promise<KakaoSendResult> {
887
960
  return this.executeWithReconnect(async ({ session }) => {
888
961
  try {
889
- const response = await session.sendMessage(parseLong(chatId), text)
962
+ const response = options?.replyTo
963
+ ? await session.sendReply(
964
+ parseLong(chatId),
965
+ text,
966
+ buildReplyExtra(options.replyTo) as unknown as Record<string, unknown>,
967
+ )
968
+ : await session.sendMessage(parseLong(chatId), text)
890
969
 
891
970
  return {
892
971
  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', () => {