agent-messenger 2.20.2 → 2.20.4

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 (44) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/bun.lock +41 -0
  3. package/dist/package.json +2 -1
  4. package/dist/src/platforms/webex/client.d.ts +2 -0
  5. package/dist/src/platforms/webex/client.d.ts.map +1 -1
  6. package/dist/src/platforms/webex/client.js +29 -5
  7. package/dist/src/platforms/webex/client.js.map +1 -1
  8. package/dist/src/platforms/webex/commands/message.d.ts.map +1 -1
  9. package/dist/src/platforms/webex/commands/message.js +32 -52
  10. package/dist/src/platforms/webex/commands/message.js.map +1 -1
  11. package/dist/src/platforms/webex/encryption.d.ts +7 -0
  12. package/dist/src/platforms/webex/encryption.d.ts.map +1 -1
  13. package/dist/src/platforms/webex/encryption.js +12 -1
  14. package/dist/src/platforms/webex/encryption.js.map +1 -1
  15. package/dist/src/platforms/webex/kms-key-provider.d.ts +20 -0
  16. package/dist/src/platforms/webex/kms-key-provider.d.ts.map +1 -0
  17. package/dist/src/platforms/webex/kms-key-provider.js +78 -0
  18. package/dist/src/platforms/webex/kms-key-provider.js.map +1 -0
  19. package/docs/content/docs/cli/webex.mdx +1 -1
  20. package/package.json +2 -1
  21. package/skills/agent-channeltalk/SKILL.md +1 -1
  22. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  23. package/skills/agent-discord/SKILL.md +1 -1
  24. package/skills/agent-discordbot/SKILL.md +1 -1
  25. package/skills/agent-instagram/SKILL.md +1 -1
  26. package/skills/agent-kakaotalk/SKILL.md +1 -1
  27. package/skills/agent-line/SKILL.md +1 -1
  28. package/skills/agent-slack/SKILL.md +1 -1
  29. package/skills/agent-slackbot/SKILL.md +1 -1
  30. package/skills/agent-teams/SKILL.md +1 -1
  31. package/skills/agent-telegram/SKILL.md +1 -1
  32. package/skills/agent-telegrambot/SKILL.md +1 -1
  33. package/skills/agent-webex/SKILL.md +1 -1
  34. package/skills/agent-webex/references/authentication.md +1 -1
  35. package/skills/agent-wechatbot/SKILL.md +1 -1
  36. package/skills/agent-whatsapp/SKILL.md +1 -1
  37. package/skills/agent-whatsappbot/SKILL.md +1 -1
  38. package/src/platforms/webex/client.ts +35 -6
  39. package/src/platforms/webex/commands/message.test.ts +16 -0
  40. package/src/platforms/webex/commands/message.ts +38 -57
  41. package/src/platforms/webex/encryption.test.ts +58 -3
  42. package/src/platforms/webex/encryption.ts +19 -1
  43. package/src/platforms/webex/kms-key-provider.ts +99 -0
  44. package/src/platforms/webex/typings/webex-message-handler.d.ts +45 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"kms-key-provider.js","sourceRoot":"","sources":["../../../../src/platforms/webex/kms-key-provider.ts"],"names":[],"mappings":"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"}
@@ -40,7 +40,7 @@ This command:
40
40
  - Supported browsers: Chrome, Chrome Canary, Edge, Arc, Brave, Vivaldi, Chromium
41
41
  - Supports `--browser-profile <path>` for agent-browser profiles, custom Chrome user data dirs, or portable browser profiles; repeatable and comma-separated values are accepted
42
42
  - Auto-extraction runs when no valid token is stored, so manual extraction is rarely needed
43
- - Messages are encrypted client-side (JWE/AES-256-GCM) when encryption keys are available; falls back to plaintext otherwise. Re-run `auth extract` to refresh keys for new conversations.
43
+ - Messages are encrypted client-side (JWE/AES-256-GCM). Encryption keys are cached at extract time; for end-to-end encrypted conversations whose key is not cached, the key is fetched on demand (Mercury + KMS) and persisted for reuse. Non-encrypted conversations send as plaintext.
44
44
 
45
45
  ### OAuth Device Grant (Fallback)
