@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/src/poll-loop.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
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
|
+
import type { RelayClient } from "./client.js";
|
|
8
|
+
import type { RelayCursorStore } from "./cursor-store.js";
|
|
9
|
+
import type { RelayInboundDeduper } from "./inbound-dedupe.js";
|
|
10
|
+
import type { RelayEvent } from "./types.js";
|
|
11
|
+
|
|
12
|
+
const TRANSIENT_BASE_DELAY_MS = 500;
|
|
13
|
+
const TRANSIENT_MAX_DELAY_MS = 30_000;
|
|
14
|
+
|
|
15
|
+
export type RelayPollLoopParams = {
|
|
16
|
+
client: RelayClient;
|
|
17
|
+
cursorStore: RelayCursorStore;
|
|
18
|
+
deduper: RelayInboundDeduper;
|
|
19
|
+
abortSignal: AbortSignal;
|
|
20
|
+
/**
|
|
21
|
+
* Perform safe preflight, then call markAttempt exactly before the first
|
|
22
|
+
* non-replayable agent/tool side effect. Errors before that callback replay;
|
|
23
|
+
* errors after it are acknowledged because tools may already have run.
|
|
24
|
+
*/
|
|
25
|
+
handleEvent: (event: RelayEvent, markAttempt: () => Promise<void>) => Promise<void>;
|
|
26
|
+
/**
|
|
27
|
+
* Cheap pre-dedupe classification: events returning false (receipts,
|
|
28
|
+
* reactions, echoes) are acked by the batch cursor without burning a
|
|
29
|
+
* dedupe claim/commit or a dispatch.
|
|
30
|
+
*/
|
|
31
|
+
shouldProcess?: (event: RelayEvent) => boolean;
|
|
32
|
+
timeoutSeconds?: number;
|
|
33
|
+
limit?: number;
|
|
34
|
+
onBatch?: (events: readonly RelayEvent[]) => void;
|
|
35
|
+
log?: (line: string) => void;
|
|
36
|
+
/** Injectable for tests. */
|
|
37
|
+
sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
|
|
38
|
+
random?: () => number;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function defaultSleep(ms: number, signal: AbortSignal): Promise<void> {
|
|
42
|
+
return new Promise((resolve) => {
|
|
43
|
+
if (signal.aborted) {
|
|
44
|
+
resolve();
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const timer = setTimeout(() => {
|
|
48
|
+
signal.removeEventListener("abort", onAbort);
|
|
49
|
+
resolve();
|
|
50
|
+
}, ms);
|
|
51
|
+
const onAbort = () => {
|
|
52
|
+
clearTimeout(timer);
|
|
53
|
+
resolve();
|
|
54
|
+
};
|
|
55
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function runRelayPollLoop(params: RelayPollLoopParams): Promise<void> {
|
|
60
|
+
const sleep = params.sleep ?? defaultSleep;
|
|
61
|
+
const random = params.random ?? Math.random;
|
|
62
|
+
const log = params.log ?? (() => {});
|
|
63
|
+
let transientAttempts = 0;
|
|
64
|
+
|
|
65
|
+
const transientDelayMs = () => {
|
|
66
|
+
const backoff = Math.min(
|
|
67
|
+
TRANSIENT_MAX_DELAY_MS,
|
|
68
|
+
TRANSIENT_BASE_DELAY_MS * 2 ** Math.min(transientAttempts, 6),
|
|
69
|
+
);
|
|
70
|
+
return backoff + Math.floor(random() * 250);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
while (!params.abortSignal.aborted) {
|
|
74
|
+
let page;
|
|
75
|
+
try {
|
|
76
|
+
page = await params.client.pollEvents({
|
|
77
|
+
cursor: params.cursorStore.current(),
|
|
78
|
+
timeoutSeconds: params.timeoutSeconds ?? 30,
|
|
79
|
+
...(params.limit === undefined ? {} : { limit: params.limit }),
|
|
80
|
+
signal: params.abortSignal,
|
|
81
|
+
});
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (params.abortSignal.aborted || isAbortError(error)) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (error instanceof RelayApiError && error.kind === "auth") {
|
|
87
|
+
// Token revoked: settle so the supervisor applies terminalDisconnect
|
|
88
|
+
// (server-channels.ts:718) — an operator has to fix the token.
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
if (error instanceof RelayApiError && error.kind === "conflict") {
|
|
92
|
+
// Another consumer took the long poll. Settle and let the
|
|
93
|
+
// supervisor's backoff arbitrate.
|
|
94
|
+
log(`[relay] long poll terminated by another consumer: ${error.message}`);
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
transientAttempts += 1;
|
|
98
|
+
log(`[relay] transient poll error (attempt ${transientAttempts}): ${String(error)}`);
|
|
99
|
+
await sleep(transientDelayMs(), params.abortSignal);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
transientAttempts = 0;
|
|
103
|
+
|
|
104
|
+
if (page.events.length > 0) {
|
|
105
|
+
params.onBatch?.(page.events);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let batchFailed = false;
|
|
109
|
+
for (const event of page.events) {
|
|
110
|
+
if (params.abortSignal.aborted) {
|
|
111
|
+
batchFailed = true;
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
if (params.shouldProcess && !params.shouldProcess(event)) {
|
|
115
|
+
// Bookkeeping events (receipts, reactions, echoes) never dispatch, so
|
|
116
|
+
// they are acked by the batch cursor without a dedupe row.
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const claimed = await params.deduper.claimEvent(event.event_id);
|
|
120
|
+
if (claimed) {
|
|
121
|
+
let attempted = false;
|
|
122
|
+
const markAttempt = async () => {
|
|
123
|
+
if (attempted) return;
|
|
124
|
+
await params.deduper.commitEvent(event.event_id);
|
|
125
|
+
attempted = true;
|
|
126
|
+
if (params.abortSignal.aborted) throw new Error("aborted after durable attempt");
|
|
127
|
+
};
|
|
128
|
+
try {
|
|
129
|
+
await params.handleEvent(event, markAttempt);
|
|
130
|
+
} catch (error) {
|
|
131
|
+
if (!attempted) {
|
|
132
|
+
params.deduper.releaseEvent(event.event_id);
|
|
133
|
+
log(`[relay] event ${event.event_id} safe preflight failed, will replay: ${String(error)}`);
|
|
134
|
+
batchFailed = true;
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
log(`[relay] event ${event.event_id} dispatch failed after its attempt was committed; ` +
|
|
138
|
+
`will not replay possible tool side effects: ${String(error)}`);
|
|
139
|
+
}
|
|
140
|
+
if (!attempted) {
|
|
141
|
+
// Admission/preflight intentionally consumed no side effect; release
|
|
142
|
+
// the claim and allow the page cursor to acknowledge it.
|
|
143
|
+
params.deduper.releaseEvent(event.event_id);
|
|
144
|
+
}
|
|
145
|
+
if (params.abortSignal.aborted) {
|
|
146
|
+
batchFailed = true;
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// The server's next_cursor covers the whole page (cursor N acks <= N), so
|
|
153
|
+
// only a batch with durable attempt markers may ack it. A marker-write
|
|
154
|
+
// failure acks nothing and committed predecessors absorb the replay.
|
|
155
|
+
if (!batchFailed) {
|
|
156
|
+
await params.cursorStore.advance(page.nextCursor);
|
|
157
|
+
} else {
|
|
158
|
+
await sleep(transientDelayMs(), params.abortSignal);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
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
|
+
import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
|
|
5
|
+
|
|
6
|
+
const { setRuntime: setRelayRuntime, getRuntime: getRelayRuntime } =
|
|
7
|
+
createPluginRuntimeStore<PluginRuntime>({
|
|
8
|
+
pluginId: "relay",
|
|
9
|
+
errorMessage: "Relay runtime not initialized",
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export { getRelayRuntime, setRelayRuntime };
|
|
13
|
+
export type { PluginRuntime };
|
package/src/security.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { RelayAgentProfile } from "./types.js";
|
|
2
|
+
|
|
3
|
+
function normalizeSenderId(value: string | number): string | null {
|
|
4
|
+
const normalized = String(value).trim();
|
|
5
|
+
return normalized ? normalized : null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Build the only identities allowed to start an OpenClaw turn. The Relay
|
|
10
|
+
* account owner is pinned from the authenticated agent profile; operators can
|
|
11
|
+
* deliberately extend that set with `allowFrom`. No wildcard is accepted.
|
|
12
|
+
*/
|
|
13
|
+
export function resolveRelayAllowedSenderIds(params: {
|
|
14
|
+
profile: Pick<RelayAgentProfile, "owner_user_id">;
|
|
15
|
+
allowFrom?: Array<string | number>;
|
|
16
|
+
}): string[] {
|
|
17
|
+
const allowed = new Set<string>();
|
|
18
|
+
const owner = params.profile.owner_user_id?.trim();
|
|
19
|
+
if (owner) {
|
|
20
|
+
allowed.add(owner);
|
|
21
|
+
}
|
|
22
|
+
for (const entry of params.allowFrom ?? []) {
|
|
23
|
+
const normalized = normalizeSenderId(entry);
|
|
24
|
+
if (normalized && normalized !== "*") {
|
|
25
|
+
allowed.add(normalized);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return [...allowed];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function relaySenderIsAllowed(
|
|
32
|
+
allowedSenderIds: readonly string[],
|
|
33
|
+
senderId: string,
|
|
34
|
+
): boolean {
|
|
35
|
+
return allowedSenderIds.includes(senderId);
|
|
36
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { jsonStore } from "@openclaw/fs-safe/store";
|
|
2
|
+
import type { JsonStore } from "@openclaw/fs-safe/store";
|
|
3
|
+
import { withFileLock } from "@openclaw/fs-safe/file-lock";
|
|
4
|
+
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
|
|
5
|
+
import { chmodSync, lstatSync, mkdirSync } from "node:fs";
|
|
6
|
+
import { hostname } from "node:os";
|
|
7
|
+
import { basename, join } from "node:path";
|
|
8
|
+
|
|
9
|
+
const RELAY_STATE_DOCUMENT_VERSION = 1;
|
|
10
|
+
const RELAY_STATE_LOCK_VERSION = 1;
|
|
11
|
+
const RELAY_STATE_LOCK_TIMEOUT_MS = 30_000;
|
|
12
|
+
|
|
13
|
+
type RelayStateLockOwner = {
|
|
14
|
+
version: typeof RELAY_STATE_LOCK_VERSION;
|
|
15
|
+
kind: "relay-state";
|
|
16
|
+
pid: number;
|
|
17
|
+
host: string;
|
|
18
|
+
createdAt: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type RelayStateDocument<T> = {
|
|
22
|
+
version: typeof RELAY_STATE_DOCUMENT_VERSION;
|
|
23
|
+
entries: Record<string, T>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function emptyRelayStateDocument<T>(): RelayStateDocument<T> {
|
|
27
|
+
return { version: RELAY_STATE_DOCUMENT_VERSION, entries: {} };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
31
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function assertRelayStateDocument<T>(
|
|
35
|
+
value: unknown,
|
|
36
|
+
label: string,
|
|
37
|
+
validateEntry: (key: string, value: unknown) => value is T,
|
|
38
|
+
): asserts value is RelayStateDocument<T> {
|
|
39
|
+
if (
|
|
40
|
+
!isRecord(value) ||
|
|
41
|
+
value.version !== RELAY_STATE_DOCUMENT_VERSION ||
|
|
42
|
+
!isRecord(value.entries)
|
|
43
|
+
) {
|
|
44
|
+
throw new Error(`relay ${label} state is corrupt`);
|
|
45
|
+
}
|
|
46
|
+
for (const [key, entry] of Object.entries(value.entries)) {
|
|
47
|
+
if (!validateEntry(key, entry)) {
|
|
48
|
+
throw new Error(`relay ${label} state is corrupt`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isRelayStateLockOwner(value: unknown): value is RelayStateLockOwner {
|
|
54
|
+
if (!isRecord(value)) return false;
|
|
55
|
+
return (
|
|
56
|
+
value.version === RELAY_STATE_LOCK_VERSION &&
|
|
57
|
+
value.kind === "relay-state" &&
|
|
58
|
+
Number.isSafeInteger(value.pid) &&
|
|
59
|
+
(value.pid as number) > 0 &&
|
|
60
|
+
typeof value.host === "string" &&
|
|
61
|
+
value.host.length > 0 &&
|
|
62
|
+
typeof value.createdAt === "string" &&
|
|
63
|
+
Number.isFinite(Date.parse(value.createdAt))
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function localProcessIsLive(pid: number): boolean {
|
|
68
|
+
try {
|
|
69
|
+
process.kill(pid, 0);
|
|
70
|
+
return true;
|
|
71
|
+
} catch (error) {
|
|
72
|
+
// EPERM proves the process exists but is owned by another user. Unknown
|
|
73
|
+
// failures also fail closed; only ESRCH proves this host no longer has it.
|
|
74
|
+
return !(error instanceof Error && "code" in error && error.code === "ESRCH");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Windows opens the sidecar lock with an openat-style
|
|
80
|
+
* `O_CREAT | O_EXCL` beneath a parent handle. While a just-released lock file
|
|
81
|
+
* is still delete-pending, that create returns `ACCESS_DENIED` instead of the
|
|
82
|
+
* `already exists` fs-safe retries on, so contention escapes the acquire loop
|
|
83
|
+
* as a hard `EACCES`. Retry those on Windows only, bounded by the caller's lock
|
|
84
|
+
* timeout: a genuine permission failure simply reproduces until the deadline
|
|
85
|
+
* and then surfaces unchanged.
|
|
86
|
+
*/
|
|
87
|
+
function isWindowsLockAcquisitionContention(error: unknown): boolean {
|
|
88
|
+
if (process.platform !== "win32") return false;
|
|
89
|
+
const code = (error as NodeJS.ErrnoException | null)?.code;
|
|
90
|
+
return code === "EACCES" || code === "EPERM";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function lockRetryDelayMs(attempt: number, remainingMs: number): number {
|
|
94
|
+
const backoff = Math.min(25 * 2 ** attempt, 250);
|
|
95
|
+
return Math.max(1, Math.min(backoff * (0.5 + Math.random() / 2), remainingMs));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function canRecoverRelayStateLock(value: unknown): boolean {
|
|
99
|
+
return (
|
|
100
|
+
isRelayStateLockOwner(value) &&
|
|
101
|
+
value.host === hostname() &&
|
|
102
|
+
!localProcessIsLive(value.pid)
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function ensurePrivateStateDirectory(path: string): void {
|
|
107
|
+
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
108
|
+
const stat = lstatSync(path);
|
|
109
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
110
|
+
throw new Error(`relay state path is not a private directory: ${path}`);
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
chmodSync(path, 0o700);
|
|
114
|
+
} catch {
|
|
115
|
+
// POSIX modes are not fully implemented on Windows. fs-safe's private
|
|
116
|
+
// write path still owns the platform-specific file guarantees.
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Relay owns these files rather than requesting OpenClaw's privileged host
|
|
122
|
+
* SQLite. jsonStore gives every mutation a private atomic replacement. An
|
|
123
|
+
* fs-safe sidecar lock serializes cross-process mutations and is recovered
|
|
124
|
+
* only when its valid Relay owner names this host and its PID is provably dead.
|
|
125
|
+
*/
|
|
126
|
+
export function openRelayStateDocument<T>(params: {
|
|
127
|
+
fileName: string;
|
|
128
|
+
env?: NodeJS.ProcessEnv;
|
|
129
|
+
lockTimeoutMs?: number;
|
|
130
|
+
}): JsonStore<RelayStateDocument<T>> {
|
|
131
|
+
if (!params.fileName || basename(params.fileName) !== params.fileName) {
|
|
132
|
+
throw new Error("relay state fileName must be one file name");
|
|
133
|
+
}
|
|
134
|
+
const lockTimeoutMs = params.lockTimeoutMs ?? RELAY_STATE_LOCK_TIMEOUT_MS;
|
|
135
|
+
if (!Number.isSafeInteger(lockTimeoutMs) || lockTimeoutMs < 1) {
|
|
136
|
+
throw new Error("relay state lockTimeoutMs must be a positive safe integer");
|
|
137
|
+
}
|
|
138
|
+
const stateRoot = resolveStateDir(params.env ?? process.env);
|
|
139
|
+
const relayRoot = join(stateRoot, "relay");
|
|
140
|
+
const relayStateRoot = join(relayRoot, "state");
|
|
141
|
+
ensurePrivateStateDirectory(relayRoot);
|
|
142
|
+
ensurePrivateStateDirectory(relayStateRoot);
|
|
143
|
+
const store = jsonStore<RelayStateDocument<T>>({
|
|
144
|
+
filePath: join(relayStateRoot, params.fileName),
|
|
145
|
+
dirMode: 0o700,
|
|
146
|
+
mode: 0o600,
|
|
147
|
+
});
|
|
148
|
+
const withMutationLock = async <R>(run: () => Promise<R>): Promise<R> => {
|
|
149
|
+
const deadline = Date.now() + lockTimeoutMs;
|
|
150
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
151
|
+
// Only acquisition is retried. Once the mutation itself has started it has
|
|
152
|
+
// observed state under the lock, so replaying it could double-apply.
|
|
153
|
+
let mutationStarted = false;
|
|
154
|
+
try {
|
|
155
|
+
return await withFileLock(
|
|
156
|
+
store.filePath,
|
|
157
|
+
{
|
|
158
|
+
managerKey: `relay-state:${store.filePath}`,
|
|
159
|
+
staleMs: RELAY_STATE_LOCK_TIMEOUT_MS,
|
|
160
|
+
timeoutMs: Math.max(1, deadline - Date.now()),
|
|
161
|
+
staleRecovery: "remove-if-unchanged",
|
|
162
|
+
retry: {
|
|
163
|
+
retries: 300,
|
|
164
|
+
minTimeout: 25,
|
|
165
|
+
maxTimeout: 250,
|
|
166
|
+
randomize: true,
|
|
167
|
+
},
|
|
168
|
+
payload: (): RelayStateLockOwner => ({
|
|
169
|
+
version: RELAY_STATE_LOCK_VERSION,
|
|
170
|
+
kind: "relay-state",
|
|
171
|
+
pid: process.pid,
|
|
172
|
+
host: hostname(),
|
|
173
|
+
createdAt: new Date().toISOString(),
|
|
174
|
+
}),
|
|
175
|
+
shouldReclaim: ({ payload }) => canRecoverRelayStateLock(payload),
|
|
176
|
+
shouldRemoveStaleLock: ({ payload }) => canRecoverRelayStateLock(payload),
|
|
177
|
+
},
|
|
178
|
+
async () => {
|
|
179
|
+
mutationStarted = true;
|
|
180
|
+
return await run();
|
|
181
|
+
},
|
|
182
|
+
);
|
|
183
|
+
} catch (error) {
|
|
184
|
+
const remaining = deadline - Date.now();
|
|
185
|
+
if (
|
|
186
|
+
mutationStarted ||
|
|
187
|
+
remaining <= 0 ||
|
|
188
|
+
!isWindowsLockAcquisitionContention(error)
|
|
189
|
+
) {
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
192
|
+
await new Promise((resolve) =>
|
|
193
|
+
setTimeout(resolve, lockRetryDelayMs(attempt, remaining)),
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
filePath: store.filePath,
|
|
201
|
+
read: store.read,
|
|
202
|
+
readOr: store.readOr,
|
|
203
|
+
readRequired: store.readRequired,
|
|
204
|
+
write: async (value) => {
|
|
205
|
+
await withMutationLock(async () => await store.write(value));
|
|
206
|
+
},
|
|
207
|
+
update: async (run) =>
|
|
208
|
+
await withMutationLock(async () => await store.update(run)),
|
|
209
|
+
updateOr: async (fallback, run) =>
|
|
210
|
+
await withMutationLock(async () => await store.updateOr(fallback, run)),
|
|
211
|
+
};
|
|
212
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// Relay wire types plus the plugin's config and resolved-account shapes. The
|
|
2
|
+
// long-poll receive contract is GET /v1/events?cursor&timeout&limit ->
|
|
3
|
+
// { events, next_cursor }, cursor N acknowledges everything <= N.
|
|
4
|
+
|
|
5
|
+
export type RelaySender = {
|
|
6
|
+
kind: "user" | "agent";
|
|
7
|
+
id: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
// Inline mention of a conversation participant. Offsets are UTF-16 code
|
|
11
|
+
// units into the part's text, which holds the inserted display name with
|
|
12
|
+
// no "@". Ranges are sorted by start and never overlap.
|
|
13
|
+
export type RelayMentionRange = {
|
|
14
|
+
start: number;
|
|
15
|
+
length: number;
|
|
16
|
+
participant_id: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type RelayTextStyle = "bold" | "italic" | "underline" | "strikethrough" | "monospace" | "spoiler";
|
|
20
|
+
|
|
21
|
+
// One formatting run over a text part, offsets in UTF-16 code units like
|
|
22
|
+
// mentions. An EMPTY styles array on the part is meaningful: it marks
|
|
23
|
+
// structured plain text as opposed to a legacy Markdown body.
|
|
24
|
+
export type RelayStyleRange = {
|
|
25
|
+
start: number;
|
|
26
|
+
length: number;
|
|
27
|
+
styles: RelayTextStyle[];
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type RelayTextPart = {
|
|
31
|
+
part_index?: number;
|
|
32
|
+
type: "text";
|
|
33
|
+
text: string;
|
|
34
|
+
mentions?: RelayMentionRange[];
|
|
35
|
+
styles?: RelayStyleRange[];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type RelayMediaPart = {
|
|
39
|
+
part_index?: number;
|
|
40
|
+
type: "media";
|
|
41
|
+
url: string;
|
|
42
|
+
attachment_id?: string;
|
|
43
|
+
// Pixel dimensions (always paired) and a blurhash placeholder (base83)
|
|
44
|
+
// to draw before the bytes download.
|
|
45
|
+
width?: number;
|
|
46
|
+
height?: number;
|
|
47
|
+
blur_hash?: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type RelayVoiceMemoPart = {
|
|
51
|
+
part_index?: number;
|
|
52
|
+
type: "voice_memo";
|
|
53
|
+
url: string;
|
|
54
|
+
attachment_id?: string;
|
|
55
|
+
duration_ms?: number;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export type RelayLinkPreviewPart = {
|
|
59
|
+
part_index?: number;
|
|
60
|
+
type: "link_preview";
|
|
61
|
+
url: string;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export type RelayDataPart = {
|
|
65
|
+
part_index?: number;
|
|
66
|
+
type: "data";
|
|
67
|
+
data: unknown;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export type RelayPart =
|
|
71
|
+
| RelayTextPart
|
|
72
|
+
| RelayMediaPart
|
|
73
|
+
| RelayVoiceMemoPart
|
|
74
|
+
| RelayLinkPreviewPart
|
|
75
|
+
| RelayDataPart;
|
|
76
|
+
|
|
77
|
+
export type RelayReplyRef = {
|
|
78
|
+
message_id?: string;
|
|
79
|
+
} | null;
|
|
80
|
+
|
|
81
|
+
export type RelayMessage = {
|
|
82
|
+
id: string;
|
|
83
|
+
conversation_id: string;
|
|
84
|
+
sequence: number;
|
|
85
|
+
sender: RelaySender;
|
|
86
|
+
parts: RelayPart[];
|
|
87
|
+
reply_to?: RelayReplyRef;
|
|
88
|
+
fallback_text: string;
|
|
89
|
+
status: string;
|
|
90
|
+
created_at: string;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export type RelayEventType =
|
|
94
|
+
| "message.received"
|
|
95
|
+
| "reaction.added"
|
|
96
|
+
| "reaction.removed"
|
|
97
|
+
| "message.delivered"
|
|
98
|
+
| "message.read"
|
|
99
|
+
| (string & {});
|
|
100
|
+
|
|
101
|
+
export type RelayEvent = {
|
|
102
|
+
event_id: string;
|
|
103
|
+
event_type: RelayEventType;
|
|
104
|
+
agent_id: string;
|
|
105
|
+
created_at: string;
|
|
106
|
+
data: {
|
|
107
|
+
message?: RelayMessage;
|
|
108
|
+
[key: string]: unknown;
|
|
109
|
+
};
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export type RelayAgentProfile = {
|
|
113
|
+
id: string;
|
|
114
|
+
owner_user_id?: string;
|
|
115
|
+
handle: string;
|
|
116
|
+
display_name: string;
|
|
117
|
+
tagline?: string;
|
|
118
|
+
avatar_url?: string | null;
|
|
119
|
+
visibility?: "private" | "unlisted" | "public";
|
|
120
|
+
created_at?: string;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export type RelayEventsPage = {
|
|
124
|
+
events: RelayEvent[];
|
|
125
|
+
nextCursor: number;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// The 202 from POST /v1/messages. The server splits the accepted parts at
|
|
129
|
+
// ingest: each visible non-media part becomes its own message, contiguous
|
|
130
|
+
// media parts stay one media message, and a voice memo always commits alone,
|
|
131
|
+
// so one send commits one or more messages, in display order.
|
|
132
|
+
export type RelaySendResult = {
|
|
133
|
+
messages: RelayMessage[];
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// Plugin config (channels.relay) and resolved account.
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
export type RelayAccountConfig = {
|
|
141
|
+
name?: string;
|
|
142
|
+
enabled?: boolean;
|
|
143
|
+
token?: string;
|
|
144
|
+
tokenFile?: string;
|
|
145
|
+
baseUrl?: string;
|
|
146
|
+
allowFrom?: Array<string | number>;
|
|
147
|
+
pollTimeoutSeconds?: number;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
export type RelayChannelConfig = RelayAccountConfig & {
|
|
151
|
+
accounts?: Record<string, Partial<RelayAccountConfig>>;
|
|
152
|
+
defaultAccount?: string;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
export type RelayCoreConfig = {
|
|
156
|
+
channels?: {
|
|
157
|
+
relay?: RelayChannelConfig;
|
|
158
|
+
};
|
|
159
|
+
session?: {
|
|
160
|
+
store?: string;
|
|
161
|
+
};
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
export type ResolvedRelayAccount = {
|
|
165
|
+
accountId: string;
|
|
166
|
+
enabled: boolean;
|
|
167
|
+
configured: boolean;
|
|
168
|
+
name?: string;
|
|
169
|
+
token: string;
|
|
170
|
+
baseUrl: string;
|
|
171
|
+
pollTimeoutSeconds: number;
|
|
172
|
+
config: RelayAccountConfig;
|
|
173
|
+
};
|