@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.
- package/LICENSE +1 -1
- package/README.md +159 -124
- package/contracts/relay-sdk-0.3.0-staging.4.registry.json +58 -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 -498
- 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 -80
- package/dist/src/ingress.js +64 -0
- package/dist/src/outbound.js +48 -109
- 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 +113 -40
- package/setup-entry.ts +0 -2
- package/src/accounts.ts +95 -51
- package/src/channel.ts +271 -611
- package/src/dispatch.ts +324 -0
- package/src/full-sync.ts +47 -0
- package/src/gateway.ts +216 -0
- package/src/inbound.ts +71 -111
- package/src/ingress.ts +123 -0
- package/src/outbound.ts +70 -142
- package/src/runtime.ts +4 -4
- package/src/state.ts +609 -0
- package/src/types.ts +51 -148
- package/dist/src/account-lock.js +0 -91
- package/dist/src/client.js +0 -229
- package/dist/src/cursor-store.js +0 -136
- package/dist/src/inbound-dedupe.js +0 -175
- package/dist/src/lifecycle.js +0 -35
- package/dist/src/poll-loop.js +0 -125
- package/dist/src/responding.js +0 -13
- package/dist/src/security.js +0 -26
- package/dist/src/state-files.js +0 -167
- package/src/account-lock.ts +0 -108
- package/src/client.ts +0 -330
- package/src/cursor-store.ts +0 -186
- package/src/inbound-dedupe.ts +0 -241
- package/src/lifecycle.ts +0 -42
- package/src/poll-loop.ts +0 -161
- package/src/responding.ts +0 -21
- package/src/security.ts +0 -36
- package/src/state-files.ts +0 -212
package/src/types.ts
CHANGED
|
@@ -1,141 +1,12 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
};
|
|
9
|
-
|
|
10
|
-
// Inline mention of a conversation participant. Offsets are UTF-16 code
|
|
11
|
-
// units into the part's text, which holds the inserted display name with
|
|
12
|
-
// no "@". Ranges are sorted by start and never overlap.
|
|
13
|
-
export type RelayMentionRange = {
|
|
14
|
-
start: number;
|
|
15
|
-
length: number;
|
|
16
|
-
participant_id: string;
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
export type RelayTextStyle = "bold" | "italic" | "underline" | "strikethrough" | "monospace" | "spoiler";
|
|
20
|
-
|
|
21
|
-
// One formatting run over a text part, offsets in UTF-16 code units like
|
|
22
|
-
// mentions. An EMPTY styles array on the part is meaningful: it marks
|
|
23
|
-
// structured plain text as opposed to a legacy Markdown body.
|
|
24
|
-
export type RelayStyleRange = {
|
|
25
|
-
start: number;
|
|
26
|
-
length: number;
|
|
27
|
-
styles: RelayTextStyle[];
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
export type RelayTextPart = {
|
|
31
|
-
part_index?: number;
|
|
32
|
-
type: "text";
|
|
33
|
-
text: string;
|
|
34
|
-
mentions?: RelayMentionRange[];
|
|
35
|
-
styles?: RelayStyleRange[];
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
export type RelayMediaPart = {
|
|
39
|
-
part_index?: number;
|
|
40
|
-
type: "media";
|
|
41
|
-
url: string;
|
|
42
|
-
attachment_id?: string;
|
|
43
|
-
// Pixel dimensions (always paired) and a blurhash placeholder (base83)
|
|
44
|
-
// to draw before the bytes download.
|
|
45
|
-
width?: number;
|
|
46
|
-
height?: number;
|
|
47
|
-
blur_hash?: string;
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
export type RelayVoiceMemoPart = {
|
|
51
|
-
part_index?: number;
|
|
52
|
-
type: "voice_memo";
|
|
53
|
-
url: string;
|
|
54
|
-
attachment_id?: string;
|
|
55
|
-
duration_ms?: number;
|
|
56
|
-
};
|
|
57
|
-
|
|
58
|
-
export type RelayLinkPreviewPart = {
|
|
59
|
-
part_index?: number;
|
|
60
|
-
type: "link_preview";
|
|
61
|
-
url: string;
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
export type RelayDataPart = {
|
|
65
|
-
part_index?: number;
|
|
66
|
-
type: "data";
|
|
67
|
-
data: unknown;
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
export type RelayPart =
|
|
71
|
-
| RelayTextPart
|
|
72
|
-
| RelayMediaPart
|
|
73
|
-
| RelayVoiceMemoPart
|
|
74
|
-
| RelayLinkPreviewPart
|
|
75
|
-
| RelayDataPart;
|
|
76
|
-
|
|
77
|
-
export type RelayReplyRef = {
|
|
78
|
-
message_id?: string;
|
|
79
|
-
} | null;
|
|
80
|
-
|
|
81
|
-
export type RelayMessage = {
|
|
82
|
-
id: string;
|
|
83
|
-
conversation_id: string;
|
|
84
|
-
sequence: number;
|
|
85
|
-
sender: RelaySender;
|
|
86
|
-
parts: RelayPart[];
|
|
87
|
-
reply_to?: RelayReplyRef;
|
|
88
|
-
fallback_text: string;
|
|
89
|
-
status: string;
|
|
90
|
-
created_at: string;
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
export type RelayEventType =
|
|
94
|
-
| "message.received"
|
|
95
|
-
| "reaction.added"
|
|
96
|
-
| "reaction.removed"
|
|
97
|
-
| "message.delivered"
|
|
98
|
-
| "message.read"
|
|
99
|
-
| (string & {});
|
|
100
|
-
|
|
101
|
-
export type RelayEvent = {
|
|
102
|
-
event_id: string;
|
|
103
|
-
event_type: RelayEventType;
|
|
104
|
-
agent_id: string;
|
|
105
|
-
created_at: string;
|
|
106
|
-
data: {
|
|
107
|
-
message?: RelayMessage;
|
|
108
|
-
[key: string]: unknown;
|
|
109
|
-
};
|
|
110
|
-
};
|
|
111
|
-
|
|
112
|
-
export type RelayAgentProfile = {
|
|
113
|
-
id: string;
|
|
114
|
-
owner_user_id?: string;
|
|
115
|
-
handle: string;
|
|
116
|
-
display_name: string;
|
|
117
|
-
tagline?: string;
|
|
118
|
-
avatar_url?: string | null;
|
|
119
|
-
visibility?: "private" | "unlisted" | "public";
|
|
120
|
-
created_at?: string;
|
|
121
|
-
};
|
|
122
|
-
|
|
123
|
-
export type RelayEventsPage = {
|
|
124
|
-
events: RelayEvent[];
|
|
125
|
-
nextCursor: number;
|
|
126
|
-
};
|
|
127
|
-
|
|
128
|
-
// The 202 from POST /v1/messages. The server splits the accepted parts at
|
|
129
|
-
// ingest: each visible non-media part becomes its own message, contiguous
|
|
130
|
-
// media parts stay one media message, and a voice memo always commits alone,
|
|
131
|
-
// so one send commits one or more messages, in display order.
|
|
132
|
-
export type RelaySendResult = {
|
|
133
|
-
messages: RelayMessage[];
|
|
134
|
-
};
|
|
135
|
-
|
|
136
|
-
// ---------------------------------------------------------------------------
|
|
137
|
-
// Plugin config (channels.relay) and resolved account.
|
|
138
|
-
// ---------------------------------------------------------------------------
|
|
1
|
+
import type {
|
|
2
|
+
Chat,
|
|
3
|
+
ChatHandle,
|
|
4
|
+
MessageWebhookData,
|
|
5
|
+
Message,
|
|
6
|
+
RelayWebhookEnvelope,
|
|
7
|
+
RelayWebhookEvent,
|
|
8
|
+
} from "@relaymessenger/sdk";
|
|
9
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk/channel-core";
|
|
139
10
|
|
|
140
11
|
export type RelayAccountConfig = {
|
|
141
12
|
name?: string;
|
|
@@ -143,31 +14,63 @@ export type RelayAccountConfig = {
|
|
|
143
14
|
token?: string;
|
|
144
15
|
tokenFile?: string;
|
|
145
16
|
baseUrl?: string;
|
|
146
|
-
allowFrom?:
|
|
147
|
-
pollTimeoutSeconds?: number;
|
|
17
|
+
allowFrom?: string[];
|
|
148
18
|
};
|
|
149
19
|
|
|
150
20
|
export type RelayChannelConfig = RelayAccountConfig & {
|
|
151
|
-
accounts?: Record<string, Partial<RelayAccountConfig>>;
|
|
152
21
|
defaultAccount?: string;
|
|
22
|
+
accounts?: Record<string, RelayAccountConfig>;
|
|
153
23
|
};
|
|
154
24
|
|
|
155
|
-
export type RelayCoreConfig = {
|
|
156
|
-
channels?: {
|
|
25
|
+
export type RelayCoreConfig = OpenClawConfig & {
|
|
26
|
+
channels?: OpenClawConfig["channels"] & {
|
|
157
27
|
relay?: RelayChannelConfig;
|
|
158
28
|
};
|
|
159
|
-
session?: {
|
|
160
|
-
store?: string;
|
|
161
|
-
};
|
|
162
29
|
};
|
|
163
30
|
|
|
164
31
|
export type ResolvedRelayAccount = {
|
|
165
32
|
accountId: string;
|
|
33
|
+
name?: string;
|
|
166
34
|
enabled: boolean;
|
|
167
35
|
configured: boolean;
|
|
168
|
-
name?: string;
|
|
169
36
|
token: string;
|
|
170
37
|
baseUrl: string;
|
|
171
|
-
|
|
38
|
+
allowFrom: string[];
|
|
172
39
|
config: RelayAccountConfig;
|
|
173
40
|
};
|
|
41
|
+
|
|
42
|
+
export type RelayIngressPayload = {
|
|
43
|
+
version: 1;
|
|
44
|
+
rawEvent: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type RelaySnapshot = {
|
|
48
|
+
version: 1;
|
|
49
|
+
throughSequence: string;
|
|
50
|
+
reason: "checkpoint_outside_retention";
|
|
51
|
+
completedAt: string;
|
|
52
|
+
chats: Array<{
|
|
53
|
+
chat: Chat;
|
|
54
|
+
messages: Message[];
|
|
55
|
+
}>;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export type RelayMessageReceivedEvent = RelayWebhookEnvelope<
|
|
59
|
+
MessageWebhookData,
|
|
60
|
+
"message.received"
|
|
61
|
+
>;
|
|
62
|
+
|
|
63
|
+
export type RelayInboundFacts = {
|
|
64
|
+
eventId: string;
|
|
65
|
+
messageId: string;
|
|
66
|
+
chatId: string;
|
|
67
|
+
chatType: "direct" | "group";
|
|
68
|
+
contactId: string;
|
|
69
|
+
handle: string;
|
|
70
|
+
displayName: string;
|
|
71
|
+
text: string;
|
|
72
|
+
mentionHandles: string[];
|
|
73
|
+
ownerHandle?: ChatHandle;
|
|
74
|
+
replyToId?: string;
|
|
75
|
+
timestamp?: number;
|
|
76
|
+
};
|
package/dist/src/account-lock.js
DELETED
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
|
|
3
|
-
import { homedir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
function processIsLive(pid) {
|
|
6
|
-
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
7
|
-
return false;
|
|
8
|
-
try {
|
|
9
|
-
process.kill(pid, 0);
|
|
10
|
-
return true;
|
|
11
|
-
}
|
|
12
|
-
catch (error) {
|
|
13
|
-
return error?.code === "EPERM";
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
function readOwner(path) {
|
|
17
|
-
try {
|
|
18
|
-
const value = JSON.parse(readFileSync(path, "utf8"));
|
|
19
|
-
if (Number.isSafeInteger(value.pid) &&
|
|
20
|
-
typeof value.nonce === "string" &&
|
|
21
|
-
typeof value.account_id === "string" &&
|
|
22
|
-
typeof value.created_at === "string") {
|
|
23
|
-
return value;
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
catch {
|
|
27
|
-
// Missing/malformed ownership is never deleted in place by a contender.
|
|
28
|
-
}
|
|
29
|
-
return undefined;
|
|
30
|
-
}
|
|
31
|
-
/** Atomic filesystem lease preventing two OpenClaw processes polling one agent. */
|
|
32
|
-
export class RelayAccountLock {
|
|
33
|
-
accountId;
|
|
34
|
-
lockPath;
|
|
35
|
-
ownerPath;
|
|
36
|
-
nonce = randomUUID();
|
|
37
|
-
held = false;
|
|
38
|
-
constructor(baseUrl, agentId, accountId, baseDir = join(homedir(), ".openclaw", "relay", "consumer-locks")) {
|
|
39
|
-
this.accountId = accountId;
|
|
40
|
-
const key = createHash("sha256").update(`${baseUrl}\0${agentId}`).digest("hex");
|
|
41
|
-
this.lockPath = join(baseDir, key);
|
|
42
|
-
this.ownerPath = join(this.lockPath, "owner.json");
|
|
43
|
-
}
|
|
44
|
-
acquire() {
|
|
45
|
-
mkdirSync(join(this.lockPath, ".."), { recursive: true, mode: 0o700 });
|
|
46
|
-
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
47
|
-
try {
|
|
48
|
-
mkdirSync(this.lockPath, { mode: 0o700 });
|
|
49
|
-
const owner = {
|
|
50
|
-
pid: process.pid,
|
|
51
|
-
nonce: this.nonce,
|
|
52
|
-
account_id: this.accountId,
|
|
53
|
-
created_at: new Date().toISOString(),
|
|
54
|
-
};
|
|
55
|
-
writeFileSync(this.ownerPath, `${JSON.stringify(owner)}\n`, { mode: 0o600 });
|
|
56
|
-
this.held = true;
|
|
57
|
-
return;
|
|
58
|
-
}
|
|
59
|
-
catch (error) {
|
|
60
|
-
if (error?.code !== "EEXIST")
|
|
61
|
-
throw error;
|
|
62
|
-
const owner = readOwner(this.ownerPath);
|
|
63
|
-
if (!owner || processIsLive(owner.pid)) {
|
|
64
|
-
const claimant = owner
|
|
65
|
-
? `account "${owner.account_id}" (pid ${owner.pid})`
|
|
66
|
-
: "an existing process with unreadable ownership";
|
|
67
|
-
throw new Error(`relay: this agent already has an active consumer in ${claimant}`);
|
|
68
|
-
}
|
|
69
|
-
const stalePath = `${this.lockPath}.stale-${Date.now()}-${randomUUID()}`;
|
|
70
|
-
try {
|
|
71
|
-
renameSync(this.lockPath, stalePath);
|
|
72
|
-
rmSync(stalePath, { recursive: true, force: true });
|
|
73
|
-
}
|
|
74
|
-
catch (renameError) {
|
|
75
|
-
if (renameError?.code !== "ENOENT")
|
|
76
|
-
throw renameError;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
throw new Error("relay: could not acquire the agent consumer lock");
|
|
81
|
-
}
|
|
82
|
-
release() {
|
|
83
|
-
if (!this.held)
|
|
84
|
-
return;
|
|
85
|
-
const owner = readOwner(this.ownerPath);
|
|
86
|
-
if (owner?.nonce === this.nonce && existsSync(this.lockPath)) {
|
|
87
|
-
rmSync(this.lockPath, { recursive: true, force: true });
|
|
88
|
-
}
|
|
89
|
-
this.held = false;
|
|
90
|
-
}
|
|
91
|
-
}
|
package/dist/src/client.js
DELETED
|
@@ -1,229 +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
|
-
export const DEFAULT_RELAY_BASE_URL = "https://api.relayapp.im";
|
|
6
|
-
function isLoopbackHostname(hostname) {
|
|
7
|
-
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
8
|
-
const ipVersion = isIP(normalized);
|
|
9
|
-
if (ipVersion === 4) {
|
|
10
|
-
return normalized.split(".")[0] === "127";
|
|
11
|
-
}
|
|
12
|
-
if (ipVersion === 6) {
|
|
13
|
-
return normalized === "::1";
|
|
14
|
-
}
|
|
15
|
-
return (normalized === "localhost" ||
|
|
16
|
-
normalized.endsWith(".localhost"));
|
|
17
|
-
}
|
|
18
|
-
/**
|
|
19
|
-
* Validate and canonicalize the API origin before a bearer token can be sent
|
|
20
|
-
* to it. Production/custom remote origins must use HTTPS. Plain HTTP remains
|
|
21
|
-
* available only for an explicit loopback development server.
|
|
22
|
-
*/
|
|
23
|
-
export function normalizeRelayBaseUrl(raw) {
|
|
24
|
-
const candidate = raw?.trim() || DEFAULT_RELAY_BASE_URL;
|
|
25
|
-
let url;
|
|
26
|
-
try {
|
|
27
|
-
url = new URL(candidate);
|
|
28
|
-
}
|
|
29
|
-
catch {
|
|
30
|
-
throw new Error(`relay: invalid baseUrl ${JSON.stringify(candidate)}`);
|
|
31
|
-
}
|
|
32
|
-
if (url.username || url.password) {
|
|
33
|
-
throw new Error("relay: baseUrl must not contain credentials");
|
|
34
|
-
}
|
|
35
|
-
if (url.search || url.hash) {
|
|
36
|
-
throw new Error("relay: baseUrl must not contain a query or fragment");
|
|
37
|
-
}
|
|
38
|
-
if (!/^\/+$/u.test(url.pathname)) {
|
|
39
|
-
throw new Error("relay: baseUrl must be an origin without a path");
|
|
40
|
-
}
|
|
41
|
-
if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopbackHostname(url.hostname))) {
|
|
42
|
-
throw new Error("relay: baseUrl must use HTTPS (HTTP is allowed only for loopback development)");
|
|
43
|
-
}
|
|
44
|
-
return url.origin;
|
|
45
|
-
}
|
|
46
|
-
/** Classified Relay API failure. `terminal` means operator action (bad token). */
|
|
47
|
-
export class RelayApiError extends Error {
|
|
48
|
-
status;
|
|
49
|
-
kind;
|
|
50
|
-
/** Server error code from the response body (`error.code`), when present. */
|
|
51
|
-
code;
|
|
52
|
-
constructor(message, params) {
|
|
53
|
-
super(message);
|
|
54
|
-
this.name = "RelayApiError";
|
|
55
|
-
this.status = params.status;
|
|
56
|
-
this.kind = params.kind;
|
|
57
|
-
this.code = params.code;
|
|
58
|
-
}
|
|
59
|
-
get terminal() {
|
|
60
|
-
return this.kind === "auth";
|
|
61
|
-
}
|
|
62
|
-
get retryable() {
|
|
63
|
-
return this.kind === "retryable";
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
/**
|
|
67
|
-
* 409 from the webhook XOR rule: an enabled webhook endpoint makes long
|
|
68
|
-
* polling unavailable until the operator disables it (server code
|
|
69
|
-
* `conflict`, distinct from `terminated_by_other_consumer`).
|
|
70
|
-
*/
|
|
71
|
-
export function isRelayWebhookConflict(error) {
|
|
72
|
-
return (error instanceof RelayApiError &&
|
|
73
|
-
error.status === 409 &&
|
|
74
|
-
error.code !== "terminated_by_other_consumer");
|
|
75
|
-
}
|
|
76
|
-
export function classifyRelayHttpStatus(status) {
|
|
77
|
-
if (status === 401) {
|
|
78
|
-
return "auth";
|
|
79
|
-
}
|
|
80
|
-
if (status === 409) {
|
|
81
|
-
return "conflict";
|
|
82
|
-
}
|
|
83
|
-
if (status === 408 || status === 429 || status >= 500) {
|
|
84
|
-
return "retryable";
|
|
85
|
-
}
|
|
86
|
-
return "rejected";
|
|
87
|
-
}
|
|
88
|
-
export function isAbortError(error) {
|
|
89
|
-
return error instanceof Error && error.name === "AbortError";
|
|
90
|
-
}
|
|
91
|
-
async function readErrorDetail(response) {
|
|
92
|
-
try {
|
|
93
|
-
const body = (await response.json());
|
|
94
|
-
return {
|
|
95
|
-
...(body?.error?.code ? { code: body.error.code } : {}),
|
|
96
|
-
message: body?.error?.message ?? body?.message ?? "",
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
|
-
catch {
|
|
100
|
-
return { message: "" };
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
export function createRelayClient(options) {
|
|
104
|
-
const baseUrl = normalizeRelayBaseUrl(options.baseUrl);
|
|
105
|
-
const fetchImpl = options.fetchImpl ?? ((input, init) => fetch(input, init));
|
|
106
|
-
const requestTimeoutMs = options.requestTimeoutMs ?? 15_000;
|
|
107
|
-
const request = async (params) => {
|
|
108
|
-
const url = new URL(`${baseUrl}${params.path}`);
|
|
109
|
-
for (const [key, value] of Object.entries(params.query ?? {})) {
|
|
110
|
-
if (value !== undefined) {
|
|
111
|
-
url.searchParams.set(key, String(value));
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
let response;
|
|
115
|
-
const timeoutSignal = AbortSignal.timeout(params.timeoutMs ?? requestTimeoutMs);
|
|
116
|
-
const signal = params.signal
|
|
117
|
-
? AbortSignal.any([params.signal, timeoutSignal])
|
|
118
|
-
: timeoutSignal;
|
|
119
|
-
try {
|
|
120
|
-
response = await fetchImpl(url.toString(), {
|
|
121
|
-
method: params.method,
|
|
122
|
-
headers: {
|
|
123
|
-
authorization: `Bearer ${options.token}`,
|
|
124
|
-
...(params.body === undefined ? {} : { "content-type": "application/json" }),
|
|
125
|
-
...params.headers,
|
|
126
|
-
},
|
|
127
|
-
...(params.body === undefined ? {} : { body: JSON.stringify(params.body) }),
|
|
128
|
-
signal,
|
|
129
|
-
});
|
|
130
|
-
}
|
|
131
|
-
catch (error) {
|
|
132
|
-
if (timeoutSignal.aborted && !params.signal?.aborted) {
|
|
133
|
-
throw new RelayApiError(`relay: ${params.method} ${params.path} timed out after ${params.timeoutMs ?? requestTimeoutMs}ms`, { kind: "retryable" });
|
|
134
|
-
}
|
|
135
|
-
if (isAbortError(error)) {
|
|
136
|
-
throw error;
|
|
137
|
-
}
|
|
138
|
-
// Network-level failure (DNS, reset, offline): always retryable.
|
|
139
|
-
throw new RelayApiError(`relay: network error: ${String(error)}`, { kind: "retryable" });
|
|
140
|
-
}
|
|
141
|
-
if (!response.ok) {
|
|
142
|
-
const detail = await readErrorDetail(response);
|
|
143
|
-
throw new RelayApiError(`relay: ${params.method} ${params.path} failed with ${response.status}${detail.message ? `: ${detail.message}` : ""}`, {
|
|
144
|
-
status: response.status,
|
|
145
|
-
kind: classifyRelayHttpStatus(response.status),
|
|
146
|
-
...(detail.code ? { code: detail.code } : {}),
|
|
147
|
-
});
|
|
148
|
-
}
|
|
149
|
-
return response;
|
|
150
|
-
};
|
|
151
|
-
return {
|
|
152
|
-
getMe: async (params) => {
|
|
153
|
-
const response = await request({
|
|
154
|
-
method: "GET",
|
|
155
|
-
path: "/v1/agents/me",
|
|
156
|
-
signal: params?.signal,
|
|
157
|
-
});
|
|
158
|
-
const body = (await response.json());
|
|
159
|
-
return body.agent;
|
|
160
|
-
},
|
|
161
|
-
pollEvents: async (params) => {
|
|
162
|
-
const timeoutSeconds = Math.min(Math.max(params.timeoutSeconds ?? 30, 1), 30);
|
|
163
|
-
// Guard against a wedged connection: the server holds <= timeout seconds,
|
|
164
|
-
// so anything past timeout + slack is a dead socket, not a slow poll.
|
|
165
|
-
const response = await request({
|
|
166
|
-
method: "GET",
|
|
167
|
-
path: "/v1/events",
|
|
168
|
-
query: {
|
|
169
|
-
cursor: params.cursor,
|
|
170
|
-
timeout: timeoutSeconds,
|
|
171
|
-
...(params.limit === undefined ? {} : { limit: params.limit }),
|
|
172
|
-
},
|
|
173
|
-
signal: params.signal,
|
|
174
|
-
timeoutMs: (timeoutSeconds + 15) * 1_000,
|
|
175
|
-
});
|
|
176
|
-
const body = (await response.json());
|
|
177
|
-
const events = Array.isArray(body.events) ? body.events : [];
|
|
178
|
-
const nextCursor = typeof body.next_cursor === "number" && Number.isSafeInteger(body.next_cursor)
|
|
179
|
-
? body.next_cursor
|
|
180
|
-
: params.cursor;
|
|
181
|
-
return { events, nextCursor };
|
|
182
|
-
},
|
|
183
|
-
sendMessage: async (params) => {
|
|
184
|
-
const response = await request({
|
|
185
|
-
method: "POST",
|
|
186
|
-
path: "/v1/messages",
|
|
187
|
-
headers: { "idempotency-key": params.idempotencyKey },
|
|
188
|
-
body: {
|
|
189
|
-
conversation_id: params.conversationId,
|
|
190
|
-
parts: params.parts,
|
|
191
|
-
...(params.replyTo ? { reply_to: params.replyTo } : {}),
|
|
192
|
-
},
|
|
193
|
-
signal: params.signal,
|
|
194
|
-
});
|
|
195
|
-
const body = (await response.json());
|
|
196
|
-
return { messages: body.messages };
|
|
197
|
-
},
|
|
198
|
-
setTyping: async (params) => {
|
|
199
|
-
await request({
|
|
200
|
-
method: "POST",
|
|
201
|
-
path: `/v1/conversations/${encodeURIComponent(params.conversationId)}/typing`,
|
|
202
|
-
body: {
|
|
203
|
-
started: params.started,
|
|
204
|
-
...(params.label ? { label: params.label } : {}),
|
|
205
|
-
},
|
|
206
|
-
signal: params.signal,
|
|
207
|
-
});
|
|
208
|
-
},
|
|
209
|
-
setResponding: async (params) => {
|
|
210
|
-
await request({
|
|
211
|
-
method: "POST",
|
|
212
|
-
path: `/v1/conversations/${encodeURIComponent(params.conversationId)}/responding`,
|
|
213
|
-
body: {
|
|
214
|
-
message_id: params.messageId,
|
|
215
|
-
...(params.label ? { label: params.label } : {}),
|
|
216
|
-
},
|
|
217
|
-
signal: params.signal,
|
|
218
|
-
});
|
|
219
|
-
},
|
|
220
|
-
markRead: async (params) => {
|
|
221
|
-
await request({
|
|
222
|
-
method: "POST",
|
|
223
|
-
path: `/v1/conversations/${encodeURIComponent(params.conversationId)}/read`,
|
|
224
|
-
body: { message_id: params.messageId },
|
|
225
|
-
signal: params.signal,
|
|
226
|
-
});
|
|
227
|
-
},
|
|
228
|
-
};
|
|
229
|
-
}
|
package/dist/src/cursor-store.js
DELETED
|
@@ -1,136 +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 { 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
|
-
}
|