clawrelay 0.2.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.2.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,15 +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';
6
8
  import { relayOnboardingAdapter } from './onboarding.js';
7
9
 
8
10
  const CHANNEL_ID = 'relay' as const;
9
11
 
10
- // Store active inbound servers per account
11
- const servers = new Map<string, InboundServer>();
12
-
13
12
  export function createRelayChannel(api: any) {
14
13
  const logger = api.logger;
15
14
 
@@ -54,7 +53,6 @@ export function createRelayChannel(api: any) {
54
53
  return {
55
54
  accountId: id,
56
55
  authToken: account.authToken,
57
- port: account.port ?? 7600,
58
56
  enabled: account.enabled !== false,
59
57
  };
60
58
  },
@@ -64,74 +62,12 @@ export function createRelayChannel(api: any) {
64
62
  startAccount: async (ctx: any) => {
65
63
  const account = ctx.account as RelayAccount;
66
64
  const accountId = account.accountId ?? 'default';
67
-
68
- logger.info(`[clawrelay] startAccount called for ${accountId}`);
69
-
70
- if (!account.enabled) {
71
- logger.info(`[clawrelay] Account ${accountId} not enabled, skipping`);
72
- return;
73
- }
74
-
75
- if (servers.has(accountId)) {
76
- logger.warn(`[clawrelay] Server already running for ${accountId}, skipping`);
77
- return;
78
- }
79
-
80
- const server = new InboundServer({
81
- port: account.port,
82
- authToken: account.authToken,
83
- logger: ctx.log ?? logger,
84
- onMessage: (message: RelayInboundMessage, reply: ReplyFn) => {
85
- handleRelayInbound({
86
- message,
87
- reply,
88
- account,
89
- config: ctx.cfg,
90
- log: ctx.log ?? logger,
91
- }).catch((err) => {
92
- const errMsg = err instanceof Error ? err.message : String(err);
93
- (ctx.log ?? logger).error(`[clawrelay] Failed to process inbound: ${errMsg}`);
94
- // Best-effort error response over WS
95
- reply({
96
- messageId: message.messageId,
97
- content: `[Relay Plugin] Error processing message: ${errMsg}`,
98
- replyToMessageId: message.messageId,
99
- });
100
- });
101
- },
102
- });
103
-
104
- servers.set(accountId, server);
105
-
106
- // Listen for abort signal
107
- if (ctx.abortSignal) {
108
- ctx.abortSignal.addEventListener('abort', () => {
109
- (ctx.log ?? logger).info(`[clawrelay] Received abort signal for ${accountId}`);
110
- const srv = servers.get(accountId);
111
- if (srv) {
112
- srv.stop();
113
- servers.delete(accountId);
114
- }
115
- }, { once: true });
116
- }
117
-
118
- try {
119
- await server.start();
120
- (ctx.log ?? logger).info(`[clawrelay] Server started for ${accountId} on port ${account.port}`);
121
- } catch (err) {
122
- (ctx.log ?? logger).error(`[clawrelay] Failed to start server for ${accountId}: ${err}`);
123
- servers.delete(accountId);
124
- }
65
+ logger.info(`[clawrelay] Account ${accountId} started (relay.inbound via gateway)`);
125
66
  },
126
67
 
127
68
  stopAccount: async (ctx: any) => {
128
69
  const accountId = ctx.account?.accountId ?? 'default';
129
- const server = servers.get(accountId);
130
- if (server) {
131
- await server.stop();
132
- servers.delete(accountId);
133
- (ctx.log ?? logger).info(`[clawrelay] Server stopped for ${accountId}`);
134
- }
70
+ logger.info(`[clawrelay] Account ${accountId} stopped`);
135
71
  },
136
72
  },
137
73
 
@@ -149,149 +85,17 @@ export function createRelayChannel(api: any) {
149
85
  accountId?: string;
150
86
  cfg: any;
151
87
  }) => {
152
- // Outbound via sendText is not the primary path for relay-channel.
153
- // The main response path is via WebSocket reply.
154
- // This exists for completeness if OpenClaw core needs to send proactively.
155
- logger.warn(`[clawrelay] sendText called for chatId=${chatId} — relay-channel uses WS for responses`);
156
- 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' };
157
90
  },
158
91
  },
159
92
 
160
93
  status: {
161
94
  getHealth: (accountId: string) => {
162
- const server = servers.get(accountId);
163
- if (!server) {
164
- return { status: 'disconnected', message: 'Server not running' };
165
- }
166
- return { status: 'connected', message: 'Server listening (HTTP + WS)' };
95
+ return { status: 'connected', message: 'Listening via gateway method (relay.inbound)' };
167
96
  },
168
97
  },
