agent-messenger 2.23.4 → 2.23.5

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 (50) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +2 -2
  3. package/dist/package.json +1 -1
  4. package/dist/src/platforms/webex/cli.d.ts.map +1 -1
  5. package/dist/src/platforms/webex/cli.js +2 -1
  6. package/dist/src/platforms/webex/cli.js.map +1 -1
  7. package/dist/src/platforms/webex/client.d.ts +4 -0
  8. package/dist/src/platforms/webex/client.d.ts.map +1 -1
  9. package/dist/src/platforms/webex/client.js +161 -0
  10. package/dist/src/platforms/webex/client.js.map +1 -1
  11. package/dist/src/platforms/webex/commands/file.d.ts +12 -0
  12. package/dist/src/platforms/webex/commands/file.d.ts.map +1 -0
  13. package/dist/src/platforms/webex/commands/file.js +64 -0
  14. package/dist/src/platforms/webex/commands/file.js.map +1 -0
  15. package/dist/src/platforms/webex/commands/index.d.ts +1 -0
  16. package/dist/src/platforms/webex/commands/index.d.ts.map +1 -1
  17. package/dist/src/platforms/webex/commands/index.js +1 -0
  18. package/dist/src/platforms/webex/commands/index.js.map +1 -1
  19. package/dist/src/platforms/webex/encryption.d.ts +14 -0
  20. package/dist/src/platforms/webex/encryption.d.ts.map +1 -1
  21. package/dist/src/platforms/webex/encryption.js +36 -0
  22. package/dist/src/platforms/webex/encryption.js.map +1 -1
  23. package/docs/content/docs/cli/webex.mdx +13 -0
  24. package/docs/content/docs/sdk/webex.mdx +12 -0
  25. package/package.json +1 -1
  26. package/skills/agent-channeltalk/SKILL.md +1 -1
  27. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  28. package/skills/agent-discord/SKILL.md +1 -1
  29. package/skills/agent-discordbot/SKILL.md +1 -1
  30. package/skills/agent-instagram/SKILL.md +1 -1
  31. package/skills/agent-kakaotalk/SKILL.md +1 -1
  32. package/skills/agent-line/SKILL.md +1 -1
  33. package/skills/agent-slack/SKILL.md +1 -1
  34. package/skills/agent-slackbot/SKILL.md +1 -1
  35. package/skills/agent-teams/SKILL.md +1 -1
  36. package/skills/agent-telegram/SKILL.md +1 -1
  37. package/skills/agent-telegrambot/SKILL.md +1 -1
  38. package/skills/agent-webex/SKILL.md +14 -2
  39. package/skills/agent-webexbot/SKILL.md +1 -1
  40. package/skills/agent-wechatbot/SKILL.md +1 -1
  41. package/skills/agent-whatsapp/SKILL.md +1 -1
  42. package/skills/agent-whatsappbot/SKILL.md +1 -1
  43. package/src/platforms/webex/cli.ts +10 -1
  44. package/src/platforms/webex/client.test.ts +131 -0
  45. package/src/platforms/webex/client.ts +195 -0
  46. package/src/platforms/webex/commands/file.test.ts +96 -0
  47. package/src/platforms/webex/commands/file.ts +87 -0
  48. package/src/platforms/webex/commands/index.ts +1 -0
  49. package/src/platforms/webex/encryption.test.ts +38 -0
  50. package/src/platforms/webex/encryption.ts +59 -0
