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.
- 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 +16 -2
- 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 +57 -0
- package/src/platforms/kakaotalk/client.ts +23 -2
- 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 +24 -14
- package/src/platforms/webex/commands/member.test.ts +14 -20
- package/src/platforms/webex/commands/message.test.ts +11 -20
- package/src/platforms/webex/commands/snapshot.test.ts +11 -20
- package/src/platforms/webex/commands/space.test.ts +15 -23
- package/src/platforms/webex/commands/whoami.test.ts +8 -22
- package/src/shared/chromium/browsers.ts +1 -1
- package/src/test-setup.ts +5 -0
- 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(
|
|
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', () => {
|
|
@@ -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 =
|
|
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) =>
|
|
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
|
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Binary, Long } from 'bson'
|
|
2
2
|
|
|
3
|
-
import type
|
|
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, '
|
|
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 =
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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, '
|
|
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 =
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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 () => {
|