agent-messenger 2.22.0 → 2.23.1

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 (102) 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 +6 -0
  5. package/dist/src/platforms/webex/client.d.ts.map +1 -1
  6. package/dist/src/platforms/webex/client.js +34 -4
  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 +6 -0
  20. package/dist/src/platforms/webex/index.d.ts.map +1 -1
  21. package/dist/src/platforms/webex/index.js +3 -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/client.d.ts +4 -0
  39. package/dist/src/platforms/webexbot/client.d.ts.map +1 -1
  40. package/dist/src/platforms/webexbot/client.js +70 -8
  41. package/dist/src/platforms/webexbot/client.js.map +1 -1
  42. package/dist/src/platforms/webexbot/index.d.ts +2 -0
  43. package/dist/src/platforms/webexbot/index.d.ts.map +1 -1
  44. package/dist/src/platforms/webexbot/index.js +1 -0
  45. package/dist/src/platforms/webexbot/index.js.map +1 -1
  46. package/dist/src/platforms/webexbot/listener.d.ts +3 -41
  47. package/dist/src/platforms/webexbot/listener.d.ts.map +1 -1
  48. package/dist/src/platforms/webexbot/listener.js +13 -208
  49. package/dist/src/platforms/webexbot/listener.js.map +1 -1
  50. package/dist/src/platforms/webexbot/types.d.ts +1 -18
  51. package/dist/src/platforms/webexbot/types.d.ts.map +1 -1
  52. package/dist/src/platforms/webexbot/types.js.map +1 -1
  53. package/docs/content/docs/cli/webex.mdx +38 -12
  54. package/docs/content/docs/sdk/webexbot.mdx +16 -0
  55. package/package.json +1 -1
  56. package/skills/agent-channeltalk/SKILL.md +1 -1
  57. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  58. package/skills/agent-discord/SKILL.md +1 -1
  59. package/skills/agent-discordbot/SKILL.md +1 -1
  60. package/skills/agent-instagram/SKILL.md +1 -1
  61. package/skills/agent-kakaotalk/SKILL.md +1 -1
  62. package/skills/agent-line/SKILL.md +1 -1
  63. package/skills/agent-slack/SKILL.md +1 -1
  64. package/skills/agent-slackbot/SKILL.md +1 -1
  65. package/skills/agent-teams/SKILL.md +1 -1
  66. package/skills/agent-telegram/SKILL.md +1 -1
  67. package/skills/agent-telegrambot/SKILL.md +1 -1
  68. package/skills/agent-webex/SKILL.md +76 -22
  69. package/skills/agent-webex/references/authentication.md +55 -14
  70. package/skills/agent-webex/references/common-patterns.md +5 -2
  71. package/skills/agent-webexbot/SKILL.md +3 -1
  72. package/skills/agent-wechatbot/SKILL.md +1 -1
  73. package/skills/agent-whatsapp/SKILL.md +1 -1
  74. package/skills/agent-whatsappbot/SKILL.md +1 -1
  75. package/src/platforms/webex/cli.test.ts +31 -1
  76. package/src/platforms/webex/client.test.ts +57 -0
  77. package/src/platforms/webex/client.ts +39 -4
  78. package/src/platforms/webex/commands/auth.test.ts +189 -28
  79. package/src/platforms/webex/commands/auth.ts +194 -35
  80. package/src/platforms/webex/credential-manager.test.ts +40 -0
  81. package/src/platforms/webex/credential-manager.ts +7 -4
  82. package/src/platforms/webex/id-normalizer.test.ts +207 -0
  83. package/src/platforms/webex/id-normalizer.ts +76 -0
  84. package/src/platforms/webex/index.test.ts +10 -0
  85. package/src/platforms/webex/index.ts +6 -0
  86. package/src/platforms/webex/listener.test.ts +243 -0
  87. package/src/platforms/webex/listener.ts +285 -0
  88. package/src/platforms/webex/password-login.test.ts +193 -0
  89. package/src/platforms/webex/password-login.ts +332 -0
  90. package/src/platforms/webex/types.test.ts +16 -0
  91. package/src/platforms/webex/types.ts +2 -2
  92. package/src/platforms/{webexbot → webex}/wdm-discovery.ts +3 -3
  93. package/src/platforms/webexbot/client.test.ts +125 -1
  94. package/src/platforms/webexbot/client.ts +79 -8
  95. package/src/platforms/webexbot/index.ts +2 -0
  96. package/src/platforms/webexbot/listener.test.ts +37 -224
  97. package/src/platforms/webexbot/listener.ts +18 -250
  98. package/src/platforms/webexbot/types.ts +2 -23
  99. package/dist/src/platforms/webexbot/wdm-discovery.d.ts.map +0 -1
  100. package/dist/src/platforms/webexbot/wdm-discovery.js.map +0 -1
  101. /package/dist/src/platforms/{webexbot → webex}/wdm-discovery.d.ts +0 -0
  102. /package/src/platforms/{webexbot → webex}/wdm-discovery.test.ts +0 -0
