🚀 Socket Launch Week Day 5:Introducing Repository Access Permissions and Custom Roles.Learn more
Sign In

@shadowob/sdk

Package Overview
Dependencies
Maintainers
1
Versions
78
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@shadowob/sdk - npm Package Compare versions

Comparing version
0.3.4
to
0.4.0
+17
-5
package.json
{
"name": "@shadowob/sdk",
"version": "0.3.4",
"version": "0.4.0",
"description": "Shadow SDK — typed REST client and real-time Socket.IO event listener for Shadow servers",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": "./src/index.ts"
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"files": [
"dist"
],
"dependencies": {
"socket.io-client": "^4.8.1",
"@shadowob/shared": "0.3.4"
"@shadowob/shared": "0.4.0"
},
"devDependencies": {
"tsup": "^8.5.0",
"typescript": "^5.9.3"
},
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"test": "vitest run",

@@ -17,0 +29,0 @@ "test:watch": "vitest"

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ShadowClient } from '../src/client'
describe('ShadowClient', () => {
let client: ShadowClient
beforeEach(() => {
client = new ShadowClient('https://api.example.com', 'test-token-123')
})
describe('constructor', () => {
it('should strip trailing /api from baseUrl', () => {
const c = new ShadowClient('https://api.example.com/api', 'token')
// Access internal state via a request that would expose the URL
expect(c).toBeDefined()
})
it('should strip trailing /api/ from baseUrl', () => {
const c = new ShadowClient('https://api.example.com/api/', 'token')
expect(c).toBeDefined()
})
it('should leave baseUrl without /api suffix unchanged', () => {
const c = new ShadowClient('https://api.example.com', 'token')
expect(c).toBeDefined()
})
})
describe('request error handling', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn())
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('should throw on non-ok response with status and body', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 401,
text: () => Promise.resolve('Unauthorized'),
})
vi.stubGlobal('fetch', mockFetch)
await expect(client.getMe()).rejects.toThrow(
'Shadow API GET /api/auth/me failed (401): Unauthorized',
)
})
it('should include authorization header in requests', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: '1', username: 'test' }),
})
vi.stubGlobal('fetch', mockFetch)
await client.getMe()
expect(mockFetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer test-token-123',
}),
}),
)
})
it('should set Content-Type to application/json', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ token: 'new', user: {} }),
})
vi.stubGlobal('fetch', mockFetch)
await client.login({ email: 'a@b.com', password: '12345678' })
expect(mockFetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({
'Content-Type': 'application/json',
}),
}),
)
})
it('should handle fetch text() error gracefully', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
text: () => Promise.reject(new Error('parse error')),
})
vi.stubGlobal('fetch', mockFetch)
await expect(client.getMe()).rejects.toThrow('failed (500)')
})
})
describe('auth methods', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn())
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('should call register with correct path and body', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ token: 'jwt', user: { id: '1' } }),
})
vi.stubGlobal('fetch', mockFetch)
const data = {
email: 'test@example.com',
password: 'password123',
username: 'testuser',
inviteCode: 'ABCD1234',
}
await client.register(data)
expect(mockFetch).toHaveBeenCalledWith(
'https://api.example.com/api/auth/register',
expect.objectContaining({
method: 'POST',
body: JSON.stringify(data),
}),
)
})
it('should call login with correct path and body', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ token: 'jwt', user: { id: '1' } }),
})
vi.stubGlobal('fetch', mockFetch)
await client.login({ email: 'test@example.com', password: 'password123' })
expect(mockFetch).toHaveBeenCalledWith(
'https://api.example.com/api/auth/login',
expect.objectContaining({
method: 'POST',
}),
)
})
it('should call getMe with correct path', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: '1', username: 'test' }),
})
vi.stubGlobal('fetch', mockFetch)
await client.getMe()
expect(mockFetch).toHaveBeenCalledWith(
'https://api.example.com/api/auth/me',
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer test-token-123',
}),
}),
)
})
})
describe('server methods', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn())
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('should call listServers with GET', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve([]),
})
vi.stubGlobal('fetch', mockFetch)
await client.listServers()
expect(mockFetch).toHaveBeenCalledWith(
'https://api.example.com/api/servers',
expect.any(Object),
)
})
it('should call createServer with POST and body', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: 's1', name: 'Test' }),
})
vi.stubGlobal('fetch', mockFetch)
await client.createServer({ name: 'Test' })
expect(mockFetch).toHaveBeenCalledWith(
'https://api.example.com/api/servers',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ name: 'Test' }),
}),
)
})
})
describe('message methods', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn())
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('should call sendMessage with correct path', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: 'm1' }),
})
vi.stubGlobal('fetch', mockFetch)
await client.sendMessage('ch1', 'Hello')
expect(mockFetch).toHaveBeenCalledWith(
'https://api.example.com/api/channels/ch1/messages',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ content: 'Hello' }),
}),
)
})
it('should call getMessages with channel ID', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve([]),
})
vi.stubGlobal('fetch', mockFetch)
await client.getMessages('ch1')
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('/api/channels/ch1/messages'),
expect.any(Object),
)
})
})
})
import { describe, expect, it } from 'vitest'
import { CLIENT_EVENTS, channelRoom, SERVER_EVENTS, threadRoom, userRoom } from '../src/constants'
describe('room helpers', () => {
describe('channelRoom', () => {
it('should build channel room name', () => {
expect(channelRoom('abc123')).toBe('channel:abc123')
})
it('should handle empty string', () => {
expect(channelRoom('')).toBe('channel:')
})
})
describe('threadRoom', () => {
it('should build thread room name', () => {
expect(threadRoom('t456')).toBe('thread:t456')
})
})
describe('userRoom', () => {
it('should build user room name', () => {
expect(userRoom('u789')).toBe('user:u789')
})
})
})
describe('re-exported constants', () => {
it('should re-export CLIENT_EVENTS from shared', () => {
expect(CLIENT_EVENTS).toBeDefined()
expect(CLIENT_EVENTS.CHANNEL_JOIN).toBe('channel:join')
})
it('should re-export SERVER_EVENTS from shared', () => {
expect(SERVER_EVENTS).toBeDefined()
expect(SERVER_EVENTS.MESSAGE_NEW).toBe('message:new')
})
})
import type {
ShadowApp,
ShadowCartItem,
ShadowCategory,
ShadowChannel,
ShadowContract,
ShadowDmChannel,
ShadowFriendship,
ShadowInviteCode,
ShadowListing,
ShadowMember,
ShadowMessage,
ShadowNotification,
ShadowNotificationPreferences,
ShadowOAuthApp,
ShadowOAuthConsent,
ShadowOAuthToken,
ShadowOrder,
ShadowProduct,
ShadowRemoteConfig,
ShadowReview,
ShadowServer,
ShadowShop,
ShadowTask,
ShadowThread,
ShadowTransaction,
ShadowUser,
ShadowWallet,
} from './types'
/**
* Shadow REST API client.
*
* Provides typed HTTP methods for interacting with the Shadow server API.
*/
export class ShadowClient {
private baseUrl: string
constructor(
baseUrl: string,
private token: string,
) {
// Normalize: strip trailing /api or /api/ to prevent doubled paths
this.baseUrl = baseUrl.replace(/\/api\/?$/, '')
}
private async request<T>(path: string, init?: RequestInit): Promise<T> {
const url = `${this.baseUrl}${path}`
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 60_000)
try {
const res = await fetch(url, {
...init,
signal: init?.signal ?? controller.signal,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.token}`,
...init?.headers,
},
})
if (!res.ok) {
const body = await res.text().catch(() => '')
throw new Error(
`Shadow API ${init?.method ?? 'GET'} ${path} failed (${res.status}): ${body}`,
)
}
return res.json() as Promise<T>
} finally {
clearTimeout(timeout)
}
}
private async requestRaw(path: string, init?: RequestInit): Promise<Response> {
const url = `${this.baseUrl}${path}`
const res = await fetch(url, {
...init,
headers: {
Authorization: `Bearer ${this.token}`,
...init?.headers,
},
})
if (!res.ok) {
const body = await res.text().catch(() => '')
throw new Error(`Shadow API ${init?.method ?? 'GET'} ${path} failed (${res.status}): ${body}`)
}
return res
}
// ── Auth ──────────────────────────────────────────────────────────────
async register(data: {
email: string
password: string
username: string
displayName?: string
inviteCode: string
}): Promise<{ token: string; user: ShadowUser }> {
return this.request('/api/auth/register', {
method: 'POST',
body: JSON.stringify(data),
})
}
async login(data: {
email: string
password: string
}): Promise<{ token: string; user: ShadowUser }> {
return this.request('/api/auth/login', {
method: 'POST',
body: JSON.stringify(data),
})
}
async refreshToken(): Promise<{ token: string }> {
return this.request('/api/auth/refresh', { method: 'POST' })
}
async getMe(): Promise<ShadowUser> {
return this.request('/api/auth/me')
}
async updateProfile(data: {
displayName?: string
avatarUrl?: string | null
}): Promise<ShadowUser> {
return this.request('/api/auth/me', {
method: 'PATCH',
body: JSON.stringify(data),
})
}
async disconnect(): Promise<{
success: boolean
}> {
return this.request('/api/auth/disconnect', { method: 'POST' })
}
// ── Agents ────────────────────────────────────────────────────────────
async listAgents(): Promise<{ id: string; name: string; status: string }[]> {
return this.request('/api/agents')
}
async createAgent(data: {
name: string
displayName?: string
avatarUrl?: string | null
}): Promise<{ id: string; token: string; userId: string }> {
return this.request('/api/agents', {
method: 'POST',
body: JSON.stringify(data),
})
}
async getAgent(
agentId: string,
): Promise<{ id: string; name: string; status: string; userId: string }> {
return this.request(`/api/agents/${agentId}`)
}
async updateAgent(
agentId: string,
data: { name?: string; displayName?: string; avatarUrl?: string | null },
): Promise<{ id: string; name: string }> {
return this.request(`/api/agents/${agentId}`, {
method: 'PATCH',
body: JSON.stringify(data),
})
}
async deleteAgent(agentId: string): Promise<{ success: boolean }> {
return this.request(`/api/agents/${agentId}`, { method: 'DELETE' })
}
async generateAgentToken(agentId: string): Promise<{ token: string }> {
return this.request(`/api/agents/${agentId}/token`, { method: 'POST' })
}
async startAgent(agentId: string): Promise<{ ok: boolean }> {
return this.request(`/api/agents/${agentId}/start`, { method: 'POST' })
}
async stopAgent(agentId: string): Promise<{ ok: boolean }> {
return this.request(`/api/agents/${agentId}/stop`, { method: 'POST' })
}
async sendHeartbeat(agentId: string): Promise<{ ok: boolean }> {
return this.request(`/api/agents/${agentId}/heartbeat`, {
method: 'POST',
body: JSON.stringify({}),
})
}
async getAgentConfig(agentId: string): Promise<ShadowRemoteConfig> {
return this.request<ShadowRemoteConfig>(`/api/agents/${agentId}/config`)
}
// ── Agent Policies ────────────────────────────────────────────────────
async listPolicies(
agentId: string,
serverId: string,
): Promise<
{
channelId: string | null
mentionOnly: boolean
reply: boolean
config: Record<string, unknown>
}[]
> {
return this.request(`/api/agents/${agentId}/servers/${serverId}/policies`)
}
async upsertPolicy(
agentId: string,
serverId: string,
data: {
channelId?: string | null
mentionOnly?: boolean
reply?: boolean
config?: Record<string, unknown>
},
): Promise<{ channelId: string | null; mentionOnly: boolean; reply: boolean }> {
return this.request(`/api/agents/${agentId}/servers/${serverId}/policies`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
async deletePolicy(
agentId: string,
serverId: string,
channelId: string,
): Promise<{ success: boolean }> {
return this.request(`/api/agents/${agentId}/servers/${serverId}/policies/${channelId}`, {
method: 'DELETE',
})
}
// ── Servers ───────────────────────────────────────────────────────────
async discoverServers(): Promise<ShadowServer[]> {
return this.request('/api/servers/discover')
}
async getServerByInvite(inviteCode: string): Promise<ShadowServer> {
return this.request(`/api/servers/invite/${encodeURIComponent(inviteCode)}`)
}
async createServer(data: {
name: string
slug?: string
description?: string
isPublic?: boolean
}): Promise<ShadowServer> {
return this.request('/api/servers', {
method: 'POST',
body: JSON.stringify(data),
})
}
async listServers(): Promise<ShadowServer[]> {
return this.request('/api/servers')
}
async getServer(serverIdOrSlug: string): Promise<ShadowServer> {
return this.request(`/api/servers/${serverIdOrSlug}`)
}
async updateServer(
serverIdOrSlug: string,
data: {
name?: string
description?: string | null
slug?: string | null
homepageHtml?: string | null
isPublic?: boolean
},
): Promise<ShadowServer> {
return this.request(`/api/servers/${serverIdOrSlug}`, {
method: 'PATCH',
body: JSON.stringify(data),
})
}
async updateServerHomepage(
serverIdOrSlug: string,
homepageHtml: string | null,
): Promise<ShadowServer> {
return this.updateServer(serverIdOrSlug, { homepageHtml })
}
async deleteServer(serverId: string): Promise<{ success: boolean }> {
return this.request(`/api/servers/${serverId}`, { method: 'DELETE' })
}
async joinServer(serverId: string, inviteCode?: string): Promise<{ success: boolean }> {
return this.request(`/api/servers/${serverId}/join`, {
method: 'POST',
body: JSON.stringify(inviteCode ? { inviteCode } : {}),
})
}
async leaveServer(serverId: string): Promise<{ success: boolean }> {
return this.request(`/api/servers/${serverId}/leave`, { method: 'POST' })
}
async getMembers(serverId: string): Promise<ShadowMember[]> {
return this.request(`/api/servers/${serverId}/members`)
}
async updateMember(
serverId: string,
userId: string,
data: { role?: string },
): Promise<ShadowMember> {
return this.request(`/api/servers/${serverId}/members/${userId}`, {
method: 'PATCH',
body: JSON.stringify(data),
})
}
async kickMember(serverId: string, userId: string): Promise<{ success: boolean }> {
return this.request(`/api/servers/${serverId}/members/${userId}`, { method: 'DELETE' })
}
async regenerateInviteCode(serverId: string): Promise<{ inviteCode: string }> {
return this.request(`/api/servers/${serverId}/invite`, { method: 'POST' })
}
async addAgentsToServer(serverId: string, agentIds: string[]): Promise<{ added: string[] }> {
return this.request(`/api/servers/${serverId}/agents`, {
method: 'POST',
body: JSON.stringify({ agentIds }),
})
}
// ── Channels ──────────────────────────────────────────────────────────
async getServerChannels(serverId: string): Promise<ShadowChannel[]> {
return this.request<ShadowChannel[]>(`/api/servers/${serverId}/channels`)
}
async createChannel(
serverId: string,
data: { name: string; type?: string; description?: string },
): Promise<ShadowChannel> {
const { description, ...rest } = data
const body = { ...rest, ...(description !== undefined ? { topic: description } : {}) }
const ch = await this.request<Record<string, unknown>>(`/api/servers/${serverId}/channels`, {
method: 'POST',
body: JSON.stringify(body),
})
return { ...ch, description: ch.topic } as unknown as ShadowChannel
}
async getChannel(channelId: string): Promise<ShadowChannel> {
const ch = await this.request<Record<string, unknown>>(`/api/channels/${channelId}`)
return { ...ch, description: ch.topic } as unknown as ShadowChannel
}
async getChannelMembers(channelId: string): Promise<ShadowMember[]> {
return this.request(`/api/channels/${channelId}/members`)
}
async updateChannel(
channelId: string,
data: { name?: string; description?: string | null },
): Promise<ShadowChannel> {
const { description, ...rest } = data
const body = { ...rest, ...(description !== undefined ? { topic: description } : {}) }
const ch = await this.request<Record<string, unknown>>(`/api/channels/${channelId}`, {
method: 'PATCH',
body: JSON.stringify(body),
})
return { ...ch, description: ch.topic } as unknown as ShadowChannel
}
async deleteChannel(channelId: string): Promise<{ success: boolean }> {
return this.request(`/api/channels/${channelId}`, { method: 'DELETE' })
}
async reorderChannels(serverId: string, channelIds: string[]): Promise<{ success: boolean }> {
return this.request(`/api/servers/${serverId}/channels/reorder`, {
method: 'PUT',
body: JSON.stringify({ channelIds }),
})
}
async addChannelMember(channelId: string, userId: string): Promise<{ success: boolean }> {
return this.request(`/api/channels/${channelId}/members`, {
method: 'POST',
body: JSON.stringify({ userId }),
})
}
async removeChannelMember(channelId: string, userId: string): Promise<{ success: boolean }> {
return this.request(`/api/channels/${channelId}/members/${userId}`, { method: 'DELETE' })
}
// ── Channel Buddy Policy ─────────────────────────────────────────────
async setBuddyPolicy(
channelId: string,
data: { buddyUserId: string; mentionOnly?: boolean; reply?: boolean },
): Promise<{ success: boolean }> {
return this.request(`/api/channels/${channelId}/buddy-policy`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
async getBuddyPolicy(
channelId: string,
): Promise<{ buddyUserId: string | null; mentionOnly: boolean; reply: boolean } | null> {
return this.request(`/api/channels/${channelId}/buddy-policy`)
}
// ── Messages ──────────────────────────────────────────────────────────
async sendMessage(
channelId: string,
content: string,
opts?: { threadId?: string; replyToId?: string },
): Promise<ShadowMessage> {
return this.request<ShadowMessage>(`/api/channels/${channelId}/messages`, {
method: 'POST',
body: JSON.stringify({
content,
...(opts?.threadId ? { threadId: opts.threadId } : {}),
...(opts?.replyToId ? { replyToId: opts.replyToId } : {}),
}),
})
}
async getMessages(
channelId: string,
limit = 50,
cursor?: string,
): Promise<{ messages: ShadowMessage[]; hasMore: boolean }> {
const params = new URLSearchParams({ limit: String(limit) })
if (cursor) params.set('cursor', cursor)
return this.request<{ messages: ShadowMessage[]; hasMore: boolean }>(
`/api/channels/${channelId}/messages?${params}`,
)
}
async getMessage(messageId: string): Promise<ShadowMessage> {
return this.request(`/api/messages/${messageId}`)
}
async editMessage(messageId: string, content: string): Promise<ShadowMessage> {
return this.request<ShadowMessage>(`/api/messages/${messageId}`, {
method: 'PATCH',
body: JSON.stringify({ content }),
})
}
async deleteMessage(messageId: string): Promise<void> {
await this.request<{ success: boolean }>(`/api/messages/${messageId}`, {
method: 'DELETE',
})
}
// ── Pins ──────────────────────────────────────────────────────────────
async pinMessage(messageId: string, channelId?: string): Promise<{ success: boolean }> {
if (channelId) {
return this.request(`/api/channels/${channelId}/pins/${messageId}`, { method: 'PUT' })
}
return this.request(`/api/messages/${messageId}/pin`, { method: 'POST' })
}
async unpinMessage(messageId: string, channelId?: string): Promise<{ success: boolean }> {
if (channelId) {
return this.request(`/api/channels/${channelId}/pins/${messageId}`, { method: 'DELETE' })
}
return this.request(`/api/messages/${messageId}/pin`, { method: 'DELETE' })
}
async getPinnedMessages(channelId: string): Promise<ShadowMessage[]> {
return this.request(`/api/channels/${channelId}/pins`)
}
// ── Reactions ─────────────────────────────────────────────────────────
async addReaction(messageId: string, emoji: string): Promise<void> {
await this.request(`/api/messages/${messageId}/reactions`, {
method: 'POST',
body: JSON.stringify({ emoji }),
})
}
async removeReaction(messageId: string, emoji: string): Promise<void> {
await this.request(`/api/messages/${messageId}/reactions/${encodeURIComponent(emoji)}`, {
method: 'DELETE',
})
}
async getReactions(
messageId: string,
): Promise<{ emoji: string; count: number; users: string[] }[]> {
return this.request(`/api/messages/${messageId}/reactions`)
}
// ── Threads ───────────────────────────────────────────────────────────
async listThreads(channelId: string): Promise<ShadowThread[]> {
return this.request(`/api/channels/${channelId}/threads`)
}
async createThread(
channelId: string,
name: string,
parentMessageId: string,
): Promise<{ id: string; name: string }> {
return this.request(`/api/channels/${channelId}/threads`, {
method: 'POST',
body: JSON.stringify({ name, parentMessageId }),
})
}
async getThread(threadId: string): Promise<ShadowThread> {
return this.request(`/api/threads/${threadId}`)
}
async updateThread(threadId: string, data: { name?: string }): Promise<ShadowThread> {
return this.request(`/api/threads/${threadId}`, {
method: 'PATCH',
body: JSON.stringify(data),
})
}
async deleteThread(threadId: string): Promise<{ success: boolean }> {
return this.request(`/api/threads/${threadId}`, { method: 'DELETE' })
}
async getThreadMessages(threadId: string, limit = 50, cursor?: string): Promise<ShadowMessage[]> {
const params = new URLSearchParams({ limit: String(limit) })
if (cursor) params.set('cursor', cursor)
return this.request<ShadowMessage[]>(`/api/threads/${threadId}/messages?${params}`)
}
async sendToThread(threadId: string, content: string): Promise<ShadowMessage> {
return this.request<ShadowMessage>(`/api/threads/${threadId}/messages`, {
method: 'POST',
body: JSON.stringify({ content }),
})
}
// ── DMs ───────────────────────────────────────────────────────────────
async createDmChannel(userId: string): Promise<ShadowDmChannel> {
return this.request('/api/dm/channels', {
method: 'POST',
body: JSON.stringify({ userId }),
})
}
async listDmChannels(): Promise<ShadowDmChannel[]> {
return this.request('/api/dm/channels')
}
async getDmMessages(channelId: string, limit = 50, cursor?: string): Promise<ShadowMessage[]> {
const params = new URLSearchParams({ limit: String(limit) })
if (cursor) params.set('cursor', cursor)
return this.request(`/api/dm/channels/${channelId}/messages?${params}`)
}
async sendDmMessage(
channelId: string,
content: string,
options?: { replyToId?: string },
): Promise<ShadowMessage> {
return this.request(`/api/dm/channels/${channelId}/messages`, {
method: 'POST',
body: JSON.stringify({ content, replyToId: options?.replyToId }),
})
}
// ── Notifications ─────────────────────────────────────────────────────
async listNotifications(limit = 50, offset = 0): Promise<ShadowNotification[]> {
const params = new URLSearchParams({ limit: String(limit), offset: String(offset) })
return this.request(`/api/notifications?${params}`)
}
async markNotificationRead(notificationId: string): Promise<{ success: boolean }> {
return this.request(`/api/notifications/${notificationId}/read`, { method: 'PATCH' })
}
async markAllNotificationsRead(): Promise<{ success: boolean }> {
return this.request('/api/notifications/read-all', { method: 'POST' })
}
async getUnreadCount(): Promise<{ count: number }> {
return this.request('/api/notifications/unread-count')
}
// ── Search ────────────────────────────────────────────────────────────
async searchMessages(query: {
q: string
serverId?: string
channelId?: string
authorId?: string
limit?: number
offset?: number
}): Promise<{ messages: ShadowMessage[]; total: number }> {
const params = new URLSearchParams({ query: query.q })
if (query.serverId) params.set('serverId', query.serverId)
if (query.channelId) params.set('channelId', query.channelId)
if (query.authorId) params.set('from', query.authorId)
if (query.limit) params.set('limit', String(query.limit))
if (query.offset) params.set('offset', String(query.offset))
const result = await this.request<
ShadowMessage[] | { messages: ShadowMessage[]; total: number }
>(`/api/search/messages?${params}`)
if (Array.isArray(result)) {
return { messages: result, total: result.length }
}
return result
}
// ── Invites ───────────────────────────────────────────────────────────
async listInvites(): Promise<ShadowInviteCode[]> {
return this.request('/api/invite-codes')
}
async createInvites(count: number, note?: string): Promise<ShadowInviteCode[]> {
return this.request('/api/invite-codes', {
method: 'POST',
body: JSON.stringify({ count, ...(note ? { note } : {}) }),
})
}
async deactivateInvite(inviteId: string): Promise<ShadowInviteCode> {
return this.request(`/api/invite-codes/${inviteId}/deactivate`, { method: 'PATCH' })
}
async deleteInvite(inviteId: string): Promise<{ success: boolean }> {
return this.request(`/api/invite-codes/${inviteId}`, { method: 'DELETE' })
}
// ── Media ─────────────────────────────────────────────────────────────
async uploadMedia(
file: Blob | ArrayBuffer,
filename: string,
contentType: string,
messageId?: string,
): Promise<{ url: string; key: string; size: number }> {
const formData = new FormData()
const blob = file instanceof Blob ? file : new Blob([file], { type: contentType })
formData.append('file', blob, filename)
if (messageId) {
formData.append('messageId', messageId)
}
const url = `${this.baseUrl}/api/media/upload`
const res = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.token}`,
},
body: formData,
})
if (!res.ok) {
const body = await res.text().catch(() => '')
throw new Error(`Shadow API POST /api/media/upload failed (${res.status}): ${body}`)
}
return res.json() as Promise<{ url: string; key: string; size: number }>
}
/**
* Download a file from a URL and upload it to the Shadow media service.
* Supports local filesystem paths, file:// URLs, tilde paths, and HTTP(S) URLs.
*/
async uploadMediaFromUrl(
mediaUrl: string,
messageId?: string,
): Promise<{ url: string; key: string; size: number }> {
// Dynamic imports for Node.js fs/path/os
// @ts-expect-error node:fs/promises is available at runtime
const { readFile } = await import('node:fs/promises')
// @ts-expect-error node:path is available at runtime
const { basename } = await import('node:path')
// @ts-expect-error node:os is available at runtime
const { homedir } = await import('node:os')
// Strip MEDIA: prefix used by agent tools to tag media paths
let normalizedUrl = mediaUrl.replace(/^\s*MEDIA\s*:\s*/i, '')
// Handle file:// URLs
if (normalizedUrl.startsWith('file://')) {
normalizedUrl = normalizedUrl.replace(/^file:\/\//, '')
}
// Expand tilde paths
if (normalizedUrl.startsWith('~')) {
normalizedUrl = normalizedUrl.replace(/^~/, homedir())
}
// Resolve relative paths
if (
!normalizedUrl.startsWith('/') &&
!normalizedUrl.startsWith('http://') &&
!normalizedUrl.startsWith('https://') &&
!normalizedUrl.startsWith('//')
) {
// @ts-expect-error node:fs is available at runtime
const { existsSync } = await import('node:fs')
// @ts-expect-error node:path is available at runtime
const { resolve } = await import('node:path')
const cwd = (globalThis as Record<string, unknown>).process
? ((globalThis as Record<string, unknown>).process as { cwd: () => string }).cwd()
: '/'
const roots = [resolve(homedir(), '.openclaw', 'workspace'), cwd]
let resolved = false
for (const root of roots) {
const candidate = resolve(root, normalizedUrl)
if (existsSync(candidate)) {
normalizedUrl = candidate
resolved = true
break
}
}
if (!resolved) {
normalizedUrl = resolve(cwd, normalizedUrl)
}
}
if (normalizedUrl.startsWith('/') && !normalizedUrl.startsWith('//')) {
// Local filesystem path
const fileBuffer = await readFile(normalizedUrl)
const bytes = new Uint8Array(fileBuffer)
const filename: string = basename(normalizedUrl)
const ext = filename.split('.').pop()?.toLowerCase() ?? ''
const mimeMap: Record<string, string> = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
gif: 'image/gif',
webp: 'image/webp',
svg: 'image/svg+xml',
mp4: 'video/mp4',
webm: 'video/webm',
mp3: 'audio/mpeg',
wav: 'audio/wav',
ogg: 'audio/ogg',
pdf: 'application/pdf',
txt: 'text/plain',
csv: 'text/csv',
json: 'application/json',
html: 'text/html',
xml: 'application/xml',
zip: 'application/zip',
}
const contentType = mimeMap[ext] ?? 'application/octet-stream'
return this.uploadMedia(
new Blob([bytes], { type: contentType }),
filename,
contentType,
messageId,
)
}
// HTTP/HTTPS URL
const res = await fetch(normalizedUrl)
if (!res.ok) {
throw new Error(`Failed to download media from ${normalizedUrl}: ${res.status}`)
}
const blob = await res.blob()
const urlPath = new URL(normalizedUrl).pathname
const filename = urlPath.split('/').pop() ?? 'file'
const contentType = blob.type || 'application/octet-stream'
return this.uploadMedia(blob, filename, contentType, messageId)
}
async downloadFile(
fileUrl: string,
): Promise<{ buffer: ArrayBuffer; contentType: string; filename: string }> {
const headers: Record<string, string> = {}
if (fileUrl.startsWith(this.baseUrl) || fileUrl.startsWith('/')) {
headers.Authorization = `Bearer ${this.token}`
}
const fullUrl = fileUrl.startsWith('/') ? `${this.baseUrl}${fileUrl}` : fileUrl
const res = await fetch(fullUrl, { headers, redirect: 'follow' })
if (!res.ok) {
throw new Error(`Failed to download file from ${fullUrl}: ${res.status}`)
}
const buffer = await res.arrayBuffer()
const contentType = res.headers.get('content-type') ?? 'application/octet-stream'
const urlPath = new URL(fullUrl).pathname
const filename = decodeURIComponent(urlPath.split('/').pop() ?? 'file')
return { buffer, contentType, filename }
}
// ── Workspace ─────────────────────────────────────────────────────────
async getWorkspace(serverId: string): Promise<Record<string, unknown>> {
return this.request(`/api/servers/${serverId}/workspace`)
}
async updateWorkspace(
serverId: string,
data: { name?: string; description?: string | null },
): Promise<Record<string, unknown>> {
return this.request(`/api/servers/${serverId}/workspace`, {
method: 'PATCH',
body: JSON.stringify(data),
})
}
async getWorkspaceTree(serverId: string): Promise<Record<string, unknown>> {
return this.request(`/api/servers/${serverId}/workspace/tree`)
}
async getWorkspaceStats(serverId: string): Promise<Record<string, unknown>> {
return this.request(`/api/servers/${serverId}/workspace/stats`)
}
async getWorkspaceChildren(
serverId: string,
parentId?: string | null,
): Promise<Record<string, unknown>[]> {
const params = new URLSearchParams()
if (parentId !== undefined && parentId !== null) params.set('parentId', parentId)
const qs = params.toString()
return this.request(`/api/servers/${serverId}/workspace/children${qs ? `?${qs}` : ''}`)
}
async batchWorkspaceChildren(
serverId: string,
parentIds: (string | null)[],
): Promise<Record<string, Record<string, unknown>[]>> {
return this.request(`/api/servers/${serverId}/workspace/children/batch`, {
method: 'POST',
body: JSON.stringify({ parentIds }),
})
}
async createWorkspaceFolder(
serverId: string,
data: { parentId?: string | null; name: string },
): Promise<Record<string, unknown>> {
return this.request(`/api/servers/${serverId}/workspace/folders`, {
method: 'POST',
body: JSON.stringify(data),
})
}
async updateWorkspaceFolder(
serverId: string,
folderId: string,
data: { name?: string; parentId?: string | null; pos?: number },
): Promise<Record<string, unknown>> {
return this.request(`/api/servers/${serverId}/workspace/folders/${folderId}`, {
method: 'PATCH',
body: JSON.stringify(data),
})
}
async deleteWorkspaceFolder(serverId: string, folderId: string): Promise<{ success: boolean }> {
return this.request(`/api/servers/${serverId}/workspace/folders/${folderId}`, {
method: 'DELETE',
})
}
async searchWorkspaceFolders(
serverId: string,
query: { searchText?: string; limit?: number },
): Promise<Record<string, unknown>[]> {
const params = new URLSearchParams()
if (query.searchText) params.set('searchText', query.searchText)
if (query.limit) params.set('limit', String(query.limit))
return this.request(`/api/servers/${serverId}/workspace/folders/search?${params}`)
}
async createWorkspaceFile(
serverId: string,
data: {
parentId?: string | null
name: string
ext?: string | null
mime?: string | null
sizeBytes?: number | null
contentRef?: string | null
previewUrl?: string | null
metadata?: Record<string, unknown> | null
},
): Promise<Record<string, unknown>> {
return this.request(`/api/servers/${serverId}/workspace/files`, {
method: 'POST',
body: JSON.stringify(data),
})
}
async searchWorkspaceFiles(
serverId: string,
query: {
parentId?: string
searchText?: string
ext?: string
limit?: number
offset?: number
},
): Promise<Record<string, unknown>[]> {
const params = new URLSearchParams()
if (query.parentId) params.set('parentId', query.parentId)
if (query.searchText) params.set('searchText', query.searchText)
if (query.ext) params.set('ext', query.ext)
if (query.limit) params.set('limit', String(query.limit))
if (query.offset) params.set('offset', String(query.offset))
return this.request(`/api/servers/${serverId}/workspace/files/search?${params}`)
}
async getWorkspaceFile(serverId: string, fileId: string): Promise<Record<string, unknown>> {
return this.request(`/api/servers/${serverId}/workspace/files/${fileId}`)
}
async updateWorkspaceFile(
serverId: string,
fileId: string,
data: {
name?: string
parentId?: string | null
pos?: number
ext?: string | null
mime?: string | null
sizeBytes?: number | null
contentRef?: string | null
previewUrl?: string | null
metadata?: Record<string, unknown> | null
},
): Promise<Record<string, unknown>> {
return this.request(`/api/servers/${serverId}/workspace/files/${fileId}`, {
method: 'PATCH',
body: JSON.stringify(data),
})
}
async deleteWorkspaceFile(serverId: string, fileId: string): Promise<{ success: boolean }> {
return this.request(`/api/servers/${serverId}/workspace/files/${fileId}`, { method: 'DELETE' })
}
async cloneWorkspaceFile(serverId: string, fileId: string): Promise<Record<string, unknown>> {
return this.request(`/api/servers/${serverId}/workspace/files/${fileId}/clone`, {
method: 'POST',
})
}
async pasteWorkspaceNodes(
serverId: string,
data: {
sourceWorkspaceId: string
targetParentId?: string | null
nodeIds: string[]
mode: 'copy' | 'cut'
},
): Promise<Record<string, unknown>> {
return this.request(`/api/servers/${serverId}/workspace/nodes/paste`, {
method: 'POST',
body: JSON.stringify(data),
})
}
async executeWorkspaceCommands(
serverId: string,
commands: Record<string, unknown>[],
): Promise<Record<string, unknown>[]> {
return this.request(`/api/servers/${serverId}/workspace/commands`, {
method: 'POST',
body: JSON.stringify({ commands }),
})
}
async uploadWorkspaceFile(
serverId: string,
file: Blob,
filename: string,
parentId?: string,
): Promise<Record<string, unknown>> {
const formData = new FormData()
formData.append('file', file, filename)
if (parentId) formData.append('parentId', parentId)
const res = await this.requestRaw(`/api/servers/${serverId}/workspace/upload`, {
method: 'POST',
body: formData,
})
return res.json() as Promise<Record<string, unknown>>
}
async downloadWorkspace(serverId: string): Promise<ArrayBuffer> {
const res = await this.requestRaw(`/api/servers/${serverId}/workspace/download`)
return res.arrayBuffer()
}
async downloadWorkspaceFolder(serverId: string, folderId: string): Promise<ArrayBuffer> {
const res = await this.requestRaw(
`/api/servers/${serverId}/workspace/folders/${folderId}/download`,
)
return res.arrayBuffer()
}
// ── Auth (extended) ───────────────────────────────────────────────────
async getUserProfile(userId: string): Promise<ShadowUser> {
return this.request(`/api/auth/users/${userId}`)
}
async listOAuthAccounts(): Promise<
{ id: string; provider: string; providerAccountId: string }[]
> {
return this.request('/api/auth/oauth/accounts')
}
async unlinkOAuthAccount(accountId: string): Promise<{ success: boolean }> {
return this.request(`/api/auth/oauth/accounts/${accountId}`, { method: 'DELETE' })
}
// ── Friendships ───────────────────────────────────────────────────────
async sendFriendRequest(username: string): Promise<ShadowFriendship> {
return this.request('/api/friends/request', {
method: 'POST',
body: JSON.stringify({ username }),
})
}
async acceptFriendRequest(requestId: string): Promise<ShadowFriendship> {
return this.request(`/api/friends/${requestId}/accept`, { method: 'POST' })
}
async rejectFriendRequest(requestId: string): Promise<ShadowFriendship> {
return this.request(`/api/friends/${requestId}/reject`, { method: 'POST' })
}
async removeFriend(friendshipId: string): Promise<{ success: boolean }> {
return this.request(`/api/friends/${friendshipId}`, { method: 'DELETE' })
}
async listFriends(): Promise<ShadowFriendship[]> {
return this.request('/api/friends')
}
async listPendingFriendRequests(): Promise<ShadowFriendship[]> {
return this.request('/api/friends/pending')
}
async listSentFriendRequests(): Promise<ShadowFriendship[]> {
return this.request('/api/friends/sent')
}
// ── Notifications (extended) ──────────────────────────────────────────
async markScopeRead(scope: {
serverId?: string
channelId?: string
}): Promise<{ success: boolean }> {
return this.request('/api/notifications/read-scope', {
method: 'POST',
body: JSON.stringify(scope),
})
}
async getScopedUnread(): Promise<Record<string, number>> {
return this.request('/api/notifications/scoped-unread')
}
async getNotificationPreferences(): Promise<ShadowNotificationPreferences> {
return this.request('/api/notifications/preferences')
}
async updateNotificationPreferences(
data: Partial<ShadowNotificationPreferences>,
): Promise<ShadowNotificationPreferences> {
return this.request('/api/notifications/preferences', {
method: 'PATCH',
body: JSON.stringify(data),
})
}
// ── OAuth Apps ────────────────────────────────────────────────────────
async createOAuthApp(data: {
name: string
redirectUris: string[]
scopes?: string[]
}): Promise<ShadowOAuthApp> {
return this.request('/api/oauth/apps', {
method: 'POST',
body: JSON.stringify(data),
})
}
async listOAuthApps(): Promise<ShadowOAuthApp[]> {
return this.request('/api/oauth/apps')
}
async updateOAuthApp(
appId: string,
data: { name?: string; redirectUris?: string[]; scopes?: string[] },
): Promise<ShadowOAuthApp> {
return this.request(`/api/oauth/apps/${appId}`, {
method: 'PATCH',
body: JSON.stringify(data),
})
}
async deleteOAuthApp(appId: string): Promise<{ success: boolean }> {
return this.request(`/api/oauth/apps/${appId}`, { method: 'DELETE' })
}
async resetOAuthAppSecret(appId: string): Promise<{ clientSecret: string }> {
return this.request(`/api/oauth/apps/${appId}/reset-secret`, { method: 'POST' })
}
async getOAuthAuthorization(params: {
client_id: string
redirect_uri: string
scope?: string
state?: string
}): Promise<{ app: ShadowOAuthApp }> {
const qs = new URLSearchParams(params)
return this.request(`/api/oauth/authorize?${qs}`)
}
async approveOAuthAuthorization(data: {
client_id: string
redirect_uri: string
scope?: string
state?: string
}): Promise<{ redirectUrl: string }> {
return this.request('/api/oauth/authorize', {
method: 'POST',
body: JSON.stringify(data),
})
}
async exchangeOAuthToken(data: {
grant_type: 'authorization_code' | 'refresh_token'
code?: string
refresh_token?: string
client_id: string
client_secret: string
redirect_uri?: string
}): Promise<ShadowOAuthToken> {
return this.request('/api/oauth/token', {
method: 'POST',
body: JSON.stringify(data),
})
}
async listOAuthConsents(): Promise<ShadowOAuthConsent[]> {
return this.request('/api/oauth/consents')
}
async revokeOAuthConsent(appId: string): Promise<{ success: boolean }> {
return this.request('/api/oauth/revoke', {
method: 'POST',
body: JSON.stringify({ appId }),
})
}
// ── Marketplace / Rentals ─────────────────────────────────────────────
async browseListings(params?: {
search?: string
tags?: string[]
minPrice?: number
maxPrice?: number
limit?: number
offset?: number
}): Promise<{ listings: ShadowListing[]; total: number }> {
const qs = new URLSearchParams()
if (params?.search) qs.set('search', params.search)
if (params?.tags) for (const t of params.tags) qs.append('tags', t)
if (params?.minPrice != null) qs.set('minPrice', String(params.minPrice))
if (params?.maxPrice != null) qs.set('maxPrice', String(params.maxPrice))
if (params?.limit) qs.set('limit', String(params.limit))
if (params?.offset) qs.set('offset', String(params.offset))
return this.request(`/api/marketplace/listings?${qs}`)
}
async getListing(listingId: string): Promise<ShadowListing> {
return this.request(`/api/marketplace/listings/${listingId}`)
}
async estimateRentalCost(
listingId: string,
hours: number,
): Promise<{ totalCost: number; currency: string }> {
const qs = new URLSearchParams({ hours: String(hours) })
return this.request(`/api/marketplace/listings/${listingId}/estimate?${qs}`)
}
async listMyListings(): Promise<ShadowListing[]> {
return this.request('/api/marketplace/my-listings')
}
async createListing(data: {
agentId: string
title: string
description: string
pricePerHour: number
currency?: string
tags?: string[]
}): Promise<ShadowListing> {
return this.request('/api/marketplace/listings', {
method: 'POST',
body: JSON.stringify(data),
})
}
async updateListing(
listingId: string,
data: Partial<{ title: string; description: string; pricePerHour: number; tags: string[] }>,
): Promise<ShadowListing> {
return this.request(`/api/marketplace/listings/${listingId}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
async toggleListing(listingId: string): Promise<ShadowListing> {
return this.request(`/api/marketplace/listings/${listingId}/toggle`, { method: 'PUT' })
}
async deleteListing(listingId: string): Promise<{ success: boolean }> {
return this.request(`/api/marketplace/listings/${listingId}`, { method: 'DELETE' })
}
async signContract(data: { listingId: string; hours: number }): Promise<ShadowContract> {
return this.request('/api/marketplace/contracts', {
method: 'POST',
body: JSON.stringify(data),
})
}
async listContracts(params?: {
role?: 'tenant' | 'owner'
status?: string
}): Promise<ShadowContract[]> {
const qs = new URLSearchParams()
if (params?.role) qs.set('role', params.role)
if (params?.status) qs.set('status', params.status)
return this.request(`/api/marketplace/contracts?${qs}`)
}
async getContract(contractId: string): Promise<ShadowContract> {
return this.request(`/api/marketplace/contracts/${contractId}`)
}
async terminateContract(contractId: string): Promise<ShadowContract> {
return this.request(`/api/marketplace/contracts/${contractId}/terminate`, { method: 'POST' })
}
async recordUsageSession(
contractId: string,
data: { durationMinutes: number; description?: string },
): Promise<{ success: boolean }> {
return this.request(`/api/marketplace/contracts/${contractId}/usage`, {
method: 'POST',
body: JSON.stringify(data),
})
}
async reportViolation(
contractId: string,
data: { reason: string },
): Promise<{ success: boolean }> {
return this.request(`/api/marketplace/contracts/${contractId}/violate`, {
method: 'POST',
body: JSON.stringify(data),
})
}
// ── Shop ──────────────────────────────────────────────────────────────
async getShop(serverId: string): Promise<ShadowShop> {
return this.request(`/api/servers/${serverId}/shop`)
}
async updateShop(
serverId: string,
data: Partial<{ name: string; description: string | null; isEnabled: boolean }>,
): Promise<ShadowShop> {
return this.request(`/api/servers/${serverId}/shop`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
async listCategories(serverId: string): Promise<ShadowCategory[]> {
return this.request(`/api/servers/${serverId}/shop/categories`)
}
async createCategory(
serverId: string,
data: { name: string; description?: string },
): Promise<ShadowCategory> {
return this.request(`/api/servers/${serverId}/shop/categories`, {
method: 'POST',
body: JSON.stringify(data),
})
}
async updateCategory(
serverId: string,
categoryId: string,
data: Partial<{ name: string; description: string | null; position: number }>,
): Promise<ShadowCategory> {
return this.request(`/api/servers/${serverId}/shop/categories/${categoryId}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
async deleteCategory(serverId: string, categoryId: string): Promise<{ success: boolean }> {
return this.request(`/api/servers/${serverId}/shop/categories/${categoryId}`, {
method: 'DELETE',
})
}
async listProducts(
serverId: string,
params?: {
status?: string
categoryId?: string
keyword?: string
limit?: number
offset?: number
},
): Promise<{ products: ShadowProduct[]; total: number }> {
const qs = new URLSearchParams()
if (params?.status) qs.set('status', params.status)
if (params?.categoryId) qs.set('categoryId', params.categoryId)
if (params?.keyword) qs.set('keyword', params.keyword)
if (params?.limit) qs.set('limit', String(params.limit))
if (params?.offset) qs.set('offset', String(params.offset))
return this.request(`/api/servers/${serverId}/shop/products?${qs}`)
}
async getProduct(serverId: string, productId: string): Promise<ShadowProduct> {
return this.request(`/api/servers/${serverId}/shop/products/${productId}`)
}
async createProduct(
serverId: string,
data: {
name: string
description?: string
price: number
currency?: string
stock: number
categoryId?: string
images?: string[]
},
): Promise<ShadowProduct> {
return this.request(`/api/servers/${serverId}/shop/products`, {
method: 'POST',
body: JSON.stringify(data),
})
}
async updateProduct(
serverId: string,
productId: string,
data: Partial<{
name: string
description: string | null
price: number
stock: number
status: string
categoryId: string | null
images: string[]
}>,
): Promise<ShadowProduct> {
return this.request(`/api/servers/${serverId}/shop/products/${productId}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
async deleteProduct(serverId: string, productId: string): Promise<{ success: boolean }> {
return this.request(`/api/servers/${serverId}/shop/products/${productId}`, { method: 'DELETE' })
}
async getCart(serverId: string): Promise<ShadowCartItem[]> {
return this.request(`/api/servers/${serverId}/shop/cart`)
}
async addToCart(
serverId: string,
data: { productId: string; quantity: number },
): Promise<ShadowCartItem> {
return this.request(`/api/servers/${serverId}/shop/cart`, {
method: 'POST',
body: JSON.stringify(data),
})
}
async updateCartItem(
serverId: string,
itemId: string,
quantity: number,
): Promise<ShadowCartItem> {
return this.request(`/api/servers/${serverId}/shop/cart/${itemId}`, {
method: 'PUT',
body: JSON.stringify({ quantity }),
})
}
async removeCartItem(serverId: string, itemId: string): Promise<{ success: boolean }> {
return this.request(`/api/servers/${serverId}/shop/cart/${itemId}`, { method: 'DELETE' })
}
async createOrder(
serverId: string,
data?: { items?: { productId: string; quantity: number }[] },
): Promise<ShadowOrder> {
return this.request(`/api/servers/${serverId}/shop/orders`, {
method: 'POST',
body: JSON.stringify(data ?? {}),
})
}
async listOrders(serverId: string): Promise<ShadowOrder[]> {
return this.request(`/api/servers/${serverId}/shop/orders`)
}
async listShopOrders(serverId: string): Promise<ShadowOrder[]> {
return this.request(`/api/servers/${serverId}/shop/orders/manage`)
}
async getOrder(serverId: string, orderId: string): Promise<ShadowOrder> {
return this.request(`/api/servers/${serverId}/shop/orders/${orderId}`)
}
async updateOrderStatus(serverId: string, orderId: string, status: string): Promise<ShadowOrder> {
return this.request(`/api/servers/${serverId}/shop/orders/${orderId}/status`, {
method: 'PUT',
body: JSON.stringify({ status }),
})
}
async cancelOrder(serverId: string, orderId: string): Promise<ShadowOrder> {
return this.request(`/api/servers/${serverId}/shop/orders/${orderId}/cancel`, {
method: 'POST',
})
}
async getProductReviews(serverId: string, productId: string): Promise<ShadowReview[]> {
return this.request(`/api/servers/${serverId}/shop/products/${productId}/reviews`)
}
async createReview(
serverId: string,
orderId: string,
data: { productId: string; rating: number; content: string },
): Promise<ShadowReview> {
return this.request(`/api/servers/${serverId}/shop/orders/${orderId}/review`, {
method: 'POST',
body: JSON.stringify(data),
})
}
async replyToReview(serverId: string, reviewId: string, reply: string): Promise<ShadowReview> {
return this.request(`/api/servers/${serverId}/shop/reviews/${reviewId}/reply`, {
method: 'PUT',
body: JSON.stringify({ reply }),
})
}
async getWallet(): Promise<ShadowWallet> {
return this.request('/api/wallet')
}
async topUpWallet(amount: number): Promise<ShadowWallet> {
return this.request('/api/wallet/topup', {
method: 'POST',
body: JSON.stringify({ amount }),
})
}
async getWalletTransactions(): Promise<ShadowTransaction[]> {
return this.request('/api/wallet/transactions')
}
async getEntitlements(serverId: string): Promise<Record<string, unknown>[]> {
return this.request(`/api/servers/${serverId}/shop/entitlements`)
}
// ── Task Center ───────────────────────────────────────────────────────
async getTaskCenter(): Promise<{ tasks: ShadowTask[] }> {
return this.request('/api/tasks')
}
async claimTask(taskKey: string): Promise<{ success: boolean; reward: number }> {
return this.request(`/api/tasks/${taskKey}/claim`, { method: 'POST' })
}
async getReferralSummary(): Promise<{ count: number; rewards: number }> {
return this.request('/api/tasks/referral-summary')
}
async getRewardHistory(): Promise<{
rewards: { amount: number; reason: string; createdAt: string }[]
}> {
return this.request('/api/tasks/rewards')
}
// ── Server Apps ───────────────────────────────────────────────────────
async listApps(
serverId: string,
params?: { status?: string; limit?: number; offset?: number },
): Promise<{ apps: ShadowApp[]; total: number }> {
const qs = new URLSearchParams()
if (params?.status) qs.set('status', params.status)
if (params?.limit) qs.set('limit', String(params.limit))
if (params?.offset) qs.set('offset', String(params.offset))
return this.request(`/api/servers/${serverId}/apps?${qs}`)
}
async getHomepageApp(serverId: string): Promise<ShadowApp | null> {
return this.request(`/api/servers/${serverId}/apps/homepage`)
}
async getApp(serverId: string, appId: string): Promise<ShadowApp> {
return this.request(`/api/servers/${serverId}/apps/${appId}`)
}
async createApp(
serverId: string,
data: { name: string; slug: string; type: string; url?: string },
): Promise<ShadowApp> {
return this.request(`/api/servers/${serverId}/apps`, {
method: 'POST',
body: JSON.stringify(data),
})
}
async updateApp(
serverId: string,
appId: string,
data: Partial<{ name: string; slug: string; type: string; url: string; status: string }>,
): Promise<ShadowApp> {
return this.request(`/api/servers/${serverId}/apps/${appId}`, {
method: 'PATCH',
body: JSON.stringify(data),
})
}
async deleteApp(serverId: string, appId: string): Promise<{ success: boolean }> {
return this.request(`/api/servers/${serverId}/apps/${appId}`, { method: 'DELETE' })
}
async publishApp(serverId: string, data: { name: string; slug: string }): Promise<ShadowApp> {
return this.request(`/api/servers/${serverId}/apps/publish`, {
method: 'POST',
body: JSON.stringify(data),
})
}
}
// Re-export shared event constants for convenience
export type { ClientEvent, ServerEvent } from '@shadowob/shared'
export { CLIENT_EVENTS, SERVER_EVENTS } from '@shadowob/shared'
// ─── Room helpers ───────────────────────────────────────────────────────────
/** Build a Socket.IO room name for a channel */
export const channelRoom = (channelId: string) => `channel:${channelId}` as const
/** Build a Socket.IO room name for a thread */
export const threadRoom = (threadId: string) => `thread:${threadId}` as const
/** Build a Socket.IO room name for user-level notifications */
export const userRoom = (userId: string) => `user:${userId}` as const
// Client
export { ShadowClient } from './client'
export type { ClientEvent, ServerEvent } from './constants'
// Constants & room helpers
export {
CLIENT_EVENTS,
channelRoom,
SERVER_EVENTS,
threadRoom,
userRoom,
} from './constants'
export type { ShadowSocketOptions } from './socket'
// Socket
export { ShadowSocket } from './socket'
// Types
export type {
ChannelCreatedPayload,
ChannelMemberAddedPayload,
ChannelMemberRemovedPayload,
ClientEventMap,
DmMessage,
MemberJoinPayload,
MemberLeavePayload,
MessageDeletedPayload,
PolicyChangedPayload,
PresenceActivityPayload,
PresenceChangePayload,
ReactionPayload,
ServerEventMap,
ServerJoinedPayload,
ShadowApp,
ShadowAttachment,
ShadowCartItem,
ShadowCategory,
ShadowChannel,
ShadowChannelPolicy,
ShadowContract,
ShadowDmChannel,
ShadowFriendship,
ShadowInviteCode,
ShadowListing,
ShadowMember,
ShadowMessage,
ShadowNotification,
ShadowNotificationPreferences,
ShadowOAuthApp,
ShadowOAuthConsent,
ShadowOAuthToken,
ShadowOrder,
ShadowProduct,
ShadowRemoteChannel,
ShadowRemoteConfig,
ShadowRemoteServer,
ShadowReview,
ShadowServer,
ShadowShop,
ShadowTask,
ShadowThread,
ShadowTransaction,
ShadowUser,
ShadowWallet,
TypingPayload,
} from './types'
import { io, type Socket } from 'socket.io-client'
import type { ClientEventMap, ServerEventMap } from './types'
export interface ShadowSocketOptions {
/** Shadow server base URL (e.g. "https://shadowob.shadowob.com") */
serverUrl: string
/** JWT token for authentication */
token: string
/** Socket.IO transports (default: ['websocket']) */
transports?: string[]
/** Auto-reconnect on disconnect (default: true) */
autoReconnect?: boolean
/** Reconnection delay in ms (default: 1000) */
reconnectionDelay?: number
}
type ServerEventName = keyof ServerEventMap
type ServerEventHandler<E extends ServerEventName> = ServerEventMap[E]
/**
* Shadow real-time event listener.
*
* Wraps Socket.IO with strongly-typed events that match the Shadow server
* gateway broadcasts. Provides channel/thread room management and
* convenience methods for sending messages and typing indicators.
*/
export class ShadowSocket {
private socket: Socket
private _connected = false
constructor(options: ShadowSocketOptions) {
this.socket = io(options.serverUrl, {
auth: { token: options.token },
transports: options.transports ?? ['websocket'],
autoConnect: false,
reconnection: options.autoReconnect ?? true,
reconnectionDelay: options.reconnectionDelay ?? 1000,
})
this.socket.on('connect', () => {
this._connected = true
})
this.socket.on('disconnect', () => {
this._connected = false
})
}
/** Whether the socket is currently connected */
get connected(): boolean {
return this._connected
}
/** The underlying Socket.IO socket instance */
get raw(): Socket {
return this.socket
}
// ── Connection lifecycle ──────────────────────────────────────────────
/** Connect to the Shadow server */
connect(): void {
if (!this.socket.connected) {
this.socket.connect()
}
}
/** Disconnect from the Shadow server */
disconnect(): void {
this.socket.disconnect()
}
/** Wait until the socket is connected (resolves immediately if already connected) */
waitForConnect(timeoutMs = 5000): Promise<void> {
if (this.socket.connected) return Promise.resolve()
return new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`Socket connect timeout after ${timeoutMs}ms`))
}, timeoutMs)
this.socket.once('connect', () => {
clearTimeout(timer)
resolve()
})
})
}
// ── Typed event listeners ─────────────────────────────────────────────
/** Listen for a server event */
on<E extends ServerEventName>(event: E, handler: ServerEventHandler<E>): this {
this.socket.on(event as string, handler as (...args: unknown[]) => void)
return this
}
/** Listen for a server event (one-time) */
once<E extends ServerEventName>(event: E, handler: ServerEventHandler<E>): this {
this.socket.once(event as string, handler as (...args: unknown[]) => void)
return this
}
/** Remove a specific event listener */
off<E extends ServerEventName>(event: E, handler: ServerEventHandler<E>): this {
this.socket.off(event as string, handler as (...args: unknown[]) => void)
return this
}
/** Remove all listeners for an event or all events */
removeAllListeners(event?: ServerEventName): this {
if (event) {
this.socket.removeAllListeners(event)
} else {
this.socket.removeAllListeners()
}
return this
}
// ── Connection event listeners ────────────────────────────────────────
/** Listen for raw connection events (connect, disconnect, connect_error) */
onConnect(handler: () => void): this {
this.socket.on('connect', handler)
return this
}
onDisconnect(handler: (reason: string) => void): this {
this.socket.on('disconnect', handler)
return this
}
onConnectError(handler: (error: Error) => void): this {
this.socket.on('connect_error', handler)
return this
}
// ── Room management ───────────────────────────────────────────────────
/** Join a channel room to receive its messages and events */
joinChannel(channelId: string): Promise<{ ok: boolean }> {
return new Promise((resolve) => {
this.socket.emit(
'channel:join' satisfies keyof ClientEventMap,
{ channelId },
(res: { ok: boolean }) => {
resolve(res ?? { ok: true })
},
)
})
}
/** Leave a channel room */
leaveChannel(channelId: string): void {
this.socket.emit('channel:leave' satisfies keyof ClientEventMap, { channelId })
}
// ── Client actions ────────────────────────────────────────────────────
/** Send a message via WebSocket (text-only; for file attachments use REST) */
sendMessage(data: {
channelId: string
content: string
threadId?: string
replyToId?: string
}): void {
this.socket.emit('message:send' satisfies keyof ClientEventMap, data)
}
/** Send a typing indicator */
sendTyping(channelId: string): void {
this.socket.emit('message:typing' satisfies keyof ClientEventMap, { channelId })
}
/** Update user presence status */
updatePresence(status: 'online' | 'idle' | 'dnd' | 'offline'): void {
this.socket.emit('presence:update' satisfies keyof ClientEventMap, { status })
}
/** Update activity status in a channel (e.g. 'thinking', 'working', null) */
updateActivity(channelId: string, activity: string | null): void {
this.socket.emit('presence:activity' satisfies keyof ClientEventMap, { channelId, activity })
}
// ── DM actions ────────────────────────────────────────────────────────
/** Join a DM channel room */
joinDmChannel(dmChannelId: string): void {
this.socket.emit('dm:join' as string, { dmChannelId })
}
/** Leave a DM channel room */
leaveDmChannel(dmChannelId: string): void {
this.socket.emit('dm:leave' as string, { dmChannelId })
}
/** Send a DM message via WebSocket */
sendDmMessage(data: { dmChannelId: string; content: string; replyToId?: string }): void {
this.socket.emit('dm:send' as string, data)
}
/** Send a DM typing indicator */
sendDmTyping(dmChannelId: string): void {
this.socket.emit('dm:typing' as string, { dmChannelId })
}
}
// ─── Shadow SDK Types ───────────────────────────────────────────────────────
/** Message returned by the Shadow REST API and Socket.IO broadcasts */
export interface ShadowMessage {
id: string
content: string
channelId: string
authorId: string
threadId?: string | null
replyToId?: string | null
isPinned?: boolean
createdAt: string
updatedAt: string
author?: {
id: string
username: string
displayName?: string | null
avatarUrl?: string | null
isBot?: boolean
}
attachments?: ShadowAttachment[]
}
export interface ShadowAttachment {
id: string
filename: string
url: string
contentType: string
size: number
width?: number | null
height?: number | null
}
export interface ShadowChannel {
id: string
name: string
type: string
serverId: string
description?: string | null
position?: number
}
export interface ShadowDmChannel {
id: string
user1Id: string
user2Id: string
createdAt: string
}
export interface ShadowThread {
id: string
name: string
channelId: string
parentMessageId: string
createdAt: string
}
export interface ShadowMember {
userId: string
serverId: string
role: string
user?: ShadowUser
}
export interface ShadowInviteCode {
id: string
code: string
createdBy: string
usedBy?: string | null
usedAt?: string | null
isActive: boolean
note?: string | null
createdAt: string
}
export interface ShadowServer {
id: string
name: string
slug: string
description: string | null
iconUrl: string | null
bannerUrl: string | null
homepageHtml: string | null
isPublic: boolean
}
export interface ShadowUser {
id: string
username: string
displayName?: string
avatarUrl?: string
isBot?: boolean
agentId?: string
}
export interface ShadowNotification {
id: string
userId: string
type: string
title: string
body: string
referenceId?: string
referenceType?: string
isRead: boolean
createdAt: string
}
// ─── Channel Policy Types ───────────────────────────────────────────────────
export interface ShadowChannelPolicy {
listen: boolean
reply: boolean
mentionOnly: boolean
config: Record<string, unknown>
}
export interface ShadowRemoteChannel {
id: string
name: string
type: string
policy: ShadowChannelPolicy
}
export interface ShadowRemoteServer {
id: string
name: string
slug?: string
iconUrl?: string | null
defaultPolicy: ShadowChannelPolicy
channels: ShadowRemoteChannel[]
}
export interface ShadowRemoteConfig {
agentId: string
botUserId: string
servers: ShadowRemoteServer[]
}
// ─── Socket Event Payloads ──────────────────────────────────────────────────
export interface TypingPayload {
channelId: string
userId: string
username: string
}
export interface PresenceChangePayload {
userId: string
status: 'online' | 'idle' | 'dnd' | 'offline'
}
export interface PresenceActivityPayload {
userId: string
activity: string | null
channelId: string
}
export interface MemberJoinPayload {
channelId: string
userId: string
}
export interface MemberLeavePayload {
channelId: string
userId: string
}
export interface ReactionPayload {
messageId: string
userId: string
emoji: string
}
export interface MessageDeletedPayload {
id: string
channelId: string
}
export interface ChannelCreatedPayload {
id: string
name: string
type: string
serverId: string
}
export interface ChannelMemberAddedPayload {
channelId: string
userId: string
}
export interface ChannelMemberRemovedPayload {
channelId: string
userId: string
}
export interface ServerJoinedPayload {
serverId: string
serverName: string
}
export interface PolicyChangedPayload {
agentId: string
serverId: string
channelId?: string | null
}
export interface DmMessage {
id: string
content: string
senderId: string
receiverId: string
dmChannelId: string
channelId: string
authorId: string
author?: {
id: string
username: string
displayName?: string
avatarUrl?: string
isBot?: boolean
}
createdAt: string
}
// ─── Friendship Types ───────────────────────────────────────────────────────
export interface ShadowFriendship {
id: string
userId: string
friendId: string
status: 'pending' | 'accepted' | 'rejected'
createdAt: string
user?: ShadowUser
friend?: ShadowUser
}
// ─── OAuth App Types ────────────────────────────────────────────────────────
export interface ShadowOAuthApp {
id: string
name: string
clientId: string
clientSecret?: string
redirectUris: string[]
scopes: string[]
createdAt: string
}
export interface ShadowOAuthConsent {
id: string
appId: string
appName: string
scopes: string[]
createdAt: string
}
export interface ShadowOAuthToken {
access_token: string
token_type: string
expires_in: number
refresh_token?: string
scope: string
}
// ─── Marketplace / Rental Types ─────────────────────────────────────────────
export interface ShadowListing {
id: string
agentId: string
title: string
description: string
pricePerHour: number
currency: string
tags: string[]
isActive: boolean
createdAt: string
agent?: { id: string; name: string; status: string }
}
export interface ShadowContract {
id: string
listingId: string
tenantId: string
ownerId: string
status: string
startedAt: string
expiresAt: string
totalCost: number
createdAt: string
}
// ─── Shop Types ─────────────────────────────────────────────────────────────
export interface ShadowShop {
id: string
serverId: string
name: string
description?: string | null
isEnabled: boolean
}
export interface ShadowCategory {
id: string
shopId: string
name: string
description?: string | null
position: number
}
export interface ShadowProduct {
id: string
shopId: string
categoryId?: string | null
name: string
description?: string | null
price: number
currency: string
stock: number
status: string
images: string[]
createdAt: string
}
export interface ShadowCartItem {
id: string
productId: string
quantity: number
product?: ShadowProduct
}
export interface ShadowOrder {
id: string
shopId: string
buyerId: string
status: string
totalAmount: number
currency: string
items: { productId: string; quantity: number; price: number }[]
createdAt: string
}
export interface ShadowReview {
id: string
orderId: string
productId: string
userId: string
rating: number
content: string
reply?: string | null
createdAt: string
}
export interface ShadowWallet {
id: string
userId: string
balance: number
currency: string
}
export interface ShadowTransaction {
id: string
walletId: string
type: string
amount: number
description?: string | null
createdAt: string
}
// ─── Task Center Types ──────────────────────────────────────────────────────
export interface ShadowTask {
key: string
title: string
description: string
reward: number
status: string
claimedAt?: string | null
}
// ─── App Types ──────────────────────────────────────────────────────────────
export interface ShadowApp {
id: string
serverId: string
name: string
slug: string
type: string
url?: string | null
status: string
createdAt: string
}
// ─── Notification Preferences ───────────────────────────────────────────────
export interface ShadowNotificationPreferences {
strategy: 'all' | 'mention_only' | 'none'
mutedServerIds: string[]
mutedChannelIds: string[]
}
// ─── Socket Event Map ───────────────────────────────────────────────────────
/** Events the server pushes to the client */
export interface ServerEventMap {
'message:new': (message: ShadowMessage) => void
'message:updated': (message: ShadowMessage) => void
'message:deleted': (payload: MessageDeletedPayload) => void
'member:typing': (payload: TypingPayload) => void
'member:join': (payload: MemberJoinPayload) => void
'member:leave': (payload: MemberLeavePayload) => void
'presence:change': (payload: PresenceChangePayload) => void
'presence:activity': (payload: PresenceActivityPayload) => void
'reaction:add': (payload: ReactionPayload) => void
'reaction:remove': (payload: ReactionPayload) => void
'notification:new': (notification: ShadowNotification) => void
'dm:message:new': (message: DmMessage) => void
'channel:created': (payload: ChannelCreatedPayload) => void
'channel:member-added': (payload: ChannelMemberAddedPayload) => void
'channel:member-removed': (payload: ChannelMemberRemovedPayload) => void
'server:joined': (payload: ServerJoinedPayload) => void
'agent:policy-changed': (payload: PolicyChangedPayload) => void
error: (payload: { message: string }) => void
}
/** Events the client sends to the server */
export interface ClientEventMap {
'channel:join': (data: { channelId: string }, ack?: (res: { ok: boolean }) => void) => void
'channel:leave': (data: { channelId: string }) => void
'message:send': (data: {
channelId: string
content: string
threadId?: string
replyToId?: string
}) => void
'message:typing': (data: { channelId: string }) => void
'presence:update': (data: { status: 'online' | 'idle' | 'dnd' | 'offline' }) => void
'presence:activity': (data: { channelId: string; activity: string | null }) => void
}
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist"
},
"include": ["src"]
}