@@ -0,0 +1,96 @@
1
+ import { afterEach, beforeEach, expect, it, spyOn } from 'bun:test'
2
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ import { WebexClient } from '../client'
7
+ import { toRestId } from '../id-normalizer'
8
+
9
+ const roomId = toRestId('space_456', 'ROOM')
10
+
11
+ const mockMessage = {
12
+ id: toRestId('msg_123', 'MESSAGE'),
13
+ ref: 'msg_123',
14
+ roomId,
15
+ roomRef: 'space_456',
16
+ roomType: 'group' as const,
17
+ text: '',
18
+ personId: toRestId('person_789', 'PEOPLE'),
19
+ personRef: 'person_789',
20
+ personEmail: 'user@example.com',
21
+ files: ['https://files.wbx2.com/files/f1'],
22
+ created: '2025-01-29T10:00:00.000Z',
23
+ }
24
+
25
+ import { downloadAction, uploadAction } from './file'
26
+
27
+ let mockUploadFile: ReturnType<typeof spyOn>
28
+ let mockDownloadContent: ReturnType<typeof spyOn>
29
+ let consoleLogSpy: ReturnType<typeof spyOn>
30
+ const protoSpies: ReturnType<typeof spyOn>[] = []
31
+ let workDir: string
32
+
33
+ function protoSpy(method: keyof WebexClient) {
34
+ const s = spyOn(WebexClient.prototype, method as never)
35
+ protoSpies.push(s)
36
+ return s
37
+ }
38
+
39
+ beforeEach(() => {
40
+ protoSpy('login').mockImplementation(async function (this: WebexClient) {
41
+ return this
42
+ })
43
+ protoSpy('dispose').mockResolvedValue(undefined)
44
+ mockUploadFile = protoSpy('uploadFile').mockResolvedValue(mockMessage)
45
+ mockDownloadContent = protoSpy('downloadContent').mockResolvedValue({
46
+ data: new TextEncoder().encode('file-bytes').buffer,
47
+ filename: 'report.pdf',
48
+ contentType: 'application/pdf',
49
+ })
50
+ consoleLogSpy = spyOn(console, 'log').mockImplementation(() => {})
51
+ workDir = mkdtempSync(join(tmpdir(), 'webex-file-test-'))
52
+ })
53
+
54
+ afterEach(() => {
55
+ for (const s of protoSpies) s.mockRestore()
56
+ protoSpies.length = 0
57
+ consoleLogSpy.mockRestore()
58
+ rmSync(workDir, { recursive: true, force: true })
59
+ })
60
+
61
+ it('upload reads the local file and forwards filename plus text to uploadFile', async () => {
62
+ const filePath = join(workDir, 'note.txt')
63
+ writeFileSync(filePath, 'hello world')
64
+
65
+ await uploadAction(roomId, filePath, { text: 'see attached' })
66
+
67
+ expect(mockUploadFile).toHaveBeenCalledTimes(1)
68
+ const [space, file, options] = mockUploadFile.mock.calls[0] as [
69
+ string,
70
+ { content: Blob; filename: string },
71
+ { text?: string },
72
+ ]
73
+ expect(space).toBe(roomId)
74
+ expect(file.filename).toBe('note.txt')
75
+ expect(options.text).toBe('see attached')
76
+ })
77
+
78
+ it('upload prints the resulting message with file urls', async () => {
79
+ const filePath = join(workDir, 'note.txt')
80
+ writeFileSync(filePath, 'hello world')
81
+
82
+ await uploadAction(roomId, filePath, {})
83
+
84
+ const printed = JSON.parse(consoleLogSpy.mock.calls[0]?.[0] as string)
85
+ expect(printed.id).toBe(mockMessage.id)
86
+ expect(printed.files).toEqual(['https://files.wbx2.com/files/f1'])
87
+ })
88
+
89
+ it('download writes content to the given output path', async () => {
90
+ const outPath = join(workDir, 'out.pdf')
91
+
92
+ await downloadAction('https://webexapis.com/v1/contents/c1', outPath, {})
93
+
94
+ expect(mockDownloadContent).toHaveBeenCalledWith('https://webexapis.com/v1/contents/c1')
95
+ expect(readFileSync(outPath, 'utf8')).toBe('file-bytes')
96
+ })
@@ -0,0 +1,87 @@
1
+ import { readFile, writeFile } from 'node:fs/promises'
2
+ import { basename, resolve } from 'node:path'
3
+
4
+ import { Command } from 'commander'
5
+
6
+ import { handleError } from '@/shared/utils/error-handler'
7
+ import { formatOutput } from '@/shared/utils/output'
8
+
9
+ import { WebexClient } from '../client'
10
+
11
+ async function withWebexClient<T>(run: (client: WebexClient) => Promise<T>): Promise<T> {
12
+ const client = new WebexClient()
13
+ try {
14
+ await client.login()
15
+ return await run(client)
16
+ } finally {
17
+ await client.dispose()
18
+ }
19
+ }
20
+
21
+ export async function uploadAction(
22
+ space: string,
23
+ path: string,
24
+ options: { text?: string; markdown?: boolean; parent?: string; pretty?: boolean },
25
+ ): Promise<void> {
26
+ try {
27
+ const filePath = resolve(path)
28
+ const content = await readFile(filePath)
29
+ const message = await withWebexClient((client) =>
30
+ client.uploadFile(
31
+ space,
32
+ { content: new Blob([content]), filename: basename(filePath) },
33
+ { text: options.text, markdown: options.markdown, parentId: options.parent },
34
+ ),
35
+ )
36
+
37
+ const output = {
38
+ id: message.id,
39
+ ref: message.ref,
40
+ roomId: message.roomId,
41
+ roomRef: message.roomRef,
42
+ files: message.files,
43
+ created: message.created,
44
+ }
45
+ console.log(formatOutput(output, options.pretty))
46
+ } catch (error) {
47
+ handleError(error as Error)
48
+ }
49
+ }
50
+
51
+ export async function downloadAction(
52
+ content: string,
53
+ output: string | undefined,
54
+ options: { pretty?: boolean },
55
+ ): Promise<void> {
56
+ try {
57
+ const { data, filename, contentType } = await withWebexClient((client) => client.downloadContent(content))
58
+ const outputPath = output ? resolve(output) : resolve(process.cwd(), basename(filename))
59
+ await writeFile(outputPath, Buffer.from(data))
60
+
61
+ console.log(formatOutput({ downloaded: outputPath, filename, contentType, size: data.byteLength }, options.pretty))
62
+ } catch (error) {
63
+ handleError(error as Error)
64
+ }
65
+ }
66
+
67
+ export const fileCommand = new Command('file')
68
+ .description('File commands')
69
+ .addCommand(
70
+ new Command('upload')
71
+ .description('Upload a local file to a space')
72
+ .argument('<space>', 'Space/Room ID')
73
+ .argument('<path>', 'Local file path')
74
+ .option('--text <text>', 'Optional message to send with the file')
75
+ .option('--markdown', 'Treat --text as markdown')
76
+ .option('--parent <id>', 'Reply within a thread (parent message ID)')
77
+ .option('--pretty', 'Pretty print JSON output')
78
+ .action(uploadAction),
79
+ )
80
+ .addCommand(
81
+ new Command('download')
82
+ .description('Download a file attachment by content URL or ID')
83
+ .argument('<content>', 'File content URL (from message.files) or content ID')
84
+ .argument('[output]', 'Output path (defaults to original filename)')
85
+ .option('--pretty', 'Pretty print JSON output')
86
+ .action(downloadAction),
87
+ )
@@ -1,4 +1,5 @@
1
1
  export { authCommand } from './auth'
