@relaymessenger/openclaw-plugin 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +141 -0
- package/dist/index.js +11 -0
- package/dist/setup-entry.js +5 -0
- package/dist/src/account-lock.js +91 -0
- package/dist/src/accounts.js +67 -0
- package/dist/src/channel.js +606 -0
- package/dist/src/client.js +219 -0
- package/dist/src/cursor-store.js +136 -0
- package/dist/src/inbound-dedupe.js +175 -0
- package/dist/src/inbound.js +94 -0
- package/dist/src/lifecycle.js +35 -0
- package/dist/src/outbound.js +98 -0
- package/dist/src/poll-loop.js +125 -0
- package/dist/src/runtime.js +8 -0
- package/dist/src/security.js +26 -0
- package/dist/src/state-files.js +167 -0
- package/dist/src/types.js +4 -0
- package/index.ts +12 -0
- package/openclaw.plugin.json +101 -0
- package/package.json +97 -0
- package/setup-entry.ts +6 -0
- package/src/account-lock.ts +108 -0
- package/src/accounts.ts +98 -0
- package/src/channel.ts +669 -0
- package/src/client.ts +313 -0
- package/src/cursor-store.ts +186 -0
- package/src/inbound-dedupe.ts +241 -0
- package/src/inbound.ts +128 -0
- package/src/lifecycle.ts +42 -0
- package/src/outbound.ts +136 -0
- package/src/poll-loop.ts +161 -0
- package/src/runtime.ts +13 -0
- package/src/security.ts +36 -0
- package/src/state-files.ts +212 -0
- package/src/types.ts +173 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// Thin Relay REST client for the OpenClaw channel plugin. Bespoke fetch until
|
|
2
|
+
// the Relay SDK ships. Owns the abort-aware long poll, idempotent
|
|
3
|
+
// sends, typing, and read watermarks. No SDK imports so unit tests run
|
|
4
|
+
// without an OpenClaw runtime.
|
|
5
|
+
import { isIP } from "node:net";
|
|
6
|
+
export const DEFAULT_RELAY_BASE_URL = "https://api.relayapp.im";
|
|
7
|
+
function isLoopbackHostname(hostname) {
|
|
8
|
+
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
9
|
+
const ipVersion = isIP(normalized);
|
|
10
|
+
if (ipVersion === 4) {
|
|
11
|
+
return normalized.split(".")[0] === "127";
|
|
12
|
+
}
|
|
13
|
+
if (ipVersion === 6) {
|
|
14
|
+
return normalized === "::1";
|
|
15
|
+
}
|
|
16
|
+
return (normalized === "localhost" ||
|
|
17
|
+
normalized.endsWith(".localhost"));
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Validate and canonicalize the API origin before a bearer token can be sent
|
|
21
|
+
* to it. Production/custom remote origins must use HTTPS. Plain HTTP remains
|
|
22
|
+
* available only for an explicit loopback development server.
|
|
23
|
+
*/
|
|
24
|
+
export function normalizeRelayBaseUrl(raw) {
|
|
25
|
+
const candidate = raw?.trim() || DEFAULT_RELAY_BASE_URL;
|
|
26
|
+
let url;
|
|
27
|
+
try {
|
|
28
|
+
url = new URL(candidate);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
throw new Error(`relay: invalid baseUrl ${JSON.stringify(candidate)}`);
|
|
32
|
+
}
|
|
33
|
+
if (url.username || url.password) {
|
|
34
|
+
throw new Error("relay: baseUrl must not contain credentials");
|
|
35
|
+
}
|
|
36
|
+
if (url.search || url.hash) {
|
|
37
|
+
throw new Error("relay: baseUrl must not contain a query or fragment");
|
|
38
|
+
}
|
|
39
|
+
if (!/^\/+$/u.test(url.pathname)) {
|
|
40
|
+
throw new Error("relay: baseUrl must be an origin without a path");
|
|
41
|
+
}
|
|
42
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopbackHostname(url.hostname))) {
|
|
43
|
+
throw new Error("relay: baseUrl must use HTTPS (HTTP is allowed only for loopback development)");
|
|
44
|
+
}
|
|
45
|
+
return url.origin;
|
|
46
|
+
}
|
|
47
|
+
/** Classified Relay API failure. `terminal` means operator action (bad token). */
|
|
48
|
+
export class RelayApiError extends Error {
|
|
49
|
+
status;
|
|
50
|
+
kind;
|
|
51
|
+
/** Server error code from the response body (`error.code`), when present. */
|
|
52
|
+
code;
|
|
53
|
+
constructor(message, params) {
|
|
54
|
+
super(message);
|
|
55
|
+
this.name = "RelayApiError";
|
|
56
|
+
this.status = params.status;
|
|
57
|
+
this.kind = params.kind;
|
|
58
|
+
this.code = params.code;
|
|
59
|
+
}
|
|
60
|
+
get terminal() {
|
|
61
|
+
return this.kind === "auth";
|
|
62
|
+
}
|
|
63
|
+
get retryable() {
|
|
64
|
+
return this.kind === "retryable";
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* 409 from the webhook XOR rule: an enabled webhook endpoint makes long
|
|
69
|
+
* polling unavailable until the operator disables it (server code
|
|
70
|
+
* `conflict`, distinct from `terminated_by_other_consumer`).
|
|
71
|
+
*/
|
|
72
|
+
export function isRelayWebhookConflict(error) {
|
|
73
|
+
return (error instanceof RelayApiError &&
|
|
74
|
+
error.status === 409 &&
|
|
75
|
+
error.code !== "terminated_by_other_consumer");
|
|
76
|
+
}
|
|
77
|
+
export function classifyRelayHttpStatus(status) {
|
|
78
|
+
if (status === 401) {
|
|
79
|
+
return "auth";
|
|
80
|
+
}
|
|
81
|
+
if (status === 409) {
|
|
82
|
+
return "conflict";
|
|
83
|
+
}
|
|
84
|
+
if (status === 408 || status === 429 || status >= 500) {
|
|
85
|
+
return "retryable";
|
|
86
|
+
}
|
|
87
|
+
return "rejected";
|
|
88
|
+
}
|
|
89
|
+
export function isAbortError(error) {
|
|
90
|
+
return error instanceof Error && error.name === "AbortError";
|
|
91
|
+
}
|
|
92
|
+
async function readErrorDetail(response) {
|
|
93
|
+
try {
|
|
94
|
+
const body = (await response.json());
|
|
95
|
+
return {
|
|
96
|
+
...(body?.error?.code ? { code: body.error.code } : {}),
|
|
97
|
+
message: body?.error?.message ?? body?.message ?? "",
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return { message: "" };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
export function createRelayClient(options) {
|
|
105
|
+
const baseUrl = normalizeRelayBaseUrl(options.baseUrl);
|
|
106
|
+
const fetchImpl = options.fetchImpl ?? ((input, init) => fetch(input, init));
|
|
107
|
+
const requestTimeoutMs = options.requestTimeoutMs ?? 15_000;
|
|
108
|
+
const request = async (params) => {
|
|
109
|
+
const url = new URL(`${baseUrl}${params.path}`);
|
|
110
|
+
for (const [key, value] of Object.entries(params.query ?? {})) {
|
|
111
|
+
if (value !== undefined) {
|
|
112
|
+
url.searchParams.set(key, String(value));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
let response;
|
|
116
|
+
const timeoutSignal = AbortSignal.timeout(params.timeoutMs ?? requestTimeoutMs);
|
|
117
|
+
const signal = params.signal
|
|
118
|
+
? AbortSignal.any([params.signal, timeoutSignal])
|
|
119
|
+
: timeoutSignal;
|
|
120
|
+
try {
|
|
121
|
+
response = await fetchImpl(url.toString(), {
|
|
122
|
+
method: params.method,
|
|
123
|
+
headers: {
|
|
124
|
+
authorization: `Bearer ${options.token}`,
|
|
125
|
+
...(params.body === undefined ? {} : { "content-type": "application/json" }),
|
|
126
|
+
...params.headers,
|
|
127
|
+
},
|
|
128
|
+
...(params.body === undefined ? {} : { body: JSON.stringify(params.body) }),
|
|
129
|
+
signal,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
if (timeoutSignal.aborted && !params.signal?.aborted) {
|
|
134
|
+
throw new RelayApiError(`relay: ${params.method} ${params.path} timed out after ${params.timeoutMs ?? requestTimeoutMs}ms`, { kind: "retryable" });
|
|
135
|
+
}
|
|
136
|
+
if (isAbortError(error)) {
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
// Network-level failure (DNS, reset, offline): always retryable.
|
|
140
|
+
throw new RelayApiError(`relay: network error: ${String(error)}`, { kind: "retryable" });
|
|
141
|
+
}
|
|
142
|
+
if (!response.ok) {
|
|
143
|
+
const detail = await readErrorDetail(response);
|
|
144
|
+
throw new RelayApiError(`relay: ${params.method} ${params.path} failed with ${response.status}${detail.message ? `: ${detail.message}` : ""}`, {
|
|
145
|
+
status: response.status,
|
|
146
|
+
kind: classifyRelayHttpStatus(response.status),
|
|
147
|
+
...(detail.code ? { code: detail.code } : {}),
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
return response;
|
|
151
|
+
};
|
|
152
|
+
return {
|
|
153
|
+
getMe: async (params) => {
|
|
154
|
+
const response = await request({
|
|
155
|
+
method: "GET",
|
|
156
|
+
path: "/v1/agents/me",
|
|
157
|
+
signal: params?.signal,
|
|
158
|
+
});
|
|
159
|
+
const body = (await response.json());
|
|
160
|
+
return body.agent;
|
|
161
|
+
},
|
|
162
|
+
pollEvents: async (params) => {
|
|
163
|
+
const timeoutSeconds = Math.min(Math.max(params.timeoutSeconds ?? 30, 1), 30);
|
|
164
|
+
// Guard against a wedged connection: the server holds <= timeout seconds,
|
|
165
|
+
// so anything past timeout + slack is a dead socket, not a slow poll.
|
|
166
|
+
const response = await request({
|
|
167
|
+
method: "GET",
|
|
168
|
+
path: "/v1/events",
|
|
169
|
+
query: {
|
|
170
|
+
cursor: params.cursor,
|
|
171
|
+
timeout: timeoutSeconds,
|
|
172
|
+
...(params.limit === undefined ? {} : { limit: params.limit }),
|
|
173
|
+
},
|
|
174
|
+
signal: params.signal,
|
|
175
|
+
timeoutMs: (timeoutSeconds + 15) * 1_000,
|
|
176
|
+
});
|
|
177
|
+
const body = (await response.json());
|
|
178
|
+
const events = Array.isArray(body.events) ? body.events : [];
|
|
179
|
+
const nextCursor = typeof body.next_cursor === "number" && Number.isSafeInteger(body.next_cursor)
|
|
180
|
+
? body.next_cursor
|
|
181
|
+
: params.cursor;
|
|
182
|
+
return { events, nextCursor };
|
|
183
|
+
},
|
|
184
|
+
sendMessage: async (params) => {
|
|
185
|
+
const response = await request({
|
|
186
|
+
method: "POST",
|
|
187
|
+
path: "/v1/messages",
|
|
188
|
+
headers: { "idempotency-key": params.idempotencyKey },
|
|
189
|
+
body: {
|
|
190
|
+
conversation_id: params.conversationId,
|
|
191
|
+
parts: params.parts,
|
|
192
|
+
...(params.replyTo ? { reply_to: params.replyTo } : {}),
|
|
193
|
+
},
|
|
194
|
+
signal: params.signal,
|
|
195
|
+
});
|
|
196
|
+
const body = (await response.json());
|
|
197
|
+
return { messages: body.messages };
|
|
198
|
+
},
|
|
199
|
+
setTyping: async (params) => {
|
|
200
|
+
await request({
|
|
201
|
+
method: "POST",
|
|
202
|
+
path: `/v1/conversations/${encodeURIComponent(params.conversationId)}/typing`,
|
|
203
|
+
body: {
|
|
204
|
+
started: params.started,
|
|
205
|
+
...(params.label ? { label: params.label } : {}),
|
|
206
|
+
},
|
|
207
|
+
signal: params.signal,
|
|
208
|
+
});
|
|
209
|
+
},
|
|
210
|
+
markRead: async (params) => {
|
|
211
|
+
await request({
|
|
212
|
+
method: "POST",
|
|
213
|
+
path: `/v1/conversations/${encodeURIComponent(params.conversationId)}/read`,
|
|
214
|
+
body: { message_id: params.messageId },
|
|
215
|
+
signal: params.signal,
|
|
216
|
+
});
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
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 { assertRelayStateDocument, emptyRelayStateDocument, openRelayStateDocument, } from "./state-files.js";
|
|
7
|
+
export const RELAY_CURSOR_MAX_ENTRIES = 1_000;
|
|
8
|
+
export const RELAY_CURSOR_OVERFLOW_POLICY = "reject-new";
|
|
9
|
+
const RECORD_VERSION = 2;
|
|
10
|
+
/**
|
|
11
|
+
* Open Relay's private, lock-protected state file with fail-closed capacity
|
|
12
|
+
* semantics. A cursor is permanent safety state: evicting an old identity to
|
|
13
|
+
* admit a new one could replay retained events when the old identity returns.
|
|
14
|
+
*/
|
|
15
|
+
export function openRelayCursorStateStore(warn, options = {}) {
|
|
16
|
+
const maxEntries = options.maxEntries ?? RELAY_CURSOR_MAX_ENTRIES;
|
|
17
|
+
if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) {
|
|
18
|
+
throw new Error("relay cursor maxEntries must be a positive safe integer");
|
|
19
|
+
}
|
|
20
|
+
const store = openRelayStateDocument({
|
|
21
|
+
fileName: "cursors.json",
|
|
22
|
+
...(options.env ? { env: options.env } : {}),
|
|
23
|
+
});
|
|
24
|
+
const storageKey = (key) => createHash("sha256").update(key).digest("hex");
|
|
25
|
+
const validateEntry = (key, value) => {
|
|
26
|
+
if (!/^[a-f0-9]{64}$/u.test(key) || !value || typeof value !== "object")
|
|
27
|
+
return false;
|
|
28
|
+
const record = value;
|
|
29
|
+
return (record.version === RECORD_VERSION &&
|
|
30
|
+
isValidCursor(record.cursor) &&
|
|
31
|
+
typeof record.baseUrl === "string" &&
|
|
32
|
+
(() => {
|
|
33
|
+
try {
|
|
34
|
+
return new URL(record.baseUrl).origin === record.baseUrl;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
})() &&
|
|
40
|
+
typeof record.agentId === "string" &&
|
|
41
|
+
record.agentId.length > 0);
|
|
42
|
+
};
|
|
43
|
+
const read = async () => {
|
|
44
|
+
try {
|
|
45
|
+
const current = await store.read();
|
|
46
|
+
if (current === undefined)
|
|
47
|
+
return emptyRelayStateDocument();
|
|
48
|
+
assertRelayStateDocument(current, "cursor", validateEntry);
|
|
49
|
+
return current;
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
warn(`[relay] cursor state unavailable; refusing unsafe cursor reset: ${String(error)}`);
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
return {
|
|
57
|
+
lookup: async (key) => (await read()).entries[storageKey(key)],
|
|
58
|
+
register: async (key, value) => {
|
|
59
|
+
try {
|
|
60
|
+
await store.updateOr(emptyRelayStateDocument(), (current) => {
|
|
61
|
+
assertRelayStateDocument(current, "cursor", validateEntry);
|
|
62
|
+
const hashedKey = storageKey(key);
|
|
63
|
+
if (!Object.hasOwn(current.entries, hashedKey) &&
|
|
64
|
+
Object.keys(current.entries).length >= maxEntries) {
|
|
65
|
+
throw new Error(`relay cursor state reached ${maxEntries} identities (${RELAY_CURSOR_OVERFLOW_POLICY})`);
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
version: current.version,
|
|
69
|
+
entries: { ...current.entries, [hashedKey]: value },
|
|
70
|
+
};
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
warn(`[relay] cursor state unavailable; refusing unsafe cursor reset: ${String(error)}`);
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
function isValidCursor(value) {
|
|
81
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
82
|
+
}
|
|
83
|
+
export function createRelayCursorStore(params) {
|
|
84
|
+
const baseUrl = new URL(params.baseUrl).origin;
|
|
85
|
+
const key = `relay:${baseUrl}:${params.agentId}`;
|
|
86
|
+
let cursor = 0;
|
|
87
|
+
let loaded = false;
|
|
88
|
+
return {
|
|
89
|
+
load: async () => {
|
|
90
|
+
let record;
|
|
91
|
+
try {
|
|
92
|
+
record = await params.store.lookup(key);
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
params.onPersistError?.(error);
|
|
96
|
+
throw new Error(`relay cursor state could not be loaded: ${String(error)}`);
|
|
97
|
+
}
|
|
98
|
+
if (!record) {
|
|
99
|
+
cursor = 0;
|
|
100
|
+
loaded = true;
|
|
101
|
+
return cursor;
|
|
102
|
+
}
|
|
103
|
+
if (record.version !== RECORD_VERSION ||
|
|
104
|
+
!isValidCursor(record.cursor) ||
|
|
105
|
+
record.agentId !== params.agentId ||
|
|
106
|
+
record.baseUrl !== baseUrl) {
|
|
107
|
+
throw new Error(`relay cursor state is corrupt for ${baseUrl} ${params.agentId}; refusing cursor-zero replay`);
|
|
108
|
+
}
|
|
109
|
+
cursor = record.cursor;
|
|
110
|
+
loaded = true;
|
|
111
|
+
return cursor;
|
|
112
|
+
},
|
|
113
|
+
current: () => cursor,
|
|
114
|
+
advance: async (next) => {
|
|
115
|
+
if (!loaded) {
|
|
116
|
+
throw new Error("relay cursor store: advance() before load()");
|
|
117
|
+
}
|
|
118
|
+
if (!isValidCursor(next) || next <= cursor) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
await params.store.register(key, {
|
|
123
|
+
version: RECORD_VERSION,
|
|
124
|
+
cursor: next,
|
|
125
|
+
baseUrl,
|
|
126
|
+
agentId: params.agentId,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
params.onPersistError?.(error);
|
|
131
|
+
throw new Error(`relay cursor advance was not durable: ${String(error)}`);
|
|
132
|
+
}
|
|
133
|
+
cursor = next;
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Relay inbound replay protection. The long-poll cursor acknowledges batches,
|
|
2
|
+
// so a crash between dispatch and cursor advance
|
|
3
|
+
// replays events. Each (canonical origin, agent, event) is claimed, safely
|
|
4
|
+
// preflighted, and durably committed immediately before agent dispatch. That
|
|
5
|
+
// gives engine/tool side effects at-most-once semantics:
|
|
6
|
+
// an interrupted turn may need the user to resend, but it is never silently
|
|
7
|
+
// executed twice.
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { assertRelayStateDocument, emptyRelayStateDocument, openRelayStateDocument, } from "./state-files.js";
|
|
10
|
+
// One shared namespace with stable Relay identity baked into each key so local
|
|
11
|
+
// account renames cannot reset safety state or partition the row budget.
|
|
12
|
+
const RELAY_INBOUND_DEDUPE_SCOPE = "global";
|
|
13
|
+
// 30d window: a long outage can replay a deep cursor backlog.
|
|
14
|
+
export const RELAY_INBOUND_DEDUPE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
15
|
+
export const RELAY_INBOUND_DEDUPE_STATE_MAX_ENTRIES = 20_000;
|
|
16
|
+
export function buildRelayInboundDedupeKey(params) {
|
|
17
|
+
const eventId = params.eventId.trim();
|
|
18
|
+
if (!eventId) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
// NUL separator: event ids are opaque strings, so a printable separator
|
|
22
|
+
// could collide two distinct (account, event) pairs.
|
|
23
|
+
return `${new URL(params.baseUrl).origin}\0${params.agentId}\0${eventId}`;
|
|
24
|
+
}
|
|
25
|
+
export function createRelayInboundDeduper(params) {
|
|
26
|
+
const namespace = RELAY_INBOUND_DEDUPE_SCOPE;
|
|
27
|
+
return {
|
|
28
|
+
claimEvent: async (eventId) => {
|
|
29
|
+
const key = buildRelayInboundDedupeKey({ baseUrl: params.baseUrl, agentId: params.agentId, eventId });
|
|
30
|
+
if (!key) {
|
|
31
|
+
// Fail closed: an event without a durable identity cannot safely
|
|
32
|
+
// cross the at-most-once agent/tool side-effect boundary.
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
return (await params.guard.claim(key, { namespace })).kind === "claimed";
|
|
36
|
+
},
|
|
37
|
+
commitEvent: async (eventId) => {
|
|
38
|
+
const key = buildRelayInboundDedupeKey({ baseUrl: params.baseUrl, agentId: params.agentId, eventId });
|
|
39
|
+
if (!key) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
await params.guard.commit(key, { namespace });
|
|
43
|
+
},
|
|
44
|
+
releaseEvent: (eventId) => {
|
|
45
|
+
const key = buildRelayInboundDedupeKey({ baseUrl: params.baseUrl, agentId: params.agentId, eventId });
|
|
46
|
+
if (key) {
|
|
47
|
+
params.guard.release(key, { namespace });
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function durableAttemptKey(key, namespace) {
|
|
53
|
+
return createHash("sha256").update(`${namespace}\0${key}`).digest("hex");
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Strict Relay-owned guard used by the channel runtime. Unlike a normal
|
|
57
|
+
* message dedupe cache, persistence is not best effort: a failed attempt write
|
|
58
|
+
* must stop before agent dispatch or a crash could execute local tools twice.
|
|
59
|
+
*/
|
|
60
|
+
export function createRelayInboundDedupeGuard(params) {
|
|
61
|
+
const now = params?.now ?? Date.now;
|
|
62
|
+
const ttlMs = params?.ttlMs ?? RELAY_INBOUND_DEDUPE_TTL_MS;
|
|
63
|
+
const maxEntries = params?.maxEntries ?? RELAY_INBOUND_DEDUPE_STATE_MAX_ENTRIES;
|
|
64
|
+
if (!Number.isSafeInteger(ttlMs) || ttlMs < 1) {
|
|
65
|
+
throw new Error("relay inbound dedupe ttlMs must be a positive safe integer");
|
|
66
|
+
}
|
|
67
|
+
if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) {
|
|
68
|
+
throw new Error("relay inbound dedupe maxEntries must be a positive safe integer");
|
|
69
|
+
}
|
|
70
|
+
const readNow = () => {
|
|
71
|
+
const timestamp = now();
|
|
72
|
+
if (!Number.isSafeInteger(timestamp) || timestamp < 0) {
|
|
73
|
+
throw new Error("relay inbound dedupe clock returned an invalid timestamp");
|
|
74
|
+
}
|
|
75
|
+
return timestamp;
|
|
76
|
+
};
|
|
77
|
+
const isPersistedAttempt = (key, value) => {
|
|
78
|
+
if (!/^[a-f0-9]{64}$/u.test(key) || !value || typeof value !== "object")
|
|
79
|
+
return false;
|
|
80
|
+
const entry = value;
|
|
81
|
+
return (typeof entry.attemptedAt === "number" &&
|
|
82
|
+
Number.isSafeInteger(entry.attemptedAt) &&
|
|
83
|
+
entry.attemptedAt >= 0 &&
|
|
84
|
+
typeof entry.expiresAt === "number" &&
|
|
85
|
+
Number.isSafeInteger(entry.expiresAt) &&
|
|
86
|
+
entry.expiresAt > entry.attemptedAt);
|
|
87
|
+
};
|
|
88
|
+
const state = params?.store
|
|
89
|
+
? undefined
|
|
90
|
+
: openRelayStateDocument({
|
|
91
|
+
fileName: "inbound-attempts.json",
|
|
92
|
+
...(params?.env ? { env: params.env } : {}),
|
|
93
|
+
});
|
|
94
|
+
const store = params?.store ??
|
|
95
|
+
{
|
|
96
|
+
lookup: async (key) => {
|
|
97
|
+
const current = await state.read();
|
|
98
|
+
if (current === undefined)
|
|
99
|
+
return undefined;
|
|
100
|
+
assertRelayStateDocument(current, "inbound attempt", isPersistedAttempt);
|
|
101
|
+
const entry = current.entries[key];
|
|
102
|
+
if (!entry || entry.expiresAt <= readNow())
|
|
103
|
+
return undefined;
|
|
104
|
+
return { attemptedAt: entry.attemptedAt };
|
|
105
|
+
},
|
|
106
|
+
register: async (key, value, opts) => {
|
|
107
|
+
await state.updateOr(emptyRelayStateDocument(), (current) => {
|
|
108
|
+
assertRelayStateDocument(current, "inbound attempt", isPersistedAttempt);
|
|
109
|
+
const timestamp = readNow();
|
|
110
|
+
const entryTtlMs = opts?.ttlMs ?? ttlMs;
|
|
111
|
+
if (!Number.isSafeInteger(entryTtlMs) || entryTtlMs < 1) {
|
|
112
|
+
throw new Error("relay inbound dedupe ttlMs must be a positive safe integer");
|
|
113
|
+
}
|
|
114
|
+
const expiresAt = value.attemptedAt + entryTtlMs;
|
|
115
|
+
if (!Number.isSafeInteger(expiresAt)) {
|
|
116
|
+
throw new Error("relay inbound dedupe expiration exceeds safe integer range");
|
|
117
|
+
}
|
|
118
|
+
const liveEntries = Object.fromEntries(Object.entries(current.entries).filter(([, entry]) => entry.expiresAt > timestamp));
|
|
119
|
+
liveEntries[key] = {
|
|
120
|
+
attemptedAt: value.attemptedAt,
|
|
121
|
+
expiresAt,
|
|
122
|
+
};
|
|
123
|
+
const ordered = Object.entries(liveEntries).sort(([leftKey, left], [rightKey, right]) => left.attemptedAt - right.attemptedAt || leftKey.localeCompare(rightKey));
|
|
124
|
+
const retained = ordered.slice(Math.max(ordered.length - maxEntries, 0));
|
|
125
|
+
return {
|
|
126
|
+
version: current.version,
|
|
127
|
+
entries: Object.fromEntries(retained),
|
|
128
|
+
};
|
|
129
|
+
});
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
const inflight = new Set();
|
|
133
|
+
const claiming = new Set();
|
|
134
|
+
const withDiskError = async (operation) => {
|
|
135
|
+
try {
|
|
136
|
+
return await operation();
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
params?.onDiskError?.(error);
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
return {
|
|
144
|
+
claim: async (key, opts) => {
|
|
145
|
+
const storageKey = durableAttemptKey(key, opts.namespace);
|
|
146
|
+
if (inflight.has(storageKey) || claiming.has(storageKey)) {
|
|
147
|
+
return { kind: "inflight" };
|
|
148
|
+
}
|
|
149
|
+
claiming.add(storageKey);
|
|
150
|
+
try {
|
|
151
|
+
if ((await withDiskError(() => store.lookup(storageKey))) !== undefined) {
|
|
152
|
+
return { kind: "duplicate" };
|
|
153
|
+
}
|
|
154
|
+
inflight.add(storageKey);
|
|
155
|
+
return { kind: "claimed" };
|
|
156
|
+
}
|
|
157
|
+
finally {
|
|
158
|
+
claiming.delete(storageKey);
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
commit: async (key, opts) => {
|
|
162
|
+
const storageKey = durableAttemptKey(key, opts.namespace);
|
|
163
|
+
try {
|
|
164
|
+
await withDiskError(() => store.register(storageKey, { attemptedAt: readNow() }, { ttlMs }));
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
finally {
|
|
168
|
+
inflight.delete(storageKey);
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
release: (key, opts) => {
|
|
172
|
+
inflight.delete(durableAttemptKey(key, opts.namespace));
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
export function classifyRelayEvent(event) {
|
|
2
|
+
switch (event.event_type) {
|
|
3
|
+
case "message.received":
|
|
4
|
+
return "message";
|
|
5
|
+
case "reaction.added":
|
|
6
|
+
case "reaction.removed":
|
|
7
|
+
return "reaction";
|
|
8
|
+
case "message.delivered":
|
|
9
|
+
case "message.read":
|
|
10
|
+
return "lifecycle";
|
|
11
|
+
default:
|
|
12
|
+
return "unknown";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Render typed parts into agent-facing text: text parts joined, link URLs
|
|
17
|
+
* inlined, `data` parts as a compact JSON fence, media/voice as a labeled
|
|
18
|
+
* fetchable URL. The URL is a capability link: it is the authorization, so
|
|
19
|
+
* any HTTP client can fetch the bytes without an Agent Token.
|
|
20
|
+
*/
|
|
21
|
+
export function renderRelayPartsText(parts) {
|
|
22
|
+
const lines = [];
|
|
23
|
+
for (const part of parts) {
|
|
24
|
+
switch (part.type) {
|
|
25
|
+
case "text":
|
|
26
|
+
if (part.text) {
|
|
27
|
+
lines.push(part.text);
|
|
28
|
+
}
|
|
29
|
+
break;
|
|
30
|
+
case "link_preview":
|
|
31
|
+
lines.push(part.url);
|
|
32
|
+
break;
|
|
33
|
+
case "data": {
|
|
34
|
+
let rendered;
|
|
35
|
+
try {
|
|
36
|
+
rendered = JSON.stringify(part.data);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
rendered = String(part.data);
|
|
40
|
+
}
|
|
41
|
+
lines.push("```json\n" + rendered + "\n```");
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
case "media":
|
|
45
|
+
lines.push(`[attachment] ${part.url}`);
|
|
46
|
+
break;
|
|
47
|
+
case "voice_memo":
|
|
48
|
+
lines.push(part.duration_ms
|
|
49
|
+
? `[voice memo, ${Math.round(part.duration_ms / 1000)}s] ${part.url}`
|
|
50
|
+
: `[voice memo] ${part.url}`);
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return lines.join("\n");
|
|
55
|
+
}
|
|
56
|
+
/** Drop the agent's own sends echoed back on the event stream. */
|
|
57
|
+
export function isRelayEchoMessage(message, agentId) {
|
|
58
|
+
return message.sender.kind === "agent" && message.sender.id === agentId;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Build the dispatchable fact bundle for a message.received event. Returns
|
|
62
|
+
* null when the event should not start a turn: echoes of our own agent,
|
|
63
|
+
* non-message events, or messages with no renderable content.
|
|
64
|
+
*/
|
|
65
|
+
export function buildRelayInboundFacts(event, params) {
|
|
66
|
+
if (classifyRelayEvent(event) !== "message") {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
const message = event.data.message;
|
|
70
|
+
if (!message || !message.id || !message.conversation_id) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
// Agent-authored messages never start a local agent turn. This drops our
|
|
74
|
+
// own event echo and prevents agent-to-agent loops even if an id is
|
|
75
|
+
// mistakenly added to the user allowlist.
|
|
76
|
+
if (message.sender.kind !== "user" || isRelayEchoMessage(message, params.agentId)) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
const text = renderRelayPartsText(message.parts) || message.fallback_text || "";
|
|
80
|
+
if (!text.trim()) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
const createdAtMs = Date.parse(message.created_at);
|
|
84
|
+
return {
|
|
85
|
+
eventId: event.event_id,
|
|
86
|
+
messageId: message.id,
|
|
87
|
+
conversationId: message.conversation_id,
|
|
88
|
+
senderId: message.sender.id,
|
|
89
|
+
senderKind: message.sender.kind,
|
|
90
|
+
...(message.reply_to?.message_id ? { replyToId: message.reply_to.message_id } : {}),
|
|
91
|
+
text,
|
|
92
|
+
...(Number.isFinite(createdAtMs) ? { timestamp: createdAtMs } : {}),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Owns exactly one abortable long-poll lifecycle per configured account. */
|
|
2
|
+
export function createRelayAccountLifecycleRegistry() {
|
|
3
|
+
const controllers = new Map();
|
|
4
|
+
return {
|
|
5
|
+
acquire(accountId, parentSignal) {
|
|
6
|
+
if (controllers.has(accountId)) {
|
|
7
|
+
throw new Error(`relay: account "${accountId}" already has an active consumer`);
|
|
8
|
+
}
|
|
9
|
+
const controller = new AbortController();
|
|
10
|
+
controllers.set(accountId, controller);
|
|
11
|
+
const signal = AbortSignal.any([parentSignal, controller.signal]);
|
|
12
|
+
let released = false;
|
|
13
|
+
return {
|
|
14
|
+
signal,
|
|
15
|
+
release: () => {
|
|
16
|
+
if (released) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
released = true;
|
|
20
|
+
if (controllers.get(accountId) === controller) {
|
|
21
|
+
controllers.delete(accountId);
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
},
|
|
26
|
+
stop(accountId) {
|
|
27
|
+
const controller = controllers.get(accountId);
|
|
28
|
+
if (!controller) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
controller.abort();
|
|
32
|
+
return true;
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|