@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,108 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
renameSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from "node:fs";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
interface LockOwner {
|
|
14
|
+
pid: number;
|
|
15
|
+
nonce: string;
|
|
16
|
+
account_id: string;
|
|
17
|
+
created_at: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function processIsLive(pid: number): boolean {
|
|
21
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
22
|
+
try {
|
|
23
|
+
process.kill(pid, 0);
|
|
24
|
+
return true;
|
|
25
|
+
} catch (error: any) {
|
|
26
|
+
return error?.code === "EPERM";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readOwner(path: string): LockOwner | undefined {
|
|
31
|
+
try {
|
|
32
|
+
const value = JSON.parse(readFileSync(path, "utf8")) as Partial<LockOwner>;
|
|
33
|
+
if (
|
|
34
|
+
Number.isSafeInteger(value.pid) &&
|
|
35
|
+
typeof value.nonce === "string" &&
|
|
36
|
+
typeof value.account_id === "string" &&
|
|
37
|
+
typeof value.created_at === "string"
|
|
38
|
+
) {
|
|
39
|
+
return value as LockOwner;
|
|
40
|
+
}
|
|
41
|
+
} catch {
|
|
42
|
+
// Missing/malformed ownership is never deleted in place by a contender.
|
|
43
|
+
}
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Atomic filesystem lease preventing two OpenClaw processes polling one agent. */
|
|
48
|
+
export class RelayAccountLock {
|
|
49
|
+
private readonly lockPath: string;
|
|
50
|
+
private readonly ownerPath: string;
|
|
51
|
+
private readonly nonce = randomUUID();
|
|
52
|
+
private held = false;
|
|
53
|
+
|
|
54
|
+
constructor(
|
|
55
|
+
baseUrl: string,
|
|
56
|
+
agentId: string,
|
|
57
|
+
private readonly accountId: string,
|
|
58
|
+
baseDir = join(homedir(), ".openclaw", "relay", "consumer-locks"),
|
|
59
|
+
) {
|
|
60
|
+
const key = createHash("sha256").update(`${baseUrl}\0${agentId}`).digest("hex");
|
|
61
|
+
this.lockPath = join(baseDir, key);
|
|
62
|
+
this.ownerPath = join(this.lockPath, "owner.json");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
acquire(): void {
|
|
66
|
+
mkdirSync(join(this.lockPath, ".."), { recursive: true, mode: 0o700 });
|
|
67
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
68
|
+
try {
|
|
69
|
+
mkdirSync(this.lockPath, { mode: 0o700 });
|
|
70
|
+
const owner: LockOwner = {
|
|
71
|
+
pid: process.pid,
|
|
72
|
+
nonce: this.nonce,
|
|
73
|
+
account_id: this.accountId,
|
|
74
|
+
created_at: new Date().toISOString(),
|
|
75
|
+
};
|
|
76
|
+
writeFileSync(this.ownerPath, `${JSON.stringify(owner)}\n`, { mode: 0o600 });
|
|
77
|
+
this.held = true;
|
|
78
|
+
return;
|
|
79
|
+
} catch (error: any) {
|
|
80
|
+
if (error?.code !== "EEXIST") throw error;
|
|
81
|
+
const owner = readOwner(this.ownerPath);
|
|
82
|
+
if (!owner || processIsLive(owner.pid)) {
|
|
83
|
+
const claimant = owner
|
|
84
|
+
? `account "${owner.account_id}" (pid ${owner.pid})`
|
|
85
|
+
: "an existing process with unreadable ownership";
|
|
86
|
+
throw new Error(`relay: this agent already has an active consumer in ${claimant}`);
|
|
87
|
+
}
|
|
88
|
+
const stalePath = `${this.lockPath}.stale-${Date.now()}-${randomUUID()}`;
|
|
89
|
+
try {
|
|
90
|
+
renameSync(this.lockPath, stalePath);
|
|
91
|
+
rmSync(stalePath, { recursive: true, force: true });
|
|
92
|
+
} catch (renameError: any) {
|
|
93
|
+
if (renameError?.code !== "ENOENT") throw renameError;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
throw new Error("relay: could not acquire the agent consumer lock");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
release(): void {
|
|
101
|
+
if (!this.held) return;
|
|
102
|
+
const owner = readOwner(this.ownerPath);
|
|
103
|
+
if (owner?.nonce === this.nonce && existsSync(this.lockPath)) {
|
|
104
|
+
rmSync(this.lockPath, { recursive: true, force: true });
|
|
105
|
+
}
|
|
106
|
+
this.held = false;
|
|
107
|
+
}
|
|
108
|
+
}
|
package/src/accounts.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
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 {
|
|
5
|
+
createAccountListHelpers,
|
|
6
|
+
resolveMergedAccountConfig,
|
|
7
|
+
} from "openclaw/plugin-sdk/account-helpers";
|
|
8
|
+
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
|
9
|
+
import { tryReadSecretFileSync } from "openclaw/plugin-sdk/channel-core";
|
|
10
|
+
import { DEFAULT_RELAY_BASE_URL, normalizeRelayBaseUrl } from "./client.js";
|
|
11
|
+
import type { RelayAccountConfig, RelayCoreConfig, ResolvedRelayAccount } from "./types.js";
|
|
12
|
+
|
|
13
|
+
export const RELAY_TOKEN_ENV_VAR = "RELAY_AGENT_TOKEN";
|
|
14
|
+
export const RELAY_BASE_URL_ENV_VAR = "RELAY_BASE_URL";
|
|
15
|
+
|
|
16
|
+
const DEFAULT_POLL_TIMEOUT_SECONDS = 30;
|
|
17
|
+
|
|
18
|
+
const { listAccountIds, resolveDefaultAccountId } = createAccountListHelpers("relay", {
|
|
19
|
+
normalizeAccountId,
|
|
20
|
+
implicitDefaultAccount: {
|
|
21
|
+
channelKeys: ["token", "tokenFile"],
|
|
22
|
+
envVars: [RELAY_TOKEN_ENV_VAR],
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export {
|
|
27
|
+
listAccountIds as listRelayAccountIds,
|
|
28
|
+
resolveDefaultAccountId as resolveDefaultRelayAccountId,
|
|
29
|
+
DEFAULT_ACCOUNT_ID,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function resolveMergedRelayAccountConfig(
|
|
33
|
+
cfg: RelayCoreConfig,
|
|
34
|
+
accountId: string,
|
|
35
|
+
): RelayAccountConfig {
|
|
36
|
+
return resolveMergedAccountConfig<RelayAccountConfig>({
|
|
37
|
+
channelConfig: cfg.channels?.relay as RelayAccountConfig | undefined,
|
|
38
|
+
accounts: cfg.channels?.relay?.accounts,
|
|
39
|
+
accountId,
|
|
40
|
+
omitKeys: ["defaultAccount"],
|
|
41
|
+
normalizeAccountId,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function resolveToken(params: {
|
|
46
|
+
merged: RelayAccountConfig;
|
|
47
|
+
accountId: string;
|
|
48
|
+
env: NodeJS.ProcessEnv;
|
|
49
|
+
}): string {
|
|
50
|
+
const direct = params.merged.token?.trim();
|
|
51
|
+
if (direct) {
|
|
52
|
+
return direct;
|
|
53
|
+
}
|
|
54
|
+
const fromFile = params.merged.tokenFile
|
|
55
|
+
? tryReadSecretFileSync(params.merged.tokenFile, "relay tokenFile")?.trim()
|
|
56
|
+
: undefined;
|
|
57
|
+
if (fromFile) {
|
|
58
|
+
return fromFile;
|
|
59
|
+
}
|
|
60
|
+
// Env token applies to the default account only, so named accounts cannot
|
|
61
|
+
// silently share one token.
|
|
62
|
+
if (params.accountId === DEFAULT_ACCOUNT_ID) {
|
|
63
|
+
return params.env[RELAY_TOKEN_ENV_VAR]?.trim() ?? "";
|
|
64
|
+
}
|
|
65
|
+
return "";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function resolveRelayAccount(params: {
|
|
69
|
+
cfg: RelayCoreConfig;
|
|
70
|
+
accountId?: string | null;
|
|
71
|
+
env?: NodeJS.ProcessEnv;
|
|
72
|
+
}): ResolvedRelayAccount {
|
|
73
|
+
const env = params.env ?? process.env;
|
|
74
|
+
const accountId = normalizeAccountId(params.accountId);
|
|
75
|
+
const merged = resolveMergedRelayAccountConfig(params.cfg, accountId);
|
|
76
|
+
const baseEnabled = params.cfg.channels?.relay?.enabled !== false;
|
|
77
|
+
const enabled = baseEnabled && merged.enabled !== false;
|
|
78
|
+
const token = resolveToken({ merged, accountId, env });
|
|
79
|
+
const baseUrl = normalizeRelayBaseUrl(
|
|
80
|
+
merged.baseUrl?.trim() ||
|
|
81
|
+
(accountId === DEFAULT_ACCOUNT_ID ? env[RELAY_BASE_URL_ENV_VAR]?.trim() : undefined) ||
|
|
82
|
+
DEFAULT_RELAY_BASE_URL,
|
|
83
|
+
);
|
|
84
|
+
const pollTimeoutSeconds = Math.min(
|
|
85
|
+
Math.max(merged.pollTimeoutSeconds ?? DEFAULT_POLL_TIMEOUT_SECONDS, 1),
|
|
86
|
+
30,
|
|
87
|
+
);
|
|
88
|
+
return {
|
|
89
|
+
accountId,
|
|
90
|
+
enabled,
|
|
91
|
+
configured: Boolean(token),
|
|
92
|
+
...(merged.name?.trim() ? { name: merged.name.trim() } : {}),
|
|
93
|
+
token,
|
|
94
|
+
baseUrl,
|
|
95
|
+
pollTimeoutSeconds,
|
|
96
|
+
config: merged,
|
|
97
|
+
};
|
|
98
|
+
}
|