agent-messenger 2.23.4 → 2.23.6
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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +2 -2
- package/dist/package.json +1 -1
- package/dist/src/platforms/webex/cli.d.ts.map +1 -1
- package/dist/src/platforms/webex/cli.js +2 -1
- package/dist/src/platforms/webex/cli.js.map +1 -1
- package/dist/src/platforms/webex/client.d.ts +5 -0
- package/dist/src/platforms/webex/client.d.ts.map +1 -1
- package/dist/src/platforms/webex/client.js +190 -16
- package/dist/src/platforms/webex/client.js.map +1 -1
- package/dist/src/platforms/webex/commands/file.d.ts +12 -0
- package/dist/src/platforms/webex/commands/file.d.ts.map +1 -0
- package/dist/src/platforms/webex/commands/file.js +64 -0
- package/dist/src/platforms/webex/commands/file.js.map +1 -0
- package/dist/src/platforms/webex/commands/index.d.ts +1 -0
- package/dist/src/platforms/webex/commands/index.d.ts.map +1 -1
- package/dist/src/platforms/webex/commands/index.js +1 -0
- package/dist/src/platforms/webex/commands/index.js.map +1 -1
- package/dist/src/platforms/webex/encryption.d.ts +14 -0
- package/dist/src/platforms/webex/encryption.d.ts.map +1 -1
- package/dist/src/platforms/webex/encryption.js +36 -0
- package/dist/src/platforms/webex/encryption.js.map +1 -1
- package/docs/content/docs/cli/webex.mdx +13 -0
- package/docs/content/docs/sdk/webex.mdx +12 -0
- package/package.json +1 -1
- package/skills/agent-channeltalk/SKILL.md +1 -1
- package/skills/agent-channeltalkbot/SKILL.md +1 -1
- package/skills/agent-discord/SKILL.md +1 -1
- package/skills/agent-discordbot/SKILL.md +1 -1
- package/skills/agent-instagram/SKILL.md +1 -1
- package/skills/agent-kakaotalk/SKILL.md +1 -1
- package/skills/agent-line/SKILL.md +1 -1
- package/skills/agent-slack/SKILL.md +1 -1
- package/skills/agent-slackbot/SKILL.md +1 -1
- package/skills/agent-teams/SKILL.md +1 -1
- package/skills/agent-telegram/SKILL.md +1 -1
- package/skills/agent-telegrambot/SKILL.md +1 -1
- package/skills/agent-webex/SKILL.md +14 -2
- package/skills/agent-webexbot/SKILL.md +1 -1
- package/skills/agent-wechatbot/SKILL.md +1 -1
- package/skills/agent-whatsapp/SKILL.md +1 -1
- package/skills/agent-whatsappbot/SKILL.md +1 -1
- package/src/platforms/webex/cli.ts +10 -1
- package/src/platforms/webex/client.test.ts +194 -1
- package/src/platforms/webex/client.ts +230 -17
- package/src/platforms/webex/commands/file.test.ts +96 -0
- package/src/platforms/webex/commands/file.ts +87 -0
- package/src/platforms/webex/commands/index.ts +1 -0
- package/src/platforms/webex/encryption.test.ts +38 -0
- package/src/platforms/webex/encryption.ts +59 -0
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
|
|
1
3
|
import { WebexCredentialManager } from './credential-manager'
|
|
2
4
|
import { WebexEncryptionService } from './encryption'
|
|
5
|
+
import type { WebexScr } from './encryption'
|
|
3
6
|
import {
|
|
4
7
|
decodeWebexId,
|
|
5
8
|
normalizeSdkMembership,
|
|
@@ -49,6 +52,7 @@ export class WebexClient {
|
|
|
49
52
|
this.token = credentials.token
|
|
50
53
|
if (credentials.deviceUrl !== undefined) this.deviceUrl = credentials.deviceUrl
|
|
51
54
|
if (credentials.tokenType !== undefined) this.tokenType = credentials.tokenType
|
|
55
|
+
this.initializeEncryption(credentials.token)
|
|
52
56
|
return this
|
|
53
57
|
}
|
|
54
58
|
|
|
@@ -60,29 +64,46 @@ export class WebexClient {
|
|
|
60
64
|
if (!token) {
|
|
61
65
|
throw new WebexError('No Webex credentials found. Run "auth login" to authenticate.', 'no_credentials')
|
|
62
66
|
}
|
|
67
|
+
this.token = token
|
|
63
68
|
this.deviceUrl = config?.deviceUrl ?? null
|
|
64
69
|
this.tokenType = config?.tokenType ?? null
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
this.encryption = new WebexEncryptionService(keysMap)
|
|
70
|
-
const kmsProvider = new KmsKeyProvider({ token })
|
|
71
|
-
this.encryption.setKeyProvider({
|
|
72
|
-
fetchKey: async (keyUri: string) => {
|
|
73
|
-
const serializedKey = await kmsProvider.fetchKey(keyUri)
|
|
74
|
-
if (serializedKey) {
|
|
75
|
-
await this.persistEncryptionKey(credManager, keyUri, serializedKey)
|
|
76
|
-
}
|
|
77
|
-
return serializedKey
|
|
78
|
-
},
|
|
79
|
-
close: () => kmsProvider.close(),
|
|
80
|
-
})
|
|
81
|
-
}
|
|
70
|
+
this.initializeEncryption(token, {
|
|
71
|
+
cachedKeys: config?.encryptionKeys,
|
|
72
|
+
persist: (keyUri, serializedKey) => this.persistEncryptionKey(credManager, keyUri, serializedKey),
|
|
73
|
+
})
|
|
82
74
|
|
|
83
75
|
return this
|
|
84
76
|
}
|
|
85
77
|
|
|
78
|
+
// Both login paths must run this. Explicit-credential callers would otherwise
|
|
79
|
+
// skip encryption setup and send cleartext on the internal conversation API,
|
|
80
|
+
// which Webex flags as "not encrypted before it was sent". Gated on a
|
|
81
|
+
// device-backed extracted/password session, the only case E2E applies to.
|
|
82
|
+
private initializeEncryption(
|
|
83
|
+
token: string,
|
|
84
|
+
options?: {
|
|
85
|
+
cachedKeys?: Record<string, string>
|
|
86
|
+
persist?: (keyUri: string, serializedKey: string) => Promise<void>
|
|
87
|
+
},
|
|
88
|
+
): void {
|
|
89
|
+
if (this.tokenType !== 'extracted' && this.tokenType !== 'password') return
|
|
90
|
+
if (this.deviceUrl === null) return
|
|
91
|
+
|
|
92
|
+
const keysMap = new Map(Object.entries(options?.cachedKeys ?? {}))
|
|
93
|
+
this.encryption = new WebexEncryptionService(keysMap)
|
|
94
|
+
const kmsProvider = new KmsKeyProvider({ token })
|
|
95
|
+
this.encryption.setKeyProvider({
|
|
96
|
+
fetchKey: async (keyUri: string) => {
|
|
97
|
+
const serializedKey = await kmsProvider.fetchKey(keyUri)
|
|
98
|
+
if (serializedKey) {
|
|
99
|
+
await options?.persist?.(keyUri, serializedKey)
|
|
100
|
+
}
|
|
101
|
+
return serializedKey
|
|
102
|
+
},
|
|
103
|
+
close: () => kmsProvider.close(),
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
86
107
|
async dispose(): Promise<void> {
|
|
87
108
|
await this.encryption?.close()
|
|
88
109
|
}
|
|
@@ -655,6 +676,11 @@ export class WebexClient {
|
|
|
655
676
|
options?: { text?: string; markdown?: boolean; parentId?: string },
|
|
656
677
|
): Promise<WebexMessage> {
|
|
657
678
|
const resolvedRoomId = await this.resolveRoomId(roomId)
|
|
679
|
+
|
|
680
|
+
if (this.useInternalAPI) {
|
|
681
|
+
return this.uploadFileInternal(resolvedRoomId, file, options)
|
|
682
|
+
}
|
|
683
|
+
|
|
658
684
|
const resolvedParentId = options?.parentId ? this.resolveMessageId(options.parentId) : undefined
|
|
659
685
|
const form = new FormData()
|
|
660
686
|
form.set('roomId', resolvedRoomId)
|
|
@@ -677,6 +703,137 @@ export class WebexClient {
|
|
|
677
703
|
return normalizeSdkMessage((await response.json()) as WebexMessage)
|
|
678
704
|
}
|
|
679
705
|
|
|
706
|
+
private async uploadFileInternal(
|
|
707
|
+
roomId: string,
|
|
708
|
+
file: { content: Blob; filename: string },
|
|
709
|
+
options?: { text?: string; markdown?: boolean; parentId?: string },
|
|
710
|
+
): Promise<WebexMessage> {
|
|
711
|
+
const convUuid = this.decodeConvUuid(roomId)
|
|
712
|
+
const conversationUrl = `${this.convBaseUrl}/conversations/${convUuid}`
|
|
713
|
+
const conv = await this.internalRequest<InternalConversation>(
|
|
714
|
+
`/conversations/${convUuid}?activitiesLimit=0&participantsLimit=0`,
|
|
715
|
+
)
|
|
716
|
+
const keyUri = conv.defaultActivityEncryptionKeyUrl
|
|
717
|
+
|
|
718
|
+
const bytes = new Uint8Array(await file.content.arrayBuffer())
|
|
719
|
+
const fileItem = await this.uploadFileContent(conversationUrl, file.filename, bytes, keyUri)
|
|
720
|
+
|
|
721
|
+
const object: Record<string, unknown> = {
|
|
722
|
+
objectType: 'content',
|
|
723
|
+
contentCategory: contentCategoryFor(fileItem.mimeType),
|
|
724
|
+
files: { items: [fileItem.item] },
|
|
725
|
+
}
|
|
726
|
+
let encryptionKeyUrl: string | undefined
|
|
727
|
+
if (options?.text) {
|
|
728
|
+
const built = await this.buildEncryptedObject(convUuid, options.text, { markdown: options.markdown })
|
|
729
|
+
object.displayName = built.object.displayName
|
|
730
|
+
if (built.object.content) object.content = built.object.content
|
|
731
|
+
encryptionKeyUrl = built.encryptionKeyUrl
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const activity: Record<string, unknown> = {
|
|
735
|
+
verb: 'share',
|
|
736
|
+
object,
|
|
737
|
+
target: { id: convUuid, objectType: 'conversation' },
|
|
738
|
+
clientTempId: `tmp-${Date.now()}-share`,
|
|
739
|
+
}
|
|
740
|
+
if (options?.parentId) {
|
|
741
|
+
activity.parent = { id: this.toMessageRef(options.parentId), type: 'reply' }
|
|
742
|
+
}
|
|
743
|
+
if (encryptionKeyUrl ?? keyUri) {
|
|
744
|
+
activity.encryptionKeyUrl = encryptionKeyUrl ?? keyUri
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
const result = await this.internalActivityRequest<InternalActivity>(`${conversationUrl}/content`, {
|
|
748
|
+
method: 'POST',
|
|
749
|
+
body: JSON.stringify(activity),
|
|
750
|
+
})
|
|
751
|
+
return this.activityToMessage(result, roomId)
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
private async uploadFileContent(
|
|
755
|
+
conversationUrl: string,
|
|
756
|
+
filename: string,
|
|
757
|
+
bytes: Uint8Array,
|
|
758
|
+
keyUri: string | undefined,
|
|
759
|
+
): Promise<{ item: Record<string, unknown>; mimeType: string }> {
|
|
760
|
+
const space = await this.internalActivityRequest<{ spaceUrl: string }>(`${conversationUrl}/space`, {
|
|
761
|
+
method: 'PUT',
|
|
762
|
+
})
|
|
763
|
+
|
|
764
|
+
let body: Uint8Array
|
|
765
|
+
let scr: WebexScr | undefined
|
|
766
|
+
if (this.encryption && keyUri) {
|
|
767
|
+
const encrypted = this.encryption.encryptBinary(bytes)
|
|
768
|
+
body = encrypted.ciphertext
|
|
769
|
+
scr = encrypted.scr
|
|
770
|
+
} else {
|
|
771
|
+
body = bytes
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const downloadUrl = await this.uploadToSpace(space.spaceUrl, body)
|
|
775
|
+
|
|
776
|
+
const mimeType = guessMimeType(filename)
|
|
777
|
+
const item: Record<string, unknown> = {
|
|
778
|
+
objectType: 'file',
|
|
779
|
+
displayName: filename,
|
|
780
|
+
fileSize: bytes.byteLength,
|
|
781
|
+
mimeType,
|
|
782
|
+
url: downloadUrl,
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
if (scr && keyUri && this.encryption) {
|
|
786
|
+
scr.loc = downloadUrl
|
|
787
|
+
const encryptedScr = await this.encryption.encryptScr(keyUri, scr)
|
|
788
|
+
if (!encryptedScr) {
|
|
789
|
+
throw new WebexError('Cannot encrypt file for Webex E2E conversation', 'encryption_failed')
|
|
790
|
+
}
|
|
791
|
+
item.scr = encryptedScr
|
|
792
|
+
item.displayName = (await this.encryption.encryptText(keyUri, filename)) ?? filename
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
return { item, mimeType }
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
private async uploadToSpace(spaceUrl: string, body: Uint8Array): Promise<string> {
|
|
799
|
+
const session = await this.internalActivityRequest<{ uploadUrl: string; finishUploadUrl: string }>(
|
|
800
|
+
`${spaceUrl}/upload_sessions`,
|
|
801
|
+
{
|
|
802
|
+
method: 'POST',
|
|
803
|
+
body: JSON.stringify({ uploadProtocol: 'content-length', fileSize: body.byteLength }),
|
|
804
|
+
},
|
|
805
|
+
)
|
|
806
|
+
|
|
807
|
+
const putResponse = await fetch(assertTrustedWebexUrl(session.uploadUrl), {
|
|
808
|
+
method: 'PUT',
|
|
809
|
+
headers: { 'Content-Type': 'application/octet-stream', 'Content-Length': String(body.byteLength) },
|
|
810
|
+
body,
|
|
811
|
+
})
|
|
812
|
+
if (!putResponse.ok) {
|
|
813
|
+
throw new WebexError(`File upload failed: HTTP ${putResponse.status}`, `http_${putResponse.status}`)
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
const fileHash = createHash('sha256').update(body).digest('hex')
|
|
817
|
+
const finished = await this.internalActivityRequest<{ downloadUrl: string }>(session.finishUploadUrl, {
|
|
818
|
+
method: 'POST',
|
|
819
|
+
body: JSON.stringify({ fileSize: body.byteLength, fileHash }),
|
|
820
|
+
})
|
|
821
|
+
return finished.downloadUrl
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
private async internalActivityRequest<T>(url: string, init: RequestInit): Promise<T> {
|
|
825
|
+
const response = await fetch(assertTrustedWebexUrl(url), {
|
|
826
|
+
...init,
|
|
827
|
+
headers: { ...this.internalHeaders, ...(init.headers as Record<string, string>) },
|
|
828
|
+
})
|
|
829
|
+
if (!response.ok) {
|
|
830
|
+
const errorBody = (await response.json().catch(() => null)) as { message?: string } | null
|
|
831
|
+
throw new WebexError(errorBody?.message ?? `HTTP ${response.status}`, `http_${response.status}`)
|
|
832
|
+
}
|
|
833
|
+
if (response.status === 204) return undefined as T
|
|
834
|
+
return response.json() as Promise<T>
|
|
835
|
+
}
|
|
836
|
+
|
|
680
837
|
private async lookupRoomId(uuid: string, fallback: string): Promise<string> {
|
|
681
838
|
try {
|
|
682
839
|
// Page through every room the account belongs to, stopping as soon as the
|
|
@@ -816,6 +973,62 @@ function looksLikeUuid(value: string): boolean {
|
|
|
816
973
|
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)
|
|
817
974
|
}
|
|
818
975
|
|
|
976
|
+
function isTrustedWebexHost(host: string): boolean {
|
|
977
|
+
return (
|
|
978
|
+
host === 'webex.com' ||
|
|
979
|
+
host.endsWith('.webex.com') ||
|
|
980
|
+
host === 'wbx2.com' ||
|
|
981
|
+
host.endsWith('.wbx2.com') ||
|
|
982
|
+
host === 'ciscospark.com' ||
|
|
983
|
+
host.endsWith('.ciscospark.com')
|
|
984
|
+
)
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// Pin server-returned upload URLs to HTTPS Webex hosts: they receive the bearer
|
|
988
|
+
// token (activity calls) and file bytes, so a compromised response must not be
|
|
989
|
+
// able to exfiltrate them to an attacker-controlled host (SSRF/token leak).
|
|
990
|
+
function assertTrustedWebexUrl(url: string): string {
|
|
991
|
+
let parsed: URL
|
|
992
|
+
try {
|
|
993
|
+
parsed = new URL(url)
|
|
994
|
+
} catch {
|
|
995
|
+
throw new WebexError(`Invalid Webex URL: ${url}`, 'invalid_url')
|
|
996
|
+
}
|
|
997
|
+
if (parsed.protocol !== 'https:' || !isTrustedWebexHost(parsed.hostname)) {
|
|
998
|
+
throw new WebexError(`Refusing to send request to untrusted host: ${parsed.origin}`, 'untrusted_url')
|
|
999
|
+
}
|
|
1000
|
+
return parsed.toString()
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
const MIME_TYPES: Record<string, string> = {
|
|
1004
|
+
png: 'image/png',
|
|
1005
|
+
jpg: 'image/jpeg',
|
|
1006
|
+
jpeg: 'image/jpeg',
|
|
1007
|
+
gif: 'image/gif',
|
|
1008
|
+
webp: 'image/webp',
|
|
1009
|
+
svg: 'image/svg+xml',
|
|
1010
|
+
mp4: 'video/mp4',
|
|
1011
|
+
mov: 'video/quicktime',
|
|
1012
|
+
webm: 'video/webm',
|
|
1013
|
+
pdf: 'application/pdf',
|
|
1014
|
+
txt: 'text/plain',
|
|
1015
|
+
md: 'text/markdown',
|
|
1016
|
+
json: 'application/json',
|
|
1017
|
+
csv: 'text/csv',
|
|
1018
|
+
zip: 'application/zip',
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
function guessMimeType(filename: string): string {
|
|
1022
|
+
const ext = filename.split('.').pop()?.toLowerCase() ?? ''
|
|
1023
|
+
return MIME_TYPES[ext] ?? 'application/octet-stream'
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
function contentCategoryFor(mimeType: string): string {
|
|
1027
|
+
if (mimeType.startsWith('image/')) return 'images'
|
|
1028
|
+
if (mimeType.startsWith('video/')) return 'videos'
|
|
1029
|
+
return 'documents'
|
|
1030
|
+
}
|
|
1031
|
+
|
|
819
1032
|
function looksLikeEmail(value: string): boolean {
|
|
820
1033
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
|
|
821
1034
|
}
|
|
@@ -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
|
+
)
|
|
@@ -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
|
}
|