r2-relay-channel 0.3.5
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/LICENSE +21 -0
- package/README.md +309 -0
- package/dist/channel.d.ts +16 -0
- package/dist/channel.js +271 -0
- package/dist/checkpoint-store.d.ts +4 -0
- package/dist/checkpoint-store.js +16 -0
- package/dist/commands.d.ts +2 -0
- package/dist/commands.js +54 -0
- package/dist/config.d.ts +76 -0
- package/dist/config.js +123 -0
- package/dist/conversation-targets.d.ts +32 -0
- package/dist/conversation-targets.js +120 -0
- package/dist/inbound.d.ts +38 -0
- package/dist/inbound.js +440 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +26 -0
- package/dist/lifecycle.d.ts +64 -0
- package/dist/lifecycle.js +296 -0
- package/dist/media.d.ts +30 -0
- package/dist/media.js +361 -0
- package/dist/message-adapter.d.ts +27 -0
- package/dist/message-adapter.js +53 -0
- package/dist/openclaw.plugin.json +41 -0
- package/dist/outbound.d.ts +28 -0
- package/dist/outbound.js +141 -0
- package/dist/protocol.d.ts +2 -0
- package/dist/protocol.js +1 -0
- package/dist/r2-relay.d.ts +1 -0
- package/dist/r2-relay.js +1 -0
- package/dist/r2client.d.ts +33 -0
- package/dist/r2client.js +137 -0
- package/dist/relay-core/checkpoint.d.ts +31 -0
- package/dist/relay-core/checkpoint.js +95 -0
- package/dist/relay-core/errors.d.ts +9 -0
- package/dist/relay-core/errors.js +18 -0
- package/dist/relay-core/index.d.ts +6 -0
- package/dist/relay-core/index.js +6 -0
- package/dist/relay-core/keyspace.d.ts +24 -0
- package/dist/relay-core/keyspace.js +60 -0
- package/dist/relay-core/object-store.d.ts +20 -0
- package/dist/relay-core/object-store.js +1 -0
- package/dist/relay-core/transport.d.ts +48 -0
- package/dist/relay-core/transport.js +326 -0
- package/dist/relay-core/types.d.ts +220 -0
- package/dist/relay-core/types.js +1 -0
- package/dist/runtime.d.ts +9 -0
- package/dist/runtime.js +54 -0
- package/dist/service.d.ts +51 -0
- package/dist/service.js +186 -0
- package/dist/service.test.d.ts +1 -0
- package/dist/service.test.js +27 -0
- package/dist/session-publication.d.ts +7 -0
- package/dist/session-publication.js +365 -0
- package/dist/setup-entry.d.ts +6 -0
- package/dist/setup-entry.js +49 -0
- package/dist/setup.d.ts +3 -0
- package/dist/setup.js +291 -0
- package/dist/target.d.ts +6 -0
- package/dist/target.js +53 -0
- package/dist/webhook.d.ts +18 -0
- package/dist/webhook.js +225 -0
- package/openclaw.plugin.json +41 -0
- package/package.json +62 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
|
|
2
|
+
import { defineSetupPluginEntry } from "openclaw/plugin-sdk/core";
|
|
3
|
+
import { r2RelayChannelConfigSchema, resolveR2RelayAccount, listR2RelayAccountIds, } from "./config.js";
|
|
4
|
+
import { r2RelaySetupAdapter, r2RelaySetupWizard } from "./setup.js";
|
|
5
|
+
const r2RelaySetupPlugin = {
|
|
6
|
+
id: "r2-relay-channel",
|
|
7
|
+
meta: {
|
|
8
|
+
id: "r2-relay-channel",
|
|
9
|
+
label: "R2 Relay",
|
|
10
|
+
selectionLabel: "R2 Relay",
|
|
11
|
+
docsPath: "/channels/r2-relay-channel",
|
|
12
|
+
docsLabel: "r2-relay-channel",
|
|
13
|
+
blurb: "Cloudflare R2-backed relay channel for direct text messaging.",
|
|
14
|
+
order: 120,
|
|
15
|
+
},
|
|
16
|
+
capabilities: {
|
|
17
|
+
chatTypes: ["direct"],
|
|
18
|
+
reactions: true,
|
|
19
|
+
media: false,
|
|
20
|
+
},
|
|
21
|
+
reload: { configPrefixes: ["channels.r2-relay-channel"] },
|
|
22
|
+
configSchema: r2RelayChannelConfigSchema,
|
|
23
|
+
setupWizard: r2RelaySetupWizard,
|
|
24
|
+
setup: r2RelaySetupAdapter,
|
|
25
|
+
config: {
|
|
26
|
+
listAccountIds: (cfg) => listR2RelayAccountIds(cfg),
|
|
27
|
+
resolveAccount: (cfg, accountId) => resolveR2RelayAccount({ cfg, accountId }),
|
|
28
|
+
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
|
|
29
|
+
isConfigured: (account) => account.configured,
|
|
30
|
+
describeAccount: (account) => ({
|
|
31
|
+
accountId: account.accountId,
|
|
32
|
+
enabled: account.enabled,
|
|
33
|
+
configured: account.configured,
|
|
34
|
+
serverId: account.serverId,
|
|
35
|
+
bucket: account.bucket,
|
|
36
|
+
endpoint: redactEndpoint(account.endpoint),
|
|
37
|
+
}),
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
function redactEndpoint(endpoint) {
|
|
41
|
+
try {
|
|
42
|
+
const url = new URL(endpoint);
|
|
43
|
+
return `${url.protocol}//${url.host}`;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return endpoint;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export default defineSetupPluginEntry(r2RelaySetupPlugin);
|
package/dist/setup.d.ts
ADDED
package/dist/setup.js
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
|
|
4
|
+
import { readConfigFileSnapshotForWrite, writeConfigFile } from "openclaw/plugin-sdk/config-runtime";
|
|
5
|
+
import { CHANNEL_ID, DEFAULT_BACKOFF_MAX_MS, DEFAULT_FORCE_PATH_STYLE, DEFAULT_HEAD_TTL_DAYS, DEFAULT_IDENTITY_TTL_DAYS, DEFAULT_POLL_INTERVAL_MS, DEFAULT_REGION, DEFAULT_TTL_DAYS, loadR2RelaySidecarConfig, normalizeServerId, resolveRelayConfigFilePath, } from "./config.js";
|
|
6
|
+
function getChannelSection(cfg) {
|
|
7
|
+
return cfg.channels?.[CHANNEL_ID] ?? {};
|
|
8
|
+
}
|
|
9
|
+
function withChannelSection(cfg, patch) {
|
|
10
|
+
const currentChannels = cfg.channels ?? {};
|
|
11
|
+
const nextSection = {
|
|
12
|
+
enabled: true,
|
|
13
|
+
...getChannelSection(cfg),
|
|
14
|
+
...patch,
|
|
15
|
+
};
|
|
16
|
+
return {
|
|
17
|
+
...cfg,
|
|
18
|
+
channels: {
|
|
19
|
+
...currentChannels,
|
|
20
|
+
[CHANNEL_ID]: nextSection,
|
|
21
|
+
},
|
|
22
|
+
plugins: {
|
|
23
|
+
...(cfg.plugins ?? {}),
|
|
24
|
+
entries: {
|
|
25
|
+
...(cfg.plugins?.entries ?? {}),
|
|
26
|
+
[CHANNEL_ID]: {
|
|
27
|
+
enabled: true,
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function currentString(cfg, key) {
|
|
34
|
+
const sidecar = getSidecarSection(cfg);
|
|
35
|
+
const value = sidecar[key];
|
|
36
|
+
return typeof value === "string" && value.trim() ? value : undefined;
|
|
37
|
+
}
|
|
38
|
+
function normalizeEndpoint(value) {
|
|
39
|
+
return value.trim().replace(/\/+$/, "");
|
|
40
|
+
}
|
|
41
|
+
function validateUrl(value) {
|
|
42
|
+
try {
|
|
43
|
+
const url = new URL(value);
|
|
44
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
45
|
+
return "Use an http:// or https:// endpoint URL.";
|
|
46
|
+
}
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return "Enter a valid endpoint URL.";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function randomServerIdCandidate() {
|
|
54
|
+
const nouns = ["otter", "harbor", "pine", "comet", "rook", "fern"];
|
|
55
|
+
const noun = nouns[Math.floor(Math.random() * nouns.length)];
|
|
56
|
+
const suffix = Math.floor(Math.random() * 900 + 100);
|
|
57
|
+
return `gw-${noun}-${suffix}`;
|
|
58
|
+
}
|
|
59
|
+
function suggestedServerId(cfg) {
|
|
60
|
+
return currentString(cfg, "serverId") ?? randomServerIdCandidate();
|
|
61
|
+
}
|
|
62
|
+
function getConfiguredConfigFileValue(cfg) {
|
|
63
|
+
const configured = getChannelSection(cfg).configFile?.trim();
|
|
64
|
+
return configured || undefined;
|
|
65
|
+
}
|
|
66
|
+
function getConfigFilePath(cfg) {
|
|
67
|
+
return resolveRelayConfigFilePath(getConfiguredConfigFileValue(cfg));
|
|
68
|
+
}
|
|
69
|
+
function getSidecarSection(cfg) {
|
|
70
|
+
return loadR2RelaySidecarConfig(getConfigFilePath(cfg));
|
|
71
|
+
}
|
|
72
|
+
function configPointerPatch(cfg) {
|
|
73
|
+
const configured = getConfiguredConfigFileValue(cfg);
|
|
74
|
+
return configured ? { enabled: true, configFile: configured } : { enabled: true, configFile: getConfigFilePath(cfg) };
|
|
75
|
+
}
|
|
76
|
+
function buildExpandedSidecarSection(cfg, patch) {
|
|
77
|
+
const current = getSidecarSection(cfg);
|
|
78
|
+
const merged = {
|
|
79
|
+
...current,
|
|
80
|
+
...patch,
|
|
81
|
+
};
|
|
82
|
+
const defaultTtlDays = merged.defaultTtlDays ?? DEFAULT_TTL_DAYS;
|
|
83
|
+
return {
|
|
84
|
+
enabled: merged.enabled ?? true,
|
|
85
|
+
endpoint: merged.endpoint,
|
|
86
|
+
bucket: merged.bucket,
|
|
87
|
+
accessKeyId: merged.accessKeyId,
|
|
88
|
+
secretAccessKey: merged.secretAccessKey,
|
|
89
|
+
serverId: merged.serverId,
|
|
90
|
+
region: merged.region ?? DEFAULT_REGION,
|
|
91
|
+
forcePathStyle: merged.forcePathStyle ?? DEFAULT_FORCE_PATH_STYLE,
|
|
92
|
+
pollIntervalMs: merged.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
|
|
93
|
+
backoffMaxMs: merged.backoffMaxMs ?? DEFAULT_BACKOFF_MAX_MS,
|
|
94
|
+
defaultTtlDays,
|
|
95
|
+
ttl: {
|
|
96
|
+
msg: merged.ttl?.msg ?? defaultTtlDays,
|
|
97
|
+
att: merged.ttl?.att ?? defaultTtlDays,
|
|
98
|
+
identity: merged.ttl?.identity ?? DEFAULT_IDENTITY_TTL_DAYS,
|
|
99
|
+
head: merged.ttl?.head ?? DEFAULT_HEAD_TTL_DAYS,
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function writeSidecarSection(cfg, patch) {
|
|
104
|
+
const filePath = getConfigFilePath(cfg);
|
|
105
|
+
const next = buildExpandedSidecarSection(cfg, patch);
|
|
106
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
107
|
+
fs.writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
|
|
108
|
+
}
|
|
109
|
+
async function persistOpenClawConfig(cfg) {
|
|
110
|
+
const { writeOptions } = await readConfigFileSnapshotForWrite();
|
|
111
|
+
await writeConfigFile(cfg, writeOptions);
|
|
112
|
+
}
|
|
113
|
+
async function writeSidecarAndPersistChannelPointer(cfg, patch) {
|
|
114
|
+
writeSidecarSection(cfg, patch);
|
|
115
|
+
const nextCfg = withChannelSection(cfg, configPointerPatch(cfg));
|
|
116
|
+
await persistOpenClawConfig(nextCfg);
|
|
117
|
+
return nextCfg;
|
|
118
|
+
}
|
|
119
|
+
function applyInputToConfig(cfg, input) {
|
|
120
|
+
const patch = {
|
|
121
|
+
enabled: true,
|
|
122
|
+
region: DEFAULT_REGION,
|
|
123
|
+
forcePathStyle: DEFAULT_FORCE_PATH_STYLE,
|
|
124
|
+
pollIntervalMs: DEFAULT_POLL_INTERVAL_MS,
|
|
125
|
+
backoffMaxMs: DEFAULT_BACKOFF_MAX_MS,
|
|
126
|
+
defaultTtlDays: DEFAULT_TTL_DAYS,
|
|
127
|
+
ttl: {
|
|
128
|
+
msg: DEFAULT_TTL_DAYS,
|
|
129
|
+
att: DEFAULT_TTL_DAYS,
|
|
130
|
+
identity: DEFAULT_IDENTITY_TTL_DAYS,
|
|
131
|
+
head: DEFAULT_HEAD_TTL_DAYS,
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
if (typeof input.url === "string" && input.url.trim()) {
|
|
135
|
+
patch.endpoint = normalizeEndpoint(input.url);
|
|
136
|
+
}
|
|
137
|
+
if (typeof input.name === "string" && input.name.trim()) {
|
|
138
|
+
patch.bucket = input.name.trim();
|
|
139
|
+
}
|
|
140
|
+
if (typeof input.userId === "string" && input.userId.trim()) {
|
|
141
|
+
patch.accessKeyId = input.userId.trim();
|
|
142
|
+
}
|
|
143
|
+
if (typeof input.password === "string" && input.password.trim()) {
|
|
144
|
+
patch.secretAccessKey = input.password.trim();
|
|
145
|
+
}
|
|
146
|
+
if (typeof input.deviceName === "string") {
|
|
147
|
+
const trimmed = input.deviceName.trim();
|
|
148
|
+
patch.serverId = trimmed ? normalizeServerId(trimmed) ?? trimmed : undefined;
|
|
149
|
+
}
|
|
150
|
+
writeSidecarSection(cfg, patch);
|
|
151
|
+
return withChannelSection(cfg, configPointerPatch(cfg));
|
|
152
|
+
}
|
|
153
|
+
export const r2RelaySetupAdapter = {
|
|
154
|
+
resolveAccountId: () => DEFAULT_ACCOUNT_ID,
|
|
155
|
+
applyAccountConfig: ({ cfg, input }) => applyInputToConfig(cfg, input),
|
|
156
|
+
validateInput: ({ input }) => {
|
|
157
|
+
const url = input.url?.trim() ?? "";
|
|
158
|
+
const bucket = input.name?.trim() ?? "";
|
|
159
|
+
const accessKeyId = input.userId?.trim() ?? "";
|
|
160
|
+
const secretAccessKey = input.password?.trim() ?? "";
|
|
161
|
+
if (!url || !bucket || !accessKeyId || !secretAccessKey) {
|
|
162
|
+
return "R2 endpoint, bucket, access key ID, and secret access key are required.";
|
|
163
|
+
}
|
|
164
|
+
return validateUrl(url) ?? null;
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
export const r2RelaySetupWizard = {
|
|
168
|
+
channel: CHANNEL_ID,
|
|
169
|
+
resolveAccountIdForConfigure: () => DEFAULT_ACCOUNT_ID,
|
|
170
|
+
resolveShouldPromptAccountIds: () => false,
|
|
171
|
+
status: {
|
|
172
|
+
configuredLabel: "Configured",
|
|
173
|
+
unconfiguredLabel: "Not configured",
|
|
174
|
+
configuredHint: "R2 endpoint, bucket, and credentials are present.",
|
|
175
|
+
unconfiguredHint: "Add your R2 bucket details to enable the dedicated OpenClaw app flow.",
|
|
176
|
+
resolveConfigured: ({ cfg }) => {
|
|
177
|
+
const section = getSidecarSection(cfg);
|
|
178
|
+
return Boolean(section.endpoint?.trim() &&
|
|
179
|
+
section.bucket?.trim() &&
|
|
180
|
+
section.accessKeyId?.trim() &&
|
|
181
|
+
section.secretAccessKey?.trim());
|
|
182
|
+
},
|
|
183
|
+
resolveStatusLines: ({ cfg, configured }) => {
|
|
184
|
+
const section = getSidecarSection(cfg);
|
|
185
|
+
const lines = [
|
|
186
|
+
configured
|
|
187
|
+
? "AgentDeck and other dedicated clients can use this channel once the gateway is running."
|
|
188
|
+
: "This channel is intended for AgentDeck-style dedicated app onboarding.",
|
|
189
|
+
];
|
|
190
|
+
if (section.bucket?.trim()) {
|
|
191
|
+
lines.push(`Bucket: ${section.bucket.trim()}`);
|
|
192
|
+
}
|
|
193
|
+
if (section.serverId?.trim()) {
|
|
194
|
+
lines.push(`Server ID: ${section.serverId.trim()}`);
|
|
195
|
+
}
|
|
196
|
+
lines.push(`Config file: ${getConfigFilePath(cfg)}`);
|
|
197
|
+
return lines;
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
introNote: {
|
|
201
|
+
title: "R2 Relay by AgentDeck App",
|
|
202
|
+
lines: [
|
|
203
|
+
"This setup connects OpenClaw to Cloudflare R2 so a dedicated app can discover your gateway and chat through your own bucket.",
|
|
204
|
+
"You will need your R2 endpoint URL, bucket name, access key ID, and secret access key.",
|
|
205
|
+
],
|
|
206
|
+
},
|
|
207
|
+
stepOrder: "text-first",
|
|
208
|
+
credentials: [],
|
|
209
|
+
textInputs: [
|
|
210
|
+
{
|
|
211
|
+
inputKey: "url",
|
|
212
|
+
message: "R2 endpoint URL",
|
|
213
|
+
placeholder: "https://<account>.r2.cloudflarestorage.com",
|
|
214
|
+
required: true,
|
|
215
|
+
helpTitle: "Endpoint URL",
|
|
216
|
+
helpLines: ["Use the Cloudflare R2 S3-compatible endpoint for your account."],
|
|
217
|
+
currentValue: ({ cfg }) => currentString(cfg, "endpoint"),
|
|
218
|
+
applyCurrentValue: true,
|
|
219
|
+
validate: ({ value }) => validateUrl(value.trim()),
|
|
220
|
+
normalizeValue: ({ value }) => normalizeEndpoint(value),
|
|
221
|
+
applySet: async ({ cfg, value }) => await writeSidecarAndPersistChannelPointer(cfg, { endpoint: normalizeEndpoint(value) }),
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
inputKey: "name",
|
|
225
|
+
message: "R2 bucket name",
|
|
226
|
+
placeholder: "Bucket name",
|
|
227
|
+
required: true,
|
|
228
|
+
currentValue: ({ cfg }) => currentString(cfg, "bucket"),
|
|
229
|
+
applyCurrentValue: true,
|
|
230
|
+
validate: ({ value }) => (value.trim() ? undefined : "Bucket name is required."),
|
|
231
|
+
normalizeValue: ({ value }) => value.trim(),
|
|
232
|
+
applySet: async ({ cfg, value }) => await writeSidecarAndPersistChannelPointer(cfg, { bucket: value.trim() }),
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
inputKey: "userId",
|
|
236
|
+
message: "R2 access key ID",
|
|
237
|
+
placeholder: "Access key ID",
|
|
238
|
+
required: true,
|
|
239
|
+
currentValue: ({ cfg }) => currentString(cfg, "accessKeyId"),
|
|
240
|
+
applyCurrentValue: true,
|
|
241
|
+
validate: ({ value }) => (value.trim() ? undefined : "Access key ID is required."),
|
|
242
|
+
normalizeValue: ({ value }) => value.trim(),
|
|
243
|
+
applySet: async ({ cfg, value }) => await writeSidecarAndPersistChannelPointer(cfg, { accessKeyId: value.trim() }),
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
inputKey: "password",
|
|
247
|
+
message: "R2 secret access key",
|
|
248
|
+
placeholder: "Secret access key",
|
|
249
|
+
required: true,
|
|
250
|
+
currentValue: ({ cfg }) => currentString(cfg, "secretAccessKey"),
|
|
251
|
+
applyCurrentValue: true,
|
|
252
|
+
validate: ({ value }) => (value.trim() ? undefined : "Secret access key is required."),
|
|
253
|
+
normalizeValue: ({ value }) => value.trim(),
|
|
254
|
+
applySet: async ({ cfg, value }) => await writeSidecarAndPersistChannelPointer(cfg, { secretAccessKey: value.trim() }),
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
inputKey: "deviceName",
|
|
258
|
+
message: "Gateway server ID",
|
|
259
|
+
placeholder: "gateway-home",
|
|
260
|
+
required: false,
|
|
261
|
+
helpTitle: "Server ID",
|
|
262
|
+
helpLines: [
|
|
263
|
+
"This becomes the peer id published to the relay and seen by dedicated clients.",
|
|
264
|
+
"A random candidate name is suggested; you can keep it or edit it.",
|
|
265
|
+
],
|
|
266
|
+
currentValue: ({ cfg }) => currentString(cfg, "serverId"),
|
|
267
|
+
applyCurrentValue: true,
|
|
268
|
+
initialValue: ({ cfg }) => suggestedServerId(cfg),
|
|
269
|
+
normalizeValue: ({ value }) => value.trim(),
|
|
270
|
+
validate: ({ value }) => {
|
|
271
|
+
const trimmed = value.trim();
|
|
272
|
+
if (!trimmed)
|
|
273
|
+
return undefined;
|
|
274
|
+
return normalizeServerId(trimmed) ? undefined : "Enter a valid server id.";
|
|
275
|
+
},
|
|
276
|
+
applySet: async ({ cfg, value }) => {
|
|
277
|
+
const trimmed = value.trim();
|
|
278
|
+
return await writeSidecarAndPersistChannelPointer(cfg, {
|
|
279
|
+
serverId: trimmed ? normalizeServerId(trimmed) ?? trimmed : undefined,
|
|
280
|
+
});
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
],
|
|
284
|
+
completionNote: {
|
|
285
|
+
title: "Next step",
|
|
286
|
+
lines: [
|
|
287
|
+
"Restart the gateway after setup so the full plugin runtime loads with your new channel config.",
|
|
288
|
+
"Then use AgentDeck or another dedicated OpenClaw app with the same R2 connection details.",
|
|
289
|
+
],
|
|
290
|
+
},
|
|
291
|
+
};
|
package/dist/target.d.ts
ADDED
package/dist/target.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
const RESERVED_VALUE_CHARS = /[=,]/;
|
|
2
|
+
export function parseRelayTarget(raw) {
|
|
3
|
+
const input = raw.trim();
|
|
4
|
+
if (!input) {
|
|
5
|
+
throw new Error("Missing relay target");
|
|
6
|
+
}
|
|
7
|
+
if (!input.includes("=")) {
|
|
8
|
+
return {
|
|
9
|
+
peer: input,
|
|
10
|
+
conversationId: null,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
const pairs = input.split(",");
|
|
14
|
+
let peer = null;
|
|
15
|
+
let conversationId = null;
|
|
16
|
+
for (const pair of pairs) {
|
|
17
|
+
const trimmed = pair.trim();
|
|
18
|
+
if (!trimmed) {
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
const eqIndex = trimmed.indexOf("=");
|
|
22
|
+
if (eqIndex <= 0) {
|
|
23
|
+
throw new Error(`Invalid relay target segment: ${trimmed}`);
|
|
24
|
+
}
|
|
25
|
+
const key = trimmed.slice(0, eqIndex).trim().toLowerCase();
|
|
26
|
+
const value = trimmed.slice(eqIndex + 1).trim();
|
|
27
|
+
if (!value) {
|
|
28
|
+
throw new Error(`Missing relay target value for ${key}`);
|
|
29
|
+
}
|
|
30
|
+
if (RESERVED_VALUE_CHARS.test(value)) {
|
|
31
|
+
throw new Error(`Relay target values cannot contain ',' or '=' (${key})`);
|
|
32
|
+
}
|
|
33
|
+
if (key === "peer") {
|
|
34
|
+
peer = value;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (key === "conversation") {
|
|
38
|
+
conversationId = value;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
throw new Error(`Unsupported relay target key: ${key}`);
|
|
42
|
+
}
|
|
43
|
+
if (!peer) {
|
|
44
|
+
throw new Error("Relay target must include peer=<peer>");
|
|
45
|
+
}
|
|
46
|
+
if (!conversationId) {
|
|
47
|
+
throw new Error("Relay target must include conversation=<conversationId>");
|
|
48
|
+
}
|
|
49
|
+
return { peer, conversationId };
|
|
50
|
+
}
|
|
51
|
+
export function formatRelayTargetHint() {
|
|
52
|
+
return "peer=<peer>,conversation=<conversationId>";
|
|
53
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
|
|
3
|
+
declare const WEBHOOK_BASE_PATH = "/r2-relay-channel/webhook";
|
|
4
|
+
export declare function registerRelayWebhookRoute(api: {
|
|
5
|
+
config: OpenClawConfig;
|
|
6
|
+
logger?: {
|
|
7
|
+
info?: (message: string, meta?: Record<string, unknown>) => void;
|
|
8
|
+
warn?: (message: string, meta?: Record<string, unknown>) => void;
|
|
9
|
+
error?: (message: string, meta?: Record<string, unknown>) => void;
|
|
10
|
+
};
|
|
11
|
+
registerHttpRoute: (route: {
|
|
12
|
+
path: string;
|
|
13
|
+
auth: "plugin" | "gateway";
|
|
14
|
+
match?: "exact" | "prefix";
|
|
15
|
+
handler: (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
|
|
16
|
+
}) => void;
|
|
17
|
+
}): void;
|
|
18
|
+
export { WEBHOOK_BASE_PATH };
|
package/dist/webhook.js
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { resolveR2RelayAccount } from "./config.js";
|
|
3
|
+
import { emitRelayDebug, getOrCreateService } from "./lifecycle.js";
|
|
4
|
+
import { sendRelayPayloadMessage } from "./outbound.js";
|
|
5
|
+
const WEBHOOK_BASE_PATH = "/r2-relay-channel/webhook";
|
|
6
|
+
export function registerRelayWebhookRoute(api) {
|
|
7
|
+
api.registerHttpRoute({
|
|
8
|
+
path: WEBHOOK_BASE_PATH,
|
|
9
|
+
auth: "plugin",
|
|
10
|
+
match: "prefix",
|
|
11
|
+
handler: async (req, res) => {
|
|
12
|
+
if ((req.method ?? "GET").toUpperCase() !== "POST") {
|
|
13
|
+
respondJson(res, 405, { ok: false, error: "method_not_allowed" });
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
const expectedToken = readCronWebhookToken(api.config);
|
|
17
|
+
if (!expectedToken) {
|
|
18
|
+
respondJson(res, 503, { ok: false, error: "cron_webhook_token_not_configured" });
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
const providedToken = readBearerToken(req);
|
|
22
|
+
if (!providedToken || !safeEqual(providedToken, expectedToken)) {
|
|
23
|
+
respondJson(res, 401, { ok: false, error: "unauthorized" });
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
const target = resolveWebhookTarget(req.url);
|
|
27
|
+
if (!target) {
|
|
28
|
+
respondJson(res, 400, {
|
|
29
|
+
ok: false,
|
|
30
|
+
error: "invalid_target",
|
|
31
|
+
hint: `${WEBHOOK_BASE_PATH}/<peer>/<encoded-conversation-id>`,
|
|
32
|
+
});
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
let payload;
|
|
36
|
+
try {
|
|
37
|
+
payload = parseJsonBody(await readRequestBody(req));
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
respondJson(res, 400, {
|
|
41
|
+
ok: false,
|
|
42
|
+
error: "invalid_json",
|
|
43
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
44
|
+
});
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
const text = extractRelayText(payload);
|
|
48
|
+
const mediaUrls = extractRelayMediaUrls(payload);
|
|
49
|
+
if (!text && mediaUrls.length === 0) {
|
|
50
|
+
respondJson(res, 204, null);
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
const account = resolveR2RelayAccount({ cfg: api.config });
|
|
55
|
+
if (!account.enabled || !account.configured) {
|
|
56
|
+
respondJson(res, 503, { ok: false, error: "relay_not_configured" });
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
const route = {
|
|
60
|
+
agent_id: agentIdFromConversationId(target.conversationId) ?? "main",
|
|
61
|
+
conversation_id: target.conversationId ?? null,
|
|
62
|
+
instance_id: null,
|
|
63
|
+
};
|
|
64
|
+
let result;
|
|
65
|
+
if (mediaUrls.length > 0) {
|
|
66
|
+
emitRelayDebug(api.logger, `[${account.accountId}] webhook relay send starting`, {
|
|
67
|
+
to: target.to,
|
|
68
|
+
conversationId: target.conversationId,
|
|
69
|
+
mediaCount: mediaUrls.length,
|
|
70
|
+
hasText: Boolean(text),
|
|
71
|
+
});
|
|
72
|
+
result = await sendRelayPayloadMessage({
|
|
73
|
+
cfg: api.config,
|
|
74
|
+
to: `peer=${target.to},conversation=${target.conversationId}`,
|
|
75
|
+
payload: {
|
|
76
|
+
text,
|
|
77
|
+
mediaUrls,
|
|
78
|
+
mediaUrl: mediaUrls[0],
|
|
79
|
+
},
|
|
80
|
+
accountId: account.accountId,
|
|
81
|
+
log: api.logger,
|
|
82
|
+
source: "webhook",
|
|
83
|
+
meta: {
|
|
84
|
+
route,
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
const service = getOrCreateService(account);
|
|
90
|
+
result = await service.sendMessage(target.to, {
|
|
91
|
+
route,
|
|
92
|
+
content: {
|
|
93
|
+
type: "text",
|
|
94
|
+
text: text ?? "",
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
api.logger?.info?.("r2-relay-channel: delivered cron webhook to relay session", {
|
|
99
|
+
to: target.to,
|
|
100
|
+
conversationId: target.conversationId,
|
|
101
|
+
jobId: typeof payload.jobId === "string" ? payload.jobId : undefined,
|
|
102
|
+
status: typeof payload.status === "string" ? payload.status : undefined,
|
|
103
|
+
messageId: result.messageId,
|
|
104
|
+
});
|
|
105
|
+
respondJson(res, 200, {
|
|
106
|
+
ok: true,
|
|
107
|
+
to: target.to,
|
|
108
|
+
conversationId: target.conversationId,
|
|
109
|
+
messageId: result.messageId,
|
|
110
|
+
});
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
api.logger?.error?.("r2-relay-channel: cron webhook delivery failed", {
|
|
115
|
+
to: target.to,
|
|
116
|
+
conversationId: target.conversationId,
|
|
117
|
+
error: err instanceof Error ? err.message : String(err),
|
|
118
|
+
});
|
|
119
|
+
respondJson(res, 500, {
|
|
120
|
+
ok: false,
|
|
121
|
+
error: "delivery_failed",
|
|
122
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
123
|
+
});
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
function readCronWebhookToken(cfg) {
|
|
130
|
+
const token = cfg.cron?.webhookToken;
|
|
131
|
+
return typeof token === "string" && token.trim() ? token.trim() : null;
|
|
132
|
+
}
|
|
133
|
+
function readBearerToken(req) {
|
|
134
|
+
const header = req.headers.authorization;
|
|
135
|
+
if (typeof header !== "string") {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
const match = header.match(/^Bearer\s+(.+)$/i);
|
|
139
|
+
const token = match?.[1]?.trim();
|
|
140
|
+
return token || null;
|
|
141
|
+
}
|
|
142
|
+
function safeEqual(a, b) {
|
|
143
|
+
const left = Buffer.from(a);
|
|
144
|
+
const right = Buffer.from(b);
|
|
145
|
+
if (left.length !== right.length) {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
return crypto.timingSafeEqual(left, right);
|
|
149
|
+
}
|
|
150
|
+
function resolveWebhookTarget(rawUrl) {
|
|
151
|
+
const url = new URL(rawUrl || WEBHOOK_BASE_PATH, "http://localhost");
|
|
152
|
+
const parts = url.pathname
|
|
153
|
+
.split("/")
|
|
154
|
+
.filter(Boolean)
|
|
155
|
+
.map((part) => decodeURIComponent(part));
|
|
156
|
+
if (parts.length < 4 || parts[0] !== "r2-relay-channel" || parts[1] !== "webhook") {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
const to = parts[2]?.trim() ?? "";
|
|
160
|
+
const conversationId = parts.slice(3).join("/").trim();
|
|
161
|
+
if (!to || !conversationId) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
return { to, conversationId };
|
|
165
|
+
}
|
|
166
|
+
function agentIdFromConversationId(conversationId) {
|
|
167
|
+
const parts = conversationId.split(":");
|
|
168
|
+
if (parts.length >= 2 && parts[0] === "agent" && parts[1]?.trim()) {
|
|
169
|
+
return parts[1].trim();
|
|
170
|
+
}
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
async function readRequestBody(req) {
|
|
174
|
+
const chunks = [];
|
|
175
|
+
for await (const chunk of req) {
|
|
176
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
177
|
+
}
|
|
178
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
179
|
+
}
|
|
180
|
+
function parseJsonBody(raw) {
|
|
181
|
+
const text = raw.trim();
|
|
182
|
+
if (!text) {
|
|
183
|
+
throw new Error("request body is empty");
|
|
184
|
+
}
|
|
185
|
+
return JSON.parse(text);
|
|
186
|
+
}
|
|
187
|
+
function extractRelayText(payload) {
|
|
188
|
+
const directText = typeof payload.text === "string" ? payload.text.trim() : "";
|
|
189
|
+
if (directText) {
|
|
190
|
+
return directText;
|
|
191
|
+
}
|
|
192
|
+
const summary = typeof payload.summary === "string" ? payload.summary.trim() : "";
|
|
193
|
+
if (summary) {
|
|
194
|
+
return summary;
|
|
195
|
+
}
|
|
196
|
+
const error = typeof payload.error === "string" ? payload.error.trim() : "";
|
|
197
|
+
if (error) {
|
|
198
|
+
return error;
|
|
199
|
+
}
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
function extractRelayMediaUrls(payload) {
|
|
203
|
+
const values = [];
|
|
204
|
+
if (typeof payload.mediaUrl === "string" && payload.mediaUrl.trim()) {
|
|
205
|
+
values.push(payload.mediaUrl.trim());
|
|
206
|
+
}
|
|
207
|
+
if (Array.isArray(payload.mediaUrls)) {
|
|
208
|
+
for (const value of payload.mediaUrls) {
|
|
209
|
+
if (typeof value === "string" && value.trim()) {
|
|
210
|
+
values.push(value.trim());
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return values;
|
|
215
|
+
}
|
|
216
|
+
function respondJson(res, statusCode, body) {
|
|
217
|
+
res.statusCode = statusCode;
|
|
218
|
+
if (statusCode === 204) {
|
|
219
|
+
res.end();
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
223
|
+
res.end(JSON.stringify(body));
|
|
224
|
+
}
|
|
225
|
+
export { WEBHOOK_BASE_PATH };
|