169
98
  };
170
99
 
171
100
  return channel;
172
101
  }
173
-
174
-
175
- // --- Inbound message handler using OpenClaw runtime ---
176
-
177
- function resolveSessionKey(message: RelayInboundMessage): string {
178
- if (message.chatType === 'direct') {
179
- return `relay:${message.platform}:dm:${message.senderId}`;
180
- }
181
- return `relay:${message.platform}:${message.guildId ?? 'unknown'}:${message.channelId}`;
182
- }
183
-
184
- function resolvePeerId(message: RelayInboundMessage): string {
185
- if (message.chatType === 'direct') {
186
- return `relay:${message.platform}:dm:${message.senderId}`;
187
- }
188
- return `relay:${message.platform}:${message.guildId ?? 'unknown'}:${message.channelId}`;
189
- }
190
-
191
- async function handleRelayInbound(params: {
192
- message: RelayInboundMessage;
193
- reply: ReplyFn;
194
- account: RelayAccount;
195
- config: any;
196
- log: any;
197
- }): Promise<void> {
198
- const { message, reply, account, config, log } = params;
199
-
200
- let core;
201
- try {
202
- core = getRelayRuntime();
203
- } catch (err) {
204
- log?.error(`[clawrelay] Runtime not initialized: ${err}`);
205
- return;
206
- }
207
-
208
- const peerId = resolvePeerId(message);
209
-
210
- // Resolve agent route
211
- const route = core.channel.routing.resolveAgentRoute({
212
- cfg: config,
213
- channel: CHANNEL_ID,
214
- accountId: account.accountId,
215
- peer: {
216
- kind: message.chatType === 'direct' ? 'direct' : 'group',
217
- id: peerId,
218
- },
219
- });
220
-
221
- // Resolve session store path
222
- const storePath = core.channel.session.resolveStorePath(config.session?.store, {
223
- agentId: route.agentId,
224
- });
225
-
226
- // Format envelope
227
- const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
228
- const previousTimestamp = core.channel.session.readSessionUpdatedAt({
229
- storePath,
230
- sessionKey: route.sessionKey,
231
- });
232
-
233
- const fromLabel = `${message.platform}:${message.senderName}`;
234
- const body = core.channel.reply.formatAgentEnvelope({
235
- channel: `relay:${message.platform}`,
236
- from: fromLabel,
237
- timestamp: message.timestamp,
238
- previousTimestamp,
239
- envelope: envelopeOptions,
240
- body: message.content,
241
- });
242
-
243
- // Build finalized message context
244
- const ctxPayload = core.channel.reply.finalizeInboundContext({
245
- Body: body,
246
- BodyForAgent: body,
247
- RawBody: message.content,
248
- CommandBody: message.content,
249
- From: peerId,
250
- To: peerId,
251
- SessionKey: route.sessionKey,
252
- AccountId: route.accountId,
253
- ChatType: message.chatType,
254
- ConversationLabel: fromLabel,
255
- SenderName: message.senderName,
256
- SenderId: message.senderId,
257
- GroupSubject: message.groupName ?? message.channelId,
258
- Provider: CHANNEL_ID,
259
- Surface: CHANNEL_ID,
260
- Timestamp: message.timestamp,
261
- OriginatingChannel: CHANNEL_ID,
262
- OriginatingTo: peerId,
263
- });
264
-
265
- // Record inbound session
266
- await core.channel.session.recordInboundSession({
267
- storePath,
268
- sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
269
- ctx: ctxPayload,
270
- onRecordError: (err: unknown) => {
271
- log?.error(`[clawrelay] Failed updating session meta: ${String(err)}`);
272
- },
273
- });
274
-
275
- // Dispatch reply — triggers agent processing and delivers response via WS
276
- await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
277
- ctx: ctxPayload,
278
- cfg: config,
279
- dispatcherOptions: {
280
- deliver: async (payload: { text?: string }) => {
281
- const text = payload.text ?? '';
282
- if (!text.trim()) return;
283
-
284
- reply({
285
- messageId: message.messageId,
286
- content: text,
287
- replyToMessageId: message.messageId,
288
- });
289
- },
290
- onError: (err: unknown, info: { kind: string }) => {
291
- log?.error(`[clawrelay] ${info.kind} reply failed: ${String(err)}`);
292
- },
293
- },
294
- });
295
-
296
- log?.info(`[clawrelay] Processed message ${message.messageId} from ${message.senderName}`);
297
- }
@@ -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
 
package/src/onboarding.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  promptAccountId,
10
10
  } from "openclaw/plugin-sdk";
