agent-messenger 2.1.0 → 2.3.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 (217) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.env.template +35 -17
  3. package/README.md +7 -7
  4. package/bun.lock +31 -7
  5. package/dist/package.json +5 -3
  6. package/dist/src/platforms/channeltalk/commands/auth.d.ts.map +1 -1
  7. package/dist/src/platforms/channeltalk/commands/auth.js +35 -28
  8. package/dist/src/platforms/channeltalk/commands/auth.js.map +1 -1
  9. package/dist/src/platforms/channeltalk/ensure-auth.js +6 -6
  10. package/dist/src/platforms/channeltalk/ensure-auth.js.map +1 -1
  11. package/dist/src/platforms/channeltalk/token-extractor.d.ts +23 -1
  12. package/dist/src/platforms/channeltalk/token-extractor.d.ts.map +1 -1
  13. package/dist/src/platforms/channeltalk/token-extractor.js +299 -29
  14. package/dist/src/platforms/channeltalk/token-extractor.js.map +1 -1
  15. package/dist/src/platforms/discord/commands/auth.d.ts.map +1 -1
  16. package/dist/src/platforms/discord/commands/auth.js +57 -49
  17. package/dist/src/platforms/discord/commands/auth.js.map +1 -1
  18. package/dist/src/platforms/discord/ensure-auth.js +3 -3
  19. package/dist/src/platforms/discord/ensure-auth.js.map +1 -1
  20. package/dist/src/platforms/discord/token-extractor.d.ts +6 -1
  21. package/dist/src/platforms/discord/token-extractor.d.ts.map +1 -1
  22. package/dist/src/platforms/discord/token-extractor.js +167 -14
  23. package/dist/src/platforms/discord/token-extractor.js.map +1 -1
  24. package/dist/src/platforms/instagram/client.d.ts +2 -0
  25. package/dist/src/platforms/instagram/client.d.ts.map +1 -1
  26. package/dist/src/platforms/instagram/client.js +2 -2
  27. package/dist/src/platforms/instagram/client.js.map +1 -1
  28. package/dist/src/platforms/instagram/commands/auth.d.ts.map +1 -1
  29. package/dist/src/platforms/instagram/commands/auth.js +107 -14
  30. package/dist/src/platforms/instagram/commands/auth.js.map +1 -1
  31. package/dist/src/platforms/instagram/ensure-auth.d.ts.map +1 -1
  32. package/dist/src/platforms/instagram/ensure-auth.js +57 -11
  33. package/dist/src/platforms/instagram/ensure-auth.js.map +1 -1
  34. package/dist/src/platforms/instagram/index.d.ts +1 -0
  35. package/dist/src/platforms/instagram/index.d.ts.map +1 -1
  36. package/dist/src/platforms/instagram/index.js +1 -0
  37. package/dist/src/platforms/instagram/index.js.map +1 -1
  38. package/dist/src/platforms/instagram/token-extractor.d.ts +44 -0
  39. package/dist/src/platforms/instagram/token-extractor.d.ts.map +1 -0
  40. package/dist/src/platforms/instagram/token-extractor.js +407 -0
  41. package/dist/src/platforms/instagram/token-extractor.js.map +1 -0
  42. package/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
  43. package/dist/src/platforms/kakaotalk/client.js +2 -1
  44. package/dist/src/platforms/kakaotalk/client.js.map +1 -1
  45. package/dist/src/platforms/kakaotalk/commands/auth.d.ts.map +1 -1
  46. package/dist/src/platforms/kakaotalk/commands/auth.js +14 -13
  47. package/dist/src/platforms/kakaotalk/commands/auth.js.map +1 -1
  48. package/dist/src/platforms/kakaotalk/protocol/connection.d.ts.map +1 -1
  49. package/dist/src/platforms/kakaotalk/protocol/connection.js +2 -1
  50. package/dist/src/platforms/kakaotalk/protocol/connection.js.map +1 -1
  51. package/dist/src/platforms/line/client.d.ts.map +1 -1
  52. package/dist/src/platforms/line/client.js +36 -9
  53. package/dist/src/platforms/line/client.js.map +1 -1
  54. package/dist/src/platforms/line/commands/auth.d.ts.map +1 -1
  55. package/dist/src/platforms/line/commands/auth.js +6 -5
  56. package/dist/src/platforms/line/commands/auth.js.map +1 -1
  57. package/dist/src/platforms/slack/commands/auth.d.ts.map +1 -1
  58. package/dist/src/platforms/slack/commands/auth.js +11 -10
  59. package/dist/src/platforms/slack/commands/auth.js.map +1 -1
  60. package/dist/src/platforms/slack/token-extractor.d.ts +9 -0
  61. package/dist/src/platforms/slack/token-extractor.d.ts.map +1 -1
  62. package/dist/src/platforms/slack/token-extractor.js +300 -23
  63. package/dist/src/platforms/slack/token-extractor.js.map +1 -1
  64. package/dist/src/platforms/teams/commands/auth.d.ts.map +1 -1
  65. package/dist/src/platforms/teams/commands/auth.js +9 -8
  66. package/dist/src/platforms/teams/commands/auth.js.map +1 -1
  67. package/dist/src/platforms/teams/ensure-auth.d.ts.map +1 -1
  68. package/dist/src/platforms/teams/ensure-auth.js +2 -1
  69. package/dist/src/platforms/teams/ensure-auth.js.map +1 -1
  70. package/dist/src/platforms/teams/token-extractor.d.ts +5 -0
  71. package/dist/src/platforms/teams/token-extractor.d.ts.map +1 -1
  72. package/dist/src/platforms/teams/token-extractor.js +161 -29
  73. package/dist/src/platforms/teams/token-extractor.js.map +1 -1
  74. package/dist/src/platforms/telegram/client.d.ts.map +1 -1
  75. package/dist/src/platforms/telegram/client.js +25 -7
  76. package/dist/src/platforms/telegram/client.js.map +1 -1
  77. package/dist/src/platforms/telegram/commands/auth.d.ts.map +1 -1
  78. package/dist/src/platforms/telegram/commands/auth.js +6 -5
  79. package/dist/src/platforms/telegram/commands/auth.js.map +1 -1
  80. package/dist/src/platforms/webex/client.d.ts +12 -0
  81. package/dist/src/platforms/webex/client.d.ts.map +1 -1
  82. package/dist/src/platforms/webex/client.js +168 -1
  83. package/dist/src/platforms/webex/client.js.map +1 -1
  84. package/dist/src/platforms/webex/commands/auth.d.ts +4 -0
  85. package/dist/src/platforms/webex/commands/auth.d.ts.map +1 -1
  86. package/dist/src/platforms/webex/commands/auth.js +50 -4
  87. package/dist/src/platforms/webex/commands/auth.js.map +1 -1
  88. package/dist/src/platforms/webex/credential-manager.js +1 -1
  89. package/dist/src/platforms/webex/credential-manager.js.map +1 -1
  90. package/dist/src/platforms/webex/encryption.d.ts +10 -0
  91. package/dist/src/platforms/webex/encryption.d.ts.map +1 -0
  92. package/dist/src/platforms/webex/encryption.js +49 -0
  93. package/dist/src/platforms/webex/encryption.js.map +1 -0
  94. package/dist/src/platforms/webex/ensure-auth.d.ts.map +1 -1
  95. package/dist/src/platforms/webex/ensure-auth.js +25 -5
  96. package/dist/src/platforms/webex/ensure-auth.js.map +1 -1
  97. package/dist/src/platforms/webex/index.d.ts +2 -0
  98. package/dist/src/platforms/webex/index.d.ts.map +1 -1
  99. package/dist/src/platforms/webex/index.js +1 -0
  100. package/dist/src/platforms/webex/index.js.map +1 -1
  101. package/dist/src/platforms/webex/token-extractor.d.ts +29 -0
  102. package/dist/src/platforms/webex/token-extractor.d.ts.map +1 -0
  103. package/dist/src/platforms/webex/token-extractor.js +393 -0
  104. package/dist/src/platforms/webex/token-extractor.js.map +1 -0
  105. package/dist/src/platforms/webex/types.d.ts +8 -1
  106. package/dist/src/platforms/webex/types.d.ts.map +1 -1
  107. package/dist/src/platforms/webex/types.js +4 -1
  108. package/dist/src/platforms/webex/types.js.map +1 -1
  109. package/dist/src/platforms/whatsapp/client.d.ts.map +1 -1
  110. package/dist/src/platforms/whatsapp/client.js +6 -2
  111. package/dist/src/platforms/whatsapp/client.js.map +1 -1
  112. package/dist/src/shared/utils/derived-key-cache.d.ts +1 -1
  113. package/dist/src/shared/utils/derived-key-cache.d.ts.map +1 -1
  114. package/dist/src/shared/utils/error-handler.d.ts +1 -1
  115. package/dist/src/shared/utils/error-handler.d.ts.map +1 -1
  116. package/dist/src/shared/utils/error-handler.js +3 -2
  117. package/dist/src/shared/utils/error-handler.js.map +1 -1
  118. package/dist/src/shared/utils/stderr.d.ts +5 -0
  119. package/dist/src/shared/utils/stderr.d.ts.map +1 -0
  120. package/dist/src/shared/utils/stderr.js +18 -0
  121. package/dist/src/shared/utils/stderr.js.map +1 -0
  122. package/docs/content/docs/cli/channeltalk.mdx +7 -7
  123. package/docs/content/docs/cli/discord.mdx +3 -3
  124. package/docs/content/docs/cli/instagram.mdx +28 -6
  125. package/docs/content/docs/cli/slack.mdx +2 -2
  126. package/docs/content/docs/cli/teams.mdx +6 -4
  127. package/docs/content/docs/cli/webex.mdx +32 -11
  128. package/e2e/README.md +132 -8
  129. package/e2e/channeltalk.e2e.test.ts +2 -7
  130. package/e2e/channeltalkbot.e2e.test.ts +2 -6
  131. package/e2e/config.ts +172 -10
  132. package/e2e/helpers.ts +7 -0
  133. package/e2e/instagram.e2e.test.ts +97 -0
  134. package/e2e/kakaotalk.e2e.test.ts +74 -0
  135. package/e2e/line.e2e.test.ts +92 -0
  136. package/e2e/teams.e2e.test.ts +46 -1
  137. package/e2e/telegram.e2e.test.ts +84 -0
  138. package/e2e/webex.e2e.test.ts +190 -0
  139. package/e2e/whatsapp.e2e.test.ts +90 -0
  140. package/e2e/whatsappbot.e2e.test.ts +78 -0
  141. package/package.json +5 -3
  142. package/skills/agent-channeltalk/SKILL.md +9 -9
  143. package/skills/agent-channeltalk/references/authentication.md +21 -18
  144. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  145. package/skills/agent-discord/SKILL.md +5 -5
  146. package/skills/agent-discord/references/authentication.md +8 -8
  147. package/skills/agent-discordbot/SKILL.md +1 -1
  148. package/skills/agent-instagram/SKILL.md +51 -9
  149. package/skills/agent-instagram/references/authentication.md +35 -3
  150. package/skills/agent-kakaotalk/SKILL.md +1 -1
  151. package/skills/agent-line/SKILL.md +1 -1
  152. package/skills/agent-slack/SKILL.md +5 -5
  153. package/skills/agent-slack/references/authentication.md +8 -8
  154. package/skills/agent-slackbot/SKILL.md +1 -1
  155. package/skills/agent-teams/SKILL.md +6 -6
  156. package/skills/agent-teams/references/authentication.md +8 -8
  157. package/skills/agent-telegram/SKILL.md +1 -1
  158. package/skills/agent-webex/SKILL.md +35 -15
  159. package/skills/agent-webex/references/authentication.md +63 -9
  160. package/skills/agent-webex/references/common-patterns.md +6 -3
  161. package/skills/agent-whatsapp/SKILL.md +1 -1
  162. package/skills/agent-whatsappbot/SKILL.md +1 -1
  163. package/src/platforms/channeltalk/commands/auth.test.ts +5 -5
  164. package/src/platforms/channeltalk/commands/auth.ts +38 -32
  165. package/src/platforms/channeltalk/ensure-auth.test.ts +6 -6
  166. package/src/platforms/channeltalk/ensure-auth.ts +6 -6
  167. package/src/platforms/channeltalk/token-extractor.test.ts +182 -15
  168. package/src/platforms/channeltalk/token-extractor.ts +344 -30
  169. package/src/platforms/discord/commands/auth.test.ts +3 -3
  170. package/src/platforms/discord/commands/auth.ts +58 -54
  171. package/src/platforms/discord/ensure-auth.test.ts +3 -3
  172. package/src/platforms/discord/ensure-auth.ts +3 -3
  173. package/src/platforms/discord/token-extractor.test.ts +199 -27
  174. package/src/platforms/discord/token-extractor.ts +190 -17
  175. package/src/platforms/instagram/client.ts +2 -2
  176. package/src/platforms/instagram/commands/auth.ts +133 -14
  177. package/src/platforms/instagram/ensure-auth.ts +63 -12
  178. package/src/platforms/instagram/index.ts +1 -0
  179. package/src/platforms/instagram/token-extractor.test.ts +424 -0
  180. package/src/platforms/instagram/token-extractor.ts +478 -0
  181. package/src/platforms/kakaotalk/client.ts +3 -1
  182. package/src/platforms/kakaotalk/commands/auth.ts +14 -13
  183. package/src/platforms/kakaotalk/protocol/connection.ts +3 -1
  184. package/src/platforms/line/client.ts +39 -14
  185. package/src/platforms/line/commands/auth.ts +7 -6
  186. package/src/platforms/slack/cli.test.ts +6 -5
  187. package/src/platforms/slack/commands/auth.test.ts +11 -7
  188. package/src/platforms/slack/commands/auth.ts +11 -10
  189. package/src/platforms/slack/token-extractor.test.ts +98 -1
  190. package/src/platforms/slack/token-extractor.ts +338 -26
  191. package/src/platforms/teams/commands/auth.ts +9 -8
  192. package/src/platforms/teams/ensure-auth.ts +3 -1
  193. package/src/platforms/teams/token-extractor.test.ts +136 -17
  194. package/src/platforms/teams/token-extractor.ts +182 -31
  195. package/src/platforms/telegram/client.test.ts +134 -0
  196. package/src/platforms/telegram/client.ts +27 -6
  197. package/src/platforms/telegram/commands/auth.ts +6 -5
  198. package/src/platforms/webex/client.test.ts +314 -0
  199. package/src/platforms/webex/client.ts +231 -1
  200. package/src/platforms/webex/commands/auth.ts +71 -4
  201. package/src/platforms/webex/commands/member.test.ts +10 -1
  202. package/src/platforms/webex/commands/message.test.ts +9 -5
  203. package/src/platforms/webex/commands/snapshot.test.ts +13 -4
  204. package/src/platforms/webex/commands/space.test.ts +12 -2
  205. package/src/platforms/webex/credential-manager.ts +1 -1
  206. package/src/platforms/webex/encryption.ts +53 -0
  207. package/src/platforms/webex/ensure-auth.test.ts +4 -0
  208. package/src/platforms/webex/ensure-auth.ts +27 -4
  209. package/src/platforms/webex/index.ts +2 -0
  210. package/src/platforms/webex/token-extractor.test.ts +327 -0
  211. package/src/platforms/webex/token-extractor.ts +460 -0
  212. package/src/platforms/webex/types.ts +8 -2
  213. package/src/platforms/webex/typings/node-jose.d.ts +27 -0
  214. package/src/platforms/whatsapp/client.ts +11 -7
  215. package/src/shared/utils/derived-key-cache.ts +1 -1
  216. package/src/shared/utils/error-handler.ts +4 -2
  217. package/src/shared/utils/stderr.ts +22 -0
