Initial commit: WebSocket channel plugin for OpenClaw
Features: - API key authentication (X-API-Key header or query param) - Multi-tenant support with per-account configuration - Configurable capabilities (text, images, files, voice, reactions, threads, typing) - DM security policies (allowlist, open, closed) - Text-based pairing flow - Threading support - Handshake protocol with capabilities advertisement - CLI commands (websocket status, websocket accounts) - Full test suite
This commit is contained in:
48
api.ts
Normal file
48
api.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* Public API exports for WebSocket channel plugin
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Config types
|
||||||
|
export type {
|
||||||
|
ChannelCapabilities,
|
||||||
|
WebSocketAccount,
|
||||||
|
WebSocketConfig,
|
||||||
|
} from "./src/config.ts";
|
||||||
|
|
||||||
|
export { parseCapabilities, defaultCapabilities } from "./src/config.ts";
|
||||||
|
|
||||||
|
// Auth utilities
|
||||||
|
export {
|
||||||
|
extractApiKey,
|
||||||
|
validateApiKey,
|
||||||
|
authenticateConnection,
|
||||||
|
type AuthResult,
|
||||||
|
} from "./src/auth.ts";
|
||||||
|
|
||||||
|
// WebSocket server
|
||||||
|
export {
|
||||||
|
wsManager,
|
||||||
|
WebSocketServerManager,
|
||||||
|
type InboundMessage,
|
||||||
|
type OutboundMessage,
|
||||||
|
type HandshakeMessage,
|
||||||
|
type TypingIndicator,
|
||||||
|
type PairingCodeMessage,
|
||||||
|
type WebSocketMessage,
|
||||||
|
type MessageContent,
|
||||||
|
type ConnectedClient,
|
||||||
|
type MessageHandler,
|
||||||
|
} from "./src/websocket-server.ts";
|
||||||
|
|
||||||
|
// Client handler
|
||||||
|
export {
|
||||||
|
handleWebSocketUpgrade,
|
||||||
|
getActiveAccounts,
|
||||||
|
type ClientHandlerOptions,
|
||||||
|
} from "./src/client.ts";
|
||||||
|
|
||||||
|
// Channel plugin
|
||||||
|
export {
|
||||||
|
websocketChannelPlugin,
|
||||||
|
registerWebSocketRoute,
|
||||||
|
} from "./src/channel.ts";
|
||||||
85
index.ts
Normal file
85
index.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* WebSocket Channel Plugin - Main Entry Point
|
||||||
|
*
|
||||||
|
* This entry point registers the full WebSocket channel plugin with all runtime
|
||||||
|
* capabilities including the WebSocket server and HTTP route handlers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core";
|
||||||
|
import { websocketChannelPlugin, registerWebSocketRoute } from "./src/channel.ts";
|
||||||
|
|
||||||
|
export default defineChannelPluginEntry({
|
||||||
|
id: "websocket-channel",
|
||||||
|
name: "WebSocket Channel",
|
||||||
|
description: "Custom WebSocket-based messaging channel for OpenClaw with configurable capabilities and API key authentication",
|
||||||
|
plugin: websocketChannelPlugin,
|
||||||
|
|
||||||
|
setRuntime(runtime) {
|
||||||
|
// Store runtime for potential future use
|
||||||
|
// This is called when the plugin is fully loaded
|
||||||
|
console.log("WebSocket channel plugin runtime initialized");
|
||||||
|
},
|
||||||
|
|
||||||
|
registerFull(api) {
|
||||||
|
// Register the WebSocket HTTP route
|
||||||
|
registerWebSocketRoute(api, api.config);
|
||||||
|
|
||||||
|
// Register CLI commands for management
|
||||||
|
api.registerCli(
|
||||||
|
({ program }) => {
|
||||||
|
const websocketCmd = program
|
||||||
|
.command("websocket")
|
||||||
|
.description("WebSocket channel management commands");
|
||||||
|
|
||||||
|
websocketCmd
|
||||||
|
.command("status")
|
||||||
|
.description("Show WebSocket channel status")
|
||||||
|
.action(async () => {
|
||||||
|
const { wsManager } = await import("./src/websocket-server.ts");
|
||||||
|
const connections = wsManager.getConnections();
|
||||||
|
console.log(`Active WebSocket connections: ${connections.length}`);
|
||||||
|
|
||||||
|
const byAccount = new Map<string, number>();
|
||||||
|
for (const conn of connections) {
|
||||||
|
const id = conn.account.accountId ?? "default";
|
||||||
|
byAccount.set(id, (byAccount.get(id) || 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (byAccount.size > 0) {
|
||||||
|
console.log("\nConnections by account:");
|
||||||
|
for (const [accountId, count] of byAccount) {
|
||||||
|
console.log(` ${accountId}: ${count}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
websocketCmd
|
||||||
|
.command("accounts")
|
||||||
|
.description("List configured WebSocket accounts")
|
||||||
|
.action(() => {
|
||||||
|
const section = (api.config.channels as Record<string, any>)?.["websocket"];
|
||||||
|
const accounts = section?.accounts || [];
|
||||||
|
|
||||||
|
console.log(`Configured accounts: ${accounts.length}`);
|
||||||
|
for (const account of accounts) {
|
||||||
|
console.log(`\n Account: ${account.accountId}`);
|
||||||
|
console.log(` API Key: ${account.apiKey ? "***" : "missing"}`);
|
||||||
|
console.log(` DM Policy: ${account.dmPolicy ?? "allowlist"}`);
|
||||||
|
console.log(` Capabilities: ${JSON.stringify(account.capabilities ?? {})}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ commands: ["websocket"] }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Register hook for config reload
|
||||||
|
api.on?.("config_reload", () => {
|
||||||
|
api.logger?.info("WebSocket channel: Config reloaded");
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-export for consumers
|
||||||
|
export { websocketChannelPlugin } from "./src/channel.ts";
|
||||||
|
export { wsManager, type WebSocketMessage, type InboundMessage, type OutboundMessage } from "./src/websocket-server.ts";
|
||||||
|
export { extractApiKey, validateApiKey, authenticateConnection } from "./src/auth.ts";
|
||||||
102
openclaw.plugin.json
Normal file
102
openclaw.plugin.json
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
{
|
||||||
|
"id": "websocket-channel",
|
||||||
|
"kind": "channel",
|
||||||
|
"channels": ["websocket"],
|
||||||
|
"name": "WebSocket Channel",
|
||||||
|
"description": "Custom WebSocket-based messaging channel for OpenClaw",
|
||||||
|
"configSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"websocket": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"port": {
|
||||||
|
"type": "number",
|
||||||
|
"default": 8765,
|
||||||
|
"description": "Port to run the WebSocket server on"
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "/ws",
|
||||||
|
"description": "Path for WebSocket connections"
|
||||||
|
},
|
||||||
|
"accounts": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"accountId": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Unique identifier for this account"
|
||||||
|
},
|
||||||
|
"apiKey": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "API key for authenticating WebSocket connections"
|
||||||
|
},
|
||||||
|
"allowFrom": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string" },
|
||||||
|
"description": "List of allowed user IDs for DM security"
|
||||||
|
},
|
||||||
|
"dmPolicy": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["allowlist", "open", "closed"],
|
||||||
|
"default": "allowlist",
|
||||||
|
"description": "Direct message security policy"
|
||||||
|
},
|
||||||
|
"capabilities": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"text": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"description": "Support text messages"
|
||||||
|
},
|
||||||
|
"images": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false,
|
||||||
|
"description": "Support image messages"
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false,
|
||||||
|
"description": "Support file attachments"
|
||||||
|
},
|
||||||
|
"voice": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false,
|
||||||
|
"description": "Support voice messages"
|
||||||
|
},
|
||||||
|
"reactions": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false,
|
||||||
|
"description": "Support message reactions"
|
||||||
|
},
|
||||||
|
"threads": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false,
|
||||||
|
"description": "Support threaded conversations"
|
||||||
|
},
|
||||||
|
"typing": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false,
|
||||||
|
"description": "Support typing indicators"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Custom metadata exposed to clients during handshake"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["accountId", "apiKey"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["accounts"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["websocket"]
|
||||||
|
}
|
||||||
|
}
|
||||||
31
package.json
Normal file
31
package.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "@openclaw/websocket-channel",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "WebSocket channel plugin for OpenClaw - enables custom messaging platforms to connect via WebSocket",
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"ws": "^8.18.0"
|
||||||
|
},
|
||||||
|
"openclaw": {
|
||||||
|
"extensions": [
|
||||||
|
"./index.ts"
|
||||||
|
],
|
||||||
|
"setupEntry": "./setup-entry.ts",
|
||||||
|
"channel": {
|
||||||
|
"id": "websocket",
|
||||||
|
"label": "WebSocket",
|
||||||
|
"selectionLabel": "WebSocket (Custom)",
|
||||||
|
"detailLabel": "WebSocket Channel",
|
||||||
|
"docsPath": "/channels/websocket",
|
||||||
|
"docsLabel": "websocket",
|
||||||
|
"blurb": "connect custom messaging platforms via WebSocket.",
|
||||||
|
"systemImage": "network"
|
||||||
|
},
|
||||||
|
"install": {
|
||||||
|
"npmSpec": "@openclaw/websocket-channel",
|
||||||
|
"localPath": "extensions/websocket-channel",
|
||||||
|
"defaultChoice": "local",
|
||||||
|
"minHostVersion": ">=2026.3.22"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
setup-entry.ts
Normal file
12
setup-entry.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* WebSocket Channel Plugin - Setup Entry Point
|
||||||
|
*
|
||||||
|
* This lightweight entry point is loaded during setup/onboarding when the
|
||||||
|
* full plugin capabilities are not yet needed. It only exports the plugin
|
||||||
|
* definition for account resolution and configuration inspection.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { defineSetupPluginEntry } from "openclaw/plugin-sdk/core";
|
||||||
|
import { websocketChannelPlugin } from "./src/channel.ts";
|
||||||
|
|
||||||
|
export default defineSetupPluginEntry(websocketChannelPlugin);
|
||||||
68
src/auth.ts
Normal file
68
src/auth.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* Authentication utilities for WebSocket connections
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { WebSocketAccount } from "./config.ts";
|
||||||
|
|
||||||
|
export type AuthResult =
|
||||||
|
| { success: true; account: WebSocketAccount }
|
||||||
|
| { success: false; error: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract API key from WebSocket upgrade request
|
||||||
|
* Supports X-API-Key header or ?key= query parameter
|
||||||
|
*/
|
||||||
|
export function extractApiKey(
|
||||||
|
req: { headers: Record<string, string | string[]>; url?: string }
|
||||||
|
): string | null {
|
||||||
|
// Check header first
|
||||||
|
const headerValue = req.headers["x-api-key"];
|
||||||
|
if (headerValue) {
|
||||||
|
return Array.isArray(headerValue) ? headerValue[0] : headerValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check query parameter
|
||||||
|
if (req.url) {
|
||||||
|
const url = new URL(req.url, "http://localhost");
|
||||||
|
const keyParam = url.searchParams.get("key");
|
||||||
|
if (keyParam) {
|
||||||
|
return keyParam;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate API key against configured accounts
|
||||||
|
*/
|
||||||
|
export function validateApiKey(
|
||||||
|
apiKey: string,
|
||||||
|
accounts: WebSocketAccount[]
|
||||||
|
): AuthResult {
|
||||||
|
if (!apiKey) {
|
||||||
|
return { success: false, error: "API key is required" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = accounts.find((acc) => acc.apiKey === apiKey);
|
||||||
|
if (!account) {
|
||||||
|
return { success: false, error: "Invalid API key" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, account };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticate a connection request
|
||||||
|
*/
|
||||||
|
export function authenticateConnection(
|
||||||
|
req: { headers: Record<string, string | string[]>; url?: string },
|
||||||
|
accounts: WebSocketAccount[]
|
||||||
|
): AuthResult {
|
||||||
|
const apiKey = extractApiKey(req);
|
||||||
|
if (!apiKey) {
|
||||||
|
return { success: false, error: "Missing API key. Provide via X-API-Key header or ?key= query parameter" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return validateApiKey(apiKey, accounts);
|
||||||
|
}
|
||||||
339
src/channel.test.ts
Normal file
339
src/channel.test.ts
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
/**
|
||||||
|
* WebSocket Channel Plugin Tests
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { parseCapabilities, defaultCapabilities, type ChannelCapabilities } from "./config.ts";
|
||||||
|
import { extractApiKey, validateApiKey, authenticateConnection } from "./auth.ts";
|
||||||
|
import { WebSocketServerManager } from "./websocket-server.ts";
|
||||||
|
|
||||||
|
// Mock WebSocket
|
||||||
|
class MockWebSocket {
|
||||||
|
readyState = 1; // WebSocket.OPEN
|
||||||
|
listeners: Record<string, ((...args: any[]) => void)[]> = {};
|
||||||
|
|
||||||
|
on(event: string, handler: (...args: any[]) => void) {
|
||||||
|
if (!this.listeners[event]) this.listeners[event] = [];
|
||||||
|
this.listeners[event].push(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
send(data: string) {
|
||||||
|
// Mock send
|
||||||
|
}
|
||||||
|
|
||||||
|
close(code?: number, reason?: string) {
|
||||||
|
this.readyState = 3; // WebSocket.CLOSED
|
||||||
|
}
|
||||||
|
|
||||||
|
terminate() {
|
||||||
|
this.readyState = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
ping() {
|
||||||
|
// Mock ping
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(event: string, ...args: any[]) {
|
||||||
|
this.listeners[event]?.forEach((h) => h(...args));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("config", () => {
|
||||||
|
describe("parseCapabilities", () => {
|
||||||
|
it("returns defaults when given undefined", () => {
|
||||||
|
const caps = parseCapabilities(undefined);
|
||||||
|
expect(caps).toEqual(defaultCapabilities);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges partial capabilities with defaults", () => {
|
||||||
|
const caps = parseCapabilities({ images: true, voice: true });
|
||||||
|
expect(caps.text).toBe(true); // default
|
||||||
|
expect(caps.images).toBe(true); // overridden
|
||||||
|
expect(caps.voice).toBe(true); // overridden
|
||||||
|
expect(caps.files).toBe(false); // default
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns all false when explicitly set", () => {
|
||||||
|
const caps: ChannelCapabilities = {
|
||||||
|
text: false,
|
||||||
|
images: false,
|
||||||
|
files: false,
|
||||||
|
voice: false,
|
||||||
|
reactions: false,
|
||||||
|
threads: false,
|
||||||
|
typing: false,
|
||||||
|
};
|
||||||
|
const parsed = parseCapabilities(caps);
|
||||||
|
expect(parsed.text).toBe(false);
|
||||||
|
expect(parsed.images).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("auth", () => {
|
||||||
|
const mockAccounts = [
|
||||||
|
{
|
||||||
|
accountId: "test-account",
|
||||||
|
apiKey: "test-key-123",
|
||||||
|
allowFrom: ["user1", "user2"],
|
||||||
|
dmPolicy: "allowlist" as const,
|
||||||
|
capabilities: defaultCapabilities,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accountId: "prod-account",
|
||||||
|
apiKey: "prod-key-456",
|
||||||
|
allowFrom: [],
|
||||||
|
dmPolicy: "open" as const,
|
||||||
|
capabilities: { ...defaultCapabilities, images: true },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("extractApiKey", () => {
|
||||||
|
it("extracts API key from header", () => {
|
||||||
|
const req = {
|
||||||
|
headers: { "x-api-key": "my-api-key" },
|
||||||
|
};
|
||||||
|
expect(extractApiKey(req)).toBe("my-api-key");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts first API key from array header", () => {
|
||||||
|
const req = {
|
||||||
|
headers: { "x-api-key": ["key1", "key2"] },
|
||||||
|
};
|
||||||
|
expect(extractApiKey(req)).toBe("key1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts API key from query parameter", () => {
|
||||||
|
const req = {
|
||||||
|
headers: {},
|
||||||
|
url: "/ws?key=query-key",
|
||||||
|
};
|
||||||
|
expect(extractApiKey(req)).toBe("query-key");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when no API key provided", () => {
|
||||||
|
const req = {
|
||||||
|
headers: {},
|
||||||
|
url: "/ws",
|
||||||
|
};
|
||||||
|
expect(extractApiKey(req)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateApiKey", () => {
|
||||||
|
it("returns success for valid API key", () => {
|
||||||
|
const result = validateApiKey("test-key-123", mockAccounts);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.account.accountId).toBe("test-account");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns error for invalid API key", () => {
|
||||||
|
const result = validateApiKey("invalid-key", mockAccounts);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.error).toBe("Invalid API key");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns error for empty API key", () => {
|
||||||
|
const result = validateApiKey("", mockAccounts);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.error).toBe("API key is required");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("authenticateConnection", () => {
|
||||||
|
it("authenticates with valid header", () => {
|
||||||
|
const req = {
|
||||||
|
headers: { "x-api-key": "test-key-123" },
|
||||||
|
};
|
||||||
|
const result = authenticateConnection(req, mockAccounts);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails with missing API key", () => {
|
||||||
|
const req = {
|
||||||
|
headers: {},
|
||||||
|
url: "/ws",
|
||||||
|
};
|
||||||
|
const result = authenticateConnection(req, mockAccounts);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.error).toContain("Missing API key");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("WebSocketServerManager", () => {
|
||||||
|
let manager: WebSocketServerManager;
|
||||||
|
let mockWs: MockWebSocket;
|
||||||
|
|
||||||
|
const mockAccount = {
|
||||||
|
accountId: "test-account",
|
||||||
|
apiKey: "test-key",
|
||||||
|
allowFrom: ["user1"],
|
||||||
|
dmPolicy: "allowlist" as const,
|
||||||
|
capabilities: defaultCapabilities,
|
||||||
|
metadata: { serverName: "Test Server" },
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
manager = new WebSocketServerManager();
|
||||||
|
mockWs = new MockWebSocket();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("addClient", () => {
|
||||||
|
it("adds a client and sends handshake", () => {
|
||||||
|
const sentMessages: string[] = [];
|
||||||
|
mockWs.send = (data: string) => sentMessages.push(data);
|
||||||
|
|
||||||
|
manager.addClient(mockWs as any, mockAccount);
|
||||||
|
|
||||||
|
expect(sentMessages.length).toBe(1);
|
||||||
|
const handshake = JSON.parse(sentMessages[0]);
|
||||||
|
expect(handshake.type).toBe("handshake");
|
||||||
|
expect(handshake.accountId).toBe("test-account");
|
||||||
|
expect(handshake.capabilities.text).toBe(true);
|
||||||
|
expect(handshake.metadata.serverName).toBe("Test Server");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks connection count", () => {
|
||||||
|
manager.addClient(mockWs as any, mockAccount);
|
||||||
|
expect(manager.getConnectionCount("test-account")).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks multiple connections", () => {
|
||||||
|
const ws2 = new MockWebSocket();
|
||||||
|
manager.addClient(mockWs as any, mockAccount);
|
||||||
|
manager.addClient(ws2 as any, mockAccount);
|
||||||
|
expect(manager.getConnectionCount("test-account")).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sendText", () => {
|
||||||
|
it("sends text message to all connections for account", async () => {
|
||||||
|
const sentMessages: string[] = [];
|
||||||
|
mockWs.send = (data: string) => sentMessages.push(data);
|
||||||
|
|
||||||
|
manager.addClient(mockWs as any, mockAccount);
|
||||||
|
await manager.sendText("user1", "Hello!", mockAccount);
|
||||||
|
|
||||||
|
expect(sentMessages.length).toBe(2); // handshake + message
|
||||||
|
const message = JSON.parse(sentMessages[1]);
|
||||||
|
expect(message.type).toBe("message");
|
||||||
|
expect(message.to).toBe("user1");
|
||||||
|
expect(message.content.type).toBe("text");
|
||||||
|
expect(message.content.text).toBe("Hello!");
|
||||||
|
expect(message.messageId).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when no connections exist", async () => {
|
||||||
|
await expect(
|
||||||
|
manager.sendText("user1", "Hello!", mockAccount)
|
||||||
|
).rejects.toThrow("No active connections");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sendMedia", () => {
|
||||||
|
it("sends file message", async () => {
|
||||||
|
const sentMessages: string[] = [];
|
||||||
|
mockWs.send = (data: string) => sentMessages.push(data);
|
||||||
|
|
||||||
|
manager.addClient(mockWs as any, mockAccount);
|
||||||
|
await manager.sendMedia("user1", "/path/to/file.pdf", mockAccount);
|
||||||
|
|
||||||
|
const message = JSON.parse(sentMessages[1]);
|
||||||
|
expect(message.content.type).toBe("file");
|
||||||
|
expect(message.content.name).toBe("file.pdf");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sendTyping", () => {
|
||||||
|
it("sends typing indicator", async () => {
|
||||||
|
const sentMessages: string[] = [];
|
||||||
|
mockWs.send = (data: string) => sentMessages.push(data);
|
||||||
|
|
||||||
|
manager.addClient(mockWs as any, mockAccount);
|
||||||
|
await manager.sendTyping("user1", true, mockAccount);
|
||||||
|
|
||||||
|
const message = JSON.parse(sentMessages[1]);
|
||||||
|
expect(message.type).toBe("typing");
|
||||||
|
expect(message.to).toBe("user1");
|
||||||
|
expect(message.isTyping).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sendPairingCode", () => {
|
||||||
|
it("sends pairing code message", async () => {
|
||||||
|
const sentMessages: string[] = [];
|
||||||
|
mockWs.send = (data: string) => sentMessages.push(data);
|
||||||
|
|
||||||
|
manager.addClient(mockWs as any, mockAccount);
|
||||||
|
await manager.sendPairingCode("user1", "ABC123", mockAccount);
|
||||||
|
|
||||||
|
const message = JSON.parse(sentMessages[1]);
|
||||||
|
expect(message.type).toBe("pairing_code");
|
||||||
|
expect(message.code).toBe("ABC123");
|
||||||
|
expect(message.target).toBe("user1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cleanup", () => {
|
||||||
|
it("removes stale connections", () => {
|
||||||
|
manager.addClient(mockWs as any, mockAccount);
|
||||||
|
expect(manager.getConnectionCount("test-account")).toBe(1);
|
||||||
|
|
||||||
|
// Simulate old ping
|
||||||
|
const connections = manager.getConnections();
|
||||||
|
connections[0].lastPingAt = new Date(Date.now() - 120000); // 2 minutes ago
|
||||||
|
|
||||||
|
manager.cleanup();
|
||||||
|
expect(manager.getConnectionCount("test-account")).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("onMessage", () => {
|
||||||
|
it("receives and processes inbound messages", async () => {
|
||||||
|
const receivedMessages: any[] = [];
|
||||||
|
manager.onMessage(async (msg) => {
|
||||||
|
receivedMessages.push(msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.addClient(mockWs as any, mockAccount);
|
||||||
|
|
||||||
|
// Simulate incoming message
|
||||||
|
mockWs.emit("message", Buffer.from(JSON.stringify({
|
||||||
|
type: "message",
|
||||||
|
from: "user1",
|
||||||
|
content: { type: "text", text: "Hello bot!" },
|
||||||
|
})));
|
||||||
|
|
||||||
|
// Wait for async processing
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
|
||||||
|
expect(receivedMessages.length).toBe(1);
|
||||||
|
expect(receivedMessages[0].from).toBe("user1");
|
||||||
|
expect(receivedMessages[0].content.text).toBe("Hello bot!");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles invalid JSON gracefully", async () => {
|
||||||
|
const errors: string[] = [];
|
||||||
|
mockWs.send = (data: string) => {
|
||||||
|
const parsed = JSON.parse(data);
|
||||||
|
if (parsed.type === "error") errors.push(parsed.error);
|
||||||
|
};
|
||||||
|
|
||||||
|
manager.addClient(mockWs as any, mockAccount);
|
||||||
|
mockWs.emit("message", Buffer.from("not valid json"));
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
expect(errors.length).toBe(1);
|
||||||
|
expect(errors[0]).toBe("Invalid message format");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
256
src/channel.ts
Normal file
256
src/channel.ts
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
/**
|
||||||
|
* WebSocket channel plugin implementation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
createChatChannelPlugin,
|
||||||
|
createChannelPluginBase,
|
||||||
|
} from "openclaw/plugin-sdk/core";
|
||||||
|
import type { OpenClawConfig, OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||||
|
import type { WebSocketAccount, ChannelCapabilities } from "./config.ts";
|
||||||
|
import { parseCapabilities } from "./config.ts";
|
||||||
|
import { wsManager } from "./websocket-server.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve account from OpenClaw config
|
||||||
|
*/
|
||||||
|
function resolveAccount(
|
||||||
|
cfg: OpenClawConfig,
|
||||||
|
accountId?: string | null
|
||||||
|
): WebSocketAccount {
|
||||||
|
const section = (cfg.channels as Record<string, any>)?.["websocket"];
|
||||||
|
if (!section) {
|
||||||
|
throw new Error("websocket: channel configuration not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const accounts = section.accounts || [];
|
||||||
|
if (!Array.isArray(accounts) || accounts.length === 0) {
|
||||||
|
throw new Error("websocket: no accounts configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find matching account
|
||||||
|
const targetId = accountId ?? "default";
|
||||||
|
const account = accounts.find((a: any) => a.accountId === targetId);
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
throw new Error(`websocket: account ${targetId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!account.apiKey) {
|
||||||
|
throw new Error(`websocket: apiKey is required for account ${targetId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
accountId: account.accountId ?? null,
|
||||||
|
apiKey: account.apiKey,
|
||||||
|
allowFrom: account.allowFrom ?? [],
|
||||||
|
dmPolicy: account.dmPolicy ?? "allowlist",
|
||||||
|
capabilities: parseCapabilities(account.capabilities),
|
||||||
|
metadata: account.metadata,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inspect account without materializing secrets
|
||||||
|
*/
|
||||||
|
function inspectAccount(
|
||||||
|
cfg: OpenClawConfig,
|
||||||
|
accountId?: string | null
|
||||||
|
): {
|
||||||
|
enabled: boolean;
|
||||||
|
configured: boolean;
|
||||||
|
accountStatus: string;
|
||||||
|
capabilities?: ChannelCapabilities;
|
||||||
|
} {
|
||||||
|
const section = (cfg.channels as Record<string, any>)?.["websocket"];
|
||||||
|
const accounts = section?.accounts;
|
||||||
|
|
||||||
|
if (!Array.isArray(accounts) || accounts.length === 0) {
|
||||||
|
return {
|
||||||
|
enabled: false,
|
||||||
|
configured: false,
|
||||||
|
accountStatus: "no-accounts",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetId = accountId ?? "default";
|
||||||
|
const account = accounts.find((a: any) => a.accountId === targetId);
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
return {
|
||||||
|
enabled: false,
|
||||||
|
configured: false,
|
||||||
|
accountStatus: "account-not-found",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasKey = Boolean(account.apiKey);
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: hasKey,
|
||||||
|
configured: hasKey,
|
||||||
|
accountStatus: hasKey ? "configured" : "missing-api-key",
|
||||||
|
capabilities: parseCapabilities(account.capabilities),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all accounts from config
|
||||||
|
*/
|
||||||
|
function getAllAccounts(cfg: OpenClawConfig): WebSocketAccount[] {
|
||||||
|
const section = (cfg.channels as Record<string, any>)?.["websocket"];
|
||||||
|
const accounts = section?.accounts || [];
|
||||||
|
|
||||||
|
return accounts.map((a: any) => ({
|
||||||
|
accountId: a.accountId ?? null,
|
||||||
|
apiKey: a.apiKey,
|
||||||
|
allowFrom: a.allowFrom ?? [],
|
||||||
|
dmPolicy: a.dmPolicy ?? "allowlist",
|
||||||
|
capabilities: parseCapabilities(a.capabilities),
|
||||||
|
metadata: a.metadata,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the WebSocket channel plugin
|
||||||
|
*/
|
||||||
|
export const websocketChannelPlugin = createChatChannelPlugin<WebSocketAccount>({
|
||||||
|
base: createChannelPluginBase({
|
||||||
|
id: "websocket",
|
||||||
|
setup: {
|
||||||
|
resolveAccount,
|
||||||
|
inspectAccount,
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
name: "WebSocket Channel",
|
||||||
|
description: "Custom WebSocket-based messaging channel",
|
||||||
|
},
|
||||||
|
capabilities: {
|
||||||
|
supportsTyping: true,
|
||||||
|
supportsReactions: true,
|
||||||
|
supportsThreads: true,
|
||||||
|
supportsMedia: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
// DM security configuration
|
||||||
|
security: {
|
||||||
|
dm: {
|
||||||
|
channelKey: "websocket",
|
||||||
|
resolvePolicy: (account) => account.dmPolicy,
|
||||||
|
resolveAllowFrom: (account) => account.allowFrom,
|
||||||
|
defaultPolicy: "allowlist",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Pairing configuration
|
||||||
|
pairing: {
|
||||||
|
text: {
|
||||||
|
idLabel: "WebSocket user ID",
|
||||||
|
message: "Send this code to verify your identity:",
|
||||||
|
notify: async ({ target, code, account }) => {
|
||||||
|
// Resolve the account if it's a config object
|
||||||
|
const resolvedAccount = (account as any)?.apiKey
|
||||||
|
? (account as WebSocketAccount)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (resolvedAccount) {
|
||||||
|
await wsManager.sendPairingCode(target, code, resolvedAccount);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Threading configuration
|
||||||
|
threading: {
|
||||||
|
topLevelReplyToMode: "reply",
|
||||||
|
resolveThreadId: (message) => message.threadId,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Outbound messaging
|
||||||
|
outbound: {
|
||||||
|
attachedResults: {
|
||||||
|
sendText: async (params) => {
|
||||||
|
const account = resolveAccount(params.cfg, params.accountId);
|
||||||
|
const result = await wsManager.sendText(
|
||||||
|
params.to,
|
||||||
|
params.text,
|
||||||
|
account,
|
||||||
|
params.threadId
|
||||||
|
);
|
||||||
|
return { messageId: result.messageId };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
base: {
|
||||||
|
sendMedia: async (params) => {
|
||||||
|
const account = resolveAccount(params.cfg, params.accountId);
|
||||||
|
await wsManager.sendMedia(
|
||||||
|
params.to,
|
||||||
|
params.filePath,
|
||||||
|
account,
|
||||||
|
params.threadId
|
||||||
|
);
|
||||||
|
},
|
||||||
|
async sendTyping(params) {
|
||||||
|
const account = resolveAccount(params.cfg, params.accountId);
|
||||||
|
await wsManager.sendTyping(params.to, true, account);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register WebSocket HTTP route for handling upgrades
|
||||||
|
*/
|
||||||
|
export function registerWebSocketRoute(
|
||||||
|
api: OpenClawPluginApi,
|
||||||
|
config: OpenClawConfig
|
||||||
|
): void {
|
||||||
|
const path = ((config.channels as any)?.websocket?.path as string) || "/ws";
|
||||||
|
|
||||||
|
api.registerHttpRoute({
|
||||||
|
path,
|
||||||
|
auth: "plugin",
|
||||||
|
handler: async (req, res, _ctx, upgrade) => {
|
||||||
|
// Handle WebSocket upgrade
|
||||||
|
if (upgrade?.upgrade) {
|
||||||
|
const accounts = getAllAccounts(config);
|
||||||
|
|
||||||
|
if (accounts.length === 0) {
|
||||||
|
res.statusCode = 503;
|
||||||
|
res.end("No WebSocket accounts configured");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform upgrade
|
||||||
|
const ws = upgrade.upgrade(req, req.socket, Buffer.alloc(0));
|
||||||
|
|
||||||
|
// Handle the connection
|
||||||
|
const { handleWebSocketUpgrade } = await import("./client.ts");
|
||||||
|
handleWebSocketUpgrade(ws, req, { accounts, api });
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular HTTP request - return info
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.setHeader("Content-Type", "application/json");
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
service: "OpenClaw WebSocket Channel",
|
||||||
|
version: "1.0.0",
|
||||||
|
websocket: true,
|
||||||
|
path,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start cleanup interval
|
||||||
|
const interval = wsManager.startCleanupInterval();
|
||||||
|
|
||||||
|
// Clean up on plugin unload
|
||||||
|
api.on?.("plugin_unload", () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
});
|
||||||
|
}
|
||||||
147
src/client.ts
Normal file
147
src/client.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* WebSocket client connection handler
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { WebSocket as WebSocketType } from "ws";
|
||||||
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||||
|
import type { WebSocketAccount } from "./config.ts";
|
||||||
|
import { authenticateConnection } from "./auth.ts";
|
||||||
|
import { wsManager, type InboundMessage, type ConnectedClient } from "./websocket-server.ts";
|
||||||
|
|
||||||
|
export type ClientHandlerOptions = {
|
||||||
|
accounts: WebSocketAccount[];
|
||||||
|
api: OpenClawPluginApi;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle WebSocket upgrade request
|
||||||
|
*/
|
||||||
|
export function handleWebSocketUpgrade(
|
||||||
|
ws: WebSocketType,
|
||||||
|
req: { headers: Record<string, string | string[]>; url?: string },
|
||||||
|
options: ClientHandlerOptions
|
||||||
|
): boolean {
|
||||||
|
const { accounts, api } = options;
|
||||||
|
|
||||||
|
// Authenticate the connection
|
||||||
|
const authResult = authenticateConnection(req, accounts);
|
||||||
|
if (!authResult.success) {
|
||||||
|
ws.close(1008, authResult.error);
|
||||||
|
api.logger?.warn(`WebSocket authentication failed: ${authResult.error}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add client to manager
|
||||||
|
const client = wsManager.addClient(ws, authResult.account);
|
||||||
|
api.logger?.info(
|
||||||
|
`WebSocket client connected: account=${authResult.account.accountId}, connections=${wsManager.getConnectionCount(authResult.account.accountId)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set up message handler for this client
|
||||||
|
wsManager.onMessage(async (message, senderClient) => {
|
||||||
|
await handleInboundMessage(message, senderClient, api);
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process incoming message from WebSocket client
|
||||||
|
*/
|
||||||
|
async function handleInboundMessage(
|
||||||
|
message: InboundMessage,
|
||||||
|
client: ConnectedClient,
|
||||||
|
api: OpenClawPluginApi
|
||||||
|
): Promise<void> {
|
||||||
|
const account = client.account;
|
||||||
|
|
||||||
|
// Validate message type
|
||||||
|
if (message.type !== "message") {
|
||||||
|
api.logger?.warn(`Unsupported message type: ${message.type}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate capabilities
|
||||||
|
const content = message.content;
|
||||||
|
if (content.type === "image" && !account.capabilities.images) {
|
||||||
|
api.logger?.warn(`Account ${account.accountId} tried to send image but capability is disabled`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (content.type === "file" && !account.capabilities.files) {
|
||||||
|
api.logger?.warn(`Account ${account.accountId} tried to send file but capability is disabled`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (content.type === "voice" && !account.capabilities.voice) {
|
||||||
|
api.logger?.warn(`Account ${account.accountId} tried to send voice but capability is disabled`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check DM security - user must be in allowFrom list (unless policy is "open")
|
||||||
|
if (account.dmPolicy === "closed") {
|
||||||
|
api.logger?.debug(`Message rejected: DM policy is closed for account ${account.accountId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (account.dmPolicy === "allowlist" && !account.allowFrom.includes(message.from)) {
|
||||||
|
api.logger?.debug(`Message rejected: ${message.from} not in allowlist for account ${account.accountId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch to OpenClaw
|
||||||
|
try {
|
||||||
|
// Find the channel's inbound handler
|
||||||
|
const channelApi = api as OpenClawPluginApi & {
|
||||||
|
channelInbound?: {
|
||||||
|
dispatch: (params: {
|
||||||
|
channel: string;
|
||||||
|
accountId: string | null;
|
||||||
|
from: string;
|
||||||
|
content: string;
|
||||||
|
threadId?: string;
|
||||||
|
}) => Promise<void>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
if (channelApi.channelInbound) {
|
||||||
|
await channelApi.channelInbound.dispatch({
|
||||||
|
channel: "websocket",
|
||||||
|
accountId: account.accountId,
|
||||||
|
from: message.from,
|
||||||
|
content: content.type === "text" ? content.text : JSON.stringify(content),
|
||||||
|
threadId: message.threadId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
api.logger?.error("Failed to dispatch inbound message:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get active accounts from config
|
||||||
|
*/
|
||||||
|
export function getActiveAccounts(config: { websocket?: { accounts?: unknown[] } }): WebSocketAccount[] {
|
||||||
|
const accounts: WebSocketAccount[] = [];
|
||||||
|
const rawAccounts = config.websocket?.accounts;
|
||||||
|
|
||||||
|
if (Array.isArray(rawAccounts)) {
|
||||||
|
for (const raw of rawAccounts) {
|
||||||
|
if (isValidAccount(raw)) {
|
||||||
|
accounts.push(raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return accounts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidAccount(raw: unknown): raw is WebSocketAccount {
|
||||||
|
if (!raw || typeof raw !== "object") return false;
|
||||||
|
const obj = raw as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
typeof obj.accountId === "string" &&
|
||||||
|
typeof obj.apiKey === "string" &&
|
||||||
|
Array.isArray(obj.allowFrom) &&
|
||||||
|
typeof obj.dmPolicy === "string" &&
|
||||||
|
obj.capabilities !== null && typeof obj.capabilities === "object"
|
||||||
|
);
|
||||||
|
}
|
||||||
50
src/config.ts
Normal file
50
src/config.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* Configuration types for WebSocket channel plugin
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ChannelCapabilities = {
|
||||||
|
text: boolean;
|
||||||
|
images: boolean;
|
||||||
|
files: boolean;
|
||||||
|
voice: boolean;
|
||||||
|
reactions: boolean;
|
||||||
|
threads: boolean;
|
||||||
|
typing: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WebSocketAccount = {
|
||||||
|
accountId: string | null;
|
||||||
|
apiKey: string;
|
||||||
|
allowFrom: string[];
|
||||||
|
dmPolicy: "allowlist" | "open" | "closed";
|
||||||
|
capabilities: ChannelCapabilities;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WebSocketConfig = {
|
||||||
|
port: number;
|
||||||
|
path: string;
|
||||||
|
accounts: WebSocketAccount[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const defaultCapabilities: ChannelCapabilities = {
|
||||||
|
text: true,
|
||||||
|
images: false,
|
||||||
|
files: false,
|
||||||
|
voice: false,
|
||||||
|
reactions: false,
|
||||||
|
threads: false,
|
||||||
|
typing: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseCapabilities(raw?: Partial<ChannelCapabilities>): ChannelCapabilities {
|
||||||
|
return {
|
||||||
|
text: raw?.text ?? defaultCapabilities.text,
|
||||||
|
images: raw?.images ?? defaultCapabilities.images,
|
||||||
|
files: raw?.files ?? defaultCapabilities.files,
|
||||||
|
voice: raw?.voice ?? defaultCapabilities.voice,
|
||||||
|
reactions: raw?.reactions ?? defaultCapabilities.reactions,
|
||||||
|
threads: raw?.threads ?? defaultCapabilities.threads,
|
||||||
|
typing: raw?.typing ?? defaultCapabilities.typing,
|
||||||
|
};
|
||||||
|
}
|
||||||
301
src/websocket-server.ts
Normal file
301
src/websocket-server.ts
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
/**
|
||||||
|
* WebSocket server implementation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { WebSocket as WebSocketType } from "ws";
|
||||||
|
import type { WebSocketAccount, ChannelCapabilities } from "./config.ts";
|
||||||
|
|
||||||
|
// Message types for WebSocket protocol
|
||||||
|
export type InboundMessage = {
|
||||||
|
type: "message";
|
||||||
|
accountId?: string;
|
||||||
|
from: string;
|
||||||
|
content: MessageContent;
|
||||||
|
timestamp?: number;
|
||||||
|
threadId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OutboundMessage = {
|
||||||
|
type: "message";
|
||||||
|
to: string;
|
||||||
|
content: MessageContent;
|
||||||
|
timestamp: number;
|
||||||
|
threadId?: string;
|
||||||
|
messageId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HandshakeMessage = {
|
||||||
|
type: "handshake";
|
||||||
|
accountId: string | null;
|
||||||
|
capabilities: ChannelCapabilities;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
timestamp: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TypingIndicator = {
|
||||||
|
type: "typing";
|
||||||
|
to: string;
|
||||||
|
isTyping: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PairingCodeMessage = {
|
||||||
|
type: "pairing_code";
|
||||||
|
code: string;
|
||||||
|
target: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WebSocketMessage =
|
||||||
|
| InboundMessage
|
||||||
|
| OutboundMessage
|
||||||
|
| HandshakeMessage
|
||||||
|
| TypingIndicator
|
||||||
|
| PairingCodeMessage;
|
||||||
|
|
||||||
|
export type MessageContent =
|
||||||
|
| { type: "text"; text: string }
|
||||||
|
| { type: "image"; url: string; alt?: string }
|
||||||
|
| { type: "file"; url: string; name: string; size?: number }
|
||||||
|
| { type: "voice"; url: string; duration?: number };
|
||||||
|
|
||||||
|
// Connected client representation
|
||||||
|
export type ConnectedClient = {
|
||||||
|
ws: WebSocketType;
|
||||||
|
account: WebSocketAccount;
|
||||||
|
userId?: string;
|
||||||
|
connectedAt: Date;
|
||||||
|
lastPingAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MessageHandler = (message: InboundMessage, client: ConnectedClient) => Promise<void> | void;
|
||||||
|
|
||||||
|
export class WebSocketServerManager {
|
||||||
|
private clients = new Map<WebSocketType, ConnectedClient>();
|
||||||
|
private messageHandler?: MessageHandler;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a handler for incoming messages
|
||||||
|
*/
|
||||||
|
onMessage(handler: MessageHandler): void {
|
||||||
|
this.messageHandler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new connected client
|
||||||
|
*/
|
||||||
|
addClient(ws: WebSocketType, account: WebSocketAccount): ConnectedClient {
|
||||||
|
const client: ConnectedClient = {
|
||||||
|
ws,
|
||||||
|
account,
|
||||||
|
connectedAt: new Date(),
|
||||||
|
lastPingAt: new Date(),
|
||||||
|
};
|
||||||
|
this.clients.set(ws, client);
|
||||||
|
|
||||||
|
// Set up message handler
|
||||||
|
ws.on("message", async (data) => {
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(data.toString()) as InboundMessage;
|
||||||
|
if (this.messageHandler) {
|
||||||
|
await this.messageHandler(message, client);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to parse WebSocket message:", err);
|
||||||
|
this.sendError(ws, "Invalid message format");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle client disconnect
|
||||||
|
ws.on("close", () => {
|
||||||
|
this.clients.delete(ws);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle pong (keep-alive)
|
||||||
|
ws.on("pong", () => {
|
||||||
|
const c = this.clients.get(ws);
|
||||||
|
if (c) {
|
||||||
|
c.lastPingAt = new Date();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send handshake
|
||||||
|
this.sendHandshake(client);
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send handshake message to client
|
||||||
|
*/
|
||||||
|
private sendHandshake(client: ConnectedClient): void {
|
||||||
|
const handshake: HandshakeMessage = {
|
||||||
|
type: "handshake",
|
||||||
|
accountId: client.account.accountId,
|
||||||
|
capabilities: client.account.capabilities,
|
||||||
|
metadata: client.account.metadata || {},
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
this.sendToClient(client.ws, handshake);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a message to a specific client
|
||||||
|
*/
|
||||||
|
private sendToClient(ws: WebSocketType, message: WebSocketMessage): boolean {
|
||||||
|
if (ws.readyState === 1) { // WebSocket.OPEN
|
||||||
|
ws.send(JSON.stringify(message));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send error message to client
|
||||||
|
*/
|
||||||
|
sendError(ws: WebSocketType, error: string): void {
|
||||||
|
if (ws.readyState === 1) {
|
||||||
|
ws.send(JSON.stringify({ type: "error", error }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a text message to a user
|
||||||
|
*/
|
||||||
|
async sendText(to: string, text: string, account: WebSocketAccount, threadId?: string): Promise<{ messageId: string }> {
|
||||||
|
const messageId = `ws-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
const outbound: OutboundMessage = {
|
||||||
|
type: "message",
|
||||||
|
to,
|
||||||
|
content: { type: "text", text },
|
||||||
|
timestamp: Date.now(),
|
||||||
|
threadId,
|
||||||
|
messageId,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Send to all connections for this account
|
||||||
|
let sent = false;
|
||||||
|
for (const [ws, client] of this.clients) {
|
||||||
|
if (client.account.accountId === account.accountId) {
|
||||||
|
if (this.sendToClient(ws, outbound)) {
|
||||||
|
sent = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sent) {
|
||||||
|
throw new Error(`No active connections for account ${account.accountId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { messageId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send media (file) to a user
|
||||||
|
*/
|
||||||
|
async sendMedia(to: string, filePath: string, account: WebSocketAccount, threadId?: string): Promise<void> {
|
||||||
|
const outbound: OutboundMessage = {
|
||||||
|
type: "message",
|
||||||
|
to,
|
||||||
|
content: { type: "file", url: filePath, name: filePath.split("/").pop() || "file" },
|
||||||
|
timestamp: Date.now(),
|
||||||
|
threadId,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Send to all connections for this account
|
||||||
|
for (const [ws, client] of this.clients) {
|
||||||
|
if (client.account.accountId === account.accountId) {
|
||||||
|
this.sendToClient(ws, outbound);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send typing indicator
|
||||||
|
*/
|
||||||
|
async sendTyping(to: string, isTyping: boolean, account: WebSocketAccount): Promise<void> {
|
||||||
|
const typing: TypingIndicator = {
|
||||||
|
type: "typing",
|
||||||
|
to,
|
||||||
|
isTyping,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [ws, client] of this.clients) {
|
||||||
|
if (client.account.accountId === account.accountId) {
|
||||||
|
this.sendToClient(ws, typing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send pairing code to a user
|
||||||
|
*/
|
||||||
|
async sendPairingCode(target: string, code: string, account: WebSocketAccount): Promise<void> {
|
||||||
|
const pairingMsg: PairingCodeMessage = {
|
||||||
|
type: "pairing_code",
|
||||||
|
code,
|
||||||
|
target,
|
||||||
|
message: `Send this code to verify your identity: ${code}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [ws, client] of this.clients) {
|
||||||
|
if (client.account.accountId === account.accountId) {
|
||||||
|
this.sendToClient(ws, pairingMsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get connection count for an account
|
||||||
|
*/
|
||||||
|
getConnectionCount(accountId: string | null): number {
|
||||||
|
let count = 0;
|
||||||
|
for (const client of this.clients.values()) {
|
||||||
|
if (client.account.accountId === accountId) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all active connections
|
||||||
|
*/
|
||||||
|
getConnections(): ConnectedClient[] {
|
||||||
|
return Array.from(this.clients.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up disconnected clients
|
||||||
|
*/
|
||||||
|
cleanup(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [ws, client] of this.clients) {
|
||||||
|
// Remove clients that haven't responded to ping in 60 seconds
|
||||||
|
if (now - client.lastPingAt.getTime() > 60000) {
|
||||||
|
ws.terminate();
|
||||||
|
this.clients.delete(ws);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start periodic cleanup interval
|
||||||
|
*/
|
||||||
|
startCleanupInterval(intervalMs = 30000): ReturnType<typeof setInterval> {
|
||||||
|
return setInterval(() => this.cleanup(), intervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send ping to all clients
|
||||||
|
*/
|
||||||
|
pingAll(): void {
|
||||||
|
for (const ws of this.clients.keys()) {
|
||||||
|
if (ws.readyState === 1) {
|
||||||
|
ws.ping();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
export const wsManager = new WebSocketServerManager();
|
||||||
Reference in New Issue
Block a user