@@ -0,0 +1,332 @@
1
+ import { createHash, randomBytes } from 'node:crypto'
2
+
3
+ import { WebexError } from './types'
4
+
5
+ export const WEB_CLIENT_ID = 'C64ab04639eefee4798f58e7bc3fe01d47161be0d97ff0d31e040a6ffe66d7f0a'
6
+ export const WEB_CLIENT_SECRET = 'f4261a01a4111b3b3b1710583073cae9cd7104517e7f78800c43d01eea133782'
7
+
8
+ const REDIRECT_URI = 'https://web.webex.com'
9
+ const DEFAULT_IDBROKER_HOST = 'https://idbroker.webex.com'
10
+ const SCOPE =
11
+ 'webexsquare:get_conversation Identity:SCIM identity:things_read spark:kms spark:people_read spark:people_write spark:organizations_read spark:rooms_read spark:rooms_write spark:memberships_read spark:calls_read spark:calls_write webexsquare:admin'
12
+ const U2C_URL = 'https://u2c.svc.webex.com/u2c/api/v1/limited/catalog'
13
+ const MAX_REDIRECTS = 12
14
+
15
+ export interface PasswordLoginOptions {
16
+ idbrokerHost?: string
17
+ }
18
+
19
+ export interface PasswordLoginResult {
20
+ accessToken: string
21
+ refreshToken: string
22
+ expiresAt: number
23
+ deviceUrl: string
24
+ userId: string
25
+ }
26
+
27
+ interface TokenResponse {
28
+ access_token: string
29
+ refresh_token: string
30
+ expires_in: number
31
+ refresh_token_expires_in?: number
32
+ scope?: string
33
+ token_type?: string
34
+ }
35
+
36
+ interface U2CDiscovery {
37
+ idbrokerHost: string
38
+ wdmHost?: string
39
+ }
40
+
41
+ interface RedirectResult {
42
+ response: Response
43
+ url: string
44
+ }
45
+
46
+ export async function loginWithPassword(
47
+ email: string,
48
+ password: string,
49
+ options?: PasswordLoginOptions,
50
+ ): Promise<PasswordLoginResult> {
51
+ const normalizedEmail = email.toLowerCase()
52
+ const emailHash = sha256Hex(normalizedEmail)
53
+ const discovery = await discoverU2C(emailHash)
54
+ const idbrokerHost = normalizeOrigin(options?.idbrokerHost ?? discovery.idbrokerHost)
55
+ const pkce = createPkcePair()
56
+ const state = base64url(Buffer.from(JSON.stringify({ csrf_token: crypto.randomUUID(), emailhash: emailHash })))
57
+ const cookieJar = new CookieJar()
58
+
59
+ const authorizeUrl = `${idbrokerHost}/idb/oauth2/v1/authorize?${new URLSearchParams({
60
+ response_type: 'code',
61
+ cisKeepMeSignedInOption: '1',
62
+ state,
63
+ cisService: 'webex',
64
+ emailHash,
65
+ code_challenge: pkce.challenge,
66
+ code_challenge_method: 'S256',
67
+ client_id: WEB_CLIENT_ID,
68
+ redirect_uri: REDIRECT_URI,
69
+ scope: SCOPE,
70
+ }).toString()}`
71
+
72
+ const authorizeResult = await followRedirects(authorizeUrl, { method: 'GET' }, cookieJar)
73
+ const clusterHost = new URL(authorizeResult.url).origin
74
+ // Reject before touching credentials: if authorize redirected off Webex (an SSO
75
+ // IdP), posting IDToken1/IDToken2 here would leak the password to that host.
76
+ if (!isWebexHost(new URL(clusterHost).host)) {
77
+ throw new WebexError('SSO/IdP login is not supported for headless password login', 'sso_required')
78
+ }
79
+ const loginPageHtml = await authorizeResult.response.text()
80
+ const fields = parseInputFields(loginPageHtml)
81
+ fields.set('IDToken0', '')
82
+ fields.set('IDToken1', email)
83
+ fields.set('IDToken2', password)
84
+ fields.set('IDButton', 'Sign In')
85
+ fields.set('loginid', email)
86
+
87
+ const loginBody = new URLSearchParams()
88
+ for (const [key, value] of fields) loginBody.set(key, value)
89
+
90
+ const loginResult = await followRedirects(
91
+ `${clusterHost}/idb/UI/Login`,
92
+ {
93
+ method: 'POST',
94
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
95
+ body: loginBody.toString(),
96
+ },
97
+ cookieJar,
98
+ )
99
+ const code = getAuthorizationCode(loginResult.url)
100
+
101
+ if (code === null) {
102
+ await throwLoginFailure(loginResult)
103
+ throw new WebexError('Login failed: invalid credentials or unexpected response', 'login_failed')
104
+ }
105
+
106
+ const token = await exchangeCode(clusterHost, code, pkce.verifier)
107
+ const device = await registerDevice(discovery.wdmHost, token.access_token)
108
+
109
+ return {
110
+ accessToken: token.access_token,
111
+ refreshToken: token.refresh_token,
112
+ expiresAt: Date.now() + token.expires_in * 1000,
113
+ deviceUrl: device.deviceUrl,
114
+ userId: device.userId,
115
+ }
116
+ }
117
+
118
+ export function createPkcePair(verifier?: string): { verifier: string; challenge: string } {
119
+ const resolvedVerifier = verifier ?? base64url(randomBytes(64))
120
+ return {
121
+ verifier: resolvedVerifier,
122
+ challenge: base64url(createHash('sha256').update(resolvedVerifier).digest()),
123
+ }
124
+ }
125
+
126
+ async function discoverU2C(emailHash: string): Promise<U2CDiscovery> {
127
+ try {
128
+ const url = `${U2C_URL}?${new URLSearchParams({ format: 'hostmap', emailhash: emailHash }).toString()}`
129
+ const response = await fetch(url)
130
+ if (!response.ok) return { idbrokerHost: DEFAULT_IDBROKER_HOST }
131
+ const catalog = (await response.json()) as { serviceLinks?: Record<string, string> }
132
+ // Match the exact `idbroker` serviceLink key: a substring scan would wrongly
133
+ // pick `idbroker-guest`, routing login to a cluster the user has no account on.
134
+ const links = catalog.serviceLinks ?? {}
135
+ return {
136
+ idbrokerHost: typeof links.idbroker === 'string' ? normalizeOrigin(links.idbroker) : DEFAULT_IDBROKER_HOST,
137
+ wdmHost: typeof links.wdm === 'string' ? normalizeOrigin(links.wdm) : undefined,
138
+ }
139
+ } catch {
140
+ return { idbrokerHost: DEFAULT_IDBROKER_HOST }
141
+ }
142
+ }
143
+
144
+ async function exchangeCode(clusterHost: string, code: string, verifier: string): Promise<TokenResponse> {
145
+ const response = await fetch(`${clusterHost}/idb/oauth2/v1/access_token`, {
146
+ method: 'POST',
147
+ headers: {
148
+ Authorization: `Basic ${Buffer.from(`${WEB_CLIENT_ID}:${WEB_CLIENT_SECRET}`).toString('base64')}`,
149
+ 'Content-Type': 'application/x-www-form-urlencoded',
150
+ },
151
+ body: new URLSearchParams({
152
+ grant_type: 'authorization_code',
153
+ redirect_uri: REDIRECT_URI,
154
+ code,
155
+ code_verifier: verifier,
156
+ self_contained_token: 'true',
157
+ }).toString(),
158
+ })
159
+
160
+ if (!response.ok) {
161
+ throw new WebexError(`Token exchange failed: HTTP ${response.status}`, 'token_exchange_failed')
162
+ }
163
+
164
+ const token = (await response.json()) as Partial<TokenResponse>
165
+ if (!token.access_token || !token.refresh_token || typeof token.expires_in !== 'number') {
166
+ throw new WebexError('Token exchange failed: incomplete response', 'token_exchange_failed')
167
+ }
168
+
169
+ return token as TokenResponse
170
+ }
171
+
172
+ async function registerDevice(
173
+ wdmHost: string | undefined,
174
+ accessToken: string,
175
+ ): Promise<{ deviceUrl: string; userId: string }> {
176
+ // deviceUrl is required: without it WebexClient falls back to the plaintext
177
+ // public API instead of the internal KMS-encrypted path password tokens need.
178
+ if (!wdmHost) {
179
+ throw new WebexError('Webex device registration service was not found in the catalog', 'device_registration_failed')
180
+ }
181
+
182
+ const response = await fetch(`${normalizeOrigin(wdmHost)}/wdm/api/v1/devices`, {
183
+ method: 'POST',
184
+ headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
185
+ body: JSON.stringify({
186
+ deviceName: 'agent-messenger',
187
+ name: 'agent-messenger',
188
+ deviceType: 'UNKNOWN',
189
+ model: 'agent-messenger',
190
+ localizedModel: 'agent-messenger',
191
+ systemName: 'agent-messenger',
192
+ systemVersion: '1.0',
193
+ }),
194
+ })
195
+ if (!response.ok) {
196
+ throw new WebexError(`Webex device registration failed: HTTP ${response.status}`, 'device_registration_failed')
197
+ }
198
+ const data = (await response.json()) as { url?: string; userId?: string }
199
+ if (!data.url || !data.userId) {
200
+ throw new WebexError('Webex device registration returned an incomplete device', 'device_registration_failed')
201
+ }
202
+ return { deviceUrl: data.url, userId: data.userId }
203
+ }
204
+
205
+ async function followRedirects(url: string, init: RequestInit, cookieJar: CookieJar): Promise<RedirectResult> {
206
+ let currentUrl = url
207
+ let response = await fetchWithCookies(currentUrl, init, cookieJar)
208
+ let redirects = 0
209
+
210
+ while (isRedirect(response) && redirects++ < MAX_REDIRECTS) {
211
+ const location = response.headers.get('location')
212
+ if (!location) break
213
+ currentUrl = new URL(location, currentUrl).toString()
214
+ if (currentUrl.startsWith(REDIRECT_URI) && /[?&]code=/.test(currentUrl)) {
215
+ return { response, url: currentUrl }
216
+ }
217
+ response = await fetchWithCookies(currentUrl, { method: 'GET' }, cookieJar)
218
+ }
219
+
220
+ return { response, url: currentUrl }
221
+ }
222
+
223
+ async function fetchWithCookies(url: string, init: RequestInit, cookieJar: CookieJar): Promise<Response> {
224
+ const headers = new Headers(init.headers)
225
+ // Only attach/store session cookies on Webex hosts so a redirect to an external
226
+ // IdP (SSO) never receives the Webex session cookies.
227
+ const isWebex = isWebexHost(new URL(url).host)
228
+ const cookie = cookieJar.header()
229
+ if (cookie && isWebex) headers.set('Cookie', cookie)
230
+ const response = await fetch(url, { ...init, redirect: 'manual', headers })
231
+ if (isWebex) cookieJar.apply(response)
232
+ return response
233
+ }
234
+
235
+ function parseInputFields(html: string): Map<string, string> {
236
+ const fields = new Map<string, string>()
237
+ for (const input of html.match(/<input[^>]*>/gi) ?? []) {
238
+ const attrs = parseAttributes(input)
239
+ const name = attrs.get('name')
240
+ if (name && !fields.has(name)) {
241
+ fields.set(name, decodeEntities(attrs.get('value') ?? ''))
242
+ }
243
+ }
244
+ return fields
245
+ }
246
+
247
+ function parseAttributes(tag: string): Map<string, string> {
248
+ const attrs = new Map<string, string>()
249
+ const attrPattern = /([:\w-]+)\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/g
250
+ let match = attrPattern.exec(tag)
251
+ while (match) {
252
+ const rawValue = match[2]
253
+ const value = rawValue.startsWith('"') || rawValue.startsWith("'") ? rawValue.slice(1, -1) : rawValue
254
+ attrs.set(match[1], decodeEntities(value))
255
+ match = attrPattern.exec(tag)
256
+ }
257
+ return attrs
258
+ }
259
+
260
+ function decodeEntities(value: string): string {
261
+ return value
262
+ .replace(/&#x([0-9a-fA-F]+);/g, (_, hex: string) => String.fromCodePoint(parseInt(hex, 16)))
263
+ .replace(/&#(\d+);/g, (_, decimal: string) => String.fromCodePoint(parseInt(decimal, 10)))
264
+ .replace(/&amp;/g, '&')
265
+ .replace(/&quot;/g, '"')
266
+ .replace(/&#39;/g, "'")
267
+ .replace(/&lt;/g, '<')
268
+ .replace(/&gt;/g, '>')
269
+ }
270
+
271
+ async function throwLoginFailure(loginResult: RedirectResult): Promise<never> {
272
+ const finalHost = new URL(loginResult.url).host
273
+ if (!isWebexHost(finalHost)) {
274
+ throw new WebexError('SSO/IdP login is not supported for headless password login', 'sso_required')
275
+ }
276
+
277
+ const html = await loginResult.response.text()
278
+ if (/verification code|passcode|one-time|security code|\bOTP\b|\bMFA\b/i.test(html)) {
279
+ throw new WebexError('Account requires MFA; headless password login is not supported', 'mfa_required')
280
+ }
281
+
282
+ throw new WebexError('Login failed: invalid credentials or unexpected response', 'login_failed')
283
+ }
284
+
285
+ function getAuthorizationCode(url: string): string | null {
286
+ const code = new URL(url).searchParams.get('code')
287
+ return code ? decodeURIComponent(code) : null
288
+ }
289
+
290
+ function isRedirect(response: Response): boolean {
291
+ return response.status >= 300 && response.status < 400
292
+ }
293
+
294
+ function isWebexHost(host: string): boolean {
295
+ return host === 'webex.com' || host.endsWith('.webex.com') || host === 'wbx2.com' || host.endsWith('.wbx2.com')
296
+ }
297
+
298
+ function normalizeOrigin(value: string): string {
299
+ return new URL(value).origin
300
+ }
301
+
302
+ function sha256Hex(value: string): string {
303
+ return createHash('sha256').update(value).digest('hex')
304
+ }
305
+
306
+ function base64url(buffer: Buffer): string {
307
+ return buffer.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
308
+ }
309
+
310
+ class CookieJar {
311
+ private cookies = new Map<string, string>()
312
+
313
+ apply(response: Response): void {
314
+ for (const cookie of getSetCookies(response.headers)) {
315
+ const [pair] = cookie.split(';')
316
+ const index = pair.indexOf('=')
317
+ if (index > 0) this.cookies.set(pair.slice(0, index).trim(), pair.slice(index + 1).trim())
318
+ }
319
+ }
320
+
321
+ header(): string {
322
+ return [...this.cookies.entries()].map(([key, value]) => `${key}=${value}`).join('; ')
323
+ }
324
+ }
325
+
326
+ function getSetCookies(headers: Headers): string[] {
327
+ const headersWithSetCookie = headers as Headers & { getSetCookie?: () => string[] }
328
+ const cookies = headersWithSetCookie.getSetCookie?.()
329
+ if (cookies?.length) return cookies
330
+ const single = headers.get('set-cookie')
331
+ return single ? [single] : []
332
+ }
@@ -266,6 +266,22 @@ it('WebexConfigSchema validates config with tokenType manual', () => {
266
266
  }
267
267
  })
268
268
 
269
+ it('WebexConfigSchema validates config with tokenType password', () => {
270
+ const result = WebexConfigSchema.safeParse({
271
+ accessToken: 'test',
272
+ refreshToken: 'test',
273
+ expiresAt: 1234567890,
274
+ tokenType: 'password',
275
+ deviceUrl: 'https://wdm-test.wbx2.com/wdm/api/v1/devices/device-1',
276
+ userId: 'user-1',
277
+ encryptionKeys: {},
278
+ })
279
+ expect(result.success).toBe(true)
280
+ if (result.success) {
281
+ expect(result.data.tokenType).toBe('password')
282
+ }
283
+ })
284
+
269
285
  it('WebexConfigSchema rejects invalid tokenType', () => {
270
286
  const result = WebexConfigSchema.safeParse({
271
287
  accessToken: 'test',
@@ -55,7 +55,7 @@ export interface WebexConfig {
55
55
  expiresAt: number
56
56
  clientId?: string
57
57
  clientSecret?: string
58
- tokenType?: 'oauth' | 'manual' | 'extracted'
58
+ tokenType?: 'oauth' | 'manual' | 'extracted' | 'password'
59
59
  deviceUrl?: string
60
60
  userId?: string
61
61
  encryptionKeys?: Record<string, string>
@@ -126,7 +126,7 @@ export const WebexConfigSchema = z.object({
126
126
  expiresAt: z.number(),
127
127
  clientId: z.string().optional(),
128
128
  clientSecret: z.string().optional(),
129
- tokenType: z.enum(['oauth', 'manual', 'extracted']).optional(),
129
+ tokenType: z.enum(['oauth', 'manual', 'extracted', 'password']).optional(),
130
130
  deviceUrl: z.string().optional(),
131
131
  userId: z.string().optional(),
132
132
  encryptionKeys: z.record(z.string(), z.string()).optional(),
@@ -1,6 +1,6 @@
1
1
  import type { FetchFunction, FetchRequest, FetchResponse } from 'webex-message-handler'
2
2
 
3
- import { WebexBotError } from './types'
3
+ import { WebexError } from './types'
4
4
 
5
5
  const U2C_CATALOG_URL = 'https://u2c.wbx2.com/u2c/api/v1/catalog?format=hostmap'
6
6
  const HARDCODED_WDM_DEVICES_URL = 'https://wdm-a.wbx2.com/wdm/api/v1/devices'
@@ -14,13 +14,13 @@ const HARDCODED_WDM_DEVICES_URL = 'https://wdm-a.wbx2.com/wdm/api/v1/devices'
14
14
  export async function discoverWdmDevicesUrl(token: string): Promise<string> {
15
15
  const response = await fetch(U2C_CATALOG_URL, { headers: { Authorization: `Bearer ${token}` } })
16
16
  if (!response.ok) {
17
- throw new WebexBotError(`Failed to discover Webex WDM cluster: HTTP ${response.status}`, 'wdm_discovery_failed')
17
+ throw new WebexError(`Failed to discover Webex WDM cluster: HTTP ${response.status}`, 'wdm_discovery_failed')
18
18
  }
19
19
 
20
20
  const catalog = (await response.json()) as { serviceLinks?: { wdm?: string } }
21
21
  const wdm = catalog.serviceLinks?.wdm
22
22
  if (!wdm) {
23
- throw new WebexBotError('Webex U2C catalog did not include serviceLinks.wdm', 'wdm_discovery_failed')
23
+ throw new WebexError('Webex U2C catalog did not include serviceLinks.wdm', 'wdm_discovery_failed')
24
24
  }
25
25
 
26
26
  return `${wdm.replace(/\/$/, '')}/devices`
@@ -1,5 +1,6 @@
1
- import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
1
+ import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'
2
2
 
3
+ import { toRestId } from '../webex/id-normalizer'
3
4
  import { WebexBotClient } from './client'
4
5
 
5
6
  describe('WebexBotClient', () => {
@@ -195,4 +196,127 @@ describe('WebexBotClient', () => {
195
196
  expect(result.filename).toBe('passwd')
196
197
  })
197
198
  })
199
+
200
+ describe('room cluster resolution', () => {
201
+ const roomUuid = '12345678-1234-1234-1234-1234567890ab'
202
+ const usRoomId = toRestId(roomUuid, 'ROOM')
203
+ const clusteredRoomId = Buffer.from(`ciscospark://urn:TEAM:us-west-2_r/ROOM/${roomUuid}`).toString('base64url')
204
+
205
+ const isRoomsList = (url: string) => new URL(url).pathname === '/v1/rooms'
206
+
207
+ const clusteredId = (uuid: string) =>
208
+ Buffer.from(`ciscospark://urn:TEAM:us-west-2_r/ROOM/${uuid}`).toString('base64url')
209
+
210
+ const mockRoomsPage = (items: unknown[], nextCursor?: string) => {
211
+ const headers: Record<string, string> = { 'Content-Type': 'application/json' }
212
+ if (nextCursor) headers.Link = `<https://webexapis.com/v1/rooms?max=1000&before=${nextCursor}>; rel="next"`
213
+ fetchResponses.push(new Response(JSON.stringify({ items }), { status: 200, headers }))
214
+ }
215
+
216
+ it('rewrites a us-cluster roomId to the real urn:TEAM id before sending', async () => {
217
+ // given a non-default-cluster room only reachable via its clustered id
218
+ mockResponse({ items: [{ id: clusteredRoomId, title: 'Team', type: 'group' }] })
219
+ mockResponse({ id: 'msg-1', roomId: clusteredRoomId, roomType: 'group' })
220
+
221
+ // when sending to the us-flattened id the listener emitted
222
+ const client = await new WebexBotClient().login({ token: 'bot-token' })
223
+ await client.sendMessage(usRoomId, 'hello')
224
+
225
+ // then the lookup runs and the send targets the corrected clustered id
226
+ expect(isRoomsList(fetchCalls[0].url)).toBe(true)
227
+ const body = JSON.parse(fetchCalls[1].options?.body as string)
228
+ expect(body.roomId).toBe(clusteredRoomId)
229
+ })
230
+
231
+ it('routes listMemberships through the corrected clustered id', async () => {
232
+ mockResponse({ items: [{ id: clusteredRoomId, type: 'group' }] })
233
+ mockResponse({ items: [{ id: 'm1', roomId: clusteredRoomId, personId: 'p1' }] })
234
+
235
+ const client = await new WebexBotClient().login({ token: 'bot-token' })
236
+ await client.listMemberships(usRoomId)
237
+
238
+ const membershipsUrl = new URL(fetchCalls[1].url)
239
+ expect(membershipsUrl.pathname).toBe('/v1/memberships')
240
+ expect(membershipsUrl.searchParams.get('roomId')).toBe(clusteredRoomId)
241
+ })
242
+
243
+ it('routes listMessages and its space lookup through the corrected clustered id', async () => {
244
+ mockResponse({ items: [{ id: clusteredRoomId, type: 'group' }] })
245
+ mockResponse({ id: clusteredRoomId, title: 'Team', type: 'group' })
246
+ mockResponse({ items: [{ id: 'msg-1', roomId: clusteredRoomId, roomType: 'group' }] })
247
+
248
+ const client = await new WebexBotClient().login({ token: 'bot-token' })
249
+ await client.listMessages(usRoomId, { max: 5 })
250
+
251
+ expect(fetchCalls[1].url).toBe(`https://webexapis.com/v1/rooms/${clusteredRoomId}`)
252
+ const messagesUrl = new URL(fetchCalls[2].url)
253
+ expect(messagesUrl.searchParams.get('roomId')).toBe(clusteredRoomId)
254
+ })
255
+
256
+ it('passes an already-clustered roomId through without a lookup', async () => {
257
+ mockResponse({ id: 'msg-1', roomId: clusteredRoomId, roomType: 'group' })
258
+
259
+ const client = await new WebexBotClient().login({ token: 'bot-token' })
260
+ await client.sendMessage(clusteredRoomId, 'hi')
261
+
262
+ expect(fetchCalls).toHaveLength(1)
263
+ expect(new URL(fetchCalls[0].url).pathname).toBe('/v1/messages')
264
+ expect(JSON.parse(fetchCalls[0].options?.body as string).roomId).toBe(clusteredRoomId)
265
+ })
266
+
267
+ it('caches the resolution so repeated calls trigger one room lookup', async () => {
268
+ mockResponse({ items: [{ id: clusteredRoomId, type: 'group' }] })
269
+ mockResponse({ id: 'msg-1', roomId: clusteredRoomId })
270
+ mockResponse({ id: 'msg-2', roomId: clusteredRoomId })
271
+
272
+ const client = await new WebexBotClient().login({ token: 'bot-token' })
273
+ await client.sendMessage(usRoomId, 'a')
274
+ await client.sendMessage(usRoomId, 'b')
275
+
276
+ expect(fetchCalls.filter((c) => isRoomsList(c.url))).toHaveLength(1)
277
+ })
278
+
279
+ it('follows Link pages until the room is found on a later page', async () => {
280
+ // given the matching room is only on the second page
281
+ mockRoomsPage([{ id: clusteredId('00000000-0000-0000-0000-000000000000'), type: 'group' }], 'cursor1')
282
+ mockRoomsPage([{ id: clusteredRoomId, type: 'group' }])
283
+ mockResponse({ id: 'msg-1', roomId: clusteredRoomId, roomType: 'group' })
284
+
285
+ // when sending to a room not present on the first page
286
+ const client = await new WebexBotClient().login({ token: 'bot-token' })
287
+ await client.sendMessage(usRoomId, 'hi')
288
+
289
+ // then the second page is fetched via the Link cursor and the send targets the match
290
+ expect(isRoomsList(fetchCalls[0].url)).toBe(true)
291
+ expect(isRoomsList(fetchCalls[1].url)).toBe(true)
292
+ expect(fetchCalls[1].url).toContain('before=cursor1')
293
+ expect(JSON.parse(fetchCalls[2].options?.body as string).roomId).toBe(clusteredRoomId)
294
+ })
295
+
296
+ it('stops paging once the room is found and does not fetch later pages', async () => {
297
+ // given a first page that already contains the match but still advertises a next page
298
+ mockRoomsPage([{ id: clusteredRoomId, type: 'group' }], 'cursor1')
299
+ mockResponse({ id: 'msg-1', roomId: clusteredRoomId, roomType: 'group' })
300
+
301
+ const client = await new WebexBotClient().login({ token: 'bot-token' })
302
+ await client.sendMessage(usRoomId, 'hi')
303
+
304
+ // then only the first page is fetched (a second page fetch would hit the unmocked response and throw)
305
+ expect(fetchCalls.filter((c) => isRoomsList(c.url))).toHaveLength(1)
306
+ expect(JSON.parse(fetchCalls[1].options?.body as string).roomId).toBe(clusteredRoomId)
307
+ })
308
+
309
+ it('fails open to the un-clustered id and warns when no room matches', async () => {
310
+ const warnSpy = spyOn(console, 'warn').mockImplementation(() => {})
311
+ mockResponse({ items: [] })
312
+ mockResponse({ id: 'msg-1', roomId: usRoomId })
313
+
314
+ const client = await new WebexBotClient().login({ token: 'bot-token' })
315
+ await expect(client.sendMessage(usRoomId, 'x')).resolves.toBeDefined()
316
+
317
+ expect(JSON.parse(fetchCalls[1].options?.body as string).roomId).toBe(usRoomId)
318
+ expect(warnSpy).toHaveBeenCalled()
319
+ warnSpy.mockRestore()
320
+ })
321
+ })
198
322
  })
@@ -2,10 +2,33 @@ import { WebexClient } from '../webex/client'
2
2
  import type { WebexMembership, WebexMessage, WebexPerson, WebexSpace } from '../webex/types'
3
3
  import { WebexBotError } from './types'
4
4
 
5
+ interface DecodedWebexId {
6
+ cluster: string
7
+ type: string
8
+ uuid: string
9
+ }
10
+
11
+ // Webex REST ids are base64(url) of `ciscospark://<cluster>/<TYPE>/<uuid>`; the
12
+ // cluster correction needs all three parts, not just the <uuid> `fromRestId` returns.
13
+ function decodeWebexId(restId: string): DecodedWebexId | null {
14
+ if (!restId) return null
15
+ const decoded = Buffer.from(restId, 'base64').toString('utf-8')
16
+ const match = decoded.match(/^ciscospark:\/\/([^/]+)\/([^/]+)\/(.+)$/)
17
+ if (!match) return null
18
+ return { cluster: match[1], type: match[2], uuid: match[3] }
19
+ }
20
+
5
21
  export class WebexBotClient {
6
22
  private client = new WebexClient()
7
23
  private token: string | null = null
8
24
 
25
+ // The listener flattens room ids to `ciscospark://us/ROOM/<uuid>`, but team/group
26
+ // rooms live on `ciscospark://urn:TEAM:<cluster>/ROOM/<uuid>` — a cluster the bare
27
+ // uuid cannot recover. Cache the real clustered id per uuid and dedupe concurrent
28
+ // lookups so a burst of calls triggers a single `listSpaces`.
29
+ private clusteredRoomIds = new Map<string, string>()
30
+ private roomIdLookups = new Map<string, Promise<string>>()
31
+
9
32
  async login(credentials?: { token: string }): Promise<this> {
10
33
  if (credentials) {
11
34
  if (!credentials.token) {
@@ -41,7 +64,7 @@ export class WebexBotClient {
41
64
  }
42
65
 
43
66
  async getSpace(spaceId: string): Promise<WebexSpace> {
44
- return this.client.getSpace(spaceId)
67
+ return this.client.getSpace(await this.resolveRoomId(spaceId))
45
68
  }
46
69
 
47
70
  async sendMessage(
@@ -49,7 +72,7 @@ export class WebexBotClient {
49
72
  text: string,
50
73
  options?: { markdown?: boolean; parentId?: string; files?: string[] },
51
74
  ): Promise<WebexMessage> {
52
- return this.client.sendMessage(roomId, text, options)
75
+ return this.client.sendMessage(await this.resolveRoomId(roomId), text, options)
53
76
  }
54
77
 
55
78
  async sendDirectMessage(personEmail: string, text: string, options?: { markdown?: boolean }): Promise<WebexMessage> {
@@ -57,16 +80,21 @@ export class WebexBotClient {
57
80
  }
58
81
 
59
82
  async listMessages(roomId: string, options?: { max?: number; parentId?: string }): Promise<WebexMessage[]> {
60
- const space = await this.client.getSpace(roomId)
83
+ const resolvedRoomId = await this.resolveRoomId(roomId)
84
+ const space = await this.client.getSpace(resolvedRoomId)
61
85
  const messageOptions = space.type === 'group' ? { ...options, mentionedPeople: 'me' } : options
62
- return this.client.listMessages(roomId, messageOptions)
86
+ return this.client.listMessages(resolvedRoomId, messageOptions)
63
87
  }
64
88
 
65
89
  async listReplies(roomId: string, parentId: string, options?: { max?: number }): Promise<WebexMessage[]> {
66
- return this.client.listMessages(roomId, { ...options, parentId })
90
+ return this.client.listMessages(await this.resolveRoomId(roomId), { ...options, parentId })
67
91
  }
68
92
 
69
93
  async getMessage(messageId: string): Promise<WebexMessage> {
94
+ // MESSAGE ids carry their parent room's cluster, which the bare-UUID normalizer
95
+ // also flattens to `us`. Correcting that needs the room context, which a lone
96
+ // messageId does not provide; room-keyed calls (the reported failures) are
97
+ // corrected via resolveRoomId instead.
70
98
  return this.client.getMessage(messageId)
71
99
  }
72
100
 
@@ -80,7 +108,7 @@ export class WebexBotClient {
80
108
  text: string,
81
109
  options?: { markdown?: boolean },
82
110
  ): Promise<WebexMessage> {
83
- return this.client.editMessage(messageId, roomId, text, options)
111
+ return this.client.editMessage(messageId, await this.resolveRoomId(roomId), text, options)
84
112
  }
85
113
 
86
114
  async listPeople(options?: { email?: string; displayName?: string; max?: number }): Promise<WebexPerson[]> {
@@ -96,7 +124,7 @@ export class WebexBotClient {
96
124
  }
97
125
 
98
126
  async listMemberships(roomId: string, options?: { max?: number }): Promise<WebexMembership[]> {
99
- return this.client.listMemberships(roomId, options)
127
+ return this.client.listMemberships(await this.resolveRoomId(roomId), options)
100
128
  }
101
129
 
102
130
  async uploadFile(
@@ -104,10 +132,53 @@ export class WebexBotClient {
104
132
  file: { content: Blob; filename: string },
105
133
  options?: { text?: string; markdown?: boolean; parentId?: string },
106
134
  ): Promise<WebexMessage> {
107
- return this.client.uploadFile(roomId, file, options)
135
+ return this.client.uploadFile(await this.resolveRoomId(roomId), file, options)
108
136
  }
109
137
 
110
138
  async downloadContent(contentRef: string): Promise<{ data: ArrayBuffer; filename: string; contentType: string }> {
111
139
  return this.client.downloadContent(contentRef)
112
140
  }
141
+
142
+ private async resolveRoomId(roomId: string): Promise<string> {
143
+ const decoded = decodeWebexId(roomId)
144
+ // Already cluster-qualified or undecodable: nothing to correct.
145
+ if (!decoded || decoded.cluster.startsWith('urn:')) return roomId
146
+
147
+ const { uuid } = decoded
148
+ const cached = this.clusteredRoomIds.get(uuid)
149
+ if (cached) return cached
150
+
151
+ const inFlight = this.roomIdLookups.get(uuid)
152
+ if (inFlight) return inFlight
153
+
154
+ const lookup = this.lookupRoomId(uuid, roomId)
155
+ this.roomIdLookups.set(uuid, lookup)
156
+ try {
157
+ return await lookup
158
+ } finally {
159
+ this.roomIdLookups.delete(uuid)
160
+ }
161
+ }
162
+
163
+ private async lookupRoomId(uuid: string, fallback: string): Promise<string> {
164
+ try {
165
+ // Page through every room the bot belongs to (largest page size, following
166
+ // `Link` pages), stopping as soon as the trailing UUID matches.
167
+ for await (const room of this.client.iterateSpaces({ max: 1000 })) {
168
+ if (decodeWebexId(room.id)?.uuid === uuid) {
169
+ this.clusteredRoomIds.set(uuid, room.id)
170
+ return room.id
171
+ }
172
+ }
173
+ } catch {
174
+ // Network/auth failure: fail open to the un-corrected id rather than block the call.
175
+ return fallback
176
+ }
177
+
178
+ console.warn(
179
+ `[webexbot] Could not resolve clustered room id for ${uuid}; falling back to the un-clustered id. ` +
180
+ 'Room-scoped calls may fail if this room lives on a non-default Webex cluster.',
181
+ )
182
+ return fallback
183
+ }
113
184
  }