@@ -1,6 +1,7 @@
1
1
  import type { WebexMembership, WebexMessage, WebexPerson, WebexSpace } from './types'
2
2
  import { WebexError } from './types'
3
3
  import { WebexCredentialManager } from './credential-manager'
4
+ import { WebexEncryptionService } from './encryption'
4
5
 
5
6
  const BASE_URL = 'https://webexapis.com/v1'
6
7
  const MAX_RETRIES = 3
@@ -13,8 +14,11 @@ interface RateLimitBucket {
13
14
 
14
15
  export class WebexClient {
15
16
  private token: string | null = null
17
+ private deviceUrl: string | null = null
18
+ private tokenType: string | null = null
16
19
  private buckets: Map<string, RateLimitBucket> = new Map()
17
20
  private globalRateLimitUntil: number = 0
21
+ private encryption: WebexEncryptionService | null = null
18
22
 
19
23
  async login(credentials?: { token: string }): Promise<this> {
20
24
  if (credentials) {
@@ -36,7 +40,18 @@ export class WebexClient {
36
40
  'no_credentials',
37
41
  )
38
42
  }
39
- return this.login({ token })
43
+ this.deviceUrl = config?.deviceUrl ?? null
44
+ this.tokenType = config?.tokenType ?? null
45
+ await this.login({ token })
46
+
47
+ if (this.tokenType === 'extracted' && config?.encryptionKeys) {
48
+ const keysMap = new Map(Object.entries(config.encryptionKeys))
49
+ if (keysMap.size > 0) {
50
+ this.encryption = new WebexEncryptionService(keysMap)
51
+ }
52
+ }
53
+
54
+ return this
40
55
  }
41
56
 
42
57
  private ensureAuth(): string {
@@ -175,22 +190,170 @@ export class WebexClient {
175
190
  text: string,
176
191
  options?: { markdown?: boolean },
177
192
  ): Promise<WebexMessage> {
193
+ if (this.useInternalAPI) {
194
+ return this.sendMessageInternal(roomId, text, options)
195
+ }
178
196
  const body = options?.markdown ? { roomId, markdown: text } : { roomId, text }
179
197
  return this.request<WebexMessage>('POST', '/messages', body)
180
198
  }
181
199
 
200
+ private get useInternalAPI(): boolean {
201
+ return this.tokenType === 'extracted' && this.deviceUrl !== null
202
+ }
203
+
204
+ private get convBaseUrl(): string {
205
+ const match = this.deviceUrl?.match(/wdm(-[a-z0-9]+)\.wbx2\.com/)
206
+ return `https://conv${match?.[1] ?? ''}.wbx2.com/conversation/api/v1`
207
+ }
208
+
209
+ private get internalHeaders(): Record<string, string> {
210
+ return {
211
+ Authorization: `Bearer ${this.ensureAuth()}`,
212
+ 'Content-Type': 'application/json',
213
+ 'cisco-device-url': this.deviceUrl!,
214
+ }
215
+ }
216
+
217
+ private decodeConvUuid(roomId: string): string {
218
+ return Buffer.from(roomId, 'base64').toString('utf8').split('/').pop() ?? roomId
219
+ }
220
+
221
+ private async internalRequest<T>(path: string, init?: RequestInit): Promise<T> {
222
+ const response = await fetch(`${this.convBaseUrl}${path}`, {
223
+ ...init,
224
+ headers: { ...this.internalHeaders, ...(init?.headers as Record<string, string>) },
225
+ })
226
+
227
+ if (!response.ok) {
228
+ const errorBody = (await response.json().catch(() => null)) as { message?: string } | null
229
+ throw new WebexError(
230
+ errorBody?.message ?? `HTTP ${response.status}`,
231
+ `http_${response.status}`,
232
+ )
233
+ }
234
+
235
+ if (response.status === 204) return undefined as T
236
+ return response.json() as Promise<T>
237
+ }
238
+
239
+ private async activityToMessage(a: InternalActivity, roomId: string): Promise<WebexMessage> {
240
+ let text = a.object?.content ?? a.object?.displayName
241
+
242
+ if (this.encryption && text?.startsWith('eyJ')) {
243
+ const keyUrl = a.encryptionKeyUrl ?? a.object?.encryptionKeyUrl
244
+ if (keyUrl) {
245
+ const decrypted = await this.encryption.decryptText(keyUrl, text)
246
+ if (decrypted !== null) {
247
+ text = decrypted
248
+ }
249
+ }
250
+ }
251
+
252
+ return {
253
+ id: a.id,
254
+ roomId,
255
+ roomType: 'group' as const,
256
+ text,
257
+ personId: a.actor?.entryUUID ?? a.actor?.id ?? '',
258
+ personEmail: a.actor?.emailAddress ?? '',
259
+ created: a.published,
260
+ }
261
+ }
262
+
263
+ private async buildEncryptedObject(
264
+ convUuid: string,
265
+ text: string,
266
+ options?: { markdown?: boolean },
267
+ ): Promise<{ object: Record<string, string>; encryptionKeyUrl?: string }> {
268
+ const buildObject = (content: string): Record<string, string> =>
269
+ options?.markdown
270
+ ? { objectType: 'comment', displayName: content, content, markdown: content }
271
+ : { objectType: 'comment', displayName: content, content }
272
+
273
+ if (this.encryption) {
274
+ const conv = await this.internalRequest<InternalConversation>(
275
+ `/conversations/${convUuid}?activitiesLimit=0&participantsLimit=0`,
276
+ )
277
+ const keyUri = conv.defaultActivityEncryptionKeyUrl
278
+ if (keyUri) {
279
+ const encrypted = await this.encryption.encryptText(keyUri, text)
280
+ if (encrypted) {
281
+ return { object: buildObject(encrypted), encryptionKeyUrl: keyUri }
282
+ }
283
+ }
284
+ }
285
+
286
+ return { object: buildObject(text) }
287
+ }
288
+
289
+ private async sendMessageInternal(
290
+ roomId: string,
291
+ text: string,
292
+ options?: { markdown?: boolean },
293
+ ): Promise<WebexMessage> {
294
+ const convUuid = this.decodeConvUuid(roomId)
295
+ const { object, encryptionKeyUrl } = await this.buildEncryptedObject(convUuid, text, options)
296
+
297
+ const activity: Record<string, unknown> = {
298
+ verb: 'post',
299
+ object,
300
+ target: { id: convUuid, objectType: 'conversation' },
301
+ clientTempId: `tmp-${Date.now()}`,
302
+ }
303
+
304
+ if (encryptionKeyUrl) {
305
+ activity['encryptionKeyUrl'] = encryptionKeyUrl
306
+ }
307
+
308
+ const result = await this.internalRequest<InternalActivity>('/activities', {
309
+ method: 'POST',
310
+ body: JSON.stringify(activity),
311
+ })
312
+ return this.activityToMessage(result, roomId)
313
+ }
314
+
182
315
  async sendDirectMessage(
183
316
  personEmail: string,
184
317
  text: string,
185
318
  options?: { markdown?: boolean },
186
319
  ): Promise<WebexMessage> {
320
+ if (this.useInternalAPI) {
321
+ const roomId = await this.findDirectRoomByEmail(personEmail)
322
+ if (!roomId) {
323
+ throw new WebexError(`No existing direct conversation with ${personEmail}`, 'not_found')
324
+ }
325
+ return this.sendMessageInternal(roomId, text, options)
326
+ }
187
327
  const body = options?.markdown
188
328
  ? { toPersonEmail: personEmail, markdown: text }
189
329
  : { toPersonEmail: personEmail, text }
190
330
  return this.request<WebexMessage>('POST', '/messages', body)
191
331
  }
192
332
 
333
+ private async findDirectRoomByEmail(email: string): Promise<string | null> {
334
+ const rooms = await this.request<{ items: WebexSpace[] }>('GET', `/rooms?type=direct&max=100`)
335
+ for (const room of rooms.items) {
336
+ const members = await this.request<{ items: WebexMembership[] }>(
337
+ 'GET',
338
+ `/memberships?roomId=${room.id}&max=10`,
339
+ )
340
+ if (members.items.some((m) => m.personEmail === email)) {
341
+ return room.id
342
+ }
343
+ }
344
+ return null
345
+ }
346
+
193
347
  async listMessages(roomId: string, options?: { max?: number }): Promise<WebexMessage[]> {
348
+ if (this.useInternalAPI) {
349
+ const convUuid = this.decodeConvUuid(roomId)
350
+ const max = options?.max ?? 50
351
+ const conv = await this.internalRequest<InternalConversation>(
352
+ `/conversations/${convUuid}?activitiesLimit=${max}&participantsLimit=0`,
353
+ )
354
+ const activities = (conv.activities?.items ?? []).filter((a) => a.verb === 'post')
355
+ return Promise.all(activities.map((a) => this.activityToMessage(a, roomId)))
356
+ }
194
357
  const params = new URLSearchParams()
195
358
  params.set('roomId', roomId)
196
359
  params.set('max', String(options?.max ?? 50))
@@ -199,10 +362,32 @@ export class WebexClient {
199
362
  }
200
363
 
201
364
  async getMessage(messageId: string): Promise<WebexMessage> {
365
+ if (this.useInternalAPI) {
366
+ const activity = await this.internalRequest<InternalActivity>(`/activities/${messageId}`)
367
+ const convId = activity.target?.id ?? ''
368
+ const roomId = convId
369
+ ? Buffer.from(`ciscospark://urn:TEAM:unknown/ROOM/${convId}`).toString('base64')
370
+ : ''
371
+ return this.activityToMessage(activity, roomId)
372
+ }
202
373
  return this.request<WebexMessage>('GET', `/messages/${messageId}`)
203
374
  }
204
375
 
205
376
  async deleteMessage(messageId: string): Promise<void> {
377
+ if (this.useInternalAPI) {
378
+ const activity = await this.internalRequest<InternalActivity>(`/activities/${messageId}`)
379
+ const convId = activity.target?.id
380
+ if (!convId) throw new WebexError('Cannot determine conversation for activity', 'internal_error')
381
+ await this.internalRequest<unknown>('/activities', {
382
+ method: 'POST',
383
+ body: JSON.stringify({
384
+ verb: 'delete',
385
+ object: { id: messageId, objectType: 'activity' },
386
+ target: { id: convId, objectType: 'conversation' },
387
+ }),
388
+ })
389
+ return
390
+ }
206
391
  return this.request<void>('DELETE', `/messages/${messageId}`)
207
392
  }
208
393
 
@@ -212,6 +397,28 @@ export class WebexClient {
212
397
  text: string,
213
398
  options?: { markdown?: boolean },
214
399
  ): Promise<WebexMessage> {
400
+ if (this.useInternalAPI) {
401
+ const convUuid = this.decodeConvUuid(roomId)
402
+ const { object, encryptionKeyUrl } = await this.buildEncryptedObject(convUuid, text, options)
403
+
404
+ const activity: Record<string, unknown> = {
405
+ verb: 'post',
406
+ object,
407
+ target: { id: convUuid, objectType: 'conversation' },
408
+ parent: { id: messageId, type: 'edit' },
409
+ clientTempId: `tmp-${Date.now()}`,
410
+ }
411
+
412
+ if (encryptionKeyUrl) {
413
+ activity['encryptionKeyUrl'] = encryptionKeyUrl
414
+ }
415
+
416
+ const result = await this.internalRequest<InternalActivity>('/activities', {
417
+ method: 'POST',
418
+ body: JSON.stringify(activity),
419
+ })
420
+ return this.activityToMessage(result, roomId)
421
+ }
215
422
  const body = options?.markdown ? { roomId, markdown: text } : { roomId, text }
216
423
  return this.request<WebexMessage>('PUT', `/messages/${messageId}`, body)
217
424
  }
@@ -245,3 +452,26 @@ export class WebexClient {
245
452
  return data.items
246
453
  }
247
454
  }
455
+
456
+ interface InternalActivity {
457
+ id: string
458
+ verb: string
459
+ actor?: { displayName?: string; emailAddress?: string; entryUUID?: string; id?: string }
460
+ object?: {
461
+ content?: string
462
+ displayName?: string
463
+ objectType?: string
464
+ encryptionKeyUrl?: string
465
+ }
466
+ target?: { id: string; encryptionKeyUrl?: string }
467
+ published: string
468
+ encryptionKeyUrl?: string
469
+ }
470
+
471
+ interface InternalConversation {
472
+ id: string
473
+ activities?: { items: InternalActivity[] }
474
+ defaultActivityEncryptionKeyUrl?: string
475
+ kmsResourceObjectUrl?: string
476
+ encryptionKeyUrl?: string
477
+ }
@@ -2,10 +2,12 @@ import { Command } from 'commander'
2
2
 
3
3
  import { handleError } from '@/shared/utils/error-handler'
4
4
  import { formatOutput } from '@/shared/utils/output'
5
+ import { info, debug } from '@/shared/utils/stderr'
5
6
 
6
7
  import { getWebexAppCredentials } from '../app-config'
7
8
  import { WebexClient } from '../client'
8
9
  import { WebexCredentialManager } from '../credential-manager'
10
+ import { WebexTokenExtractor } from '../token-extractor'
9
11
 
10
12
  interface ResolvedCredentials {
11
13
  clientId: string
@@ -68,11 +70,11 @@ export async function loginAction(options: { token?: string; clientId?: string;
68
70
 
69
71
  const device = await credManager.requestDeviceCode(clientId)
70
72
 
71
- console.error(`Open this URL and enter the code: ${device.verificationUri}`)
72
- console.error(`Code: ${device.userCode}`)
73
- console.error('')
73
+ info(`Open this URL and enter the code: ${device.verificationUri}`)
74
+ info(`Code: ${device.userCode}`)
75
+ info('')
74
76
  await openBrowser(device.verificationUriComplete)
75
- console.error('Waiting for authorization...')
77
+ info('Waiting for authorization...')
76
78
 
77
79
  const config = await credManager.pollDeviceToken(
78
80
  device.deviceCode,
@@ -135,6 +137,64 @@ export async function statusAction(options: { pretty?: boolean }): Promise<void>
135
137
  }
136
138
  }
137
139
 
140
+ export async function extractAction(options: { pretty?: boolean; debug?: boolean }): Promise<void> {
141
+ try {
142
+ const extractor = new WebexTokenExtractor(
143
+ undefined,
144
+ options.debug ? (msg) => debug(`[debug] ${msg}`) : undefined,
145
+ )
146
+
147
+ if (options.debug) {
148
+ debug('[debug] Searching browser profiles for Webex tokens...')
149
+ }
150
+
151
+ const extracted = await extractor.extract()
152
+
153
+ if (!extracted) {
154
+ console.log(
155
+ formatOutput(
156
+ {
157
+ error: 'No Webex token found in any browser. Make sure you are logged in to web.webex.com in Chrome, Edge, Arc, or Brave.',
158
+ hint: 'Run "auth login" for OAuth Device Grant flow, or --debug for more info.',
159
+ },
160
+ options.pretty,
161
+ ),
162
+ )
163
+ process.exit(1)
164
+ return
165
+ }
166
+
167
+ const client = await new WebexClient().login({ token: extracted.accessToken })
168
+ const person = await client.testAuth()
169
+
170
+ const credManager = new WebexCredentialManager()
171
+ await credManager.saveConfig({
172
+ accessToken: extracted.accessToken,
173
+ refreshToken: extracted.refreshToken ?? '',
174
+ expiresAt: extracted.expiresAt ?? 0,
175
+ tokenType: 'extracted',
176
+ deviceUrl: extracted.deviceUrl,
177
+ userId: extracted.userId,
178
+ encryptionKeys: extracted.encryptionKeys
179
+ ? Object.fromEntries(extracted.encryptionKeys)
180
+ : undefined,
181
+ })
182
+
183
+ console.log(
184
+ formatOutput(
185
+ {
186
+ user: { id: person.id, displayName: person.displayName, emails: person.emails },
187
+ authenticated: true,
188
+ tokenType: 'extracted',
189
+ },
190
+ options.pretty,
191
+ ),
192
+ )
193
+ } catch (error) {
194
+ handleError(error as Error)
195
+ }
196
+ }
197
+
138
198
  export async function logoutAction(options: { pretty?: boolean }): Promise<void> {
139
199
  try {
140
200
  const credManager = new WebexCredentialManager()
@@ -166,6 +226,13 @@ export const authCommand = new Command('auth')
166
226
  .option('--pretty', 'Pretty print JSON output')
167
227
  .action(loginAction),
168
228
  )
229
+ .addCommand(
230
+ new Command('extract')
231
+ .description('Extract Webex token from browser (Chrome, Edge, Arc, Brave)')
232
+ .option('--pretty', 'Pretty print JSON output')
233
+ .option('--debug', 'Show debug output')
234
+ .action(extractAction),
235
+ )
169
236
  .addCommand(
170
237
  new Command('status')
171
238
  .description('Show authentication status')
@@ -8,6 +8,8 @@ describe('member commands', () => {
8
8
  let consoleSpy: ReturnType<typeof spyOn>
9
9
  let consoleErrorSpy: ReturnType<typeof spyOn>
10
10
  let processExitSpy: ReturnType<typeof spyOn>
11
+ let stderrOutput: string
12
+ let origStderrWrite: typeof process.stderr.write
11
13
  const mockMembers = [
12
14
  {
13
15
  id: 'mem-1',
@@ -35,11 +37,18 @@ describe('member commands', () => {
35
37
  processExitSpy = spyOn(process, 'exit').mockImplementation((_code?: number) => {
36
38
  throw new Error(`process.exit(${_code})`)
37
39
  })
40
+ stderrOutput = ''
41
+ origStderrWrite = process.stderr.write
42
+ process.stderr.write = ((chunk: string | Uint8Array) => {
43
+ stderrOutput += typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk)
44
+ return true
45
+ }) as typeof process.stderr.write
38
46
  spyOn(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient() as any)
39
47
  spyOn(WebexClient.prototype, 'listMemberships').mockResolvedValue(mockMembers)
40
48
  })
41
49
 
42
50
  afterEach(() => {
51
+ process.stderr.write = origStderrWrite
43
52
  mock.restore()
44
53
  })
45
54
 
@@ -90,7 +99,7 @@ describe('member commands', () => {
90
99
 
91
100
  await expect(listAction('room-1', {})).rejects.toThrow('process.exit(1)')
92
101
 
93
- expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('No Webex credentials found'))
102
+ expect(stderrOutput).toContain('No Webex credentials found')
94
103
  })
95
104
 
96
105
  test('listAction handles API error', async () => {
@@ -97,8 +97,13 @@ test('send: with --markdown passes markdown option', async () => {
97
97
 
98
98
  test('send: not authenticated shows error', async () => {
99
99
  clientLoginSpy.mockRejectedValue(new WebexError('No Webex credentials found.', 'no_credentials'))
100
- const errorSpy = mock((_msg: string) => {})
101
- console.error = errorSpy
100
+
101
+ let stderrOutput = ''
102
+ const origWrite = process.stderr.write
103
+ process.stderr.write = ((chunk: string | Uint8Array) => {
104
+ stderrOutput += typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk)
105
+ return true
106
+ }) as typeof process.stderr.write
102
107
 
103
108
  const originalExit = process.exit
104
109
  process.exit = mock((_code?: number) => {
@@ -110,11 +115,10 @@ test('send: not authenticated shows error', async () => {
110
115
  } catch {
111
116
  } finally {
112
117
  process.exit = originalExit
118
+ process.stderr.write = origWrite
113
119
  }
114
120
 
115
- expect(errorSpy).toHaveBeenCalled()
116
- const output = errorSpy.mock.calls[0][0]
117
- expect(output).toContain('No Webex credentials found')
121
+ expect(stderrOutput).toContain('No Webex credentials found')
118
122
  })
119
123
 
120
124
  test('dm: calls sendDirectMessage with email and text', async () => {
@@ -6,6 +6,8 @@ import { snapshotAction } from './snapshot'
6
6
  describe('snapshot command', () => {
7
7
  let consoleSpy: ReturnType<typeof spyOn>
8
8
  let consoleErrorSpy: ReturnType<typeof spyOn>
9
+ let stderrOutput: string
10
+ let origStderrWrite: typeof process.stderr.write
9
11
 
10
12
  const mockSpaces = [
11
13
  { id: 'space-1', title: 'General', type: 'group', isLocked: false, lastActivity: '2024-01-15T00:00:00.000Z', created: '2024-01-01T00:00:00.000Z', creatorId: 'person-1' },
@@ -22,13 +24,22 @@ describe('snapshot command', () => {
22
24
  beforeEach(() => {
23
25
  consoleSpy = spyOn(console, 'log').mockImplementation(() => {})
24
26
  consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {})
27
+ stderrOutput = ''
28
+ origStderrWrite = process.stderr.write
29
+ process.stderr.write = ((chunk: string | Uint8Array) => {
30
+ stderrOutput += typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk)
31
+ return true
32
+ }) as typeof process.stderr.write
25
33
  spyOn(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient() as any)
26
34
  spyOn(WebexClient.prototype, 'listSpaces').mockResolvedValue(mockSpaces as any)
27
35
  spyOn(WebexClient.prototype, 'listMessages').mockResolvedValue(mockMessages as any)
28
36
  spyOn(WebexClient.prototype, 'listMemberships').mockResolvedValue(mockMembers as any)
29
37
  })
30
38
 
31
- afterEach(() => { mock.restore() })
39
+ afterEach(() => {
40
+ process.stderr.write = origStderrWrite
41
+ mock.restore()
42
+ })
32
43
 
33
44
  test('full snapshot includes spaces, recent_messages, members', async () => {
34
45
  await snapshotAction({})
@@ -81,9 +92,7 @@ describe('snapshot command', () => {
81
92
  process.exit = originalExit
82
93
  }
83
94
 
84
- expect(consoleErrorSpy).toHaveBeenCalled()
85
- const output = consoleErrorSpy.mock.calls[0][0]
86
- expect(output).toContain('No Webex credentials found')
95
+ expect(stderrOutput).toContain('No Webex credentials found')
87
96
  })
88
97
 
89
98
  test('passes limit option to listMessages', async () => {
@@ -42,6 +42,8 @@ let clientGetSpaceSpy: ReturnType<typeof spyOn>
42
42
  let consoleLogSpy: ReturnType<typeof spyOn>
43
43
  let consoleErrorSpy: ReturnType<typeof spyOn>
44
44
  let processExitSpy: ReturnType<typeof spyOn>
45
+ let stderrOutput: string
46
+ let origStderrWrite: typeof process.stderr.write
45
47
 
46
48
  beforeEach(() => {
47
49
  clientLoginSpy = spyOn(WebexClient.prototype, 'login').mockResolvedValue(
@@ -58,9 +60,17 @@ beforeEach(() => {
58
60
  processExitSpy = spyOn(process, 'exit').mockImplementation((_code?: number) => {
59
61
  throw new Error(`process.exit(${_code})`)
60
62
  })
63
+
64
+ stderrOutput = ''
65
+ origStderrWrite = process.stderr.write
66
+ process.stderr.write = ((chunk: string | Uint8Array) => {
67
+ stderrOutput += typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk)
68
+ return true
69
+ }) as typeof process.stderr.write
61
70
  })
62
71
 
63
72
  afterEach(() => {
73
+ process.stderr.write = origStderrWrite
64
74
  clientLoginSpy?.mockRestore()
65
75
  clientListSpacesSpy?.mockRestore()
66
76
  clientGetSpaceSpy?.mockRestore()
@@ -139,7 +149,7 @@ describe('listAction', () => {
139
149
  await expect(listAction({})).rejects.toThrow('process.exit(1)')
140
150
 
141
151
  expect(clientListSpacesSpy).not.toHaveBeenCalled()
142
- expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('No Webex credentials found'))
152
+ expect(stderrOutput).toContain('No Webex credentials found')
143
153
  })
144
154
  })
145
155
 
@@ -201,6 +211,6 @@ describe('infoAction', () => {
201
211
  await expect(infoAction('space-1', {})).rejects.toThrow('process.exit(1)')
202
212
 
203
213
  expect(clientGetSpaceSpy).not.toHaveBeenCalled()
204
- expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('No Webex credentials found'))
214
+ expect(stderrOutput).toContain('No Webex credentials found')
205
215
  })
206
216
  })
@@ -45,7 +45,7 @@ export class WebexCredentialManager {
45
45
  const config = await this.loadConfig()
46
46
  if (!config) return null
47
47
 
48
- if (config.tokenType === 'manual') {
48
+ if (config.tokenType === 'manual' || config.tokenType === 'extracted') {
49
49
  return config.accessToken
50
50
  }
51
51
 
@@ -0,0 +1,53 @@
1
+ import * as jose from 'node-jose'
2
+
3
+ export class WebexEncryptionService {
4
+ private rawKeys: Map<string, string>
5
+ private keyCache: Map<string, jose.JWK.Key> = new Map()
6
+
7
+ constructor(serializedKeys: Map<string, string>) {
8
+ this.rawKeys = serializedKeys
9
+ }
10
+
11
+ async getKey(keyUri: string): Promise<jose.JWK.Key | null> {
12
+ const cached = this.keyCache.get(keyUri)
13
+ if (cached) return cached
14
+
15
+ const raw = this.rawKeys.get(keyUri)
16
+ if (!raw) return null
17
+
18
+ try {
19
+ const parsed = JSON.parse(raw) as { jwk: object }
20
+ const joseKey = await jose.JWK.asKey(parsed.jwk)
21
+ this.keyCache.set(keyUri, joseKey)
22
+ return joseKey
23
+ } catch {
24
+ return null
25
+ }
26
+ }
27
+
28
+ async encryptText(keyUri: string, plaintext: string): Promise<string | null> {
29
+ const key = await this.getKey(keyUri)
30
+ if (!key) return null
31
+
32
+ try {
33
+ return await jose.JWE.createEncrypt(
34
+ { format: 'compact', contentAlg: 'A256GCM' },
35
+ { key, header: { alg: 'dir' }, reference: null },
36
+ ).final(plaintext, 'utf8')
37
+ } catch {
38
+ return null
39
+ }
40
+ }
41
+
42
+ async decryptText(keyUri: string, ciphertext: string): Promise<string | null> {
43
+ const key = await this.getKey(keyUri)
44
+ if (!key) return null
45
+
46
+ try {
47
+ const result = await jose.JWE.createDecrypt(key).decrypt(ciphertext)
48
+ return result.plaintext.toString('utf8')
49
+ } catch {
50
+ return null
51
+ }
52
+ }
53
+ }
@@ -3,11 +3,13 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test'
3
3
  import { WebexClient } from './client'
4
4
  import { WebexCredentialManager } from './credential-manager'
5
5
  import { ensureWebexAuth } from './ensure-auth'
6
+ import { WebexTokenExtractor } from './token-extractor'
6
7
 
7
8
  let loadConfigSpy: ReturnType<typeof spyOn>
8
9
  let getTokenSpy: ReturnType<typeof spyOn>
9
10
  let loginSpy: ReturnType<typeof spyOn>
10
11
  let testAuthSpy: ReturnType<typeof spyOn>
12
+ let extractSpy: ReturnType<typeof spyOn>
11
13
 
12
14
  beforeEach(() => {
13
15
  loadConfigSpy = spyOn(WebexCredentialManager.prototype, 'loadConfig').mockResolvedValue(null)
@@ -19,6 +21,7 @@ beforeEach(() => {
19
21
  emails: ['test@example.com'],
20
22
  type: 'person',
21
23
  })
24
+ extractSpy = spyOn(WebexTokenExtractor.prototype, 'extract').mockResolvedValue(null)
22
25
  })
23
26
 
24
27
  afterEach(() => {
@@ -26,6 +29,7 @@ afterEach(() => {
26
29
  getTokenSpy?.mockRestore()
27
30
  loginSpy?.mockRestore()
28
31
  testAuthSpy?.mockRestore()
32
+ extractSpy?.mockRestore()
29
33
  })
30
34
 
31
35
  describe('ensureWebexAuth', () => {