clawrelay 0.1.0 → 0.3.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 CHANGED
@@ -1,13 +1,14 @@
1
1
  # clawrelay
2
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.
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 via the gateway protocol.
4
4
 
5
5
  ## How it works
6
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
7
+ 1. Plugin registers a `relay.inbound` gateway method and a `/relay/health` HTTP route on the gateway's existing server
8
+ 2. The relay service connects to the gateway via WebSocket and authenticates using the gateway protocol
9
+ 3. Inbound messages arrive as `relay.inbound` method calls
10
+ 4. Messages are dispatched through the OpenClaw agent pipeline
11
+ 5. Agent responses are returned in the gateway method response
11
12
 
12
13
  ## Installation
13
14
 
@@ -27,7 +28,7 @@ Or install from a tarball:
27
28
 
28
29
  ```bash
29
30
  cd packages/relay-channel && npm pack
30
- openclaw plugins install clawrelay-0.1.0.tgz
31
+ openclaw plugins install clawrelay-0.3.0.tgz
31
32
  ```
32
33
 
33
34
  ## Gateway configuration
@@ -40,8 +41,7 @@ Add to `~/.openclaw/openclaw.json`:
40
41
  "relay": {
41
42
  "accounts": {
42
43
  "default": {
43
- "authToken": "shared-secret",
44
- "port": 7600
44
+ "authToken": "optional-per-account-token"
45
45
  }
46
46
  }
47
47
  }
@@ -55,7 +55,7 @@ Add to `~/.openclaw/openclaw.json`:
55
55
  }
