@relaymessenger/openclaw-plugin 0.3.0 → 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.
- package/dist/src/channel.js +110 -73
- package/dist/src/client.js +13 -219
- package/dist/src/inbound.js +5 -0
- package/dist/src/invocations.js +47 -0
- package/dist/src/outbound.js +2 -0
- package/dist/src/poll-loop.js +12 -0
- package/dist/src/responding.js +36 -0
- package/dist/src/state-files.js +114 -38
- package/dist/src/vendor/relay-sdk/client.js +163 -0
- package/dist/src/vendor/relay-sdk/errors.js +45 -0
- package/dist/src/vendor/relay-sdk/types.js +2 -0
- package/dist/src/vendor/relay-sdk/url.js +39 -0
- package/package.json +1 -1
- package/src/channel.ts +111 -74
- package/src/client.ts +38 -300
- package/src/inbound.ts +11 -0
- package/src/invocations.ts +58 -0
- package/src/outbound.ts +11 -4
- package/src/poll-loop.ts +12 -0
- package/src/responding.ts +52 -0
- package/src/state-files.ts +130 -44
- package/src/types.ts +15 -1
- package/src/vendor/relay-sdk/README.md +28 -0
- package/src/vendor/relay-sdk/client.ts +293 -0
- package/src/vendor/relay-sdk/errors.ts +61 -0
- package/src/vendor/relay-sdk/types.ts +82 -0
- package/src/vendor/relay-sdk/url.ts +43 -0
package/src/channel.ts
CHANGED
|
@@ -42,6 +42,7 @@ import { createRelayCursorStore, openRelayCursorStateStore } from "./cursor-stor
|
|
|
42
42
|
import { createRelayInboundDedupeGuard, createRelayInboundDeduper } from "./inbound-dedupe.js";
|
|
43
43
|
import { buildRelayInboundFacts } from "./inbound.js";
|
|
44
44
|
import type { RelayInboundFacts } from "./inbound.js";
|
|
45
|
+
import { relayInvocationFor, rememberRelayInvocation } from "./invocations.js";
|
|
45
46
|
import { createRelayAccountLifecycleRegistry } from "./lifecycle.js";
|
|
46
47
|
import {
|
|
47
48
|
deriveRelayIdempotencyKey,
|
|
@@ -50,6 +51,7 @@ import {
|
|
|
50
51
|
sendRelayText,
|
|
51
52
|
} from "./outbound.js";
|
|
52
53
|
import { runRelayPollLoop } from "./poll-loop.js";
|
|
54
|
+
import { markRespondingBeforeAttempt } from "./responding.js";
|
|
53
55
|
import { getRelayRuntime } from "./runtime.js";
|
|
54
56
|
import { relaySenderIsAllowed, resolveRelayAllowedSenderIds } from "./security.js";
|
|
55
57
|
import type { RelayCoreConfig, ResolvedRelayAccount } from "./types.js";
|
|
@@ -137,11 +139,16 @@ const relayMessageAdapter = defineChannelMessageAdapter({
|
|
|
137
139
|
if (text === null) {
|
|
138
140
|
return null;
|
|
139
141
|
}
|
|
142
|
+
const invocationId = relayInvocationFor({
|
|
143
|
+
accountId: account.accountId,
|
|
144
|
+
conversationId: ctx.to,
|
|
145
|
+
});
|
|
140
146
|
const verdict = await reconcileRelayUnknownSend({
|
|
141
147
|
client: relayClientForAccount(account),
|
|
142
148
|
conversationId: ctx.to,
|
|
143
149
|
text,
|
|
144
150
|
replyToId: ctx.effectiveReplyToId ?? ctx.replyToId ?? null,
|
|
151
|
+
...(invocationId ? { invocationId } : {}),
|
|
145
152
|
idempotencyKey: deriveRelayIdempotencyKey({ deliveryQueueId: ctx.queueId }),
|
|
146
153
|
});
|
|
147
154
|
if (verdict.status === "sent") {
|
|
@@ -170,11 +177,19 @@ const relayMessageAdapter = defineChannelMessageAdapter({
|
|
|
170
177
|
if (!account.configured) {
|
|
171
178
|
throw new Error(`relay: account "${account.accountId}" has no Agent Token configured`);
|
|
172
179
|
}
|
|
180
|
+
// A group reply must name the invocation it answers. Core's send context
|
|
181
|
+
// has no field for it, so the turn parks it in the invocation registry
|
|
182
|
+
// under (accountId, conversationId) and it is read back here.
|
|
183
|
+
const invocationId = relayInvocationFor({
|
|
184
|
+
accountId: account.accountId,
|
|
185
|
+
conversationId: ctx.to,
|
|
186
|
+
});
|
|
173
187
|
const result = await sendRelayText({
|
|
174
188
|
client: relayClientForAccount(account),
|
|
175
189
|
conversationId: ctx.to,
|
|
176
190
|
text: ctx.text,
|
|
177
191
|
replyToId: ctx.replyToId ?? null,
|
|
192
|
+
...(invocationId ? { invocationId } : {}),
|
|
178
193
|
// Stable per (queueId, part): internal retries replay the same key,
|
|
179
194
|
// so the server-side idempotent commit makes duplicates impossible by
|
|
180
195
|
// contract. On a core with no part index the text names the part.
|
|
@@ -217,6 +232,7 @@ async function dispatchRelayInbound(params: {
|
|
|
217
232
|
client: RelayClient;
|
|
218
233
|
allowedSenderIds: readonly string[];
|
|
219
234
|
markAttempt: () => Promise<void>;
|
|
235
|
+
warn?: (line: string) => void;
|
|
220
236
|
}): Promise<void> {
|
|
221
237
|
const { account, facts } = params;
|
|
222
238
|
// Public Relay agents are discoverable, so contact membership is not an
|
|
@@ -283,76 +299,97 @@ async function dispatchRelayInbound(params: {
|
|
|
283
299
|
const recordDeliveryError = (error: unknown) => {
|
|
284
300
|
deliveryError ??= error;
|
|
285
301
|
};
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
})
|
|
340
|
-
|
|
341
|
-
|
|
302
|
+
// Park the group invocation for the life of the turn. Core's durable send
|
|
303
|
+
// adapter is a separate entry point with no inbound context, so this is how
|
|
304
|
+
// the reply learns which invocation it answers.
|
|
305
|
+
const releaseInvocation = facts.invocationId
|
|
306
|
+
? rememberRelayInvocation({
|
|
307
|
+
accountId: account.accountId,
|
|
308
|
+
conversationId: facts.conversationId,
|
|
309
|
+
invocationId: facts.invocationId,
|
|
310
|
+
})
|
|
311
|
+
: () => {};
|
|
312
|
+
try {
|
|
313
|
+
// Admission, runtime resolution, route/session lookup, envelope building,
|
|
314
|
+
// and context finalization above are replay-safe. The durable attempt starts
|
|
315
|
+
// immediately before OpenClaw can invoke the agent or its tools.
|
|
316
|
+
await markRespondingBeforeAttempt({
|
|
317
|
+
client: params.client,
|
|
318
|
+
facts,
|
|
319
|
+
label: "OpenClaw",
|
|
320
|
+
markAttempt: params.markAttempt,
|
|
321
|
+
...(params.warn ? { onReceiptFailure: params.warn } : {}),
|
|
322
|
+
});
|
|
323
|
+
await runtime.channel.inbound.dispatchReply({
|
|
324
|
+
cfg: params.cfg,
|
|
325
|
+
channel: RELAY_CHANNEL_ID,
|
|
326
|
+
accountId: account.accountId,
|
|
327
|
+
agentId: route.agentId,
|
|
328
|
+
routeSessionKey: route.sessionKey,
|
|
329
|
+
storePath,
|
|
330
|
+
ctxPayload,
|
|
331
|
+
recordInboundSession: runtime.channel.session.recordInboundSession,
|
|
332
|
+
dispatchReplyWithBufferedBlockDispatcher:
|
|
333
|
+
runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
|
|
334
|
+
delivery: {
|
|
335
|
+
// Final replies go through the durable message adapter: core renders
|
|
336
|
+
// and chunks them (chunker + textChunkLimit) and tracks the send as a
|
|
337
|
+
// durable queue intent. Requiring reconcileUnknownSend forces
|
|
338
|
+
// `durability: "required"`, so single-payload finals carry a stable
|
|
339
|
+
// deliveryQueueId into send.text (stable idempotency key + exact
|
|
340
|
+
// replay), and multi-chunk finals get core's queue-level crash
|
|
341
|
+
// recovery. Replies land as plain messages, not quotes
|
|
342
|
+
// (`replyToId: null`).
|
|
343
|
+
durable: {
|
|
344
|
+
to: facts.conversationId,
|
|
345
|
+
replyToId: null,
|
|
346
|
+
requiredCapabilities: { reconcileUnknownSend: true },
|
|
347
|
+
},
|
|
348
|
+
// Fallback for payloads the durable path does not carry (non-final
|
|
349
|
+
// visible blocks). The event id + block/chunk ordinals identify each
|
|
350
|
+
// logical send: retries reuse it while identical intentional blocks and
|
|
351
|
+
// chunks remain distinct.
|
|
352
|
+
deliver: async (payload) => {
|
|
353
|
+
const text =
|
|
354
|
+
payload && typeof payload === "object" && "text" in payload
|
|
355
|
+
? ((payload as { text?: string }).text ?? "")
|
|
356
|
+
: "";
|
|
357
|
+
if (!text.trim()) {
|
|
358
|
+
return;
|
|
342
359
|
}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
360
|
+
const logicalBlockId = `${facts.eventId}:block:${fallbackDeliveryIndex}`;
|
|
361
|
+
fallbackDeliveryIndex += 1;
|
|
362
|
+
try {
|
|
363
|
+
let chunkIndex = 0;
|
|
364
|
+
for (const chunk of chunkText(text, RELAY_TEXT_CHUNK_LIMIT)) {
|
|
365
|
+
await sendRelayText({
|
|
366
|
+
client: params.client,
|
|
367
|
+
conversationId: facts.conversationId,
|
|
368
|
+
text: chunk,
|
|
369
|
+
...(facts.invocationId ? { invocationId: facts.invocationId } : {}),
|
|
370
|
+
idempotencyKey: deriveRelayIdempotencyKey({
|
|
371
|
+
deliveryQueueId: logicalBlockId,
|
|
372
|
+
deliveryPartIndex: chunkIndex,
|
|
373
|
+
}),
|
|
374
|
+
});
|
|
375
|
+
chunkIndex += 1;
|
|
376
|
+
}
|
|
377
|
+
} catch (error) {
|
|
378
|
+
recordDeliveryError(error);
|
|
379
|
+
throw error;
|
|
380
|
+
}
|
|
381
|
+
},
|
|
382
|
+
onError: recordDeliveryError,
|
|
347
383
|
},
|
|
348
|
-
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
384
|
+
replyPipeline: {},
|
|
385
|
+
});
|
|
386
|
+
if (deliveryError) {
|
|
387
|
+
throw deliveryError instanceof Error
|
|
388
|
+
? deliveryError
|
|
389
|
+
: new Error(`relay reply delivery failed: ${String(deliveryError)}`);
|
|
390
|
+
}
|
|
391
|
+
} finally {
|
|
392
|
+
releaseInvocation();
|
|
356
393
|
}
|
|
357
394
|
}
|
|
358
395
|
|
|
@@ -491,19 +528,19 @@ async function startRelayAccount(ctx: ChannelGatewayContext<ResolvedRelayAccount
|
|
|
491
528
|
client,
|
|
492
529
|
allowedSenderIds,
|
|
493
530
|
markAttempt,
|
|
531
|
+
warn,
|
|
494
532
|
});
|
|
495
|
-
// Read watermark after the turn is handled: read implies delivered;
|
|
496
|
-
// best effort — a failed receipt must not replay the event.
|
|
497
|
-
await client
|
|
498
|
-
.markRead({ conversationId: facts.conversationId, messageId: facts.messageId })
|
|
499
|
-
.catch((error) => log(`[relay] markRead failed: ${String(error)}`));
|
|
500
533
|
},
|
|
501
534
|
});
|
|
502
535
|
} catch (error) {
|
|
503
536
|
if (abortSignal.aborted || isAbortError(error)) {
|
|
504
537
|
return;
|
|
505
538
|
}
|
|
506
|
-
|
|
539
|
+
// Named as `kind === "auth"`, not `error.terminal`. The SDK client counts
|
|
540
|
+
// every non-retryable kind as terminal, which would swallow the 409 cases
|
|
541
|
+
// below — including `terminated_by_other_consumer`, whose whole point is
|
|
542
|
+
// to fall through to the supervisor's restart arbitration.
|
|
543
|
+
if (error instanceof RelayApiError && error.kind === "auth") {
|
|
507
544
|
markTerminalDisconnect(error);
|
|
508
545
|
} else if (isRelayWebhookConflict(error)) {
|
|
509
546
|
// Webhook XOR: long polling stays 409 until the operator
|
package/src/client.ts
CHANGED
|
@@ -1,313 +1,51 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
} from "./types.js";
|
|
13
|
-
|
|
14
|
-
export
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
return normalized === "::1";
|
|
24
|
-
}
|
|
25
|
-
return (
|
|
26
|
-
normalized === "localhost" ||
|
|
27
|
-
normalized.endsWith(".localhost")
|
|
28
|
-
);
|
|
29
|
-
}
|
|
30
|
-
|
|
1
|
+
// The Relay API client the plugin uses. There is no implementation here: this
|
|
2
|
+
// is `@relaymessenger/sdk`'s client, vendored under `./vendor/relay-sdk`
|
|
3
|
+
// (see that directory's README for why, and for the one-file swap when the
|
|
4
|
+
// package ships).
|
|
5
|
+
//
|
|
6
|
+
// The plugin used to hand-roll its own client beside the SDK's. They drifted,
|
|
7
|
+
// and the drift was a defect: the hand-rolled one had no `invocationId` on
|
|
8
|
+
// `sendMessage`, `setTyping`, or `setResponding`, so an agent's first group
|
|
9
|
+
// mention wedged its entire event stream (REL-167).
|
|
10
|
+
import { createRelayClient as createVendoredRelayClient } from "./vendor/relay-sdk/client.js";
|
|
11
|
+
import type { RelayClient as VendoredRelayClient } from "./vendor/relay-sdk/client.js";
|
|
12
|
+
import type { RelayEventsPage } from "./types.js";
|
|
13
|
+
|
|
14
|
+
export { DEFAULT_RELAY_BASE_URL, normalizeRelayBaseUrl } from "./vendor/relay-sdk/url.js";
|
|
15
|
+
export {
|
|
16
|
+
classifyRelayHttpStatus,
|
|
17
|
+
isAbortError,
|
|
18
|
+
isRelayWebhookConflict,
|
|
19
|
+
RelayApiError,
|
|
20
|
+
} from "./vendor/relay-sdk/errors.js";
|
|
21
|
+
export type { RelayApiErrorKind } from "./vendor/relay-sdk/errors.js";
|
|
22
|
+
export type { RelayClientOptions } from "./vendor/relay-sdk/client.js";
|
|
31
23
|
/**
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
24
|
+
* A message as the server echoes it back from a send. Distinct from
|
|
25
|
+
* `types.ts`'s `RelayMessage`, which is the inbound shape the plugin renders
|
|
26
|
+
* with its typed parts: a send result is only ever read for its ids.
|
|
35
27
|
*/
|
|
36
|
-
export
|
|
37
|
-
const candidate = raw?.trim() || DEFAULT_RELAY_BASE_URL;
|
|
38
|
-
let url: URL;
|
|
39
|
-
try {
|
|
40
|
-
url = new URL(candidate);
|
|
41
|
-
} catch {
|
|
42
|
-
throw new Error(`relay: invalid baseUrl ${JSON.stringify(candidate)}`);
|
|
43
|
-
}
|
|
44
|
-
if (url.username || url.password) {
|
|
45
|
-
throw new Error("relay: baseUrl must not contain credentials");
|
|
46
|
-
}
|
|
47
|
-
if (url.search || url.hash) {
|
|
48
|
-
throw new Error("relay: baseUrl must not contain a query or fragment");
|
|
49
|
-
}
|
|
50
|
-
if (!/^\/+$/u.test(url.pathname)) {
|
|
51
|
-
throw new Error("relay: baseUrl must be an origin without a path");
|
|
52
|
-
}
|
|
53
|
-
if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopbackHostname(url.hostname))) {
|
|
54
|
-
throw new Error("relay: baseUrl must use HTTPS (HTTP is allowed only for loopback development)");
|
|
55
|
-
}
|
|
56
|
-
return url.origin;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export type RelayApiErrorKind = "auth" | "conflict" | "retryable" | "rejected";
|
|
60
|
-
|
|
61
|
-
/** Classified Relay API failure. `terminal` means operator action (bad token). */
|
|
62
|
-
export class RelayApiError extends Error {
|
|
63
|
-
readonly status: number | undefined;
|
|
64
|
-
readonly kind: RelayApiErrorKind;
|
|
65
|
-
/** Server error code from the response body (`error.code`), when present. */
|
|
66
|
-
readonly code: string | undefined;
|
|
67
|
-
|
|
68
|
-
constructor(
|
|
69
|
-
message: string,
|
|
70
|
-
params: { status?: number; kind: RelayApiErrorKind; code?: string },
|
|
71
|
-
) {
|
|
72
|
-
super(message);
|
|
73
|
-
this.name = "RelayApiError";
|
|
74
|
-
this.status = params.status;
|
|
75
|
-
this.kind = params.kind;
|
|
76
|
-
this.code = params.code;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
get terminal(): boolean {
|
|
80
|
-
return this.kind === "auth";
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
get retryable(): boolean {
|
|
84
|
-
return this.kind === "retryable";
|
|
85
|
-
}
|
|
86
|
-
}
|
|
28
|
+
export type { RelayMessage as RelaySentMessage } from "./vendor/relay-sdk/types.js";
|
|
87
29
|
|
|
88
30
|
/**
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
* `
|
|
31
|
+
* The vendored client, restated over the plugin's own event types.
|
|
32
|
+
*
|
|
33
|
+
* The SDK types an event's `data` as an open `Record<string, unknown>`; the
|
|
34
|
+
* plugin types the parts of it that it renders (`message`, its typed parts,
|
|
35
|
+
* `invocation_id`). Both describe the same JSON at different resolutions, and
|
|
36
|
+
* the narrowing happens for real in `buildRelayInboundFacts`, which returns
|
|
37
|
+
* null for anything that does not match. This type only names that boundary —
|
|
38
|
+
* the object is the SDK's, unmodified, at runtime.
|
|
92
39
|
*/
|
|
93
|
-
export
|
|
94
|
-
return (
|
|
95
|
-
error instanceof RelayApiError &&
|
|
96
|
-
error.status === 409 &&
|
|
97
|
-
error.code !== "terminated_by_other_consumer"
|
|
98
|
-
);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
export function classifyRelayHttpStatus(status: number): RelayApiErrorKind {
|
|
102
|
-
if (status === 401) {
|
|
103
|
-
return "auth";
|
|
104
|
-
}
|
|
105
|
-
if (status === 409) {
|
|
106
|
-
return "conflict";
|
|
107
|
-
}
|
|
108
|
-
if (status === 408 || status === 429 || status >= 500) {
|
|
109
|
-
return "retryable";
|
|
110
|
-
}
|
|
111
|
-
return "rejected";
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
export function isAbortError(error: unknown): boolean {
|
|
115
|
-
return error instanceof Error && error.name === "AbortError";
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
|
|
119
|
-
|
|
120
|
-
export type RelayClientOptions = {
|
|
121
|
-
baseUrl?: string;
|
|
122
|
-
token: string;
|
|
123
|
-
fetchImpl?: FetchLike;
|
|
124
|
-
/** Bounds non-poll API operations; long polls use hold time plus slack. */
|
|
125
|
-
requestTimeoutMs?: number;
|
|
126
|
-
};
|
|
127
|
-
|
|
128
|
-
export type RelayClient = {
|
|
129
|
-
getMe: (params?: { signal?: AbortSignal }) => Promise<RelayAgentProfile>;
|
|
40
|
+
export type RelayClient = Omit<VendoredRelayClient, "pollEvents"> & {
|
|
130
41
|
pollEvents: (params: {
|
|
131
42
|
cursor: number;
|
|
132
43
|
timeoutSeconds?: number;
|
|
133
44
|
limit?: number;
|
|
134
45
|
signal?: AbortSignal;
|
|
135
46
|
}) => Promise<RelayEventsPage>;
|
|
136
|
-
sendMessage: (params: {
|
|
137
|
-
conversationId: string;
|
|
138
|
-
parts: Array<Pick<RelayPart, never> & Record<string, unknown>>;
|
|
139
|
-
replyTo?: RelayReplyRef;
|
|
140
|
-
idempotencyKey: string;
|
|
141
|
-
signal?: AbortSignal;
|
|
142
|
-
}) => Promise<RelaySendResult>;
|
|
143
|
-
setTyping: (params: {
|
|
144
|
-
conversationId: string;
|
|
145
|
-
started: boolean;
|
|
146
|
-
label?: string;
|
|
147
|
-
signal?: AbortSignal;
|
|
148
|
-
}) => Promise<void>;
|
|
149
|
-
markRead: (params: {
|
|
150
|
-
conversationId: string;
|
|
151
|
-
messageId: string;
|
|
152
|
-
signal?: AbortSignal;
|
|
153
|
-
}) => Promise<void>;
|
|
154
47
|
};
|
|
155
48
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
)
|
|
159
|
-
try {
|
|
160
|
-
const body = (await response.json()) as {
|
|
161
|
-
error?: { code?: string; message?: string };
|
|
162
|
-
message?: string;
|
|
163
|
-
};
|
|
164
|
-
return {
|
|
165
|
-
...(body?.error?.code ? { code: body.error.code } : {}),
|
|
166
|
-
message: body?.error?.message ?? body?.message ?? "",
|
|
167
|
-
};
|
|
168
|
-
} catch {
|
|
169
|
-
return { message: "" };
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
export function createRelayClient(options: RelayClientOptions): RelayClient {
|
|
174
|
-
const baseUrl = normalizeRelayBaseUrl(options.baseUrl);
|
|
175
|
-
const fetchImpl: FetchLike = options.fetchImpl ?? ((input, init) => fetch(input, init));
|
|
176
|
-
const requestTimeoutMs = options.requestTimeoutMs ?? 15_000;
|
|
177
|
-
|
|
178
|
-
const request = async (params: {
|
|
179
|
-
method: string;
|
|
180
|
-
path: string;
|
|
181
|
-
query?: Record<string, string | number | boolean | undefined>;
|
|
182
|
-
body?: unknown;
|
|
183
|
-
headers?: Record<string, string>;
|
|
184
|
-
signal?: AbortSignal;
|
|
185
|
-
timeoutMs?: number;
|
|
186
|
-
}): Promise<Response> => {
|
|
187
|
-
const url = new URL(`${baseUrl}${params.path}`);
|
|
188
|
-
for (const [key, value] of Object.entries(params.query ?? {})) {
|
|
189
|
-
if (value !== undefined) {
|
|
190
|
-
url.searchParams.set(key, String(value));
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
let response: Response;
|
|
194
|
-
const timeoutSignal = AbortSignal.timeout(params.timeoutMs ?? requestTimeoutMs);
|
|
195
|
-
const signal = params.signal
|
|
196
|
-
? AbortSignal.any([params.signal, timeoutSignal])
|
|
197
|
-
: timeoutSignal;
|
|
198
|
-
try {
|
|
199
|
-
response = await fetchImpl(url.toString(), {
|
|
200
|
-
method: params.method,
|
|
201
|
-
headers: {
|
|
202
|
-
authorization: `Bearer ${options.token}`,
|
|
203
|
-
...(params.body === undefined ? {} : { "content-type": "application/json" }),
|
|
204
|
-
...params.headers,
|
|
205
|
-
},
|
|
206
|
-
...(params.body === undefined ? {} : { body: JSON.stringify(params.body) }),
|
|
207
|
-
signal,
|
|
208
|
-
});
|
|
209
|
-
} catch (error) {
|
|
210
|
-
if (timeoutSignal.aborted && !params.signal?.aborted) {
|
|
211
|
-
throw new RelayApiError(
|
|
212
|
-
`relay: ${params.method} ${params.path} timed out after ${params.timeoutMs ?? requestTimeoutMs}ms`,
|
|
213
|
-
{ kind: "retryable" },
|
|
214
|
-
);
|
|
215
|
-
}
|
|
216
|
-
if (isAbortError(error)) {
|
|
217
|
-
throw error;
|
|
218
|
-
}
|
|
219
|
-
// Network-level failure (DNS, reset, offline): always retryable.
|
|
220
|
-
throw new RelayApiError(`relay: network error: ${String(error)}`, { kind: "retryable" });
|
|
221
|
-
}
|
|
222
|
-
if (!response.ok) {
|
|
223
|
-
const detail = await readErrorDetail(response);
|
|
224
|
-
throw new RelayApiError(
|
|
225
|
-
`relay: ${params.method} ${params.path} failed with ${response.status}${detail.message ? `: ${detail.message}` : ""}`,
|
|
226
|
-
{
|
|
227
|
-
status: response.status,
|
|
228
|
-
kind: classifyRelayHttpStatus(response.status),
|
|
229
|
-
...(detail.code ? { code: detail.code } : {}),
|
|
230
|
-
},
|
|
231
|
-
);
|
|
232
|
-
}
|
|
233
|
-
return response;
|
|
234
|
-
};
|
|
235
|
-
|
|
236
|
-
return {
|
|
237
|
-
getMe: async (params) => {
|
|
238
|
-
const response = await request({
|
|
239
|
-
method: "GET",
|
|
240
|
-
path: "/v1/agents/me",
|
|
241
|
-
signal: params?.signal,
|
|
242
|
-
});
|
|
243
|
-
const body = (await response.json()) as { agent: RelayAgentProfile };
|
|
244
|
-
return body.agent;
|
|
245
|
-
},
|
|
246
|
-
|
|
247
|
-
pollEvents: async (params) => {
|
|
248
|
-
const timeoutSeconds = Math.min(Math.max(params.timeoutSeconds ?? 30, 1), 30);
|
|
249
|
-
// Guard against a wedged connection: the server holds <= timeout seconds,
|
|
250
|
-
// so anything past timeout + slack is a dead socket, not a slow poll.
|
|
251
|
-
const response = await request({
|
|
252
|
-
method: "GET",
|
|
253
|
-
path: "/v1/events",
|
|
254
|
-
query: {
|
|
255
|
-
cursor: params.cursor,
|
|
256
|
-
timeout: timeoutSeconds,
|
|
257
|
-
...(params.limit === undefined ? {} : { limit: params.limit }),
|
|
258
|
-
},
|
|
259
|
-
signal: params.signal,
|
|
260
|
-
timeoutMs: (timeoutSeconds + 15) * 1_000,
|
|
261
|
-
});
|
|
262
|
-
const body = (await response.json()) as {
|
|
263
|
-
events?: RelayEventsPage["events"];
|
|
264
|
-
next_cursor?: number;
|
|
265
|
-
};
|
|
266
|
-
const events = Array.isArray(body.events) ? body.events : [];
|
|
267
|
-
const nextCursor =
|
|
268
|
-
typeof body.next_cursor === "number" && Number.isSafeInteger(body.next_cursor)
|
|
269
|
-
? body.next_cursor
|
|
270
|
-
: params.cursor;
|
|
271
|
-
return { events, nextCursor };
|
|
272
|
-
},
|
|
273
|
-
|
|
274
|
-
sendMessage: async (params) => {
|
|
275
|
-
const response = await request({
|
|
276
|
-
method: "POST",
|
|
277
|
-
path: "/v1/messages",
|
|
278
|
-
headers: { "idempotency-key": params.idempotencyKey },
|
|
279
|
-
body: {
|
|
280
|
-
conversation_id: params.conversationId,
|
|
281
|
-
parts: params.parts,
|
|
282
|
-
...(params.replyTo ? { reply_to: params.replyTo } : {}),
|
|
283
|
-
},
|
|
284
|
-
signal: params.signal,
|
|
285
|
-
});
|
|
286
|
-
const body = (await response.json()) as {
|
|
287
|
-
messages: RelaySendResult["messages"];
|
|
288
|
-
};
|
|
289
|
-
return { messages: body.messages };
|
|
290
|
-
},
|
|
291
|
-
|
|
292
|
-
setTyping: async (params) => {
|
|
293
|
-
await request({
|
|
294
|
-
method: "POST",
|
|
295
|
-
path: `/v1/conversations/${encodeURIComponent(params.conversationId)}/typing`,
|
|
296
|
-
body: {
|
|
297
|
-
started: params.started,
|
|
298
|
-
...(params.label ? { label: params.label } : {}),
|
|
299
|
-
},
|
|
300
|
-
signal: params.signal,
|
|
301
|
-
});
|
|
302
|
-
},
|
|
303
|
-
|
|
304
|
-
markRead: async (params) => {
|
|
305
|
-
await request({
|
|
306
|
-
method: "POST",
|
|
307
|
-
path: `/v1/conversations/${encodeURIComponent(params.conversationId)}/read`,
|
|
308
|
-
body: { message_id: params.messageId },
|
|
309
|
-
signal: params.signal,
|
|
310
|
-
});
|
|
311
|
-
},
|
|
312
|
-
};
|
|
313
|
-
}
|
|
49
|
+
export const createRelayClient = createVendoredRelayClient as (
|
|
50
|
+
options: Parameters<typeof createVendoredRelayClient>[0],
|
|
51
|
+
) => RelayClient;
|
package/src/inbound.ts
CHANGED
|
@@ -86,6 +86,12 @@ export type RelayInboundFacts = {
|
|
|
86
86
|
replyToId?: string;
|
|
87
87
|
text: string;
|
|
88
88
|
timestamp?: number;
|
|
89
|
+
/**
|
|
90
|
+
* The group invocation this message belongs to, when it is group work.
|
|
91
|
+
* Every subsequent server call about this message must carry it back, and
|
|
92
|
+
* because only a group mints one, its presence is also the group signal.
|
|
93
|
+
*/
|
|
94
|
+
invocationId?: string;
|
|
89
95
|
};
|
|
90
96
|
|
|
91
97
|
/**
|
|
@@ -115,6 +121,10 @@ export function buildRelayInboundFacts(
|
|
|
115
121
|
return null;
|
|
116
122
|
}
|
|
117
123
|
const createdAtMs = Date.parse(message.created_at);
|
|
124
|
+
const invocationId = typeof event.data.invocation_id === "string"
|
|
125
|
+
&& event.data.invocation_id.trim()
|
|
126
|
+
? event.data.invocation_id
|
|
127
|
+
: undefined;
|
|
118
128
|
return {
|
|
119
129
|
eventId: event.event_id,
|
|
120
130
|
messageId: message.id,
|
|
@@ -124,5 +134,6 @@ export function buildRelayInboundFacts(
|
|
|
124
134
|
...(message.reply_to?.message_id ? { replyToId: message.reply_to.message_id } : {}),
|
|
125
135
|
text,
|
|
126
136
|
...(Number.isFinite(createdAtMs) ? { timestamp: createdAtMs } : {}),
|
|
137
|
+
...(invocationId ? { invocationId } : {}),
|
|
127
138
|
};
|
|
128
139
|
}
|