agent-messenger 2.9.0 → 2.10.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.
Files changed (117) 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 +67 -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.ts +77 -12
  105. package/src/platforms/teams/types.test.ts +17 -0
  106. package/src/platforms/teams/types.ts +6 -0
  107. package/src/platforms/webex/client.test.ts +157 -13
  108. package/src/platforms/webex/client.ts +64 -15
  109. package/src/platforms/webex/commands/auth.test.ts +122 -1
  110. package/src/platforms/webex/commands/auth.ts +72 -17
  111. package/src/platforms/webex/credential-manager.test.ts +63 -0
  112. package/src/platforms/webex/credential-manager.ts +22 -8
  113. package/src/platforms/webex/encryption.test.ts +54 -0
  114. package/src/platforms/webex/encryption.ts +3 -1
  115. package/src/platforms/webex/ensure-auth.ts +10 -2
  116. package/src/platforms/webex/token-extractor.test.ts +32 -3
  117. package/src/platforms/webex/token-extractor.ts +26 -5
@@ -88,6 +88,31 @@ describe('TeamsCredentialManager', () => {
88
88
  expect(team).toBeNull()
89
89
  })
90
90
 
91
+ test('getTokenWithExpiry includes region', async () => {
92
+ const manager = setup()
93
+ await manager.saveConfig({
94
+ current_account: 'work',
95
+ accounts: {
96
+ work: {
97
+ token: 'test-token',
98
+ token_expires_at: '2025-12-31T23:59:59Z',
99
+ region: 'emea',
100
+ account_type: 'work',
101
+ current_team: null,
102
+ teams: {},
103
+ },
104
+ },
105
+ })
106
+
107
+ const token = await manager.getTokenWithExpiry()
108
+ expect(token).toEqual({
109
+ token: 'test-token',
110
+ tokenExpiresAt: '2025-12-31T23:59:59Z',
111
+ accountType: 'work',
112
+ region: 'emea',
113
+ })
114
+ })
115
+
91
116
  test('getCurrentTeam returns null when current_team is set but team not in teams record', async () => {
92
117
  const manager = setup()
93
118
  await manager.setToken('test-token', 'work')
@@ -3,7 +3,7 @@ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
3
3
  import { homedir } from 'node:os'
4
4
  import { join } from 'node:path'
5
5
 
6
- import type { TeamsAccount, TeamsAccountType, TeamsConfig, TeamsConfigLegacy } from './types'
6
+ import type { TeamsAccount, TeamsAccountType, TeamsConfig, TeamsConfigLegacy, TeamsRegion } from './types'
7
7
 
8
8
  export class TeamsCredentialManager {
9
9
  static accountOverride?: TeamsAccountType
@@ -78,12 +78,22 @@ export class TeamsCredentialManager {
78
78
  return this.resolveCurrentAccount(config)?.token ?? null
79
79
  }
80
80
 
81
- async getTokenWithExpiry(): Promise<{ token: string; tokenExpiresAt?: string } | null> {
81
+ async getTokenWithExpiry(): Promise<{
82
+ token: string
83
+ tokenExpiresAt?: string
84
+ accountType?: TeamsAccountType
85
+ region?: TeamsRegion
86
+ } | null> {
82
87
  const config = await this.loadConfig()
83
88
  if (!config) return null
84
89
  const account = this.resolveCurrentAccount(config)
85
90
  if (!account?.token) return null
86
- return { token: account.token, tokenExpiresAt: account.token_expires_at }
91
+ return {
92
+ token: account.token,
93
+ tokenExpiresAt: account.token_expires_at,
94
+ accountType: account.account_type,
95
+ region: account.region,
96
+ }
87
97
  }
88
98
 
89
99
  async setToken(token: string, accountType: TeamsAccountType, expiresAt?: string): Promise<void> {
@@ -10,6 +10,7 @@ let extractSpy: ReturnType<typeof spyOn>
10
10
  let testAuthSpy: ReturnType<typeof spyOn>
11
11
  let listTeamsSpy: ReturnType<typeof spyOn>
12
12
  let saveConfigSpy: ReturnType<typeof spyOn>
13
+ let getRegionSpy: ReturnType<typeof spyOn>
13
14
 
14
15
  beforeEach(() => {
15
16
  loadConfigSpy = spyOn(TeamsCredentialManager.prototype, 'loadConfig').mockResolvedValue(null)
@@ -28,6 +29,8 @@ beforeEach(() => {
28
29
  { id: 'team-2', name: 'Team Two' },
29
30
  ])
30
31
 
32
+ getRegionSpy = spyOn(TeamsClient.prototype, 'getRegion').mockReturnValue('emea')
33
+
31
34
  saveConfigSpy = spyOn(TeamsCredentialManager.prototype, 'saveConfig').mockResolvedValue(undefined)
32
35
  })
33
36
 
@@ -37,6 +40,7 @@ afterEach(() => {
37
40
  testAuthSpy?.mockRestore()
38
41
  listTeamsSpy?.mockRestore()
39
42
  saveConfigSpy?.mockRestore()
43
+ getRegionSpy?.mockRestore()
40
44
  })
41
45
 
42
46
  describe('ensureTeamsAuth', () => {
@@ -78,6 +82,7 @@ describe('ensureTeamsAuth', () => {
78
82
  accounts: expect.objectContaining({
79
83
  work: expect.objectContaining({
80
84
  token: 'test-teams-token',
85
+ region: 'emea',
81
86
  current_team: 'team-1',
82
87
  teams: {
83
88
  'team-1': { team_id: 'team-1', team_name: 'Team One' },
@@ -215,7 +220,7 @@ describe('ensureTeamsAuth', () => {
215
220
  current_account: 'work',
216
221
  accounts: expect.objectContaining({
217
222
  work: expect.objectContaining({ token: 'work-token', current_team: 'team-w' }),
218
- personal: expect.objectContaining({ token: 'personal-token', current_team: 'team-p' }),
223
+ personal: expect.objectContaining({ token: 'personal-token', region: 'emea', current_team: 'team-p' }),
219
224
  }),
220
225
  }),
221
226
  )
@@ -23,11 +23,15 @@ export async function ensureTeamsAuth(): Promise<void> {
23
23
 
24
24
  for (const { token, accountType } of extracted) {
25
25
  try {
26
- const client = await new TeamsClient().login({ token })
26
+ const client = await new TeamsClient().login({
27
+ token,
28
+ accountType,
29
+ region: config?.accounts[accountType]?.region,
30
+ })
27
31
  await client.testAuth()
28
32
 
29
33
  const teams = await client.listTeams()
30
- if (teams.length === 0) continue
34
+ if (accountType !== 'personal' && teams.length === 0) continue
31
35
 
32
36
  const teamMap: Record<string, { team_id: string; team_name: string }> = {}
33
37
  for (const team of teams) {
@@ -38,6 +42,7 @@ export async function ensureTeamsAuth(): Promise<void> {
38
42
  const account: TeamsAccount = {
39
43
  token,
40
44
  token_expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
45
+ region: client.getRegion(),
41
46
  account_type: accountType,
42
47
  user_name: existing?.user_name,
43
48
  current_team: existing?.current_team ?? teams[0].id,
@@ -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': {
@@ -197,17 +203,38 @@ export class TeamsTokenExtractor {
197
203
  private async extractFromCookiesDB(): Promise<ExtractedTeamsToken[]> {
198
204
  const results: ExtractedTeamsToken[] = []
199
205
  const seenAccountTypes = new Set<TeamsAccountType>()
206
+ const allPaths = this.getTeamsCookiesPaths()
207
+
208
+ this.debug(`Scanning ${allPaths.length} candidate cookie path(s)`)
209
+
210
+ for (const { path: dbPath, accountType } of allPaths) {
211
+ if (!dbPath) continue
212
+
213
+ if (!existsSync(dbPath)) {
214
+ this.debug(` [skip] ${dbPath} (not found)`)
215
+ continue
216
+ }
217
+
218
+ if (seenAccountTypes.has(accountType)) {
219
+ this.debug(` [skip] ${dbPath} (already have ${accountType} account)`)
220
+ continue
221
+ }
200
222
 
201
- for (const { path: dbPath, accountType } of this.getTeamsCookiesPaths()) {
202
- if (!dbPath || !existsSync(dbPath) || seenAccountTypes.has(accountType)) continue
223
+ this.debug(` [try] ${dbPath} (${accountType})`)
203
224
 
204
225
  const token = await this.copyAndExtract(dbPath)
205
226
  if (token && this.isValidSkypeToken(token)) {
227
+ this.debug(` [ok] Extracted valid token (${token.length} chars)`)
206
228
  results.push({ token, accountType })
207
229
  seenAccountTypes.add(accountType)
230
+ } else if (token) {
231
+ this.debug(` [fail] Token too short (${token.length} chars, need >=50)`)
232
+ } else {
233
+ this.debug(` [fail] No token extracted`)
208
234
  }
209
235
  }
210
236
 
237
+ this.debug(`Extraction complete: ${results.length} token(s) found`)
211
238
  return results
212
239
  }
213
240
 
@@ -216,11 +243,26 @@ export class TeamsTokenExtractor {
216
243
 
217
244
  try {
218
245
  tempPath = this.copyDatabaseToTemp(dbPath, dbPath)
219
- const localStatePath =
220
- this.platform === 'win32' ? (findLocalStatePath(dbPath) ?? this.getLocalStatePath()) : undefined
246
+
247
+ let localStatePath: string | undefined
248
+ if (this.platform === 'win32') {
249
+ localStatePath = findLocalStatePath(dbPath) ?? undefined
250
+ if (localStatePath) {
251
+ this.debug(` Local State (from cookie path): ${localStatePath}`)
252
+ } else {
253
+ localStatePath = this.getLocalStatePath()
254
+ if (existsSync(localStatePath)) {
255
+ this.debug(` Local State (fallback): ${localStatePath}`)
256
+ } else {
257
+ this.debug(` Local State not found (tried fallback: ${localStatePath})`)
258
+ localStatePath = undefined
259
+ }
260
+ }
261
+ }
221
262
 
222
263
  return await this.extractFromSQLite(tempPath, localStatePath)
223
- } catch {
264
+ } catch (error) {
265
+ this.debug(` Copy/extract error: ${(error as Error).message}`)
224
266
  return null
225
267
  } finally {
226
268
  this.cleanupTempFile(tempPath)
@@ -231,27 +273,50 @@ export class TeamsTokenExtractor {
231
273
  try {
232
274
  for (const hostPattern of TEAMS_HOST_PATTERNS) {
233
275
  const sql = `
234
- SELECT encrypted_value
276
+ SELECT value, encrypted_value
235
277
  FROM cookies
236
278
  WHERE name = '${SKYPETOKEN_COOKIE_NAME}'
237
279
  AND host_key LIKE '%${hostPattern}%'
238
280
  LIMIT 1
239
281
  `
240
282
 
241
- type CookieRow = { encrypted_value?: Uint8Array | Buffer } | null
283
+ type CookieRow = { value?: string; encrypted_value?: Uint8Array | Buffer } | null
242
284
 
243
285
  const row = await this.cookieReader.queryFirst<CookieRow>(dbPath, sql)
244
- if (!row?.encrypted_value) continue
286
+ if (!row) continue
287
+
288
+ if (row.value && row.value.length >= 50) {
289
+ this.debug(` Found plaintext cookie for ${hostPattern} (${row.value.length} chars)`)
290
+ return row.value
291
+ }
292
+
293
+ if (!row.encrypted_value || row.encrypted_value.length === 0) {
294
+ this.debug(` No cookie data for ${hostPattern}`)
295
+ continue
296
+ }
297
+
298
+ const encBuf = Buffer.from(row.encrypted_value)
299
+ const isEncrypted = this.isEncryptedValue(encBuf)
300
+ this.debug(
301
+ ` Found cookie for ${hostPattern}: ${encBuf.length} bytes, encrypted=${isEncrypted}, prefix=${encBuf.subarray(0, 3).toString('utf8')}`,
302
+ )
245
303
 
246
- const decryptedBuf = this.decryptor.decryptCookieRaw(Buffer.from(row.encrypted_value), localStatePath)
247
- if (!decryptedBuf) continue
304
+ const decryptedBuf = this.decryptor.decryptCookieRaw(encBuf, localStatePath)
305
+ if (!decryptedBuf) {
306
+ this.debug(` Decryption failed`)
307
+ continue
308
+ }
248
309
 
310
+ this.debug(` Decrypted: ${decryptedBuf.length} bytes`)
249
311
  const token = this.postProcessDecrypted(decryptedBuf)
250
312
  if (this.isValidSkypeToken(token)) return token
313
+
314
+ this.debug(` Post-process result not a valid token (${token.length} chars)`)
251
315
  }
252
316
 
253
317
  return null
254
- } catch {
318
+ } catch (error) {
319
+ this.debug(` SQLite query error: ${(error as Error).message}`)
255
320
  return null
256
321
  }
257
322
  }
@@ -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', () => {