@shadowob/sdk 0.2.1

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/src/client.ts ADDED
@@ -0,0 +1,968 @@
1
+ import type {
2
+ ShadowChannel,
3
+ ShadowDmChannel,
4
+ ShadowInviteCode,
5
+ ShadowMember,
6
+ ShadowMessage,
7
+ ShadowNotification,
8
+ ShadowRemoteConfig,
9
+ ShadowServer,
10
+ ShadowThread,
11
+ ShadowUser,
12
+ } from './types'
13
+
14
+ /**
15
+ * Shadow REST API client.
16
+ *
17
+ * Provides typed HTTP methods for interacting with the Shadow server API.
18
+ */
19
+ export class ShadowClient {
20
+ private baseUrl: string
21
+
22
+ constructor(
23
+ baseUrl: string,
24
+ private token: string,
25
+ ) {
26
+ // Normalize: strip trailing /api or /api/ to prevent doubled paths
27
+ this.baseUrl = baseUrl.replace(/\/api\/?$/, '')
28
+ }
29
+
30
+ private async request<T>(path: string, init?: RequestInit): Promise<T> {
31
+ const url = `${this.baseUrl}${path}`
32
+ const controller = new AbortController()
33
+ const timeout = setTimeout(() => controller.abort(), 60_000)
34
+ try {
35
+ const res = await fetch(url, {
36
+ ...init,
37
+ signal: init?.signal ?? controller.signal,
38
+ headers: {
39
+ 'Content-Type': 'application/json',
40
+ Authorization: `Bearer ${this.token}`,
41
+ ...init?.headers,
42
+ },
43
+ })
44
+ if (!res.ok) {
45
+ const body = await res.text().catch(() => '')
46
+ throw new Error(
47
+ `Shadow API ${init?.method ?? 'GET'} ${path} failed (${res.status}): ${body}`,
48
+ )
49
+ }
50
+ return res.json() as Promise<T>
51
+ } finally {
52
+ clearTimeout(timeout)
53
+ }
54
+ }
55
+
56
+ private async requestRaw(path: string, init?: RequestInit): Promise<Response> {
57
+ const url = `${this.baseUrl}${path}`
58
+ const res = await fetch(url, {
59
+ ...init,
60
+ headers: {
61
+ Authorization: `Bearer ${this.token}`,
62
+ ...init?.headers,
63
+ },
64
+ })
65
+ if (!res.ok) {
66
+ const body = await res.text().catch(() => '')
67
+ throw new Error(`Shadow API ${init?.method ?? 'GET'} ${path} failed (${res.status}): ${body}`)
68
+ }
69
+ return res
70
+ }
71
+
72
+ // ── Auth ──────────────────────────────────────────────────────────────
73
+
74
+ async register(data: {
75
+ email: string
76
+ password: string
77
+ username: string
78
+ displayName?: string
79
+ inviteCode: string
80
+ }): Promise<{ token: string; user: ShadowUser }> {
81
+ return this.request('/api/auth/register', {
82
+ method: 'POST',
83
+ body: JSON.stringify(data),
84
+ })
85
+ }
86
+
87
+ async login(data: {
88
+ email: string
89
+ password: string
90
+ }): Promise<{ token: string; user: ShadowUser }> {
91
+ return this.request('/api/auth/login', {
92
+ method: 'POST',
93
+ body: JSON.stringify(data),
94
+ })
95
+ }
96
+
97
+ async refreshToken(): Promise<{ token: string }> {
98
+ return this.request('/api/auth/refresh', { method: 'POST' })
99
+ }
100
+
101
+ async getMe(): Promise<ShadowUser> {
102
+ return this.request('/api/auth/me')
103
+ }
104
+
105
+ async updateProfile(data: {
106
+ displayName?: string
107
+ avatarUrl?: string | null
108
+ }): Promise<ShadowUser> {
109
+ return this.request('/api/auth/me', {
110
+ method: 'PATCH',
111
+ body: JSON.stringify(data),
112
+ })
113
+ }
114
+
115
+ async disconnect(): Promise<{
116
+ success: boolean
117
+ }> {
118
+ return this.request('/api/auth/disconnect', { method: 'POST' })
119
+ }
120
+
121
+ // ── Agents ────────────────────────────────────────────────────────────
122
+
123
+ async listAgents(): Promise<{ id: string; name: string; status: string }[]> {
124
+ return this.request('/api/agents')
125
+ }
126
+
127
+ async createAgent(data: {
128
+ name: string
129
+ displayName?: string
130
+ avatarUrl?: string | null
131
+ }): Promise<{ id: string; token: string; userId: string }> {
132
+ return this.request('/api/agents', {
133
+ method: 'POST',
134
+ body: JSON.stringify(data),
135
+ })
136
+ }
137
+
138
+ async getAgent(
139
+ agentId: string,
140
+ ): Promise<{ id: string; name: string; status: string; userId: string }> {
141
+ return this.request(`/api/agents/${agentId}`)
142
+ }
143
+
144
+ async updateAgent(
145
+ agentId: string,
146
+ data: { name?: string; displayName?: string; avatarUrl?: string | null },
147
+ ): Promise<{ id: string; name: string }> {
148
+ return this.request(`/api/agents/${agentId}`, {
149
+ method: 'PATCH',
150
+ body: JSON.stringify(data),
151
+ })
152
+ }
153
+
154
+ async deleteAgent(agentId: string): Promise<{ success: boolean }> {
155
+ return this.request(`/api/agents/${agentId}`, { method: 'DELETE' })
156
+ }
157
+
158
+ async generateAgentToken(agentId: string): Promise<{ token: string }> {
159
+ return this.request(`/api/agents/${agentId}/token`, { method: 'POST' })
160
+ }
161
+
162
+ async startAgent(agentId: string): Promise<{ ok: boolean }> {
163
+ return this.request(`/api/agents/${agentId}/start`, { method: 'POST' })
164
+ }
165
+
166
+ async stopAgent(agentId: string): Promise<{ ok: boolean }> {
167
+ return this.request(`/api/agents/${agentId}/stop`, { method: 'POST' })
168
+ }
169
+
170
+ async sendHeartbeat(agentId: string): Promise<{ ok: boolean }> {
171
+ return this.request(`/api/agents/${agentId}/heartbeat`, {
172
+ method: 'POST',
173
+ body: JSON.stringify({}),
174
+ })
175
+ }
176
+
177
+ async getAgentConfig(agentId: string): Promise<ShadowRemoteConfig> {
178
+ return this.request<ShadowRemoteConfig>(`/api/agents/${agentId}/config`)
179
+ }
180
+
181
+ // ── Agent Policies ────────────────────────────────────────────────────
182
+
183
+ async listPolicies(
184
+ agentId: string,
185
+ serverId: string,
186
+ ): Promise<
187
+ {
188
+ channelId: string | null
189
+ mentionOnly: boolean
190
+ reply: boolean
191
+ config: Record<string, unknown>
192
+ }[]
193
+ > {
194
+ return this.request(`/api/agents/${agentId}/servers/${serverId}/policies`)
195
+ }
196
+
197
+ async upsertPolicy(
198
+ agentId: string,
199
+ serverId: string,
200
+ data: {
201
+ channelId?: string | null
202
+ mentionOnly?: boolean
203
+ reply?: boolean
204
+ config?: Record<string, unknown>
205
+ },
206
+ ): Promise<{ channelId: string | null; mentionOnly: boolean; reply: boolean }> {
207
+ return this.request(`/api/agents/${agentId}/servers/${serverId}/policies`, {
208
+ method: 'PUT',
209
+ body: JSON.stringify(data),
210
+ })
211
+ }
212
+
213
+ async deletePolicy(
214
+ agentId: string,
215
+ serverId: string,
216
+ channelId: string,
217
+ ): Promise<{ success: boolean }> {
218
+ return this.request(`/api/agents/${agentId}/servers/${serverId}/policies/${channelId}`, {
219
+ method: 'DELETE',
220
+ })
221
+ }
222
+
223
+ // ── Servers ───────────────────────────────────────────────────────────
224
+
225
+ async discoverServers(): Promise<ShadowServer[]> {
226
+ return this.request('/api/servers/discover')
227
+ }
228
+
229
+ async getServerByInvite(inviteCode: string): Promise<ShadowServer> {
230
+ return this.request(`/api/servers/invite/${encodeURIComponent(inviteCode)}`)
231
+ }
232
+
233
+ async createServer(data: {
234
+ name: string
235
+ slug?: string
236
+ description?: string
237
+ isPublic?: boolean
238
+ }): Promise<ShadowServer> {
239
+ return this.request('/api/servers', {
240
+ method: 'POST',
241
+ body: JSON.stringify(data),
242
+ })
243
+ }
244
+
245
+ async listServers(): Promise<ShadowServer[]> {
246
+ return this.request('/api/servers')
247
+ }
248
+
249
+ async getServer(serverIdOrSlug: string): Promise<ShadowServer> {
250
+ return this.request(`/api/servers/${serverIdOrSlug}`)
251
+ }
252
+
253
+ async updateServer(
254
+ serverIdOrSlug: string,
255
+ data: {
256
+ name?: string
257
+ description?: string | null
258
+ slug?: string | null
259
+ homepageHtml?: string | null
260
+ isPublic?: boolean
261
+ },
262
+ ): Promise<ShadowServer> {
263
+ return this.request(`/api/servers/${serverIdOrSlug}`, {
264
+ method: 'PATCH',
265
+ body: JSON.stringify(data),
266
+ })
267
+ }
268
+
269
+ async updateServerHomepage(
270
+ serverIdOrSlug: string,
271
+ homepageHtml: string | null,
272
+ ): Promise<ShadowServer> {
273
+ return this.updateServer(serverIdOrSlug, { homepageHtml })
274
+ }
275
+
276
+ async deleteServer(serverId: string): Promise<{ success: boolean }> {
277
+ return this.request(`/api/servers/${serverId}`, { method: 'DELETE' })
278
+ }
279
+
280
+ async joinServer(serverId: string, inviteCode?: string): Promise<{ success: boolean }> {
281
+ return this.request(`/api/servers/${serverId}/join`, {
282
+ method: 'POST',
283
+ body: JSON.stringify(inviteCode ? { inviteCode } : {}),
284
+ })
285
+ }
286
+
287
+ async leaveServer(serverId: string): Promise<{ success: boolean }> {
288
+ return this.request(`/api/servers/${serverId}/leave`, { method: 'POST' })
289
+ }
290
+
291
+ async getMembers(serverId: string): Promise<ShadowMember[]> {
292
+ return this.request(`/api/servers/${serverId}/members`)
293
+ }
294
+
295
+ async updateMember(
296
+ serverId: string,
297
+ userId: string,
298
+ data: { role?: string },
299
+ ): Promise<ShadowMember> {
300
+ return this.request(`/api/servers/${serverId}/members/${userId}`, {
301
+ method: 'PATCH',
302
+ body: JSON.stringify(data),
303
+ })
304
+ }
305
+
306
+ async kickMember(serverId: string, userId: string): Promise<{ success: boolean }> {
307
+ return this.request(`/api/servers/${serverId}/members/${userId}`, { method: 'DELETE' })
308
+ }
309
+
310
+ async regenerateInviteCode(serverId: string): Promise<{ inviteCode: string }> {
311
+ return this.request(`/api/servers/${serverId}/invite`, { method: 'POST' })
312
+ }
313
+
314
+ async addAgentsToServer(serverId: string, agentIds: string[]): Promise<{ added: string[] }> {
315
+ return this.request(`/api/servers/${serverId}/agents`, {
316
+ method: 'POST',
317
+ body: JSON.stringify({ agentIds }),
318
+ })
319
+ }
320
+
321
+ // ── Channels ──────────────────────────────────────────────────────────
322
+
323
+ async getServerChannels(serverId: string): Promise<ShadowChannel[]> {
324
+ return this.request<ShadowChannel[]>(`/api/servers/${serverId}/channels`)
325
+ }
326
+
327
+ async createChannel(
328
+ serverId: string,
329
+ data: { name: string; type?: string; description?: string },
330
+ ): Promise<ShadowChannel> {
331
+ return this.request(`/api/servers/${serverId}/channels`, {
332
+ method: 'POST',
333
+ body: JSON.stringify(data),
334
+ })
335
+ }
336
+
337
+ async getChannel(channelId: string): Promise<ShadowChannel> {
338
+ return this.request(`/api/channels/${channelId}`)
339
+ }
340
+
341
+ async getChannelMembers(channelId: string): Promise<ShadowMember[]> {
342
+ return this.request(`/api/channels/${channelId}/members`)
343
+ }
344
+
345
+ async updateChannel(
346
+ channelId: string,
347
+ data: { name?: string; description?: string | null },
348
+ ): Promise<ShadowChannel> {
349
+ return this.request(`/api/channels/${channelId}`, {
350
+ method: 'PATCH',
351
+ body: JSON.stringify(data),
352
+ })
353
+ }
354
+
355
+ async deleteChannel(channelId: string): Promise<{ success: boolean }> {
356
+ return this.request(`/api/channels/${channelId}`, { method: 'DELETE' })
357
+ }
358
+
359
+ async reorderChannels(serverId: string, channelIds: string[]): Promise<{ success: boolean }> {
360
+ return this.request(`/api/servers/${serverId}/channels/reorder`, {
361
+ method: 'PUT',
362
+ body: JSON.stringify({ channelIds }),
363
+ })
364
+ }
365
+
366
+ async addChannelMember(channelId: string, userId: string): Promise<{ success: boolean }> {
367
+ return this.request(`/api/channels/${channelId}/members`, {
368
+ method: 'POST',
369
+ body: JSON.stringify({ userId }),
370
+ })
371
+ }
372
+
373
+ async removeChannelMember(channelId: string, userId: string): Promise<{ success: boolean }> {
374
+ return this.request(`/api/channels/${channelId}/members/${userId}`, { method: 'DELETE' })
375
+ }
376
+
377
+ // ── Channel Buddy Policy ─────────────────────────────────────────────
378
+
379
+ async setBuddyPolicy(
380
+ channelId: string,
381
+ data: { buddyUserId: string; mentionOnly?: boolean; reply?: boolean },
382
+ ): Promise<{ success: boolean }> {
383
+ return this.request(`/api/channels/${channelId}/buddy-policy`, {
384
+ method: 'PUT',
385
+ body: JSON.stringify(data),
386
+ })
387
+ }
388
+
389
+ async getBuddyPolicy(
390
+ channelId: string,
391
+ ): Promise<{ buddyUserId: string | null; mentionOnly: boolean; reply: boolean } | null> {
392
+ return this.request(`/api/channels/${channelId}/buddy-policy`)
393
+ }
394
+
395
+ // ── Messages ──────────────────────────────────────────────────────────
396
+
397
+ async sendMessage(
398
+ channelId: string,
399
+ content: string,
400
+ opts?: { threadId?: string; replyToId?: string },
401
+ ): Promise<ShadowMessage> {
402
+ return this.request<ShadowMessage>(`/api/channels/${channelId}/messages`, {
403
+ method: 'POST',
404
+ body: JSON.stringify({
405
+ content,
406
+ ...(opts?.threadId ? { threadId: opts.threadId } : {}),
407
+ ...(opts?.replyToId ? { replyToId: opts.replyToId } : {}),
408
+ }),
409
+ })
410
+ }
411
+
412
+ async getMessages(
413
+ channelId: string,
414
+ limit = 50,
415
+ cursor?: string,
416
+ ): Promise<{ messages: ShadowMessage[]; hasMore: boolean }> {
417
+ const params = new URLSearchParams({ limit: String(limit) })
418
+ if (cursor) params.set('cursor', cursor)
419
+ return this.request<{ messages: ShadowMessage[]; hasMore: boolean }>(
420
+ `/api/channels/${channelId}/messages?${params}`,
421
+ )
422
+ }
423
+
424
+ async getMessage(messageId: string): Promise<ShadowMessage> {
425
+ return this.request(`/api/messages/${messageId}`)
426
+ }
427
+
428
+ async editMessage(messageId: string, content: string): Promise<ShadowMessage> {
429
+ return this.request<ShadowMessage>(`/api/messages/${messageId}`, {
430
+ method: 'PATCH',
431
+ body: JSON.stringify({ content }),
432
+ })
433
+ }
434
+
435
+ async deleteMessage(messageId: string): Promise<void> {
436
+ await this.request<{ success: boolean }>(`/api/messages/${messageId}`, {
437
+ method: 'DELETE',
438
+ })
439
+ }
440
+
441
+ // ── Pins ──────────────────────────────────────────────────────────────
442
+
443
+ async pinMessage(messageId: string): Promise<{ success: boolean }> {
444
+ return this.request(`/api/messages/${messageId}/pin`, { method: 'POST' })
445
+ }
446
+
447
+ async unpinMessage(messageId: string): Promise<{ success: boolean }> {
448
+ return this.request(`/api/messages/${messageId}/pin`, { method: 'DELETE' })
449
+ }
450
+
451
+ async getPinnedMessages(channelId: string): Promise<ShadowMessage[]> {
452
+ return this.request(`/api/channels/${channelId}/pins`)
453
+ }
454
+
455
+ // ── Reactions ─────────────────────────────────────────────────────────
456
+
457
+ async addReaction(messageId: string, emoji: string): Promise<void> {
458
+ await this.request(`/api/messages/${messageId}/reactions`, {
459
+ method: 'POST',
460
+ body: JSON.stringify({ emoji }),
461
+ })
462
+ }
463
+
464
+ async removeReaction(messageId: string, emoji: string): Promise<void> {
465
+ await this.request(`/api/messages/${messageId}/reactions/${encodeURIComponent(emoji)}`, {
466
+ method: 'DELETE',
467
+ })
468
+ }
469
+
470
+ async getReactions(
471
+ messageId: string,
472
+ ): Promise<{ emoji: string; count: number; users: string[] }[]> {
473
+ return this.request(`/api/messages/${messageId}/reactions`)
474
+ }
475
+
476
+ // ── Threads ───────────────────────────────────────────────────────────
477
+
478
+ async listThreads(channelId: string): Promise<ShadowThread[]> {
479
+ return this.request(`/api/channels/${channelId}/threads`)
480
+ }
481
+
482
+ async createThread(
483
+ channelId: string,
484
+ name: string,
485
+ parentMessageId: string,
486
+ ): Promise<{ id: string; name: string }> {
487
+ return this.request(`/api/channels/${channelId}/threads`, {
488
+ method: 'POST',
489
+ body: JSON.stringify({ name, parentMessageId }),
490
+ })
491
+ }
492
+
493
+ async getThread(threadId: string): Promise<ShadowThread> {
494
+ return this.request(`/api/threads/${threadId}`)
495
+ }
496
+
497
+ async updateThread(threadId: string, data: { name?: string }): Promise<ShadowThread> {
498
+ return this.request(`/api/threads/${threadId}`, {
499
+ method: 'PATCH',
500
+ body: JSON.stringify(data),
501
+ })
502
+ }
503
+
504
+ async deleteThread(threadId: string): Promise<{ success: boolean }> {
505
+ return this.request(`/api/threads/${threadId}`, { method: 'DELETE' })
506
+ }
507
+
508
+ async getThreadMessages(threadId: string, limit = 50, cursor?: string): Promise<ShadowMessage[]> {
509
+ const params = new URLSearchParams({ limit: String(limit) })
510
+ if (cursor) params.set('cursor', cursor)
511
+ return this.request<ShadowMessage[]>(`/api/threads/${threadId}/messages?${params}`)
512
+ }
513
+
514
+ async sendToThread(threadId: string, content: string): Promise<ShadowMessage> {
515
+ return this.request<ShadowMessage>(`/api/threads/${threadId}/messages`, {
516
+ method: 'POST',
517
+ body: JSON.stringify({ content }),
518
+ })
519
+ }
520
+
521
+ // ── DMs ───────────────────────────────────────────────────────────────
522
+
523
+ async createDmChannel(userId: string): Promise<ShadowDmChannel> {
524
+ return this.request('/api/dm/channels', {
525
+ method: 'POST',
526
+ body: JSON.stringify({ userId }),
527
+ })
528
+ }
529
+
530
+ async listDmChannels(): Promise<ShadowDmChannel[]> {
531
+ return this.request('/api/dm/channels')
532
+ }
533
+
534
+ async getDmMessages(channelId: string, limit = 50, cursor?: string): Promise<ShadowMessage[]> {
535
+ const params = new URLSearchParams({ limit: String(limit) })
536
+ if (cursor) params.set('cursor', cursor)
537
+ return this.request(`/api/dm/channels/${channelId}/messages?${params}`)
538
+ }
539
+
540
+ async sendDmMessage(channelId: string, content: string): Promise<ShadowMessage> {
541
+ return this.request(`/api/dm/channels/${channelId}/messages`, {
542
+ method: 'POST',
543
+ body: JSON.stringify({ content }),
544
+ })
545
+ }
546
+
547
+ // ── Notifications ─────────────────────────────────────────────────────
548
+
549
+ async listNotifications(limit = 50, offset = 0): Promise<ShadowNotification[]> {
550
+ const params = new URLSearchParams({ limit: String(limit), offset: String(offset) })
551
+ return this.request(`/api/notifications?${params}`)
552
+ }
553
+
554
+ async markNotificationRead(notificationId: string): Promise<{ success: boolean }> {
555
+ return this.request(`/api/notifications/${notificationId}/read`, { method: 'PATCH' })
556
+ }
557
+
558
+ async markAllNotificationsRead(): Promise<{ success: boolean }> {
559
+ return this.request('/api/notifications/read-all', { method: 'PATCH' })
560
+ }
561
+
562
+ async getUnreadCount(): Promise<{ count: number }> {
563
+ return this.request('/api/notifications/unread-count')
564
+ }
565
+
566
+ // ── Search ────────────────────────────────────────────────────────────
567
+
568
+ async searchMessages(query: {
569
+ q: string
570
+ serverId?: string
571
+ channelId?: string
572
+ authorId?: string
573
+ limit?: number
574
+ offset?: number
575
+ }): Promise<{ messages: ShadowMessage[]; total: number }> {
576
+ const params = new URLSearchParams({ q: query.q })
577
+ if (query.serverId) params.set('serverId', query.serverId)
578
+ if (query.channelId) params.set('channelId', query.channelId)
579
+ if (query.authorId) params.set('authorId', query.authorId)
580
+ if (query.limit) params.set('limit', String(query.limit))
581
+ if (query.offset) params.set('offset', String(query.offset))
582
+ return this.request(`/api/search/messages?${params}`)
583
+ }
584
+
585
+ // ── Invites ───────────────────────────────────────────────────────────
586
+
587
+ async listInvites(): Promise<ShadowInviteCode[]> {
588
+ return this.request('/api/invites')
589
+ }
590
+
591
+ async createInvites(count: number, note?: string): Promise<ShadowInviteCode[]> {
592
+ return this.request('/api/invites', {
593
+ method: 'POST',
594
+ body: JSON.stringify({ count, ...(note ? { note } : {}) }),
595
+ })
596
+ }
597
+
598
+ async deactivateInvite(inviteId: string): Promise<ShadowInviteCode> {
599
+ return this.request(`/api/invites/${inviteId}/deactivate`, { method: 'PATCH' })
600
+ }
601
+
602
+ async deleteInvite(inviteId: string): Promise<{ success: boolean }> {
603
+ return this.request(`/api/invites/${inviteId}`, { method: 'DELETE' })
604
+ }
605
+
606
+ // ── Media ─────────────────────────────────────────────────────────────
607
+
608
+ async uploadMedia(
609
+ file: Blob | ArrayBuffer,
610
+ filename: string,
611
+ contentType: string,
612
+ messageId?: string,
613
+ ): Promise<{ url: string; key: string; size: number }> {
614
+ const formData = new FormData()
615
+ const blob = file instanceof Blob ? file : new Blob([file], { type: contentType })
616
+ formData.append('file', blob, filename)
617
+ if (messageId) {
618
+ formData.append('messageId', messageId)
619
+ }
620
+
621
+ const url = `${this.baseUrl}/api/media/upload`
622
+ const res = await fetch(url, {
623
+ method: 'POST',
624
+ headers: {
625
+ Authorization: `Bearer ${this.token}`,
626
+ },
627
+ body: formData,
628
+ })
629
+ if (!res.ok) {
630
+ const body = await res.text().catch(() => '')
631
+ throw new Error(`Shadow API POST /api/media/upload failed (${res.status}): ${body}`)
632
+ }
633
+ return res.json() as Promise<{ url: string; key: string; size: number }>
634
+ }
635
+
636
+ /**
637
+ * Download a file from a URL and upload it to the Shadow media service.
638
+ * Supports local filesystem paths, file:// URLs, tilde paths, and HTTP(S) URLs.
639
+ */
640
+ async uploadMediaFromUrl(
641
+ mediaUrl: string,
642
+ messageId?: string,
643
+ ): Promise<{ url: string; key: string; size: number }> {
644
+ // Dynamic imports for Node.js fs/path/os
645
+ // @ts-expect-error node:fs/promises is available at runtime
646
+ const { readFile } = await import('node:fs/promises')
647
+ // @ts-expect-error node:path is available at runtime
648
+ const { basename } = await import('node:path')
649
+ // @ts-expect-error node:os is available at runtime
650
+ const { homedir } = await import('node:os')
651
+
652
+ // Strip MEDIA: prefix used by agent tools to tag media paths
653
+ let normalizedUrl = mediaUrl.replace(/^\s*MEDIA\s*:\s*/i, '')
654
+
655
+ // Handle file:// URLs
656
+ if (normalizedUrl.startsWith('file://')) {
657
+ normalizedUrl = normalizedUrl.replace(/^file:\/\//, '')
658
+ }
659
+
660
+ // Expand tilde paths
661
+ if (normalizedUrl.startsWith('~')) {
662
+ normalizedUrl = normalizedUrl.replace(/^~/, homedir())
663
+ }
664
+
665
+ // Resolve relative paths
666
+ if (
667
+ !normalizedUrl.startsWith('/') &&
668
+ !normalizedUrl.startsWith('http://') &&
669
+ !normalizedUrl.startsWith('https://') &&
670
+ !normalizedUrl.startsWith('//')
671
+ ) {
672
+ // @ts-expect-error node:fs is available at runtime
673
+ const { existsSync } = await import('node:fs')
674
+ // @ts-expect-error node:path is available at runtime
675
+ const { resolve } = await import('node:path')
676
+
677
+ const cwd = (globalThis as Record<string, unknown>).process
678
+ ? ((globalThis as Record<string, unknown>).process as { cwd: () => string }).cwd()
679
+ : '/'
680
+ const roots = [resolve(homedir(), '.openclaw', 'workspace'), cwd]
681
+ let resolved = false
682
+ for (const root of roots) {
683
+ const candidate = resolve(root, normalizedUrl)
684
+ if (existsSync(candidate)) {
685
+ normalizedUrl = candidate
686
+ resolved = true
687
+ break
688
+ }
689
+ }
690
+ if (!resolved) {
691
+ normalizedUrl = resolve(cwd, normalizedUrl)
692
+ }
693
+ }
694
+
695
+ if (normalizedUrl.startsWith('/') && !normalizedUrl.startsWith('//')) {
696
+ // Local filesystem path
697
+ const fileBuffer = await readFile(normalizedUrl)
698
+ const bytes = new Uint8Array(fileBuffer)
699
+ const filename: string = basename(normalizedUrl)
700
+ const ext = filename.split('.').pop()?.toLowerCase() ?? ''
701
+ const mimeMap: Record<string, string> = {
702
+ jpg: 'image/jpeg',
703
+ jpeg: 'image/jpeg',
704
+ png: 'image/png',
705
+ gif: 'image/gif',
706
+ webp: 'image/webp',
707
+ svg: 'image/svg+xml',
708
+ mp4: 'video/mp4',
709
+ webm: 'video/webm',
710
+ mp3: 'audio/mpeg',
711
+ wav: 'audio/wav',
712
+ ogg: 'audio/ogg',
713
+ pdf: 'application/pdf',
714
+ txt: 'text/plain',
715
+ csv: 'text/csv',
716
+ json: 'application/json',
717
+ html: 'text/html',
718
+ xml: 'application/xml',
719
+ zip: 'application/zip',
720
+ }
721
+ const contentType = mimeMap[ext] ?? 'application/octet-stream'
722
+ return this.uploadMedia(
723
+ new Blob([bytes], { type: contentType }),
724
+ filename,
725
+ contentType,
726
+ messageId,
727
+ )
728
+ }
729
+
730
+ // HTTP/HTTPS URL
731
+ const res = await fetch(normalizedUrl)
732
+ if (!res.ok) {
733
+ throw new Error(`Failed to download media from ${normalizedUrl}: ${res.status}`)
734
+ }
735
+ const blob = await res.blob()
736
+ const urlPath = new URL(normalizedUrl).pathname
737
+ const filename = urlPath.split('/').pop() ?? 'file'
738
+ const contentType = blob.type || 'application/octet-stream'
739
+ return this.uploadMedia(blob, filename, contentType, messageId)
740
+ }
741
+
742
+ async downloadFile(
743
+ fileUrl: string,
744
+ ): Promise<{ buffer: ArrayBuffer; contentType: string; filename: string }> {
745
+ const headers: Record<string, string> = {}
746
+ if (fileUrl.startsWith(this.baseUrl) || fileUrl.startsWith('/')) {
747
+ headers.Authorization = `Bearer ${this.token}`
748
+ }
749
+ const fullUrl = fileUrl.startsWith('/') ? `${this.baseUrl}${fileUrl}` : fileUrl
750
+ const res = await fetch(fullUrl, { headers, redirect: 'follow' })
751
+ if (!res.ok) {
752
+ throw new Error(`Failed to download file from ${fullUrl}: ${res.status}`)
753
+ }
754
+ const buffer = await res.arrayBuffer()
755
+ const contentType = res.headers.get('content-type') ?? 'application/octet-stream'
756
+ const urlPath = new URL(fullUrl).pathname
757
+ const filename = decodeURIComponent(urlPath.split('/').pop() ?? 'file')
758
+ return { buffer, contentType, filename }
759
+ }
760
+
761
+ // ── Workspace ─────────────────────────────────────────────────────────
762
+
763
+ async getWorkspace(serverId: string): Promise<Record<string, unknown>> {
764
+ return this.request(`/api/servers/${serverId}/workspace`)
765
+ }
766
+
767
+ async updateWorkspace(
768
+ serverId: string,
769
+ data: { name?: string; description?: string | null },
770
+ ): Promise<Record<string, unknown>> {
771
+ return this.request(`/api/servers/${serverId}/workspace`, {
772
+ method: 'PATCH',
773
+ body: JSON.stringify(data),
774
+ })
775
+ }
776
+
777
+ async getWorkspaceTree(serverId: string): Promise<Record<string, unknown>> {
778
+ return this.request(`/api/servers/${serverId}/workspace/tree`)
779
+ }
780
+
781
+ async getWorkspaceStats(serverId: string): Promise<Record<string, unknown>> {
782
+ return this.request(`/api/servers/${serverId}/workspace/stats`)
783
+ }
784
+
785
+ async getWorkspaceChildren(
786
+ serverId: string,
787
+ parentId?: string | null,
788
+ ): Promise<Record<string, unknown>[]> {
789
+ const params = new URLSearchParams()
790
+ if (parentId !== undefined && parentId !== null) params.set('parentId', parentId)
791
+ const qs = params.toString()
792
+ return this.request(`/api/servers/${serverId}/workspace/children${qs ? `?${qs}` : ''}`)
793
+ }
794
+
795
+ async batchWorkspaceChildren(
796
+ serverId: string,
797
+ parentIds: (string | null)[],
798
+ ): Promise<Record<string, Record<string, unknown>[]>> {
799
+ return this.request(`/api/servers/${serverId}/workspace/children/batch`, {
800
+ method: 'POST',
801
+ body: JSON.stringify({ parentIds }),
802
+ })
803
+ }
804
+
805
+ async createWorkspaceFolder(
806
+ serverId: string,
807
+ data: { parentId?: string | null; name: string },
808
+ ): Promise<Record<string, unknown>> {
809
+ return this.request(`/api/servers/${serverId}/workspace/folders`, {
810
+ method: 'POST',
811
+ body: JSON.stringify(data),
812
+ })
813
+ }
814
+
815
+ async updateWorkspaceFolder(
816
+ serverId: string,
817
+ folderId: string,
818
+ data: { name?: string; parentId?: string | null; pos?: number },
819
+ ): Promise<Record<string, unknown>> {
820
+ return this.request(`/api/servers/${serverId}/workspace/folders/${folderId}`, {
821
+ method: 'PATCH',
822
+ body: JSON.stringify(data),
823
+ })
824
+ }
825
+
826
+ async deleteWorkspaceFolder(serverId: string, folderId: string): Promise<{ success: boolean }> {
827
+ return this.request(`/api/servers/${serverId}/workspace/folders/${folderId}`, {
828
+ method: 'DELETE',
829
+ })
830
+ }
831
+
832
+ async searchWorkspaceFolders(
833
+ serverId: string,
834
+ query: { searchText?: string; limit?: number },
835
+ ): Promise<Record<string, unknown>[]> {
836
+ const params = new URLSearchParams()
837
+ if (query.searchText) params.set('searchText', query.searchText)
838
+ if (query.limit) params.set('limit', String(query.limit))
839
+ return this.request(`/api/servers/${serverId}/workspace/folders/search?${params}`)
840
+ }
841
+
842
+ async createWorkspaceFile(
843
+ serverId: string,
844
+ data: {
845
+ parentId?: string | null
846
+ name: string
847
+ ext?: string | null
848
+ mime?: string | null
849
+ sizeBytes?: number | null
850
+ contentRef?: string | null
851
+ previewUrl?: string | null
852
+ metadata?: Record<string, unknown> | null
853
+ },
854
+ ): Promise<Record<string, unknown>> {
855
+ return this.request(`/api/servers/${serverId}/workspace/files`, {
856
+ method: 'POST',
857
+ body: JSON.stringify(data),
858
+ })
859
+ }
860
+
861
+ async searchWorkspaceFiles(
862
+ serverId: string,
863
+ query: {
864
+ parentId?: string
865
+ searchText?: string
866
+ ext?: string
867
+ limit?: number
868
+ offset?: number
869
+ },
870
+ ): Promise<Record<string, unknown>[]> {
871
+ const params = new URLSearchParams()
872
+ if (query.parentId) params.set('parentId', query.parentId)
873
+ if (query.searchText) params.set('searchText', query.searchText)
874
+ if (query.ext) params.set('ext', query.ext)
875
+ if (query.limit) params.set('limit', String(query.limit))
876
+ if (query.offset) params.set('offset', String(query.offset))
877
+ return this.request(`/api/servers/${serverId}/workspace/files/search?${params}`)
878
+ }
879
+
880
+ async getWorkspaceFile(serverId: string, fileId: string): Promise<Record<string, unknown>> {
881
+ return this.request(`/api/servers/${serverId}/workspace/files/${fileId}`)
882
+ }
883
+
884
+ async updateWorkspaceFile(
885
+ serverId: string,
886
+ fileId: string,
887
+ data: {
888
+ name?: string
889
+ parentId?: string | null
890
+ pos?: number
891
+ ext?: string | null
892
+ mime?: string | null
893
+ sizeBytes?: number | null
894
+ contentRef?: string | null
895
+ previewUrl?: string | null
896
+ metadata?: Record<string, unknown> | null
897
+ },
898
+ ): Promise<Record<string, unknown>> {
899
+ return this.request(`/api/servers/${serverId}/workspace/files/${fileId}`, {
900
+ method: 'PATCH',
901
+ body: JSON.stringify(data),
902
+ })
903
+ }
904
+
905
+ async deleteWorkspaceFile(serverId: string, fileId: string): Promise<{ success: boolean }> {
906
+ return this.request(`/api/servers/${serverId}/workspace/files/${fileId}`, { method: 'DELETE' })
907
+ }
908
+
909
+ async cloneWorkspaceFile(serverId: string, fileId: string): Promise<Record<string, unknown>> {
910
+ return this.request(`/api/servers/${serverId}/workspace/files/${fileId}/clone`, {
911
+ method: 'POST',
912
+ })
913
+ }
914
+
915
+ async pasteWorkspaceNodes(
916
+ serverId: string,
917
+ data: {
918
+ sourceWorkspaceId: string
919
+ targetParentId?: string | null
920
+ nodeIds: string[]
921
+ mode: 'copy' | 'cut'
922
+ },
923
+ ): Promise<Record<string, unknown>> {
924
+ return this.request(`/api/servers/${serverId}/workspace/nodes/paste`, {
925
+ method: 'POST',
926
+ body: JSON.stringify(data),
927
+ })
928
+ }
929
+
930
+ async executeWorkspaceCommands(
931
+ serverId: string,
932
+ commands: Record<string, unknown>[],
933
+ ): Promise<Record<string, unknown>[]> {
934
+ return this.request(`/api/servers/${serverId}/workspace/commands`, {
935
+ method: 'POST',
936
+ body: JSON.stringify({ commands }),
937
+ })
938
+ }
939
+
940
+ async uploadWorkspaceFile(
941
+ serverId: string,
942
+ file: Blob,
943
+ filename: string,
944
+ parentId?: string,
945
+ ): Promise<Record<string, unknown>> {
946
+ const formData = new FormData()
947
+ formData.append('file', file, filename)
948
+ if (parentId) formData.append('parentId', parentId)
949
+
950
+ const res = await this.requestRaw(`/api/servers/${serverId}/workspace/upload`, {
951
+ method: 'POST',
952
+ body: formData,
953
+ })
954
+ return res.json() as Promise<Record<string, unknown>>
955
+ }
956
+
957
+ async downloadWorkspace(serverId: string): Promise<ArrayBuffer> {
958
+ const res = await this.requestRaw(`/api/servers/${serverId}/workspace/download`)
959
+ return res.arrayBuffer()
960
+ }
961
+
962
+ async downloadWorkspaceFolder(serverId: string, folderId: string): Promise<ArrayBuffer> {
963
+ const res = await this.requestRaw(
964
+ `/api/servers/${serverId}/workspace/folders/${folderId}/download`,
965
+ )
966
+ return res.arrayBuffer()
967
+ }
968
+ }