agent-messenger 2.22.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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +21 -0
- package/dist/package.json +1 -1
- package/dist/src/platforms/webex/client.d.ts +6 -0
- package/dist/src/platforms/webex/client.d.ts.map +1 -1
- package/dist/src/platforms/webex/client.js +34 -4
- package/dist/src/platforms/webex/client.js.map +1 -1
- package/dist/src/platforms/webex/commands/auth.d.ts +9 -1
- package/dist/src/platforms/webex/commands/auth.d.ts.map +1 -1
- package/dist/src/platforms/webex/commands/auth.js +141 -25
- package/dist/src/platforms/webex/commands/auth.js.map +1 -1
- package/dist/src/platforms/webex/credential-manager.d.ts.map +1 -1
- package/dist/src/platforms/webex/credential-manager.js +8 -4
- package/dist/src/platforms/webex/credential-manager.js.map +1 -1
- package/dist/src/platforms/webex/id-normalizer.d.ts +19 -0
- package/dist/src/platforms/webex/id-normalizer.d.ts.map +1 -0
- package/dist/src/platforms/webex/id-normalizer.js +60 -0
- package/dist/src/platforms/webex/id-normalizer.js.map +1 -0
- package/dist/src/platforms/webex/index.d.ts +4 -0
- package/dist/src/platforms/webex/index.d.ts.map +1 -1
- package/dist/src/platforms/webex/index.js +2 -0
- package/dist/src/platforms/webex/index.js.map +1 -1
- package/dist/src/platforms/webex/listener.d.ts +61 -0
- package/dist/src/platforms/webex/listener.d.ts.map +1 -0
- package/dist/src/platforms/webex/listener.js +222 -0
- package/dist/src/platforms/webex/listener.js.map +1 -0
- package/dist/src/platforms/webex/password-login.d.ts +18 -0
- package/dist/src/platforms/webex/password-login.d.ts.map +1 -0
- package/dist/src/platforms/webex/password-login.js +259 -0
- package/dist/src/platforms/webex/password-login.js.map +1 -0
- package/dist/src/platforms/webex/types.d.ts +2 -1
- package/dist/src/platforms/webex/types.d.ts.map +1 -1
- package/dist/src/platforms/webex/types.js +1 -1
- package/dist/src/platforms/webex/types.js.map +1 -1
- package/dist/src/platforms/webex/wdm-discovery.d.ts.map +1 -0
- package/dist/src/platforms/{webexbot → webex}/wdm-discovery.js +3 -3
- package/dist/src/platforms/webex/wdm-discovery.js.map +1 -0
- package/dist/src/platforms/webexbot/client.d.ts +4 -0
- package/dist/src/platforms/webexbot/client.d.ts.map +1 -1
- package/dist/src/platforms/webexbot/client.js +70 -8
- package/dist/src/platforms/webexbot/client.js.map +1 -1
- package/dist/src/platforms/webexbot/index.d.ts +2 -0
- package/dist/src/platforms/webexbot/index.d.ts.map +1 -1
- package/dist/src/platforms/webexbot/index.js +1 -0
- package/dist/src/platforms/webexbot/index.js.map +1 -1
- package/dist/src/platforms/webexbot/listener.d.ts +3 -41
- package/dist/src/platforms/webexbot/listener.d.ts.map +1 -1
- package/dist/src/platforms/webexbot/listener.js +13 -208
- package/dist/src/platforms/webexbot/listener.js.map +1 -1
- package/dist/src/platforms/webexbot/types.d.ts +1 -18
- package/dist/src/platforms/webexbot/types.d.ts.map +1 -1
- package/dist/src/platforms/webexbot/types.js.map +1 -1
- package/docs/content/docs/cli/webex.mdx +38 -12
- package/docs/content/docs/sdk/webexbot.mdx +16 -0
- package/package.json +1 -1
- package/skills/agent-channeltalk/SKILL.md +1 -1
- package/skills/agent-channeltalkbot/SKILL.md +1 -1
- package/skills/agent-discord/SKILL.md +1 -1
- package/skills/agent-discordbot/SKILL.md +1 -1
- package/skills/agent-instagram/SKILL.md +1 -1
- package/skills/agent-kakaotalk/SKILL.md +1 -1
- package/skills/agent-line/SKILL.md +1 -1
- package/skills/agent-slack/SKILL.md +1 -1
- package/skills/agent-slackbot/SKILL.md +1 -1
- package/skills/agent-teams/SKILL.md +1 -1
- package/skills/agent-telegram/SKILL.md +1 -1
- package/skills/agent-telegrambot/SKILL.md +1 -1
- package/skills/agent-webex/SKILL.md +76 -22
- package/skills/agent-webex/references/authentication.md +55 -14
- package/skills/agent-webex/references/common-patterns.md +5 -2
- package/skills/agent-webexbot/SKILL.md +3 -1
- package/skills/agent-wechatbot/SKILL.md +1 -1
- package/skills/agent-whatsapp/SKILL.md +1 -1
- package/skills/agent-whatsappbot/SKILL.md +1 -1
- package/src/platforms/webex/cli.test.ts +31 -1
- package/src/platforms/webex/client.test.ts +57 -0
- package/src/platforms/webex/client.ts +39 -4
- package/src/platforms/webex/commands/auth.test.ts +189 -28
- package/src/platforms/webex/commands/auth.ts +194 -35
- package/src/platforms/webex/credential-manager.test.ts +40 -0
- package/src/platforms/webex/credential-manager.ts +7 -4
- package/src/platforms/webex/id-normalizer.test.ts +207 -0
- package/src/platforms/webex/id-normalizer.ts +76 -0
- package/src/platforms/webex/index.test.ts +6 -0
- package/src/platforms/webex/index.ts +4 -0
- package/src/platforms/webex/listener.test.ts +243 -0
- package/src/platforms/webex/listener.ts +285 -0
- package/src/platforms/webex/password-login.test.ts +193 -0
- package/src/platforms/webex/password-login.ts +332 -0
- package/src/platforms/webex/types.test.ts +16 -0
- package/src/platforms/webex/types.ts +2 -2
- package/src/platforms/{webexbot → webex}/wdm-discovery.ts +3 -3
- package/src/platforms/webexbot/client.test.ts +125 -1
- package/src/platforms/webexbot/client.ts +79 -8
- package/src/platforms/webexbot/index.ts +2 -0
- package/src/platforms/webexbot/listener.test.ts +37 -224
- package/src/platforms/webexbot/listener.ts +18 -250
- package/src/platforms/webexbot/types.ts +2 -23
- package/dist/src/platforms/webexbot/wdm-discovery.d.ts.map +0 -1
- package/dist/src/platforms/webexbot/wdm-discovery.js.map +0 -1
- /package/dist/src/platforms/{webexbot → webex}/wdm-discovery.d.ts +0 -0
- /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(/&/g, '&')
|
|
265
|
+
.replace(/"/g, '"')
|
|
266
|
+
.replace(/'/g, "'")
|
|
267
|
+
.replace(/</g, '<')
|
|
268
|
+
.replace(/>/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 {
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
}
|