agent-messenger 2.23.1 → 2.23.3

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 (130) 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 +18 -0
  4. package/dist/src/platforms/webex/client.d.ts.map +1 -1
  5. package/dist/src/platforms/webex/client.js +202 -49
  6. package/dist/src/platforms/webex/client.js.map +1 -1
  7. package/dist/src/platforms/webex/commands/auth.d.ts.map +1 -1
  8. package/dist/src/platforms/webex/commands/auth.js +9 -6
  9. package/dist/src/platforms/webex/commands/auth.js.map +1 -1
  10. package/dist/src/platforms/webex/commands/member.d.ts.map +1 -1
  11. package/dist/src/platforms/webex/commands/member.js +2 -0
  12. package/dist/src/platforms/webex/commands/member.js.map +1 -1
  13. package/dist/src/platforms/webex/commands/message.d.ts.map +1 -1
  14. package/dist/src/platforms/webex/commands/message.js +2 -0
  15. package/dist/src/platforms/webex/commands/message.js.map +1 -1
  16. package/dist/src/platforms/webex/commands/snapshot.d.ts.map +1 -1
  17. package/dist/src/platforms/webex/commands/snapshot.js +3 -1
  18. package/dist/src/platforms/webex/commands/snapshot.js.map +1 -1
  19. package/dist/src/platforms/webex/commands/space.d.ts.map +1 -1
  20. package/dist/src/platforms/webex/commands/space.js +5 -0
  21. package/dist/src/platforms/webex/commands/space.js.map +1 -1
  22. package/dist/src/platforms/webex/commands/whoami.d.ts.map +1 -1
  23. package/dist/src/platforms/webex/commands/whoami.js +2 -0
  24. package/dist/src/platforms/webex/commands/whoami.js.map +1 -1
  25. package/dist/src/platforms/webex/id-normalizer.d.ts +11 -0
  26. package/dist/src/platforms/webex/id-normalizer.d.ts.map +1 -1
  27. package/dist/src/platforms/webex/id-normalizer.js +102 -20
  28. package/dist/src/platforms/webex/id-normalizer.js.map +1 -1
  29. package/dist/src/platforms/webex/index.d.ts +2 -2
  30. package/dist/src/platforms/webex/index.d.ts.map +1 -1
  31. package/dist/src/platforms/webex/index.js +1 -1
  32. package/dist/src/platforms/webex/index.js.map +1 -1
  33. package/dist/src/platforms/webex/types.d.ts +20 -0
  34. package/dist/src/platforms/webex/types.d.ts.map +1 -1
  35. package/dist/src/platforms/webex/types.js +10 -0
  36. package/dist/src/platforms/webex/types.js.map +1 -1
  37. package/dist/src/platforms/webexbot/client.d.ts +0 -4
  38. package/dist/src/platforms/webexbot/client.d.ts.map +1 -1
  39. package/dist/src/platforms/webexbot/client.js +8 -65
  40. package/dist/src/platforms/webexbot/client.js.map +1 -1
  41. package/dist/src/platforms/webexbot/commands/file.d.ts +2 -0
  42. package/dist/src/platforms/webexbot/commands/file.d.ts.map +1 -1
  43. package/dist/src/platforms/webexbot/commands/file.js +2 -0
  44. package/dist/src/platforms/webexbot/commands/file.js.map +1 -1
  45. package/dist/src/platforms/webexbot/commands/member.d.ts +2 -0
  46. package/dist/src/platforms/webexbot/commands/member.d.ts.map +1 -1
  47. package/dist/src/platforms/webexbot/commands/member.js +2 -0
  48. package/dist/src/platforms/webexbot/commands/member.js.map +1 -1
  49. package/dist/src/platforms/webexbot/commands/message.d.ts +4 -0
  50. package/dist/src/platforms/webexbot/commands/message.d.ts.map +1 -1
  51. package/dist/src/platforms/webexbot/commands/message.js +6 -0
  52. package/dist/src/platforms/webexbot/commands/message.js.map +1 -1
  53. package/dist/src/platforms/webexbot/commands/snapshot.d.ts +2 -0
  54. package/dist/src/platforms/webexbot/commands/snapshot.d.ts.map +1 -1
  55. package/dist/src/platforms/webexbot/commands/snapshot.js +10 -2
  56. package/dist/src/platforms/webexbot/commands/snapshot.js.map +1 -1
  57. package/dist/src/platforms/webexbot/commands/space.d.ts +4 -0
  58. package/dist/src/platforms/webexbot/commands/space.d.ts.map +1 -1
  59. package/dist/src/platforms/webexbot/commands/space.js +5 -0
  60. package/dist/src/platforms/webexbot/commands/space.js.map +1 -1
  61. package/dist/src/platforms/webexbot/commands/user.d.ts +3 -0
  62. package/dist/src/platforms/webexbot/commands/user.d.ts.map +1 -1
  63. package/dist/src/platforms/webexbot/commands/user.js +3 -0
  64. package/dist/src/platforms/webexbot/commands/user.js.map +1 -1
  65. package/dist/src/platforms/webexbot/commands/whoami.d.ts +2 -0
  66. package/dist/src/platforms/webexbot/commands/whoami.d.ts.map +1 -1
  67. package/dist/src/platforms/webexbot/commands/whoami.js +2 -0
  68. package/dist/src/platforms/webexbot/commands/whoami.js.map +1 -1
  69. package/dist/src/platforms/webexbot/index.d.ts +2 -2
  70. package/dist/src/platforms/webexbot/index.d.ts.map +1 -1
  71. package/dist/src/platforms/webexbot/index.js +1 -1
  72. package/dist/src/platforms/webexbot/index.js.map +1 -1
  73. package/dist/src/tui/adapters/types.d.ts +3 -0
  74. package/dist/src/tui/adapters/types.d.ts.map +1 -1
  75. package/dist/src/tui/adapters/webex-adapter.d.ts.map +1 -1
  76. package/dist/src/tui/adapters/webex-adapter.js +4 -0
  77. package/dist/src/tui/adapters/webex-adapter.js.map +1 -1
  78. package/docs/content/docs/cli/webex.mdx +2 -2
  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 +3 -3
  93. package/skills/agent-webexbot/SKILL.md +2 -2
  94. package/skills/agent-webexbot/references/common-patterns.md +1 -1
  95. package/skills/agent-wechatbot/SKILL.md +1 -1
  96. package/skills/agent-whatsapp/SKILL.md +1 -1
  97. package/skills/agent-whatsappbot/SKILL.md +1 -1
  98. package/src/platforms/webex/client.test.ts +94 -6
  99. package/src/platforms/webex/client.ts +226 -44
  100. package/src/platforms/webex/commands/auth.test.ts +3 -1
  101. package/src/platforms/webex/commands/auth.ts +12 -7
  102. package/src/platforms/webex/commands/member.test.ts +24 -8
  103. package/src/platforms/webex/commands/member.ts +2 -0
  104. package/src/platforms/webex/commands/message.test.ts +37 -23
  105. package/src/platforms/webex/commands/message.ts +2 -0
  106. package/src/platforms/webex/commands/snapshot.test.ts +18 -10
  107. package/src/platforms/webex/commands/snapshot.ts +3 -1
  108. package/src/platforms/webex/commands/space.test.ts +36 -17
  109. package/src/platforms/webex/commands/space.ts +5 -0
  110. package/src/platforms/webex/commands/whoami.test.ts +16 -6
  111. package/src/platforms/webex/commands/whoami.ts +2 -0
  112. package/src/platforms/webex/id-normalizer.test.ts +282 -2
  113. package/src/platforms/webex/id-normalizer.ts +112 -20
  114. package/src/platforms/webex/index.ts +2 -2
  115. package/src/platforms/webex/listener.test.ts +3 -0
  116. package/src/platforms/webex/types.test.ts +20 -0
  117. package/src/platforms/webex/types.ts +20 -0
  118. package/src/platforms/webex/typings/webex-message-handler.d.ts +40 -2
  119. package/src/platforms/webexbot/client.ts +8 -74
  120. package/src/platforms/webexbot/commands/file.ts +4 -0
  121. package/src/platforms/webexbot/commands/member.ts +4 -0
  122. package/src/platforms/webexbot/commands/message.ts +10 -0
  123. package/src/platforms/webexbot/commands/snapshot.ts +12 -2
  124. package/src/platforms/webexbot/commands/space.ts +9 -0
  125. package/src/platforms/webexbot/commands/user.test.ts +15 -5
  126. package/src/platforms/webexbot/commands/user.ts +6 -0
  127. package/src/platforms/webexbot/commands/whoami.ts +4 -0
  128. package/src/platforms/webexbot/index.ts +2 -2
  129. package/src/tui/adapters/types.ts +3 -0
  130. package/src/tui/adapters/webex-adapter.ts +4 -0
