agent-messenger 2.21.0 → 2.23.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/README.md +21 -0
- package/dist/package.json +1 -1
- package/dist/src/platforms/webex/client.d.ts +25 -0
- package/dist/src/platforms/webex/client.d.ts.map +1 -1
- package/dist/src/platforms/webex/client.js +115 -5
- package/dist/src/platforms/webex/client.js.map +1 -1
- package/dist/src/platforms/webex/commands/auth.d.ts +9 -1
- package/dist/src/platforms/webex/commands/auth.d.ts.map +1 -1
- package/dist/src/platforms/webex/commands/auth.js +141 -25
- package/dist/src/platforms/webex/commands/auth.js.map +1 -1
- package/dist/src/platforms/webex/credential-manager.d.ts.map +1 -1
- package/dist/src/platforms/webex/credential-manager.js +8 -4
- package/dist/src/platforms/webex/credential-manager.js.map +1 -1
- package/dist/src/platforms/webex/id-normalizer.d.ts +19 -0
- package/dist/src/platforms/webex/id-normalizer.d.ts.map +1 -0
- package/dist/src/platforms/webex/id-normalizer.js +60 -0
- package/dist/src/platforms/webex/id-normalizer.js.map +1 -0
- package/dist/src/platforms/webex/index.d.ts +4 -0
- package/dist/src/platforms/webex/index.d.ts.map +1 -1
- package/dist/src/platforms/webex/index.js +2 -0
- package/dist/src/platforms/webex/index.js.map +1 -1
- package/dist/src/platforms/webex/listener.d.ts +61 -0
- package/dist/src/platforms/webex/listener.d.ts.map +1 -0
- package/dist/src/platforms/webex/listener.js +222 -0
- package/dist/src/platforms/webex/listener.js.map +1 -0
- package/dist/src/platforms/webex/password-login.d.ts +18 -0
- package/dist/src/platforms/webex/password-login.d.ts.map +1 -0
- package/dist/src/platforms/webex/password-login.js +259 -0
- package/dist/src/platforms/webex/password-login.js.map +1 -0
- package/dist/src/platforms/webex/types.d.ts +2 -1
- package/dist/src/platforms/webex/types.d.ts.map +1 -1
- package/dist/src/platforms/webex/types.js +1 -1
- package/dist/src/platforms/webex/types.js.map +1 -1
- package/dist/src/platforms/webex/wdm-discovery.d.ts.map +1 -0
- package/dist/src/platforms/{webexbot → webex}/wdm-discovery.js +3 -3
- package/dist/src/platforms/webex/wdm-discovery.js.map +1 -0
- package/dist/src/platforms/webexbot/cli.d.ts.map +1 -1
- package/dist/src/platforms/webexbot/cli.js +4 -1
- package/dist/src/platforms/webexbot/cli.js.map +1 -1
- package/dist/src/platforms/webexbot/client.d.ts +24 -0
- package/dist/src/platforms/webexbot/client.d.ts.map +1 -1
- package/dist/src/platforms/webexbot/client.js +81 -5
- package/dist/src/platforms/webexbot/client.js.map +1 -1
- package/dist/src/platforms/webexbot/commands/file.d.ts +22 -0
- package/dist/src/platforms/webexbot/commands/file.d.ts.map +1 -0
- package/dist/src/platforms/webexbot/commands/file.js +64 -0
- package/dist/src/platforms/webexbot/commands/file.js.map +1 -0
- package/dist/src/platforms/webexbot/commands/index.d.ts +3 -0
- package/dist/src/platforms/webexbot/commands/index.d.ts.map +1 -1
- package/dist/src/platforms/webexbot/commands/index.js +3 -0
- package/dist/src/platforms/webexbot/commands/index.js.map +1 -1
- package/dist/src/platforms/webexbot/commands/message.d.ts +7 -0
- package/dist/src/platforms/webexbot/commands/message.d.ts.map +1 -1
- package/dist/src/platforms/webexbot/commands/message.js +52 -1
- package/dist/src/platforms/webexbot/commands/message.js.map +1 -1
- package/dist/src/platforms/webexbot/commands/snapshot.d.ts +24 -0
- package/dist/src/platforms/webexbot/commands/snapshot.d.ts.map +1 -0
- package/dist/src/platforms/webexbot/commands/snapshot.js +37 -0
- package/dist/src/platforms/webexbot/commands/snapshot.js.map +1 -0
- package/dist/src/platforms/webexbot/commands/user.d.ts +30 -0
- package/dist/src/platforms/webexbot/commands/user.d.ts.map +1 -0
- package/dist/src/platforms/webexbot/commands/user.js +66 -0
- package/dist/src/platforms/webexbot/commands/user.js.map +1 -0
- package/dist/src/platforms/webexbot/index.d.ts +2 -0
- package/dist/src/platforms/webexbot/index.d.ts.map +1 -1
- package/dist/src/platforms/webexbot/index.js +1 -0
- package/dist/src/platforms/webexbot/index.js.map +1 -1
- package/dist/src/platforms/webexbot/listener.d.ts +3 -41
- package/dist/src/platforms/webexbot/listener.d.ts.map +1 -1
- package/dist/src/platforms/webexbot/listener.js +13 -208
- package/dist/src/platforms/webexbot/listener.js.map +1 -1
- package/dist/src/platforms/webexbot/types.d.ts +1 -18
- package/dist/src/platforms/webexbot/types.d.ts.map +1 -1
- package/dist/src/platforms/webexbot/types.js.map +1 -1
- package/docs/content/docs/cli/webex.mdx +38 -12
- package/docs/content/docs/cli/webexbot.mdx +2 -0
- package/docs/content/docs/sdk/webexbot.mdx +18 -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 +1 -1
- package/skills/agent-instagram/SKILL.md +1 -1
- package/skills/agent-kakaotalk/SKILL.md +1 -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 +76 -22
- package/skills/agent-webex/references/authentication.md +55 -14
- package/skills/agent-webex/references/common-patterns.md +5 -2
- package/skills/agent-webexbot/SKILL.md +60 -5
- package/skills/agent-webexbot/references/common-patterns.md +118 -0
- 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/webex/cli.test.ts +31 -1
- package/src/platforms/webex/client.test.ts +67 -0
- package/src/platforms/webex/client.ts +136 -7
- package/src/platforms/webex/commands/auth.test.ts +189 -28
- package/src/platforms/webex/commands/auth.ts +194 -35
- package/src/platforms/webex/credential-manager.test.ts +40 -0
- package/src/platforms/webex/credential-manager.ts +7 -4
- package/src/platforms/webex/id-normalizer.test.ts +207 -0
- package/src/platforms/webex/id-normalizer.ts +76 -0
- package/src/platforms/webex/index.test.ts +6 -0
- package/src/platforms/webex/index.ts +4 -0
- package/src/platforms/webex/listener.test.ts +243 -0
- package/src/platforms/webex/listener.ts +285 -0
- package/src/platforms/webex/password-login.test.ts +193 -0
- package/src/platforms/webex/password-login.ts +332 -0
- package/src/platforms/webex/types.test.ts +16 -0
- package/src/platforms/webex/types.ts +2 -2
- package/src/platforms/{webexbot → webex}/wdm-discovery.ts +3 -3
- package/src/platforms/webexbot/cli.ts +6 -0
- package/src/platforms/webexbot/client.test.ts +322 -0
- package/src/platforms/webexbot/client.ts +104 -7
- package/src/platforms/webexbot/commands/file.ts +104 -0
- package/src/platforms/webexbot/commands/index.ts +3 -0
- package/src/platforms/webexbot/commands/message.ts +68 -2
- package/src/platforms/webexbot/commands/snapshot.ts +60 -0
- package/src/platforms/webexbot/commands/user.test.ts +77 -0
- package/src/platforms/webexbot/commands/user.ts +98 -0
- package/src/platforms/webexbot/index.ts +2 -0
- package/src/platforms/webexbot/listener.test.ts +37 -224
- package/src/platforms/webexbot/listener.ts +18 -250
- package/src/platforms/webexbot/types.ts +2 -23
- package/dist/src/platforms/webexbot/wdm-discovery.d.ts.map +0 -1
- package/dist/src/platforms/webexbot/wdm-discovery.js.map +0 -1
- /package/dist/src/platforms/{webexbot → webex}/wdm-discovery.d.ts +0 -0
- /package/src/platforms/{webexbot → webex}/wdm-discovery.test.ts +0 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { toRestId } from '../webex/id-normalizer'
|
|
4
|
+
import { WebexBotClient } from './client'
|
|
5
|
+
|
|
6
|
+
describe('WebexBotClient', () => {
|
|
7
|
+
const originalFetch = globalThis.fetch
|
|
8
|
+
let fetchCalls: Array<{ url: string; options?: RequestInit }> = []
|
|
9
|
+
let fetchResponses: Response[] = []
|
|
10
|
+
let fetchIndex = 0
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
fetchCalls = []
|
|
14
|
+
fetchResponses = []
|
|
15
|
+
fetchIndex = 0
|
|
16
|
+
;(globalThis as { fetch: unknown }).fetch = async (
|
|
17
|
+
url: string | URL | Request,
|
|
18
|
+
options?: RequestInit,
|
|
19
|
+
): Promise<Response> => {
|
|
20
|
+
fetchCalls.push({ url: url.toString(), options })
|
|
21
|
+
const response = fetchResponses[fetchIndex]
|
|
22
|
+
fetchIndex++
|
|
23
|
+
if (!response) {
|
|
24
|
+
throw new Error('No mock response configured')
|
|
25
|
+
}
|
|
26
|
+
return response
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
globalThis.fetch = originalFetch
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const mockResponse = (body: unknown, status = 200) => {
|
|
35
|
+
fetchResponses.push(
|
|
36
|
+
new Response(JSON.stringify(body), {
|
|
37
|
+
status,
|
|
38
|
+
headers: { 'Content-Type': 'application/json' },
|
|
39
|
+
}),
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('listMessages', () => {
|
|
44
|
+
it('limits group-space history to messages that mention the bot', async () => {
|
|
45
|
+
mockResponse({ id: 'group-room', title: 'Team', type: 'group' })
|
|
46
|
+
mockResponse({ items: [{ id: 'msg-1', roomId: 'group-room', roomType: 'group' }] })
|
|
47
|
+
|
|
48
|
+
const client = await new WebexBotClient().login({ token: 'bot-token' })
|
|
49
|
+
const messages = await client.listMessages('group-room', { max: 5 })
|
|
50
|
+
|
|
51
|
+
expect(messages).toHaveLength(1)
|
|
52
|
+
expect(fetchCalls[0].url).toBe('https://webexapis.com/v1/rooms/group-room')
|
|
53
|
+
|
|
54
|
+
const messagesUrl = new URL(fetchCalls[1].url)
|
|
55
|
+
expect(messagesUrl.origin + messagesUrl.pathname).toBe('https://webexapis.com/v1/messages')
|
|
56
|
+
expect(messagesUrl.searchParams.get('roomId')).toBe('group-room')
|
|
57
|
+
expect(messagesUrl.searchParams.get('max')).toBe('5')
|
|
58
|
+
expect(messagesUrl.searchParams.get('mentionedPeople')).toBe('me')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('does not add mentionedPeople for direct spaces', async () => {
|
|
62
|
+
mockResponse({ id: 'direct-room', title: 'DM', type: 'direct' })
|
|
63
|
+
mockResponse({ items: [{ id: 'msg-1', roomId: 'direct-room', roomType: 'direct' }] })
|
|
64
|
+
|
|
65
|
+
const client = await new WebexBotClient().login({ token: 'bot-token' })
|
|
66
|
+
await client.listMessages('direct-room', { max: 5 })
|
|
67
|
+
|
|
68
|
+
const messagesUrl = new URL(fetchCalls[1].url)
|
|
69
|
+
expect(messagesUrl.searchParams.get('roomId')).toBe('direct-room')
|
|
70
|
+
expect(messagesUrl.searchParams.get('mentionedPeople')).toBeNull()
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
describe('sendMessage threading', () => {
|
|
75
|
+
it('includes parentId in the request body when threading', async () => {
|
|
76
|
+
mockResponse({ id: 'msg-1', roomId: 'room-1', roomType: 'group' })
|
|
77
|
+
|
|
78
|
+
const client = await new WebexBotClient().login({ token: 'bot-token' })
|
|
79
|
+
await client.sendMessage('room-1', 'reply text', { parentId: 'parent-1' })
|
|
80
|
+
|
|
81
|
+
const body = JSON.parse(fetchCalls[0].options?.body as string)
|
|
82
|
+
expect(body.roomId).toBe('room-1')
|
|
83
|
+
expect(body.text).toBe('reply text')
|
|
84
|
+
expect(body.parentId).toBe('parent-1')
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('listReplies', () => {
|
|
89
|
+
it('queries messages filtered by parentId', async () => {
|
|
90
|
+
mockResponse({ items: [{ id: 'reply-1', roomId: 'room-1', roomType: 'group', parentId: 'parent-1' }] })
|
|
91
|
+
|
|
92
|
+
const client = await new WebexBotClient().login({ token: 'bot-token' })
|
|
93
|
+
const replies = await client.listReplies('room-1', 'parent-1', { max: 10 })
|
|
94
|
+
|
|
95
|
+
expect(replies).toHaveLength(1)
|
|
96
|
+
const url = new URL(fetchCalls[0].url)
|
|
97
|
+
expect(url.searchParams.get('roomId')).toBe('room-1')
|
|
98
|
+
expect(url.searchParams.get('parentId')).toBe('parent-1')
|
|
99
|
+
expect(url.searchParams.get('max')).toBe('10')
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe('getPerson', () => {
|
|
104
|
+
it('fetches a person by id', async () => {
|
|
105
|
+
mockResponse({ id: 'person-1', emails: ['a@b.com'], displayName: 'Alice', type: 'person' })
|
|
106
|
+
|
|
107
|
+
const client = await new WebexBotClient().login({ token: 'bot-token' })
|
|
108
|
+
const person = await client.getPerson('person-1')
|
|
109
|
+
|
|
110
|
+
expect(person.displayName).toBe('Alice')
|
|
111
|
+
expect(fetchCalls[0].url).toBe('https://webexapis.com/v1/people/person-1')
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
describe('uploadFile', () => {
|
|
116
|
+
it('posts multipart form data to messages', async () => {
|
|
117
|
+
mockResponse({
|
|
118
|
+
id: 'msg-1',
|
|
119
|
+
roomId: 'room-1',
|
|
120
|
+
roomType: 'group',
|
|
121
|
+
files: ['https://webexapis.com/v1/contents/c1'],
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
const client = await new WebexBotClient().login({ token: 'bot-token' })
|
|
125
|
+
const message = await client.uploadFile(
|
|
126
|
+
'room-1',
|
|
127
|
+
{ content: new Blob(['hello']), filename: 'note.txt' },
|
|
128
|
+
{ text: 'see attached' },
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
expect(message.files).toEqual(['https://webexapis.com/v1/contents/c1'])
|
|
132
|
+
expect(fetchCalls[0].url).toBe('https://webexapis.com/v1/messages')
|
|
133
|
+
expect(fetchCalls[0].options?.method).toBe('POST')
|
|
134
|
+
const body = fetchCalls[0].options?.body as FormData
|
|
135
|
+
expect(body).toBeInstanceOf(FormData)
|
|
136
|
+
expect(body.get('roomId')).toBe('room-1')
|
|
137
|
+
expect(body.get('text')).toBe('see attached')
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
describe('downloadContent', () => {
|
|
142
|
+
it('returns binary data with filename parsed from Content-Disposition', async () => {
|
|
143
|
+
fetchResponses.push(
|
|
144
|
+
new Response('binary-bytes', {
|
|
145
|
+
status: 200,
|
|
146
|
+
headers: {
|
|
147
|
+
'Content-Disposition': 'attachment; filename="report.pdf"',
|
|
148
|
+
'Content-Type': 'application/pdf',
|
|
149
|
+
},
|
|
150
|
+
}),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
const client = await new WebexBotClient().login({ token: 'bot-token' })
|
|
154
|
+
const result = await client.downloadContent('https://webexapis.com/v1/contents/c1')
|
|
155
|
+
|
|
156
|
+
expect(result.filename).toBe('report.pdf')
|
|
157
|
+
expect(result.contentType).toBe('application/pdf')
|
|
158
|
+
expect(fetchCalls[0].url).toBe('https://webexapis.com/v1/contents/c1')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('builds the contents URL from a bare content id', async () => {
|
|
162
|
+
fetchResponses.push(new Response('data', { status: 200, headers: {} }))
|
|
163
|
+
|
|
164
|
+
const client = await new WebexBotClient().login({ token: 'bot-token' })
|
|
165
|
+
const result = await client.downloadContent('abc123')
|
|
166
|
+
|
|
167
|
+
expect(fetchCalls[0].url).toBe('https://webexapis.com/v1/contents/abc123')
|
|
168
|
+
expect(result.filename).toBe('abc123')
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('refuses to download from a non-Webex host', async () => {
|
|
172
|
+
const client = await new WebexBotClient().login({ token: 'bot-token' })
|
|
173
|
+
|
|
174
|
+
await expect(client.downloadContent('https://attacker.example/file')).rejects.toThrow(/untrusted/i)
|
|
175
|
+
expect(fetchCalls).toHaveLength(0)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('refuses to download over plain http from the Webex host', async () => {
|
|
179
|
+
const client = await new WebexBotClient().login({ token: 'bot-token' })
|
|
180
|
+
|
|
181
|
+
await expect(client.downloadContent('http://webexapis.com/v1/contents/c1')).rejects.toThrow(/untrusted/i)
|
|
182
|
+
expect(fetchCalls).toHaveLength(0)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('sanitizes a path-traversal filename from Content-Disposition', async () => {
|
|
186
|
+
fetchResponses.push(
|
|
187
|
+
new Response('data', {
|
|
188
|
+
status: 200,
|
|
189
|
+
headers: { 'Content-Disposition': 'attachment; filename="../../etc/passwd"' },
|
|
190
|
+
}),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
const client = await new WebexBotClient().login({ token: 'bot-token' })
|
|
194
|
+
const result = await client.downloadContent('https://webexapis.com/v1/contents/c1')
|
|
195
|
+
|
|
196
|
+
expect(result.filename).toBe('passwd')
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
describe('room cluster resolution', () => {
|
|
201
|
+
const roomUuid = '12345678-1234-1234-1234-1234567890ab'
|
|
202
|
+
const usRoomId = toRestId(roomUuid, 'ROOM')
|
|
203
|
+
const clusteredRoomId = Buffer.from(`ciscospark://urn:TEAM:us-west-2_r/ROOM/${roomUuid}`).toString('base64url')
|
|
204
|
+
|
|
205
|
+
const isRoomsList = (url: string) => new URL(url).pathname === '/v1/rooms'
|
|
206
|
+
|
|
207
|
+
const clusteredId = (uuid: string) =>
|
|
208
|
+
Buffer.from(`ciscospark://urn:TEAM:us-west-2_r/ROOM/${uuid}`).toString('base64url')
|
|
209
|
+
|
|
210
|
+
const mockRoomsPage = (items: unknown[], nextCursor?: string) => {
|
|
211
|
+
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
|
212
|
+
if (nextCursor) headers.Link = `<https://webexapis.com/v1/rooms?max=1000&before=${nextCursor}>; rel="next"`
|
|
213
|
+
fetchResponses.push(new Response(JSON.stringify({ items }), { status: 200, headers }))
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
it('rewrites a us-cluster roomId to the real urn:TEAM id before sending', async () => {
|
|
217
|
+
// given a non-default-cluster room only reachable via its clustered id
|
|
218
|
+
mockResponse({ items: [{ id: clusteredRoomId, title: 'Team', type: 'group' }] })
|
|
219
|
+
mockResponse({ id: 'msg-1', roomId: clusteredRoomId, roomType: 'group' })
|
|
220
|
+
|
|
221
|
+
// when sending to the us-flattened id the listener emitted
|
|
222
|
+
const client = await new WebexBotClient().login({ token: 'bot-token' })
|
|
223
|
+
await client.sendMessage(usRoomId, 'hello')
|
|
224
|
+
|
|
225
|
+
// then the lookup runs and the send targets the corrected clustered id
|
|
226
|
+
expect(isRoomsList(fetchCalls[0].url)).toBe(true)
|
|
227
|
+
const body = JSON.parse(fetchCalls[1].options?.body as string)
|
|
228
|
+
expect(body.roomId).toBe(clusteredRoomId)
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('routes listMemberships through the corrected clustered id', async () => {
|
|
232
|
+
mockResponse({ items: [{ id: clusteredRoomId, type: 'group' }] })
|
|
233
|
+
mockResponse({ items: [{ id: 'm1', roomId: clusteredRoomId, personId: 'p1' }] })
|
|
234
|
+
|
|
235
|
+
const client = await new WebexBotClient().login({ token: 'bot-token' })
|
|
236
|
+
await client.listMemberships(usRoomId)
|
|
237
|
+
|
|
238
|
+
const membershipsUrl = new URL(fetchCalls[1].url)
|
|
239
|
+
expect(membershipsUrl.pathname).toBe('/v1/memberships')
|
|
240
|
+
expect(membershipsUrl.searchParams.get('roomId')).toBe(clusteredRoomId)
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('routes listMessages and its space lookup through the corrected clustered id', async () => {
|
|
244
|
+
mockResponse({ items: [{ id: clusteredRoomId, type: 'group' }] })
|
|
245
|
+
mockResponse({ id: clusteredRoomId, title: 'Team', type: 'group' })
|
|
246
|
+
mockResponse({ items: [{ id: 'msg-1', roomId: clusteredRoomId, roomType: 'group' }] })
|
|
247
|
+
|
|
248
|
+
const client = await new WebexBotClient().login({ token: 'bot-token' })
|
|
249
|
+
await client.listMessages(usRoomId, { max: 5 })
|
|
250
|
+
|
|
251
|
+
expect(fetchCalls[1].url).toBe(`https://webexapis.com/v1/rooms/${clusteredRoomId}`)
|
|
252
|
+
const messagesUrl = new URL(fetchCalls[2].url)
|
|
253
|
+
expect(messagesUrl.searchParams.get('roomId')).toBe(clusteredRoomId)
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('passes an already-clustered roomId through without a lookup', async () => {
|
|
257
|
+
mockResponse({ id: 'msg-1', roomId: clusteredRoomId, roomType: 'group' })
|
|
258
|
+
|
|
259
|
+
const client = await new WebexBotClient().login({ token: 'bot-token' })
|
|
260
|
+
await client.sendMessage(clusteredRoomId, 'hi')
|
|
261
|
+
|
|
262
|
+
expect(fetchCalls).toHaveLength(1)
|
|
263
|
+
expect(new URL(fetchCalls[0].url).pathname).toBe('/v1/messages')
|
|
264
|
+
expect(JSON.parse(fetchCalls[0].options?.body as string).roomId).toBe(clusteredRoomId)
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('caches the resolution so repeated calls trigger one room lookup', async () => {
|
|
268
|
+
mockResponse({ items: [{ id: clusteredRoomId, type: 'group' }] })
|
|
269
|
+
mockResponse({ id: 'msg-1', roomId: clusteredRoomId })
|
|
270
|
+
mockResponse({ id: 'msg-2', roomId: clusteredRoomId })
|
|
271
|
+
|
|
272
|
+
const client = await new WebexBotClient().login({ token: 'bot-token' })
|
|
273
|
+
await client.sendMessage(usRoomId, 'a')
|
|
274
|
+
await client.sendMessage(usRoomId, 'b')
|
|
275
|
+
|
|
276
|
+
expect(fetchCalls.filter((c) => isRoomsList(c.url))).toHaveLength(1)
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it('follows Link pages until the room is found on a later page', async () => {
|
|
280
|
+
// given the matching room is only on the second page
|
|
281
|
+
mockRoomsPage([{ id: clusteredId('00000000-0000-0000-0000-000000000000'), type: 'group' }], 'cursor1')
|
|
282
|
+
mockRoomsPage([{ id: clusteredRoomId, type: 'group' }])
|
|
283
|
+
mockResponse({ id: 'msg-1', roomId: clusteredRoomId, roomType: 'group' })
|
|
284
|
+
|
|
285
|
+
// when sending to a room not present on the first page
|
|
286
|
+
const client = await new WebexBotClient().login({ token: 'bot-token' })
|
|
287
|
+
await client.sendMessage(usRoomId, 'hi')
|
|
288
|
+
|
|
289
|
+
// then the second page is fetched via the Link cursor and the send targets the match
|
|
290
|
+
expect(isRoomsList(fetchCalls[0].url)).toBe(true)
|
|
291
|
+
expect(isRoomsList(fetchCalls[1].url)).toBe(true)
|
|
292
|
+
expect(fetchCalls[1].url).toContain('before=cursor1')
|
|
293
|
+
expect(JSON.parse(fetchCalls[2].options?.body as string).roomId).toBe(clusteredRoomId)
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it('stops paging once the room is found and does not fetch later pages', async () => {
|
|
297
|
+
// given a first page that already contains the match but still advertises a next page
|
|
298
|
+
mockRoomsPage([{ id: clusteredRoomId, type: 'group' }], 'cursor1')
|
|
299
|
+
mockResponse({ id: 'msg-1', roomId: clusteredRoomId, roomType: 'group' })
|
|
300
|
+
|
|
301
|
+
const client = await new WebexBotClient().login({ token: 'bot-token' })
|
|
302
|
+
await client.sendMessage(usRoomId, 'hi')
|
|
303
|
+
|
|
304
|
+
// then only the first page is fetched (a second page fetch would hit the unmocked response and throw)
|
|
305
|
+
expect(fetchCalls.filter((c) => isRoomsList(c.url))).toHaveLength(1)
|
|
306
|
+
expect(JSON.parse(fetchCalls[1].options?.body as string).roomId).toBe(clusteredRoomId)
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it('fails open to the un-clustered id and warns when no room matches', async () => {
|
|
310
|
+
const warnSpy = spyOn(console, 'warn').mockImplementation(() => {})
|
|
311
|
+
mockResponse({ items: [] })
|
|
312
|
+
mockResponse({ id: 'msg-1', roomId: usRoomId })
|
|
313
|
+
|
|
314
|
+
const client = await new WebexBotClient().login({ token: 'bot-token' })
|
|
315
|
+
await expect(client.sendMessage(usRoomId, 'x')).resolves.toBeDefined()
|
|
316
|
+
|
|
317
|
+
expect(JSON.parse(fetchCalls[1].options?.body as string).roomId).toBe(usRoomId)
|
|
318
|
+
expect(warnSpy).toHaveBeenCalled()
|
|
319
|
+
warnSpy.mockRestore()
|
|
320
|
+
})
|
|
321
|
+
})
|
|
322
|
+
})
|
|
@@ -2,10 +2,33 @@ import { WebexClient } from '../webex/client'
|
|
|
2
2
|
import type { WebexMembership, WebexMessage, WebexPerson, WebexSpace } from '../webex/types'
|
|
3
3
|
import { WebexBotError } from './types'
|
|
4
4
|
|
|
5
|
+
interface DecodedWebexId {
|
|
6
|
+
cluster: string
|
|
7
|
+
type: string
|
|
8
|
+
uuid: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Webex REST ids are base64(url) of `ciscospark://<cluster>/<TYPE>/<uuid>`; the
|
|
12
|
+
// cluster correction needs all three parts, not just the <uuid> `fromRestId` returns.
|
|
13
|
+
function decodeWebexId(restId: string): DecodedWebexId | null {
|
|
14
|
+
if (!restId) return null
|
|
15
|
+
const decoded = Buffer.from(restId, 'base64').toString('utf-8')
|
|
16
|
+
const match = decoded.match(/^ciscospark:\/\/([^/]+)\/([^/]+)\/(.+)$/)
|
|
17
|
+
if (!match) return null
|
|
18
|
+
return { cluster: match[1], type: match[2], uuid: match[3] }
|
|
19
|
+
}
|
|
20
|
+
|
|
5
21
|
export class WebexBotClient {
|
|
6
22
|
private client = new WebexClient()
|
|
7
23
|
private token: string | null = null
|
|
8
24
|
|
|
25
|
+
// The listener flattens room ids to `ciscospark://us/ROOM/<uuid>`, but team/group
|
|
26
|
+
// rooms live on `ciscospark://urn:TEAM:<cluster>/ROOM/<uuid>` — a cluster the bare
|
|
27
|
+
// uuid cannot recover. Cache the real clustered id per uuid and dedupe concurrent
|
|
28
|
+
// lookups so a burst of calls triggers a single `listSpaces`.
|
|
29
|
+
private clusteredRoomIds = new Map<string, string>()
|
|
30
|
+
private roomIdLookups = new Map<string, Promise<string>>()
|
|
31
|
+
|
|
9
32
|
async login(credentials?: { token: string }): Promise<this> {
|
|
10
33
|
if (credentials) {
|
|
11
34
|
if (!credentials.token) {
|
|
@@ -41,22 +64,37 @@ export class WebexBotClient {
|
|
|
41
64
|
}
|
|
42
65
|
|
|
43
66
|
async getSpace(spaceId: string): Promise<WebexSpace> {
|
|
44
|
-
return this.client.getSpace(spaceId)
|
|
67
|
+
return this.client.getSpace(await this.resolveRoomId(spaceId))
|
|
45
68
|
}
|
|
46
69
|
|
|
47
|
-
async sendMessage(
|
|
48
|
-
|
|
70
|
+
async sendMessage(
|
|
71
|
+
roomId: string,
|
|
72
|
+
text: string,
|
|
73
|
+
options?: { markdown?: boolean; parentId?: string; files?: string[] },
|
|
74
|
+
): Promise<WebexMessage> {
|
|
75
|
+
return this.client.sendMessage(await this.resolveRoomId(roomId), text, options)
|
|
49
76
|
}
|
|
50
77
|
|
|
51
78
|
async sendDirectMessage(personEmail: string, text: string, options?: { markdown?: boolean }): Promise<WebexMessage> {
|
|
52
79
|
return this.client.sendDirectMessage(personEmail, text, options)
|
|
53
80
|
}
|
|
54
81
|
|
|
55
|
-
async listMessages(roomId: string, options?: { max?: number }): Promise<WebexMessage[]> {
|
|
56
|
-
|
|
82
|
+
async listMessages(roomId: string, options?: { max?: number; parentId?: string }): Promise<WebexMessage[]> {
|
|
83
|
+
const resolvedRoomId = await this.resolveRoomId(roomId)
|
|
84
|
+
const space = await this.client.getSpace(resolvedRoomId)
|
|
85
|
+
const messageOptions = space.type === 'group' ? { ...options, mentionedPeople: 'me' } : options
|
|
86
|
+
return this.client.listMessages(resolvedRoomId, messageOptions)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async listReplies(roomId: string, parentId: string, options?: { max?: number }): Promise<WebexMessage[]> {
|
|
90
|
+
return this.client.listMessages(await this.resolveRoomId(roomId), { ...options, parentId })
|
|
57
91
|
}
|
|
58
92
|
|
|
59
93
|
async getMessage(messageId: string): Promise<WebexMessage> {
|
|
94
|
+
// MESSAGE ids carry their parent room's cluster, which the bare-UUID normalizer
|
|
95
|
+
// also flattens to `us`. Correcting that needs the room context, which a lone
|
|
96
|
+
// messageId does not provide; room-keyed calls (the reported failures) are
|
|
97
|
+
// corrected via resolveRoomId instead.
|
|
60
98
|
return this.client.getMessage(messageId)
|
|
61
99
|
}
|
|
62
100
|
|
|
@@ -70,18 +108,77 @@ export class WebexBotClient {
|
|
|
70
108
|
text: string,
|
|
71
109
|
options?: { markdown?: boolean },
|
|
72
110
|
): Promise<WebexMessage> {
|
|
73
|
-
return this.client.editMessage(messageId, roomId, text, options)
|
|
111
|
+
return this.client.editMessage(messageId, await this.resolveRoomId(roomId), text, options)
|
|
74
112
|
}
|
|
75
113
|
|
|
76
114
|
async listPeople(options?: { email?: string; displayName?: string; max?: number }): Promise<WebexPerson[]> {
|
|
77
115
|
return this.client.listPeople(options)
|
|
78
116
|
}
|
|
79
117
|
|
|
118
|
+
async getPerson(personId: string): Promise<WebexPerson> {
|
|
119
|
+
return this.client.getPerson(personId)
|
|
120
|
+
}
|
|
121
|
+
|
|
80
122
|
async listMyMemberships(options?: { max?: number }): Promise<WebexMembership[]> {
|
|
81
123
|
return this.client.listMyMemberships(options)
|
|
82
124
|
}
|
|
83
125
|
|
|
84
126
|
async listMemberships(roomId: string, options?: { max?: number }): Promise<WebexMembership[]> {
|
|
85
|
-
return this.client.listMemberships(roomId, options)
|
|
127
|
+
return this.client.listMemberships(await this.resolveRoomId(roomId), options)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async uploadFile(
|
|
131
|
+
roomId: string,
|
|
132
|
+
file: { content: Blob; filename: string },
|
|
133
|
+
options?: { text?: string; markdown?: boolean; parentId?: string },
|
|
134
|
+
): Promise<WebexMessage> {
|
|
135
|
+
return this.client.uploadFile(await this.resolveRoomId(roomId), file, options)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async downloadContent(contentRef: string): Promise<{ data: ArrayBuffer; filename: string; contentType: string }> {
|
|
139
|
+
return this.client.downloadContent(contentRef)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private async resolveRoomId(roomId: string): Promise<string> {
|
|
143
|
+
const decoded = decodeWebexId(roomId)
|
|
144
|
+
// Already cluster-qualified or undecodable: nothing to correct.
|
|
145
|
+
if (!decoded || decoded.cluster.startsWith('urn:')) return roomId
|
|
146
|
+
|
|
147
|
+
const { uuid } = decoded
|
|
148
|
+
const cached = this.clusteredRoomIds.get(uuid)
|
|
149
|
+
if (cached) return cached
|
|
150
|
+
|
|
151
|
+
const inFlight = this.roomIdLookups.get(uuid)
|
|
152
|
+
if (inFlight) return inFlight
|
|
153
|
+
|
|
154
|
+
const lookup = this.lookupRoomId(uuid, roomId)
|
|
155
|
+
this.roomIdLookups.set(uuid, lookup)
|
|
156
|
+
try {
|
|
157
|
+
return await lookup
|
|
158
|
+
} finally {
|
|
159
|
+
this.roomIdLookups.delete(uuid)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private async lookupRoomId(uuid: string, fallback: string): Promise<string> {
|
|
164
|
+
try {
|
|
165
|
+
// Page through every room the bot belongs to (largest page size, following
|
|
166
|
+
// `Link` pages), stopping as soon as the trailing UUID matches.
|
|
167
|
+
for await (const room of this.client.iterateSpaces({ max: 1000 })) {
|
|
168
|
+
if (decodeWebexId(room.id)?.uuid === uuid) {
|
|
169
|
+
this.clusteredRoomIds.set(uuid, room.id)
|
|
170
|
+
return room.id
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
} catch {
|
|
174
|
+
// Network/auth failure: fail open to the un-corrected id rather than block the call.
|
|
175
|
+
return fallback
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
console.warn(
|
|
179
|
+
`[webexbot] Could not resolve clustered room id for ${uuid}; falling back to the un-clustered id. ` +
|
|
180
|
+
'Room-scoped calls may fail if this room lives on a non-default Webex cluster.',
|
|
181
|
+
)
|
|
182
|
+
return fallback
|
|
86
183
|
}
|
|
87
184
|
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { basename, resolve } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { Command } from 'commander'
|
|
5
|
+
|
|
6
|
+
import { cliOutput } from '@/shared/utils/cli-output'
|
|
7
|
+
|
|
8
|
+
import type { BotOption } from './shared'
|
|
9
|
+
import { getClient } from './shared'
|
|
10
|
+
|
|
11
|
+
interface FileResult {
|
|
12
|
+
id?: string
|
|
13
|
+
roomId?: string
|
|
14
|
+
files?: string[]
|
|
15
|
+
created?: string
|
|
16
|
+
downloaded?: string
|
|
17
|
+
filename?: string
|
|
18
|
+
contentType?: string
|
|
19
|
+
size?: number
|
|
20
|
+
error?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function uploadAction(
|
|
24
|
+
space: string,
|
|
25
|
+
path: string,
|
|
26
|
+
options: BotOption & { text?: string; markdown?: boolean; parent?: string },
|
|
27
|
+
): Promise<FileResult> {
|
|
28
|
+
try {
|
|
29
|
+
const client = await getClient(options)
|
|
30
|
+
const filePath = resolve(path)
|
|
31
|
+
const content = await readFile(filePath)
|
|
32
|
+
const message = await client.uploadFile(
|
|
33
|
+
space,
|
|
34
|
+
{ content: new Blob([content]), filename: basename(filePath) },
|
|
35
|
+
{ text: options.text, markdown: options.markdown, parentId: options.parent },
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
id: message.id,
|
|
40
|
+
roomId: message.roomId,
|
|
41
|
+
files: message.files,
|
|
42
|
+
created: message.created,
|
|
43
|
+
}
|
|
44
|
+
} catch (error) {
|
|
45
|
+
return { error: (error as Error).message }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function downloadAction(
|
|
50
|
+
contentRef: string,
|
|
51
|
+
output: string | undefined,
|
|
52
|
+
options: BotOption,
|
|
53
|
+
): Promise<FileResult> {
|
|
54
|
+
try {
|
|
55
|
+
const client = await getClient(options)
|
|
56
|
+
const { data, filename, contentType } = await client.downloadContent(contentRef)
|
|
57
|
+
// When no explicit output is given, confine the server-provided name to cwd.
|
|
58
|
+
const outputPath = output ? resolve(output) : resolve(process.cwd(), basename(filename))
|
|
59
|
+
await writeFile(outputPath, Buffer.from(data))
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
downloaded: outputPath,
|
|
63
|
+
filename,
|
|
64
|
+
contentType,
|
|
65
|
+
size: data.byteLength,
|
|
66
|
+
}
|
|
67
|
+
} catch (error) {
|
|
68
|
+
return { error: (error as Error).message }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const fileCommand = new Command('file')
|
|
73
|
+
.description('File commands')
|
|
74
|
+
.addCommand(
|
|
75
|
+
new Command('upload')
|
|
76
|
+
.description('Upload a local file to a space')
|
|
77
|
+
.argument('<space>', 'Space/Room ID')
|
|
78
|
+
.argument('<path>', 'Local file path')
|
|
79
|
+
.option('--text <text>', 'Optional message to send with the file')
|
|
80
|
+
.option('--markdown', 'Treat --text as markdown')
|
|
81
|
+
.option('--parent <id>', 'Reply within a thread (parent message ID)')
|
|
82
|
+
.option('--bot <id>', 'Use specific bot')
|
|
83
|
+
.option('--pretty', 'Pretty print JSON output')
|
|
84
|
+
.action(
|
|
85
|
+
async (
|
|
86
|
+
space: string,
|
|
87
|
+
path: string,
|
|
88
|
+
opts: BotOption & { text?: string; markdown?: boolean; parent?: string },
|
|
89
|
+
) => {
|
|
90
|
+
cliOutput(await uploadAction(space, path, opts), opts.pretty)
|
|
91
|
+
},
|
|
92
|
+
),
|
|
93
|
+
)
|
|
94
|
+
.addCommand(
|
|
95
|
+
new Command('download')
|
|
96
|
+
.description('Download a file attachment by content URL or ID')
|
|
97
|
+
.argument('<content>', 'File content URL (from message.files) or content ID')
|
|
98
|
+
.argument('[output]', 'Output path (defaults to original filename)')
|
|
99
|
+
.option('--bot <id>', 'Use specific bot')
|
|
100
|
+
.option('--pretty', 'Pretty print JSON output')
|
|
101
|
+
.action(async (content: string, output: string | undefined, opts: BotOption) => {
|
|
102
|
+
cliOutput(await downloadAction(content, output, opts), opts.pretty)
|
|
103
|
+
}),
|
|
104
|
+
)
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
export { authCommand } from './auth'
|
|
2
|
+
export { fileCommand } from './file'
|
|
2
3
|
export { listenCommand } from './listen'
|
|
3
4
|
export { memberCommand } from './member'
|
|
4
5
|
export { messageCommand } from './message'
|
|
6
|
+
export { snapshotCommand } from './snapshot'
|
|
5
7
|
export { spaceCommand } from './space'
|
|
8
|
+
export { userCommand } from './user'
|
|
6
9
|
export { whoamiCommand } from './whoami'
|