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.
Files changed (65) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/dist/package.json +1 -1
  3. package/dist/src/platforms/webex/client.d.ts +19 -0
  4. package/dist/src/platforms/webex/client.d.ts.map +1 -1
  5. package/dist/src/platforms/webex/client.js +81 -1
  6. package/dist/src/platforms/webex/client.js.map +1 -1
  7. package/dist/src/platforms/webexbot/cli.d.ts.map +1 -1
  8. package/dist/src/platforms/webexbot/cli.js +4 -1
  9. package/dist/src/platforms/webexbot/cli.js.map +1 -1
  10. package/dist/src/platforms/webexbot/client.d.ts +20 -0
  11. package/dist/src/platforms/webexbot/client.d.ts.map +1 -1
  12. package/dist/src/platforms/webexbot/client.js +15 -1
  13. package/dist/src/platforms/webexbot/client.js.map +1 -1
  14. package/dist/src/platforms/webexbot/commands/file.d.ts +22 -0
  15. package/dist/src/platforms/webexbot/commands/file.d.ts.map +1 -0
  16. package/dist/src/platforms/webexbot/commands/file.js +64 -0
  17. package/dist/src/platforms/webexbot/commands/file.js.map +1 -0
  18. package/dist/src/platforms/webexbot/commands/index.d.ts +3 -0
  19. package/dist/src/platforms/webexbot/commands/index.d.ts.map +1 -1
  20. package/dist/src/platforms/webexbot/commands/index.js +3 -0
  21. package/dist/src/platforms/webexbot/commands/index.js.map +1 -1
  22. package/dist/src/platforms/webexbot/commands/message.d.ts +7 -0
  23. package/dist/src/platforms/webexbot/commands/message.d.ts.map +1 -1
  24. package/dist/src/platforms/webexbot/commands/message.js +52 -1
  25. package/dist/src/platforms/webexbot/commands/message.js.map +1 -1
  26. package/dist/src/platforms/webexbot/commands/snapshot.d.ts +24 -0
  27. package/dist/src/platforms/webexbot/commands/snapshot.d.ts.map +1 -0
  28. package/dist/src/platforms/webexbot/commands/snapshot.js +37 -0
  29. package/dist/src/platforms/webexbot/commands/snapshot.js.map +1 -0
  30. package/dist/src/platforms/webexbot/commands/user.d.ts +30 -0
  31. package/dist/src/platforms/webexbot/commands/user.d.ts.map +1 -0
  32. package/dist/src/platforms/webexbot/commands/user.js +66 -0
  33. package/dist/src/platforms/webexbot/commands/user.js.map +1 -0
  34. package/docs/content/docs/cli/webexbot.mdx +2 -0
  35. package/docs/content/docs/sdk/webexbot.mdx +2 -0
  36. package/package.json +1 -1
  37. package/skills/agent-channeltalk/SKILL.md +1 -1
  38. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  39. package/skills/agent-discord/SKILL.md +1 -1
  40. package/skills/agent-discordbot/SKILL.md +1 -1
  41. package/skills/agent-instagram/SKILL.md +1 -1
  42. package/skills/agent-kakaotalk/SKILL.md +1 -1
  43. package/skills/agent-line/SKILL.md +1 -1
  44. package/skills/agent-slack/SKILL.md +1 -1
  45. package/skills/agent-slackbot/SKILL.md +1 -1
  46. package/skills/agent-teams/SKILL.md +1 -1
  47. package/skills/agent-telegram/SKILL.md +1 -1
  48. package/skills/agent-telegrambot/SKILL.md +1 -1
  49. package/skills/agent-webex/SKILL.md +1 -1
  50. package/skills/agent-webexbot/SKILL.md +58 -5
  51. package/skills/agent-webexbot/references/common-patterns.md +118 -0
  52. package/skills/agent-wechatbot/SKILL.md +1 -1
  53. package/skills/agent-whatsapp/SKILL.md +1 -1
  54. package/skills/agent-whatsappbot/SKILL.md +1 -1
  55. package/src/platforms/webex/client.test.ts +10 -0
  56. package/src/platforms/webex/client.ts +97 -3
  57. package/src/platforms/webexbot/cli.ts +6 -0
  58. package/src/platforms/webexbot/client.test.ts +198 -0
  59. package/src/platforms/webexbot/client.ts +29 -3
  60. package/src/platforms/webexbot/commands/file.ts +104 -0
  61. package/src/platforms/webexbot/commands/index.ts +3 -0
  62. package/src/platforms/webexbot/commands/message.ts +68 -2
  63. package/src/platforms/webexbot/commands/snapshot.ts +60 -0
  64. package/src/platforms/webexbot/commands/user.test.ts +77 -0
  65. 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
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-wechatbot
3
3
  description: Interact with WeChat Official Account using API credentials - send messages, manage templates, list followers
4
- version: 2.21.0
4
+ version: 2.22.0
5
5
  allowed-tools: Bash(agent-wechatbot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-whatsapp
3
3
  description: Interact with WhatsApp - send messages, read chats, manage conversations
4
- version: 2.21.0
4
+ version: 2.22.0
5
5
  allowed-tools: Bash(agent-whatsapp:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-whatsappbot
3
3
  description: Interact with WhatsApp using Cloud API credentials - send messages, manage templates
4
- version: 2.21.0
4
+ version: 2.22.0
5
5
  allowed-tools: Bash(agent-whatsappbot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -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(roomId: string, text: string, options?: { markdown?: boolean }): Promise<WebexMessage> {
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 = options?.markdown ? { roomId, markdown: text } : { roomId, text }
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(roomId: string, options?: { max?: number }): Promise<WebexMessage[]> {
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(roomId: string, text: string, options?: { markdown?: boolean }): Promise<WebexMessage> {
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
- return this.client.listMessages(roomId, options)
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
  }