11
11
  import crypto from "node:crypto";
12
+ import { execSync } from "node:child_process";
12
13
 
13
14
  const channel = "relay" as const;
14
15
 
@@ -74,12 +75,54 @@ function getExistingToken(cfg: OpenClawConfig, accountId: string): string | unde
74
75
  return relay?.accounts?.[accountId]?.authToken;
75
76
  }
76
77
 
77
- function getExistingPort(cfg: OpenClawConfig, accountId: string): number | undefined {
78
- const relay = (cfg.channels as any)?.relay;
79
- if (accountId === DEFAULT_ACCOUNT_ID) {
80
- return relay?.port;
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
+ );
81
125
  }
82
- return relay?.accounts?.[accountId]?.port;
83
126
  }
84
127
 
85
128
  export const relayOnboardingAdapter: ChannelOnboardingAdapter = {
@@ -123,7 +166,10 @@ export const relayOnboardingAdapter: ChannelOnboardingAdapter = {
123
166
  [
124
167
  "ClawRelay bridges Discord/Telegram to OpenClaw via a",
125
168
  "lightweight always-on relay service. This wizard configures",
126
- "the channel plugin side (auth token + port).",
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.",
127
173
  "",
128
174
  "You'll deploy the relay service separately afterwards.",
129
175
  ].join("\n"),
@@ -150,7 +196,7 @@ export const relayOnboardingAdapter: ChannelOnboardingAdapter = {
150
196
 
151
197
  let next = cfg;
152
198
 
153
- // --- Auth Token ---
199
+ // --- Auth Token (optional per-account verification token) ---
154
200
  const existingToken = getExistingToken(next, accountId);
155
201
  let authToken: string;
156
202
 
@@ -168,42 +214,31 @@ export const relayOnboardingAdapter: ChannelOnboardingAdapter = {
168
214
  authToken = await promptAuthToken(prompter);
169
215
  }
170
216
 
171
- // --- Port ---
172
- const existingPort = getExistingPort(next, accountId);
173
- let port: number;
174
-
175
- if (existingPort) {
176
- const keepPort = await prompter.confirm({
177
- message: `Inbound port is ${existingPort}. Keep it?`,
178
- initialValue: true,
179
- });
180
- if (keepPort) {
181
- port = existingPort;
182
- } else {
183
- port = await promptPort(prompter);
184
- }
185
- } else {
186
- port = await promptPort(prompter);
187
- }
188
-
189
217
  // --- Write config ---
190
218
  next = setRelayAccountConfig(next, accountId, {
191
219
  authToken,
192
- port,
193
220
  });
194
221
 
222
+ // --- Sprite service setup ---
223
+ await setupSpriteService(prompter);
224
+
195
225
  // --- Next steps ---
196
226
  await prompter.note(
197
227
  [
198
228
  "Plugin configured! Now deploy the relay service.",
199
229
  "",
230
+ "The relay connects to your OpenClaw gateway via WebSocket.",
231
+ "",
200
232
  "Required env vars for the relay service:",
201
233
  ` DISCORD_TOKEN=<your-bot-token>`,
202
- ` SANDBOX_PLUGIN_URL=ws://localhost:${port}`,
203
- ` SANDBOX_AUTH_TOKEN=${authToken}`,
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).",
204
239
  "",
205
240
  "Quick start:",
206
- ` docker run -e DISCORD_TOKEN=... -e SANDBOX_PLUGIN_URL=ws://host.docker.internal:${port} -e SANDBOX_AUTH_TOKEN=${authToken} ghcr.io/kylemclaren/clawrelay:latest`,
241
+ ` docker run -e DISCORD_TOKEN=... -e GATEWAY_URL=https://my-sprite.sprites.dev -e GATEWAY_AUTH_TOKEN=... ghcr.io/kylemclaren/clawrelay:latest`,
207
242
  "",
208
243
  "Docs: https://github.com/kylemclaren/clawrelay",
209
244
  ].join("\n"),
@@ -234,18 +269,3 @@ async function promptAuthToken(prompter: WizardPrompter): Promise<string> {
234
269
  });
235
270
  return String(custom).trim();
236
271
  }
237
-
238
- async function promptPort(prompter: WizardPrompter): Promise<number> {
239
- const portStr = await prompter.text({
240
- message: "Inbound server port",
241
- initialValue: "7600",
242
- validate: (value) => {
243
- const n = Number(value);
244
- if (!Number.isInteger(n) || n < 1 || n > 65535) {
245
- return "Enter a valid port (1-65535)";
246
- }
247
- return undefined;
248
- },
249
- });
250
- return Number(portStr);
251
- }
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
- }