2
+ export { fileCommand } from './file'
2
3
  export { memberCommand } from './member'
3
4
  export { messageCommand } from './message'
4
5
  export { snapshotAction, snapshotCommand } from './snapshot'
@@ -106,4 +106,42 @@ describe('WebexEncryptionService', () => {
106
106
  expect(key).not.toBeNull()
107
107
  expect(provider.fetchKey).toHaveBeenCalledTimes(1)
108
108
  })
109
+
110
+ it('encryptBinary produces A256GCM scr material and ciphertext that differs from input', async () => {
111
+ const service = new WebexEncryptionService(new Map())
112
+
113
+ const plaintext = new Uint8Array([1, 2, 3, 4, 5])
114
+ const { scr, ciphertext } = service.encryptBinary(plaintext)
115
+
116
+ expect(scr.enc).toBe('A256GCM')
117
+ expect(scr.key).toMatch(/^[A-Za-z0-9_-]+$/)
118
+ expect(scr.iv).toMatch(/^[A-Za-z0-9_-]+$/)
119
+ expect(scr.tag).toMatch(/^[A-Za-z0-9_-]+$/)
120
+ expect(Buffer.from(scr.key, 'base64url')).toHaveLength(32)
121
+ expect(Buffer.from(scr.iv, 'base64url')).toHaveLength(12)
122
+ expect(Buffer.from(ciphertext)).not.toEqual(Buffer.from(plaintext))
123
+ })
124
+
125
+ it('encryptScr requires loc to be set before encrypting', async () => {
126
+ const service = await createKeyring(keyUri)
127
+ const { scr } = service.encryptBinary(new Uint8Array([9, 9, 9]))
128
+
129
+ const result = await service.encryptScr(keyUri, scr)
130
+
131
+ expect(result).toBeNull()
132
+ })
133
+
134
+ it('encryptScr wraps the scr as a JWE with kid once loc is set', async () => {
135
+ const service = await createKeyring(keyUri)
136
+ const { scr } = service.encryptBinary(new Uint8Array([9, 9, 9]))
137
+ scr.loc = 'https://files.wbx2.com/files/f1'
138
+
139
+ const jwe = await service.encryptScr(keyUri, scr)
140
+
141
+ expect(jwe).not.toBeNull()
142
+ const header = decodeJweHeader(jwe as string)
143
+ expect(header.alg).toBe('dir')
144
+ expect(header.enc).toBe('A256GCM')
145
+ expect(header.kid).toBe(keyUri)
146
+ })
109
147
  })
