@relaymessenger/openclaw-plugin 0.2.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/LICENSE +21 -0
- package/README.md +141 -0
- package/dist/index.js +11 -0
- package/dist/setup-entry.js +5 -0
- package/dist/src/account-lock.js +91 -0
- package/dist/src/accounts.js +67 -0
- package/dist/src/channel.js +606 -0
- package/dist/src/client.js +219 -0
- package/dist/src/cursor-store.js +136 -0
- package/dist/src/inbound-dedupe.js +175 -0
- package/dist/src/inbound.js +94 -0
- package/dist/src/lifecycle.js +35 -0
- package/dist/src/outbound.js +98 -0
- package/dist/src/poll-loop.js +125 -0
- package/dist/src/runtime.js +8 -0
- package/dist/src/security.js +26 -0
- package/dist/src/state-files.js +167 -0
- package/dist/src/types.js +4 -0
- package/index.ts +12 -0
- package/openclaw.plugin.json +101 -0
- package/package.json +97 -0
- package/setup-entry.ts +6 -0
- package/src/account-lock.ts +108 -0
- package/src/accounts.ts +98 -0
- package/src/channel.ts +669 -0
- package/src/client.ts +313 -0
- package/src/cursor-store.ts +186 -0
- package/src/inbound-dedupe.ts +241 -0
- package/src/inbound.ts +128 -0
- package/src/lifecycle.ts +42 -0
- package/src/outbound.ts +136 -0
- package/src/poll-loop.ts +161 -0
- package/src/runtime.ts +13 -0
- package/src/security.ts +36 -0
- package/src/state-files.ts +212 -0
- package/src/types.ts +173 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Durable outbound sends: every logical send carries an
|
|
2
|
+
// Idempotency-Key; internal retries and unknown-send reconciliation replay
|
|
3
|
+
// the same key, so a retry can never duplicate a visible message
|
|
4
|
+
// (server contract: commitMessage.ts idempotent replay).
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { RelayApiError } from "./client.js";
|
|
7
|
+
/**
|
|
8
|
+
* Per-part text ceiling declared to core's renderer so long agent replies are
|
|
9
|
+
* split into multiple messages instead of truncated. Server caps a text part at 8 KiB UTF-8
|
|
10
|
+
* (server/src/domain/commitMessage.ts MAX_TEXT_BYTES); 2000 chars is safe for
|
|
11
|
+
* any UTF-8 content (4 bytes/char worst case).
|
|
12
|
+
*/
|
|
13
|
+
export const RELAY_TEXT_CHUNK_LIMIT = 2_000;
|
|
14
|
+
const IDEMPOTENCY_KEY_MAX = 255;
|
|
15
|
+
/**
|
|
16
|
+
* Idempotency key for one logical send. When core supplies a durable delivery
|
|
17
|
+
* queue id, the key is a stable function of (queueId, partIndex) so internal
|
|
18
|
+
* retries and reconciliation replay the exact same key. Without a queue id a
|
|
19
|
+
* fresh key is minted: identical intentional sends must remain distinct.
|
|
20
|
+
*/
|
|
21
|
+
export function deriveRelayIdempotencyKey(params) {
|
|
22
|
+
const queueId = params.deliveryQueueId?.trim();
|
|
23
|
+
const key = queueId
|
|
24
|
+
? `relay-send:${queueId}:${params.deliveryPartIndex ?? 0}`
|
|
25
|
+
: `relay-send:${(params.random ?? (() => crypto.randomUUID()))()}`;
|
|
26
|
+
// Server accepts 8-255 chars; the prefix guarantees the minimum.
|
|
27
|
+
if (key.length <= IDEMPOTENCY_KEY_MAX) {
|
|
28
|
+
return key;
|
|
29
|
+
}
|
|
30
|
+
// Preserve uniqueness when an opaque core queue id is unusually long; a
|
|
31
|
+
// simple prefix slice could erase the part index and collapse two chunks.
|
|
32
|
+
return `relay-send:h:${createHash("sha256").update(key).digest("hex")}`;
|
|
33
|
+
}
|
|
34
|
+
export async function sendRelayText(params) {
|
|
35
|
+
let lastError;
|
|
36
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
37
|
+
try {
|
|
38
|
+
const result = await params.client.sendMessage({
|
|
39
|
+
conversationId: params.conversationId,
|
|
40
|
+
parts: [{ type: "text", text: params.text }],
|
|
41
|
+
...(params.replyToId ? { replyTo: { message_id: params.replyToId } } : {}),
|
|
42
|
+
idempotencyKey: params.idempotencyKey,
|
|
43
|
+
...(params.signal ? { signal: params.signal } : {}),
|
|
44
|
+
});
|
|
45
|
+
const first = result.messages[0];
|
|
46
|
+
if (!first) {
|
|
47
|
+
throw new RelayApiError("relay: 202 carried no messages", { kind: "retryable" });
|
|
48
|
+
}
|
|
49
|
+
return { messageId: first.id, messages: result.messages };
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
lastError = error;
|
|
53
|
+
if (!(error instanceof RelayApiError) || !error.retryable || params.signal?.aborted) {
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
throw lastError;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Reconcile a send whose platform outcome is unknown: replay the POST with the
|
|
62
|
+
* same idempotency key and body. By server contract the replay either performs
|
|
63
|
+
* the send exactly once or returns the originally committed messages — either
|
|
64
|
+
* way the visible outcome is the one set of messages the key names, never a
|
|
65
|
+
* duplicate.
|
|
66
|
+
*/
|
|
67
|
+
export async function reconcileRelayUnknownSend(params) {
|
|
68
|
+
try {
|
|
69
|
+
const result = await sendRelayText({
|
|
70
|
+
client: params.client,
|
|
71
|
+
conversationId: params.conversationId,
|
|
72
|
+
text: params.text,
|
|
73
|
+
replyToId: params.replyToId ?? null,
|
|
74
|
+
idempotencyKey: params.idempotencyKey,
|
|
75
|
+
});
|
|
76
|
+
return { status: "sent", messageId: result.messageId, messages: result.messages };
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
if (error instanceof RelayApiError) {
|
|
80
|
+
if (error.kind === "conflict") {
|
|
81
|
+
// Key already used with a different request body: the original send
|
|
82
|
+
// reached the server but we cannot recover its receipt. Do not retry —
|
|
83
|
+
// a retry with a fresh key would duplicate the visible message.
|
|
84
|
+
return { status: "unresolved", error: error.message, retryable: false };
|
|
85
|
+
}
|
|
86
|
+
if (error.retryable) {
|
|
87
|
+
return { status: "unresolved", error: error.message, retryable: true };
|
|
88
|
+
}
|
|
89
|
+
if (error.kind === "auth") {
|
|
90
|
+
return { status: "unresolved", error: error.message, retryable: false };
|
|
91
|
+
}
|
|
92
|
+
// Deterministic rejection (403/404/422): the original request would have
|
|
93
|
+
// been rejected identically, so nothing reached the conversation.
|
|
94
|
+
return { status: "not_sent" };
|
|
95
|
+
}
|
|
96
|
+
return { status: "unresolved", error: String(error), retryable: true };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// startAccount receive engine: abort-aware long poll over GET /v1/events with
|
|
2
|
+
// claim -> durable attempt commit -> dispatch per event and the cursor advanced
|
|
3
|
+
// only after the batch's commits. The supervisor owns restart/backoff: this loop settles
|
|
4
|
+
// (throws) on terminal auth failure and consumer conflicts, and only sleeps
|
|
5
|
+
// in-loop for transient errors.
|
|
6
|
+
import { RelayApiError, isAbortError } from "./client.js";
|
|
7
|
+
const TRANSIENT_BASE_DELAY_MS = 500;
|
|
8
|
+
const TRANSIENT_MAX_DELAY_MS = 30_000;
|
|
9
|
+
function defaultSleep(ms, signal) {
|
|
10
|
+
return new Promise((resolve) => {
|
|
11
|
+
if (signal.aborted) {
|
|
12
|
+
resolve();
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
const timer = setTimeout(() => {
|
|
16
|
+
signal.removeEventListener("abort", onAbort);
|
|
17
|
+
resolve();
|
|
18
|
+
}, ms);
|
|
19
|
+
const onAbort = () => {
|
|
20
|
+
clearTimeout(timer);
|
|
21
|
+
resolve();
|
|
22
|
+
};
|
|
23
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
export async function runRelayPollLoop(params) {
|
|
27
|
+
const sleep = params.sleep ?? defaultSleep;
|
|
28
|
+
const random = params.random ?? Math.random;
|
|
29
|
+
const log = params.log ?? (() => { });
|
|
30
|
+
let transientAttempts = 0;
|
|
31
|
+
const transientDelayMs = () => {
|
|
32
|
+
const backoff = Math.min(TRANSIENT_MAX_DELAY_MS, TRANSIENT_BASE_DELAY_MS * 2 ** Math.min(transientAttempts, 6));
|
|
33
|
+
return backoff + Math.floor(random() * 250);
|
|
34
|
+
};
|
|
35
|
+
while (!params.abortSignal.aborted) {
|
|
36
|
+
let page;
|
|
37
|
+
try {
|
|
38
|
+
page = await params.client.pollEvents({
|
|
39
|
+
cursor: params.cursorStore.current(),
|
|
40
|
+
timeoutSeconds: params.timeoutSeconds ?? 30,
|
|
41
|
+
...(params.limit === undefined ? {} : { limit: params.limit }),
|
|
42
|
+
signal: params.abortSignal,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (params.abortSignal.aborted || isAbortError(error)) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (error instanceof RelayApiError && error.kind === "auth") {
|
|
50
|
+
// Token revoked: settle so the supervisor applies terminalDisconnect
|
|
51
|
+
// (server-channels.ts:718) — an operator has to fix the token.
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
if (error instanceof RelayApiError && error.kind === "conflict") {
|
|
55
|
+
// Another consumer took the long poll. Settle and let the
|
|
56
|
+
// supervisor's backoff arbitrate.
|
|
57
|
+
log(`[relay] long poll terminated by another consumer: ${error.message}`);
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
transientAttempts += 1;
|
|
61
|
+
log(`[relay] transient poll error (attempt ${transientAttempts}): ${String(error)}`);
|
|
62
|
+
await sleep(transientDelayMs(), params.abortSignal);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
transientAttempts = 0;
|
|
66
|
+
if (page.events.length > 0) {
|
|
67
|
+
params.onBatch?.(page.events);
|
|
68
|
+
}
|
|
69
|
+
let batchFailed = false;
|
|
70
|
+
for (const event of page.events) {
|
|
71
|
+
if (params.abortSignal.aborted) {
|
|
72
|
+
batchFailed = true;
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
if (params.shouldProcess && !params.shouldProcess(event)) {
|
|
76
|
+
// Bookkeeping events (receipts, reactions, echoes) never dispatch, so
|
|
77
|
+
// they are acked by the batch cursor without a dedupe row.
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const claimed = await params.deduper.claimEvent(event.event_id);
|
|
81
|
+
if (claimed) {
|
|
82
|
+
let attempted = false;
|
|
83
|
+
const markAttempt = async () => {
|
|
84
|
+
if (attempted)
|
|
85
|
+
return;
|
|
86
|
+
await params.deduper.commitEvent(event.event_id);
|
|
87
|
+
attempted = true;
|
|
88
|
+
if (params.abortSignal.aborted)
|
|
89
|
+
throw new Error("aborted after durable attempt");
|
|
90
|
+
};
|
|
91
|
+
try {
|
|
92
|
+
await params.handleEvent(event, markAttempt);
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
if (!attempted) {
|
|
96
|
+
params.deduper.releaseEvent(event.event_id);
|
|
97
|
+
log(`[relay] event ${event.event_id} safe preflight failed, will replay: ${String(error)}`);
|
|
98
|
+
batchFailed = true;
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
log(`[relay] event ${event.event_id} dispatch failed after its attempt was committed; ` +
|
|
102
|
+
`will not replay possible tool side effects: ${String(error)}`);
|
|
103
|
+
}
|
|
104
|
+
if (!attempted) {
|
|
105
|
+
// Admission/preflight intentionally consumed no side effect; release
|
|
106
|
+
// the claim and allow the page cursor to acknowledge it.
|
|
107
|
+
params.deduper.releaseEvent(event.event_id);
|
|
108
|
+
}
|
|
109
|
+
if (params.abortSignal.aborted) {
|
|
110
|
+
batchFailed = true;
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// The server's next_cursor covers the whole page (cursor N acks <= N), so
|
|
116
|
+
// only a batch with durable attempt markers may ack it. A marker-write
|
|
117
|
+
// failure acks nothing and committed predecessors absorb the replay.
|
|
118
|
+
if (!batchFailed) {
|
|
119
|
+
await params.cursorStore.advance(page.nextCursor);
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
await sleep(transientDelayMs(), params.abortSignal);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Injected plugin runtime store (qa-channel pattern): defineChannelPluginEntry
|
|
2
|
+
// calls setRelayRuntime, and gateway/inbound code reads it lazily.
|
|
3
|
+
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
|
4
|
+
const { setRuntime: setRelayRuntime, getRuntime: getRelayRuntime } = createPluginRuntimeStore({
|
|
5
|
+
pluginId: "relay",
|
|
6
|
+
errorMessage: "Relay runtime not initialized",
|
|
7
|
+
});
|
|
8
|
+
export { getRelayRuntime, setRelayRuntime };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
function normalizeSenderId(value) {
|
|
2
|
+
const normalized = String(value).trim();
|
|
3
|
+
return normalized ? normalized : null;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Build the only identities allowed to start an OpenClaw turn. The Relay
|
|
7
|
+
* account owner is pinned from the authenticated agent profile; operators can
|
|
8
|
+
* deliberately extend that set with `allowFrom`. No wildcard is accepted.
|
|
9
|
+
*/
|
|
10
|
+
export function resolveRelayAllowedSenderIds(params) {
|
|
11
|
+
const allowed = new Set();
|
|
12
|
+
const owner = params.profile.owner_user_id?.trim();
|
|
13
|
+
if (owner) {
|
|
14
|
+
allowed.add(owner);
|
|
15
|
+
}
|
|
16
|
+
for (const entry of params.allowFrom ?? []) {
|
|
17
|
+
const normalized = normalizeSenderId(entry);
|
|
18
|
+
if (normalized && normalized !== "*") {
|
|
19
|
+
allowed.add(normalized);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return [...allowed];
|
|
23
|
+
}
|
|
24
|
+
export function relaySenderIsAllowed(allowedSenderIds, senderId) {
|
|
25
|
+
return allowedSenderIds.includes(senderId);
|
|
26
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { jsonStore } from "@openclaw/fs-safe/store";
|
|
2
|
+
import { withFileLock } from "@openclaw/fs-safe/file-lock";
|
|
3
|
+
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
|
|
4
|
+
import { chmodSync, lstatSync, mkdirSync } from "node:fs";
|
|
5
|
+
import { hostname } from "node:os";
|
|
6
|
+
import { basename, join } from "node:path";
|
|
7
|
+
const RELAY_STATE_DOCUMENT_VERSION = 1;
|
|
8
|
+
const RELAY_STATE_LOCK_VERSION = 1;
|
|
9
|
+
const RELAY_STATE_LOCK_TIMEOUT_MS = 30_000;
|
|
10
|
+
export function emptyRelayStateDocument() {
|
|
11
|
+
return { version: RELAY_STATE_DOCUMENT_VERSION, entries: {} };
|
|
12
|
+
}
|
|
13
|
+
function isRecord(value) {
|
|
14
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
15
|
+
}
|
|
16
|
+
export function assertRelayStateDocument(value, label, validateEntry) {
|
|
17
|
+
if (!isRecord(value) ||
|
|
18
|
+
value.version !== RELAY_STATE_DOCUMENT_VERSION ||
|
|
19
|
+
!isRecord(value.entries)) {
|
|
20
|
+
throw new Error(`relay ${label} state is corrupt`);
|
|
21
|
+
}
|
|
22
|
+
for (const [key, entry] of Object.entries(value.entries)) {
|
|
23
|
+
if (!validateEntry(key, entry)) {
|
|
24
|
+
throw new Error(`relay ${label} state is corrupt`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function isRelayStateLockOwner(value) {
|
|
29
|
+
if (!isRecord(value))
|
|
30
|
+
return false;
|
|
31
|
+
return (value.version === RELAY_STATE_LOCK_VERSION &&
|
|
32
|
+
value.kind === "relay-state" &&
|
|
33
|
+
Number.isSafeInteger(value.pid) &&
|
|
34
|
+
value.pid > 0 &&
|
|
35
|
+
typeof value.host === "string" &&
|
|
36
|
+
value.host.length > 0 &&
|
|
37
|
+
typeof value.createdAt === "string" &&
|
|
38
|
+
Number.isFinite(Date.parse(value.createdAt)));
|
|
39
|
+
}
|
|
40
|
+
function localProcessIsLive(pid) {
|
|
41
|
+
try {
|
|
42
|
+
process.kill(pid, 0);
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
// EPERM proves the process exists but is owned by another user. Unknown
|
|
47
|
+
// failures also fail closed; only ESRCH proves this host no longer has it.
|
|
48
|
+
return !(error instanceof Error && "code" in error && error.code === "ESRCH");
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Windows opens the sidecar lock with an openat-style
|
|
53
|
+
* `O_CREAT | O_EXCL` beneath a parent handle. While a just-released lock file
|
|
54
|
+
* is still delete-pending, that create returns `ACCESS_DENIED` instead of the
|
|
55
|
+
* `already exists` fs-safe retries on, so contention escapes the acquire loop
|
|
56
|
+
* as a hard `EACCES`. Retry those on Windows only, bounded by the caller's lock
|
|
57
|
+
* timeout: a genuine permission failure simply reproduces until the deadline
|
|
58
|
+
* and then surfaces unchanged.
|
|
59
|
+
*/
|
|
60
|
+
function isWindowsLockAcquisitionContention(error) {
|
|
61
|
+
if (process.platform !== "win32")
|
|
62
|
+
return false;
|
|
63
|
+
const code = error?.code;
|
|
64
|
+
return code === "EACCES" || code === "EPERM";
|
|
65
|
+
}
|
|
66
|
+
function lockRetryDelayMs(attempt, remainingMs) {
|
|
67
|
+
const backoff = Math.min(25 * 2 ** attempt, 250);
|
|
68
|
+
return Math.max(1, Math.min(backoff * (0.5 + Math.random() / 2), remainingMs));
|
|
69
|
+
}
|
|
70
|
+
function canRecoverRelayStateLock(value) {
|
|
71
|
+
return (isRelayStateLockOwner(value) &&
|
|
72
|
+
value.host === hostname() &&
|
|
73
|
+
!localProcessIsLive(value.pid));
|
|
74
|
+
}
|
|
75
|
+
function ensurePrivateStateDirectory(path) {
|
|
76
|
+
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
77
|
+
const stat = lstatSync(path);
|
|
78
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
79
|
+
throw new Error(`relay state path is not a private directory: ${path}`);
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
chmodSync(path, 0o700);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// POSIX modes are not fully implemented on Windows. fs-safe's private
|
|
86
|
+
// write path still owns the platform-specific file guarantees.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Relay owns these files rather than requesting OpenClaw's privileged host
|
|
91
|
+
* SQLite. jsonStore gives every mutation a private atomic replacement. An
|
|
92
|
+
* fs-safe sidecar lock serializes cross-process mutations and is recovered
|
|
93
|
+
* only when its valid Relay owner names this host and its PID is provably dead.
|
|
94
|
+
*/
|
|
95
|
+
export function openRelayStateDocument(params) {
|
|
96
|
+
if (!params.fileName || basename(params.fileName) !== params.fileName) {
|
|
97
|
+
throw new Error("relay state fileName must be one file name");
|
|
98
|
+
}
|
|
99
|
+
const lockTimeoutMs = params.lockTimeoutMs ?? RELAY_STATE_LOCK_TIMEOUT_MS;
|
|
100
|
+
if (!Number.isSafeInteger(lockTimeoutMs) || lockTimeoutMs < 1) {
|
|
101
|
+
throw new Error("relay state lockTimeoutMs must be a positive safe integer");
|
|
102
|
+
}
|
|
103
|
+
const stateRoot = resolveStateDir(params.env ?? process.env);
|
|
104
|
+
const relayRoot = join(stateRoot, "relay");
|
|
105
|
+
const relayStateRoot = join(relayRoot, "state");
|
|
106
|
+
ensurePrivateStateDirectory(relayRoot);
|
|
107
|
+
ensurePrivateStateDirectory(relayStateRoot);
|
|
108
|
+
const store = jsonStore({
|
|
109
|
+
filePath: join(relayStateRoot, params.fileName),
|
|
110
|
+
dirMode: 0o700,
|
|
111
|
+
mode: 0o600,
|
|
112
|
+
});
|
|
113
|
+
const withMutationLock = async (run) => {
|
|
114
|
+
const deadline = Date.now() + lockTimeoutMs;
|
|
115
|
+
for (let attempt = 0;; attempt += 1) {
|
|
116
|
+
// Only acquisition is retried. Once the mutation itself has started it has
|
|
117
|
+
// observed state under the lock, so replaying it could double-apply.
|
|
118
|
+
let mutationStarted = false;
|
|
119
|
+
try {
|
|
120
|
+
return await withFileLock(store.filePath, {
|
|
121
|
+
managerKey: `relay-state:${store.filePath}`,
|
|
122
|
+
staleMs: RELAY_STATE_LOCK_TIMEOUT_MS,
|
|
123
|
+
timeoutMs: Math.max(1, deadline - Date.now()),
|
|
124
|
+
staleRecovery: "remove-if-unchanged",
|
|
125
|
+
retry: {
|
|
126
|
+
retries: 300,
|
|
127
|
+
minTimeout: 25,
|
|
128
|
+
maxTimeout: 250,
|
|
129
|
+
randomize: true,
|
|
130
|
+
},
|
|
131
|
+
payload: () => ({
|
|
132
|
+
version: RELAY_STATE_LOCK_VERSION,
|
|
133
|
+
kind: "relay-state",
|
|
134
|
+
pid: process.pid,
|
|
135
|
+
host: hostname(),
|
|
136
|
+
createdAt: new Date().toISOString(),
|
|
137
|
+
}),
|
|
138
|
+
shouldReclaim: ({ payload }) => canRecoverRelayStateLock(payload),
|
|
139
|
+
shouldRemoveStaleLock: ({ payload }) => canRecoverRelayStateLock(payload),
|
|
140
|
+
}, async () => {
|
|
141
|
+
mutationStarted = true;
|
|
142
|
+
return await run();
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
const remaining = deadline - Date.now();
|
|
147
|
+
if (mutationStarted ||
|
|
148
|
+
remaining <= 0 ||
|
|
149
|
+
!isWindowsLockAcquisitionContention(error)) {
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
await new Promise((resolve) => setTimeout(resolve, lockRetryDelayMs(attempt, remaining)));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
return {
|
|
157
|
+
filePath: store.filePath,
|
|
158
|
+
read: store.read,
|
|
159
|
+
readOr: store.readOr,
|
|
160
|
+
readRequired: store.readRequired,
|
|
161
|
+
write: async (value) => {
|
|
162
|
+
await withMutationLock(async () => await store.write(value));
|
|
163
|
+
},
|
|
164
|
+
update: async (run) => await withMutationLock(async () => await store.update(run)),
|
|
165
|
+
updateOr: async (fallback, run) => await withMutationLock(async () => await store.updateOr(fallback, run)),
|
|
166
|
+
};
|
|
167
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Relay channel plugin entrypoint registers the OpenClaw integration.
|
|
2
|
+
import { defineChannelPluginEntry } from "openclaw/plugin-sdk/channel-core";
|
|
3
|
+
import { relayChannelPlugin } from "./src/channel.js";
|
|
4
|
+
import { setRelayRuntime } from "./src/runtime.js";
|
|
5
|
+
|
|
6
|
+
export default defineChannelPluginEntry({
|
|
7
|
+
id: "relay",
|
|
8
|
+
name: "Relay",
|
|
9
|
+
description: "Relay channel plugin. Text your OpenClaw like a friend.",
|
|
10
|
+
plugin: relayChannelPlugin,
|
|
11
|
+
setRuntime: setRelayRuntime,
|
|
12
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "relay",
|
|
3
|
+
"name": "Relay",
|
|
4
|
+
"description": "Relay channel plugin. Connects an OpenClaw agent to a Relay contact.",
|
|
5
|
+
"activation": {
|
|
6
|
+
"onStartup": false
|
|
7
|
+
},
|
|
8
|
+
"channels": [
|
|
9
|
+
"relay"
|
|
10
|
+
],
|
|
11
|
+
"configSchema": {
|
|
12
|
+
"type": "object",
|
|
13
|
+
"additionalProperties": false,
|
|
14
|
+
"properties": {}
|
|
15
|
+
},
|
|
16
|
+
"channelConfigs": {
|
|
17
|
+
"relay": {
|
|
18
|
+
"label": "Relay",
|
|
19
|
+
"description": "Back a Relay contact with this OpenClaw agent.",
|
|
20
|
+
"schema": {
|
|
21
|
+
"type": "object",
|
|
22
|
+
"additionalProperties": false,
|
|
23
|
+
"properties": {
|
|
24
|
+
"name": {
|
|
25
|
+
"type": "string"
|
|
26
|
+
},
|
|
27
|
+
"enabled": {
|
|
28
|
+
"type": "boolean"
|
|
29
|
+
},
|
|
30
|
+
"token": {
|
|
31
|
+
"type": "string"
|
|
32
|
+
},
|
|
33
|
+
"tokenFile": {
|
|
34
|
+
"type": "string"
|
|
35
|
+
},
|
|
36
|
+
"baseUrl": {
|
|
37
|
+
"type": "string"
|
|
38
|
+
},
|
|
39
|
+
"allowFrom": {
|
|
40
|
+
"type": "array",
|
|
41
|
+
"items": {
|
|
42
|
+
"type": "string"
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"pollTimeoutSeconds": {
|
|
46
|
+
"type": "number",
|
|
47
|
+
"minimum": 1,
|
|
48
|
+
"maximum": 30
|
|
49
|
+
},
|
|
50
|
+
"defaultAccount": {
|
|
51
|
+
"type": "string"
|
|
52
|
+
},
|
|
53
|
+
"accounts": {
|
|
54
|
+
"type": "object",
|
|
55
|
+
"additionalProperties": {
|
|
56
|
+
"type": "object",
|
|
57
|
+
"additionalProperties": false,
|
|
58
|
+
"properties": {
|
|
59
|
+
"name": {
|
|
60
|
+
"type": "string"
|
|
61
|
+
},
|
|
62
|
+
"enabled": {
|
|
63
|
+
"type": "boolean"
|
|
64
|
+
},
|
|
65
|
+
"token": {
|
|
66
|
+
"type": "string"
|
|
67
|
+
},
|
|
68
|
+
"tokenFile": {
|
|
69
|
+
"type": "string"
|
|
70
|
+
},
|
|
71
|
+
"baseUrl": {
|
|
72
|
+
"type": "string"
|
|
73
|
+
},
|
|
74
|
+
"allowFrom": {
|
|
75
|
+
"type": "array",
|
|
76
|
+
"items": {
|
|
77
|
+
"type": "string"
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
"pollTimeoutSeconds": {
|
|
81
|
+
"type": "number",
|
|
82
|
+
"minimum": 1,
|
|
83
|
+
"maximum": 30
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
"uiHints": {
|
|
91
|
+
"token": {
|
|
92
|
+
"label": "Agent Token",
|
|
93
|
+
"sensitive": true
|
|
94
|
+
},
|
|
95
|
+
"baseUrl": {
|
|
96
|
+
"label": "Relay API base URL"
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@relaymessenger/openclaw-plugin",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Relay channel plugin for OpenClaw. Text your OpenClaw like a friend.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"keywords": [
|
|
10
|
+
"openclaw",
|
|
11
|
+
"relay",
|
|
12
|
+
"messaging",
|
|
13
|
+
"agent"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=22.22.3 <23 || >=24.15.0 <25 || >=25.9.0"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/relaymessenger/Relay-SDK.git",
|
|
21
|
+
"directory": "integrations/openclaw"
|
|
22
|
+
},
|
|
23
|
+
"type": "module",
|
|
24
|
+
"files": [
|
|
25
|
+
"README.md",
|
|
26
|
+
"index.ts",
|
|
27
|
+
"setup-entry.ts",
|
|
28
|
+
"openclaw.plugin.json",
|
|
29
|
+
"src/**/*.ts",
|
|
30
|
+
"!src/**/*.test.ts",
|
|
31
|
+
"dist/**/*.js"
|
|
32
|
+
],
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "tsc -p tsconfig.build.json",
|
|
35
|
+
"check": "tsc --noEmit",
|
|
36
|
+
"test": "vitest run",
|
|
37
|
+
"pack:smoke": "node scripts/pack-smoke.mjs",
|
|
38
|
+
"gateway:harness": "node scripts/gateway-harness.mjs",
|
|
39
|
+
"prepack": "npm run build"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@openclaw/fs-safe": "0.5.5"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/node": "^26.2.0",
|
|
46
|
+
"openclaw": "2026.7.2-beta.7",
|
|
47
|
+
"typescript": "^7.0.2",
|
|
48
|
+
"vitest": "^4.1.10"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"openclaw": ">=2026.7.2-beta.5"
|
|
52
|
+
},
|
|
53
|
+
"peerDependenciesMeta": {
|
|
54
|
+
"openclaw": {
|
|
55
|
+
"optional": true
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"openclaw": {
|
|
59
|
+
"compat": {
|
|
60
|
+
"pluginApi": ">=2026.7.2-beta.5"
|
|
61
|
+
},
|
|
62
|
+
"install": {
|
|
63
|
+
"localPath": ".",
|
|
64
|
+
"defaultChoice": "local",
|
|
65
|
+
"minHostVersion": ">=2026.7.2-beta.5"
|
|
66
|
+
},
|
|
67
|
+
"build": {
|
|
68
|
+
"openclawVersion": "2026.7.2-beta.7"
|
|
69
|
+
},
|
|
70
|
+
"extensions": [
|
|
71
|
+
"./index.ts"
|
|
72
|
+
],
|
|
73
|
+
"runtimeExtensions": [
|
|
74
|
+
"./dist/index.js"
|
|
75
|
+
],
|
|
76
|
+
"setupEntry": "./setup-entry.ts",
|
|
77
|
+
"runtimeSetupEntry": "./dist/setup-entry.js",
|
|
78
|
+
"channel": {
|
|
79
|
+
"id": "relay",
|
|
80
|
+
"configuredState": {
|
|
81
|
+
"env": {
|
|
82
|
+
"anyOf": [
|
|
83
|
+
"RELAY_AGENT_TOKEN"
|
|
84
|
+
]
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
"label": "Relay",
|
|
88
|
+
"selectionLabel": "Relay",
|
|
89
|
+
"detailLabel": "Relay",
|
|
90
|
+
"docsPath": "https://docs.relayapp.im/integrations/openclaw",
|
|
91
|
+
"docsLabel": "documentation",
|
|
92
|
+
"blurb": "Text your OpenClaw like a friend.",
|
|
93
|
+
"systemImage": "message",
|
|
94
|
+
"markdownCapable": false
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
package/setup-entry.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Lightweight setup entry: loaded instead of the full entry while the channel
|
|
2
|
+
// is disabled/unconfigured, so status/config surfaces avoid runtime imports.
|
|
3
|
+
import { defineSetupPluginEntry } from "openclaw/plugin-sdk/channel-core";
|
|
4
|
+
import { relayChannelPlugin } from "./src/channel.js";
|
|
5
|
+
|
|
6
|
+
export default defineSetupPluginEntry(relayChannelPlugin);
|