@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Companion Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# Relay channel plugin for OpenClaw
|
|
2
|
+
|
|
3
|
+
Backs a Relay contact with an OpenClaw agent: install the plugin, point it at
|
|
4
|
+
an owner-only Agent Token file, and your OpenClaw appears in Relay as a contact
|
|
5
|
+
you text like a friend.
|
|
6
|
+
|
|
7
|
+
Requires `openclaw >= 2026.7.2-beta.5`, which today means the OpenClaw beta
|
|
8
|
+
channel: stable `2026.7.1-2` is too old, because the plugin derives its
|
|
9
|
+
idempotency keys from the per-part delivery context that only the beta line
|
|
10
|
+
provides. Install it with `npm install -g openclaw@beta`.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
npm install -g @relaymessenger/cli
|
|
16
|
+
relaymessenger pair
|
|
17
|
+
relaymessenger install-openclaw
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The installer uses the OpenClaw archive bundled in the installed `relaymessenger`
|
|
21
|
+
package, persists that archive in the paired account's private Relay runtime,
|
|
22
|
+
and invokes OpenClaw's managed `npm-pack:` installer on the stable copy so
|
|
23
|
+
declared runtime dependencies are installed with the plugin. It surgically
|
|
24
|
+
adds Relay to `~/.openclaw/openclaw.json`, preserves unrelated configuration,
|
|
25
|
+
writes the token to an owner-only file, and never prints it. It refuses to
|
|
26
|
+
replace a different configured Relay identity.
|
|
27
|
+
|
|
28
|
+
For integration development from this checkout only:
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
cd integrations/openclaw
|
|
32
|
+
npm install
|
|
33
|
+
npm pack
|
|
34
|
+
openclaw plugins install npm-pack:./relaymessenger-openclaw-plugin-0.1.0.tgz --force
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The installer produces the equivalent Relay-specific configuration:
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{
|
|
41
|
+
"plugins": {
|
|
42
|
+
"allow": ["relay"],
|
|
43
|
+
"entries": { "relay": { "enabled": true } }
|
|
44
|
+
},
|
|
45
|
+
"channels": {
|
|
46
|
+
"relay": {
|
|
47
|
+
"enabled": true,
|
|
48
|
+
"tokenFile": "~/.openclaw/secrets/relay-agent-token",
|
|
49
|
+
"baseUrl": "https://api.relayapp.im"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`RELAY_AGENT_TOKEN` / `RELAY_BASE_URL` are honored for the default account
|
|
56
|
+
when the config fields are absent. Multiple agents run as named accounts under
|
|
57
|
+
`channels.relay.accounts.<id>`, one token each.
|
|
58
|
+
|
|
59
|
+
## Sender security
|
|
60
|
+
|
|
61
|
+
Relay agents can be discoverable, so adding an agent as a contact is not an
|
|
62
|
+
authorization boundary. The plugin accepts inbound turns only from the
|
|
63
|
+
authenticated agent's `owner_user_id` returned by `GET /v1/agents/me` and any
|
|
64
|
+
Relay user ids the operator explicitly adds to `allowFrom`. Wildcards are
|
|
65
|
+
ignored. If neither an API owner nor an explicit allowlist is available, the
|
|
66
|
+
account fails closed and does not start polling.
|
|
67
|
+
|
|
68
|
+
## One consumer per token
|
|
69
|
+
|
|
70
|
+
Relay's `GET /v1/events` long poll allows **exactly one consumer per Agent
|
|
71
|
+
Token**:
|
|
72
|
+
|
|
73
|
+
- A newer poll takes the slot; the older consumer's request ends with
|
|
74
|
+
`409 terminated_by_other_consumer`. Running this plugin and another
|
|
75
|
+
long-poll consumer (for example `relaymessenger start`, or a second OpenClaw) on
|
|
76
|
+
the same token makes them steal the slot from each other forever. Give each
|
|
77
|
+
consumer its own agent/token.
|
|
78
|
+
- Long polling is **XOR with webhooks**: while a webhook endpoint is enabled
|
|
79
|
+
for the agent, `/v1/events` returns `409 conflict` and the channel stops
|
|
80
|
+
with a terminal disconnect. Disable or delete the agent's webhooks to poll.
|
|
81
|
+
|
|
82
|
+
The plugin also refuses to start a second configured account that resolves to
|
|
83
|
+
the same agent id as a running one.
|
|
84
|
+
|
|
85
|
+
## Config reference (per account)
|
|
86
|
+
|
|
87
|
+
| Field | Meaning |
|
|
88
|
+
| --- | --- |
|
|
89
|
+
| `token` / `tokenFile` | Agent Token (or file containing it) |
|
|
90
|
+
| `baseUrl` | Relay API origin (default `https://api.relayapp.im`). Remote origins must use HTTPS; HTTP is accepted only for loopback development. Paths, credentials, queries, and fragments are rejected. |
|
|
91
|
+
| `allowFrom` | Additional Relay user ids (`usr_…`) allowed alongside the API-pinned owner. These identities may also run control commands. Wildcards are ignored. |
|
|
92
|
+
| `pollTimeoutSeconds` | Long-poll hold time, 1–30 (default 30) |
|
|
93
|
+
|
|
94
|
+
## Harness
|
|
95
|
+
|
|
96
|
+
Run `npm run gateway:harness` from the repository root for the clean installed-
|
|
97
|
+
runtime proof. It packs the plugin, installs it into an isolated `HOME`, starts
|
|
98
|
+
a real OpenClaw gateway against `harness/mock-relay-server.mjs`, receives a
|
|
99
|
+
Relay event, completes a mock model turn, and verifies the reply reaches
|
|
100
|
+
Relay. The release workflow requires this proof.
|
|
101
|
+
|
|
102
|
+
## Delivery and crash semantics
|
|
103
|
+
|
|
104
|
+
- Every outbound platform send has a logical-send idempotency key. Durable
|
|
105
|
+
queue retries reuse the same key, while intentional identical messages and
|
|
106
|
+
identical chunks remain distinct.
|
|
107
|
+
- Admission, route/session resolution, envelope building, and context
|
|
108
|
+
finalization run before an inbound event is marked attempted. Failures in
|
|
109
|
+
that replay-safe preflight release the claim and retry the event. The marker
|
|
110
|
+
is committed durably immediately before OpenClaw can dispatch the agent or
|
|
111
|
+
its tools; a failure after that boundary does not silently replay possible
|
|
112
|
+
tool side effects. The user can resend the message deliberately.
|
|
113
|
+
- The long-poll cursor and inbound attempt keys are bound to the canonical
|
|
114
|
+
Relay API origin plus Relay agent id, not to a mutable local account label.
|
|
115
|
+
Renaming an account therefore retains its cursor. A missing identity starts
|
|
116
|
+
at cursor zero; corrupt, mismatched, unreadable, or unwritable state fails
|
|
117
|
+
closed instead of replaying retained history. The cursor namespace rejects
|
|
118
|
+
new identities at capacity rather than evicting an older cursor. Cursor
|
|
119
|
+
state is independent of the bounded 30-day attempt-dedupe horizon. Both
|
|
120
|
+
namespaces are private, lock-protected, atomically replaced Relay-owned
|
|
121
|
+
files under `$OPENCLAW_STATE_DIR/relay/state` (normally
|
|
122
|
+
`~/.openclaw/relay/state`); the plugin never requests trusted-only OpenClaw
|
|
123
|
+
host SQLite access.
|
|
124
|
+
- Before polling, the plugin takes an atomic per-origin/per-agent filesystem
|
|
125
|
+
lock under `~/.openclaw/relay/consumer-locks`. A second OpenClaw process
|
|
126
|
+
fails closed; a lock whose recorded PID is dead is recovered on startup.
|
|
127
|
+
Shutdown aborts the active long poll and releases both process-local and
|
|
128
|
+
filesystem ownership before a replacement starts.
|
|
129
|
+
- Every API operation has a deadline (15 seconds for ordinary calls; the
|
|
130
|
+
configured long-poll hold plus 15 seconds for event polling). Retrying a
|
|
131
|
+
message send reuses its logical delivery idempotency key.
|
|
132
|
+
|
|
133
|
+
## v1 scope
|
|
134
|
+
|
|
135
|
+
Direct conversations only. Inbound text renders as-is; inbound media and
|
|
136
|
+
voice memos render as a labeled fetchable capability URL (the URL is itself
|
|
137
|
+
the authorization, so no Agent Token is needed to fetch the bytes) rather
|
|
138
|
+
than the agent seeing the file inline; reactions are observe-only; receipts
|
|
139
|
+
(`message.delivered`/`message.read`) never start a turn. Final agent replies
|
|
140
|
+
are delivered durably (chunked to Relay's 8 KiB per-part cap,
|
|
141
|
+
idempotency-keyed, retry-safe).
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
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
|
+
export default defineChannelPluginEntry({
|
|
6
|
+
id: "relay",
|
|
7
|
+
name: "Relay",
|
|
8
|
+
description: "Relay channel plugin. Text your OpenClaw like a friend.",
|
|
9
|
+
plugin: relayChannelPlugin,
|
|
10
|
+
setRuntime: setRelayRuntime,
|
|
11
|
+
});
|
|
@@ -0,0 +1,5 @@
|
|
|
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
|
+
export default defineSetupPluginEntry(relayChannelPlugin);
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
function processIsLive(pid) {
|
|
6
|
+
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
7
|
+
return false;
|
|
8
|
+
try {
|
|
9
|
+
process.kill(pid, 0);
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
return error?.code === "EPERM";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function readOwner(path) {
|
|
17
|
+
try {
|
|
18
|
+
const value = JSON.parse(readFileSync(path, "utf8"));
|
|
19
|
+
if (Number.isSafeInteger(value.pid) &&
|
|
20
|
+
typeof value.nonce === "string" &&
|
|
21
|
+
typeof value.account_id === "string" &&
|
|
22
|
+
typeof value.created_at === "string") {
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// Missing/malformed ownership is never deleted in place by a contender.
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
/** Atomic filesystem lease preventing two OpenClaw processes polling one agent. */
|
|
32
|
+
export class RelayAccountLock {
|
|
33
|
+
accountId;
|
|
34
|
+
lockPath;
|
|
35
|
+
ownerPath;
|
|
36
|
+
nonce = randomUUID();
|
|
37
|
+
held = false;
|
|
38
|
+
constructor(baseUrl, agentId, accountId, baseDir = join(homedir(), ".openclaw", "relay", "consumer-locks")) {
|
|
39
|
+
this.accountId = accountId;
|
|
40
|
+
const key = createHash("sha256").update(`${baseUrl}\0${agentId}`).digest("hex");
|
|
41
|
+
this.lockPath = join(baseDir, key);
|
|
42
|
+
this.ownerPath = join(this.lockPath, "owner.json");
|
|
43
|
+
}
|
|
44
|
+
acquire() {
|
|
45
|
+
mkdirSync(join(this.lockPath, ".."), { recursive: true, mode: 0o700 });
|
|
46
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
47
|
+
try {
|
|
48
|
+
mkdirSync(this.lockPath, { mode: 0o700 });
|
|
49
|
+
const owner = {
|
|
50
|
+
pid: process.pid,
|
|
51
|
+
nonce: this.nonce,
|
|
52
|
+
account_id: this.accountId,
|
|
53
|
+
created_at: new Date().toISOString(),
|
|
54
|
+
};
|
|
55
|
+
writeFileSync(this.ownerPath, `${JSON.stringify(owner)}\n`, { mode: 0o600 });
|
|
56
|
+
this.held = true;
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
if (error?.code !== "EEXIST")
|
|
61
|
+
throw error;
|
|
62
|
+
const owner = readOwner(this.ownerPath);
|
|
63
|
+
if (!owner || processIsLive(owner.pid)) {
|
|
64
|
+
const claimant = owner
|
|
65
|
+
? `account "${owner.account_id}" (pid ${owner.pid})`
|
|
66
|
+
: "an existing process with unreadable ownership";
|
|
67
|
+
throw new Error(`relay: this agent already has an active consumer in ${claimant}`);
|
|
68
|
+
}
|
|
69
|
+
const stalePath = `${this.lockPath}.stale-${Date.now()}-${randomUUID()}`;
|
|
70
|
+
try {
|
|
71
|
+
renameSync(this.lockPath, stalePath);
|
|
72
|
+
rmSync(stalePath, { recursive: true, force: true });
|
|
73
|
+
}
|
|
74
|
+
catch (renameError) {
|
|
75
|
+
if (renameError?.code !== "ENOENT")
|
|
76
|
+
throw renameError;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
throw new Error("relay: could not acquire the agent consumer lock");
|
|
81
|
+
}
|
|
82
|
+
release() {
|
|
83
|
+
if (!this.held)
|
|
84
|
+
return;
|
|
85
|
+
const owner = readOwner(this.ownerPath);
|
|
86
|
+
if (owner?.nonce === this.nonce && existsSync(this.lockPath)) {
|
|
87
|
+
rmSync(this.lockPath, { recursive: true, force: true });
|
|
88
|
+
}
|
|
89
|
+
this.held = false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Multi-account resolution: channels.relay.accounts.<id> with a
|
|
2
|
+
// default-account fallback, so one OpenClaw can back several Relay contacts
|
|
3
|
+
// (one Agent Token each). Env vars cover the single-account quickstart.
|
|
4
|
+
import { createAccountListHelpers, resolveMergedAccountConfig, } from "openclaw/plugin-sdk/account-helpers";
|
|
5
|
+
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
|
6
|
+
import { tryReadSecretFileSync } from "openclaw/plugin-sdk/channel-core";
|
|
7
|
+
import { DEFAULT_RELAY_BASE_URL, normalizeRelayBaseUrl } from "./client.js";
|
|
8
|
+
export const RELAY_TOKEN_ENV_VAR = "RELAY_AGENT_TOKEN";
|
|
9
|
+
export const RELAY_BASE_URL_ENV_VAR = "RELAY_BASE_URL";
|
|
10
|
+
const DEFAULT_POLL_TIMEOUT_SECONDS = 30;
|
|
11
|
+
const { listAccountIds, resolveDefaultAccountId } = createAccountListHelpers("relay", {
|
|
12
|
+
normalizeAccountId,
|
|
13
|
+
implicitDefaultAccount: {
|
|
14
|
+
channelKeys: ["token", "tokenFile"],
|
|
15
|
+
envVars: [RELAY_TOKEN_ENV_VAR],
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
export { listAccountIds as listRelayAccountIds, resolveDefaultAccountId as resolveDefaultRelayAccountId, DEFAULT_ACCOUNT_ID, };
|
|
19
|
+
function resolveMergedRelayAccountConfig(cfg, accountId) {
|
|
20
|
+
return resolveMergedAccountConfig({
|
|
21
|
+
channelConfig: cfg.channels?.relay,
|
|
22
|
+
accounts: cfg.channels?.relay?.accounts,
|
|
23
|
+
accountId,
|
|
24
|
+
omitKeys: ["defaultAccount"],
|
|
25
|
+
normalizeAccountId,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
function resolveToken(params) {
|
|
29
|
+
const direct = params.merged.token?.trim();
|
|
30
|
+
if (direct) {
|
|
31
|
+
return direct;
|
|
32
|
+
}
|
|
33
|
+
const fromFile = params.merged.tokenFile
|
|
34
|
+
? tryReadSecretFileSync(params.merged.tokenFile, "relay tokenFile")?.trim()
|
|
35
|
+
: undefined;
|
|
36
|
+
if (fromFile) {
|
|
37
|
+
return fromFile;
|
|
38
|
+
}
|
|
39
|
+
// Env token applies to the default account only, so named accounts cannot
|
|
40
|
+
// silently share one token.
|
|
41
|
+
if (params.accountId === DEFAULT_ACCOUNT_ID) {
|
|
42
|
+
return params.env[RELAY_TOKEN_ENV_VAR]?.trim() ?? "";
|
|
43
|
+
}
|
|
44
|
+
return "";
|
|
45
|
+
}
|
|
46
|
+
export function resolveRelayAccount(params) {
|
|
47
|
+
const env = params.env ?? process.env;
|
|
48
|
+
const accountId = normalizeAccountId(params.accountId);
|
|
49
|
+
const merged = resolveMergedRelayAccountConfig(params.cfg, accountId);
|
|
50
|
+
const baseEnabled = params.cfg.channels?.relay?.enabled !== false;
|
|
51
|
+
const enabled = baseEnabled && merged.enabled !== false;
|
|
52
|
+
const token = resolveToken({ merged, accountId, env });
|
|
53
|
+
const baseUrl = normalizeRelayBaseUrl(merged.baseUrl?.trim() ||
|
|
54
|
+
(accountId === DEFAULT_ACCOUNT_ID ? env[RELAY_BASE_URL_ENV_VAR]?.trim() : undefined) ||
|
|
55
|
+
DEFAULT_RELAY_BASE_URL);
|
|
56
|
+
const pollTimeoutSeconds = Math.min(Math.max(merged.pollTimeoutSeconds ?? DEFAULT_POLL_TIMEOUT_SECONDS, 1), 30);
|
|
57
|
+
return {
|
|
58
|
+
accountId,
|
|
59
|
+
enabled,
|
|
60
|
+
configured: Boolean(token),
|
|
61
|
+
...(merged.name?.trim() ? { name: merged.name.trim() } : {}),
|
|
62
|
+
token,
|
|
63
|
+
baseUrl,
|
|
64
|
+
pollTimeoutSeconds,
|
|
65
|
+
config: merged,
|
|
66
|
+
};
|
|
67
|
+
}
|