clawrelay 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +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 +64 -44
- 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,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
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
promptAccountId,
|
|
10
10
|
} from "openclaw/plugin-sdk";
|
|
11
11
|
import crypto from "node:crypto";
|
|
12
|
+
import { execSync } from "node:child_process";
|
|
12
13
|
|
|
13
14
|
const channel = "relay" as const;
|
|
14
15
|
|
|
@@ -74,12 +75,54 @@ function getExistingToken(cfg: OpenClawConfig, accountId: string): string | unde
|
|
|
74
75
|
return relay?.accounts?.[accountId]?.authToken;
|
|
75
76
|
}
|
|
76
77
|
|
|
77
|
-
function
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
return
|
|
78
|
+
function isSpriteEnv(): boolean {
|
|
79
|
+
try {
|
|
80
|
+
execSync('which sprite-env', { stdio: 'ignore' });
|
|
81
|
+
return true;
|
|
82
|
+
} catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function setupSpriteService(prompter: WizardPrompter): Promise<void> {
|
|
88
|
+
if (!isSpriteEnv()) return;
|
|
89
|
+
|
|
90
|
+
const setup = await prompter.confirm({
|
|
91
|
+
message: 'Sprite environment detected. Set up service to expose the gateway?',
|
|
92
|
+
initialValue: true,
|
|
93
|
+
});
|
|
94
|
+
if (!setup) return;
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
// Check if a service already exists
|
|
98
|
+
const existing = execSync('sprite-env services list', { encoding: 'utf-8' });
|
|
99
|
+
if (existing.includes('openclaw')) {
|
|
100
|
+
await prompter.note(
|
|
101
|
+
'An openclaw service already exists on this sprite.',
|
|
102
|
+
'Sprite Service',
|
|
103
|
+
);
|
|
104
|
+
} else {
|
|
105
|
+
execSync('sprite-env services create openclaw-gateway --cmd openclaw --args start --http-port 18789');
|
|
106
|
+
await prompter.note(
|
|
107
|
+
'Created sprite service: openclaw-gateway (port 18789)',
|
|
108
|
+
'Sprite Service',
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Check URL auth
|
|
113
|
+
const setPublic = await prompter.confirm({
|
|
114
|
+
message: 'Set sprite URL auth to public? (required for relay to connect without org token)',
|
|
115
|
+
initialValue: true,
|
|
116
|
+
});
|
|
117
|
+
if (setPublic) {
|
|
118
|
+
execSync('sprite url update --auth public');
|
|
119
|
+
}
|
|
120
|
+
} catch (err) {
|
|
121
|
+
await prompter.note(
|
|
122
|
+
`Failed to configure sprite service: ${err}\nYou can set it up manually later.`,
|
|
123
|
+
'Sprite Service',
|
|
124
|
+
);
|
|
81
125
|
}
|
|
82
|
-
return relay?.accounts?.[accountId]?.port;
|
|
83
126
|
}
|
|
84
127
|
|
|
85
128
|
export const relayOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
@@ -123,7 +166,10 @@ export const relayOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
|
123
166
|
[
|
|
124
167
|
"ClawRelay bridges Discord/Telegram to OpenClaw via a",
|
|
125
168
|
"lightweight always-on relay service. This wizard configures",
|
|
126
|
-
"the channel plugin side (auth token
|
|
169
|
+
"the channel plugin side (auth token).",
|
|
170
|
+
"",
|
|
171
|
+
"The relay service connects to the OpenClaw gateway via WS",
|
|
172
|
+
"and calls the relay.inbound method. No separate port needed.",
|
|
127
173
|
"",
|
|
128
174
|
"You'll deploy the relay service separately afterwards.",
|
|
129
175
|
].join("\n"),
|
|
@@ -150,7 +196,7 @@ export const relayOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
|
150
196
|
|
|
151
197
|
let next = cfg;
|
|
152
198
|
|
|
153
|
-
// --- Auth Token ---
|
|
199
|
+
// --- Auth Token (optional per-account verification token) ---
|
|
154
200
|
const existingToken = getExistingToken(next, accountId);
|
|
155
201
|
let authToken: string;
|
|
156
202
|
|
|
@@ -168,42 +214,31 @@ export const relayOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
|
168
214
|
authToken = await promptAuthToken(prompter);
|
|
169
215
|
}
|
|
170
216
|
|
|
171
|
-
// --- Port ---
|
|
172
|
-
const existingPort = getExistingPort(next, accountId);
|
|
173
|
-
let port: number;
|
|
174
|
-
|
|
175
|
-
if (existingPort) {
|
|
176
|
-
const keepPort = await prompter.confirm({
|
|
177
|
-
message: `Inbound port is ${existingPort}. Keep it?`,
|
|
178
|
-
initialValue: true,
|
|
179
|
-
});
|
|
180
|
-
if (keepPort) {
|
|
181
|
-
port = existingPort;
|
|
182
|
-
} else {
|
|
183
|
-
port = await promptPort(prompter);
|
|
184
|
-
}
|
|
185
|
-
} else {
|
|
186
|
-
port = await promptPort(prompter);
|
|
187
|
-
}
|
|
188
|
-
|
|
189
217
|
// --- Write config ---
|
|
190
218
|
next = setRelayAccountConfig(next, accountId, {
|
|
191
219
|
authToken,
|
|
192
|
-
port,
|
|
193
220
|
});
|
|
194
221
|
|
|
222
|
+
// --- Sprite service setup ---
|
|
223
|
+
await setupSpriteService(prompter);
|
|
224
|
+
|
|
195
225
|
// --- Next steps ---
|
|
196
226
|
await prompter.note(
|
|
197
227
|
[
|
|
198
228
|
"Plugin configured! Now deploy the relay service.",
|
|
199
229
|
"",
|
|
230
|
+
"The relay connects to your OpenClaw gateway via WebSocket.",
|
|
231
|
+
"",
|
|
200
232
|
"Required env vars for the relay service:",
|
|
201
233
|
` DISCORD_TOKEN=<your-bot-token>`,
|
|
202
|
-
`
|
|
203
|
-
`
|
|
234
|
+
` GATEWAY_URL=<your-sprite-or-gateway-url>`,
|
|
235
|
+
` GATEWAY_AUTH_TOKEN=<gateway-auth-token-from-openclaw-config>`,
|
|
236
|
+
"",
|
|
237
|
+
"The gateway auth token is in your OpenClaw config under",
|
|
238
|
+
"gateway.auth.token (not the relay auth token above).",
|
|
204
239
|
"",
|
|
205
240
|
"Quick start:",
|
|
206
|
-
` docker run -e DISCORD_TOKEN=... -e
|
|
241
|
+
` docker run -e DISCORD_TOKEN=... -e GATEWAY_URL=https://my-sprite.sprites.dev -e GATEWAY_AUTH_TOKEN=... ghcr.io/kylemclaren/clawrelay:latest`,
|
|
207
242
|
"",
|
|
208
243
|
"Docs: https://github.com/kylemclaren/clawrelay",
|
|
209
244
|
].join("\n"),
|
|
@@ -234,18 +269,3 @@ async function promptAuthToken(prompter: WizardPrompter): Promise<string> {
|
|
|
234
269
|
});
|
|
235
270
|
return String(custom).trim();
|
|
236
271
|
}
|
|
237
|
-
|
|
238
|
-
async function promptPort(prompter: WizardPrompter): Promise<number> {
|
|
239
|
-
const portStr = await prompter.text({
|
|
240
|
-
message: "Inbound server port",
|
|
241
|
-
initialValue: "7600",
|
|
242
|
-
validate: (value) => {
|
|
243
|
-
const n = Number(value);
|
|
244
|
-
if (!Number.isInteger(n) || n < 1 || n > 65535) {
|
|
245
|
-
return "Enter a valid port (1-65535)";
|
|
246
|
-
}
|
|
247
|
-
return undefined;
|
|
248
|
-
},
|
|
249
|
-
});
|
|
250
|
-
return Number(portStr);
|
|
251
|
-
}
|
package/src/types.ts
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
export interface RelayAccount {
|
|
4
4
|
accountId: string;
|
|
5
5
|
authToken: string;
|
|
6
|
-
port: number;
|
|
7
6
|
enabled?: boolean;
|
|
8
7
|
}
|
|
9
8
|
|
|
@@ -27,11 +26,3 @@ export interface RelayOutboundMessage {
|
|
|
27
26
|
content: string;
|
|
28
27
|
replyToMessageId?: string;
|
|
29
28
|
}
|
|
30
|
-
|
|
31
|
-
// WebSocket protocol envelope
|
|
32
|
-
export type WsEnvelope =
|
|
33
|
-
| { type: 'message'; payload: RelayInboundMessage }
|
|
34
|
-
| { type: 'response'; payload: RelayOutboundMessage };
|
|
35
|
-
|
|
36
|
-
// Reply function passed to message handlers
|
|
37
|
-
export type ReplyFn = (response: RelayOutboundMessage) => void;
|
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
|
-
}
|