@relaymessenger/openclaw-plugin 0.3.3 → 0.3.4

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.
@@ -0,0 +1,293 @@
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
+ }
@@ -0,0 +1,61 @@
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
+ }
@@ -0,0 +1,82 @@
1
+ /** Wire types for the Relay v1 developer API used by plugins and examples. */
2
+
3
+ export type RelaySender = {
4
+ kind: "user" | "agent" | "system";
5
+ id: string;
6
+ display_name?: string;
7
+ };
8
+
9
+ export type RelayOutgoingPart =
10
+ | { type: "text"; text: string }
11
+ | { type: "media"; attachment_id?: string; url?: string }
12
+ | { type: "voice_memo"; attachment_id?: string; url?: string; duration_ms?: number }
13
+ | { type: "link_preview"; url: string }
14
+ | { type: "data"; data: Record<string, unknown> };
15
+
16
+ export type RelayPart = {
17
+ type: string;
18
+ part_index?: number;
19
+ text?: string;
20
+ url?: string;
21
+ attachment_id?: string;
22
+ duration_ms?: number;
23
+ data?: Record<string, unknown>;
24
+ [key: string]: unknown;
25
+ };
26
+
27
+ export type RelayReplyRef = {
28
+ message_id: string;
29
+ };
30
+
31
+ export type RelayMessage = {
32
+ id: string;
33
+ conversation_id: string;
34
+ sequence: number;
35
+ sender: RelaySender;
36
+ parts: RelayPart[];
37
+ reply_to?: RelayReplyRef | null;
38
+ fallback_text?: string;
39
+ status?: string;
40
+ created_at: string;
41
+ };
42
+
43
+ export type RelayEventEnvelope<TData = Record<string, unknown>> = {
44
+ event_id: string;
45
+ event_type: string;
46
+ agent_id: string;
47
+ created_at: string;
48
+ data: TData;
49
+ };
50
+
51
+ export type MessageReceivedData = {
52
+ message: RelayMessage;
53
+ invocation_id?: string;
54
+ };
55
+
56
+ export type MessageReceivedEvent = RelayEventEnvelope<MessageReceivedData>;
57
+
58
+ export type RelayAgentProfile = {
59
+ id: string;
60
+ owner_user_id?: string;
61
+ handle: string;
62
+ display_name: string;
63
+ tagline?: string;
64
+ avatar_url?: string | null;
65
+ visibility?: "private" | "unlisted" | "public";
66
+ created_at?: string;
67
+ };
68
+
69
+ export type RelayEventsPage = {
70
+ events: RelayEventEnvelope[];
71
+ nextCursor: number;
72
+ };
73
+
74
+ /**
75
+ * The 202 from `POST /v1/messages`. The server splits the accepted parts at
76
+ * ingest: each visible non-media part becomes its own message, contiguous
77
+ * media parts stay one media message, and a voice memo always commits alone,
78
+ * so one send commits one or more messages, in display order.
79
+ */
80
+ export type RelaySendResult = {
81
+ messages: RelayMessage[];
82
+ };
@@ -0,0 +1,43 @@
1
+ import { isIP } from "node:net";
2
+
3
+ export const DEFAULT_RELAY_BASE_URL = "https://api.relayapp.im";
4
+
5
+ function isLoopbackHostname(hostname: string): boolean {
6
+ const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
7
+ const ipVersion = isIP(normalized);
8
+ if (ipVersion === 4) return normalized.split(".")[0] === "127";
9
+ if (ipVersion === 6) return normalized === "::1";
10
+ return normalized === "localhost" || normalized.endsWith(".localhost");
11
+ }
12
+
13
+ /**
14
+ * Validate and canonicalize the API origin before a bearer token can be sent.
15
+ * Remote origins must use HTTPS. Plain HTTP is allowed only on loopback.
16
+ */
17
+ export function normalizeRelayBaseUrl(raw?: string): string {
18
+ const candidate = raw?.trim() || DEFAULT_RELAY_BASE_URL;
19
+ let url: URL;
20
+ try {
21
+ url = new URL(candidate);
22
+ } catch {
23
+ throw new Error(`relay: invalid baseUrl ${JSON.stringify(candidate)}`);
24
+ }
25
+ if (url.username || url.password) {
26
+ throw new Error("relay: baseUrl must not contain credentials");
27
+ }
28
+ if (url.search || url.hash) {
29
+ throw new Error("relay: baseUrl must not contain a query or fragment");
30
+ }
31
+ if (!/^\/+$/u.test(url.pathname)) {
32
+ throw new Error("relay: baseUrl must be an origin without a path");
33
+ }
34
+ if (
35
+ url.protocol !== "https:" &&
36
+ !(url.protocol === "http:" && isLoopbackHostname(url.hostname))
37
+ ) {
38
+ throw new Error(
39
+ "relay: baseUrl must use HTTPS (HTTP is allowed only for loopback development)",
40
+ );
41
+ }
42
+ return url.origin;
43
+ }