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.
Files changed (134) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +21 -0
  3. package/dist/package.json +1 -1
  4. package/dist/src/platforms/webex/client.d.ts +25 -0
  5. package/dist/src/platforms/webex/client.d.ts.map +1 -1
  6. package/dist/src/platforms/webex/client.js +115 -5
  7. package/dist/src/platforms/webex/client.js.map +1 -1
  8. package/dist/src/platforms/webex/commands/auth.d.ts +9 -1
  9. package/dist/src/platforms/webex/commands/auth.d.ts.map +1 -1
  10. package/dist/src/platforms/webex/commands/auth.js +141 -25
  11. package/dist/src/platforms/webex/commands/auth.js.map +1 -1
  12. package/dist/src/platforms/webex/credential-manager.d.ts.map +1 -1
  13. package/dist/src/platforms/webex/credential-manager.js +8 -4
  14. package/dist/src/platforms/webex/credential-manager.js.map +1 -1
  15. package/dist/src/platforms/webex/id-normalizer.d.ts +19 -0
  16. package/dist/src/platforms/webex/id-normalizer.d.ts.map +1 -0
  17. package/dist/src/platforms/webex/id-normalizer.js +60 -0
  18. package/dist/src/platforms/webex/id-normalizer.js.map +1 -0
  19. package/dist/src/platforms/webex/index.d.ts +4 -0
  20. package/dist/src/platforms/webex/index.d.ts.map +1 -1
  21. package/dist/src/platforms/webex/index.js +2 -0
  22. package/dist/src/platforms/webex/index.js.map +1 -1
  23. package/dist/src/platforms/webex/listener.d.ts +61 -0
  24. package/dist/src/platforms/webex/listener.d.ts.map +1 -0
  25. package/dist/src/platforms/webex/listener.js +222 -0
  26. package/dist/src/platforms/webex/listener.js.map +1 -0
  27. package/dist/src/platforms/webex/password-login.d.ts +18 -0
  28. package/dist/src/platforms/webex/password-login.d.ts.map +1 -0
  29. package/dist/src/platforms/webex/password-login.js +259 -0
  30. package/dist/src/platforms/webex/password-login.js.map +1 -0
  31. package/dist/src/platforms/webex/types.d.ts +2 -1
  32. package/dist/src/platforms/webex/types.d.ts.map +1 -1
  33. package/dist/src/platforms/webex/types.js +1 -1
  34. package/dist/src/platforms/webex/types.js.map +1 -1
  35. package/dist/src/platforms/webex/wdm-discovery.d.ts.map +1 -0
  36. package/dist/src/platforms/{webexbot → webex}/wdm-discovery.js +3 -3
  37. package/dist/src/platforms/webex/wdm-discovery.js.map +1 -0
  38. package/dist/src/platforms/webexbot/cli.d.ts.map +1 -1
  39. package/dist/src/platforms/webexbot/cli.js +4 -1
  40. package/dist/src/platforms/webexbot/cli.js.map +1 -1
  41. package/dist/src/platforms/webexbot/client.d.ts +24 -0
  42. package/dist/src/platforms/webexbot/client.d.ts.map +1 -1
  43. package/dist/src/platforms/webexbot/client.js +81 -5
  44. package/dist/src/platforms/webexbot/client.js.map +1 -1
  45. package/dist/src/platforms/webexbot/commands/file.d.ts +22 -0
  46. package/dist/src/platforms/webexbot/commands/file.d.ts.map +1 -0
  47. package/dist/src/platforms/webexbot/commands/file.js +64 -0
  48. package/dist/src/platforms/webexbot/commands/file.js.map +1 -0
  49. package/dist/src/platforms/webexbot/commands/index.d.ts +3 -0
  50. package/dist/src/platforms/webexbot/commands/index.d.ts.map +1 -1
  51. package/dist/src/platforms/webexbot/commands/index.js +3 -0
  52. package/dist/src/platforms/webexbot/commands/index.js.map +1 -1
  53. package/dist/src/platforms/webexbot/commands/message.d.ts +7 -0
  54. package/dist/src/platforms/webexbot/commands/message.d.ts.map +1 -1
  55. package/dist/src/platforms/webexbot/commands/message.js +52 -1
  56. package/dist/src/platforms/webexbot/commands/message.js.map +1 -1
  57. package/dist/src/platforms/webexbot/commands/snapshot.d.ts +24 -0
  58. package/dist/src/platforms/webexbot/commands/snapshot.d.ts.map +1 -0
  59. package/dist/src/platforms/webexbot/commands/snapshot.js +37 -0
  60. package/dist/src/platforms/webexbot/commands/snapshot.js.map +1 -0
  61. package/dist/src/platforms/webexbot/commands/user.d.ts +30 -0
  62. package/dist/src/platforms/webexbot/commands/user.d.ts.map +1 -0
  63. package/dist/src/platforms/webexbot/commands/user.js +66 -0
  64. package/dist/src/platforms/webexbot/commands/user.js.map +1 -0
  65. package/dist/src/platforms/webexbot/index.d.ts +2 -0
  66. package/dist/src/platforms/webexbot/index.d.ts.map +1 -1
  67. package/dist/src/platforms/webexbot/index.js +1 -0
  68. package/dist/src/platforms/webexbot/index.js.map +1 -1
  69. package/dist/src/platforms/webexbot/listener.d.ts +3 -41
  70. package/dist/src/platforms/webexbot/listener.d.ts.map +1 -1
  71. package/dist/src/platforms/webexbot/listener.js +13 -208
  72. package/dist/src/platforms/webexbot/listener.js.map +1 -1
  73. package/dist/src/platforms/webexbot/types.d.ts +1 -18
  74. package/dist/src/platforms/webexbot/types.d.ts.map +1 -1
  75. package/dist/src/platforms/webexbot/types.js.map +1 -1
  76. package/docs/content/docs/cli/webex.mdx +38 -12
  77. package/docs/content/docs/cli/webexbot.mdx +2 -0
  78. package/docs/content/docs/sdk/webexbot.mdx +18 -0
  79. package/package.json +1 -1
  80. package/skills/agent-channeltalk/SKILL.md +1 -1
  81. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  82. package/skills/agent-discord/SKILL.md +1 -1
  83. package/skills/agent-discordbot/SKILL.md +1 -1
  84. package/skills/agent-instagram/SKILL.md +1 -1
  85. package/skills/agent-kakaotalk/SKILL.md +1 -1
  86. package/skills/agent-line/SKILL.md +1 -1
  87. package/skills/agent-slack/SKILL.md +1 -1
  88. package/skills/agent-slackbot/SKILL.md +1 -1
  89. package/skills/agent-teams/SKILL.md +1 -1
  90. package/skills/agent-telegram/SKILL.md +1 -1
  91. package/skills/agent-telegrambot/SKILL.md +1 -1
  92. package/skills/agent-webex/SKILL.md +76 -22
  93. package/skills/agent-webex/references/authentication.md +55 -14
  94. package/skills/agent-webex/references/common-patterns.md +5 -2
  95. package/skills/agent-webexbot/SKILL.md +60 -5
  96. package/skills/agent-webexbot/references/common-patterns.md +118 -0
  97. package/skills/agent-wechatbot/SKILL.md +1 -1
  98. package/skills/agent-whatsapp/SKILL.md +1 -1
  99. package/skills/agent-whatsappbot/SKILL.md +1 -1
  100. package/src/platforms/webex/cli.test.ts +31 -1
  101. package/src/platforms/webex/client.test.ts +67 -0
  102. package/src/platforms/webex/client.ts +136 -7
  103. package/src/platforms/webex/commands/auth.test.ts +189 -28
  104. package/src/platforms/webex/commands/auth.ts +194 -35
  105. package/src/platforms/webex/credential-manager.test.ts +40 -0
  106. package/src/platforms/webex/credential-manager.ts +7 -4
  107. package/src/platforms/webex/id-normalizer.test.ts +207 -0
  108. package/src/platforms/webex/id-normalizer.ts +76 -0
  109. package/src/platforms/webex/index.test.ts +6 -0
  110. package/src/platforms/webex/index.ts +4 -0
  111. package/src/platforms/webex/listener.test.ts +243 -0
  112. package/src/platforms/webex/listener.ts +285 -0
  113. package/src/platforms/webex/password-login.test.ts +193 -0
  114. package/src/platforms/webex/password-login.ts +332 -0
  115. package/src/platforms/webex/types.test.ts +16 -0
  116. package/src/platforms/webex/types.ts +2 -2
  117. package/src/platforms/{webexbot → webex}/wdm-discovery.ts +3 -3
  118. package/src/platforms/webexbot/cli.ts +6 -0
  119. package/src/platforms/webexbot/client.test.ts +322 -0
  120. package/src/platforms/webexbot/client.ts +104 -7
  121. package/src/platforms/webexbot/commands/file.ts +104 -0
  122. package/src/platforms/webexbot/commands/index.ts +3 -0
  123. package/src/platforms/webexbot/commands/message.ts +68 -2
  124. package/src/platforms/webexbot/commands/snapshot.ts +60 -0
  125. package/src/platforms/webexbot/commands/user.test.ts +77 -0
  126. package/src/platforms/webexbot/commands/user.ts +98 -0
  127. package/src/platforms/webexbot/index.ts +2 -0
  128. package/src/platforms/webexbot/listener.test.ts +37 -224
  129. package/src/platforms/webexbot/listener.ts +18 -250
  130. package/src/platforms/webexbot/types.ts +2 -23
  131. package/dist/src/platforms/webexbot/wdm-discovery.d.ts.map +0 -1
  132. package/dist/src/platforms/webexbot/wdm-discovery.js.map +0 -1
  133. /package/dist/src/platforms/{webexbot → webex}/wdm-discovery.d.ts +0 -0
  134. /package/src/platforms/{webexbot → webex}/wdm-discovery.test.ts +0 -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.23.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.23.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.23.0
