@whimmy-ai/whimmy 0.2.1 → 0.4.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.
- package/index.ts +13 -1
- package/package.json +1 -1
- package/src/channel.ts +129 -31
- package/src/sync.ts +86 -0
- package/src/utils.ts +44 -0
package/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { OpenClawPluginApi } from 'openclaw/plugin-sdk';
|
|
2
|
-
import { whimmyPlugin, registerWhimmyHooks } from './src/channel';
|
|
2
|
+
import { whimmyPlugin, registerWhimmyHooks, setApprovalManager, broadcastApprovalRequest } from './src/channel';
|
|
3
3
|
import { setWhimmyRuntime } from './src/runtime';
|
|
4
4
|
import { registerWhimmyCli } from './src/setup';
|
|
5
5
|
|
|
@@ -13,6 +13,18 @@ const plugin = {
|
|
|
13
13
|
api.registerChannel({ plugin: whimmyPlugin });
|
|
14
14
|
registerWhimmyCli(api);
|
|
15
15
|
registerWhimmyHooks(api);
|
|
16
|
+
|
|
17
|
+
// Register a gateway method that captures the ExecApprovalManager reference.
|
|
18
|
+
// The manager is only accessible via GatewayRequestHandlerOptions.context,
|
|
19
|
+
// so we use this method as the capture point.
|
|
20
|
+
// The backend should call this method once after connecting to the gateway.
|
|
21
|
+
api.registerGatewayMethod('whimmy.approval.init', (opts) => {
|
|
22
|
+
const manager = opts.context.execApprovalManager;
|
|
23
|
+
if (manager) {
|
|
24
|
+
setApprovalManager(manager);
|
|
25
|
+
}
|
|
26
|
+
opts.respond(true, { ok: true });
|
|
27
|
+
});
|
|
16
28
|
},
|
|
17
29
|
};
|
|
18
30
|
|
package/package.json
CHANGED
package/src/channel.ts
CHANGED
|
@@ -2,7 +2,8 @@ import WebSocket from 'ws';
|
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import type { OpenClawConfig, OpenClawPluginApi } from 'openclaw/plugin-sdk';
|
|
4
4
|
import { getWhimmyRuntime } from './runtime';
|
|
5
|
-
import { resolveConnection, resolveConnectionAsync, buildWsUrl, isConfigured as isConfiguredUtil } from './utils';
|
|
5
|
+
import { resolveConnection, resolveConnectionAsync, buildWsUrl, isConfigured as isConfiguredUtil, uploadFile } from './utils';
|
|
6
|
+
import { ensureWhimmyAgent } from './sync';
|
|
6
7
|
import type {
|
|
7
8
|
WhimmyConfig,
|
|
8
9
|
WhimmyChannelPlugin,
|
|
@@ -31,6 +32,27 @@ import type {
|
|
|
31
32
|
AgentInfo,
|
|
32
33
|
} from './types';
|
|
33
34
|
|
|
35
|
+
// ============ Exec Approval Manager Singleton ============
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Minimal interface matching ExecApprovalManager.resolve().
|
|
39
|
+
* The full class isn't exported from openclaw/plugin-sdk's barrel,
|
|
40
|
+
* so we type just the method we need.
|
|
41
|
+
*/
|
|
42
|
+
interface ApprovalManagerLike {
|
|
43
|
+
resolve(recordId: string, decision: 'allow-once' | 'allow-always' | 'deny', resolvedBy?: string | null): boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let approvalManager: ApprovalManagerLike | null = null;
|
|
47
|
+
|
|
48
|
+
export function setApprovalManager(manager: ApprovalManagerLike): void {
|
|
49
|
+
approvalManager = manager;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function getApprovalManager(): ApprovalManagerLike | null {
|
|
53
|
+
return approvalManager;
|
|
54
|
+
}
|
|
55
|
+
|
|
34
56
|
// ============ Config Helpers ============
|
|
35
57
|
|
|
36
58
|
function getConfig(cfg: OpenClawConfig, accountId?: string): WhimmyConfig {
|
|
@@ -105,15 +127,14 @@ async function handleHookAgent(
|
|
|
105
127
|
|
|
106
128
|
log?.info?.(`[Whimmy] Inbound: agent=${request.agentId} session=${request.sessionKey} text="${request.message.slice(0, 80)}..."`);
|
|
107
129
|
|
|
108
|
-
//
|
|
109
|
-
const
|
|
110
|
-
cfg,
|
|
111
|
-
channel: 'whimmy',
|
|
112
|
-
accountId,
|
|
113
|
-
peer: { kind: 'direct', id: request.sessionKey },
|
|
114
|
-
});
|
|
130
|
+
// Sync Whimmy agent config into OpenClaw (model + system prompt).
|
|
131
|
+
const syncedCfg = await ensureWhimmyAgent(request.agentId, request.agentConfig, log);
|
|
115
132
|
|
|
116
|
-
|
|
133
|
+
// Construct session key directly — no need for resolveAgentRoute.
|
|
134
|
+
const agentId = request.agentId;
|
|
135
|
+
const sessionKey = `agent:${agentId}:direct:${request.sessionKey}`.toLowerCase();
|
|
136
|
+
const mainSessionKey = `agent:${agentId}:main`.toLowerCase();
|
|
137
|
+
const storePath = rt.channel.session.resolveStorePath(syncedCfg.session?.store, { agentId });
|
|
117
138
|
|
|
118
139
|
// Build media fields from attachments (following MS Teams / BlueBubbles pattern).
|
|
119
140
|
const attachments = request.attachments ?? [];
|
|
@@ -138,7 +159,7 @@ async function handleHookAgent(
|
|
|
138
159
|
CommandBody: request.message,
|
|
139
160
|
From: request.sessionKey,
|
|
140
161
|
To: request.sessionKey,
|
|
141
|
-
SessionKey:
|
|
162
|
+
SessionKey: sessionKey,
|
|
142
163
|
AccountId: accountId,
|
|
143
164
|
ChatType: 'direct',
|
|
144
165
|
ConversationLabel: `Whimmy ${request.agentId}`,
|
|
@@ -164,10 +185,10 @@ async function handleHookAgent(
|
|
|
164
185
|
// Record session.
|
|
165
186
|
await rt.channel.session.recordInboundSession({
|
|
166
187
|
storePath,
|
|
167
|
-
sessionKey: ctx.SessionKey ||
|
|
188
|
+
sessionKey: ctx.SessionKey || sessionKey,
|
|
168
189
|
ctx,
|
|
169
190
|
updateLastRoute: {
|
|
170
|
-
sessionKey:
|
|
191
|
+
sessionKey: mainSessionKey,
|
|
171
192
|
channel: 'whimmy',
|
|
172
193
|
to: request.sessionKey,
|
|
173
194
|
accountId,
|
|
@@ -195,13 +216,27 @@ async function handleHookAgent(
|
|
|
195
216
|
deliver: async (payload: any) => {
|
|
196
217
|
// Handle media attachments.
|
|
197
218
|
const urls: string[] = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
|
198
|
-
|
|
199
|
-
|
|
219
|
+
const connInfo = activeConnections.get(accountId)?.conn;
|
|
220
|
+
for (let url of urls) {
|
|
221
|
+
let fileName = url.split('/').pop()?.split('?')[0] || 'file';
|
|
222
|
+
let mimeType = payload.mimeType || 'application/octet-stream';
|
|
223
|
+
// Upload local files to the backend first.
|
|
224
|
+
if (connInfo && url.startsWith('/')) {
|
|
225
|
+
try {
|
|
226
|
+
const uploaded = await uploadFile(url, connInfo);
|
|
227
|
+
url = uploaded.url;
|
|
228
|
+
fileName = uploaded.fileName;
|
|
229
|
+
mimeType = uploaded.mimeType;
|
|
230
|
+
} catch (err: any) {
|
|
231
|
+
log?.error?.(`[Whimmy] Failed to upload file ${url}: ${err.message}`);
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
200
235
|
const media: ChatMediaPayload = {
|
|
201
236
|
sessionKey: request.sessionKey,
|
|
202
237
|
agentId: request.agentId,
|
|
203
238
|
mediaUrl: url,
|
|
204
|
-
mimeType
|
|
239
|
+
mimeType,
|
|
205
240
|
fileName,
|
|
206
241
|
audioAsVoice: payload.audioAsVoice ?? false,
|
|
207
242
|
};
|
|
@@ -215,7 +250,7 @@ async function handleHookAgent(
|
|
|
215
250
|
|
|
216
251
|
await rt.channel.reply.dispatchReplyFromConfig({
|
|
217
252
|
ctx,
|
|
218
|
-
cfg,
|
|
253
|
+
cfg: syncedCfg,
|
|
219
254
|
dispatcher,
|
|
220
255
|
replyOptions: {
|
|
221
256
|
...replyOptions,
|
|
@@ -264,14 +299,22 @@ async function handleHookApproval(
|
|
|
264
299
|
request: HookApprovalRequest,
|
|
265
300
|
log?: Logger,
|
|
266
301
|
): Promise<void> {
|
|
267
|
-
const rt = getWhimmyRuntime();
|
|
268
|
-
|
|
269
302
|
log?.info?.(`[Whimmy] Approval: execution=${request.executionId} approved=${request.approved}`);
|
|
270
303
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
304
|
+
const manager = getApprovalManager();
|
|
305
|
+
if (!manager) {
|
|
306
|
+
log?.warn?.(`[Whimmy] ExecApprovalManager not yet captured — cannot resolve execution ${request.executionId}`);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const decision = request.approved ? 'allow-once' as const : 'deny' as const;
|
|
311
|
+
const resolved = manager.resolve(request.executionId, decision, 'whimmy');
|
|
312
|
+
|
|
313
|
+
if (resolved) {
|
|
314
|
+
log?.info?.(`[Whimmy] Resolved execution ${request.executionId} → ${decision}`);
|
|
315
|
+
} else {
|
|
316
|
+
log?.warn?.(`[Whimmy] Failed to resolve execution ${request.executionId} (expired or unknown)`);
|
|
317
|
+
}
|
|
275
318
|
}
|
|
276
319
|
|
|
277
320
|
// ============ Inbound Event Handlers ============
|
|
@@ -300,6 +343,11 @@ function broadcastEvent(event: string, payload: unknown): void {
|
|
|
300
343
|
}
|
|
301
344
|
}
|
|
302
345
|
|
|
346
|
+
/** Forward an exec.approval.requested event to all connected Whimmy backends. */
|
|
347
|
+
export function broadcastApprovalRequest(payload: ExecApprovalRequestedPayload): void {
|
|
348
|
+
broadcastEvent('exec.approval.requested', payload);
|
|
349
|
+
}
|
|
350
|
+
|
|
303
351
|
// ============ Actions ============
|
|
304
352
|
|
|
305
353
|
function createWhimmyActions(ws: WebSocket, sessionKey: string, agentId: string) {
|
|
@@ -323,7 +371,7 @@ function createWhimmyActions(ws: WebSocket, sessionKey: string, agentId: string)
|
|
|
323
371
|
|
|
324
372
|
// Track active connections per account to prevent duplicate connections
|
|
325
373
|
// when the framework retries startAccount.
|
|
326
|
-
const activeConnections = new Map<string, { ws: WebSocket; stopFn: () => void }>();
|
|
374
|
+
const activeConnections = new Map<string, { ws: WebSocket; conn: ConnectionInfo; stopFn: () => void }>();
|
|
327
375
|
|
|
328
376
|
async function connectWebSocket(
|
|
329
377
|
conn: ConnectionInfo,
|
|
@@ -470,7 +518,7 @@ async function connectWebSocket(
|
|
|
470
518
|
log?.info?.(`[Whimmy][${accountId}] Stopped`);
|
|
471
519
|
};
|
|
472
520
|
|
|
473
|
-
const result = { ws, stopFn };
|
|
521
|
+
const result = { ws, conn, stopFn };
|
|
474
522
|
activeConnections.set(accountId, result);
|
|
475
523
|
return result;
|
|
476
524
|
}
|
|
@@ -565,6 +613,18 @@ export const whimmyPlugin: WhimmyChannelPlugin = {
|
|
|
565
613
|
const actions = createWhimmyActions(conn.ws, sessionKey, agentId);
|
|
566
614
|
|
|
567
615
|
switch (action) {
|
|
616
|
+
case 'send': {
|
|
617
|
+
const text = typeof params.text === 'string' ? params.text : '';
|
|
618
|
+
if (!text) return { ok: false, error: 'Missing text' };
|
|
619
|
+
const chunk: ChatChunkPayload = {
|
|
620
|
+
sessionKey,
|
|
621
|
+
agentId,
|
|
622
|
+
content: text,
|
|
623
|
+
done: true,
|
|
624
|
+
};
|
|
625
|
+
sendEvent(conn.ws, 'chat.done', chunk);
|
|
626
|
+
return { ok: true };
|
|
627
|
+
}
|
|
568
628
|
case 'react': {
|
|
569
629
|
const messageId = typeof params.messageId === 'string' ? params.messageId : '';
|
|
570
630
|
const emoji = typeof params.emoji === 'string' ? params.emoji : '';
|
|
@@ -586,18 +646,46 @@ export const whimmyPlugin: WhimmyChannelPlugin = {
|
|
|
586
646
|
return { ok: true };
|
|
587
647
|
}
|
|
588
648
|
case 'sendAttachment': {
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
649
|
+
let mediaUrl = typeof params.mediaUrl === 'string' ? params.mediaUrl : '';
|
|
650
|
+
let fileName = typeof params.fileName === 'string' ? params.fileName : '';
|
|
651
|
+
let mimeType = typeof params.mimeType === 'string' ? params.mimeType : '';
|
|
652
|
+
const filePath = typeof params.filePath === 'string' ? params.filePath : '';
|
|
653
|
+
|
|
654
|
+
// If a local file path is provided, upload it to the backend first.
|
|
655
|
+
if (filePath && !mediaUrl) {
|
|
656
|
+
try {
|
|
657
|
+
const uploaded = await uploadFile(filePath, conn.conn);
|
|
658
|
+
mediaUrl = uploaded.url;
|
|
659
|
+
if (!fileName) fileName = uploaded.fileName;
|
|
660
|
+
if (!mimeType) mimeType = uploaded.mimeType;
|
|
661
|
+
} catch (uploadErr: any) {
|
|
662
|
+
return { ok: false, error: `File upload failed: ${uploadErr.message}` };
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
if (!mediaUrl) return { ok: false, error: 'Missing mediaUrl or filePath' };
|
|
667
|
+
if (!fileName) fileName = mediaUrl.split('/').pop()?.split('?')[0] || 'file';
|
|
668
|
+
if (!mimeType) mimeType = 'application/octet-stream';
|
|
669
|
+
|
|
592
670
|
const media: ChatMediaPayload = {
|
|
593
671
|
sessionKey,
|
|
594
672
|
agentId,
|
|
595
673
|
mediaUrl,
|
|
596
|
-
mimeType
|
|
674
|
+
mimeType,
|
|
597
675
|
fileName,
|
|
598
676
|
audioAsVoice: params.audioAsVoice === true,
|
|
599
677
|
};
|
|
600
678
|
sendEvent(conn.ws, 'chat.media', media);
|
|
679
|
+
// Send caption as a follow-up text message if provided.
|
|
680
|
+
if (typeof params.caption === 'string' && params.caption) {
|
|
681
|
+
const caption: ChatChunkPayload = {
|
|
682
|
+
sessionKey,
|
|
683
|
+
agentId,
|
|
684
|
+
content: params.caption,
|
|
685
|
+
done: true,
|
|
686
|
+
};
|
|
687
|
+
sendEvent(conn.ws, 'chat.done', caption);
|
|
688
|
+
}
|
|
601
689
|
return { ok: true };
|
|
602
690
|
}
|
|
603
691
|
default:
|
|
@@ -614,9 +702,19 @@ export const whimmyPlugin: WhimmyChannelPlugin = {
|
|
|
614
702
|
return { ok: true as const, to: to.trim() };
|
|
615
703
|
},
|
|
616
704
|
sendText: async ({ cfg, to, text, accountId, log }: any) => {
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
705
|
+
const id = accountId ?? 'default';
|
|
706
|
+
const conn = activeConnections.get(id);
|
|
707
|
+
if (!conn || conn.ws.readyState !== WebSocket.OPEN) {
|
|
708
|
+
log?.warn?.(`[Whimmy] sendText: not connected (account=${id})`);
|
|
709
|
+
return { channel: 'whimmy', messageId: randomUUID() };
|
|
710
|
+
}
|
|
711
|
+
const chunk: ChatChunkPayload = {
|
|
712
|
+
sessionKey: to,
|
|
713
|
+
agentId: 'default',
|
|
714
|
+
content: text,
|
|
715
|
+
done: true,
|
|
716
|
+
};
|
|
717
|
+
sendEvent(conn.ws, 'chat.done', chunk);
|
|
620
718
|
log?.debug?.(`[Whimmy] sendText: to=${to} text=${text.slice(0, 80)}`);
|
|
621
719
|
return {
|
|
622
720
|
channel: 'whimmy',
|
package/src/sync.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import type { OpenClawConfig } from 'openclaw/plugin-sdk';
|
|
6
|
+
import { getWhimmyRuntime } from './runtime';
|
|
7
|
+
import type { AgentConfig, Logger } from './types';
|
|
8
|
+
|
|
9
|
+
/** In-memory cache: agentId → hash of { model, systemPrompt }. */
|
|
10
|
+
const hashCache = new Map<string, string>();
|
|
11
|
+
|
|
12
|
+
function computeHash(agentConfig: AgentConfig): string {
|
|
13
|
+
const data = JSON.stringify({
|
|
14
|
+
model: agentConfig.model,
|
|
15
|
+
systemPrompt: agentConfig.systemPrompt ?? '',
|
|
16
|
+
});
|
|
17
|
+
return createHash('sha256').update(data).digest('hex');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Ensure the Whimmy agent exists in OpenClaw's config with the correct
|
|
22
|
+
* model and system prompt. Uses an in-memory hash cache to skip redundant
|
|
23
|
+
* disk writes when nothing changed.
|
|
24
|
+
*/
|
|
25
|
+
export async function ensureWhimmyAgent(
|
|
26
|
+
agentId: string,
|
|
27
|
+
agentConfig: AgentConfig,
|
|
28
|
+
log?: Logger,
|
|
29
|
+
): Promise<OpenClawConfig> {
|
|
30
|
+
const rt = getWhimmyRuntime();
|
|
31
|
+
const hash = computeHash(agentConfig);
|
|
32
|
+
|
|
33
|
+
// Fast path: nothing changed since last sync.
|
|
34
|
+
if (hashCache.get(agentId) === hash) {
|
|
35
|
+
log?.debug?.(`[Whimmy] Agent config unchanged for ${agentId}, skipping sync`);
|
|
36
|
+
return rt.config.loadConfig();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
log?.info?.(`[Whimmy] Syncing agent config for ${agentId}`);
|
|
40
|
+
|
|
41
|
+
const cfg = rt.config.loadConfig();
|
|
42
|
+
|
|
43
|
+
// Ensure agents.list exists.
|
|
44
|
+
if (!cfg.agents) {
|
|
45
|
+
(cfg as any).agents = {};
|
|
46
|
+
}
|
|
47
|
+
if (!cfg.agents!.list) {
|
|
48
|
+
cfg.agents!.list = [];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Find or create agent entry.
|
|
52
|
+
let entry = cfg.agents!.list!.find((a) => a.id === agentId);
|
|
53
|
+
if (!entry) {
|
|
54
|
+
cfg.agents!.list!.push({ id: agentId });
|
|
55
|
+
entry = cfg.agents!.list![cfg.agents!.list!.length - 1];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Set model.
|
|
59
|
+
entry!.model = agentConfig.model;
|
|
60
|
+
|
|
61
|
+
// Resolve workspace dir and write SOUL.md.
|
|
62
|
+
const workspace = cfg.agents?.defaults?.workspace
|
|
63
|
+
?? join(homedir(), '.openclaw', 'workspace');
|
|
64
|
+
const agentDir = join(workspace, 'agents', agentId);
|
|
65
|
+
|
|
66
|
+
mkdirSync(agentDir, { recursive: true });
|
|
67
|
+
|
|
68
|
+
// Write SOUL.md with the system prompt (or empty to clear a previous one).
|
|
69
|
+
writeFileSync(join(agentDir, 'SOUL.md'), agentConfig.systemPrompt ?? '', 'utf-8');
|
|
70
|
+
|
|
71
|
+
// Write an empty BOOTSTRAP.md so the default workspace bootstrap isn't inherited.
|
|
72
|
+
writeFileSync(join(agentDir, 'BOOTSTRAP.md'), '', 'utf-8');
|
|
73
|
+
|
|
74
|
+
entry!.workspace = agentDir;
|
|
75
|
+
entry!.agentDir = agentDir;
|
|
76
|
+
|
|
77
|
+
// Persist config.
|
|
78
|
+
await rt.config.writeConfigFile(cfg);
|
|
79
|
+
|
|
80
|
+
// Update cache.
|
|
81
|
+
hashCache.set(agentId, hash);
|
|
82
|
+
|
|
83
|
+
log?.info?.(`[Whimmy] Agent ${agentId} synced: model=${agentConfig.model} agentDir=${agentDir}`);
|
|
84
|
+
|
|
85
|
+
return cfg;
|
|
86
|
+
}
|
package/src/utils.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { basename } from 'node:path';
|
|
1
3
|
import type { ConnectionInfo, WhimmyConfig, Logger } from './types';
|
|
2
4
|
|
|
3
5
|
const DEFAULT_HOST = 'api.whimmy.ai';
|
|
@@ -176,3 +178,45 @@ export async function retryWithBackoff<T>(
|
|
|
176
178
|
|
|
177
179
|
throw new Error('Retry exhausted');
|
|
178
180
|
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Upload a local file to the Whimmy backend.
|
|
184
|
+
* Returns the uploaded file's public URL.
|
|
185
|
+
*/
|
|
186
|
+
export async function uploadFile(
|
|
187
|
+
filePath: string,
|
|
188
|
+
conn: ConnectionInfo,
|
|
189
|
+
): Promise<{ url: string; fileName: string; mimeType: string }> {
|
|
190
|
+
const protocol = conn.tls ? 'https' : 'http';
|
|
191
|
+
const url = `${protocol}://${conn.host}/files/upload`;
|
|
192
|
+
|
|
193
|
+
const fileBuffer = readFileSync(filePath);
|
|
194
|
+
const fileName = basename(filePath);
|
|
195
|
+
|
|
196
|
+
const form = new FormData();
|
|
197
|
+
form.append('file', new Blob([fileBuffer]), fileName);
|
|
198
|
+
|
|
199
|
+
const resp = await fetch(url, {
|
|
200
|
+
method: 'POST',
|
|
201
|
+
headers: { 'Authorization': `Bearer ${conn.token}` },
|
|
202
|
+
body: form,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
if (!resp.ok) {
|
|
206
|
+
const body = await resp.text();
|
|
207
|
+
throw new Error(`File upload failed (${resp.status}): ${body}`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const data = await resp.json() as {
|
|
211
|
+
id: string;
|
|
212
|
+
url: string;
|
|
213
|
+
originalName: string;
|
|
214
|
+
mimeType: string;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
url: data.url,
|
|
219
|
+
fileName: data.originalName,
|
|
220
|
+
mimeType: data.mimeType,
|
|
221
|
+
};
|
|
222
|
+
}
|