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.
Files changed (118) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/dist/package.json +1 -1
  3. package/dist/src/platforms/teams/client.d.ts +9 -1
  4. package/dist/src/platforms/teams/client.d.ts.map +1 -1
  5. package/dist/src/platforms/teams/client.js +69 -18
  6. package/dist/src/platforms/teams/client.js.map +1 -1
  7. package/dist/src/platforms/teams/commands/auth.d.ts.map +1 -1
  8. package/dist/src/platforms/teams/commands/auth.js +7 -2
  9. package/dist/src/platforms/teams/commands/auth.js.map +1 -1
  10. package/dist/src/platforms/teams/commands/channel.d.ts.map +1 -1
  11. package/dist/src/platforms/teams/commands/channel.js +18 -3
  12. package/dist/src/platforms/teams/commands/channel.js.map +1 -1
  13. package/dist/src/platforms/teams/commands/file.d.ts.map +1 -1
  14. package/dist/src/platforms/teams/commands/file.js +18 -3
  15. package/dist/src/platforms/teams/commands/file.js.map +1 -1
  16. package/dist/src/platforms/teams/commands/message.d.ts.map +1 -1
  17. package/dist/src/platforms/teams/commands/message.js +24 -4
  18. package/dist/src/platforms/teams/commands/message.js.map +1 -1
  19. package/dist/src/platforms/teams/commands/reaction.d.ts.map +1 -1
  20. package/dist/src/platforms/teams/commands/reaction.js +12 -2
  21. package/dist/src/platforms/teams/commands/reaction.js.map +1 -1
  22. package/dist/src/platforms/teams/commands/snapshot.d.ts.map +1 -1
  23. package/dist/src/platforms/teams/commands/snapshot.js +6 -1
  24. package/dist/src/platforms/teams/commands/snapshot.js.map +1 -1
  25. package/dist/src/platforms/teams/commands/team.d.ts.map +1 -1
  26. package/dist/src/platforms/teams/commands/team.js +6 -1
  27. package/dist/src/platforms/teams/commands/team.js.map +1 -1
  28. package/dist/src/platforms/teams/commands/user.d.ts.map +1 -1
  29. package/dist/src/platforms/teams/commands/user.js +18 -3
  30. package/dist/src/platforms/teams/commands/user.js.map +1 -1
  31. package/dist/src/platforms/teams/commands/whoami.d.ts.map +1 -1
  32. package/dist/src/platforms/teams/commands/whoami.js +6 -1
  33. package/dist/src/platforms/teams/commands/whoami.js.map +1 -1
  34. package/dist/src/platforms/teams/credential-manager.d.ts +3 -1
  35. package/dist/src/platforms/teams/credential-manager.d.ts.map +1 -1
  36. package/dist/src/platforms/teams/credential-manager.js +6 -1
  37. package/dist/src/platforms/teams/credential-manager.js.map +1 -1
  38. package/dist/src/platforms/teams/ensure-auth.d.ts.map +1 -1
  39. package/dist/src/platforms/teams/ensure-auth.js +7 -2
  40. package/dist/src/platforms/teams/ensure-auth.js.map +1 -1
  41. package/dist/src/platforms/teams/token-extractor.d.ts +3 -1
  42. package/dist/src/platforms/teams/token-extractor.d.ts.map +1 -1
  43. package/dist/src/platforms/teams/token-extractor.js +73 -10
  44. package/dist/src/platforms/teams/token-extractor.js.map +1 -1
  45. package/dist/src/platforms/teams/types.d.ts +17 -0
  46. package/dist/src/platforms/teams/types.d.ts.map +1 -1
  47. package/dist/src/platforms/teams/types.js +2 -0
  48. package/dist/src/platforms/teams/types.js.map +1 -1
  49. package/dist/src/platforms/webex/client.d.ts +3 -0
  50. package/dist/src/platforms/webex/client.d.ts.map +1 -1
  51. package/dist/src/platforms/webex/client.js +58 -13
  52. package/dist/src/platforms/webex/client.js.map +1 -1
  53. package/dist/src/platforms/webex/commands/auth.d.ts.map +1 -1
  54. package/dist/src/platforms/webex/commands/auth.js +61 -10
  55. package/dist/src/platforms/webex/commands/auth.js.map +1 -1
  56. package/dist/src/platforms/webex/credential-manager.d.ts.map +1 -1
  57. package/dist/src/platforms/webex/credential-manager.js +18 -6
  58. package/dist/src/platforms/webex/credential-manager.js.map +1 -1
  59. package/dist/src/platforms/webex/encryption.d.ts.map +1 -1
  60. package/dist/src/platforms/webex/encryption.js +3 -1
  61. package/dist/src/platforms/webex/encryption.js.map +1 -1
  62. package/dist/src/platforms/webex/ensure-auth.d.ts.map +1 -1
  63. package/dist/src/platforms/webex/ensure-auth.js +10 -2
  64. package/dist/src/platforms/webex/ensure-auth.js.map +1 -1
  65. package/dist/src/platforms/webex/token-extractor.d.ts +1 -0
  66. package/dist/src/platforms/webex/token-extractor.d.ts.map +1 -1
  67. package/dist/src/platforms/webex/token-extractor.js +21 -4
  68. package/dist/src/platforms/webex/token-extractor.js.map +1 -1
  69. package/e2e/webex.e2e.test.ts +57 -0
  70. package/package.json +1 -1
  71. package/skills/agent-channeltalk/SKILL.md +1 -1
  72. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  73. package/skills/agent-discord/SKILL.md +1 -1
  74. package/skills/agent-discordbot/SKILL.md +1 -1
  75. package/skills/agent-instagram/SKILL.md +1 -1
  76. package/skills/agent-kakaotalk/SKILL.md +1 -1
  77. package/skills/agent-line/SKILL.md +1 -1
  78. package/skills/agent-slack/SKILL.md +1 -1
  79. package/skills/agent-slackbot/SKILL.md +1 -1
  80. package/skills/agent-teams/SKILL.md +1 -1
  81. package/skills/agent-telegram/SKILL.md +1 -1
  82. package/skills/agent-webex/SKILL.md +1 -1
  83. package/skills/agent-wechatbot/SKILL.md +1 -1
  84. package/skills/agent-whatsapp/SKILL.md +1 -1
  85. package/skills/agent-whatsappbot/SKILL.md +1 -1
  86. package/src/platforms/teams/client.test.ts +34 -30
  87. package/src/platforms/teams/client.ts +92 -20
  88. package/src/platforms/teams/commands/auth.test.ts +6 -2
  89. package/src/platforms/teams/commands/auth.ts +7 -2
  90. package/src/platforms/teams/commands/channel.test.ts +6 -6
  91. package/src/platforms/teams/commands/channel.ts +18 -3
  92. package/src/platforms/teams/commands/file.ts +18 -3
  93. package/src/platforms/teams/commands/message.ts +24 -4
  94. package/src/platforms/teams/commands/reaction.ts +12 -2
  95. package/src/platforms/teams/commands/snapshot.ts +6 -1
  96. package/src/platforms/teams/commands/team.test.ts +2 -2
  97. package/src/platforms/teams/commands/team.ts +6 -1
  98. package/src/platforms/teams/commands/user.ts +18 -3
  99. package/src/platforms/teams/commands/whoami.ts +6 -1
  100. package/src/platforms/teams/credential-manager.test.ts +25 -0
  101. package/src/platforms/teams/credential-manager.ts +13 -3
  102. package/src/platforms/teams/ensure-auth.test.ts +6 -1
  103. package/src/platforms/teams/ensure-auth.ts +7 -2
  104. package/src/platforms/teams/token-extractor.test.ts +112 -98
  105. package/src/platforms/teams/token-extractor.ts +83 -12
  106. package/src/platforms/teams/types.test.ts +17 -0
  107. package/src/platforms/teams/types.ts +6 -0
  108. package/src/platforms/webex/client.test.ts +157 -13
  109. package/src/platforms/webex/client.ts +64 -15
  110. package/src/platforms/webex/commands/auth.test.ts +122 -1
  111. package/src/platforms/webex/commands/auth.ts +72 -17
  112. package/src/platforms/webex/credential-manager.test.ts +63 -0
  113. package/src/platforms/webex/credential-manager.ts +22 -8
  114. package/src/platforms/webex/encryption.test.ts +54 -0
  115. package/src/platforms/webex/encryption.ts +3 -1
  116. package/src/platforms/webex/ensure-auth.ts +10 -2
  117. package/src/platforms/webex/token-extractor.test.ts +32 -3
  118. 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
- for (const { path: dbPath, accountType } of this.getTeamsCookiesPaths()) {
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
- const localStatePath =
220
- this.platform === 'win32' ? (findLocalStatePath(dbPath) ?? this.getLocalStatePath()) : undefined
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?.encrypted_value) continue
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(Buffer.from(row.encrypted_value), localStatePath)
247
- if (!decryptedBuf) continue
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
- const client = await new WebexClient().login({ token: 'extracted-token' })
406
- ;(client as any).deviceUrl = TEST_DEVICE_URL
407
- ;(client as any).tokenType = 'extracted'
408
- return client
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, and content', async () => {
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).toBe('Hello world')
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('markdown option does not affect plain text messages', async () => {
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).toBe('Hello world')
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(mockActivity('Edited text'))
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('body has object with comment type and new text', async () => {
635
- mockResponse(mockActivity('Edited text'))
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(mockActivity('Edited text'))
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(mockActivity('italic text'))
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
- const content = options?.markdown ? markdownToHtml(text) : text
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 && encryptedContent) {
265
- return {
266
- object: {
267
- objectType: 'comment',
268
- displayName: encryptedDisplayName,
269
- content: encryptedContent,
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
- return { object: { objectType: 'comment', displayName, content } }
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, options)
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
  }