@relaymessenger/openclaw-plugin 0.3.3 → 0.4.0-staging.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.
Files changed (50) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +159 -124
  3. package/contracts/relay-sdk-0.3.0-staging.4.registry.json +58 -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 -498
  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 -80
  13. package/dist/src/ingress.js +64 -0
  14. package/dist/src/outbound.js +48 -109
  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 +113 -40
  21. package/setup-entry.ts +0 -2
  22. package/src/accounts.ts +95 -51
  23. package/src/channel.ts +271 -611
  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 -111
  28. package/src/ingress.ts +123 -0
  29. package/src/outbound.ts +70 -142
  30. package/src/runtime.ts +4 -4
  31. package/src/state.ts +609 -0
  32. package/src/types.ts +51 -148
  33. package/dist/src/account-lock.js +0 -91
  34. package/dist/src/client.js +0 -229
  35. package/dist/src/cursor-store.js +0 -136
  36. package/dist/src/inbound-dedupe.js +0 -175
  37. package/dist/src/lifecycle.js +0 -35
  38. package/dist/src/poll-loop.js +0 -125
  39. package/dist/src/responding.js +0 -13
  40. package/dist/src/security.js +0 -26
  41. package/dist/src/state-files.js +0 -167
  42. package/src/account-lock.ts +0 -108
  43. package/src/client.ts +0 -330
  44. package/src/cursor-store.ts +0 -186
  45. package/src/inbound-dedupe.ts +0 -241
  46. package/src/lifecycle.ts +0 -42
  47. package/src/poll-loop.ts +0 -161
  48. package/src/responding.ts +0 -21
  49. package/src/security.ts +0 -36
  50. package/src/state-files.ts +0 -212