46
46
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-messenger",
3
- "version": "2.20.2",
3
+ "version": "2.20.4",
4
4
  "description": "Multi-platform messaging CLI for AI agents (Slack, Discord, Teams, Webex, Telegram, Telegram Bot, WhatsApp, LINE, Instagram, KakaoTalk, Channel Talk)",
5
5
  "repository": {
6
6
  "type": "git",
@@ -177,6 +177,7 @@
177
177
  "qrcode": "^1.5.4",
178
178
  "thrift": "^0.20.0",
179
179
  "tweetnacl": "^1.0.3",
180
+ "webex-message-handler": "^0.6.10",
180
181
  "ws": "^8.19.0",
181
182
  "zod": "^4.3.6"
182
183
  },
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-channeltalk
3
3
  description: Interact with Channel Talk using extracted desktop app or browser credentials - read chats, send messages, search messages, manage groups
4
- version: 2.20.2
4
+ version: 2.20.4
5
5
  allowed-tools: Bash(agent-channeltalk:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-channeltalkbot
3
3
  description: Interact with Channel Talk workspaces using API credentials - send messages, read chats, manage groups and bots
4
- version: 2.20.2
4
+ version: 2.20.4
5
5
  allowed-tools: Bash(agent-channeltalkbot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-discord
3
3
  description: Interact with Discord servers - send messages, read channels, manage reactions
4
- version: 2.20.2
4
+ version: 2.20.4
5
5
  allowed-tools: Bash(agent-discord:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-discordbot
3
3
  description: Interact with Discord servers using bot tokens - send messages, read channels, manage reactions
4
- version: 2.20.2
4
+ version: 2.20.4
5
5
  allowed-tools: Bash(agent-discordbot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-instagram
3
3
  description: Interact with Instagram DMs - send messages, read conversations, manage accounts
4
- version: 2.20.2
4
+ version: 2.20.4
5
5
  allowed-tools: Bash(agent-instagram:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-kakaotalk
3
3
  description: Interact with KakaoTalk - send messages, read chats, manage conversations
4
- version: 2.20.2
4
+ version: 2.20.4
5
5
  allowed-tools: Bash(agent-kakaotalk:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-line
3
3
  description: Interact with LINE - send messages, read chats, manage conversations
4
- version: 2.20.2
4
+ version: 2.20.4
5
5
  allowed-tools: Bash(agent-line:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-slack
3
3
  description: Interact with Slack workspaces - send messages, read channels, manage reactions
4
- version: 2.20.2
4
+ version: 2.20.4
5
5
  allowed-tools: Bash(agent-slack:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-slackbot
3
3
  description: Interact with Slack workspaces using bot tokens - send messages, read channels, manage reactions
4
- version: 2.20.2
4
+ version: 2.20.4
5
5
  allowed-tools: Bash(agent-slackbot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-teams
3
3
  description: Interact with Microsoft Teams - send messages, read channels, manage reactions
4
- version: 2.20.2
4
+ version: 2.20.4
5
5
  allowed-tools: Bash(agent-teams:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-telegram
3
3
  description: Interact with Telegram through TDLib - authenticate, inspect chats, and send messages
4
- version: 2.20.2
4
+ version: 2.20.4
5
5
  allowed-tools: Bash(agent-telegram:*)
6
6
  ---
7
7
 
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-telegrambot
3
3
  description: Interact with Telegram using bot tokens - send messages, read chats, manage reactions
4
- version: 2.20.2
4
+ version: 2.20.4
5
5
  allowed-tools: Bash(agent-telegrambot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-webex
3
3
  description: Interact with Cisco Webex - send messages, read spaces, manage memberships
4
- version: 2.20.2
4
+ version: 2.20.4
5
5
  allowed-tools: Bash(agent-webex:*)
6
6
  metadata:
7
7
  openclaw:
@@ -19,7 +19,7 @@ Extracts your first-party Webex session token from a Chromium-based browser wher
19
19
  - **Supported browsers**: Chrome, Chrome Canary, Edge, Arc, Brave, Vivaldi, Chromium
20
20
  - **Token lifetime**: Depends on Webex session policy (typically hours to days). Re-extract when expired.
21
21
  - **Auto-extraction**: The CLI attempts browser extraction automatically when no valid token is stored, so you often don't need to run `auth extract` manually.
22
- - **End-to-end encryption**: When encryption keys are found in the browser's cache, messages are encrypted client-side (JWE with AES-256-GCM) before sending via the internal conversation API. This ensures messages appear as encrypted in the Webex client. If no keys are found (e.g., the conversation hasn't been opened in the browser), messages fall back to plaintext.
22
+ - **End-to-end encryption**: Messages are encrypted client-side (JWE with AES-256-GCM) before sending via the internal conversation API, so they appear as encrypted in the Webex client. Keys cached from the browser are used directly; for an end-to-end encrypted conversation whose key was not cached at extract time, the key is fetched on demand over the Mercury websocket + KMS flow and persisted for reuse. Only non-encrypted conversations are sent as plaintext.
23
23
  - **Best for**: Interactive use, sending messages as yourself without the "via" label
24
24
 
25
25
  ```bash
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-wechatbot
3
3
  description: Interact with WeChat Official Account using API credentials - send messages, manage templates, list followers
4
- version: 2.20.2
4
+ version: 2.20.4
5
5
  allowed-tools: Bash(agent-wechatbot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-whatsapp
3
3
  description: Interact with WhatsApp - send messages, read chats, manage conversations
4
- version: 2.20.2
4
+ version: 2.20.4
5
5
  allowed-tools: Bash(agent-whatsapp:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-whatsappbot
3
3
  description: Interact with WhatsApp using Cloud API credentials - send messages, manage templates
4
- version: 2.20.2
4
+ version: 2.20.4
5
5
  allowed-tools: Bash(agent-whatsappbot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,8 @@
1
1
  import { WebexCredentialManager } from './credential-manager'
2
2
  import { WebexEncryptionService } from './encryption'
3
+ import { KmsKeyProvider } from './kms-key-provider'
3
4
  import { escapeHtml, markdownToHtml, stripMarkdown } from './markdown-to-html'
4
- import type { WebexMembership, WebexMessage, WebexPerson, WebexSpace } from './types'
5
+ import type { WebexConfig, WebexMembership, WebexMessage, WebexPerson, WebexSpace } from './types'
5
6
  import { WebexError } from './types'
6
7
 
7
8
  const BASE_URL = 'https://webexapis.com/v1'
@@ -44,16 +45,40 @@ export class WebexClient {
44
45
  this.tokenType = config?.tokenType ?? null
45
46
  await this.login({ token })
46
47
 
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
- }
48
+ if (this.tokenType === 'extracted') {
49
+ const keysMap = new Map(Object.entries(config?.encryptionKeys ?? {}))
50
+ this.encryption = new WebexEncryptionService(keysMap)
51
+ const kmsProvider = new KmsKeyProvider({ token })
52
+ this.encryption.setKeyProvider({
53
+ fetchKey: async (keyUri: string) => {
54
+ const serializedKey = await kmsProvider.fetchKey(keyUri)
55
+ if (serializedKey) {
56
+ await this.persistEncryptionKey(credManager, keyUri, serializedKey)
57
+ }
58
+ return serializedKey
59
+ },
60
+ close: () => kmsProvider.close(),
61
+ })
52
62
  }
53
63
 
54
64
  return this
55
65
  }
56
66
 
67
+ async dispose(): Promise<void> {
68
+ await this.encryption?.close()
69
+ }
70
+
71
+ private async persistEncryptionKey(
72
+ credManager: WebexCredentialManager,
73
+ keyUri: string,
74
+ serializedKey: string,
75
+ ): Promise<void> {
76
+ const latestConfig = await credManager.loadConfig()
77
+ if (!latestConfig) return
78
+ const encryptionKeys = { ...latestConfig.encryptionKeys, [keyUri]: serializedKey }
79
+ await credManager.saveConfig({ ...latestConfig, encryptionKeys } satisfies WebexConfig)
80
+ }
81
+
57
82
  private ensureAuth(): string {
58
83
  if (this.token === null) {
59
84
  throw new WebexError('Not authenticated. Call .login() first.', 'not_authenticated')
@@ -287,6 +312,9 @@ export class WebexClient {
287
312
  if (keyUri) {
288
313
  const encryptedDisplayName = await this.encryption.encryptText(keyUri, displayName)
289
314
  const encryptedContent = content ? await this.encryption.encryptText(keyUri, content) : undefined
315
+ if (content && !encryptedContent) {
316
+ throw new WebexError('Cannot encrypt message for Webex E2E conversation', 'encryption_failed')
317
+ }
290
318
  if (encryptedDisplayName) {
291
319
  const object: Record<string, string> = {
292
320
  objectType: 'comment',
@@ -297,6 +325,7 @@ export class WebexClient {
297
325
  }
298
326
  return { object, encryptionKeyUrl: keyUri }
299
327
  }
328
+ throw new WebexError('Cannot encrypt message for Webex E2E conversation', 'encryption_failed')
300
329
  }
301
330
  }
302
331
 
@@ -8,6 +8,7 @@ const mockMessage = {
8
8
  roomId: 'space_456',
9
9
  roomType: 'group' as const,
10
10
  text: 'Hello world',
11
+ html: '<p>Hello <a href="https://example.com">world</a></p>',
11
12
  personId: 'person_789',
12
13
  personEmail: 'user@example.com',
13
14
  created: '2025-01-29T10:00:00.000Z',
@@ -18,6 +19,7 @@ const mockMessage2 = {
18
19
  roomId: 'space_456',
19
20
  roomType: 'group' as const,
20
21
  text: 'Second message',
22
+ html: '<p>Second message</p>',
21
23
  personId: 'person_789',
22
24
  personEmail: 'user@example.com',
23
25
  created: '2025-01-29T10:01:00.000Z',
@@ -32,6 +34,7 @@ let mockGetMessage: ReturnType<typeof spyOn>
32
34
  let mockDeleteMessage: ReturnType<typeof spyOn>
33
35
  let mockEditMessage: ReturnType<typeof spyOn>
34
36
  let mockLogin: ReturnType<typeof spyOn>
37
+ let mockDispose: ReturnType<typeof spyOn>
35
38
  let consoleLogSpy: ReturnType<typeof spyOn>
36
39
  let consoleErrorSpy: ReturnType<typeof spyOn>
37
40
  let processExitSpy: ReturnType<typeof spyOn>
@@ -47,6 +50,7 @@ beforeEach(() => {
47
50
  mockLogin = protoSpy('login').mockImplementation(async function (this: WebexClient) {
48
51
  return this
49
52
  })
53
+ mockDispose = protoSpy('dispose').mockResolvedValue(undefined)
50
54
  mockSendMessage = protoSpy('sendMessage').mockResolvedValue(mockMessage)
51
55
  mockSendDirectMessage = protoSpy('sendDirectMessage').mockResolvedValue(mockMessage)
52
56
  mockListMessages = protoSpy('listMessages').mockResolvedValue([mockMessage, mockMessage2])
@@ -78,6 +82,7 @@ it('calls sendMessage with correct args and outputs result', async () => {
78
82
  expect(output).toContain('msg_123')
79
83
  expect(output).toContain('space_456')
80
84
  expect(output).toContain('user@example.com')
85
+ expect(mockDispose).toHaveBeenCalled()
81
86
  })
82
87
 
83
88
  it('passes markdown option when --markdown flag is set on send', async () => {
@@ -86,6 +91,14 @@ it('passes markdown option when --markdown flag is set on send', async () => {
86
91
  expect(mockSendMessage).toHaveBeenCalledWith('space_456', '**bold**', { markdown: true })
87
92
  })
88
93
 
94
+ it('disposes the client when send fails', async () => {
95
+ mockSendMessage.mockRejectedValue(new WebexError('Send failed', 'send_failed'))
96
+
97
+ await sendAction('space_456', 'Hello world', { pretty: false })
98
+
99
+ expect(mockDispose).toHaveBeenCalled()
100
+ })
101
+
89
102
  it('exits with code 1 when not authenticated on send', async () => {
90
103
  mockLogin.mockRejectedValue(new WebexError('No Webex credentials found.', 'no_credentials'))
91
104
 
@@ -122,6 +135,7 @@ it('calls listMessages with limit and outputs array', async () => {
122
135
  const output = consoleLogSpy.mock.calls[0][0]
123
136
  expect(output).toContain('msg_123')
124
137
  expect(output).toContain('msg_124')
138
+ expect(output).toContain('https://example.com')
125
139
  })
126
140
 
127
141
  it('calls getMessage with correct id and outputs result', async () => {
@@ -132,6 +146,7 @@ it('calls getMessage with correct id and outputs result', async () => {
132
146
  const output = consoleLogSpy.mock.calls[0][0]
133
147
  expect(output).toContain('msg_123')
134
148
  expect(output).toContain('user@example.com')
149
+ expect(output).toContain('https://example.com')
135
150
  })
136
151
 
137
152
  it('calls deleteMessage and outputs deleted id when --force flag is set', async () => {
@@ -166,6 +181,7 @@ it('calls editMessage with roomId in args and outputs result', async () => {
166
181
  const output = consoleLogSpy.mock.calls[0][0]
167
182
  expect(output).toContain('msg_123')
168
183
  expect(output).toContain('Updated message')
184
+ expect(mockDispose).toHaveBeenCalled()
169
185
  })
170
186
 
171
187
  it('passes markdown option to editMessage when --markdown flag is set', async () => {
@@ -6,24 +6,36 @@ import { formatOutput } from '@/shared/utils/output'
6
6
  import { WebexClient } from '../client'
7
7
  import type { WebexMessage } from '../types'
8
8
 
9
+ function formatMessageOutput(message: WebexMessage) {
10
+ return {
11
+ id: message.id,
12
+ roomId: message.roomId,
13
+ text: message.text,
14
+ html: message.html,
15
+ personEmail: message.personEmail,
16
+ created: message.created,
17
+ }
18
+ }
19
+
20
+ async function withWebexClient<T>(run: (client: WebexClient) => Promise<T>): Promise<T> {
21
+ const client = new WebexClient()
22
+ try {
23
+ await client.login()
24
+ return await run(client)
25
+ } finally {
26
+ await client.dispose()
27
+ }
28
+ }
29
+
9
30
  export async function sendAction(
10
31
  spaceId: string,
11
32
  text: string,
12
33
  options: { markdown?: boolean; pretty?: boolean },
13
34
  ): Promise<void> {
14
35
  try {
15
- const client = await new WebexClient().login()
16
- const message = await client.sendMessage(spaceId, text, { markdown: options.markdown })
17
-
18
- const output = {
19
- id: message.id,
20
- roomId: message.roomId,
21
- text: message.text,
22
- personEmail: message.personEmail,
23
- created: message.created,
24
- }
36
+ const message = await withWebexClient((client) => client.sendMessage(spaceId, text, { markdown: options.markdown }))
25
37
 
26
- console.log(formatOutput(output, options.pretty))
38
+ console.log(formatOutput(formatMessageOutput(message), options.pretty))
27
39
  } catch (error) {
28
40
  handleError(error as Error)
29
41
  }
@@ -31,17 +43,10 @@ export async function sendAction(
31
43
 
32
44
  export async function listAction(spaceId: string, options: { limit?: number; pretty?: boolean }): Promise<void> {
33
45
  try {
34
- const client = await new WebexClient().login()
35
46
  const limit = options.limit ?? 50
36
- const messages = await client.listMessages(spaceId, { max: limit })
47
+ const messages = await withWebexClient((client) => client.listMessages(spaceId, { max: limit }))
37
48
 
38
- const output = messages.map((msg: WebexMessage) => ({
39
- id: msg.id,
40
- roomId: msg.roomId,
41
- text: msg.text,
42
- personEmail: msg.personEmail,
43
- created: msg.created,
44
- }))
49
+ const output = messages.map(formatMessageOutput)
45
50
 
46
51
  console.log(formatOutput(output, options.pretty))
47
52
  } catch (error) {
@@ -51,18 +56,9 @@ export async function listAction(spaceId: string, options: { limit?: number; pre
51
56
 
52
57
  export async function getAction(messageId: string, options: { pretty?: boolean }): Promise<void> {
53
58
  try {
54
- const client = await new WebexClient().login()
55
- const message = await client.getMessage(messageId)
56
-
57
- const output = {
58
- id: message.id,
59
- roomId: message.roomId,
60
- text: message.text,
61
- personEmail: message.personEmail,
62
- created: message.created,
63
- }
59
+ const message = await withWebexClient((client) => client.getMessage(messageId))
64
60
 
65
- console.log(formatOutput(output, options.pretty))
61
+ console.log(formatOutput(formatMessageOutput(message), options.pretty))
66
62
  } catch (error) {
67
63
  handleError(error as Error)
68
64
  }
@@ -75,8 +71,7 @@ export async function deleteAction(messageId: string, options: { force?: boolean
75
71
  return process.exit(0)
76
72
  }
77
73
 
78
- const client = await new WebexClient().login()
79
- await client.deleteMessage(messageId)
74
+ await withWebexClient((client) => client.deleteMessage(messageId))
80
75
 
81
76
  console.log(formatOutput({ deleted: messageId }, options.pretty))
82
77
  } catch (error) {
@@ -91,20 +86,13 @@ export async function editAction(
91
86
  options: { markdown?: boolean; pretty?: boolean },
92
87
  ): Promise<void> {
93
88
  try {
94
- const client = await new WebexClient().login()
95
- const message = await client.editMessage(messageId, spaceId, text, {
96
- markdown: options.markdown,
97
- })
98
-
99
- const output = {
100
- id: message.id,
101
- roomId: message.roomId,
102
- text: message.text,
103
- personEmail: message.personEmail,
104
- created: message.created,
105
- }
89
+ const message = await withWebexClient((client) =>
90
+ client.editMessage(messageId, spaceId, text, {
91
+ markdown: options.markdown,
92
+ }),
93
+ )
106
94
 
107
- console.log(formatOutput(output, options.pretty))
95
+ console.log(formatOutput(formatMessageOutput(message), options.pretty))
108
96
  } catch (error) {
109
97
  handleError(error as Error)
110
98
  }
@@ -116,18 +104,11 @@ export async function dmAction(
116
104
  options: { markdown?: boolean; pretty?: boolean },
117
105
  ): Promise<void> {
118
106
  try {
119
- const client = await new WebexClient().login()
120
- const message = await client.sendDirectMessage(email, text, { markdown: options.markdown })
121
-
122
- const output = {
123
- id: message.id,
124
- roomId: message.roomId,
125
- text: message.text,
126
- personEmail: message.personEmail,
127
- created: message.created,
128
- }
107
+ const message = await withWebexClient((client) =>
108
+ client.sendDirectMessage(email, text, { markdown: options.markdown }),
109
+ )
129
110
 
130
- console.log(formatOutput(output, options.pretty))
111
+ console.log(formatOutput(formatMessageOutput(message), options.pretty))
131
112
  } catch (error) {
132
113
  handleError(error as Error)
133
114
  }
@@ -1,4 +1,4 @@
1
- import { describe, expect, it } from 'bun:test'
1
+ import { describe, expect, it, mock } from 'bun:test'
2
2
 
3
3
  import * as jose from 'node-jose'
4
4
 
@@ -11,12 +11,16 @@ const decodeJweHeader = (jwe: string): Record<string, unknown> => {
11
11
  return JSON.parse(json) as Record<string, unknown>
12
12
  }
13
13
 
14
- const createKeyring = async (keyUri: string) => {
14
+ const createSerializedKey = async (keyUri: string) => {
15
15
  const keystore = jose.JWK.createKeyStore()
16
16
  const key = await keystore.generate('oct', 256, { alg: 'A256GCM' })
17
17
  const jwk = key.toJSON(true)
18
+ return JSON.stringify({ uri: keyUri, jwk })
19
+ }
20
+
21
+ const createKeyring = async (keyUri: string) => {
18
22
  const rawKeys = new Map<string, string>()
19
- rawKeys.set(keyUri, JSON.stringify({ jwk }))
23
+ rawKeys.set(keyUri, await createSerializedKey(keyUri))
20
24
  return new WebexEncryptionService(rawKeys)
21
25
  }
22
26
 
@@ -51,4 +55,55 @@ describe('WebexEncryptionService', () => {
51
55
 
52
56
  expect(plaintext).toBe('round trip')
53
57
  })
58
+
59
+ it('getKey returns cached key without calling provider when key is present', async () => {
60
+ const service = await createKeyring(keyUri)
61
+ const provider = { fetchKey: mock(async () => null as string | null) }
62
+ service.setKeyProvider(provider)
63
+
64
+ const key = await service.getKey(keyUri)
65
+
66
+ expect(key).not.toBeNull()
67
+ expect(provider.fetchKey).not.toHaveBeenCalled()
68
+ })
69
+
70
+ it('getKey calls provider and returns key when key is missing', async () => {
71
+ const missingKeyUri = 'kms://kms-aore.wbx2.com/keys/0d7a0dfb-0464-40ce-8f3d-e65a33b61561'
72
+ const serializedKey = await createSerializedKey(missingKeyUri)
73
+ const service = new WebexEncryptionService(new Map())
74
+ const provider = { fetchKey: mock(async () => serializedKey) }
75
+ service.setKeyProvider(provider)
76
+
77
+ const key = await service.getKey(missingKeyUri)
78
+
79
+ expect(key).not.toBeNull()
80
+ expect(provider.fetchKey).toHaveBeenCalledWith(missingKeyUri)
81
+ })
82
+
83
+ it('getKey returns null when provider returns null', async () => {
84
+ const missingKeyUri = 'kms://kms-aore.wbx2.com/keys/13d6256d-f7f1-4b98-8102-4d3d87b2834a'
85
+ const service = new WebexEncryptionService(new Map())
86
+ const provider = { fetchKey: mock(async () => null as string | null) }
87
+ service.setKeyProvider(provider)
88
+
89
+ const key = await service.getKey(missingKeyUri)
90
+
91
+ expect(key).toBeNull()
92
+ expect(provider.fetchKey).toHaveBeenCalledWith(missingKeyUri)
93
+ })
94
+
95
+ it('getKey reuses provider result from raw keys after first fetch', async () => {
96
+ const missingKeyUri = 'kms://kms-aore.wbx2.com/keys/84afb005-5ba5-49c8-bd46-0c5d7ddf1c30'
97
+ const serializedKey = await createSerializedKey(missingKeyUri)
98
+ const service = new WebexEncryptionService(new Map())
99
+ const provider = { fetchKey: mock(async () => serializedKey) }
100
+ service.setKeyProvider(provider)
101
+
102
+ await service.getKey(missingKeyUri)
103
+ ;(service as unknown as { keyCache: Map<string, jose.JWK.Key> }).keyCache.clear()
104
+ const key = await service.getKey(missingKeyUri)
105
+
106
+ expect(key).not.toBeNull()
107
+ expect(provider.fetchKey).toHaveBeenCalledTimes(1)
108
+ })
54
109
  })
@@ -1,23 +1,41 @@
1
1
  import * as jose from 'node-jose'
2
2
 
3
+ export interface WebexKeyProvider {
4
+ fetchKey(keyUri: string): Promise<string | null>
5
+ close?(): Promise<void>
6
+ }
7
+
3
8
  export class WebexEncryptionService {
4
9
  private rawKeys: Map<string, string>
5
10
  private keyCache: Map<string, jose.JWK.Key> = new Map()
11
+ private keyProvider: WebexKeyProvider | null = null
6
12
 
7
13
  constructor(serializedKeys: Map<string, string>) {
8
14
  this.rawKeys = serializedKeys
9
15
  }
10
16
 
17
+ setKeyProvider(provider: WebexKeyProvider): void {
18
+ this.keyProvider = provider
19
+ }
20
+
21
+ async close(): Promise<void> {
22
+ await this.keyProvider?.close?.()
23
+ }
24
+
11
25
  async getKey(keyUri: string): Promise<jose.JWK.Key | null> {
12
26
  const cached = this.keyCache.get(keyUri)
13
27
  if (cached) return cached
14
28
 
15
- const raw = this.rawKeys.get(keyUri)
29
+ let raw = this.rawKeys.get(keyUri)
30
+ if (!raw && this.keyProvider) {
31
+ raw = (await this.keyProvider.fetchKey(keyUri)) ?? undefined
32
+ }
16
33
  if (!raw) return null
17
34
 
18
35
  try {
19
36
  const parsed = JSON.parse(raw) as { jwk: object }
20
37
  const joseKey = await jose.JWK.asKey(parsed.jwk)
38
+ this.rawKeys.set(keyUri, raw)
21
39
  this.keyCache.set(keyUri, joseKey)
22
40
  return joseKey
23
41
  } catch {