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 +17 -15
- package/package.json +1 -4
- package/src/channel.ts +13 -206
- package/src/gateway-handler.ts +204 -0
- package/src/health-handler.ts +10 -0
- package/src/index.ts +14 -1
- package/src/onboarding.ts +271 -0
- package/src/types.ts +0 -9
- package/src/inbound-server.ts +0 -144
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
|
|
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
|
|
8
|
-
2. The relay service connects via WebSocket
|
|
9
|
-
3. Inbound messages
|
|
10
|
-
4.
|
|
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.
|
|
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": "
|
|
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 `
|
|
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
|
-
|
|
|
70
|
+
| Type | Path/Method | Description |
|
|
71
71
|
|---|---|---|
|
|
72
|
-
|
|
|
73
|
-
|
|
|
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
|
|
80
|
-
| `src/channel.ts` | Channel definition,
|
|
81
|
-
| `src/
|
|
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.
|
|
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
|
|
4
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/src/inbound-server.ts
DELETED
|
@@ -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
|
-
}
|