agent-messenger 2.21.0 → 2.22.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/dist/package.json +1 -1
- package/dist/src/platforms/webex/client.d.ts +19 -0
- package/dist/src/platforms/webex/client.d.ts.map +1 -1
- package/dist/src/platforms/webex/client.js +81 -1
- package/dist/src/platforms/webex/client.js.map +1 -1
- 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 +20 -0
- package/dist/src/platforms/webexbot/client.d.ts.map +1 -1
- package/dist/src/platforms/webexbot/client.js +15 -1
- 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/docs/content/docs/cli/webexbot.mdx +2 -0
- package/docs/content/docs/sdk/webexbot.mdx +2 -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 +1 -1
- package/skills/agent-webexbot/SKILL.md +58 -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/client.test.ts +10 -0
- package/src/platforms/webex/client.ts +97 -3
- package/src/platforms/webexbot/cli.ts +6 -0
- package/src/platforms/webexbot/client.test.ts +198 -0
- package/src/platforms/webexbot/client.ts +29 -3
- 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
|
@@ -220,6 +220,124 @@ MESSAGE_ID="Y2lzY29zcGFyazovL..."
|
|
|
220
220
|
agent-webexbot message delete "$MESSAGE_ID"
|
|
221
221
|
```
|
|
222
222
|
|
|
223
|
+
## Thread Patterns
|
|
224
|
+
|
|
225
|
+
### Pattern 12a: Reply in a Thread
|
|
226
|
+
|
|
227
|
+
**Use case**: Keep a conversation organized under a parent message
|
|
228
|
+
|
|
229
|
+
```bash
|
|
230
|
+
#!/bin/bash
|
|
231
|
+
|
|
232
|
+
SPACE_ID="Y2lzY29zcGFyazovL..."
|
|
233
|
+
|
|
234
|
+
# Send a parent message and capture its ID
|
|
235
|
+
PARENT=$(agent-webexbot message send "$SPACE_ID" "Deploy started" | jq -r '.id')
|
|
236
|
+
|
|
237
|
+
# Reply within the thread
|
|
238
|
+
agent-webexbot message reply "$SPACE_ID" "$PARENT" "Step 1 complete"
|
|
239
|
+
|
|
240
|
+
# Equivalent using send --parent
|
|
241
|
+
agent-webexbot message send "$SPACE_ID" "Step 2 complete" --parent "$PARENT"
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
### Pattern 12b: Read Thread Replies
|
|
245
|
+
|
|
246
|
+
**Use case**: Fetch all replies under a parent message
|
|
247
|
+
|
|
248
|
+
```bash
|
|
249
|
+
#!/bin/bash
|
|
250
|
+
|
|
251
|
+
SPACE_ID="Y2lzY29zcGFyazovL..."
|
|
252
|
+
PARENT_ID="Y2lzY29zcGFyazovL..."
|
|
253
|
+
|
|
254
|
+
agent-webexbot message replies "$SPACE_ID" "$PARENT_ID" --max 20 \
|
|
255
|
+
| jq -r '.messages[] | "[\(.created)] \(.personEmail): \(.text)"'
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
## File Patterns
|
|
259
|
+
|
|
260
|
+
### Pattern 12c: Upload a File
|
|
261
|
+
|
|
262
|
+
**Use case**: Attach a local file (report, log, image) to a space
|
|
263
|
+
|
|
264
|
+
```bash
|
|
265
|
+
#!/bin/bash
|
|
266
|
+
|
|
267
|
+
SPACE_ID="Y2lzY29zcGFyazovL..."
|
|
268
|
+
|
|
269
|
+
# Upload with an accompanying message
|
|
270
|
+
agent-webexbot file upload "$SPACE_ID" ./coverage.html --text "Latest coverage report"
|
|
271
|
+
|
|
272
|
+
# Upload as a threaded reply
|
|
273
|
+
agent-webexbot file upload "$SPACE_ID" ./build.log --parent "$PARENT_ID"
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
**Note**: Max file size is 100 MB, one file per message.
|
|
277
|
+
|
|
278
|
+
### Pattern 12d: Download an Attachment
|
|
279
|
+
|
|
280
|
+
**Use case**: Save a file someone shared in a space
|
|
281
|
+
|
|
282
|
+
```bash
|
|
283
|
+
#!/bin/bash
|
|
284
|
+
|
|
285
|
+
SPACE_ID="Y2lzY29zcGFyazovL..."
|
|
286
|
+
|
|
287
|
+
# Get the content URL from a message's "files" array
|
|
288
|
+
CONTENT_URL=$(agent-webexbot message list "$SPACE_ID" --max 20 \
|
|
289
|
+
| jq -r 'first(.messages[].files[]? // empty)')
|
|
290
|
+
|
|
291
|
+
# Download (defaults to the original filename in the current directory)
|
|
292
|
+
agent-webexbot file download "$CONTENT_URL"
|
|
293
|
+
|
|
294
|
+
# Or choose an output path explicitly
|
|
295
|
+
agent-webexbot file download "$CONTENT_URL" ./downloaded-report.html
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
**Security note**: Downloads are restricted to `https://webexapis.com/v1/contents/*` URLs — the bot token is never sent to other hosts. Server-provided filenames are reduced to their base name, so the default output always stays in the current directory.
|
|
299
|
+
|
|
300
|
+
## People Patterns
|
|
301
|
+
|
|
302
|
+
### Pattern 12e: Look Up a Person by Email
|
|
303
|
+
|
|
304
|
+
**Use case**: Resolve a person ID or display name from an email address
|
|
305
|
+
|
|
306
|
+
```bash
|
|
307
|
+
#!/bin/bash
|
|
308
|
+
|
|
309
|
+
agent-webexbot user list --email alice@example.com \
|
|
310
|
+
| jq -r '.users[] | "\(.displayName) — \(.id)"'
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
### Pattern 12f: Get Person Details
|
|
314
|
+
|
|
315
|
+
**Use case**: Fetch full profile details for a known person ID
|
|
316
|
+
|
|
317
|
+
```bash
|
|
318
|
+
#!/bin/bash
|
|
319
|
+
|
|
320
|
+
PERSON_ID="Y2lzY29zcGFyazovL..."
|
|
321
|
+
|
|
322
|
+
agent-webexbot user info "$PERSON_ID" | jq '{displayName, emails, type}'
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
## Snapshot Patterns
|
|
326
|
+
|
|
327
|
+
### Pattern 12g: Workspace Overview
|
|
328
|
+
|
|
329
|
+
**Use case**: Give an AI agent a quick picture of the bot's workspace
|
|
330
|
+
|
|
331
|
+
```bash
|
|
332
|
+
#!/bin/bash
|
|
333
|
+
|
|
334
|
+
# Brief: bot identity + space IDs/titles
|
|
335
|
+
agent-webexbot snapshot
|
|
336
|
+
|
|
337
|
+
# Full: includes space type and last activity
|
|
338
|
+
agent-webexbot snapshot --full --max 50
|
|
339
|
+
```
|
|
340
|
+
|
|
223
341
|
## Member Patterns
|
|
224
342
|
|
|
225
343
|
### Pattern 13: List Space Members
|
|
@@ -274,6 +274,16 @@ describe('WebexClient', () => {
|
|
|
274
274
|
|
|
275
275
|
expect(fetchCalls[0].url).toContain('max=10')
|
|
276
276
|
})
|
|
277
|
+
|
|
278
|
+
it('passes mentionedPeople when requested', async () => {
|
|
279
|
+
mockResponse({ items: [] })
|
|
280
|
+
|
|
281
|
+
const client = await new WebexClient().login({ token: 'test-token' })
|
|
282
|
+
await client.listMessages('room1', { max: 10, mentionedPeople: 'me' })
|
|
283
|
+
|
|
284
|
+
const url = new URL(fetchCalls[0].url)
|
|
285
|
+
expect(url.searchParams.get('mentionedPeople')).toBe('me')
|
|
286
|
+
})
|
|
277
287
|
})
|
|
278
288
|
|
|
279
289
|
describe('getMessage', () => {
|
|
@@ -6,6 +6,7 @@ import type { WebexConfig, WebexMembership, WebexMessage, WebexPerson, WebexSpac
|
|
|
6
6
|
import { WebexError } from './types'
|
|
7
7
|
|
|
8
8
|
const BASE_URL = 'https://webexapis.com/v1'
|
|
9
|
+
const CONTENT_HOST = 'webexapis.com'
|
|
9
10
|
const MAX_RETRIES = 3
|
|
10
11
|
const BASE_BACKOFF_MS = 100
|
|
11
12
|
|
|
@@ -223,11 +224,19 @@ export class WebexClient {
|
|
|
223
224
|
return this.request<WebexSpace>('GET', `/rooms/${spaceId}`)
|
|
224
225
|
}
|
|
225
226
|
|
|
226
|
-
async sendMessage(
|
|
227
|
+
async sendMessage(
|
|
228
|
+
roomId: string,
|
|
229
|
+
text: string,
|
|
230
|
+
options?: { markdown?: boolean; parentId?: string; files?: string[] },
|
|
231
|
+
): Promise<WebexMessage> {
|
|
227
232
|
if (this.useInternalAPI) {
|
|
228
233
|
return this.sendMessageInternal(roomId, text, options)
|
|
229
234
|
}
|
|
230
|
-
const body
|
|
235
|
+
const body: Record<string, unknown> = { roomId }
|
|
236
|
+
if (options?.markdown) body.markdown = text
|
|
237
|
+
else body.text = text
|
|
238
|
+
if (options?.parentId) body.parentId = options.parentId
|
|
239
|
+
if (options?.files?.length) body.files = options.files
|
|
231
240
|
return this.request<WebexMessage>('POST', '/messages', body)
|
|
232
241
|
}
|
|
233
242
|
|
|
@@ -387,7 +396,10 @@ export class WebexClient {
|
|
|
387
396
|
return null
|
|
388
397
|
}
|
|
389
398
|
|
|
390
|
-
async listMessages(
|
|
399
|
+
async listMessages(
|
|
400
|
+
roomId: string,
|
|
401
|
+
options?: { max?: number; mentionedPeople?: string; parentId?: string },
|
|
402
|
+
): Promise<WebexMessage[]> {
|
|
391
403
|
if (this.useInternalAPI) {
|
|
392
404
|
const convUuid = this.decodeConvUuid(roomId)
|
|
393
405
|
const max = options?.max ?? 50
|
|
@@ -400,6 +412,8 @@ export class WebexClient {
|
|
|
400
412
|
const params = new URLSearchParams()
|
|
401
413
|
params.set('roomId', roomId)
|
|
402
414
|
params.set('max', String(options?.max ?? 50))
|
|
415
|
+
if (options?.mentionedPeople) params.set('mentionedPeople', options.mentionedPeople)
|
|
416
|
+
if (options?.parentId) params.set('parentId', options.parentId)
|
|
403
417
|
const data = await this.request<{ items: WebexMessage[] }>('GET', `/messages?${params}`)
|
|
404
418
|
return data.items
|
|
405
419
|
}
|
|
@@ -493,6 +507,10 @@ export class WebexClient {
|
|
|
493
507
|
return data.items
|
|
494
508
|
}
|
|
495
509
|
|
|
510
|
+
async getPerson(personId: string): Promise<WebexPerson> {
|
|
511
|
+
return this.request<WebexPerson>('GET', `/people/${personId}`)
|
|
512
|
+
}
|
|
513
|
+
|
|
496
514
|
async listMyMemberships(options?: { max?: number }): Promise<WebexMembership[]> {
|
|
497
515
|
const params = new URLSearchParams()
|
|
498
516
|
params.set('max', String(options?.max ?? 100))
|
|
@@ -507,6 +525,82 @@ export class WebexClient {
|
|
|
507
525
|
const data = await this.request<{ items: WebexMembership[] }>('GET', `/memberships?${params}`)
|
|
508
526
|
return data.items
|
|
509
527
|
}
|
|
528
|
+
|
|
529
|
+
async uploadFile(
|
|
530
|
+
roomId: string,
|
|
531
|
+
file: { content: Blob; filename: string },
|
|
532
|
+
options?: { text?: string; markdown?: boolean; parentId?: string },
|
|
533
|
+
): Promise<WebexMessage> {
|
|
534
|
+
const form = new FormData()
|
|
535
|
+
form.set('roomId', roomId)
|
|
536
|
+
if (options?.text) {
|
|
537
|
+
form.set(options.markdown ? 'markdown' : 'text', options.text)
|
|
538
|
+
}
|
|
539
|
+
if (options?.parentId) form.set('parentId', options.parentId)
|
|
540
|
+
form.set('files', file.content, file.filename)
|
|
541
|
+
|
|
542
|
+
const response = await fetch(`${BASE_URL}/messages`, {
|
|
543
|
+
method: 'POST',
|
|
544
|
+
headers: { Authorization: `Bearer ${this.ensureAuth()}` },
|
|
545
|
+
body: form,
|
|
546
|
+
})
|
|
547
|
+
|
|
548
|
+
if (!response.ok) {
|
|
549
|
+
const errorBody = (await response.json().catch(() => null)) as { message?: string } | null
|
|
550
|
+
throw new WebexError(errorBody?.message ?? `HTTP ${response.status}`, `http_${response.status}`)
|
|
551
|
+
}
|
|
552
|
+
return response.json() as Promise<WebexMessage>
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
async downloadContent(contentRef: string): Promise<{ data: ArrayBuffer; filename: string; contentType: string }> {
|
|
556
|
+
const url = this.resolveContentUrl(contentRef)
|
|
557
|
+
const response = await fetch(url, {
|
|
558
|
+
headers: { Authorization: `Bearer ${this.ensureAuth()}` },
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
if (!response.ok) {
|
|
562
|
+
const errorBody = (await response.json().catch(() => null)) as { message?: string } | null
|
|
563
|
+
throw new WebexError(errorBody?.message ?? `HTTP ${response.status}`, `http_${response.status}`)
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const disposition = response.headers.get('Content-Disposition') ?? ''
|
|
567
|
+
const match = disposition.match(/filename="?([^"]+)"?/)
|
|
568
|
+
const filename = sanitizeFilename(match?.[1]) ?? sanitizeFilename(contentRef.split('/').pop()) ?? 'download'
|
|
569
|
+
const contentType = response.headers.get('Content-Type') ?? 'application/octet-stream'
|
|
570
|
+
const data = await response.arrayBuffer()
|
|
571
|
+
return { data, filename, contentType }
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
private resolveContentUrl(contentRef: string): string {
|
|
575
|
+
// A bare content id never contains a scheme or path separators.
|
|
576
|
+
if (!contentRef.includes('://') && !contentRef.includes('/')) {
|
|
577
|
+
return `${BASE_URL}/contents/${encodeURIComponent(contentRef)}`
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Only attach the bearer token to HTTPS Webex content URLs to avoid
|
|
581
|
+
// leaking credentials to attacker-controlled hosts (SSRF/token exfiltration).
|
|
582
|
+
let parsed: URL
|
|
583
|
+
try {
|
|
584
|
+
parsed = new URL(contentRef)
|
|
585
|
+
} catch {
|
|
586
|
+
throw new WebexError(`Invalid content reference: ${contentRef}`, 'invalid_content_ref')
|
|
587
|
+
}
|
|
588
|
+
if (parsed.protocol !== 'https:' || parsed.host !== CONTENT_HOST || !parsed.pathname.startsWith('/v1/contents/')) {
|
|
589
|
+
throw new WebexError(
|
|
590
|
+
`Refusing to download from untrusted location: ${parsed.origin}${parsed.pathname}`,
|
|
591
|
+
'untrusted_content_url',
|
|
592
|
+
)
|
|
593
|
+
}
|
|
594
|
+
return parsed.toString()
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function sanitizeFilename(name: string | undefined): string | undefined {
|
|
599
|
+
if (!name) return undefined
|
|
600
|
+
// Strip any path components so a server-supplied name cannot escape the target directory.
|
|
601
|
+
const base = name.replace(/\\/g, '/').split('/').pop()
|
|
602
|
+
if (!base || base === '.' || base === '..') return undefined
|
|
603
|
+
return base
|
|
510
604
|
}
|
|
511
605
|
|
|
512
606
|
interface InternalActivity {
|
|
@@ -5,10 +5,13 @@ import { Command } from 'commander'
|
|
|
5
5
|
import pkg from '../../../package.json' with { type: 'json' }
|
|
6
6
|
import {
|
|
7
7
|
authCommand,
|
|
8
|
+
fileCommand,
|
|
8
9
|
listenCommand,
|
|
9
10
|
memberCommand,
|
|
10
11
|
messageCommand,
|
|
12
|
+
snapshotCommand,
|
|
11
13
|
spaceCommand,
|
|
14
|
+
userCommand,
|
|
12
15
|
whoamiCommand,
|
|
13
16
|
} from './commands/index'
|
|
14
17
|
|
|
@@ -35,6 +38,9 @@ program.addCommand(whoamiCommand)
|
|
|
35
38
|
program.addCommand(messageCommand)
|
|
36
39
|
program.addCommand(spaceCommand)
|
|
37
40
|
program.addCommand(memberCommand)
|
|
41
|
+
program.addCommand(userCommand)
|
|
42
|
+
program.addCommand(fileCommand)
|
|
43
|
+
program.addCommand(snapshotCommand)
|
|
38
44
|
program.addCommand(listenCommand)
|
|
39
45
|
|
|
40
46
|
program.parseAsync(process.argv)
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { WebexBotClient } from './client'
|
|
4
|
+
|
|
5
|
+
describe('WebexBotClient', () => {
|
|
6
|
+
const originalFetch = globalThis.fetch
|
|
7
|
+
let fetchCalls: Array<{ url: string; options?: RequestInit }> = []
|
|
8
|
+
let fetchResponses: Response[] = []
|
|
9
|
+
let fetchIndex = 0
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
fetchCalls = []
|
|
13
|
+
fetchResponses = []
|
|
14
|
+
fetchIndex = 0
|
|
15
|
+
;(globalThis as { fetch: unknown }).fetch = async (
|
|
16
|
+
url: string | URL | Request,
|
|
17
|
+
options?: RequestInit,
|
|
18
|
+
): Promise<Response> => {
|
|
19
|
+
fetchCalls.push({ url: url.toString(), options })
|
|
20
|
+
const response = fetchResponses[fetchIndex]
|
|
21
|
+
fetchIndex++
|
|
22
|
+
if (!response) {
|
|
23
|
+
throw new Error('No mock response configured')
|
|
24
|
+
}
|
|
25
|
+
return response
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
globalThis.fetch = originalFetch
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const mockResponse = (body: unknown, status = 200) => {
|
|
34
|
+
fetchResponses.push(
|
|
35
|
+
new Response(JSON.stringify(body), {
|
|
36
|
+
status,
|
|
37
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38
|
+
}),
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('listMessages', () => {
|
|
43
|
+
it('limits group-space history to messages that mention the bot', async () => {
|
|
44
|
+
mockResponse({ id: 'group-room', title: 'Team', type: 'group' })
|
|
45
|
+
mockResponse({ items: [{ id: 'msg-1', roomId: 'group-room', roomType: 'group' }] })
|
|
46
|
+
|
|
47
|
+
const client = await new WebexBotClient().login({ token: 'bot-token' })
|
|
48
|
+
const messages = await client.listMessages('group-room', { max: 5 })
|
|
49
|
+
|
|
50
|
+
expect(messages).toHaveLength(1)
|
|
51
|
+
expect(fetchCalls[0].url).toBe('https://webexapis.com/v1/rooms/group-room')
|
|
52
|
+
|
|
53
|
+
const messagesUrl = new URL(fetchCalls[1].url)
|
|
54
|
+
expect(messagesUrl.origin + messagesUrl.pathname).toBe('https://webexapis.com/v1/messages')
|
|
55
|
+
expect(messagesUrl.searchParams.get('roomId')).toBe('group-room')
|
|
56
|
+
expect(messagesUrl.searchParams.get('max')).toBe('5')
|
|
57
|
+
expect(messagesUrl.searchParams.get('mentionedPeople')).toBe('me')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('does not add mentionedPeople for direct spaces', async () => {
|
|
61
|
+
mockResponse({ id: 'direct-room', title: 'DM', type: 'direct' })
|
|
62
|
+
mockResponse({ items: [{ id: 'msg-1', roomId: 'direct-room', roomType: 'direct' }] })
|
|
63
|
+
|
|
64
|
+
const client = await new WebexBotClient().login({ token: 'bot-token' })
|
|
65
|
+
await client.listMessages('direct-room', { max: 5 })
|
|
66
|
+
|
|
67
|
+
const messagesUrl = new URL(fetchCalls[1].url)
|
|
68
|
+
expect(messagesUrl.searchParams.get('roomId')).toBe('direct-room')
|
|
69
|
+
expect(messagesUrl.searchParams.get('mentionedPeople')).toBeNull()
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('sendMessage threading', () => {
|
|
74
|
+
it('includes parentId in the request body when threading', async () => {
|
|
75
|
+
mockResponse({ id: 'msg-1', roomId: 'room-1', roomType: 'group' })
|
|
76
|
+
|
|
77
|
+
const client = await new WebexBotClient().login({ token: 'bot-token' })
|
|
78
|
+
await client.sendMessage('room-1', 'reply text', { parentId: 'parent-1' })
|
|
79
|
+
|
|
80
|
+
const body = JSON.parse(fetchCalls[0].options?.body as string)
|
|
81
|
+
expect(body.roomId).toBe('room-1')
|
|
82
|
+
expect(body.text).toBe('reply text')
|
|
83
|
+
expect(body.parentId).toBe('parent-1')
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('listReplies', () => {
|
|
88
|
+
it('queries messages filtered by parentId', async () => {
|
|
89
|
+
mockResponse({ items: [{ id: 'reply-1', roomId: 'room-1', roomType: 'group', parentId: 'parent-1' }] })
|
|
90
|
+
|
|
91
|
+
const client = await new WebexBotClient().login({ token: 'bot-token' })
|
|
92
|
+
const replies = await client.listReplies('room-1', 'parent-1', { max: 10 })
|
|
93
|
+
|
|
94
|
+
expect(replies).toHaveLength(1)
|
|
95
|
+
const url = new URL(fetchCalls[0].url)
|
|
96
|
+
expect(url.searchParams.get('roomId')).toBe('room-1')
|
|
97
|
+
expect(url.searchParams.get('parentId')).toBe('parent-1')
|
|
98
|
+
expect(url.searchParams.get('max')).toBe('10')
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
describe('getPerson', () => {
|
|
103
|
+
it('fetches a person by id', async () => {
|
|
104
|
+
mockResponse({ id: 'person-1', emails: ['a@b.com'], displayName: 'Alice', type: 'person' })
|
|
105
|
+
|
|
106
|
+
const client = await new WebexBotClient().login({ token: 'bot-token' })
|
|
107
|
+
const person = await client.getPerson('person-1')
|
|
108
|
+
|
|
109
|
+
expect(person.displayName).toBe('Alice')
|
|
110
|
+
expect(fetchCalls[0].url).toBe('https://webexapis.com/v1/people/person-1')
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
describe('uploadFile', () => {
|
|
115
|
+
it('posts multipart form data to messages', async () => {
|
|
116
|
+
mockResponse({
|
|
117
|
+
id: 'msg-1',
|
|
118
|
+
roomId: 'room-1',
|
|
119
|
+
roomType: 'group',
|
|
120
|
+
files: ['https://webexapis.com/v1/contents/c1'],
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
const client = await new WebexBotClient().login({ token: 'bot-token' })
|
|
124
|
+
const message = await client.uploadFile(
|
|
125
|
+
'room-1',
|
|
126
|
+
{ content: new Blob(['hello']), filename: 'note.txt' },
|
|
127
|
+
{ text: 'see attached' },
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
expect(message.files).toEqual(['https://webexapis.com/v1/contents/c1'])
|
|
131
|
+
expect(fetchCalls[0].url).toBe('https://webexapis.com/v1/messages')
|
|
132
|
+
expect(fetchCalls[0].options?.method).toBe('POST')
|
|
133
|
+
const body = fetchCalls[0].options?.body as FormData
|
|
134
|
+
expect(body).toBeInstanceOf(FormData)
|
|
135
|
+
expect(body.get('roomId')).toBe('room-1')
|
|
136
|
+
expect(body.get('text')).toBe('see attached')
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
describe('downloadContent', () => {
|
|
141
|
+
it('returns binary data with filename parsed from Content-Disposition', async () => {
|
|
142
|
+
fetchResponses.push(
|
|
143
|
+
new Response('binary-bytes', {
|
|
144
|
+
status: 200,
|
|
145
|
+
headers: {
|
|
146
|
+
'Content-Disposition': 'attachment; filename="report.pdf"',
|
|
147
|
+
'Content-Type': 'application/pdf',
|
|
148
|
+
},
|
|
149
|
+
}),
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
const client = await new WebexBotClient().login({ token: 'bot-token' })
|
|
153
|
+
const result = await client.downloadContent('https://webexapis.com/v1/contents/c1')
|
|
154
|
+
|
|
155
|
+
expect(result.filename).toBe('report.pdf')
|
|
156
|
+
expect(result.contentType).toBe('application/pdf')
|
|
157
|
+
expect(fetchCalls[0].url).toBe('https://webexapis.com/v1/contents/c1')
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('builds the contents URL from a bare content id', async () => {
|
|
161
|
+
fetchResponses.push(new Response('data', { status: 200, headers: {} }))
|
|
162
|
+
|
|
163
|
+
const client = await new WebexBotClient().login({ token: 'bot-token' })
|
|
164
|
+
const result = await client.downloadContent('abc123')
|
|
165
|
+
|
|
166
|
+
expect(fetchCalls[0].url).toBe('https://webexapis.com/v1/contents/abc123')
|
|
167
|
+
expect(result.filename).toBe('abc123')
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('refuses to download from a non-Webex host', async () => {
|
|
171
|
+
const client = await new WebexBotClient().login({ token: 'bot-token' })
|
|
172
|
+
|
|
173
|
+
await expect(client.downloadContent('https://attacker.example/file')).rejects.toThrow(/untrusted/i)
|
|
174
|
+
expect(fetchCalls).toHaveLength(0)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('refuses to download over plain http from the Webex host', async () => {
|
|
178
|
+
const client = await new WebexBotClient().login({ token: 'bot-token' })
|
|
179
|
+
|
|
180
|
+
await expect(client.downloadContent('http://webexapis.com/v1/contents/c1')).rejects.toThrow(/untrusted/i)
|
|
181
|
+
expect(fetchCalls).toHaveLength(0)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('sanitizes a path-traversal filename from Content-Disposition', async () => {
|
|
185
|
+
fetchResponses.push(
|
|
186
|
+
new Response('data', {
|
|
187
|
+
status: 200,
|
|
188
|
+
headers: { 'Content-Disposition': 'attachment; filename="../../etc/passwd"' },
|
|
189
|
+
}),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
const client = await new WebexBotClient().login({ token: 'bot-token' })
|
|
193
|
+
const result = await client.downloadContent('https://webexapis.com/v1/contents/c1')
|
|
194
|
+
|
|
195
|
+
expect(result.filename).toBe('passwd')
|
|
196
|
+
})
|
|
197
|
+
})
|
|
198
|
+
})
|
|
@@ -44,7 +44,11 @@ export class WebexBotClient {
|
|
|
44
44
|
return this.client.getSpace(spaceId)
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
async sendMessage(
|
|
47
|
+
async sendMessage(
|
|
48
|
+
roomId: string,
|
|
49
|
+
text: string,
|
|
50
|
+
options?: { markdown?: boolean; parentId?: string; files?: string[] },
|
|
51
|
+
): Promise<WebexMessage> {
|
|
48
52
|
return this.client.sendMessage(roomId, text, options)
|
|
49
53
|
}
|
|
50
54
|
|
|
@@ -52,8 +56,14 @@ export class WebexBotClient {
|
|
|
52
56
|
return this.client.sendDirectMessage(personEmail, text, options)
|
|
53
57
|
}
|
|
54
58
|
|
|
55
|
-
async listMessages(roomId: string, options?: { max?: number }): Promise<WebexMessage[]> {
|
|
56
|
-
|
|
59
|
+
async listMessages(roomId: string, options?: { max?: number; parentId?: string }): Promise<WebexMessage[]> {
|
|
60
|
+
const space = await this.client.getSpace(roomId)
|
|
61
|
+
const messageOptions = space.type === 'group' ? { ...options, mentionedPeople: 'me' } : options
|
|
62
|
+
return this.client.listMessages(roomId, messageOptions)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async listReplies(roomId: string, parentId: string, options?: { max?: number }): Promise<WebexMessage[]> {
|
|
66
|
+
return this.client.listMessages(roomId, { ...options, parentId })
|
|
57
67
|
}
|
|
58
68
|
|
|
59
69
|
async getMessage(messageId: string): Promise<WebexMessage> {
|
|
@@ -77,6 +87,10 @@ export class WebexBotClient {
|
|
|
77
87
|
return this.client.listPeople(options)
|
|
78
88
|
}
|
|
79
89
|
|
|
90
|
+
async getPerson(personId: string): Promise<WebexPerson> {
|
|
91
|
+
return this.client.getPerson(personId)
|
|
92
|
+
}
|
|
93
|
+
|
|
80
94
|
async listMyMemberships(options?: { max?: number }): Promise<WebexMembership[]> {
|
|
81
95
|
return this.client.listMyMemberships(options)
|
|
82
96
|
}
|
|
@@ -84,4 +98,16 @@ export class WebexBotClient {
|
|
|
84
98
|
async listMemberships(roomId: string, options?: { max?: number }): Promise<WebexMembership[]> {
|
|
85
99
|
return this.client.listMemberships(roomId, options)
|
|
86
100
|
}
|
|
101
|
+
|
|
102
|
+
async uploadFile(
|
|
103
|
+
roomId: string,
|
|
104
|
+
file: { content: Blob; filename: string },
|
|
105
|
+
options?: { text?: string; markdown?: boolean; parentId?: string },
|
|
106
|
+
): Promise<WebexMessage> {
|
|
107
|
+
return this.client.uploadFile(roomId, file, options)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async downloadContent(contentRef: string): Promise<{ data: ArrayBuffer; filename: string; contentType: string }> {
|
|
111
|
+
return this.client.downloadContent(contentRef)
|
|
112
|
+
}
|
|
87
113
|
}
|