5
5
  allowed-tools: Bash(agent-whatsappbot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -20,6 +20,21 @@ describe('Webex CLI program structure', () => {
20
20
  expect(output).toContain('space')
21
21
  })
22
22
 
23
+ it('auth --help lists login and oauth subcommands', async () => {
24
+ const proc = spawn(['bun', 'run', './src/platforms/webex/cli.ts', 'auth', '--help'], {
25
+ cwd: process.cwd(),
26
+ stdio: ['pipe', 'pipe', 'pipe'],
27
+ })
28
+
29
+ const output = await new Response(proc.stdout).text()
30
+
31
+ expect(output).toContain('login')
32
+ expect(output).toContain('oauth')
33
+ expect(output).toContain('extract')
34
+ expect(output).toContain('status')
35
+ expect(output).toContain('logout')
36
+ })
37
+
23
38
  it('--version shows package version', async () => {
24
39
  const proc = spawn(['bun', 'run', './src/platforms/webex/cli.ts', '--version'], {
25
40
  cwd: process.cwd(),
@@ -30,7 +45,7 @@ describe('Webex CLI program structure', () => {
30
45
  expect(output.trim()).toBe(pkg.version)
31
46
  })
32
47
 
33
- it('auth login --help shows Device Grant options', async () => {
48
+ it('auth login --help shows email/password options', async () => {
34
49
  const proc = spawn(['bun', 'run', './src/platforms/webex/cli.ts', 'auth', 'login', '--help'], {
35
50
  cwd: process.cwd(),
36
51
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -38,7 +53,22 @@ describe('Webex CLI program structure', () => {
38
53
 
39
54
  const output = await new Response(proc.stdout).text()
40
55
 
56
+ expect(output).toContain('--email')
57
+ expect(output).toContain('--password')
58
+ expect(output).toContain('--password-stdin')
41
59
  expect(output).toContain('--token')
60
+ expect(output).toContain('--pretty')
61
+ })
62
+
63
+ it('auth oauth --help shows Device Grant options', async () => {
64
+ const proc = spawn(['bun', 'run', './src/platforms/webex/cli.ts', 'auth', 'oauth', '--help'], {
65
+ cwd: process.cwd(),
66
+ stdio: ['pipe', 'pipe', 'pipe'],
67
+ })
68
+
69
+ const output = await new Response(proc.stdout).text()
70
+
71
+ expect(output).toContain('--device-code')
42
72
  expect(output).toContain('--client-id')
43
73
  expect(output).toContain('--client-secret')
44
74
  expect(output).toContain('--pretty')
@@ -60,6 +60,16 @@ describe('WebexClient', () => {
60
60
  await expect(new WebexClient().login({ token: '' })).rejects.toThrow('Token is required')
61
61
  })
62
62
 
63
+ it('returns the authenticated token', async () => {
64
+ const client = await new WebexClient().login({ token: 'test-token' })
65
+
66
+ expect(client.getToken()).toBe('test-token')
67
+ })
68
+
69
+ it('throws when reading token before login', () => {
70
+ expect(() => new WebexClient().getToken()).toThrow('Not authenticated. Call .login() first.')
71
+ })
72
+
63
73
  it('accepts deviceUrl and tokenType', async () => {
64
74
  const client = await new WebexClient().login({
65
75
  token: 'test-token',
@@ -191,6 +201,53 @@ describe('WebexClient', () => {
191
201
  })
192
202
  })
193
203
 
204
+ describe('iterateSpaces', () => {
205
+ it('follows the Link header across pages and yields every room', async () => {
206
+ // given two pages chained by a rel="next" Link header
207
+ mockResponse({ items: [{ id: 'room1', title: 'One', type: 'group' }] }, 200, {
208
+ Link: '<https://webexapis.com/v1/rooms?max=1000&before=cursor1>; rel="next"',
209
+ })
210
+ mockResponse({ items: [{ id: 'room2', title: 'Two', type: 'group' }] })
211
+
212
+ const client = await new WebexClient().login({ token: 'test-token' })
213
+ const ids: string[] = []
214
+ for await (const room of client.iterateSpaces({ max: 1000 })) {
215
+ ids.push(room.id)
216
+ }
217
+
218
+ expect(ids).toEqual(['room1', 'room2'])
219
+ expect(fetchCalls[0].url).toContain('/rooms?max=1000')
220
+ expect(fetchCalls[1].url).toContain('before=cursor1')
221
+ })
222
+
223
+ it('stops after a single page when no next Link is present', async () => {
224
+ mockResponse({ items: [{ id: 'room1', title: 'One', type: 'group' }] })
225
+
226
+ const client = await new WebexClient().login({ token: 'test-token' })
227
+ const ids: string[] = []
228
+ for await (const room of client.iterateSpaces()) {
229
+ ids.push(room.id)
230
+ }
231
+
232
+ expect(ids).toEqual(['room1'])
233
+ expect(fetchCalls).toHaveLength(1)
234
+ })
235
+
236
+ it('stops consuming the generator early without fetching the next page', async () => {
237
+ mockResponse({ items: [{ id: 'room1', title: 'One', type: 'group' }] }, 200, {
238
+ Link: '<https://webexapis.com/v1/rooms?max=1000&before=cursor1>; rel="next"',
239
+ })
240
+
241
+ const client = await new WebexClient().login({ token: 'test-token' })
242
+ for await (const room of client.iterateSpaces({ max: 1000 })) {
243
+ expect(room.id).toBe('room1')
244
+ break
245
+ }
246
+
247
+ expect(fetchCalls).toHaveLength(1)
248
+ })
249
+ })
250
+
194
251
  describe('getSpace', () => {
195
252
  it('calls GET /rooms/{spaceId}', async () => {
196
253
  mockResponse({ id: 'room1', title: 'Test Room', type: 'group' })
@@ -274,6 +331,16 @@ describe('WebexClient', () => {
274
331
 
275
332
  expect(fetchCalls[0].url).toContain('max=10')
276
333
  })
334
+
335
+ it('passes mentionedPeople when requested', async () => {
336
+ mockResponse({ items: [] })
337
+
338
+ const client = await new WebexClient().login({ token: 'test-token' })
339
+ await client.listMessages('room1', { max: 10, mentionedPeople: 'me' })
340
+
341
+ const url = new URL(fetchCalls[0].url)
342
+ expect(url.searchParams.get('mentionedPeople')).toBe('me')
343
+ })
277
344
  })
278
345
 
279
346
  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
 
@@ -45,7 +46,7 @@ export class WebexClient {
45
46
  this.tokenType = config?.tokenType ?? null
46
47
  await this.login({ token })
47
48
 
48
- if (this.tokenType === 'extracted') {
49
+ if (this.tokenType === 'extracted' || this.tokenType === 'password') {
49
50
  const keysMap = new Map(Object.entries(config?.encryptionKeys ?? {}))
50
51
  this.encryption = new WebexEncryptionService(keysMap)
51
52
  const kmsProvider = new KmsKeyProvider({ token })
@@ -68,6 +69,10 @@ export class WebexClient {
68
69
  await this.encryption?.close()
69
70
  }
70
71
 
72
+ getToken(): string {
73
+ return this.ensureAuth()
74
+ }
75
+
71
76
  private async persistEncryptionKey(
72
77
  credManager: WebexCredentialManager,
73
78
  keyUri: string,
@@ -130,6 +135,14 @@ export class WebexClient {
130
135
  }
131
136
 
132
137
  private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
138
+ return (await this.requestWithLink<T>(method, path, body)).data
139
+ }
140
+
141
+ private async requestWithLink<T>(
142
+ method: string,
143
+ path: string,
144
+ body?: unknown,
145
+ ): Promise<{ data: T; nextPath: string | null }> {
133
146
  const url = `${BASE_URL}${path}`
134
147
  const bucketKey = this.getBucketKey(method, path)
135
148
 
@@ -178,10 +191,11 @@ export class WebexClient {
178
191
  }
179
192
 
180
193
  if (response.status === 204) {
181
- return undefined as T
194
+ return { data: undefined as T, nextPath: null }
182
195
  }
183
196
 
184
- return response.json() as Promise<T>
197
+ const data = (await response.json()) as T
198
+ return { data, nextPath: parseNextPath(response.headers.get('Link')) }
185
199
  }
186
200
 
187
201
  throw new WebexError('Request failed after retries', 'max_retries')
@@ -219,20 +233,40 @@ export class WebexClient {
219
233
  return data.items
220
234
  }
221
235
 
236
+ async *iterateSpaces(options?: { type?: string; max?: number }): AsyncGenerator<WebexSpace> {
237
+ const params = new URLSearchParams()
238
+ if (options?.type) params.set('type', options.type)
239
+ params.set('max', String(options?.max ?? 100))
240
+ let path: string | null = `/rooms?${params.toString()}`
241
+ while (path) {
242
+ const page: { data: { items: WebexSpace[] }; nextPath: string | null } = await this.requestWithLink('GET', path)
243
+ yield* page.data.items
244
+ path = page.nextPath
245
+ }
246
+ }
247
+
222
248
  async getSpace(spaceId: string): Promise<WebexSpace> {
223
249
  return this.request<WebexSpace>('GET', `/rooms/${spaceId}`)
224
250
  }
225
251
 
226
- async sendMessage(roomId: string, text: string, options?: { markdown?: boolean }): Promise<WebexMessage> {
252
+ async sendMessage(
253
+ roomId: string,
254
+ text: string,
255
+ options?: { markdown?: boolean; parentId?: string; files?: string[] },
256
+ ): Promise<WebexMessage> {
227
257
  if (this.useInternalAPI) {
228
258
  return this.sendMessageInternal(roomId, text, options)
229
259
  }
230
- const body = options?.markdown ? { roomId, markdown: text } : { roomId, text }
260
+ const body: Record<string, unknown> = { roomId }
261
+ if (options?.markdown) body.markdown = text
262
+ else body.text = text
263
+ if (options?.parentId) body.parentId = options.parentId
264
+ if (options?.files?.length) body.files = options.files
231
265
  return this.request<WebexMessage>('POST', '/messages', body)
232
266
  }
233
267
 
234
268
  private get useInternalAPI(): boolean {
235
- return this.tokenType === 'extracted' && this.deviceUrl !== null
269
+ return (this.tokenType === 'extracted' || this.tokenType === 'password') && this.deviceUrl !== null
236
270
  }
237
271
 
238
272
  private get convBaseUrl(): string {
@@ -387,7 +421,10 @@ export class WebexClient {
387
421
  return null
388
422
  }
389
423
 
390
- async listMessages(roomId: string, options?: { max?: number }): Promise<WebexMessage[]> {
424
+ async listMessages(
425
+ roomId: string,
426
+ options?: { max?: number; mentionedPeople?: string; parentId?: string },
427
+ ): Promise<WebexMessage[]> {
391
428
  if (this.useInternalAPI) {
392
429
  const convUuid = this.decodeConvUuid(roomId)
393
430
  const max = options?.max ?? 50
@@ -400,6 +437,8 @@ export class WebexClient {
400
437
  const params = new URLSearchParams()
401
438
  params.set('roomId', roomId)
402
439
  params.set('max', String(options?.max ?? 50))
440
+ if (options?.mentionedPeople) params.set('mentionedPeople', options.mentionedPeople)
441
+ if (options?.parentId) params.set('parentId', options.parentId)
403
442
  const data = await this.request<{ items: WebexMessage[] }>('GET', `/messages?${params}`)
404
443
  return data.items
405
444
  }
@@ -493,6 +532,10 @@ export class WebexClient {
493
532
  return data.items
494
533
  }
495
534
 
535
+ async getPerson(personId: string): Promise<WebexPerson> {
536
+ return this.request<WebexPerson>('GET', `/people/${personId}`)
537
+ }
538
+
496
539
  async listMyMemberships(options?: { max?: number }): Promise<WebexMembership[]> {
497
540
  const params = new URLSearchParams()
498
541
  params.set('max', String(options?.max ?? 100))
@@ -507,6 +550,92 @@ export class WebexClient {
507
550
  const data = await this.request<{ items: WebexMembership[] }>('GET', `/memberships?${params}`)
508
551
  return data.items
509
552
  }
553
+
554
+ async uploadFile(
555
+ roomId: string,
556
+ file: { content: Blob; filename: string },
557
+ options?: { text?: string; markdown?: boolean; parentId?: string },
558
+ ): Promise<WebexMessage> {
559
+ const form = new FormData()
560
+ form.set('roomId', roomId)
561
+ if (options?.text) {
562
+ form.set(options.markdown ? 'markdown' : 'text', options.text)
563
+ }
564
+ if (options?.parentId) form.set('parentId', options.parentId)
565
+ form.set('files', file.content, file.filename)
566
+
567
+ const response = await fetch(`${BASE_URL}/messages`, {
568
+ method: 'POST',
569
+ headers: { Authorization: `Bearer ${this.ensureAuth()}` },
570
+ body: form,
571
+ })
572
+
573
+ if (!response.ok) {
574
+ const errorBody = (await response.json().catch(() => null)) as { message?: string } | null
575
+ throw new WebexError(errorBody?.message ?? `HTTP ${response.status}`, `http_${response.status}`)
576
+ }
577
+ return response.json() as Promise<WebexMessage>
578
+ }
579
+
580
+ async downloadContent(contentRef: string): Promise<{ data: ArrayBuffer; filename: string; contentType: string }> {
581
+ const url = this.resolveContentUrl(contentRef)
582
+ const response = await fetch(url, {
583
+ headers: { Authorization: `Bearer ${this.ensureAuth()}` },
584
+ })
585
+
586
+ if (!response.ok) {
587
+ const errorBody = (await response.json().catch(() => null)) as { message?: string } | null
588
+ throw new WebexError(errorBody?.message ?? `HTTP ${response.status}`, `http_${response.status}`)
589
+ }
590
+
591
+ const disposition = response.headers.get('Content-Disposition') ?? ''
592
+ const match = disposition.match(/filename="?([^"]+)"?/)
593
+ const filename = sanitizeFilename(match?.[1]) ?? sanitizeFilename(contentRef.split('/').pop()) ?? 'download'
594
+ const contentType = response.headers.get('Content-Type') ?? 'application/octet-stream'
595
+ const data = await response.arrayBuffer()
596
+ return { data, filename, contentType }
597
+ }
598
+
599
+ private resolveContentUrl(contentRef: string): string {
600
+ // A bare content id never contains a scheme or path separators.
601
+ if (!contentRef.includes('://') && !contentRef.includes('/')) {
602
+ return `${BASE_URL}/contents/${encodeURIComponent(contentRef)}`
603
+ }
604
+
605
+ // Only attach the bearer token to HTTPS Webex content URLs to avoid
606
+ // leaking credentials to attacker-controlled hosts (SSRF/token exfiltration).
607
+ let parsed: URL
608
+ try {
609
+ parsed = new URL(contentRef)
610
+ } catch {
611
+ throw new WebexError(`Invalid content reference: ${contentRef}`, 'invalid_content_ref')
612
+ }
613
+ if (parsed.protocol !== 'https:' || parsed.host !== CONTENT_HOST || !parsed.pathname.startsWith('/v1/contents/')) {
614
+ throw new WebexError(
615
+ `Refusing to download from untrusted location: ${parsed.origin}${parsed.pathname}`,
616
+ 'untrusted_content_url',
617
+ )
618
+ }
619
+ return parsed.toString()
620
+ }
621
+ }
622
+
623
+ // Webex paginates List endpoints via an RFC 5988 `Link` header
624
+ // (`<https://webexapis.com/v1/rooms?...&before=cursor>; rel="next"`). Return the
625
+ // next page as a BASE_URL-relative path, or null when there is no next page.
626
+ function parseNextPath(linkHeader: string | null): string | null {
627
+ if (!linkHeader) return null
628
+ const next = linkHeader.match(/<([^>]+)>\s*;\s*rel="next"/i)
629
+ if (!next) return null
630
+ return next[1].startsWith(BASE_URL) ? next[1].slice(BASE_URL.length) : null
631
+ }
632
+
633
+ function sanitizeFilename(name: string | undefined): string | undefined {
634
+ if (!name) return undefined
635
+ // Strip any path components so a server-supplied name cannot escape the target directory.
636
+ const base = name.replace(/\\/g, '/').split('/').pop()
637
+ if (!base || base === '.' || base === '..') return undefined
638
+ return base
510
639
  }
511
640
 
512
641
  interface InternalActivity {