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