clawrelay 0.2.0 → 0.3.1
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 +10 -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 +17 -48
- 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.1",
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -74,14 +74,6 @@ function getExistingToken(cfg: OpenClawConfig, accountId: string): string | unde
|
|
|
74
74
|
return relay?.accounts?.[accountId]?.authToken;
|
|
75
75
|
}
|
|
76
76
|
|
|
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;
|
|
81
|
-
}
|
|
82
|
-
return relay?.accounts?.[accountId]?.port;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
77
|
export const relayOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
86
78
|
channel,
|
|
87
79
|
|
|
@@ -123,7 +115,10 @@ export const relayOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
|
123
115
|
[
|
|
124
116
|
"ClawRelay bridges Discord/Telegram to OpenClaw via a",
|
|
125
117
|
"lightweight always-on relay service. This wizard configures",
|
|
126
|
-
"the channel plugin side (auth token
|
|
118
|
+
"the channel plugin side (auth token).",
|
|
119
|
+
"",
|
|
120
|
+
"The relay service connects to the OpenClaw gateway via WS",
|
|
121
|
+
"and calls the relay.inbound method. No separate port needed.",
|
|
127
122
|
"",
|
|
128
123
|
"You'll deploy the relay service separately afterwards.",
|
|
129
124
|
].join("\n"),
|
|
@@ -150,7 +145,7 @@ export const relayOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
|
150
145
|
|
|
151
146
|
let next = cfg;
|
|
152
147
|
|
|
153
|
-
// --- Auth Token ---
|
|
148
|
+
// --- Auth Token (optional per-account verification token) ---
|
|
154
149
|
const existingToken = getExistingToken(next, accountId);
|
|
155
150
|
let authToken: string;
|
|
156
151
|
|
|
@@ -168,28 +163,9 @@ export const relayOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
|
168
163
|
authToken = await promptAuthToken(prompter);
|
|
169
164
|
}
|
|
170
165
|
|
|
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
166
|
// --- Write config ---
|
|
190
167
|
next = setRelayAccountConfig(next, accountId, {
|
|
191
168
|
authToken,
|
|
192
|
-
port,
|
|
193
169
|
});
|
|
194
170
|
|
|
195
171
|
// --- Next steps ---
|
|
@@ -197,13 +173,21 @@ export const relayOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
|
197
173
|
[
|
|
198
174
|
"Plugin configured! Now deploy the relay service.",
|
|
199
175
|
"",
|
|
176
|
+
"The relay connects to your OpenClaw gateway via WebSocket.",
|
|
177
|
+
"",
|
|
200
178
|
"Required env vars for the relay service:",
|
|
201
179
|
` DISCORD_TOKEN=<your-bot-token>`,
|
|
202
|
-
`
|
|
203
|
-
`
|
|
180
|
+
` GATEWAY_URL=<your-sprite-or-gateway-url>`,
|
|
181
|
+
` GATEWAY_AUTH_TOKEN=<gateway-auth-token-from-openclaw-config>`,
|
|
182
|
+
"",
|
|
183
|
+
"The gateway auth token is in your OpenClaw config under",
|
|
184
|
+
"gateway.auth.token (not the relay auth token above).",
|
|
204
185
|
"",
|
|
205
|
-
"
|
|
206
|
-
|
|
186
|
+
"On a sprite, expose the gateway with:",
|
|
187
|
+
" sprite-env services create openclaw-gateway \\",
|
|
188
|
+
" --cmd openclaw --args 'gateway --allow-unconfigured' \\",
|
|
189
|
+
" --http-port 18789",
|
|
190
|
+
" sprite url update --auth public",
|
|
207
191
|
"",
|
|
208
192
|
"Docs: https://github.com/kylemclaren/clawrelay",
|
|
209
193
|
].join("\n"),
|
|
@@ -234,18 +218,3 @@ async function promptAuthToken(prompter: WizardPrompter): Promise<string> {
|
|
|
234
218
|
});
|
|
235
219
|
return String(custom).trim();
|
|
236
220
|
}
|
|
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;
|
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
|
-
}
|