56
56
  ```
57
57
 
58
- The `authToken` must match the relay service's `SANDBOX_AUTH_TOKEN`.
58
+ The gateway's own auth token (in `gateway.auth.token`) is used for authentication. The relay account `authToken` is an optional per-account verification token.
59
59
 
60
60
  ## Verify installation
61
61
 
@@ -67,18 +67,20 @@ openclaw plugins doctor
67
67
 
68
68
  ## Endpoints
69
69
 
70
- | Method | Path | Description |
70
+ | Type | Path/Method | Description |
71
71
  |---|---|---|
72
- | GET | `/relay/health` | Health check — returns `{"status":"ok","ready":true}` |
73
- | WS | `/relay/ws` | WebSocket endpoint for relay communication (Bearer auth required) |
72
+ | HTTP | `GET /relay/health` | Health check — returns `{"status":"ok","ready":true}` |
73
+ | Gateway | `relay.inbound` | Gateway method for receiving relay messages |
74
74
 
75
75
  ## Source files
76
76
 
77
77
  | File | Purpose |
78
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 |
79
+ | `src/index.ts` | Plugin entry point, gateway method + HTTP route registration |
80
+ | `src/channel.ts` | Channel definition, config resolution, onboarding |
81
+ | `src/gateway-handler.ts` | `relay.inbound` gateway method handler |
82
+ | `src/health-handler.ts` | HTTP health route handler |
83
+ | `src/onboarding.ts` | Setup wizard (auth token, sprite service) |
82
84
  | `src/runtime.ts` | OpenClaw runtime accessor |
83
85
  | `src/types.ts` | Protocol and config types |
84
86
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawrelay",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Channel relay plugin for OpenClaw — receives messages from an always-on relay proxy",
5
5
  "main": "src/index.ts",
6
6
  "type": "module",
@@ -38,9 +38,6 @@
38
38
  ],
39
39
  "author": "Snowy Road",
40
40
  "license": "MIT",
41
- "dependencies": {
42
- "ws": "^8.18.0"
43
- },
44
41
  "devDependencies": {
45
42
  "typescript": "^5.3.0"
46
43
  },
package/src/channel.ts CHANGED
@@ -1,14 +1,14 @@
1
1
  // Relay Channel Plugin Definition
2
+ //
3
+ // The relay channel no longer runs its own server. Inbound messages arrive via
4
+ // the `relay.inbound` gateway method registered in index.ts. This file defines
5
+ // the channel metadata, config resolution, and onboarding adapter.
2
6
 
3
- import type { RelayAccount, RelayInboundMessage, RelayOutboundMessage, ReplyFn } from './types.js';
4
- import { InboundServer } from './inbound-server.js';
5
- import { getRelayRuntime } from './runtime.js';
7
+ import type { RelayAccount } from './types.js';
8
+ import { relayOnboardingAdapter } from './onboarding.js';
6
9
 
7
10
  const CHANNEL_ID = 'relay' as const;
8
11
 
9
- // Store active inbound servers per account
10
- const servers = new Map<string, InboundServer>();
11
-
12
12
  export function createRelayChannel(api: any) {
13
13
  const logger = api.logger;
14
14
 
@@ -24,6 +24,8 @@ export function createRelayChannel(api: any) {
24
24
  aliases: ['relay'],
25
25
  },
26
26
 
27
+ onboarding: relayOnboardingAdapter,
28
+
27
29
  capabilities: {
28
30
  chatTypes: ['direct', 'group'],
29
31
  media: {
@@ -51,7 +53,6 @@ export function createRelayChannel(api: any) {
51
53
  return {
52
54
  accountId: id,
53
55
  authToken: account.authToken,
54
- port: account.port ?? 7600,
55
56
  enabled: account.enabled !== false,
56
57
  };
57
58
  },
@@ -61,74 +62,12 @@ export function createRelayChannel(api: any) {
61
62
  startAccount: async (ctx: any) => {
62
63
  const account = ctx.account as RelayAccount;
63
64
  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
- }
65
+ logger.info(`[clawrelay] Account ${accountId} started (relay.inbound via gateway)`);
122
66
  },
123
67
 
124
68
  stopAccount: async (ctx: any) => {
125
69
  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
- }
70
+ logger.info(`[clawrelay] Account ${accountId} stopped`);
132
71
  },
133
72
  },
134
73
 
@@ -146,149 +85,17 @@ export function createRelayChannel(api: any) {
146
85
  accountId?: string;
147
86
  cfg: any;
148
87
  }) => {
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' };
88
+ logger.warn(`[clawrelay] sendText called for chatId=${chatId} relay-channel uses gateway method for responses`);
89
+ return { ok: false, error: 'Relay channel uses gateway method for responses, not sendText' };
154
90
  },
155
91
  },
156
92
 
157
93
  status: {
158
94
  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)' };
95
+ return { status: 'connected', message: 'Listening via gateway method (relay.inbound)' };
164
96
  },
165
97
  },
166
98
  };
167
99
 
168
100
  return channel;
169
101
  }
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,204 @@
1
+ // Gateway method handler for relay.inbound
2
+ //
3
+ // Registered as a gateway method so the relay service can call it via the
4
+ // gateway WS protocol instead of needing a standalone HTTP+WS server.
5
+
6
+ import type { RelayAccount, RelayInboundMessage } from './types.js';
7
+ import { getRelayRuntime } from './runtime.js';
8
+
9
+ const CHANNEL_ID = 'relay' as const;
10
+
11
+ /**
12
+ * Creates the `relay.inbound` gateway method handler.
13
+ * The handler receives params `{ accountId?, message }`, processes the message
14
+ * through the agent pipeline, and responds with the agent's reply.
15
+ */
16
+ export function createRelayInboundHandler(api: any) {
17
+ const logger = api.logger;
18
+
19
+ return async (opts: {
20
+ req: any;
21
+ params: Record<string, unknown>;
22
+ client: any;
23
+ respond: (ok: boolean, payload?: unknown, error?: unknown) => void;
24
+ context: any;
25
+ }) => {
26
+ const { params, respond } = opts;
27
+
28
+ const message = params.message as RelayInboundMessage | undefined;
29
+ if (!message || !message.messageId || !message.content) {
30
+ respond(false, undefined, {
31
+ code: 'INVALID_PARAMS',
32
+ message: 'Missing or invalid "message" in params',
33
+ });
34
+ return;
35
+ }
36
+
37
+ // Resolve the account — use accountId from params or fall back to default
38
+ const accountId = (params.accountId as string) ?? 'default';
39
+ const config = api.runtime.config.load();
40
+ const accounts = config.channels?.relay?.accounts ?? {};
41
+ const accountData = accounts[accountId] ?? config.channels?.relay;
42
+
43
+ if (!accountData) {
44
+ respond(false, undefined, {
45
+ code: 'NOT_FOUND',
46
+ message: `Relay account "${accountId}" not configured`,
47
+ });
48
+ return;
49
+ }
50
+
51
+ const account: RelayAccount = {
52
+ accountId,
53
+ authToken: accountData.authToken ?? '',
54
+ enabled: accountData.enabled !== false,
55
+ };
56
+
57
+ if (!account.enabled) {
58
+ respond(false, undefined, {
59
+ code: 'UNAVAILABLE',
60
+ message: `Relay account "${accountId}" is not enabled`,
61
+ });
62
+ return;
63
+ }
64
+
65
+ try {
66
+ const responseContent = await processRelayMessage({
67
+ message,
68
+ account,
69
+ config,
70
+ log: logger,
71
+ });
72
+
73
+ respond(true, {
74
+ messageId: message.messageId,
75
+ content: responseContent,
76
+ replyToMessageId: message.messageId,
77
+ });
78
+ } catch (err) {
79
+ const errMsg = err instanceof Error ? err.message : String(err);
80
+ logger.error(`[clawrelay] Failed to process inbound: ${errMsg}`);
81
+ respond(false, undefined, {
82
+ code: 'INTERNAL_ERROR',
83
+ message: `Error processing message: ${errMsg}`,
84
+ });
85
+ }
86
+ };
87
+ }
88
+
89
+
90
+ // --- Inbound message processing (extracted from channel.ts) ---
91
+
92
+ function resolveSessionKey(message: RelayInboundMessage): string {
93
+ if (message.chatType === 'direct') {
94
+ return `relay:${message.platform}:dm:${message.senderId}`;
95
+ }
96
+ return `relay:${message.platform}:${message.guildId ?? 'unknown'}:${message.channelId}`;
97
+ }
98
+
99
+ function resolvePeerId(message: RelayInboundMessage): string {
100
+ if (message.chatType === 'direct') {
101
+ return `relay:${message.platform}:dm:${message.senderId}`;
102
+ }
103
+ return `relay:${message.platform}:${message.guildId ?? 'unknown'}:${message.channelId}`;
104
+ }
105
+
106
+ async function processRelayMessage(params: {
107
+ message: RelayInboundMessage;
108
+ account: RelayAccount;
109
+ config: any;
110
+ log: any;
111
+ }): Promise<string> {
112
+ const { message, account, config, log } = params;
113
+
114
+ const core = getRelayRuntime();
115
+ const peerId = resolvePeerId(message);
116
+
117
+ // Resolve agent route
118
+ const route = core.channel.routing.resolveAgentRoute({
119
+ cfg: config,
120
+ channel: CHANNEL_ID,
121
+ accountId: account.accountId,
122
+ peer: {
123
+ kind: message.chatType === 'direct' ? 'direct' : 'group',
124
+ id: peerId,
125
+ },
126
+ });
127
+
128
+ // Resolve session store path
129
+ const storePath = core.channel.session.resolveStorePath(config.session?.store, {
130
+ agentId: route.agentId,
131
+ });
132
+
133
+ // Format envelope
134
+ const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
135
+ const previousTimestamp = core.channel.session.readSessionUpdatedAt({
136
+ storePath,
137
+ sessionKey: route.sessionKey,
138
+ });
139
+
140
+ const fromLabel = `${message.platform}:${message.senderName}`;
141
+ const body = core.channel.reply.formatAgentEnvelope({
142
+ channel: `relay:${message.platform}`,
143
+ from: fromLabel,
144
+ timestamp: message.timestamp,
145
+ previousTimestamp,
146
+ envelope: envelopeOptions,
147
+ body: message.content,
148
+ });
149
+
150
+ // Build finalized message context
151
+ const ctxPayload = core.channel.reply.finalizeInboundContext({
152
+ Body: body,
153
+ BodyForAgent: body,
154
+ RawBody: message.content,
155
+ CommandBody: message.content,
156
+ From: peerId,
157
+ To: peerId,
158
+ SessionKey: route.sessionKey,
159
+ AccountId: route.accountId,
160
+ ChatType: message.chatType,
161
+ ConversationLabel: fromLabel,
162
+ SenderName: message.senderName,
163
+ SenderId: message.senderId,
164
+ GroupSubject: message.groupName ?? message.channelId,
165
+ Provider: CHANNEL_ID,
166
+ Surface: CHANNEL_ID,
167
+ Timestamp: message.timestamp,
168
+ OriginatingChannel: CHANNEL_ID,
169
+ OriginatingTo: peerId,
170
+ });
171
+
172
+ // Record inbound session
173
+ await core.channel.session.recordInboundSession({
174
+ storePath,
175
+ sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
176
+ ctx: ctxPayload,
177
+ onRecordError: (err: unknown) => {
178
+ log?.error(`[clawrelay] Failed updating session meta: ${String(err)}`);
179
+ },
180
+ });
181
+
182
+ // Dispatch reply — collect all deliver() calls into a single buffer
183
+ const parts: string[] = [];
184
+
185
+ await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
186
+ ctx: ctxPayload,
187
+ cfg: config,
188
+ dispatcherOptions: {
189
+ deliver: async (payload: { text?: string }) => {
190
+ const text = payload.text ?? '';
191
+ if (text.trim()) {
192
+ parts.push(text);
193
+ }
194
+ },
195
+ onError: (err: unknown, info: { kind: string }) => {
196
+ log?.error(`[clawrelay] ${info.kind} reply failed: ${String(err)}`);
197
+ },
198
+ },
199
+ });
200
+
201
+ log?.info(`[clawrelay] Processed message ${message.messageId} from ${message.senderName}`);
202
+
203
+ return parts.join('\n');
204
+ }
@@ -0,0 +1,10 @@
1
+ // HTTP health route handler for /relay/health
2
+ //
3
+ // Registered on the gateway's HTTP server via api.registerHttpRoute.
4
+
5
+ import type { IncomingMessage, ServerResponse } from 'node:http';
6
+
7
+ export function relayHealthHandler(_req: IncomingMessage, res: ServerResponse): void {
8
+ res.writeHead(200, { 'Content-Type': 'application/json' });
9
+ res.end(JSON.stringify({ status: 'ok', ready: true }));
10
+ }
package/src/index.ts CHANGED
@@ -2,11 +2,17 @@
2
2
  //
3
3
  // Receives forwarded messages from an always-on relay service and processes
4
4
  // them through the standard OpenClaw channel pipeline.
5
+ //
6
+ // Instead of running a standalone HTTP+WS server, this plugin registers a
7
+ // gateway method (`relay.inbound`) and an HTTP health route (`/relay/health`)
8
+ // on the gateway's existing server.
5
9
 
6
10
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
7
11
  import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
8
12
  import { createRelayChannel } from './channel.js';
9
13
  import { setRelayRuntime } from './runtime.js';
14
+ import { createRelayInboundHandler } from './gateway-handler.js';
15
+ import { relayHealthHandler } from './health-handler.js';
10
16
 
11
17
  const plugin = {
12
18
  id: 'clawrelay',
@@ -26,7 +32,14 @@ const plugin = {
26
32
  const channel = createRelayChannel(api);
27
33
  api.registerChannel({ plugin: channel });
28
34
 
29
- logger.info('[clawrelay] Relay channel plugin registered');
35
+ // Register gateway method for relay.inbound calls
36
+ const relayInboundHandler = createRelayInboundHandler(api);
37
+ api.registerGatewayMethod('relay.inbound', relayInboundHandler);
38
+
39
+ // Register HTTP health route on the gateway's HTTP server
40
+ api.registerHttpRoute({ path: '/relay/health', handler: relayHealthHandler });
41
+
42
+ logger.info('[clawrelay] Relay channel plugin registered (gateway method + HTTP health)');
30
43
  },
31
44
  };
32
45
 
@@ -0,0 +1,271 @@
1
+ import type {
2
+ ChannelOnboardingAdapter,
3
+ OpenClawConfig,
4
+ WizardPrompter,
5
+ } from "openclaw/plugin-sdk";
6
+ import {
7
+ DEFAULT_ACCOUNT_ID,
8
+ normalizeAccountId,
9
+ promptAccountId,
10
+ } from "openclaw/plugin-sdk";
11
+ import crypto from "node:crypto";
12
+ import { execSync } from "node:child_process";
13
+
14
+ const channel = "relay" as const;
15
+
16
+ function generateAuthToken(): string {
17
+ const bytes = crypto.randomBytes(24);
18
+ return `crly_${bytes.toString("base64url")}`;
19
+ }
20
+
21
+ function setRelayAccountConfig(
22
+ cfg: OpenClawConfig,
23
+ accountId: string,
24
+ defaultPatch: Record<string, unknown>,
25
+ accountPatch: Record<string, unknown> = defaultPatch,
26
+ ): OpenClawConfig {
27
+ if (accountId === DEFAULT_ACCOUNT_ID) {
28
+ return {
29
+ ...cfg,
30
+ channels: {
31
+ ...cfg.channels,
32
+ relay: {
33
+ ...cfg.channels?.relay,
34
+ enabled: true,
35
+ ...defaultPatch,
36
+ },
37
+ },
38
+ } as OpenClawConfig;
39
+ }
40
+ return {
41
+ ...cfg,
42
+ channels: {
43
+ ...cfg.channels,
44
+ relay: {
45
+ ...cfg.channels?.relay,
46
+ enabled: true,
47
+ accounts: {
48
+ ...cfg.channels?.relay?.accounts,
49
+ [accountId]: {
50
+ ...cfg.channels?.relay?.accounts?.[accountId],
51
+ enabled: cfg.channels?.relay?.accounts?.[accountId]?.enabled ?? true,
52
+ ...accountPatch,
53
+ },
54
+ },
55
+ },
56
+ },
57
+ } as OpenClawConfig;
58
+ }
59
+
60
+ function listRelayAccountIds(cfg: OpenClawConfig): string[] {
61
+ return Object.keys((cfg.channels as any)?.relay?.accounts ?? {});
62
+ }
63
+
64
+ function resolveDefaultRelayAccountId(cfg: OpenClawConfig): string {
65
+ const ids = listRelayAccountIds(cfg);
66
+ if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID;
67
+ return ids[0] ?? DEFAULT_ACCOUNT_ID;
68
+ }
69
+
70
+ function getExistingToken(cfg: OpenClawConfig, accountId: string): string | undefined {
71
+ const relay = (cfg.channels as any)?.relay;
72
+ if (accountId === DEFAULT_ACCOUNT_ID) {
73
+ return relay?.authToken;
74
+ }
75
+ return relay?.accounts?.[accountId]?.authToken;
76
+ }
77
+
78
+ function isSpriteEnv(): boolean {
79
+ try {
80
+ execSync('which sprite-env', { stdio: 'ignore' });
81
+ return true;
82
+ } catch {
83
+ return false;
84
+ }
85
+ }
86
+
87
+ async function setupSpriteService(prompter: WizardPrompter): Promise<void> {
88
+ if (!isSpriteEnv()) return;
89
+
90
+ const setup = await prompter.confirm({
91
+ message: 'Sprite environment detected. Set up service to expose the gateway?',
92
+ initialValue: true,
93
+ });
94
+ if (!setup) return;
95
+
96
+ try {
97
+ // Check if a service already exists
98
+ const existing = execSync('sprite-env services list', { encoding: 'utf-8' });
99
+ if (existing.includes('openclaw')) {
100
+ await prompter.note(
101
+ 'An openclaw service already exists on this sprite.',
102
+ 'Sprite Service',
103
+ );
104
+ } else {
105
+ execSync('sprite-env services create openclaw-gateway --cmd openclaw --args start --http-port 18789');
106
+ await prompter.note(
107
+ 'Created sprite service: openclaw-gateway (port 18789)',
108
+ 'Sprite Service',
109
+ );
110
+ }
111
+
112
+ // Check URL auth
113
+ const setPublic = await prompter.confirm({
114
+ message: 'Set sprite URL auth to public? (required for relay to connect without org token)',
115
+ initialValue: true,
116
+ });
117
+ if (setPublic) {
118
+ execSync('sprite url update --auth public');
119
+ }
120
+ } catch (err) {
121
+ await prompter.note(
122
+ `Failed to configure sprite service: ${err}\nYou can set it up manually later.`,
123
+ 'Sprite Service',
124
+ );
125
+ }
126
+ }
127
+
128
+ export const relayOnboardingAdapter: ChannelOnboardingAdapter = {
129
+ channel,
130
+
131
+ getStatus: async ({ cfg }) => {
132
+ const ids = listRelayAccountIds(cfg);
133
+ const relay = (cfg.channels as any)?.relay;
134
+
135
+ // Check default account (top-level authToken) or any named account
136
+ let configured = Boolean(relay?.authToken);
137
+ if (!configured) {
138
+ for (const id of ids) {
139
+ if (relay?.accounts?.[id]?.authToken) {
140
+ configured = true;
141
+ break;
142
+ }
143
+ }
144
+ }
145
+
146
+ return {
147
+ channel,
148
+ configured,
149
+ statusLines: [
150
+ `Channel Relay: ${configured ? "configured" : "needs setup"}`,
151
+ ],
152
+ selectionHint: configured
153
+ ? "configured"
154
+ : "proxy for Discord/Telegram",
155
+ quickstartScore: configured ? 1 : 20,
156
+ };
157
+ },
158
+
159
+ configure: async ({
160
+ cfg,
161
+ prompter,
162
+ accountOverrides,
163
+ shouldPromptAccountIds,
164
+ }) => {
165
+ await prompter.note(
166
+ [
167
+ "ClawRelay bridges Discord/Telegram to OpenClaw via a",
168
+ "lightweight always-on relay service. This wizard configures",
169
+ "the channel plugin side (auth token).",
170
+ "",
171
+ "The relay service connects to the OpenClaw gateway via WS",
172
+ "and calls the relay.inbound method. No separate port needed.",
173
+ "",
174
+ "You'll deploy the relay service separately afterwards.",
175
+ ].join("\n"),
176
+ "Channel Relay Setup",
177
+ );
178
+
179
+ // --- Account ID ---
180
+ const relayOverride = (accountOverrides as any).relay?.trim();
181
+ const defaultAccountId = resolveDefaultRelayAccountId(cfg);
182
+ let accountId = relayOverride
183
+ ? normalizeAccountId(relayOverride)
184
+ : defaultAccountId;
185
+
186
+ if (shouldPromptAccountIds && !relayOverride) {
187
+ accountId = await promptAccountId({
188
+ cfg,
189
+ prompter,
190
+ label: "Channel Relay",
191
+ currentId: accountId,
192
+ listAccountIds: listRelayAccountIds,
193
+ defaultAccountId,
194
+ });
195
+ }
196
+
197
+ let next = cfg;
198
+
199
+ // --- Auth Token (optional per-account verification token) ---
200
+ const existingToken = getExistingToken(next, accountId);
201
+ let authToken: string;
202
+
203
+ if (existingToken) {
204
+ const keepToken = await prompter.confirm({
205
+ message: `Auth token already set (${existingToken.slice(0, 12)}...). Keep it?`,
206
+ initialValue: true,
207
+ });
208
+ if (keepToken) {
209
+ authToken = existingToken;
210
+ } else {
211
+ authToken = await promptAuthToken(prompter);
212
+ }
213
+ } else {
214
+ authToken = await promptAuthToken(prompter);
215
+ }
216
+
217
+ // --- Write config ---
218
+ next = setRelayAccountConfig(next, accountId, {
219
+ authToken,
220
+ });
221
+
222
+ // --- Sprite service setup ---
223
+ await setupSpriteService(prompter);
224
+
225
+ // --- Next steps ---
226
+ await prompter.note(
227
+ [
228
+ "Plugin configured! Now deploy the relay service.",
229
+ "",
230
+ "The relay connects to your OpenClaw gateway via WebSocket.",
231
+ "",
232
+ "Required env vars for the relay service:",
233
+ ` DISCORD_TOKEN=<your-bot-token>`,
234
+ ` GATEWAY_URL=<your-sprite-or-gateway-url>`,
235
+ ` GATEWAY_AUTH_TOKEN=<gateway-auth-token-from-openclaw-config>`,
236
+ "",
237
+ "The gateway auth token is in your OpenClaw config under",
238
+ "gateway.auth.token (not the relay auth token above).",
239
+ "",
240
+ "Quick start:",
241
+ ` docker run -e DISCORD_TOKEN=... -e GATEWAY_URL=https://my-sprite.sprites.dev -e GATEWAY_AUTH_TOKEN=... ghcr.io/kylemclaren/clawrelay:latest`,
242
+ "",
243
+ "Docs: https://github.com/kylemclaren/clawrelay",
244
+ ].join("\n"),
245
+ "Next Steps",
246
+ );
247
+
248
+ return { cfg: next, accountId };
249
+ },
250
+ };
251
+
252
+ async function promptAuthToken(prompter: WizardPrompter): Promise<string> {
253
+ const generated = generateAuthToken();
254
+ const useGenerated = await prompter.confirm({
255
+ message: `Use auto-generated token? (${generated})`,
256
+ initialValue: true,
257
+ });
258
+
259
+ if (useGenerated) {
260
+ return generated;
261
+ }
262
+
263
+ const custom = await prompter.text({
264
+ message: "Enter your auth token",
265
+ validate: (value) =>
266
+ String(value ?? "").trim().length >= 8
267
+ ? undefined
268
+ : "Token must be at least 8 characters",
269
+ });
270
+ return String(custom).trim();
271
+ }
package/src/types.ts CHANGED
@@ -3,7 +3,6 @@
3
3
  export interface RelayAccount {
4
4
  accountId: string;
5
5
  authToken: string;
6
- port: number;
7
6
  enabled?: boolean;
8
7
  }
9
8
 
@@ -27,11 +26,3 @@ export interface RelayOutboundMessage {
27
26
  content: string;
28
27
  replyToMessageId?: string;
29
28
  }
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;
@@ -1,144 +0,0 @@
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
- }