agent-messenger 2.9.0 → 2.10.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.
- package/.claude-plugin/plugin.json +1 -1
- package/dist/package.json +1 -1
- package/dist/src/platforms/teams/client.d.ts +9 -1
- package/dist/src/platforms/teams/client.d.ts.map +1 -1
- package/dist/src/platforms/teams/client.js +69 -18
- package/dist/src/platforms/teams/client.js.map +1 -1
- package/dist/src/platforms/teams/commands/auth.d.ts.map +1 -1
- package/dist/src/platforms/teams/commands/auth.js +7 -2
- package/dist/src/platforms/teams/commands/auth.js.map +1 -1
- package/dist/src/platforms/teams/commands/channel.d.ts.map +1 -1
- package/dist/src/platforms/teams/commands/channel.js +18 -3
- package/dist/src/platforms/teams/commands/channel.js.map +1 -1
- package/dist/src/platforms/teams/commands/file.d.ts.map +1 -1
- package/dist/src/platforms/teams/commands/file.js +18 -3
- package/dist/src/platforms/teams/commands/file.js.map +1 -1
- package/dist/src/platforms/teams/commands/message.d.ts.map +1 -1
- package/dist/src/platforms/teams/commands/message.js +24 -4
- package/dist/src/platforms/teams/commands/message.js.map +1 -1
- package/dist/src/platforms/teams/commands/reaction.d.ts.map +1 -1
- package/dist/src/platforms/teams/commands/reaction.js +12 -2
- package/dist/src/platforms/teams/commands/reaction.js.map +1 -1
- package/dist/src/platforms/teams/commands/snapshot.d.ts.map +1 -1
- package/dist/src/platforms/teams/commands/snapshot.js +6 -1
- package/dist/src/platforms/teams/commands/snapshot.js.map +1 -1
- package/dist/src/platforms/teams/commands/team.d.ts.map +1 -1
- package/dist/src/platforms/teams/commands/team.js +6 -1
- package/dist/src/platforms/teams/commands/team.js.map +1 -1
- package/dist/src/platforms/teams/commands/user.d.ts.map +1 -1
- package/dist/src/platforms/teams/commands/user.js +18 -3
- package/dist/src/platforms/teams/commands/user.js.map +1 -1
- package/dist/src/platforms/teams/commands/whoami.d.ts.map +1 -1
- package/dist/src/platforms/teams/commands/whoami.js +6 -1
- package/dist/src/platforms/teams/commands/whoami.js.map +1 -1
- package/dist/src/platforms/teams/credential-manager.d.ts +3 -1
- package/dist/src/platforms/teams/credential-manager.d.ts.map +1 -1
- package/dist/src/platforms/teams/credential-manager.js +6 -1
- package/dist/src/platforms/teams/credential-manager.js.map +1 -1
- package/dist/src/platforms/teams/ensure-auth.d.ts.map +1 -1
- package/dist/src/platforms/teams/ensure-auth.js +7 -2
- package/dist/src/platforms/teams/ensure-auth.js.map +1 -1
- package/dist/src/platforms/teams/token-extractor.d.ts +3 -1
- package/dist/src/platforms/teams/token-extractor.d.ts.map +1 -1
- package/dist/src/platforms/teams/token-extractor.js +73 -10
- package/dist/src/platforms/teams/token-extractor.js.map +1 -1
- package/dist/src/platforms/teams/types.d.ts +17 -0
- package/dist/src/platforms/teams/types.d.ts.map +1 -1
- package/dist/src/platforms/teams/types.js +2 -0
- package/dist/src/platforms/teams/types.js.map +1 -1
- package/dist/src/platforms/webex/client.d.ts +3 -0
- package/dist/src/platforms/webex/client.d.ts.map +1 -1
- package/dist/src/platforms/webex/client.js +58 -13
- package/dist/src/platforms/webex/client.js.map +1 -1
- package/dist/src/platforms/webex/commands/auth.d.ts.map +1 -1
- package/dist/src/platforms/webex/commands/auth.js +61 -10
- 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 +18 -6
- package/dist/src/platforms/webex/credential-manager.js.map +1 -1
- package/dist/src/platforms/webex/encryption.d.ts.map +1 -1
- package/dist/src/platforms/webex/encryption.js +3 -1
- package/dist/src/platforms/webex/encryption.js.map +1 -1
- package/dist/src/platforms/webex/ensure-auth.d.ts.map +1 -1
- package/dist/src/platforms/webex/ensure-auth.js +10 -2
- package/dist/src/platforms/webex/ensure-auth.js.map +1 -1
- package/dist/src/platforms/webex/token-extractor.d.ts +1 -0
- package/dist/src/platforms/webex/token-extractor.d.ts.map +1 -1
- package/dist/src/platforms/webex/token-extractor.js +21 -4
- package/dist/src/platforms/webex/token-extractor.js.map +1 -1
- package/e2e/webex.e2e.test.ts +57 -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-webex/SKILL.md +1 -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/teams/client.test.ts +34 -30
- package/src/platforms/teams/client.ts +92 -20
- package/src/platforms/teams/commands/auth.test.ts +6 -2
- package/src/platforms/teams/commands/auth.ts +7 -2
- package/src/platforms/teams/commands/channel.test.ts +6 -6
- package/src/platforms/teams/commands/channel.ts +18 -3
- package/src/platforms/teams/commands/file.ts +18 -3
- package/src/platforms/teams/commands/message.ts +24 -4
- package/src/platforms/teams/commands/reaction.ts +12 -2
- package/src/platforms/teams/commands/snapshot.ts +6 -1
- package/src/platforms/teams/commands/team.test.ts +2 -2
- package/src/platforms/teams/commands/team.ts +6 -1
- package/src/platforms/teams/commands/user.ts +18 -3
- package/src/platforms/teams/commands/whoami.ts +6 -1
- package/src/platforms/teams/credential-manager.test.ts +25 -0
- package/src/platforms/teams/credential-manager.ts +13 -3
- package/src/platforms/teams/ensure-auth.test.ts +6 -1
- package/src/platforms/teams/ensure-auth.ts +7 -2
- package/src/platforms/teams/token-extractor.test.ts +112 -98
- package/src/platforms/teams/token-extractor.ts +83 -12
- package/src/platforms/teams/types.test.ts +17 -0
- package/src/platforms/teams/types.ts +6 -0
- package/src/platforms/webex/client.test.ts +157 -13
- package/src/platforms/webex/client.ts +64 -15
- package/src/platforms/webex/commands/auth.test.ts +122 -1
- package/src/platforms/webex/commands/auth.ts +72 -17
- package/src/platforms/webex/credential-manager.test.ts +63 -0
- package/src/platforms/webex/credential-manager.ts +22 -8
- package/src/platforms/webex/encryption.test.ts +54 -0
- package/src/platforms/webex/encryption.ts +3 -1
- package/src/platforms/webex/ensure-auth.ts +10 -2
- package/src/platforms/webex/token-extractor.test.ts +32 -3
- package/src/platforms/webex/token-extractor.ts +26 -5
|
@@ -55,9 +55,11 @@ export class TeamsTokenExtractor {
|
|
|
55
55
|
private platform: NodeJS.Platform
|
|
56
56
|
private decryptor: ChromiumCookieDecryptor
|
|
57
57
|
private cookieReader: ChromiumCookieReader
|
|
58
|
+
private debugLog: ((message: string) => void) | null
|
|
58
59
|
|
|
59
|
-
constructor(platform?: NodeJS.Platform, keyCache?: DerivedKeyCache) {
|
|
60
|
+
constructor(platform?: NodeJS.Platform, keyCache?: DerivedKeyCache, debugLog?: (message: string) => void) {
|
|
60
61
|
this.platform = platform ?? process.platform
|
|
62
|
+
this.debugLog = debugLog ?? null
|
|
61
63
|
|
|
62
64
|
const resolvedKeyCache = keyCache ?? new DerivedKeyCache()
|
|
63
65
|
this.decryptor = new ChromiumCookieDecryptor({
|
|
@@ -69,6 +71,10 @@ export class TeamsTokenExtractor {
|
|
|
69
71
|
this.cookieReader = new ChromiumCookieReader()
|
|
70
72
|
}
|
|
71
73
|
|
|
74
|
+
private debug(message: string): void {
|
|
75
|
+
this.debugLog?.(message)
|
|
76
|
+
}
|
|
77
|
+
|
|
72
78
|
getDesktopCookiesPaths(): TeamsCookiePath[] {
|
|
73
79
|
switch (this.platform) {
|
|
74
80
|
case 'darwin': {
|
|
@@ -86,8 +92,11 @@ export class TeamsTokenExtractor {
|
|
|
86
92
|
)
|
|
87
93
|
return [
|
|
88
94
|
{ path: join(ebWebViewBase, 'WV2Profile_tfw', 'Cookies'), accountType: 'work' },
|
|
95
|
+
{ path: join(ebWebViewBase, 'WV2Profile_tfw', 'Network', 'Cookies'), accountType: 'work' },
|
|
89
96
|
{ path: join(ebWebViewBase, 'WV2Profile_tfl', 'Cookies'), accountType: 'personal' },
|
|
97
|
+
{ path: join(ebWebViewBase, 'WV2Profile_tfl', 'Network', 'Cookies'), accountType: 'personal' },
|
|
90
98
|
{ path: join(ebWebViewBase, 'Default', 'Cookies'), accountType: 'work' },
|
|
99
|
+
{ path: join(ebWebViewBase, 'Default', 'Network', 'Cookies'), accountType: 'work' },
|
|
91
100
|
{
|
|
92
101
|
path: join(homedir(), 'Library', 'Application Support', 'Microsoft', 'Teams', 'Cookies'),
|
|
93
102
|
accountType: 'work',
|
|
@@ -115,8 +124,11 @@ export class TeamsTokenExtractor {
|
|
|
115
124
|
)
|
|
116
125
|
return [
|
|
117
126
|
{ path: join(ebWebViewBase, 'WV2Profile_tfw', 'Cookies'), accountType: 'work' },
|
|
127
|
+
{ path: join(ebWebViewBase, 'WV2Profile_tfw', 'Network', 'Cookies'), accountType: 'work' },
|
|
118
128
|
{ path: join(ebWebViewBase, 'WV2Profile_tfl', 'Cookies'), accountType: 'personal' },
|
|
129
|
+
{ path: join(ebWebViewBase, 'WV2Profile_tfl', 'Network', 'Cookies'), accountType: 'personal' },
|
|
119
130
|
{ path: join(ebWebViewBase, 'Default', 'Cookies'), accountType: 'work' },
|
|
131
|
+
{ path: join(ebWebViewBase, 'Default', 'Network', 'Cookies'), accountType: 'work' },
|
|
120
132
|
{ path: join(appdata, 'Microsoft', 'Teams', 'Cookies'), accountType: 'work' },
|
|
121
133
|
]
|
|
122
134
|
}
|
|
@@ -197,17 +209,38 @@ export class TeamsTokenExtractor {
|
|
|
197
209
|
private async extractFromCookiesDB(): Promise<ExtractedTeamsToken[]> {
|
|
198
210
|
const results: ExtractedTeamsToken[] = []
|
|
199
211
|
const seenAccountTypes = new Set<TeamsAccountType>()
|
|
212
|
+
const allPaths = this.getTeamsCookiesPaths()
|
|
213
|
+
|
|
214
|
+
this.debug(`Scanning ${allPaths.length} candidate cookie path(s)`)
|
|
215
|
+
|
|
216
|
+
for (const { path: dbPath, accountType } of allPaths) {
|
|
217
|
+
if (!dbPath) continue
|
|
218
|
+
|
|
219
|
+
if (!existsSync(dbPath)) {
|
|
220
|
+
this.debug(` [skip] ${dbPath} (not found)`)
|
|
221
|
+
continue
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (seenAccountTypes.has(accountType)) {
|
|
225
|
+
this.debug(` [skip] ${dbPath} (already have ${accountType} account)`)
|
|
226
|
+
continue
|
|
227
|
+
}
|
|
200
228
|
|
|
201
|
-
|
|
202
|
-
if (!dbPath || !existsSync(dbPath) || seenAccountTypes.has(accountType)) continue
|
|
229
|
+
this.debug(` [try] ${dbPath} (${accountType})`)
|
|
203
230
|
|
|
204
231
|
const token = await this.copyAndExtract(dbPath)
|
|
205
232
|
if (token && this.isValidSkypeToken(token)) {
|
|
233
|
+
this.debug(` [ok] Extracted valid token (${token.length} chars)`)
|
|
206
234
|
results.push({ token, accountType })
|
|
207
235
|
seenAccountTypes.add(accountType)
|
|
236
|
+
} else if (token) {
|
|
237
|
+
this.debug(` [fail] Token too short (${token.length} chars, need >=50)`)
|
|
238
|
+
} else {
|
|
239
|
+
this.debug(` [fail] No token extracted`)
|
|
208
240
|
}
|
|
209
241
|
}
|
|
210
242
|
|
|
243
|
+
this.debug(`Extraction complete: ${results.length} token(s) found`)
|
|
211
244
|
return results
|
|
212
245
|
}
|
|
213
246
|
|
|
@@ -216,11 +249,26 @@ export class TeamsTokenExtractor {
|
|
|
216
249
|
|
|
217
250
|
try {
|
|
218
251
|
tempPath = this.copyDatabaseToTemp(dbPath, dbPath)
|
|
219
|
-
|
|
220
|
-
|
|
252
|
+
|
|
253
|
+
let localStatePath: string | undefined
|
|
254
|
+
if (this.platform === 'win32') {
|
|
255
|
+
localStatePath = findLocalStatePath(dbPath) ?? undefined
|
|
256
|
+
if (localStatePath) {
|
|
257
|
+
this.debug(` Local State (from cookie path): ${localStatePath}`)
|
|
258
|
+
} else {
|
|
259
|
+
localStatePath = this.getLocalStatePath()
|
|
260
|
+
if (existsSync(localStatePath)) {
|
|
261
|
+
this.debug(` Local State (fallback): ${localStatePath}`)
|
|
262
|
+
} else {
|
|
263
|
+
this.debug(` Local State not found (tried fallback: ${localStatePath})`)
|
|
264
|
+
localStatePath = undefined
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
221
268
|
|
|
222
269
|
return await this.extractFromSQLite(tempPath, localStatePath)
|
|
223
|
-
} catch {
|
|
270
|
+
} catch (error) {
|
|
271
|
+
this.debug(` Copy/extract error: ${(error as Error).message}`)
|
|
224
272
|
return null
|
|
225
273
|
} finally {
|
|
226
274
|
this.cleanupTempFile(tempPath)
|
|
@@ -231,27 +279,50 @@ export class TeamsTokenExtractor {
|
|
|
231
279
|
try {
|
|
232
280
|
for (const hostPattern of TEAMS_HOST_PATTERNS) {
|
|
233
281
|
const sql = `
|
|
234
|
-
SELECT encrypted_value
|
|
282
|
+
SELECT value, encrypted_value
|
|
235
283
|
FROM cookies
|
|
236
284
|
WHERE name = '${SKYPETOKEN_COOKIE_NAME}'
|
|
237
285
|
AND host_key LIKE '%${hostPattern}%'
|
|
238
286
|
LIMIT 1
|
|
239
287
|
`
|
|
240
288
|
|
|
241
|
-
type CookieRow = { encrypted_value?: Uint8Array | Buffer } | null
|
|
289
|
+
type CookieRow = { value?: string; encrypted_value?: Uint8Array | Buffer } | null
|
|
242
290
|
|
|
243
291
|
const row = await this.cookieReader.queryFirst<CookieRow>(dbPath, sql)
|
|
244
|
-
if (!row
|
|
292
|
+
if (!row) continue
|
|
293
|
+
|
|
294
|
+
if (row.value && row.value.length >= 50) {
|
|
295
|
+
this.debug(` Found plaintext cookie for ${hostPattern} (${row.value.length} chars)`)
|
|
296
|
+
return row.value
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (!row.encrypted_value || row.encrypted_value.length === 0) {
|
|
300
|
+
this.debug(` No cookie data for ${hostPattern}`)
|
|
301
|
+
continue
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const encBuf = Buffer.from(row.encrypted_value)
|
|
305
|
+
const isEncrypted = this.isEncryptedValue(encBuf)
|
|
306
|
+
this.debug(
|
|
307
|
+
` Found cookie for ${hostPattern}: ${encBuf.length} bytes, encrypted=${isEncrypted}, prefix=${encBuf.subarray(0, 3).toString('utf8')}`,
|
|
308
|
+
)
|
|
245
309
|
|
|
246
|
-
const decryptedBuf = this.decryptor.decryptCookieRaw(
|
|
247
|
-
if (!decryptedBuf)
|
|
310
|
+
const decryptedBuf = this.decryptor.decryptCookieRaw(encBuf, localStatePath)
|
|
311
|
+
if (!decryptedBuf) {
|
|
312
|
+
this.debug(` Decryption failed`)
|
|
313
|
+
continue
|
|
314
|
+
}
|
|
248
315
|
|
|
316
|
+
this.debug(` Decrypted: ${decryptedBuf.length} bytes`)
|
|
249
317
|
const token = this.postProcessDecrypted(decryptedBuf)
|
|
250
318
|
if (this.isValidSkypeToken(token)) return token
|
|
319
|
+
|
|
320
|
+
this.debug(` Post-process result not a valid token (${token.length} chars)`)
|
|
251
321
|
}
|
|
252
322
|
|
|
253
323
|
return null
|
|
254
|
-
} catch {
|
|
324
|
+
} catch (error) {
|
|
325
|
+
this.debug(` SQLite query error: ${(error as Error).message}`)
|
|
255
326
|
return null
|
|
256
327
|
}
|
|
257
328
|
}
|
|
@@ -203,6 +203,7 @@ test('TeamsConfigSchema validates config with multiple accounts', () => {
|
|
|
203
203
|
work: {
|
|
204
204
|
token: 'work_token',
|
|
205
205
|
token_expires_at: '2024-01-01T00:00:00.000Z',
|
|
206
|
+
region: 'emea',
|
|
206
207
|
account_type: 'work',
|
|
207
208
|
user_name: 'Work User',
|
|
208
209
|
current_team: '19:abc123@thread.tacv2',
|
|
@@ -224,6 +225,22 @@ test('TeamsConfigSchema validates config with multiple accounts', () => {
|
|
|
224
225
|
expect(result.success).toBe(true)
|
|
225
226
|
})
|
|
226
227
|
|
|
228
|
+
test('TeamsConfigSchema rejects invalid region', () => {
|
|
229
|
+
const result = TeamsConfigSchema.safeParse({
|
|
230
|
+
current_account: 'work',
|
|
231
|
+
accounts: {
|
|
232
|
+
work: {
|
|
233
|
+
token: 'token_value',
|
|
234
|
+
region: 'invalid',
|
|
235
|
+
account_type: 'work',
|
|
236
|
+
current_team: null,
|
|
237
|
+
teams: {},
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
})
|
|
241
|
+
expect(result.success).toBe(false)
|
|
242
|
+
})
|
|
243
|
+
|
|
227
244
|
test('TeamsConfigSchema rejects missing required fields', () => {
|
|
228
245
|
const result = TeamsConfigSchema.safeParse({
|
|
229
246
|
current_account: null,
|
|
@@ -55,9 +55,12 @@ export interface TeamsCredentials {
|
|
|
55
55
|
|
|
56
56
|
export type TeamsAccountType = 'work' | 'personal'
|
|
57
57
|
|
|
58
|
+
export type TeamsRegion = 'amer' | 'emea' | 'apac'
|
|
59
|
+
|
|
58
60
|
export interface TeamsAccount {
|
|
59
61
|
token: string
|
|
60
62
|
token_expires_at?: string
|
|
63
|
+
region?: TeamsRegion
|
|
61
64
|
account_type: TeamsAccountType
|
|
62
65
|
user_name?: string
|
|
63
66
|
current_team: string | null
|
|
@@ -141,9 +144,12 @@ export const TeamsCredentialsSchema = z.object({
|
|
|
141
144
|
|
|
142
145
|
export const TeamsAccountTypeSchema = z.enum(['work', 'personal'])
|
|
143
146
|
|
|
147
|
+
export const TeamsRegionSchema = z.enum(['amer', 'emea', 'apac'])
|
|
148
|
+
|
|
144
149
|
export const TeamsAccountSchema = z.object({
|
|
145
150
|
token: z.string(),
|
|
146
151
|
token_expires_at: z.string().optional(),
|
|
152
|
+
region: TeamsRegionSchema.optional(),
|
|
147
153
|
account_type: TeamsAccountTypeSchema,
|
|
148
154
|
user_name: z.string().optional(),
|
|
149
155
|
current_team: z.string().nullable(),
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
2
2
|
|
|
3
|
+
import * as jose from 'node-jose'
|
|
4
|
+
|
|
3
5
|
import { WebexClient } from './client'
|
|
6
|
+
import { WebexEncryptionService } from './encryption'
|
|
4
7
|
import { WebexError } from './types'
|
|
5
8
|
|
|
6
9
|
describe('WebexClient', () => {
|
|
@@ -56,6 +59,17 @@ describe('WebexClient', () => {
|
|
|
56
59
|
await expect(new WebexClient().login({ token: '' })).rejects.toThrow(WebexError)
|
|
57
60
|
await expect(new WebexClient().login({ token: '' })).rejects.toThrow('Token is required')
|
|
58
61
|
})
|
|
62
|
+
|
|
63
|
+
test('accepts deviceUrl and tokenType', async () => {
|
|
64
|
+
const client = await new WebexClient().login({
|
|
65
|
+
token: 'test-token',
|
|
66
|
+
deviceUrl: 'https://wdm-r.wbx2.com/wdm/api/v1/devices/dev-1',
|
|
67
|
+
tokenType: 'extracted',
|
|
68
|
+
})
|
|
69
|
+
expect(client).toBeInstanceOf(WebexClient)
|
|
70
|
+
expect((client as any).deviceUrl).toBe('https://wdm-r.wbx2.com/wdm/api/v1/devices/dev-1')
|
|
71
|
+
expect((client as any).tokenType).toBe('extracted')
|
|
72
|
+
})
|
|
59
73
|
})
|
|
60
74
|
|
|
61
75
|
describe('testAuth', () => {
|
|
@@ -84,6 +98,59 @@ describe('WebexClient', () => {
|
|
|
84
98
|
const client = await new WebexClient().login({ token: 'bad-token' })
|
|
85
99
|
await expect(client.testAuth()).rejects.toThrow(WebexError)
|
|
86
100
|
})
|
|
101
|
+
|
|
102
|
+
test('falls back to internal API when public API fails for extracted tokens', async () => {
|
|
103
|
+
// given - public API rejects, internal API succeeds
|
|
104
|
+
mockResponse({ message: 'Unauthorized' }, 401)
|
|
105
|
+
fetchResponses.push(
|
|
106
|
+
new Response(JSON.stringify({ id: 'conv-1', activities: { items: [] } }), {
|
|
107
|
+
status: 200,
|
|
108
|
+
headers: { 'Content-Type': 'application/json' },
|
|
109
|
+
}),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
const client = await new WebexClient().login({
|
|
113
|
+
token: 'extracted-token',
|
|
114
|
+
deviceUrl: 'https://wdm-r.wbx2.com/wdm/api/v1/devices/dev-1',
|
|
115
|
+
tokenType: 'extracted',
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
// when
|
|
119
|
+
const person = await client.testAuth()
|
|
120
|
+
|
|
121
|
+
// then - succeeds via internal API
|
|
122
|
+
expect(fetchCalls.length).toBe(2)
|
|
123
|
+
expect(fetchCalls[0].url).toBe('https://webexapis.com/v1/people/me')
|
|
124
|
+
expect(fetchCalls[1].url).toContain('conv-r.wbx2.com/conversation/api/v1/conversations')
|
|
125
|
+
expect(person).toBeTruthy()
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('throws when both public and internal APIs fail for extracted tokens', async () => {
|
|
129
|
+
// given - both APIs reject
|
|
130
|
+
mockResponse({ message: 'Unauthorized' }, 401)
|
|
131
|
+
fetchResponses.push(
|
|
132
|
+
new Response(JSON.stringify({ message: 'Unauthorized' }), {
|
|
133
|
+
status: 401,
|
|
134
|
+
headers: { 'Content-Type': 'application/json' },
|
|
135
|
+
}),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
const client = await new WebexClient().login({
|
|
139
|
+
token: 'bad-extracted-token',
|
|
140
|
+
deviceUrl: 'https://wdm-r.wbx2.com/wdm/api/v1/devices/dev-1',
|
|
141
|
+
tokenType: 'extracted',
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
await expect(client.testAuth()).rejects.toThrow(WebexError)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test('does not use internal API fallback for non-extracted tokens', async () => {
|
|
148
|
+
mockResponse({ message: 'Unauthorized' }, 401)
|
|
149
|
+
|
|
150
|
+
const client = await new WebexClient().login({ token: 'bad-token' })
|
|
151
|
+
await expect(client.testAuth()).rejects.toThrow(WebexError)
|
|
152
|
+
expect(fetchCalls.length).toBe(1)
|
|
153
|
+
})
|
|
87
154
|
})
|
|
88
155
|
|
|
89
156
|
describe('listSpaces', () => {
|
|
@@ -402,10 +469,11 @@ describe('WebexClient', () => {
|
|
|
402
469
|
})
|
|
403
470
|
|
|
404
471
|
const createExtractedClient = async () => {
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
472
|
+
return new WebexClient().login({
|
|
473
|
+
token: 'extracted-token',
|
|
474
|
+
deviceUrl: TEST_DEVICE_URL,
|
|
475
|
+
tokenType: 'extracted',
|
|
476
|
+
})
|
|
409
477
|
}
|
|
410
478
|
|
|
411
479
|
describe('sendMessage', () => {
|
|
@@ -419,7 +487,7 @@ describe('WebexClient', () => {
|
|
|
419
487
|
expect(fetchCalls[0].options?.method).toBe('POST')
|
|
420
488
|
})
|
|
421
489
|
|
|
422
|
-
test('body has verb, object type, displayName
|
|
490
|
+
test('body has verb, object type, and displayName (no content for plain text)', async () => {
|
|
423
491
|
mockResponse(mockActivity('Hello world'))
|
|
424
492
|
|
|
425
493
|
const client = await createExtractedClient()
|
|
@@ -429,7 +497,7 @@ describe('WebexClient', () => {
|
|
|
429
497
|
expect(body.verb).toBe('post')
|
|
430
498
|
expect(body.object.objectType).toBe('comment')
|
|
431
499
|
expect(body.object.displayName).toBe('Hello world')
|
|
432
|
-
expect(body.object.content).
|
|
500
|
+
expect(body.object.content).toBeUndefined()
|
|
433
501
|
})
|
|
434
502
|
|
|
435
503
|
test('body has target with decoded conv UUID and conversation type', async () => {
|
|
@@ -488,7 +556,7 @@ describe('WebexClient', () => {
|
|
|
488
556
|
expect(body.object.markdown).toBeUndefined()
|
|
489
557
|
})
|
|
490
558
|
|
|
491
|
-
test('
|
|
559
|
+
test('plain text messages omit content field', async () => {
|
|
492
560
|
mockResponse(mockActivity('Hello world'))
|
|
493
561
|
|
|
494
562
|
const client = await createExtractedClient()
|
|
@@ -496,7 +564,7 @@ describe('WebexClient', () => {
|
|
|
496
564
|
|
|
497
565
|
const body = JSON.parse(fetchCalls[0].options?.body as string)
|
|
498
566
|
expect(body.object.displayName).toBe('Hello world')
|
|
499
|
-
expect(body.object.content).
|
|
567
|
+
expect(body.object.content).toBeUndefined()
|
|
500
568
|
})
|
|
501
569
|
})
|
|
502
570
|
|
|
@@ -620,8 +688,11 @@ describe('WebexClient', () => {
|
|
|
620
688
|
})
|
|
621
689
|
|
|
622
690
|
describe('editMessage', () => {
|
|
691
|
+
const mockEditActivity = (text: string, parentId = 'activity-123') =>
|
|
692
|
+
mockActivity(text, { parent: { id: parentId, type: 'edit' } })
|
|
693
|
+
|
|
623
694
|
test('posts activity with verb post and parent edit reference', async () => {
|
|
624
|
-
mockResponse(
|
|
695
|
+
mockResponse(mockEditActivity('Edited text'))
|
|
625
696
|
|
|
626
697
|
const client = await createExtractedClient()
|
|
627
698
|
await client.editMessage('activity-123', TEST_ROOM_ID, 'Edited text')
|
|
@@ -631,8 +702,8 @@ describe('WebexClient', () => {
|
|
|
631
702
|
expect(body.parent).toEqual({ id: 'activity-123', type: 'edit' })
|
|
632
703
|
})
|
|
633
704
|
|
|
634
|
-
test('
|
|
635
|
-
mockResponse(
|
|
705
|
+
test('plain text edit populates both displayName and content to avoid auto-tombstone', async () => {
|
|
706
|
+
mockResponse(mockEditActivity('Edited text'))
|
|
636
707
|
|
|
637
708
|
const client = await createExtractedClient()
|
|
638
709
|
await client.editMessage('activity-123', TEST_ROOM_ID, 'Edited text')
|
|
@@ -643,8 +714,18 @@ describe('WebexClient', () => {
|
|
|
643
714
|
expect(body.object.content).toBe('Edited text')
|
|
644
715
|
})
|
|
645
716
|
|
|
717
|
+
test('clientTempId uses -edit suffix to match Webex web client format', async () => {
|
|
718
|
+
mockResponse(mockEditActivity('Edited text'))
|
|
719
|
+
|
|
720
|
+
const client = await createExtractedClient()
|
|
721
|
+
await client.editMessage('activity-123', TEST_ROOM_ID, 'Edited text')
|
|
722
|
+
|
|
723
|
+
const body = JSON.parse(fetchCalls[0].options?.body as string)
|
|
724
|
+
expect(body.clientTempId).toMatch(/^tmp-\d+-edit$/)
|
|
725
|
+
})
|
|
726
|
+
|
|
646
727
|
test('target has decoded conv UUID', async () => {
|
|
647
|
-
mockResponse(
|
|
728
|
+
mockResponse(mockEditActivity('Edited text'))
|
|
648
729
|
|
|
649
730
|
const client = await createExtractedClient()
|
|
650
731
|
await client.editMessage('activity-123', TEST_ROOM_ID, 'Edited text')
|
|
@@ -654,7 +735,7 @@ describe('WebexClient', () => {
|
|
|
654
735
|
})
|
|
655
736
|
|
|
656
737
|
test('markdown option converts content to HTML and strips displayName', async () => {
|
|
657
|
-
mockResponse(
|
|
738
|
+
mockResponse(mockEditActivity('italic text'))
|
|
658
739
|
|
|
659
740
|
const client = await createExtractedClient()
|
|
660
741
|
await client.editMessage('activity-123', TEST_ROOM_ID, '_italic text_', { markdown: true })
|
|
@@ -664,6 +745,69 @@ describe('WebexClient', () => {
|
|
|
664
745
|
expect(body.object.content).toBe('<em>italic text</em>')
|
|
665
746
|
expect(body.object.markdown).toBeUndefined()
|
|
666
747
|
})
|
|
748
|
+
|
|
749
|
+
test('tolerates responses that omit parent (minimal success shape)', async () => {
|
|
750
|
+
mockResponse(mockActivity('Edited text'))
|
|
751
|
+
|
|
752
|
+
const client = await createExtractedClient()
|
|
753
|
+
const message = await client.editMessage('activity-123', TEST_ROOM_ID, 'Edited text')
|
|
754
|
+
expect(message.id).toBe('activity-123')
|
|
755
|
+
})
|
|
756
|
+
|
|
757
|
+
test('throws when server returns activity linked to a different parent', async () => {
|
|
758
|
+
mockResponse(mockEditActivity('Edited text', 'activity-999'))
|
|
759
|
+
|
|
760
|
+
const client = await createExtractedClient()
|
|
761
|
+
await expect(client.editMessage('activity-123', TEST_ROOM_ID, 'Edited text')).rejects.toThrow(/Edit rejected/)
|
|
762
|
+
})
|
|
763
|
+
})
|
|
764
|
+
|
|
765
|
+
describe('encrypted send and edit', () => {
|
|
766
|
+
const TEST_KEY_URI = 'kms://kms-aore.wbx2.com/keys/test-key-id'
|
|
767
|
+
|
|
768
|
+
const decodeJweHeader = (jwe: string): Record<string, unknown> => {
|
|
769
|
+
const [header = ''] = jwe.split('.')
|
|
770
|
+
const padded = header + '='.repeat((4 - (header.length % 4)) % 4)
|
|
771
|
+
return JSON.parse(Buffer.from(padded, 'base64url').toString('utf8')) as Record<string, unknown>
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const createEncryptedClient = async () => {
|
|
775
|
+
const keystore = jose.JWK.createKeyStore()
|
|
776
|
+
const key = await keystore.generate('oct', 256, { alg: 'A256GCM' })
|
|
777
|
+
const rawKeys = new Map<string, string>([[TEST_KEY_URI, JSON.stringify({ jwk: key.toJSON(true) })]])
|
|
778
|
+
const service = new WebexEncryptionService(rawKeys)
|
|
779
|
+
const client = await createExtractedClient()
|
|
780
|
+
;(client as unknown as { encryption: WebexEncryptionService }).encryption = service
|
|
781
|
+
return client
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
test('plain text send omits content field on encrypted path (preserves prior fix)', async () => {
|
|
785
|
+
mockResponse({ id: TEST_CONV_UUID, defaultActivityEncryptionKeyUrl: TEST_KEY_URI })
|
|
786
|
+
mockResponse(mockActivity('Hello world'))
|
|
787
|
+
|
|
788
|
+
const client = await createEncryptedClient()
|
|
789
|
+
await client.sendMessage(TEST_ROOM_ID, 'Hello world')
|
|
790
|
+
|
|
791
|
+
const body = JSON.parse(fetchCalls[1].options?.body as string)
|
|
792
|
+
expect(body.object.content).toBeUndefined()
|
|
793
|
+
expect(body.object.displayName.startsWith('eyJ')).toBe(true)
|
|
794
|
+
expect(body.encryptionKeyUrl).toBe(TEST_KEY_URI)
|
|
795
|
+
})
|
|
796
|
+
|
|
797
|
+
test('plain text edit encrypts both displayName and content with kid in JWE header', async () => {
|
|
798
|
+
mockResponse({ id: TEST_CONV_UUID, defaultActivityEncryptionKeyUrl: TEST_KEY_URI })
|
|
799
|
+
mockResponse(mockActivity('Edited text', { parent: { id: 'activity-123', type: 'edit' } }))
|
|
800
|
+
|
|
801
|
+
const client = await createEncryptedClient()
|
|
802
|
+
await client.editMessage('activity-123', TEST_ROOM_ID, 'Edited text')
|
|
803
|
+
|
|
804
|
+
const body = JSON.parse(fetchCalls[1].options?.body as string)
|
|
805
|
+
expect(body.object.displayName.startsWith('eyJ')).toBe(true)
|
|
806
|
+
expect(body.object.content.startsWith('eyJ')).toBe(true)
|
|
807
|
+
expect(body.encryptionKeyUrl).toBe(TEST_KEY_URI)
|
|
808
|
+
expect(decodeJweHeader(body.object.displayName).kid).toBe(TEST_KEY_URI)
|
|
809
|
+
expect(decodeJweHeader(body.object.content).kid).toBe(TEST_KEY_URI)
|
|
810
|
+
})
|
|
667
811
|
})
|
|
668
812
|
|
|
669
813
|
describe('sendDirectMessage', () => {
|
|
@@ -21,12 +21,14 @@ export class WebexClient {
|
|
|
21
21
|
private globalRateLimitUntil: number = 0
|
|
22
22
|
private encryption: WebexEncryptionService | null = null
|
|
23
23
|
|
|
24
|
-
async login(credentials?: { token: string }): Promise<this> {
|
|
24
|
+
async login(credentials?: { token: string; deviceUrl?: string; tokenType?: string }): Promise<this> {
|
|
25
25
|
if (credentials) {
|
|
26
26
|
if (!credentials.token) {
|
|
27
27
|
throw new WebexError('Token is required', 'missing_token')
|
|
28
28
|
}
|
|
29
29
|
this.token = credentials.token
|
|
30
|
+
if (credentials.deviceUrl !== undefined) this.deviceUrl = credentials.deviceUrl
|
|
31
|
+
if (credentials.tokenType !== undefined) this.tokenType = credentials.tokenType
|
|
30
32
|
return this
|
|
31
33
|
}
|
|
32
34
|
|
|
@@ -161,9 +163,28 @@ export class WebexClient {
|
|
|
161
163
|
}
|
|
162
164
|
|
|
163
165
|
async testAuth(): Promise<WebexPerson> {
|
|
166
|
+
if (this.useInternalAPI) {
|
|
167
|
+
try {
|
|
168
|
+
return await this.request<WebexPerson>('GET', '/people/me')
|
|
169
|
+
} catch (err) {
|
|
170
|
+
const isAuthError = err instanceof WebexError && (err.code === 'http_401' || err.code === 'http_403')
|
|
171
|
+
if (!isAuthError) throw err
|
|
172
|
+
await this.testAuthInternal()
|
|
173
|
+
return { id: '', emails: [], displayName: '', orgId: '', type: 'person', created: '' } as WebexPerson
|
|
174
|
+
}
|
|
175
|
+
}
|
|
164
176
|
return this.request<WebexPerson>('GET', '/people/me')
|
|
165
177
|
}
|
|
166
178
|
|
|
179
|
+
private async testAuthInternal(): Promise<void> {
|
|
180
|
+
if (!this.deviceUrl) {
|
|
181
|
+
throw new WebexError('No device URL available for internal API validation', 'no_device_url')
|
|
182
|
+
}
|
|
183
|
+
await this.internalRequest<InternalConversation>(
|
|
184
|
+
'/conversations?participantsLimit=0&activitiesLimit=0&conversationsLimit=1',
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
|
|
167
188
|
async listSpaces(options?: { type?: string; max?: number }): Promise<WebexSpace[]> {
|
|
168
189
|
const params = new URLSearchParams()
|
|
169
190
|
if (options?.type) params.set('type', options.type)
|
|
@@ -248,10 +269,15 @@ export class WebexClient {
|
|
|
248
269
|
private async buildEncryptedObject(
|
|
249
270
|
convUuid: string,
|
|
250
271
|
text: string,
|
|
251
|
-
options?: { markdown?: boolean },
|
|
272
|
+
options?: { markdown?: boolean; forEdit?: boolean },
|
|
252
273
|
): Promise<{ object: Record<string, string>; encryptionKeyUrl?: string }> {
|
|
253
274
|
const displayName = options?.markdown ? stripMarkdown(text) : text
|
|
254
|
-
|
|
275
|
+
let content: string | undefined
|
|
276
|
+
if (options?.markdown) {
|
|
277
|
+
content = markdownToHtml(text)
|
|
278
|
+
} else if (options?.forEdit) {
|
|
279
|
+
content = text
|
|
280
|
+
}
|
|
255
281
|
|
|
256
282
|
if (this.encryption) {
|
|
257
283
|
const conv = await this.internalRequest<InternalConversation>(
|
|
@@ -260,21 +286,25 @@ export class WebexClient {
|
|
|
260
286
|
const keyUri = conv.defaultActivityEncryptionKeyUrl
|
|
261
287
|
if (keyUri) {
|
|
262
288
|
const encryptedDisplayName = await this.encryption.encryptText(keyUri, displayName)
|
|
263
|
-
const encryptedContent = await this.encryption.encryptText(keyUri, content)
|
|
264
|
-
if (encryptedDisplayName
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
encryptionKeyUrl: keyUri,
|
|
289
|
+
const encryptedContent = content ? await this.encryption.encryptText(keyUri, content) : undefined
|
|
290
|
+
if (encryptedDisplayName) {
|
|
291
|
+
const object: Record<string, string> = {
|
|
292
|
+
objectType: 'comment',
|
|
293
|
+
displayName: encryptedDisplayName,
|
|
294
|
+
}
|
|
295
|
+
if (encryptedContent) {
|
|
296
|
+
object.content = encryptedContent
|
|
272
297
|
}
|
|
298
|
+
return { object, encryptionKeyUrl: keyUri }
|
|
273
299
|
}
|
|
274
300
|
}
|
|
275
301
|
}
|
|
276
302
|
|
|
277
|
-
|
|
303
|
+
const object: Record<string, string> = { objectType: 'comment', displayName }
|
|
304
|
+
if (content) {
|
|
305
|
+
object.content = content
|
|
306
|
+
}
|
|
307
|
+
return { object }
|
|
278
308
|
}
|
|
279
309
|
|
|
280
310
|
private async sendMessageInternal(
|
|
@@ -349,6 +379,11 @@ export class WebexClient {
|
|
|
349
379
|
if (this.useInternalAPI) {
|
|
350
380
|
const activity = await this.internalRequest<InternalActivity>(`/activities/${messageId}`)
|
|
351
381
|
const convId = activity.target?.id ?? ''
|
|
382
|
+
// Internal API responses don't carry the cluster shard (e.g. `us-west-2_r`) the
|
|
383
|
+
// public roomId encoding requires. The `unknown` placeholder is a sentinel — it
|
|
384
|
+
// round-trips through other internal API calls because they decode only the
|
|
385
|
+
// conversation UUID suffix. Callers that need a public-API-safe roomId should
|
|
386
|
+
// obtain it from `listSpaces()` or pass it through from a prior `sendMessage`.
|
|
352
387
|
const roomId = convId ? Buffer.from(`ciscospark://urn:TEAM:unknown/ROOM/${convId}`).toString('base64') : ''
|
|
353
388
|
return this.activityToMessage(activity, roomId)
|
|
354
389
|
}
|
|
@@ -381,14 +416,17 @@ export class WebexClient {
|
|
|
381
416
|
): Promise<WebexMessage> {
|
|
382
417
|
if (this.useInternalAPI) {
|
|
383
418
|
const convUuid = this.decodeConvUuid(roomId)
|
|
384
|
-
const { object, encryptionKeyUrl } = await this.buildEncryptedObject(convUuid, text,
|
|
419
|
+
const { object, encryptionKeyUrl } = await this.buildEncryptedObject(convUuid, text, {
|
|
420
|
+
...options,
|
|
421
|
+
forEdit: true,
|
|
422
|
+
})
|
|
385
423
|
|
|
386
424
|
const activity: Record<string, unknown> = {
|
|
387
425
|
verb: 'post',
|
|
388
426
|
object,
|
|
389
427
|
target: { id: convUuid, objectType: 'conversation' },
|
|
390
428
|
parent: { id: messageId, type: 'edit' },
|
|
391
|
-
clientTempId: `tmp-${Date.now()}`,
|
|
429
|
+
clientTempId: `tmp-${Date.now()}-edit`,
|
|
392
430
|
}
|
|
393
431
|
|
|
394
432
|
if (encryptionKeyUrl) {
|
|
@@ -399,6 +437,16 @@ export class WebexClient {
|
|
|
399
437
|
method: 'POST',
|
|
400
438
|
body: JSON.stringify(activity),
|
|
401
439
|
})
|
|
440
|
+
|
|
441
|
+
// Tolerate responses that omit `parent` (server may return minimal shape) —
|
|
442
|
+
// only fail on an explicit mismatch between the echoed parent and the edited id.
|
|
443
|
+
if (result.parent && result.parent.id !== messageId) {
|
|
444
|
+
throw new WebexError(
|
|
445
|
+
`Edit rejected: server linked the new activity ${result.id} to ${result.parent.id} instead of ${messageId}.`,
|
|
446
|
+
'edit_failed',
|
|
447
|
+
)
|
|
448
|
+
}
|
|
449
|
+
|
|
402
450
|
return this.activityToMessage(result, roomId)
|
|
403
451
|
}
|
|
404
452
|
const body = options?.markdown ? { roomId, markdown: text } : { roomId, text }
|
|
@@ -443,6 +491,7 @@ interface InternalActivity {
|
|
|
443
491
|
encryptionKeyUrl?: string
|
|
444
492
|
}
|
|
445
493
|
target?: { id: string; encryptionKeyUrl?: string }
|
|
494
|
+
parent?: { id: string; type: string }
|
|
446
495
|
published: string
|
|
447
496
|
encryptionKeyUrl?: string
|
|
448
497
|
}
|