@@ -1,9 +1,10 @@
1
- import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
1
+ import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'
2
2
 
3
3
  import * as jose from 'node-jose'
4
4
 
5
5
  import { WebexClient } from './client'
6
6
  import { WebexEncryptionService } from './encryption'
7
+ import { toRestId } from './id-normalizer'
7
8
  import { WebexError } from './types'
8
9
 
9
10
  describe('WebexClient', () => {
@@ -444,6 +445,92 @@ describe('WebexClient', () => {
444
445
  })
445
446
  })
446
447
 
448
+ describe('id ref resolution', () => {
449
+ const roomUuid = '12345678-1234-1234-1234-1234567890ab'
450
+ const personUuid = '22222222-2222-2222-2222-222222222222'
451
+ const messageUuid = '33333333-3333-3333-3333-333333333333'
452
+ const usRoomId = toRestId(roomUuid, 'ROOM')
453
+ const clusteredRoomId = Buffer.from(`ciscospark://urn:TEAM:us-west-2_r/ROOM/${roomUuid}`).toString('base64url')
454
+
455
+ const isRoomsList = (url: string) => new URL(url).pathname === '/v1/rooms'
456
+
457
+ it('resolves a bare room uuid to the real clustered id before sending', async () => {
458
+ mockResponse({ items: [{ id: clusteredRoomId, title: 'Team', type: 'group' }] })
459
+ mockResponse({ id: 'msg-1', roomId: clusteredRoomId, roomType: 'group' })
460
+
461
+ const client = await new WebexClient().login({ token: 'test-token' })
462
+ await client.sendMessage(roomUuid, 'hello')
463
+
464
+ expect(isRoomsList(fetchCalls[0].url)).toBe(true)
465
+ expect(JSON.parse(fetchCalls[1].options?.body as string).roomId).toBe(clusteredRoomId)
466
+ })
467
+
468
+ it('rewrites a us-cluster room id to the real clustered id for memberships', async () => {
469
+ mockResponse({ items: [{ id: clusteredRoomId, title: 'Team', type: 'group' }] })
470
+ mockResponse({ items: [{ id: 'm1', roomId: clusteredRoomId, personId: 'p1' }] })
471
+
472
+ const client = await new WebexClient().login({ token: 'test-token' })
473
+ await client.listMemberships(usRoomId)
474
+
475
+ const membershipsUrl = new URL(fetchCalls[1].url)
476
+ expect(membershipsUrl.pathname).toBe('/v1/memberships')
477
+ expect(membershipsUrl.searchParams.get('roomId')).toBe(clusteredRoomId)
478
+ })
479
+
480
+ it('passes an already-clustered room id through without a lookup', async () => {
481
+ mockResponse({ id: 'msg-1', roomId: clusteredRoomId, roomType: 'group' })
482
+
483
+ const client = await new WebexClient().login({ token: 'test-token' })
484
+ await client.sendMessage(clusteredRoomId, 'hello')
485
+
486
+ expect(fetchCalls).toHaveLength(1)
487
+ expect(JSON.parse(fetchCalls[0].options?.body as string).roomId).toBe(clusteredRoomId)
488
+ })
489
+
490
+ it('fails open to the reconstructed room id and warns when no room matches', async () => {
491
+ const warnSpy = spyOn(console, 'warn').mockImplementation(() => {})
492
+ try {
493
+ mockResponse({ items: [] })
494
+ mockResponse({ id: 'msg-1', roomId: usRoomId, roomType: 'group' })
495
+
496
+ const client = await new WebexClient().login({ token: 'test-token' })
497
+ await client.sendMessage(roomUuid, 'hello')
498
+
499
+ expect(JSON.parse(fetchCalls[1].options?.body as string).roomId).toBe(usRoomId)
500
+ expect(warnSpy).toHaveBeenCalled()
501
+ } finally {
502
+ warnSpy.mockRestore()
503
+ }
504
+ })
505
+
506
+ it('resolves a person email through the people search endpoint', async () => {
507
+ const personId = toRestId(personUuid, 'PEOPLE')
508
+ mockResponse({ items: [{ id: personId, emails: ['alice@example.com'], displayName: 'Alice', type: 'person' }] })
509
+
510
+ const client = await new WebexClient().login({ token: 'test-token' })
511
+ const person = await client.getPerson('alice@example.com')
512
+
513
+ expect(person.id).toBe(personId)
514
+ const url = new URL(fetchCalls[0].url)
515
+ expect(url.pathname).toBe('/v1/people')
516
+ expect(url.searchParams.get('email')).toBe('alice@example.com')
517
+ })
518
+
519
+ it('reconstructs bare person and message uuids for REST calls', async () => {
520
+ const personId = toRestId(personUuid, 'PEOPLE')
521
+ const messageId = toRestId(messageUuid, 'MESSAGE')
522
+ mockResponse({ id: personId, emails: ['alice@example.com'], displayName: 'Alice', type: 'person' })
523
+ mockResponse({ id: messageId, roomId: usRoomId, text: 'hi' })
524
+
525
+ const client = await new WebexClient().login({ token: 'test-token' })
526
+ await client.getPerson(personUuid)
527
+ await client.getMessage(messageUuid)
528
+
529
+ expect(fetchCalls[0].url).toBe(`https://webexapis.com/v1/people/${personId}`)
530
+ expect(fetchCalls[1].url).toBe(`https://webexapis.com/v1/messages/${messageId}`)
531
+ })
532
+ })
533
+
447
534
  describe('rate limiting', () => {
448
535
  it('retries on 429 with Retry-After header', async () => {
449
536
  mockResponse({ message: 'Rate limited' }, 429, { 'Retry-After': '0.1' })
@@ -519,6 +606,7 @@ describe('WebexClient', () => {
519
606
  const CONV_BASE = 'https://conv-r.wbx2.com/conversation/api/v1'
520
607
  const TEST_ROOM_ID = Buffer.from('ciscospark://urn:TEAM:us-west-2_r/ROOM/abc123-def456').toString('base64')
521
608
  const TEST_CONV_UUID = 'abc123-def456'
609
+ const TEST_ACTIVITY_ID = toRestId('activity-123', 'MESSAGE')
522
610
 
523
611
  const mockActivity = (text: string, overrides?: Partial<Record<string, unknown>>) => ({
524
612
  id: 'activity-123',
@@ -605,7 +693,7 @@ describe('WebexClient', () => {
605
693
  const client = await createExtractedClient()
606
694
  const message = await client.sendMessage(TEST_ROOM_ID, 'Hello world')
607
695
 
608
- expect(message.id).toBe('activity-123')
696
+ expect(message.id).toBe(TEST_ACTIVITY_ID)
609
697
  expect(message.text).toBe('Hello world')
610
698
  expect(message.personEmail).toBe('test@example.com')
611
699
  expect(message.created).toBe('2026-01-01T00:00:00.000Z')
@@ -670,7 +758,7 @@ describe('WebexClient', () => {
670
758
  const client = await createExtractedClient()
671
759
  const messages = await client.listMessages(TEST_ROOM_ID)
672
760
 
673
- expect(messages[0].id).toBe('activity-123')
761
+ expect(messages[0].id).toBe(TEST_ACTIVITY_ID)
674
762
  expect(messages[0].text).toBe('Hello')
675
763
  expect(messages[0].personEmail).toBe('test@example.com')
676
764
  expect(messages[0].created).toBe('2026-01-01T00:00:00.000Z')
@@ -713,7 +801,7 @@ describe('WebexClient', () => {
713
801
  const client = await createExtractedClient()
714
802
  const message = await client.getMessage('activity-123')
715
803
 
716
- expect(message.id).toBe('activity-123')
804
+ expect(message.id).toBe(TEST_ACTIVITY_ID)
717
805
  expect(message.text).toBe('Hello')
718
806
  expect(message.personEmail).toBe('test@example.com')
719
807
  })
@@ -843,7 +931,7 @@ describe('WebexClient', () => {
843
931
 
844
932
  const client = await createExtractedClient()
845
933
  const message = await client.editMessage('activity-123', TEST_ROOM_ID, 'Edited text')
846
- expect(message.id).toBe('activity-123')
934
+ expect(message.id).toBe(TEST_ACTIVITY_ID)
847
935
  })
848
936
 
849
937
  it('throws when server returns activity linked to a different parent', async () => {
@@ -916,7 +1004,7 @@ describe('WebexClient', () => {
916
1004
  expect(fetchCalls[0].url).toContain('/rooms?type=direct&max=100')
917
1005
  expect(fetchCalls[1].url).toContain('/memberships?roomId=')
918
1006
  expect(fetchCalls[2].url).toBe(`${CONV_BASE}/activities`)
919
- expect(message.id).toBe('activity-123')
1007
+ expect(message.id).toBe(TEST_ACTIVITY_ID)
920
1008
  })
921
1009
 
922
1010
  it('throws WebexError when no existing direct conversation found', async () => {
@@ -1,5 +1,12 @@
1
1
  import { WebexCredentialManager } from './credential-manager'
2
2
  import { WebexEncryptionService } from './encryption'
3
+ import {
4
+ decodeWebexId,
5
+ normalizeSdkMembership,
6
+ normalizeSdkMessage,
7
+ normalizeSdkPerson,
8
+ toRestId,
9
+ } from './id-normalizer'
3
10
  import { KmsKeyProvider } from './kms-key-provider'
4
11
  import { escapeHtml, markdownToHtml, stripMarkdown } from './markdown-to-html'
5
12
  import type { WebexConfig, WebexMembership, WebexMessage, WebexPerson, WebexSpace } from './types'
@@ -15,6 +22,10 @@ interface RateLimitBucket {
15
22
  resetAt: number
16
23
  }
17
24
 
25
+ interface WebexClientOptions {
26
+ roomResolutionWarningPrefix?: string
27
+ }
28
+
18
29
  export class WebexClient {
19
30
  private token: string | null = null
20
31
  private deviceUrl: string | null = null
@@ -22,6 +33,13 @@ export class WebexClient {
22
33
  private buckets: Map<string, RateLimitBucket> = new Map()
23
34
  private globalRateLimitUntil: number = 0
24
35
  private encryption: WebexEncryptionService | null = null
36
+ private clusteredRoomIds = new Map<string, string>()
37
+ private roomIdLookups = new Map<string, Promise<string>>()
38
+ private roomResolutionWarningPrefix: string
39
+
40
+ constructor(options: WebexClientOptions = {}) {
41
+ this.roomResolutionWarningPrefix = options.roomResolutionWarningPrefix ?? '[webex]'
42
+ }
25
43
 
26
44
  async login(credentials?: { token: string; deviceUrl?: string; tokenType?: string }): Promise<this> {
27
45
  if (credentials) {
@@ -204,15 +222,24 @@ export class WebexClient {
204
222
  async testAuth(): Promise<WebexPerson> {
205
223
  if (this.useInternalAPI) {
206
224
  try {
207
- return await this.request<WebexPerson>('GET', '/people/me')
225
+ return normalizeSdkPerson(await this.request<WebexPerson>('GET', '/people/me'))
208
226
  } catch (err) {
209
227
  const isAuthError = err instanceof WebexError && (err.code === 'http_401' || err.code === 'http_403')
210
228
  if (!isAuthError) throw err
211
229
  await this.testAuthInternal()
212
- return { id: '', emails: [], displayName: '', orgId: '', type: 'person', created: '' } as WebexPerson
230
+ return normalizeSdkPerson({
231
+ id: '',
232
+ ref: '',
233
+ emails: [],
234
+ displayName: '',
235
+ orgId: '',
236
+ orgRef: '',
237
+ type: 'person',
238
+ created: '',
239
+ })
213
240
  }
214
241
  }
215
- return this.request<WebexPerson>('GET', '/people/me')
242
+ return normalizeSdkPerson(await this.request<WebexPerson>('GET', '/people/me'))
216
243
  }
217
244
 
218
245
  private async testAuthInternal(): Promise<void> {
@@ -245,8 +272,55 @@ export class WebexClient {
245
272
  }
246
273
  }
247
274
 
275
+ async resolveRoomId(roomId: string): Promise<string> {
276
+ const decoded = decodeWebexId(roomId)
277
+ let uuid: string
278
+ let fallback: string
279
+
280
+ if (decoded) {
281
+ if (decoded.type !== 'ROOM' || decoded.cluster.startsWith('urn:')) return roomId
282
+ uuid = decoded.uuid
283
+ fallback = roomId
284
+ } else if (looksLikeUuid(roomId)) {
285
+ uuid = roomId
286
+ fallback = toRestId(roomId, 'ROOM')
287
+ } else {
288
+ return roomId
289
+ }
290
+
291
+ const cached = this.clusteredRoomIds.get(uuid)
292
+ if (cached) return cached
293
+
294
+ const inFlight = this.roomIdLookups.get(uuid)
295
+ if (inFlight) return inFlight
296
+
297
+ const lookup = this.lookupRoomId(uuid, fallback)
298
+ this.roomIdLookups.set(uuid, lookup)
299
+ try {
300
+ return await lookup
301
+ } finally {
302
+ this.roomIdLookups.delete(uuid)
303
+ }
304
+ }
305
+
306
+ async resolvePersonId(personId: string): Promise<string> {
307
+ if (!personId || decodeWebexId(personId)) return personId
308
+
309
+ if (looksLikeEmail(personId)) {
310
+ const [person] = await this.listPeople({ email: personId, max: 1 })
311
+ if (!person) {
312
+ throw new WebexError(`Person not found for ref: ${personId}`, 'not_found')
313
+ }
314
+ return person.id
315
+ }
316
+
317
+ if (looksLikeUuid(personId)) return toRestId(personId, 'PEOPLE')
318
+ return personId
319
+ }
320
+
248
321
  async getSpace(spaceId: string): Promise<WebexSpace> {
249
- return this.request<WebexSpace>('GET', `/rooms/${spaceId}`)
322
+ const resolvedSpaceId = await this.resolveRoomId(spaceId)
323
+ return this.request<WebexSpace>('GET', `/rooms/${resolvedSpaceId}`)
250
324
  }
251
325
 
252
326
  async sendMessage(
@@ -254,15 +328,18 @@ export class WebexClient {
254
328
  text: string,
255
329
  options?: { markdown?: boolean; parentId?: string; files?: string[] },
256
330
  ): Promise<WebexMessage> {
331
+ const resolvedRoomId = await this.resolveRoomId(roomId)
332
+ const resolvedOptions = this.resolveMessageOptions(options)
333
+
257
334
  if (this.useInternalAPI) {
258
- return this.sendMessageInternal(roomId, text, options)
335
+ return this.sendMessageInternal(resolvedRoomId, text, resolvedOptions)
259
336
  }
260
- const body: Record<string, unknown> = { roomId }
261
- if (options?.markdown) body.markdown = text
337
+ const body: Record<string, unknown> = { roomId: resolvedRoomId }
338
+ if (resolvedOptions?.markdown) body.markdown = text
262
339
  else body.text = text
263
- if (options?.parentId) body.parentId = options.parentId
264
- if (options?.files?.length) body.files = options.files
265
- return this.request<WebexMessage>('POST', '/messages', body)
340
+ if (resolvedOptions?.parentId) body.parentId = resolvedOptions.parentId
341
+ if (resolvedOptions?.files?.length) body.files = resolvedOptions.files
342
+ return normalizeSdkMessage(await this.request<WebexMessage>('POST', '/messages', body))
266
343
  }
267
344
 
268
345
  private get useInternalAPI(): boolean {
@@ -283,7 +360,7 @@ export class WebexClient {
283
360
  }
284
361
 
285
362
  private decodeConvUuid(roomId: string): string {
286
- return Buffer.from(roomId, 'base64').toString('utf8').split('/').pop() ?? roomId
363
+ return decodeWebexId(roomId)?.uuid ?? roomId
287
364
  }
288
365
 
289
366
  private async internalRequest<T>(path: string, init?: RequestInit): Promise<T> {
@@ -314,15 +391,18 @@ export class WebexClient {
314
391
  }
315
392
  }
316
393
 
317
- return {
318
- id: a.id,
394
+ return normalizeSdkMessage({
395
+ id: this.normalizeMessageId(a.id),
396
+ ref: '',
319
397
  roomId,
398
+ roomRef: '',
320
399
  roomType: 'group' as const,
321
400
  text,
322
- personId: a.actor?.entryUUID ?? a.actor?.id ?? '',
401
+ personId: this.normalizePersonId(a.actor?.entryUUID ?? a.actor?.id ?? ''),
402
+ personRef: '',
323
403
  personEmail: a.actor?.emailAddress ?? '',
324
404
  created: a.published,
325
- }
405
+ })
326
406
  }
327
407
 
328
408
  private async buildEncryptedObject(
@@ -407,7 +487,7 @@ export class WebexClient {
407
487
  const body = options?.markdown
408
488
  ? { toPersonEmail: personEmail, markdown: text }
409
489
  : { toPersonEmail: personEmail, text }
410
- return this.request<WebexMessage>('POST', '/messages', body)
490
+ return normalizeSdkMessage(await this.request<WebexMessage>('POST', '/messages', body))
411
491
  }
412
492
 
413
493
  private async findDirectRoomByEmail(email: string): Promise<string | null> {
@@ -425,27 +505,31 @@ export class WebexClient {
425
505
  roomId: string,
426
506
  options?: { max?: number; mentionedPeople?: string; parentId?: string },
427
507
  ): Promise<WebexMessage[]> {
508
+ const resolvedRoomId = await this.resolveRoomId(roomId)
509
+ const resolvedOptions = await this.resolveListMessageOptions(options)
510
+
428
511
  if (this.useInternalAPI) {
429
- const convUuid = this.decodeConvUuid(roomId)
430
- const max = options?.max ?? 50
512
+ const convUuid = this.decodeConvUuid(resolvedRoomId)
513
+ const max = resolvedOptions?.max ?? 50
431
514
  const conv = await this.internalRequest<InternalConversation>(
432
515
  `/conversations/${convUuid}?activitiesLimit=${max}&participantsLimit=0`,
433
516
  )
434
517
  const activities = (conv.activities?.items ?? []).filter((a) => a.verb === 'post')
435
- return Promise.all(activities.map((a) => this.activityToMessage(a, roomId)))
518
+ return Promise.all(activities.map((a) => this.activityToMessage(a, resolvedRoomId)))
436
519
  }
437
520
  const params = new URLSearchParams()
438
- params.set('roomId', roomId)
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)
521
+ params.set('roomId', resolvedRoomId)
522
+ params.set('max', String(resolvedOptions?.max ?? 50))
523
+ if (resolvedOptions?.mentionedPeople) params.set('mentionedPeople', resolvedOptions.mentionedPeople)
524
+ if (resolvedOptions?.parentId) params.set('parentId', resolvedOptions.parentId)
442
525
  const data = await this.request<{ items: WebexMessage[] }>('GET', `/messages?${params}`)
443
- return data.items
526
+ return data.items.map(normalizeSdkMessage)
444
527
  }
445
528
 
446
529
  async getMessage(messageId: string): Promise<WebexMessage> {
447
530
  if (this.useInternalAPI) {
448
- const activity = await this.internalRequest<InternalActivity>(`/activities/${messageId}`)
531
+ const activityId = this.toMessageRef(messageId)
532
+ const activity = await this.internalRequest<InternalActivity>(`/activities/${activityId}`)
449
533
  const convId = activity.target?.id ?? ''
450
534
  // Internal API responses don't carry the cluster shard (e.g. `us-west-2_r`) the
451
535
  // public roomId encoding requires. The `unknown` placeholder is a sentinel — it
@@ -455,25 +539,26 @@ export class WebexClient {
455
539
  const roomId = convId ? Buffer.from(`ciscospark://urn:TEAM:unknown/ROOM/${convId}`).toString('base64') : ''
456
540
  return this.activityToMessage(activity, roomId)
457
541
  }
458
- return this.request<WebexMessage>('GET', `/messages/${messageId}`)
542
+ return normalizeSdkMessage(await this.request<WebexMessage>('GET', `/messages/${this.resolveMessageId(messageId)}`))
459
543
  }
460
544
 
461
545
  async deleteMessage(messageId: string): Promise<void> {
462
546
  if (this.useInternalAPI) {
463
- const activity = await this.internalRequest<InternalActivity>(`/activities/${messageId}`)
547
+ const activityId = this.toMessageRef(messageId)
548
+ const activity = await this.internalRequest<InternalActivity>(`/activities/${activityId}`)
464
549
  const convId = activity.target?.id
465
550
  if (!convId) throw new WebexError('Cannot determine conversation for activity', 'internal_error')
466
551
  await this.internalRequest<unknown>('/activities', {
467
552
  method: 'POST',
468
553
  body: JSON.stringify({
469
554
  verb: 'delete',
470
- object: { id: messageId, objectType: 'activity' },
555
+ object: { id: activityId, objectType: 'activity' },
471
556
  target: { id: convId, objectType: 'conversation' },
472
557
  }),
473
558
  })
474
559
  return
475
560
  }
476
- return this.request<void>('DELETE', `/messages/${messageId}`)
561
+ return this.request<void>('DELETE', `/messages/${this.resolveMessageId(messageId)}`)
477
562
  }
478
563
 
479
564
  async editMessage(
@@ -482,8 +567,11 @@ export class WebexClient {
482
567
  text: string,
483
568
  options?: { markdown?: boolean },
484
569
  ): Promise<WebexMessage> {
570
+ const resolvedRoomId = await this.resolveRoomId(roomId)
571
+
485
572
  if (this.useInternalAPI) {
486
- const convUuid = this.decodeConvUuid(roomId)
573
+ const activityId = this.toMessageRef(messageId)
574
+ const convUuid = this.decodeConvUuid(resolvedRoomId)
487
575
  const { object, encryptionKeyUrl } = await this.buildEncryptedObject(convUuid, text, {
488
576
  ...options,
489
577
  forEdit: true,
@@ -493,7 +581,7 @@ export class WebexClient {
493
581
  verb: 'post',
494
582
  object,
495
583
  target: { id: convUuid, objectType: 'conversation' },
496
- parent: { id: messageId, type: 'edit' },
584
+ parent: { id: activityId, type: 'edit' },
497
585
  clientTempId: `tmp-${Date.now()}-edit`,
498
586
  }
499
587
 
@@ -508,17 +596,19 @@ export class WebexClient {
508
596
 
509
597
  // Tolerate responses that omit `parent` (server may return minimal shape) —
510
598
  // only fail on an explicit mismatch between the echoed parent and the edited id.
511
- if (result.parent && result.parent.id !== messageId) {
599
+ if (result.parent && result.parent.id !== activityId) {
512
600
  throw new WebexError(
513
- `Edit rejected: server linked the new activity ${result.id} to ${result.parent.id} instead of ${messageId}.`,
601
+ `Edit rejected: server linked the new activity ${result.id} to ${result.parent.id} instead of ${activityId}.`,
514
602
  'edit_failed',
515
603
  )
516
604
  }
517
605
 
518
- return this.activityToMessage(result, roomId)
606
+ return this.activityToMessage(result, resolvedRoomId)
519
607
  }
520
- const body = options?.markdown ? { roomId, markdown: text } : { roomId, text }
521
- return this.request<WebexMessage>('PUT', `/messages/${messageId}`, body)
608
+ const body = options?.markdown ? { roomId: resolvedRoomId, markdown: text } : { roomId: resolvedRoomId, text }
609
+ return normalizeSdkMessage(
610
+ await this.request<WebexMessage>('PUT', `/messages/${this.resolveMessageId(messageId)}`, body),
611
+ )
522
612
  }
523
613
 
524
614
  async listPeople(options?: { email?: string; displayName?: string; max?: number }): Promise<WebexPerson[]> {
@@ -529,26 +619,34 @@ export class WebexClient {
529
619
  const query = params.toString()
530
620
  const path = query ? `/people?${query}` : '/people'
531
621
  const data = await this.request<{ items: WebexPerson[] }>('GET', path)
532
- return data.items
622
+ return data.items.map(normalizeSdkPerson)
533
623
  }
534
624
 
535
625
  async getPerson(personId: string): Promise<WebexPerson> {
536
- return this.request<WebexPerson>('GET', `/people/${personId}`)
626
+ if (!decodeWebexId(personId) && looksLikeEmail(personId)) {
627
+ const [person] = await this.listPeople({ email: personId, max: 1 })
628
+ if (!person) {
629
+ throw new WebexError(`Person not found for ref: ${personId}`, 'not_found')
630
+ }
631
+ return person
632
+ }
633
+ return normalizeSdkPerson(await this.request<WebexPerson>('GET', `/people/${await this.resolvePersonId(personId)}`))
537
634
  }
538
635
 
539
636
  async listMyMemberships(options?: { max?: number }): Promise<WebexMembership[]> {
540
637
  const params = new URLSearchParams()
541
638
  params.set('max', String(options?.max ?? 100))
542
639
  const data = await this.request<{ items: WebexMembership[] }>('GET', `/memberships?${params}`)
543
- return data.items
640
+ return data.items.map(normalizeSdkMembership)
544
641
  }
545
642
 
546
643
  async listMemberships(roomId: string, options?: { max?: number }): Promise<WebexMembership[]> {
644
+ const resolvedRoomId = await this.resolveRoomId(roomId)
547
645
  const params = new URLSearchParams()
548
- params.set('roomId', roomId)
646
+ params.set('roomId', resolvedRoomId)
549
647
  if (options?.max) params.set('max', String(options.max))
550
648
  const data = await this.request<{ items: WebexMembership[] }>('GET', `/memberships?${params}`)
551
- return data.items
649
+ return data.items.map(normalizeSdkMembership)
552
650
  }
553
651
 
554
652
  async uploadFile(
@@ -556,12 +654,14 @@ export class WebexClient {
556
654
  file: { content: Blob; filename: string },
557
655
  options?: { text?: string; markdown?: boolean; parentId?: string },
558
656
  ): Promise<WebexMessage> {
657
+ const resolvedRoomId = await this.resolveRoomId(roomId)
658
+ const resolvedParentId = options?.parentId ? this.resolveMessageId(options.parentId) : undefined
559
659
  const form = new FormData()
560
- form.set('roomId', roomId)
660
+ form.set('roomId', resolvedRoomId)
561
661
  if (options?.text) {
562
662
  form.set(options.markdown ? 'markdown' : 'text', options.text)
563
663
  }
564
- if (options?.parentId) form.set('parentId', options.parentId)
664
+ if (resolvedParentId) form.set('parentId', resolvedParentId)
565
665
  form.set('files', file.content, file.filename)
566
666
 
567
667
  const response = await fetch(`${BASE_URL}/messages`, {
@@ -574,7 +674,81 @@ export class WebexClient {
574
674
  const errorBody = (await response.json().catch(() => null)) as { message?: string } | null
575
675
  throw new WebexError(errorBody?.message ?? `HTTP ${response.status}`, `http_${response.status}`)
576
676
  }
577
- return response.json() as Promise<WebexMessage>
677
+ return normalizeSdkMessage((await response.json()) as WebexMessage)
678
+ }
679
+
680
+ private async lookupRoomId(uuid: string, fallback: string): Promise<string> {
681
+ try {
682
+ // Page through every room the account belongs to, stopping as soon as the
683
+ // trailing UUID matches because room titles are not stable identifiers.
684
+ for await (const room of this.iterateSpaces({ max: 1000 })) {
685
+ if (decodeWebexId(room.id)?.uuid === uuid) {
686
+ this.clusteredRoomIds.set(uuid, room.id)
687
+ return room.id
688
+ }
689
+ }
690
+ } catch {
691
+ // Network/auth failure: fail open to the un-corrected id rather than block the call.
692
+ return fallback
693
+ }
694
+
695
+ console.warn(
696
+ `${this.roomResolutionWarningPrefix} Could not resolve clustered room id for ${uuid}; falling back to the un-clustered id. ` +
697
+ 'Room-scoped calls may fail if this room lives on a non-default Webex cluster.',
698
+ )
699
+ return fallback
700
+ }
701
+
702
+ private resolveMessageOptions(options?: {
703
+ markdown?: boolean
704
+ parentId?: string
705
+ files?: string[]
706
+ }): { markdown?: boolean; parentId?: string; files?: string[] } | undefined {
707
+ if (!options?.parentId) return options
708
+ return { ...options, parentId: this.resolveMessageId(options.parentId) }
709
+ }
710
+
711
+ private async resolveListMessageOptions(options?: {
712
+ max?: number
713
+ mentionedPeople?: string
714
+ parentId?: string
715
+ }): Promise<{ max?: number; mentionedPeople?: string; parentId?: string } | undefined> {
716
+ if (!options) return undefined
717
+ const resolved = { ...options }
718
+ if (options.mentionedPeople) {
719
+ resolved.mentionedPeople = await this.resolveMentionedPeople(options.mentionedPeople)
720
+ }
721
+ if (options.parentId) {
722
+ resolved.parentId = this.resolveMessageId(options.parentId)
723
+ }
724
+ return resolved
725
+ }
726
+
727
+ private async resolveMentionedPeople(mentionedPeople: string): Promise<string> {
728
+ if (mentionedPeople === 'me') return mentionedPeople
729
+ return this.resolvePersonId(mentionedPeople)
730
+ }
731
+
732
+ private resolveMessageId(messageId: string): string {
733
+ if (!messageId || decodeWebexId(messageId)) return messageId
734
+ // A lone message UUID does not identify its room cluster, so cluster correction
735
+ // is not possible without the room context.
736
+ if (looksLikeUuid(messageId)) return toRestId(messageId, 'MESSAGE')
737
+ return messageId
738
+ }
739
+
740
+ private toMessageRef(messageId: string): string {
741
+ return decodeWebexId(messageId)?.uuid ?? messageId
742
+ }
743
+
744
+ private normalizeMessageId(messageId: string): string {
745
+ if (!messageId || decodeWebexId(messageId)) return messageId
746
+ return toRestId(messageId, 'MESSAGE')
747
+ }
748
+
749
+ private normalizePersonId(personId: string): string {
750
+ if (!personId || decodeWebexId(personId)) return personId
751
+ return toRestId(personId, 'PEOPLE')
578
752
  }
579
753
 
580
754
  async downloadContent(contentRef: string): Promise<{ data: ArrayBuffer; filename: string; contentType: string }> {
@@ -638,6 +812,14 @@ function sanitizeFilename(name: string | undefined): string | undefined {
638
812
  return base
639
813
  }
640
814
 
815
+ function looksLikeUuid(value: string): boolean {
816
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)
817
+ }
818
+
819
+ function looksLikeEmail(value: string): boolean {
820
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
821
+ }
822
+
641
823
  interface InternalActivity {
642
824
  id: string
643
825
  verb: string
@@ -9,6 +9,8 @@ import { WebexTokenExtractor } from '../token-extractor'
9
9
  import { WebexError } from '../types'
10
10
  import { extractAction, loginAction, logoutAction, oauthAction, statusAction } from './auth'
11
11
 
12
+ const personId = Buffer.from('ciscospark://us/PEOPLE/person-1').toString('base64url')
13
+
12
14
  let promptQueue: string[] = []
13
15
  mock.module('node:readline/promises', () => ({
14
16
  createInterface: () => ({
@@ -33,7 +35,7 @@ describe('auth commands', () => {
33
35
  let originalStdinTTY: boolean | undefined
34
36
  let originalStdoutTTY: boolean | undefined
35
37
  const mockPerson = {
36
- id: 'person-1',
38
+ id: personId,
37
39
  displayName: 'Test User',
38
40
  emails: ['test@example.com'],
39
41
  orgId: 'org-1',