package/src/client.ts DELETED
@@ -1,330 +0,0 @@
1
- // Thin Relay REST client boundary for the standalone OpenClaw channel plugin.
2
- // Owns the abort-aware long poll, idempotent sends, typing, responding, and
3
- // read watermarks without loading an OpenClaw runtime in unit tests.
4
- import { isIP } from "node:net";
5
- import type {
6
- RelayAgentProfile,
7
- RelayEventsPage,
8
- RelayPart,
9
- RelayReplyRef,
10
- RelaySendResult,
11
- } from "./types.js";
12
-
13
- export const DEFAULT_RELAY_BASE_URL = "https://api.relayapp.im";
14
-
15
- function isLoopbackHostname(hostname: string): boolean {
16
- const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
17
- const ipVersion = isIP(normalized);
18
- if (ipVersion === 4) {
19
- return normalized.split(".")[0] === "127";
20
- }
21
- if (ipVersion === 6) {
22
- return normalized === "::1";
23
- }
24
- return (
25
- normalized === "localhost" ||
26
- normalized.endsWith(".localhost")
27
- );
28
- }
29
-
30
- /**
31
- * Validate and canonicalize the API origin before a bearer token can be sent
32
- * to it. Production/custom remote origins must use HTTPS. Plain HTTP remains
33
- * available only for an explicit loopback development server.
34
- */
35
- export function normalizeRelayBaseUrl(raw?: string): string {
36
- const candidate = raw?.trim() || DEFAULT_RELAY_BASE_URL;
37
- let url: URL;
38
- try {
39
- url = new URL(candidate);
40
- } catch {
41
- throw new Error(`relay: invalid baseUrl ${JSON.stringify(candidate)}`);
42
- }
43
- if (url.username || url.password) {
44
- throw new Error("relay: baseUrl must not contain credentials");
45
- }
46
- if (url.search || url.hash) {
47
- throw new Error("relay: baseUrl must not contain a query or fragment");
48
- }
49
- if (!/^\/+$/u.test(url.pathname)) {
50
- throw new Error("relay: baseUrl must be an origin without a path");
51
- }
52
- if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopbackHostname(url.hostname))) {
53
- throw new Error("relay: baseUrl must use HTTPS (HTTP is allowed only for loopback development)");
54
- }
55
- return url.origin;
56
- }
57
-
58
- export type RelayApiErrorKind = "auth" | "conflict" | "retryable" | "rejected";
59
-
60
- /** Classified Relay API failure. `terminal` means operator action (bad token). */
61
- export class RelayApiError extends Error {
62
- readonly status: number | undefined;
63
- readonly kind: RelayApiErrorKind;
64
- /** Server error code from the response body (`error.code`), when present. */
65
- readonly code: string | undefined;
66
-
67
- constructor(
68
- message: string,
69
- params: { status?: number; kind: RelayApiErrorKind; code?: string },
70
- ) {
71
- super(message);
72
- this.name = "RelayApiError";
73
- this.status = params.status;
74
- this.kind = params.kind;
75
- this.code = params.code;
76
- }
77
-
78
- get terminal(): boolean {
79
- return this.kind === "auth";
80
- }
81
-
82
- get retryable(): boolean {
83
- return this.kind === "retryable";
84
- }
85
- }
86
-
87
- /**
88
- * 409 from the webhook XOR rule: an enabled webhook endpoint makes long
89
- * polling unavailable until the operator disables it (server code
90
- * `conflict`, distinct from `terminated_by_other_consumer`).
91
- */
92
- export function isRelayWebhookConflict(error: unknown): error is RelayApiError {
93
- return (
94
- error instanceof RelayApiError &&
95
- error.status === 409 &&
96
- error.code !== "terminated_by_other_consumer"
97
- );
98
- }
99
-
100
- export function classifyRelayHttpStatus(status: number): RelayApiErrorKind {
101
- if (status === 401) {
102
- return "auth";
103
- }
104
- if (status === 409) {
105
- return "conflict";
106
- }
107
- if (status === 408 || status === 429 || status >= 500) {
108
- return "retryable";
109
- }
110
- return "rejected";
111
- }
112
-
113
- export function isAbortError(error: unknown): boolean {
114
- return error instanceof Error && error.name === "AbortError";
115
- }
116
-
117
- type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
118
-
119
- export type RelayClientOptions = {
120
- baseUrl?: string;
121
- token: string;
122
- fetchImpl?: FetchLike;
123
- /** Bounds non-poll API operations; long polls use hold time plus slack. */
124
- requestTimeoutMs?: number;
125
- };
126
-
127
- export type RelayClient = {
128
- getMe: (params?: { signal?: AbortSignal }) => Promise<RelayAgentProfile>;
129
- pollEvents: (params: {
130
- cursor: number;
131
- timeoutSeconds?: number;
132
- limit?: number;
133
- signal?: AbortSignal;
134
- }) => Promise<RelayEventsPage>;
135
- sendMessage: (params: {
136
- conversationId: string;
137
- parts: Array<Pick<RelayPart, never> & Record<string, unknown>>;
138
- replyTo?: RelayReplyRef;
139
- idempotencyKey: string;
140
- signal?: AbortSignal;
141
- }) => Promise<RelaySendResult>;
142
- setTyping: (params: {
143
- conversationId: string;
144
- started: boolean;
145
- label?: string;
146
- signal?: AbortSignal;
147
- }) => Promise<void>;
148
- setResponding: (params: {
149
- conversationId: string;
150
- messageId: string;
151
- label?: string;
152
- signal?: AbortSignal;
153
- }) => Promise<void>;
154
- markRead: (params: {
155
- conversationId: string;
156
- messageId: string;
157
- signal?: AbortSignal;
158
- }) => Promise<void>;
159
- };
160
-
161
- async function readErrorDetail(
162
- response: Response,
163
- ): Promise<{ code?: string; message: string }> {
164
- try {
165
- const body = (await response.json()) as {
166
- error?: { code?: string; message?: string };
167
- message?: string;
168
- };
169
- return {
170
- ...(body?.error?.code ? { code: body.error.code } : {}),
171
- message: body?.error?.message ?? body?.message ?? "",
172
- };
173
- } catch {
174
- return { message: "" };
175
- }
176
- }
177
-
178
- export function createRelayClient(options: RelayClientOptions): RelayClient {
179
- const baseUrl = normalizeRelayBaseUrl(options.baseUrl);
180
- const fetchImpl: FetchLike = options.fetchImpl ?? ((input, init) => fetch(input, init));
181
- const requestTimeoutMs = options.requestTimeoutMs ?? 15_000;
182
-
183
- const request = async (params: {
184
- method: string;
185
- path: string;
186
- query?: Record<string, string | number | boolean | undefined>;
187
- body?: unknown;
188
- headers?: Record<string, string>;
189
- signal?: AbortSignal;
190
- timeoutMs?: number;
191
- }): Promise<Response> => {
192
- const url = new URL(`${baseUrl}${params.path}`);
193
- for (const [key, value] of Object.entries(params.query ?? {})) {
194
- if (value !== undefined) {
195
- url.searchParams.set(key, String(value));
196
- }
197
- }
198
- let response: Response;
199
- const timeoutSignal = AbortSignal.timeout(params.timeoutMs ?? requestTimeoutMs);
200
- const signal = params.signal
201
- ? AbortSignal.any([params.signal, timeoutSignal])
202
- : timeoutSignal;
203
- try {
204
- response = await fetchImpl(url.toString(), {
205
- method: params.method,
206
- headers: {
207
- authorization: `Bearer ${options.token}`,
208
- ...(params.body === undefined ? {} : { "content-type": "application/json" }),
209
- ...params.headers,
210
- },
211
- ...(params.body === undefined ? {} : { body: JSON.stringify(params.body) }),
212
- signal,
213
- });
214
- } catch (error) {
215
- if (timeoutSignal.aborted && !params.signal?.aborted) {
216
- throw new RelayApiError(
217
- `relay: ${params.method} ${params.path} timed out after ${params.timeoutMs ?? requestTimeoutMs}ms`,
218
- { kind: "retryable" },
219
- );
220
- }
221
- if (isAbortError(error)) {
222
- throw error;
223
- }
224
- // Network-level failure (DNS, reset, offline): always retryable.
225
- throw new RelayApiError(`relay: network error: ${String(error)}`, { kind: "retryable" });
226
- }
227
- if (!response.ok) {
228
- const detail = await readErrorDetail(response);
229
- throw new RelayApiError(
230
- `relay: ${params.method} ${params.path} failed with ${response.status}${detail.message ? `: ${detail.message}` : ""}`,
231
- {
232
- status: response.status,
233
- kind: classifyRelayHttpStatus(response.status),
234
- ...(detail.code ? { code: detail.code } : {}),
235
- },
236
- );
237
- }
238
- return response;
239
- };
240
-
241
- return {
242
- getMe: async (params) => {
243
- const response = await request({
244
- method: "GET",
245
- path: "/v1/agents/me",
246
- signal: params?.signal,
247
- });
248
- const body = (await response.json()) as { agent: RelayAgentProfile };
249
- return body.agent;
250
- },
251
-
252
- pollEvents: async (params) => {
253
- const timeoutSeconds = Math.min(Math.max(params.timeoutSeconds ?? 30, 1), 30);
254
- // Guard against a wedged connection: the server holds <= timeout seconds,
255
- // so anything past timeout + slack is a dead socket, not a slow poll.
256
- const response = await request({
257
- method: "GET",
258
- path: "/v1/events",
259
- query: {
260
- cursor: params.cursor,
261
- timeout: timeoutSeconds,
262
- ...(params.limit === undefined ? {} : { limit: params.limit }),
263
- },
264
- signal: params.signal,
265
- timeoutMs: (timeoutSeconds + 15) * 1_000,
266
- });
267
- const body = (await response.json()) as {
268
- events?: RelayEventsPage["events"];
269
- next_cursor?: number;
270
- };
271
- const events = Array.isArray(body.events) ? body.events : [];
272
- const nextCursor =
273
- typeof body.next_cursor === "number" && Number.isSafeInteger(body.next_cursor)
274
- ? body.next_cursor
275
- : params.cursor;
276
- return { events, nextCursor };
277
- },
278
-
279
- sendMessage: async (params) => {
280
- const response = await request({
281
- method: "POST",
282
- path: "/v1/messages",
283
- headers: { "idempotency-key": params.idempotencyKey },
284
- body: {
285
- conversation_id: params.conversationId,
286
- parts: params.parts,
287
- ...(params.replyTo ? { reply_to: params.replyTo } : {}),
288
- },
289
- signal: params.signal,
290
- });
291
- const body = (await response.json()) as {
292
- messages: RelaySendResult["messages"];
293
- };
294
- return { messages: body.messages };
295
- },
296
-
297
- setTyping: async (params) => {
298
- await request({
299
- method: "POST",
300
- path: `/v1/conversations/${encodeURIComponent(params.conversationId)}/typing`,
301
- body: {
302
- started: params.started,
303
- ...(params.label ? { label: params.label } : {}),
304
- },
305
- signal: params.signal,
306
- });
307
- },
308
-
309
- setResponding: async (params) => {
310
- await request({
311
- method: "POST",
312
- path: `/v1/conversations/${encodeURIComponent(params.conversationId)}/responding`,
313
- body: {
314
- message_id: params.messageId,
315
- ...(params.label ? { label: params.label } : {}),
316
- },
317
- signal: params.signal,
318
- });
319
- },
320
-
321
- markRead: async (params) => {
322
- await request({
323
- method: "POST",
324
- path: `/v1/conversations/${encodeURIComponent(params.conversationId)}/read`,
325
- body: { message_id: params.messageId },
326
- signal: params.signal,
327
- });
328
- },
329
- };
330
- }
@@ -1,186 +0,0 @@
1
- // Persisted long-poll cursor, Telegram-offset style:
2
- // monotonic writes only, bound to the agent identity so a token that now
3
- // resolves to a different agent discards the stale cursor instead of acking
4
- // another contact's event stream.
5
- import { createHash } from "node:crypto";
6
- import {
7
- assertRelayStateDocument,
8
- emptyRelayStateDocument,
9
- openRelayStateDocument,
10
- } from "./state-files.js";
11
-
12
- export const RELAY_CURSOR_MAX_ENTRIES = 1_000;
13
- export const RELAY_CURSOR_OVERFLOW_POLICY = "reject-new" as const;
14
-
15
- const RECORD_VERSION = 2;
16
-
17
- export type RelayCursorRecord = {
18
- version: number;
19
- cursor: number;
20
- baseUrl: string;
21
- agentId: string;
22
- };
23
-
24
- /**
25
- * Minimal state-store slice the cursor needs. The channel runtime binds this
26
- * to Relay-owned files; tests can inject a memory map.
27
- */
28
- export type RelayCursorStateStore = {
29
- lookup(key: string): Promise<RelayCursorRecord | undefined>;
30
- register(key: string, value: RelayCursorRecord): Promise<void>;
31
- };
32
-
33
- export type RelayCursorStore = {
34
- /** Loads the persisted cursor; only a genuinely absent stable identity starts at 0. */
35
- load(): Promise<number>;
36
- /** Last accepted cursor (in-memory view). */
37
- current(): number;
38
- /**
39
- * Persist a new cursor. Only called after the batch it acknowledges has been
40
- * durably handled (claim/commit); ignores non-monotonic or invalid values so
41
- * a replayed batch can never move the ack backwards.
42
- */
43
- advance(cursor: number): Promise<void>;
44
- };
45
-
46
- /**
47
- * Open Relay's private, lock-protected state file with fail-closed capacity
48
- * semantics. A cursor is permanent safety state: evicting an old identity to
49
- * admit a new one could replay retained events when the old identity returns.
50
- */
51
- export function openRelayCursorStateStore(
52
- warn: (line: string) => void,
53
- options: { env?: NodeJS.ProcessEnv; maxEntries?: number } = {},
54
- ): RelayCursorStateStore {
55
- const maxEntries = options.maxEntries ?? RELAY_CURSOR_MAX_ENTRIES;
56
- if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) {
57
- throw new Error("relay cursor maxEntries must be a positive safe integer");
58
- }
59
- const store = openRelayStateDocument<RelayCursorRecord>({
60
- fileName: "cursors.json",
61
- ...(options.env ? { env: options.env } : {}),
62
- });
63
- const storageKey = (key: string) => createHash("sha256").update(key).digest("hex");
64
- const validateEntry = (key: string, value: unknown): value is RelayCursorRecord => {
65
- if (!/^[a-f0-9]{64}$/u.test(key) || !value || typeof value !== "object") return false;
66
- const record = value as Partial<RelayCursorRecord>;
67
- return (
68
- record.version === RECORD_VERSION &&
69
- isValidCursor(record.cursor) &&
70
- typeof record.baseUrl === "string" &&
71
- (() => {
72
- try {
73
- return new URL(record.baseUrl).origin === record.baseUrl;
74
- } catch {
75
- return false;
76
- }
77
- })() &&
78
- typeof record.agentId === "string" &&
79
- record.agentId.length > 0
80
- );
81
- };
82
- const read = async () => {
83
- try {
84
- const current = await store.read();
85
- if (current === undefined) return emptyRelayStateDocument<RelayCursorRecord>();
86
- assertRelayStateDocument(current, "cursor", validateEntry);
87
- return current;
88
- } catch (error) {
89
- warn(`[relay] cursor state unavailable; refusing unsafe cursor reset: ${String(error)}`);
90
- throw error;
91
- }
92
- };
93
- return {
94
- lookup: async (key) => (await read()).entries[storageKey(key)],
95
- register: async (key, value) => {
96
- try {
97
- await store.updateOr(emptyRelayStateDocument<RelayCursorRecord>(), (current) => {
98
- assertRelayStateDocument(current, "cursor", validateEntry);
99
- const hashedKey = storageKey(key);
100
- if (
101
- !Object.hasOwn(current.entries, hashedKey) &&
102
- Object.keys(current.entries).length >= maxEntries
103
- ) {
104
- throw new Error(
105
- `relay cursor state reached ${maxEntries} identities (${RELAY_CURSOR_OVERFLOW_POLICY})`,
106
- );
107
- }
108
- return {
109
- version: current.version,
110
- entries: { ...current.entries, [hashedKey]: value },
111
- };
112
- });
113
- } catch (error) {
114
- warn(`[relay] cursor state unavailable; refusing unsafe cursor reset: ${String(error)}`);
115
- throw error;
116
- }
117
- },
118
- };
119
- }
120
-
121
- function isValidCursor(value: unknown): value is number {
122
- return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
123
- }
124
-
125
- export function createRelayCursorStore(params: {
126
- store: RelayCursorStateStore;
127
- baseUrl: string;
128
- agentId: string;
129
- onPersistError?: (error: unknown) => void;
130
- }): RelayCursorStore {
131
- const baseUrl = new URL(params.baseUrl).origin;
132
- const key = `relay:${baseUrl}:${params.agentId}`;
133
- let cursor = 0;
134
- let loaded = false;
135
-
136
- return {
137
- load: async () => {
138
- let record: RelayCursorRecord | undefined;
139
- try {
140
- record = await params.store.lookup(key);
141
- } catch (error) {
142
- params.onPersistError?.(error);
143
- throw new Error(`relay cursor state could not be loaded: ${String(error)}`);
144
- }
145
- if (!record) {
146
- cursor = 0;
147
- loaded = true;
148
- return cursor;
149
- }
150
- if (
151
- record.version !== RECORD_VERSION ||
152
- !isValidCursor(record.cursor) ||
153
- record.agentId !== params.agentId ||
154
- record.baseUrl !== baseUrl
155
- ) {
156
- throw new Error(`relay cursor state is corrupt for ${baseUrl} ${params.agentId}; refusing cursor-zero replay`);
157
- }
158
- cursor = record.cursor;
159
- loaded = true;
160
- return cursor;
161
- },
162
-
163
- current: () => cursor,
164
-
165
- advance: async (next) => {
166
- if (!loaded) {
167
- throw new Error("relay cursor store: advance() before load()");
168
- }
169
- if (!isValidCursor(next) || next <= cursor) {
170
- return;
171
- }
172
- try {
173
- await params.store.register(key, {
174
- version: RECORD_VERSION,
175
- cursor: next,
176
- baseUrl,
177
- agentId: params.agentId,
178
- });
179
- } catch (error) {
180
- params.onPersistError?.(error);
181
- throw new Error(`relay cursor advance was not durable: ${String(error)}`);
182
- }
183
- cursor = next;
184
- },
185
- };
186
- }