@@ -1,3 +1,5 @@
1
+ import { createCipheriv, randomBytes } from 'node:crypto'
2
+
1
3
  import * as jose from 'node-jose'
2
4
 
3
5
  export interface WebexKeyProvider {
@@ -5,6 +7,26 @@ export interface WebexKeyProvider {
5
7
  close?(): Promise<void>
6
8
  }
7
9
 
10
+ // SCR (Secure Content Resource): Webex's per-file AES-256-GCM material. The file bytes
11
+ // are encrypted with this key, then the SCR itself is JWE-wrapped with the conversation key.
12
+ export interface WebexScr {
13
+ enc: 'A256GCM'
14
+ key: string
15
+ iv: string
16
+ aad: string
17
+ loc?: string
18
+ tag: string
19
+ }
20
+
21
+ export interface WebexEncryptedBinary {
22
+ scr: WebexScr
23
+ ciphertext: Uint8Array
24
+ }
25
+
26
+ function toBase64Url(buffer: Buffer): string {
27
+ return buffer.toString('base64url')
28
+ }
29
+
8
30
  export class WebexEncryptionService {
9
31
  private rawKeys: Map<string, string>
10
32
  private keyCache: Map<string, jose.JWK.Key> = new Map()
@@ -70,4 +92,41 @@ export class WebexEncryptionService {
70
92
  return null
71
93
  }
72
94
  }
95
+
96
+ encryptBinary(plaintext: Uint8Array): WebexEncryptedBinary {
97
+ const key = randomBytes(32)
98
+ const iv = randomBytes(12)
99
+ const aad = new Date().toISOString()
100
+
101
+ const cipher = createCipheriv('aes-256-gcm', key, iv)
102
+ cipher.setAAD(Buffer.from(aad, 'utf8'))
103
+ const ciphertext = Buffer.concat([cipher.update(Buffer.from(plaintext)), cipher.final()])
104
+ const tag = cipher.getAuthTag()
105
+
106
+ return {
107
+ scr: {
108
+ enc: 'A256GCM',
109
+ key: toBase64Url(key),
110
+ iv: toBase64Url(iv),
111
+ aad,
112
+ tag: toBase64Url(tag),
113
+ },
114
+ ciphertext,
115
+ }
116
+ }
117
+
118
+ async encryptScr(keyUri: string, scr: WebexScr): Promise<string | null> {
119
+ if (!scr.loc) return null
120
+ const key = await this.getKey(keyUri)
121
+ if (!key) return null
122
+
123
+ try {
124
+ return await jose.JWE.createEncrypt(
125
+ { format: 'compact', contentAlg: 'A256GCM' },
126
+ { key, header: { alg: 'dir', kid: keyUri }, reference: null },
127
+ ).final(JSON.stringify(scr), 'utf8')
128
+ } catch {
129
+ return null
130
+ }
131
+ }
73
132
  }