@relaymessenger/openclaw-plugin 0.3.4 → 0.4.0-staging.1
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 +1 -1
- package/README.md +162 -123
- package/contracts/relay-sdk-0.3.0-staging.5.registry.json +69 -0
- package/contracts/relay-v1.lock.json +77 -0
- package/dist/index.js +2 -2
- package/dist/setup-entry.js +1 -2
- package/dist/src/accounts.js +63 -34
- package/dist/src/channel.js +144 -533
- package/dist/src/dispatch.js +257 -0
- package/dist/src/full-sync.js +24 -0
- package/dist/src/gateway.js +171 -0
- package/dist/src/inbound.js +54 -85
- package/dist/src/ingress.js +64 -0
- package/dist/src/outbound.js +48 -111
- package/dist/src/runtime.js +2 -3
- package/dist/src/state.js +492 -0
- package/dist/src/types.js +1 -3
- package/index.ts +1 -2
- package/openclaw.plugin.json +15 -18
- package/package.json +114 -40
- package/setup-entry.ts +0 -2
- package/src/accounts.ts +95 -51
- package/src/channel.ts +271 -646
- package/src/dispatch.ts +324 -0
- package/src/full-sync.ts +47 -0
- package/src/gateway.ts +216 -0
- package/src/inbound.ts +71 -122
- package/src/ingress.ts +123 -0
- package/src/outbound.ts +70 -149
- package/src/runtime.ts +4 -4
- package/src/state.ts +609 -0
- package/src/types.ts +51 -162
- package/dist/src/account-lock.js +0 -91
- package/dist/src/client.js +0 -13
- package/dist/src/cursor-store.js +0 -136
- package/dist/src/inbound-dedupe.js +0 -175
- package/dist/src/invocations.js +0 -47
- package/dist/src/lifecycle.js +0 -35
- package/dist/src/poll-loop.js +0 -137
- package/dist/src/responding.js +0 -36
- package/dist/src/security.js +0 -26
- package/dist/src/state-files.js +0 -243
- package/dist/src/vendor/relay-sdk/client.js +0 -163
- package/dist/src/vendor/relay-sdk/errors.js +0 -45
- package/dist/src/vendor/relay-sdk/types.js +0 -2
- package/dist/src/vendor/relay-sdk/url.js +0 -39
- package/src/account-lock.ts +0 -108
- package/src/client.ts +0 -51
- package/src/cursor-store.ts +0 -186
- package/src/inbound-dedupe.ts +0 -241
- package/src/invocations.ts +0 -58
- package/src/lifecycle.ts +0 -42
- package/src/poll-loop.ts +0 -173
- package/src/responding.ts +0 -52
- package/src/security.ts +0 -36
- package/src/state-files.ts +0 -298
- package/src/vendor/relay-sdk/README.md +0 -28
- package/src/vendor/relay-sdk/client.ts +0 -293
- package/src/vendor/relay-sdk/errors.ts +0 -61
- package/src/vendor/relay-sdk/types.ts +0 -82
- package/src/vendor/relay-sdk/url.ts +0 -43
package/src/state-files.ts
DELETED
|
@@ -1,298 +0,0 @@
|
|
|
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
|
-
/**
|
|
99
|
-
* The sidecar lock has no in-process fast path: every waiter polls the lock
|
|
100
|
-
* file, and losing an attempt costs an exclusive create plus a snapshot read.
|
|
101
|
-
* Relay mutates one document from several tasks at once — a poll batch
|
|
102
|
-
* registers one dedupe entry per inbound message — so N in-process writers
|
|
103
|
-
* become N pollers competing with the holder for the same file. Funnelling
|
|
104
|
-
* them through one in-memory queue leaves a single poller per process, which
|
|
105
|
-
* matters most on Windows: same-process losers no longer race the holder's
|
|
106
|
-
* unlink, so contention stops manifesting as delete-pending denials.
|
|
107
|
-
*
|
|
108
|
-
* Keyed by the store's file path. Two paths spelled differently for one file
|
|
109
|
-
* would each get a queue and simply fall back to the sidecar lock for
|
|
110
|
-
* correctness, so a miss costs throughput rather than serialization.
|
|
111
|
-
*/
|
|
112
|
-
const RELAY_STATE_MUTEX_KEY = Symbol.for("relay.stateFileMutexes");
|
|
113
|
-
|
|
114
|
-
function stateFileMutexes(): Map<string, Promise<void>> {
|
|
115
|
-
const container = globalThis as typeof globalThis & {
|
|
116
|
-
[RELAY_STATE_MUTEX_KEY]?: Map<string, Promise<void>>;
|
|
117
|
-
};
|
|
118
|
-
container[RELAY_STATE_MUTEX_KEY] ??= new Map<string, Promise<void>>();
|
|
119
|
-
return container[RELAY_STATE_MUTEX_KEY];
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function fileLockTimeout(filePath: string): Error {
|
|
123
|
-
return Object.assign(new Error(`file lock timeout for ${filePath}`), {
|
|
124
|
-
code: "file_lock_timeout",
|
|
125
|
-
});
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/** Waits for our turn, but never past the caller's lock deadline. */
|
|
129
|
-
async function awaitTurn(
|
|
130
|
-
turn: Promise<void>,
|
|
131
|
-
deadline: number,
|
|
132
|
-
filePath: string,
|
|
133
|
-
): Promise<void> {
|
|
134
|
-
const remaining = deadline - Date.now();
|
|
135
|
-
if (remaining <= 0) throw fileLockTimeout(filePath);
|
|
136
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
137
|
-
try {
|
|
138
|
-
await Promise.race([
|
|
139
|
-
turn,
|
|
140
|
-
new Promise<never>((_resolve, reject) => {
|
|
141
|
-
timer = setTimeout(() => reject(fileLockTimeout(filePath)), remaining);
|
|
142
|
-
}),
|
|
143
|
-
]);
|
|
144
|
-
} finally {
|
|
145
|
-
if (timer) clearTimeout(timer);
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
async function withStateFileMutex<R>(
|
|
150
|
-
filePath: string,
|
|
151
|
-
deadline: number,
|
|
152
|
-
run: () => Promise<R>,
|
|
153
|
-
): Promise<R> {
|
|
154
|
-
const mutexes = stateFileMutexes();
|
|
155
|
-
const previous = mutexes.get(filePath);
|
|
156
|
-
let release!: () => void;
|
|
157
|
-
const ours = new Promise<void>((resolve) => {
|
|
158
|
-
release = resolve;
|
|
159
|
-
});
|
|
160
|
-
// Chain even when we abandon our turn on timeout: later waiters still queue
|
|
161
|
-
// behind the holder we were waiting on, so ordering survives a giving-up
|
|
162
|
-
// waiter.
|
|
163
|
-
const tail = previous ? previous.then(() => ours) : ours;
|
|
164
|
-
mutexes.set(filePath, tail);
|
|
165
|
-
let tookTurn = false;
|
|
166
|
-
try {
|
|
167
|
-
if (previous) await awaitTurn(previous, deadline, filePath);
|
|
168
|
-
tookTurn = true;
|
|
169
|
-
return await run();
|
|
170
|
-
} finally {
|
|
171
|
-
release();
|
|
172
|
-
// Forgetting the queue is only safe once it has drained. A waiter that gave
|
|
173
|
-
// up is still queued behind a holder that is running, so dropping the entry
|
|
174
|
-
// there would let the next caller past the holder and back onto the lock
|
|
175
|
-
// file the queue exists to keep it off. Leaving it costs one settled promise
|
|
176
|
-
// until the next caller drains it.
|
|
177
|
-
if (tookTurn && mutexes.get(filePath) === tail) mutexes.delete(filePath);
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
function canRecoverRelayStateLock(value: unknown): boolean {
|
|
182
|
-
return (
|
|
183
|
-
isRelayStateLockOwner(value) &&
|
|
184
|
-
value.host === hostname() &&
|
|
185
|
-
!localProcessIsLive(value.pid)
|
|
186
|
-
);
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function ensurePrivateStateDirectory(path: string): void {
|
|
190
|
-
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
191
|
-
const stat = lstatSync(path);
|
|
192
|
-
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
193
|
-
throw new Error(`relay state path is not a private directory: ${path}`);
|
|
194
|
-
}
|
|
195
|
-
try {
|
|
196
|
-
chmodSync(path, 0o700);
|
|
197
|
-
} catch {
|
|
198
|
-
// POSIX modes are not fully implemented on Windows. fs-safe's private
|
|
199
|
-
// write path still owns the platform-specific file guarantees.
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/**
|
|
204
|
-
* Relay owns these files rather than requesting OpenClaw's privileged host
|
|
205
|
-
* SQLite. jsonStore gives every mutation a private atomic replacement. An
|
|
206
|
-
* fs-safe sidecar lock serializes cross-process mutations and is recovered
|
|
207
|
-
* only when its valid Relay owner names this host and its PID is provably dead.
|
|
208
|
-
*/
|
|
209
|
-
export function openRelayStateDocument<T>(params: {
|
|
210
|
-
fileName: string;
|
|
211
|
-
env?: NodeJS.ProcessEnv;
|
|
212
|
-
lockTimeoutMs?: number;
|
|
213
|
-
}): JsonStore<RelayStateDocument<T>> {
|
|
214
|
-
if (!params.fileName || basename(params.fileName) !== params.fileName) {
|
|
215
|
-
throw new Error("relay state fileName must be one file name");
|
|
216
|
-
}
|
|
217
|
-
const lockTimeoutMs = params.lockTimeoutMs ?? RELAY_STATE_LOCK_TIMEOUT_MS;
|
|
218
|
-
if (!Number.isSafeInteger(lockTimeoutMs) || lockTimeoutMs < 1) {
|
|
219
|
-
throw new Error("relay state lockTimeoutMs must be a positive safe integer");
|
|
220
|
-
}
|
|
221
|
-
const stateRoot = resolveStateDir(params.env ?? process.env);
|
|
222
|
-
const relayRoot = join(stateRoot, "relay");
|
|
223
|
-
const relayStateRoot = join(relayRoot, "state");
|
|
224
|
-
ensurePrivateStateDirectory(relayRoot);
|
|
225
|
-
ensurePrivateStateDirectory(relayStateRoot);
|
|
226
|
-
const store = jsonStore<RelayStateDocument<T>>({
|
|
227
|
-
filePath: join(relayStateRoot, params.fileName),
|
|
228
|
-
dirMode: 0o700,
|
|
229
|
-
mode: 0o600,
|
|
230
|
-
});
|
|
231
|
-
const withMutationLock = async <R>(run: () => Promise<R>): Promise<R> => {
|
|
232
|
-
const deadline = Date.now() + lockTimeoutMs;
|
|
233
|
-
return await withStateFileMutex(store.filePath, deadline, async () => {
|
|
234
|
-
for (let attempt = 0; ; attempt += 1) {
|
|
235
|
-
// Only acquisition is retried. Once the mutation itself has started it
|
|
236
|
-
// has observed state under the lock, so replaying it could double-apply.
|
|
237
|
-
let mutationStarted = false;
|
|
238
|
-
try {
|
|
239
|
-
return await withFileLock(
|
|
240
|
-
store.filePath,
|
|
241
|
-
{
|
|
242
|
-
managerKey: `relay-state:${store.filePath}`,
|
|
243
|
-
staleMs: RELAY_STATE_LOCK_TIMEOUT_MS,
|
|
244
|
-
timeoutMs: Math.max(1, deadline - Date.now()),
|
|
245
|
-
staleRecovery: "remove-if-unchanged",
|
|
246
|
-
retry: {
|
|
247
|
-
retries: 300,
|
|
248
|
-
minTimeout: 25,
|
|
249
|
-
maxTimeout: 250,
|
|
250
|
-
randomize: true,
|
|
251
|
-
},
|
|
252
|
-
payload: (): RelayStateLockOwner => ({
|
|
253
|
-
version: RELAY_STATE_LOCK_VERSION,
|
|
254
|
-
kind: "relay-state",
|
|
255
|
-
pid: process.pid,
|
|
256
|
-
host: hostname(),
|
|
257
|
-
createdAt: new Date().toISOString(),
|
|
258
|
-
}),
|
|
259
|
-
shouldReclaim: ({ payload }) => canRecoverRelayStateLock(payload),
|
|
260
|
-
shouldRemoveStaleLock: ({ payload }) =>
|
|
261
|
-
canRecoverRelayStateLock(payload),
|
|
262
|
-
},
|
|
263
|
-
async () => {
|
|
264
|
-
mutationStarted = true;
|
|
265
|
-
return await run();
|
|
266
|
-
},
|
|
267
|
-
);
|
|
268
|
-
} catch (error) {
|
|
269
|
-
const remaining = deadline - Date.now();
|
|
270
|
-
if (
|
|
271
|
-
mutationStarted ||
|
|
272
|
-
remaining <= 0 ||
|
|
273
|
-
!isWindowsLockAcquisitionContention(error)
|
|
274
|
-
) {
|
|
275
|
-
throw error;
|
|
276
|
-
}
|
|
277
|
-
await new Promise((resolve) =>
|
|
278
|
-
setTimeout(resolve, lockRetryDelayMs(attempt, remaining)),
|
|
279
|
-
);
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
});
|
|
283
|
-
};
|
|
284
|
-
|
|
285
|
-
return {
|
|
286
|
-
filePath: store.filePath,
|
|
287
|
-
read: store.read,
|
|
288
|
-
readOr: store.readOr,
|
|
289
|
-
readRequired: store.readRequired,
|
|
290
|
-
write: async (value) => {
|
|
291
|
-
await withMutationLock(async () => await store.write(value));
|
|
292
|
-
},
|
|
293
|
-
update: async (run) =>
|
|
294
|
-
await withMutationLock(async () => await store.update(run)),
|
|
295
|
-
updateOr: async (fallback, run) =>
|
|
296
|
-
await withMutationLock(async () => await store.updateOr(fallback, run)),
|
|
297
|
-
};
|
|
298
|
-
}
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
# Vendored `@relaymessenger/sdk` client
|
|
2
|
-
|
|
3
|
-
`client.ts`, `errors.ts`, `types.ts`, and `url.ts` are **verbatim copies** of
|
|
4
|
-
`packages/sdk/src/` in this same repository. Do not edit them here. Fix the SDK
|
|
5
|
-
and re-copy.
|
|
6
|
-
|
|
7
|
-
## Why a copy
|
|
8
|
-
|
|
9
|
-
The plugin used to carry its own hand-rolled Relay client. The two drifted, and
|
|
10
|
-
the drift shipped a defect: the plugin's client had no `invocationId` on
|
|
11
|
-
`sendMessage`, `setTyping`, or `setResponding`, so the first group mention an
|
|
12
|
-
agent received wedged its whole event stream (REL-167). The SDK client has
|
|
13
|
-
always had those parameters.
|
|
14
|
-
|
|
15
|
-
The SDK is not published to npm yet (`packages/cli/CLAUDE.md`), so the plugin
|
|
16
|
-
cannot depend on it. Vendoring adopts the correct client now instead of growing
|
|
17
|
-
a second one.
|
|
18
|
-
|
|
19
|
-
## Removing this directory
|
|
20
|
-
|
|
21
|
-
When `@relaymessenger/sdk` ships:
|
|
22
|
-
|
|
23
|
-
1. Add it to `dependencies` in `integrations/openclaw/package.json`.
|
|
24
|
-
2. Point `src/client.ts` at `@relaymessenger/sdk` instead of `./vendor/relay-sdk/*`.
|
|
25
|
-
3. Delete this directory.
|
|
26
|
-
|
|
27
|
-
`src/client.ts` is the only file that imports from here, so that is the whole
|
|
28
|
-
swap.
|
|
@@ -1,293 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
RelayApiError,
|
|
3
|
-
classifyRelayHttpStatus,
|
|
4
|
-
isAbortError,
|
|
5
|
-
} from "./errors.js";
|
|
6
|
-
import type {
|
|
7
|
-
RelayAgentProfile,
|
|
8
|
-
RelayEventsPage,
|
|
9
|
-
RelayOutgoingPart,
|
|
10
|
-
RelayReplyRef,
|
|
11
|
-
RelaySendResult,
|
|
12
|
-
} from "./types.js";
|
|
13
|
-
import { normalizeRelayBaseUrl } from "./url.js";
|
|
14
|
-
|
|
15
|
-
type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
|
|
16
|
-
|
|
17
|
-
export type RelayClientOptions = {
|
|
18
|
-
token: string;
|
|
19
|
-
baseUrl?: string;
|
|
20
|
-
fetchImpl?: FetchLike;
|
|
21
|
-
requestTimeoutMs?: number;
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
export type RelayClient = {
|
|
25
|
-
readonly baseUrl: string;
|
|
26
|
-
getMe: (params?: { signal?: AbortSignal }) => Promise<RelayAgentProfile>;
|
|
27
|
-
pollEvents: (params: {
|
|
28
|
-
cursor: number;
|
|
29
|
-
timeoutSeconds?: number;
|
|
30
|
-
limit?: number;
|
|
31
|
-
signal?: AbortSignal;
|
|
32
|
-
}) => Promise<RelayEventsPage>;
|
|
33
|
-
sendMessage: (params: {
|
|
34
|
-
conversationId: string;
|
|
35
|
-
parts: RelayOutgoingPart[];
|
|
36
|
-
replyTo?: RelayReplyRef;
|
|
37
|
-
invocationId?: string;
|
|
38
|
-
idempotencyKey: string;
|
|
39
|
-
signal?: AbortSignal;
|
|
40
|
-
}) => Promise<RelaySendResult>;
|
|
41
|
-
sendText: (params: {
|
|
42
|
-
conversationId: string;
|
|
43
|
-
text: string;
|
|
44
|
-
replyTo?: RelayReplyRef;
|
|
45
|
-
invocationId?: string;
|
|
46
|
-
idempotencyKey: string;
|
|
47
|
-
signal?: AbortSignal;
|
|
48
|
-
}) => Promise<RelaySendResult>;
|
|
49
|
-
setTyping: (params: {
|
|
50
|
-
conversationId: string;
|
|
51
|
-
started: boolean;
|
|
52
|
-
label?: string;
|
|
53
|
-
invocationId?: string;
|
|
54
|
-
signal?: AbortSignal;
|
|
55
|
-
}) => Promise<void>;
|
|
56
|
-
setResponding: (params: {
|
|
57
|
-
conversationId: string;
|
|
58
|
-
messageId: string;
|
|
59
|
-
label?: string;
|
|
60
|
-
invocationId?: string;
|
|
61
|
-
signal?: AbortSignal;
|
|
62
|
-
}) => Promise<void>;
|
|
63
|
-
/**
|
|
64
|
-
* Advance the delivered watermark to `messageId`, and every earlier message
|
|
65
|
-
* from other participants with it.
|
|
66
|
-
*
|
|
67
|
-
* Most agents never call this. Delivered means the agent's endpoint has the
|
|
68
|
-
* message, so Relay records it from the transport itself: a webhook gets it
|
|
69
|
-
* when the endpoint answers `2xx`, and a `GET /v1/events` consumer gets it
|
|
70
|
-
* when the cursor moves past the event. Neither needs a line of code, and
|
|
71
|
-
* neither can suppress it.
|
|
72
|
-
*
|
|
73
|
-
* The exception is a transcript poller — a client that reads
|
|
74
|
-
* `GET /v1/conversations/:id/messages` on a timer. Reading history records
|
|
75
|
-
* no receipt, so nothing on the server ever learns the message arrived.
|
|
76
|
-
* That client, and only that client, has to say so itself.
|
|
77
|
-
*
|
|
78
|
-
* Send it on ingest, before anything that implies a read. The server
|
|
79
|
-
* advances the delivered watermark whenever it records a read, so a
|
|
80
|
-
* delivered receipt that arrives after a read for the same message is
|
|
81
|
-
* silently dropped: the sender goes straight from "Sent" to "Read" and never
|
|
82
|
-
* sees "Delivered". Skipping this call costs the middle rung of the ladder,
|
|
83
|
-
* not the top one.
|
|
84
|
-
*/
|
|
85
|
-
markDelivered: (params: {
|
|
86
|
-
conversationId: string;
|
|
87
|
-
messageId: string;
|
|
88
|
-
signal?: AbortSignal;
|
|
89
|
-
}) => Promise<void>;
|
|
90
|
-
markRead: (params: {
|
|
91
|
-
conversationId: string;
|
|
92
|
-
messageId: string;
|
|
93
|
-
signal?: AbortSignal;
|
|
94
|
-
}) => Promise<void>;
|
|
95
|
-
};
|
|
96
|
-
|
|
97
|
-
async function readErrorDetail(response: Response): Promise<{
|
|
98
|
-
code?: string;
|
|
99
|
-
message: string;
|
|
100
|
-
details?: Record<string, unknown>;
|
|
101
|
-
}> {
|
|
102
|
-
try {
|
|
103
|
-
const body = (await response.json()) as {
|
|
104
|
-
error?: { code?: string; message?: string; details?: Record<string, unknown> };
|
|
105
|
-
message?: string;
|
|
106
|
-
};
|
|
107
|
-
return {
|
|
108
|
-
...(body?.error?.code ? { code: body.error.code } : {}),
|
|
109
|
-
...(body?.error?.details ? { details: body.error.details } : {}),
|
|
110
|
-
message: body?.error?.message ?? body?.message ?? "",
|
|
111
|
-
};
|
|
112
|
-
} catch {
|
|
113
|
-
return { message: "" };
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
export function createRelayClient(options: RelayClientOptions): RelayClient {
|
|
118
|
-
if (!options.token.trim()) {
|
|
119
|
-
throw new Error("relay: Agent Token is required");
|
|
120
|
-
}
|
|
121
|
-
const baseUrl = normalizeRelayBaseUrl(options.baseUrl);
|
|
122
|
-
const fetchImpl: FetchLike =
|
|
123
|
-
options.fetchImpl ?? ((input, init) => fetch(input, init));
|
|
124
|
-
const requestTimeoutMs = options.requestTimeoutMs ?? 15_000;
|
|
125
|
-
|
|
126
|
-
const request = async (params: {
|
|
127
|
-
method: string;
|
|
128
|
-
path: string;
|
|
129
|
-
query?: Record<string, string | number | boolean | undefined>;
|
|
130
|
-
body?: unknown;
|
|
131
|
-
headers?: Record<string, string>;
|
|
132
|
-
signal?: AbortSignal;
|
|
133
|
-
timeoutMs?: number;
|
|
134
|
-
}): Promise<Response> => {
|
|
135
|
-
const url = new URL(`${baseUrl}${params.path}`);
|
|
136
|
-
for (const [key, value] of Object.entries(params.query ?? {})) {
|
|
137
|
-
if (value !== undefined) url.searchParams.set(key, String(value));
|
|
138
|
-
}
|
|
139
|
-
const timeoutSignal = AbortSignal.timeout(params.timeoutMs ?? requestTimeoutMs);
|
|
140
|
-
const signal = params.signal
|
|
141
|
-
? AbortSignal.any([params.signal, timeoutSignal])
|
|
142
|
-
: timeoutSignal;
|
|
143
|
-
let response: Response;
|
|
144
|
-
try {
|
|
145
|
-
response = await fetchImpl(url.toString(), {
|
|
146
|
-
method: params.method,
|
|
147
|
-
headers: {
|
|
148
|
-
authorization: `Bearer ${options.token}`,
|
|
149
|
-
...(params.body === undefined ? {} : { "content-type": "application/json" }),
|
|
150
|
-
...params.headers,
|
|
151
|
-
},
|
|
152
|
-
...(params.body === undefined ? {} : { body: JSON.stringify(params.body) }),
|
|
153
|
-
signal,
|
|
154
|
-
});
|
|
155
|
-
} catch (error) {
|
|
156
|
-
if (timeoutSignal.aborted && !params.signal?.aborted) {
|
|
157
|
-
throw new RelayApiError(
|
|
158
|
-
`relay: ${params.method} ${params.path} timed out after ${params.timeoutMs ?? requestTimeoutMs}ms`,
|
|
159
|
-
{ kind: "retryable" },
|
|
160
|
-
);
|
|
161
|
-
}
|
|
162
|
-
if (isAbortError(error)) throw error;
|
|
163
|
-
throw new RelayApiError(`relay: network error: ${String(error)}`, {
|
|
164
|
-
kind: "retryable",
|
|
165
|
-
});
|
|
166
|
-
}
|
|
167
|
-
if (!response.ok) {
|
|
168
|
-
const detail = await readErrorDetail(response);
|
|
169
|
-
throw new RelayApiError(
|
|
170
|
-
`relay: ${params.method} ${params.path} failed with ${response.status}${detail.message ? `: ${detail.message}` : ""}`,
|
|
171
|
-
{
|
|
172
|
-
status: response.status,
|
|
173
|
-
kind: classifyRelayHttpStatus(response.status),
|
|
174
|
-
...(detail.code ? { code: detail.code } : {}),
|
|
175
|
-
...(detail.details ? { details: detail.details } : {}),
|
|
176
|
-
},
|
|
177
|
-
);
|
|
178
|
-
}
|
|
179
|
-
return response;
|
|
180
|
-
};
|
|
181
|
-
|
|
182
|
-
const client: RelayClient = {
|
|
183
|
-
baseUrl,
|
|
184
|
-
|
|
185
|
-
getMe: async (params) => {
|
|
186
|
-
const response = await request({
|
|
187
|
-
method: "GET",
|
|
188
|
-
path: "/v1/agents/me",
|
|
189
|
-
...(params?.signal ? { signal: params.signal } : {}),
|
|
190
|
-
});
|
|
191
|
-
const body = (await response.json()) as { agent: RelayAgentProfile };
|
|
192
|
-
return body.agent;
|
|
193
|
-
},
|
|
194
|
-
|
|
195
|
-
pollEvents: async (params) => {
|
|
196
|
-
const timeoutSeconds = Math.min(Math.max(params.timeoutSeconds ?? 30, 1), 30);
|
|
197
|
-
const response = await request({
|
|
198
|
-
method: "GET",
|
|
199
|
-
path: "/v1/events",
|
|
200
|
-
query: {
|
|
201
|
-
cursor: params.cursor,
|
|
202
|
-
timeout: timeoutSeconds,
|
|
203
|
-
...(params.limit === undefined ? {} : { limit: params.limit }),
|
|
204
|
-
},
|
|
205
|
-
...(params.signal ? { signal: params.signal } : {}),
|
|
206
|
-
timeoutMs: (timeoutSeconds + 15) * 1_000,
|
|
207
|
-
});
|
|
208
|
-
const body = (await response.json()) as {
|
|
209
|
-
events?: RelayEventsPage["events"];
|
|
210
|
-
next_cursor?: number;
|
|
211
|
-
};
|
|
212
|
-
const events = Array.isArray(body.events) ? body.events : [];
|
|
213
|
-
const nextCursor =
|
|
214
|
-
typeof body.next_cursor === "number" && Number.isSafeInteger(body.next_cursor)
|
|
215
|
-
? body.next_cursor
|
|
216
|
-
: params.cursor;
|
|
217
|
-
return { events, nextCursor };
|
|
218
|
-
},
|
|
219
|
-
|
|
220
|
-
sendMessage: async (params) => {
|
|
221
|
-
const response = await request({
|
|
222
|
-
method: "POST",
|
|
223
|
-
path: "/v1/messages",
|
|
224
|
-
headers: { "idempotency-key": params.idempotencyKey },
|
|
225
|
-
body: {
|
|
226
|
-
conversation_id: params.conversationId,
|
|
227
|
-
parts: params.parts,
|
|
228
|
-
...(params.invocationId ? { invocation_id: params.invocationId } : {}),
|
|
229
|
-
...(params.replyTo ? { reply_to: params.replyTo } : {}),
|
|
230
|
-
},
|
|
231
|
-
...(params.signal ? { signal: params.signal } : {}),
|
|
232
|
-
});
|
|
233
|
-
const body = (await response.json()) as {
|
|
234
|
-
messages: RelaySendResult["messages"];
|
|
235
|
-
};
|
|
236
|
-
return { messages: body.messages };
|
|
237
|
-
},
|
|
238
|
-
|
|
239
|
-
sendText: async (params) => {
|
|
240
|
-
const { text, ...rest } = params;
|
|
241
|
-
return client.sendMessage({
|
|
242
|
-
...rest,
|
|
243
|
-
parts: [{ type: "text", text }],
|
|
244
|
-
});
|
|
245
|
-
},
|
|
246
|
-
|
|
247
|
-
setTyping: async (params) => {
|
|
248
|
-
await request({
|
|
249
|
-
method: "POST",
|
|
250
|
-
path: `/v1/conversations/${encodeURIComponent(params.conversationId)}/typing`,
|
|
251
|
-
body: {
|
|
252
|
-
started: params.started,
|
|
253
|
-
...(params.label ? { label: params.label } : {}),
|
|
254
|
-
...(params.invocationId ? { invocation_id: params.invocationId } : {}),
|
|
255
|
-
},
|
|
256
|
-
...(params.signal ? { signal: params.signal } : {}),
|
|
257
|
-
});
|
|
258
|
-
},
|
|
259
|
-
|
|
260
|
-
setResponding: async (params) => {
|
|
261
|
-
await request({
|
|
262
|
-
method: "POST",
|
|
263
|
-
path: `/v1/conversations/${encodeURIComponent(params.conversationId)}/responding`,
|
|
264
|
-
body: {
|
|
265
|
-
message_id: params.messageId,
|
|
266
|
-
...(params.label ? { label: params.label } : {}),
|
|
267
|
-
...(params.invocationId ? { invocation_id: params.invocationId } : {}),
|
|
268
|
-
},
|
|
269
|
-
...(params.signal ? { signal: params.signal } : {}),
|
|
270
|
-
});
|
|
271
|
-
},
|
|
272
|
-
|
|
273
|
-
markDelivered: async (params) => {
|
|
274
|
-
await request({
|
|
275
|
-
method: "POST",
|
|
276
|
-
path: `/v1/conversations/${encodeURIComponent(params.conversationId)}/delivered`,
|
|
277
|
-
body: { message_id: params.messageId },
|
|
278
|
-
...(params.signal ? { signal: params.signal } : {}),
|
|
279
|
-
});
|
|
280
|
-
},
|
|
281
|
-
|
|
282
|
-
markRead: async (params) => {
|
|
283
|
-
await request({
|
|
284
|
-
method: "POST",
|
|
285
|
-
path: `/v1/conversations/${encodeURIComponent(params.conversationId)}/read`,
|
|
286
|
-
body: { message_id: params.messageId },
|
|
287
|
-
...(params.signal ? { signal: params.signal } : {}),
|
|
288
|
-
});
|
|
289
|
-
},
|
|
290
|
-
};
|
|
291
|
-
|
|
292
|
-
return client;
|
|
293
|
-
}
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
export type RelayApiErrorKind = "auth" | "conflict" | "retryable" | "rejected";
|
|
2
|
-
|
|
3
|
-
/** Classified Relay API failure. `terminal` means retrying the same request cannot succeed. */
|
|
4
|
-
export class RelayApiError extends Error {
|
|
5
|
-
readonly status: number | undefined;
|
|
6
|
-
readonly kind: RelayApiErrorKind;
|
|
7
|
-
readonly code: string | undefined;
|
|
8
|
-
/** Structured `error.details` from the response body, e.g. `highest_delivered_cursor` on 422. */
|
|
9
|
-
readonly details: Record<string, unknown> | undefined;
|
|
10
|
-
|
|
11
|
-
constructor(
|
|
12
|
-
message: string,
|
|
13
|
-
params: {
|
|
14
|
-
status?: number;
|
|
15
|
-
kind: RelayApiErrorKind;
|
|
16
|
-
code?: string;
|
|
17
|
-
details?: Record<string, unknown>;
|
|
18
|
-
},
|
|
19
|
-
) {
|
|
20
|
-
super(message);
|
|
21
|
-
this.name = "RelayApiError";
|
|
22
|
-
this.status = params.status;
|
|
23
|
-
this.kind = params.kind;
|
|
24
|
-
this.code = params.code;
|
|
25
|
-
this.details = params.details;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
get terminal(): boolean {
|
|
29
|
-
return this.kind !== "retryable";
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
get retryable(): boolean {
|
|
33
|
-
return this.kind === "retryable";
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export class WebhookVerificationError extends Error {
|
|
38
|
-
constructor(message: string) {
|
|
39
|
-
super(message);
|
|
40
|
-
this.name = "WebhookVerificationError";
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export function classifyRelayHttpStatus(status: number): RelayApiErrorKind {
|
|
45
|
-
if (status === 401) return "auth";
|
|
46
|
-
if (status === 409) return "conflict";
|
|
47
|
-
if (status === 408 || status === 429 || status >= 500) return "retryable";
|
|
48
|
-
return "rejected";
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export function isAbortError(error: unknown): boolean {
|
|
52
|
-
return error instanceof Error && error.name === "AbortError";
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export function isRelayWebhookConflict(error: unknown): error is RelayApiError {
|
|
56
|
-
return (
|
|
57
|
-
error instanceof RelayApiError &&
|
|
58
|
-
error.status === 409 &&
|
|
59
|
-
error.code !== "terminated_by_other_consumer"
|
|
60
|
-
);
|
|
61
|
-
}
|