@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.
Files changed (61) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +162 -123
  3. package/contracts/relay-sdk-0.3.0-staging.5.registry.json +69 -0
  4. package/contracts/relay-v1.lock.json +77 -0
  5. package/dist/index.js +2 -2
  6. package/dist/setup-entry.js +1 -2
  7. package/dist/src/accounts.js +63 -34
  8. package/dist/src/channel.js +144 -533
  9. package/dist/src/dispatch.js +257 -0
  10. package/dist/src/full-sync.js +24 -0
  11. package/dist/src/gateway.js +171 -0
  12. package/dist/src/inbound.js +54 -85
  13. package/dist/src/ingress.js +64 -0
  14. package/dist/src/outbound.js +48 -111
  15. package/dist/src/runtime.js +2 -3
  16. package/dist/src/state.js +492 -0
  17. package/dist/src/types.js +1 -3
  18. package/index.ts +1 -2
  19. package/openclaw.plugin.json +15 -18
  20. package/package.json +114 -40
  21. package/setup-entry.ts +0 -2
  22. package/src/accounts.ts +95 -51
  23. package/src/channel.ts +271 -646
  24. package/src/dispatch.ts +324 -0
  25. package/src/full-sync.ts +47 -0
  26. package/src/gateway.ts +216 -0
  27. package/src/inbound.ts +71 -122
  28. package/src/ingress.ts +123 -0
  29. package/src/outbound.ts +70 -149
  30. package/src/runtime.ts +4 -4
  31. package/src/state.ts +609 -0
  32. package/src/types.ts +51 -162
  33. package/dist/src/account-lock.js +0 -91
  34. package/dist/src/client.js +0 -13
  35. package/dist/src/cursor-store.js +0 -136
  36. package/dist/src/inbound-dedupe.js +0 -175
  37. package/dist/src/invocations.js +0 -47
  38. package/dist/src/lifecycle.js +0 -35
  39. package/dist/src/poll-loop.js +0 -137
  40. package/dist/src/responding.js +0 -36
  41. package/dist/src/security.js +0 -26
  42. package/dist/src/state-files.js +0 -243
  43. package/dist/src/vendor/relay-sdk/client.js +0 -163
  44. package/dist/src/vendor/relay-sdk/errors.js +0 -45
  45. package/dist/src/vendor/relay-sdk/types.js +0 -2
  46. package/dist/src/vendor/relay-sdk/url.js +0 -39
  47. package/src/account-lock.ts +0 -108
  48. package/src/client.ts +0 -51
  49. package/src/cursor-store.ts +0 -186
  50. package/src/inbound-dedupe.ts +0 -241
  51. package/src/invocations.ts +0 -58
  52. package/src/lifecycle.ts +0 -42
  53. package/src/poll-loop.ts +0 -173
  54. package/src/responding.ts +0 -52
  55. package/src/security.ts +0 -36
  56. package/src/state-files.ts +0 -298
  57. package/src/vendor/relay-sdk/README.md +0 -28
  58. package/src/vendor/relay-sdk/client.ts +0 -293
  59. package/src/vendor/relay-sdk/errors.ts +0 -61
  60. package/src/vendor/relay-sdk/types.ts +0 -82
  61. package/src/vendor/relay-sdk/url.ts +0 -43
@@ -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
- }