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.
- package/.claude-plugin/plugin.json +1 -1
- package/bunfig.toml +1 -0
- package/dist/package.json +1 -1
- package/dist/src/platforms/discordbot/client.d.ts +1 -0
- package/dist/src/platforms/discordbot/client.d.ts.map +1 -1
- package/dist/src/platforms/discordbot/client.js +3 -0
- package/dist/src/platforms/discordbot/client.js.map +1 -1
- package/dist/src/platforms/discordbot/commands/message.d.ts +1 -0
- package/dist/src/platforms/discordbot/commands/message.d.ts.map +1 -1
- package/dist/src/platforms/discordbot/commands/message.js +2 -0
- package/dist/src/platforms/discordbot/commands/message.js.map +1 -1
- package/dist/src/platforms/kakaotalk/client.d.ts +4 -2
- package/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/client.js +67 -3
- package/dist/src/platforms/kakaotalk/client.js.map +1 -1
- package/dist/src/platforms/kakaotalk/commands/message.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/commands/message.js +23 -1
- package/dist/src/platforms/kakaotalk/commands/message.js.map +1 -1
- package/dist/src/platforms/kakaotalk/index.d.ts +1 -1
- package/dist/src/platforms/kakaotalk/index.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/index.js.map +1 -1
- package/dist/src/platforms/kakaotalk/protocol/session.d.ts +2 -1
- package/dist/src/platforms/kakaotalk/protocol/session.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/protocol/session.js +15 -0
- package/dist/src/platforms/kakaotalk/protocol/session.js.map +1 -1
- package/dist/src/platforms/kakaotalk/types.d.ts +18 -0
- package/dist/src/platforms/kakaotalk/types.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/types.js +1 -0
- package/dist/src/platforms/kakaotalk/types.js.map +1 -1
- package/dist/src/shared/chromium/browsers.js +1 -1
- package/dist/src/shared/chromium/browsers.js.map +1 -1
- package/docs/content/docs/cli/discordbot.mdx +6 -0
- package/docs/content/docs/sdk/discordbot.mdx +4 -0
- package/package.json +1 -1
- package/skills/agent-channeltalk/SKILL.md +1 -1
- package/skills/agent-channeltalkbot/SKILL.md +1 -1
- package/skills/agent-discord/SKILL.md +1 -1
- package/skills/agent-discordbot/SKILL.md +9 -1
- package/skills/agent-instagram/SKILL.md +1 -1
- package/skills/agent-kakaotalk/SKILL.md +24 -1
- package/skills/agent-line/SKILL.md +1 -1
- package/skills/agent-slack/SKILL.md +1 -1
- package/skills/agent-slackbot/SKILL.md +1 -1
- package/skills/agent-teams/SKILL.md +1 -1
- package/skills/agent-telegram/SKILL.md +1 -1
- package/skills/agent-telegrambot/SKILL.md +1 -1
- package/skills/agent-webex/SKILL.md +1 -1
- package/skills/agent-wechatbot/SKILL.md +1 -1
- package/skills/agent-whatsapp/SKILL.md +1 -1
- package/skills/agent-whatsappbot/SKILL.md +1 -1
- package/src/platforms/discord/commands/dm.test.ts +28 -20
- package/src/platforms/discord/commands/reaction.test.ts +12 -7
- package/src/platforms/discordbot/client.test.ts +17 -0
- package/src/platforms/discordbot/client.ts +9 -2
- package/src/platforms/discordbot/commands/message.test.ts +28 -11
- package/src/platforms/discordbot/commands/message.ts +4 -2
- package/src/platforms/instagram/commands/auth.test.ts +11 -9
- package/src/platforms/instagram/commands/chat.test.ts +8 -6
- package/src/platforms/instagram/commands/message.test.ts +8 -6
- package/src/platforms/kakaotalk/client.test.ts +96 -0
- package/src/platforms/kakaotalk/client.ts +82 -3
- package/src/platforms/kakaotalk/commands/message.test.ts +42 -0
- package/src/platforms/kakaotalk/commands/message.ts +33 -2
- package/src/platforms/kakaotalk/index.ts +2 -0
- package/src/platforms/kakaotalk/protocol/session.ts +15 -1
- package/src/platforms/kakaotalk/types.ts +24 -0
- package/src/platforms/webex/commands/auth.test.ts +30 -14
- package/src/platforms/webex/commands/member.test.ts +30 -34
- package/src/platforms/webex/commands/message.test.ts +37 -48
- package/src/platforms/webex/commands/snapshot.test.ts +26 -36
- package/src/platforms/webex/commands/space.test.ts +32 -38
- package/src/platforms/webex/commands/whoami.test.ts +10 -22
- package/src/platforms/webex/credential-manager.test.ts +3 -0
- package/src/platforms/whatsapp/commands/auth.test.ts +14 -20
- package/src/platforms/whatsapp/commands/chat.test.ts +17 -24
- package/src/platforms/whatsapp/commands/message.test.ts +31 -41
- package/src/shared/chromium/browsers.ts +1 -1
- package/src/test-setup.ts +5 -0
- 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
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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(
|
|
253
|
-
|
|
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(
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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', {
|
|
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
|
-
|
|
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 =
|
|
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', () => {
|