clawrelay 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,88 @@
1
+ # clawrelay
2
+
3
+ OpenClaw channel plugin that receives forwarded messages from an always-on relay proxy (ClawRelay), dispatches them through the agent pipeline, and sends responses back over WebSocket.
4
+
5
+ ## How it works
6
+
7
+ 1. Plugin starts an HTTP + WebSocket server on a configurable port (default `7600`)
8
+ 2. The relay service connects via WebSocket at `/relay/ws` with Bearer token auth
9
+ 3. Inbound messages are dispatched through the OpenClaw agent pipeline
10
+ 4. Agent responses are sent back to the relay service over the same WebSocket connection
11
+
12
+ ## Installation
13
+
14
+ Install from npm:
15
+
16
+ ```bash
17
+ openclaw plugins install clawrelay
18
+ ```
19
+
20
+ Or install from a local path during development:
21
+
22
+ ```bash
23
+ openclaw plugins install ./packages/relay-channel
24
+ ```
25
+
26
+ Or install from a tarball:
27
+
28
+ ```bash
29
+ cd packages/relay-channel && npm pack
30
+ openclaw plugins install clawrelay-0.1.0.tgz
31
+ ```
32
+
33
+ ## Gateway configuration
34
+
35
+ Add to `~/.openclaw/openclaw.json`:
36
+
37
+ ```json
38
+ {
39
+ "channels": {
40
+ "relay": {
41
+ "accounts": {
42
+ "default": {
43
+ "authToken": "shared-secret",
44
+ "port": 7600
45
+ }
46
+ }
47
+ }
48
+ },
49
+ "plugins": {
50
+ "allow": ["clawrelay"],
51
+ "entries": {
52
+ "clawrelay": { "enabled": true }
53
+ }
54
+ }
55
+ }
56
+ ```
57
+
58
+ The `authToken` must match the relay service's `SANDBOX_AUTH_TOKEN`.
59
+
60
+ ## Verify installation
61
+
62
+ ```bash
63
+ openclaw plugins list
64
+ openclaw plugins info clawrelay
65
+ openclaw plugins doctor
66
+ ```
67
+
68
+ ## Endpoints
69
+
70
+ | Method | Path | Description |
71
+ |---|---|---|
72
+ | GET | `/relay/health` | Health check — returns `{"status":"ok","ready":true}` |
73
+ | WS | `/relay/ws` | WebSocket endpoint for relay communication (Bearer auth required) |
74
+
75
+ ## Source files
76
+
77
+ | File | Purpose |
78
+ |---|---|
79
+ | `src/index.ts` | Plugin entry point and registration |
80
+ | `src/channel.ts` | Channel definition, account lifecycle, inbound message handler |
81
+ | `src/inbound-server.ts` | HTTP + WebSocket server |
82
+ | `src/runtime.ts` | OpenClaw runtime accessor |
83
+ | `src/types.ts` | Protocol and config types |
84
+
85
+ ## Links
86
+
87
+ - [ClawRelay](https://github.com/kylemclaren/clawrelay) — relay proxy + plugin monorepo
88
+ - [OpenClaw Plugin Docs](https://docs.openclaw.ai/tools/plugin#plugins)
@@ -0,0 +1,9 @@
1
+ {
2
+ "id": "clawrelay",
3
+ "channels": ["relay"],
4
+ "configSchema": {
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "properties": {}
8
+ }
9
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "clawrelay",
3
+ "version": "0.1.0",
4
+ "description": "Channel relay plugin for OpenClaw — receives messages from an always-on relay proxy",
5
+ "main": "src/index.ts",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/kylemclaren/clawrelay.git",
10
+ "directory": "packages/relay-channel"
11
+ },
12
+ "openclaw": {
13
+ "extensions": [
14
+ "./src/index.ts"
15
+ ],
16
+ "channel": {
17
+ "id": "relay",
18
+ "label": "Channel Relay",
19
+ "selectionLabel": "Relay (Discord/Telegram via proxy)",
20
+ "docsPath": "/channels/relay",
21
+ "blurb": "Wake-on-message proxy for Discord/Telegram via always-on relay",
22
+ "aliases": ["relay"]
23
+ },
24
+ "install": {
25
+ "npmSpec": "clawrelay",
26
+ "localPath": "extensions/relay-channel",
27
+ "defaultChoice": "npm"
28
+ }
29
+ },
30
+ "keywords": [
31
+ "openclaw",
32
+ "openclaw-plugin",
33
+ "relay",
34
+ "channel",
35
+ "discord",
36
+ "telegram",
37
+ "proxy"
38
+ ],
39
+ "author": "Snowy Road",
40
+ "license": "MIT",
41
+ "dependencies": {
42
+ "ws": "^8.18.0"
43
+ },
44
+ "devDependencies": {
45
+ "typescript": "^5.3.0"
46
+ },
47
+ "scripts": {
48
+ "test": "echo \"No tests yet\""
49
+ }
50
+ }
package/src/channel.ts ADDED
@@ -0,0 +1,294 @@
1
+ // Relay Channel Plugin Definition
2
+
3
+ import type { RelayAccount, RelayInboundMessage, RelayOutboundMessage, ReplyFn } from './types.js';
4
+ import { InboundServer } from './inbound-server.js';
5
+ import { getRelayRuntime } from './runtime.js';
6
+
7
+ const CHANNEL_ID = 'relay' as const;
8
+
9
+ // Store active inbound servers per account
10
+ const servers = new Map<string, InboundServer>();
11
+
12
+ export function createRelayChannel(api: any) {
13
+ const logger = api.logger;
14
+
15
+ const channel = {
16
+ id: CHANNEL_ID,
17
+
18
+ meta: {
19
+ id: CHANNEL_ID,
20
+ label: 'Channel Relay',
21
+ selectionLabel: 'Relay (Discord/Telegram via proxy)',
22
+ docsPath: '/channels/relay',
23
+ blurb: 'Wake-on-message proxy for Discord/Telegram via always-on relay',
24
+ aliases: ['relay'],
25
+ },
26
+
27
+ capabilities: {
28
+ chatTypes: ['direct', 'group'],
29
+ media: {
30
+ images: false,
31
+ audio: false,
32
+ video: false,
33
+ documents: false,
34
+ },
35
+ reactions: false,
36
+ threads: false,
37
+ mentions: false,
38
+ },
39
+
40
+ config: {
41
+ listAccountIds: (cfg: any) => {
42
+ return Object.keys(cfg.channels?.relay?.accounts ?? {});
43
+ },
44
+
45
+ resolveAccount: (cfg: any, accountId?: string): RelayAccount | undefined => {
46
+ const accounts = cfg.channels?.relay?.accounts ?? {};
47
+ const id = accountId ?? 'default';
48
+ const account = accounts[id];
49
+ if (!account) return undefined;
50
+
51
+ return {
52
+ accountId: id,
53
+ authToken: account.authToken,
54
+ port: account.port ?? 7600,
55
+ enabled: account.enabled !== false,
56
+ };
57
+ },
58
+ },
59
+
60
+ gateway: {
61
+ startAccount: async (ctx: any) => {
62
+ const account = ctx.account as RelayAccount;
63
+ const accountId = account.accountId ?? 'default';
64
+
65
+ logger.info(`[clawrelay] startAccount called for ${accountId}`);
66
+
67
+ if (!account.enabled) {
68
+ logger.info(`[clawrelay] Account ${accountId} not enabled, skipping`);
69
+ return;
70
+ }
71
+
72
+ if (servers.has(accountId)) {
73
+ logger.warn(`[clawrelay] Server already running for ${accountId}, skipping`);
74
+ return;
75
+ }
76
+
77
+ const server = new InboundServer({
78
+ port: account.port,
79
+ authToken: account.authToken,
80
+ logger: ctx.log ?? logger,
81
+ onMessage: (message: RelayInboundMessage, reply: ReplyFn) => {
82
+ handleRelayInbound({
83
+ message,
84
+ reply,
85
+ account,
86
+ config: ctx.cfg,
87
+ log: ctx.log ?? logger,
88
+ }).catch((err) => {
89
+ const errMsg = err instanceof Error ? err.message : String(err);
90
+ (ctx.log ?? logger).error(`[clawrelay] Failed to process inbound: ${errMsg}`);
91
+ // Best-effort error response over WS
92
+ reply({
93
+ messageId: message.messageId,
94
+ content: `[Relay Plugin] Error processing message: ${errMsg}`,
95
+ replyToMessageId: message.messageId,
96
+ });
97
+ });
98
+ },
99
+ });
100
+
101
+ servers.set(accountId, server);
102
+
103
+ // Listen for abort signal
104
+ if (ctx.abortSignal) {
105
+ ctx.abortSignal.addEventListener('abort', () => {
106
+ (ctx.log ?? logger).info(`[clawrelay] Received abort signal for ${accountId}`);
107
+ const srv = servers.get(accountId);
108
+ if (srv) {
109
+ srv.stop();
110
+ servers.delete(accountId);
111
+ }
112
+ }, { once: true });
113
+ }
114
+
115
+ try {
116
+ await server.start();
117
+ (ctx.log ?? logger).info(`[clawrelay] Server started for ${accountId} on port ${account.port}`);
118
+ } catch (err) {
119
+ (ctx.log ?? logger).error(`[clawrelay] Failed to start server for ${accountId}: ${err}`);
120
+ servers.delete(accountId);
121
+ }
122
+ },
123
+
124
+ stopAccount: async (ctx: any) => {
125
+ const accountId = ctx.account?.accountId ?? 'default';
126
+ const server = servers.get(accountId);
127
+ if (server) {
128
+ await server.stop();
129
+ servers.delete(accountId);
130
+ (ctx.log ?? logger).info(`[clawrelay] Server stopped for ${accountId}`);
131
+ }
132
+ },
133
+ },
134
+
135
+ outbound: {
136
+ deliveryMode: 'direct' as const,
137
+
138
+ sendText: async ({
139
+ text,
140
+ chatId,
141
+ accountId,
142
+ cfg,
143
+ }: {
144
+ text: string;
145
+ chatId: string;
146
+ accountId?: string;
147
+ cfg: any;
148
+ }) => {
149
+ // Outbound via sendText is not the primary path for relay-channel.
150
+ // The main response path is via WebSocket reply.
151
+ // This exists for completeness if OpenClaw core needs to send proactively.
152
+ logger.warn(`[clawrelay] sendText called for chatId=${chatId} — relay-channel uses WS for responses`);
153
+ return { ok: false, error: 'Relay channel uses WebSocket for responses, not sendText' };
154
+ },
155
+ },
156
+
157
+ status: {
158
+ getHealth: (accountId: string) => {
159
+ const server = servers.get(accountId);
160
+ if (!server) {
161
+ return { status: 'disconnected', message: 'Server not running' };
162
+ }
163
+ return { status: 'connected', message: 'Server listening (HTTP + WS)' };
164
+ },
165
+ },
166
+ };
167
+
168
+ return channel;
169
+ }
170
+
171
+
172
+ // --- Inbound message handler using OpenClaw runtime ---
173
+
174
+ function resolveSessionKey(message: RelayInboundMessage): string {
175
+ if (message.chatType === 'direct') {
176
+ return `relay:${message.platform}:dm:${message.senderId}`;
177
+ }
178
+ return `relay:${message.platform}:${message.guildId ?? 'unknown'}:${message.channelId}`;
179
+ }
180
+
181
+ function resolvePeerId(message: RelayInboundMessage): string {
182
+ if (message.chatType === 'direct') {
183
+ return `relay:${message.platform}:dm:${message.senderId}`;
184
+ }
185
+ return `relay:${message.platform}:${message.guildId ?? 'unknown'}:${message.channelId}`;
186
+ }
187
+
188
+ async function handleRelayInbound(params: {
189
+ message: RelayInboundMessage;
190
+ reply: ReplyFn;
191
+ account: RelayAccount;
192
+ config: any;
193
+ log: any;
194
+ }): Promise<void> {
195
+ const { message, reply, account, config, log } = params;
196
+
197
+ let core;
198
+ try {
199
+ core = getRelayRuntime();
200
+ } catch (err) {
201
+ log?.error(`[clawrelay] Runtime not initialized: ${err}`);
202
+ return;
203
+ }
204
+
205
+ const peerId = resolvePeerId(message);
206
+
207
+ // Resolve agent route
208
+ const route = core.channel.routing.resolveAgentRoute({
209
+ cfg: config,
210
+ channel: CHANNEL_ID,
211
+ accountId: account.accountId,
212
+ peer: {
213
+ kind: message.chatType === 'direct' ? 'direct' : 'group',
214
+ id: peerId,
215
+ },
216
+ });
217
+
218
+ // Resolve session store path
219
+ const storePath = core.channel.session.resolveStorePath(config.session?.store, {
220
+ agentId: route.agentId,
221
+ });
222
+
223
+ // Format envelope
224
+ const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
225
+ const previousTimestamp = core.channel.session.readSessionUpdatedAt({
226
+ storePath,
227
+ sessionKey: route.sessionKey,
228
+ });
229
+
230
+ const fromLabel = `${message.platform}:${message.senderName}`;
231
+ const body = core.channel.reply.formatAgentEnvelope({
232
+ channel: `relay:${message.platform}`,
233
+ from: fromLabel,
234
+ timestamp: message.timestamp,
235
+ previousTimestamp,
236
+ envelope: envelopeOptions,
237
+ body: message.content,
238
+ });
239
+
240
+ // Build finalized message context
241
+ const ctxPayload = core.channel.reply.finalizeInboundContext({
242
+ Body: body,
243
+ BodyForAgent: body,
244
+ RawBody: message.content,
245
+ CommandBody: message.content,
246
+ From: peerId,
247
+ To: peerId,
248
+ SessionKey: route.sessionKey,
249
+ AccountId: route.accountId,
250
+ ChatType: message.chatType,
251
+ ConversationLabel: fromLabel,
252
+ SenderName: message.senderName,
253
+ SenderId: message.senderId,
254
+ GroupSubject: message.groupName ?? message.channelId,
255
+ Provider: CHANNEL_ID,
256
+ Surface: CHANNEL_ID,
257
+ Timestamp: message.timestamp,
258
+ OriginatingChannel: CHANNEL_ID,
259
+ OriginatingTo: peerId,
260
+ });
261
+
262
+ // Record inbound session
263
+ await core.channel.session.recordInboundSession({
264
+ storePath,
265
+ sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
266
+ ctx: ctxPayload,
267
+ onRecordError: (err: unknown) => {
268
+ log?.error(`[clawrelay] Failed updating session meta: ${String(err)}`);
269
+ },
270
+ });
271
+
272
+ // Dispatch reply — triggers agent processing and delivers response via WS
273
+ await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
274
+ ctx: ctxPayload,
275
+ cfg: config,
276
+ dispatcherOptions: {
277
+ deliver: async (payload: { text?: string }) => {
278
+ const text = payload.text ?? '';
279
+ if (!text.trim()) return;
280
+
281
+ reply({
282
+ messageId: message.messageId,
283
+ content: text,
284
+ replyToMessageId: message.messageId,
285
+ });
286
+ },
287
+ onError: (err: unknown, info: { kind: string }) => {
288
+ log?.error(`[clawrelay] ${info.kind} reply failed: ${String(err)}`);
289
+ },
290
+ },
291
+ });
292
+
293
+ log?.info(`[clawrelay] Processed message ${message.messageId} from ${message.senderName}`);
294
+ }
@@ -0,0 +1,144 @@
1
+ // Relay Server - HTTP health + WebSocket server for relay communication
2
+
3
+ import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'node:http';
4
+ import { WebSocketServer, WebSocket } from 'ws';
5
+ import type { RelayInboundMessage, RelayOutboundMessage, WsEnvelope, ReplyFn } from './types.js';
6
+
7
+ export interface InboundServerOptions {
8
+ port: number;
9
+ authToken: string;
10
+ onMessage: (message: RelayInboundMessage, reply: ReplyFn) => void;
11
+ logger?: any;
12
+ }
13
+
14
+ export class InboundServer {
15
+ private server: Server | null = null;
16
+ private wss: WebSocketServer | null = null;
17
+ private options: InboundServerOptions;
18
+
19
+ constructor(options: InboundServerOptions) {
20
+ this.options = options;
21
+ }
22
+
23
+ start(): Promise<void> {
24
+ return new Promise((resolve, reject) => {
25
+ const server = createServer((req, res) => this.handleRequest(req, res));
26
+
27
+ // Create WebSocket server attached to the HTTP server
28
+ const wss = new WebSocketServer({ noServer: true });
29
+
30
+ wss.on('connection', (ws) => {
31
+ this.options.logger?.info('[clawrelay] WebSocket client connected');
32
+
33
+ ws.on('message', (data) => {
34
+ try {
35
+ const envelope: WsEnvelope = JSON.parse(data.toString());
36
+
37
+ if (envelope.type === 'message') {
38
+ const message = envelope.payload as RelayInboundMessage;
39
+
40
+ if (!message.messageId || !message.content) {
41
+ this.options.logger?.warn('[clawrelay] WS message missing required fields');
42
+ return;
43
+ }
44
+
45
+ // Create reply function that sends response back over this WS connection
46
+ const reply: ReplyFn = (response: RelayOutboundMessage) => {
47
+ if (ws.readyState === WebSocket.OPEN) {
48
+ const responseEnvelope: WsEnvelope = { type: 'response', payload: response };
49
+ ws.send(JSON.stringify(responseEnvelope));
50
+ } else {
51
+ this.options.logger?.error(`[clawrelay] Cannot reply — WS not open (state=${ws.readyState})`);
52
+ }
53
+ };
54
+
55
+ this.options.onMessage(message, reply);
56
+ } else {
57
+ this.options.logger?.warn(`[clawrelay] Unexpected WS message type: ${(envelope as any).type}`);
58
+ }
59
+ } catch (err) {
60
+ this.options.logger?.error(`[clawrelay] Failed to parse WS message: ${err}`);
61
+ }
62
+ });
63
+
64
+ ws.on('close', (code, reason) => {
65
+ this.options.logger?.info(`[clawrelay] WebSocket client disconnected: ${code} ${reason.toString()}`);
66
+ });
67
+
68
+ ws.on('error', (err) => {
69
+ this.options.logger?.error(`[clawrelay] WebSocket error: ${err.message}`);
70
+ });
71
+ });
72
+
73
+ // Handle HTTP upgrade for WebSocket connections
74
+ server.on('upgrade', (req, socket, head) => {
75
+ const url = req.url ?? '';
76
+
77
+ if (url !== '/relay/ws') {
78
+ socket.destroy();
79
+ return;
80
+ }
81
+
82
+ // Verify auth token
83
+ const authHeader = req.headers['authorization'];
84
+ const expected = `Bearer ${this.options.authToken}`;
85
+ if (authHeader !== expected) {
86
+ this.options.logger?.warn('[clawrelay] WS upgrade rejected: invalid auth');
87
+ socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
88
+ socket.destroy();
89
+ return;
90
+ }
91
+
92
+ wss.handleUpgrade(req, socket, head, (ws) => {
93
+ wss.emit('connection', ws, req);
94
+ });
95
+ });
96
+
97
+ server.on('error', reject);
98
+ server.listen(this.options.port, () => {
99
+ this.server = server;
100
+ this.wss = wss;
101
+ this.options.logger?.info(`[clawrelay] Server listening on port ${this.options.port} (HTTP + WS)`);
102
+ resolve();
103
+ });
104
+ });
105
+ }
106
+
107
+ stop(): Promise<void> {
108
+ return new Promise((resolve) => {
109
+ // Close all WebSocket connections
110
+ if (this.wss) {
111
+ for (const client of this.wss.clients) {
112
+ client.close();
113
+ }
114
+ this.wss.close();
115
+ this.wss = null;
116
+ }
117
+
118
+ if (!this.server) {
119
+ resolve();
120
+ return;
121
+ }
122
+ this.server.close(() => {
123
+ this.server = null;
124
+ this.options.logger?.info('[clawrelay] Server stopped');
125
+ resolve();
126
+ });
127
+ });
128
+ }
129
+
130
+ private handleRequest(req: IncomingMessage, res: ServerResponse) {
131
+ if (req.method === 'GET' && req.url === '/relay/health') {
132
+ res.writeHead(200, { 'Content-Type': 'application/json' });
133
+ res.end(JSON.stringify({
134
+ status: 'ok',
135
+ ready: true,
136
+ wsClients: this.wss?.clients.size ?? 0,
137
+ }));
138
+ return;
139
+ }
140
+
141
+ res.writeHead(404, { 'Content-Type': 'application/json' });
142
+ res.end(JSON.stringify({ error: 'Not found' }));
143
+ }
144
+ }
package/src/index.ts ADDED
@@ -0,0 +1,33 @@
1
+ // clawrelay - Channel Relay Plugin
2
+ //
3
+ // Receives forwarded messages from an always-on relay service and processes
4
+ // them through the standard OpenClaw channel pipeline.
5
+
6
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
7
+ import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
8
+ import { createRelayChannel } from './channel.js';
9
+ import { setRelayRuntime } from './runtime.js';
10
+
11
+ const plugin = {
12
+ id: 'clawrelay',
13
+ name: 'clawrelay',
14
+ description: 'Channel relay plugin — receives messages from an always-on relay proxy',
15
+ configSchema: emptyPluginConfigSchema(),
16
+
17
+ register(api: OpenClawPluginApi) {
18
+ const logger = api.logger;
19
+
20
+ logger.info('[clawrelay] Registering relay channel plugin');
21
+
22
+ // Store runtime for use across modules
23
+ setRelayRuntime(api.runtime);
24
+
25
+ // Create and register the channel
26
+ const channel = createRelayChannel(api);
27
+ api.registerChannel({ plugin: channel });
28
+
29
+ logger.info('[clawrelay] Relay channel plugin registered');
30
+ },
31
+ };
32
+
33
+ export default plugin;
package/src/runtime.ts ADDED
@@ -0,0 +1,16 @@
1
+ // Relay Channel Runtime - Store PluginRuntime reference for use across modules
2
+
3
+ import type { PluginRuntime } from "openclaw/plugin-sdk";
4
+
5
+ let runtime: PluginRuntime | null = null;
6
+
7
+ export function setRelayRuntime(next: PluginRuntime) {
8
+ runtime = next;
9
+ }
10
+
11
+ export function getRelayRuntime(): PluginRuntime {
12
+ if (!runtime) {
13
+ throw new Error("Relay runtime not initialized");
14
+ }
15
+ return runtime;
16
+ }
package/src/types.ts ADDED
@@ -0,0 +1,37 @@
1
+ // Relay Channel Protocol Types
2
+
3
+ export interface RelayAccount {
4
+ accountId: string;
5
+ authToken: string;
6
+ port: number;
7
+ enabled?: boolean;
8
+ }
9
+
10
+ // Inbound message from relay service -> plugin
11
+ export interface RelayInboundMessage {
12
+ messageId: string;
13
+ platform: string;
14
+ channelId: string;
15
+ guildId?: string;
16
+ senderId: string;
17
+ senderName: string;
18
+ content: string;
19
+ chatType: 'group' | 'direct';
20
+ groupName?: string;
21
+ timestamp: number;
22
+ }
23
+
24
+ // Outbound response from plugin -> relay service
25
+ export interface RelayOutboundMessage {
26
+ messageId: string;
27
+ content: string;
28
+ replyToMessageId?: string;
29
+ }
30
+
31
+ // WebSocket protocol envelope
32
+ export type WsEnvelope =
33
+ | { type: 'message'; payload: RelayInboundMessage }
34
+ | { type: 'response'; payload: RelayOutboundMessage };
35
+
36
+ // Reply function passed to message handlers
37
+ export type ReplyFn = (response: RelayOutboundMessage) => void;