@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.
- package/dist/src/channel.js +109 -74
- package/dist/src/client.js +13 -229
- 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 +31 -8
- 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 +110 -75
- package/src/client.ts +38 -317
- 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 +41 -10
- 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,
|
|
@@ -138,11 +139,16 @@ const relayMessageAdapter = defineChannelMessageAdapter({
|
|
|
138
139
|
if (text === null) {
|
|
139
140
|
return null;
|
|
140
141
|
}
|
|
142
|
+
const invocationId = relayInvocationFor({
|
|
143
|
+
accountId: account.accountId,
|
|
144
|
+
conversationId: ctx.to,
|
|
145
|
+
});
|
|
141
146
|
const verdict = await reconcileRelayUnknownSend({
|
|
142
147
|
client: relayClientForAccount(account),
|
|
143
148
|
conversationId: ctx.to,
|
|
144
149
|
text,
|
|
145
150
|
replyToId: ctx.effectiveReplyToId ?? ctx.replyToId ?? null,
|
|
151
|
+
...(invocationId ? { invocationId } : {}),
|
|
146
152
|
idempotencyKey: deriveRelayIdempotencyKey({ deliveryQueueId: ctx.queueId }),
|
|
147
153
|
});
|
|
148
154
|
if (verdict.status === "sent") {
|
|
@@ -171,11 +177,19 @@ const relayMessageAdapter = defineChannelMessageAdapter({
|
|
|
171
177
|
if (!account.configured) {
|
|
172
178
|
throw new Error(`relay: account "${account.accountId}" has no Agent Token configured`);
|
|
173
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
|
+
});
|
|
174
187
|
const result = await sendRelayText({
|
|
175
188
|
client: relayClientForAccount(account),
|
|
176
189
|
conversationId: ctx.to,
|
|
177
190
|
text: ctx.text,
|
|
178
191
|
replyToId: ctx.replyToId ?? null,
|
|
192
|
+
...(invocationId ? { invocationId } : {}),
|
|
179
193
|
// Stable per (queueId, part): internal retries replay the same key,
|
|
180
194
|
// so the server-side idempotent commit makes duplicates impossible by
|
|
181
195
|
// contract. On a core with no part index the text names the part.
|
|
@@ -218,6 +232,7 @@ async function dispatchRelayInbound(params: {
|
|
|
218
232
|
client: RelayClient;
|
|
219
233
|
allowedSenderIds: readonly string[];
|
|
220
234
|
markAttempt: () => Promise<void>;
|
|
235
|
+
warn?: (line: string) => void;
|
|
221
236
|
}): Promise<void> {
|
|
222
237
|
const { account, facts } = params;
|
|
223
238
|
// Public Relay agents are discoverable, so contact membership is not an
|
|
@@ -284,82 +299,97 @@ async function dispatchRelayInbound(params: {
|
|
|
284
299
|
const recordDeliveryError = (error: unknown) => {
|
|
285
300
|
deliveryError ??= error;
|
|
286
301
|
};
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
deliveryQueueId: logicalBlockId,
|
|
345
|
-
deliveryPartIndex: chunkIndex,
|
|
346
|
-
}),
|
|
347
|
-
});
|
|
348
|
-
chunkIndex += 1;
|
|
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;
|
|
349
359
|
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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,
|
|
354
383
|
},
|
|
355
|
-
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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();
|
|
363
393
|
}
|
|
364
394
|
}
|
|
365
395
|
|
|
@@ -498,6 +528,7 @@ async function startRelayAccount(ctx: ChannelGatewayContext<ResolvedRelayAccount
|
|
|
498
528
|
client,
|
|
499
529
|
allowedSenderIds,
|
|
500
530
|
markAttempt,
|
|
531
|
+
warn,
|
|
501
532
|
});
|
|
502
533
|
},
|
|
503
534
|
});
|
|
@@ -505,7 +536,11 @@ async function startRelayAccount(ctx: ChannelGatewayContext<ResolvedRelayAccount
|
|
|
505
536
|
if (abortSignal.aborted || isAbortError(error)) {
|
|
506
537
|
return;
|
|
507
538
|
}
|
|
508
|
-
|
|
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") {
|
|
509
544
|
markTerminalDisconnect(error);
|
|
510
545
|
} else if (isRelayWebhookConflict(error)) {
|
|
511
546
|
// Webhook XOR: long polling stays 409 until the operator
|
package/src/client.ts
CHANGED
|
@@ -1,330 +1,51 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
} from "./
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
24
|
-
return (
|
|
25
|
-
normalized === "localhost" ||
|
|
26
|
-
normalized.endsWith(".localhost")
|
|
27
|
-
);
|
|
28
|
-
}
|
|
29
|
-
|
|
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";
|
|
30
23
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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.
|
|
34
27
|
*/
|
|
35
|
-
export
|
|
36
|
-
const candidate = raw?.trim() || DEFAULT_RELAY_BASE_URL;
|
|
37
|
-
let url: URL;
|
|
38
|
-
try {
|
|
39
|
-
url = new URL(candidate);
|
|
40
|
-
} catch {
|
|
41
|
-
throw new Error(`relay: invalid baseUrl ${JSON.stringify(candidate)}`);
|
|
42
|
-
}
|
|
43
|
-
if (url.username || url.password) {
|
|
44
|
-
throw new Error("relay: baseUrl must not contain credentials");
|
|
45
|
-
}
|
|
46
|
-
if (url.search || url.hash) {
|
|
47
|
-
throw new Error("relay: baseUrl must not contain a query or fragment");
|
|
48
|
-
}
|
|
49
|
-
if (!/^\/+$/u.test(url.pathname)) {
|
|
50
|
-
throw new Error("relay: baseUrl must be an origin without a path");
|
|
51
|
-
}
|
|
52
|
-
if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopbackHostname(url.hostname))) {
|
|
53
|
-
throw new Error("relay: baseUrl must use HTTPS (HTTP is allowed only for loopback development)");
|
|
54
|
-
}
|
|
55
|
-
return url.origin;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export type RelayApiErrorKind = "auth" | "conflict" | "retryable" | "rejected";
|
|
59
|
-
|
|
60
|
-
/** Classified Relay API failure. `terminal` means operator action (bad token). */
|
|
61
|
-
export class RelayApiError extends Error {
|
|
62
|
-
readonly status: number | undefined;
|
|
63
|
-
readonly kind: RelayApiErrorKind;
|
|
64
|
-
/** Server error code from the response body (`error.code`), when present. */
|
|
65
|
-
readonly code: string | undefined;
|
|
66
|
-
|
|
67
|
-
constructor(
|
|
68
|
-
message: string,
|
|
69
|
-
params: { status?: number; kind: RelayApiErrorKind; code?: string },
|
|
70
|
-
) {
|
|
71
|
-
super(message);
|
|
72
|
-
this.name = "RelayApiError";
|
|
73
|
-
this.status = params.status;
|
|
74
|
-
this.kind = params.kind;
|
|
75
|
-
this.code = params.code;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
get terminal(): boolean {
|
|
79
|
-
return this.kind === "auth";
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
get retryable(): boolean {
|
|
83
|
-
return this.kind === "retryable";
|
|
84
|
-
}
|
|
85
|
-
}
|
|
28
|
+
export type { RelayMessage as RelaySentMessage } from "./vendor/relay-sdk/types.js";
|
|
86
29
|
|
|
87
30
|
/**
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
* `
|
|
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.
|
|
91
39
|
*/
|
|
92
|
-
export
|
|
93
|
-
return (
|
|
94
|
-
error instanceof RelayApiError &&
|
|
95
|
-
error.status === 409 &&
|
|
96
|
-
error.code !== "terminated_by_other_consumer"
|
|
97
|
-
);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
export function classifyRelayHttpStatus(status: number): RelayApiErrorKind {
|
|
101
|
-
if (status === 401) {
|
|
102
|
-
return "auth";
|
|
103
|
-
}
|
|
104
|
-
if (status === 409) {
|
|
105
|
-
return "conflict";
|
|
106
|
-
}
|
|
107
|
-
if (status === 408 || status === 429 || status >= 500) {
|
|
108
|
-
return "retryable";
|
|
109
|
-
}
|
|
110
|
-
return "rejected";
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export function isAbortError(error: unknown): boolean {
|
|
114
|
-
return error instanceof Error && error.name === "AbortError";
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
|
|
118
|
-
|
|
119
|
-
export type RelayClientOptions = {
|
|
120
|
-
baseUrl?: string;
|
|
121
|
-
token: string;
|
|
122
|
-
fetchImpl?: FetchLike;
|
|
123
|
-
/** Bounds non-poll API operations; long polls use hold time plus slack. */
|
|
124
|
-
requestTimeoutMs?: number;
|
|
125
|
-
};
|
|
126
|
-
|
|
127
|
-
export type RelayClient = {
|
|
128
|
-
getMe: (params?: { signal?: AbortSignal }) => Promise<RelayAgentProfile>;
|
|
40
|
+
export type RelayClient = Omit<VendoredRelayClient, "pollEvents"> & {
|
|
129
41
|
pollEvents: (params: {
|
|
130
42
|
cursor: number;
|
|
131
43
|
timeoutSeconds?: number;
|
|
132
44
|
limit?: number;
|
|
133
45
|
signal?: AbortSignal;
|
|
134
46
|
}) => Promise<RelayEventsPage>;
|
|
135
|
-
sendMessage: (params: {
|
|
136
|
-
conversationId: string;
|
|
137
|
-
parts: Array<Pick<RelayPart, never> & Record<string, unknown>>;
|
|
138
|
-
replyTo?: RelayReplyRef;
|
|
139
|
-
idempotencyKey: string;
|
|
140
|
-
signal?: AbortSignal;
|
|
141
|
-
}) => Promise<RelaySendResult>;
|
|
142
|
-
setTyping: (params: {
|
|
143
|
-
conversationId: string;
|
|
144
|
-
started: boolean;
|
|
145
|
-
label?: string;
|
|
146
|
-
signal?: AbortSignal;
|
|
147
|
-
}) => Promise<void>;
|
|
148
|
-
setResponding: (params: {
|
|
149
|
-
conversationId: string;
|
|
150
|
-
messageId: string;
|
|
151
|
-
label?: string;
|
|
152
|
-
signal?: AbortSignal;
|
|
153
|
-
}) => Promise<void>;
|
|
154
|
-
markRead: (params: {
|
|
155
|
-
conversationId: string;
|
|
156
|
-
messageId: string;
|
|
157
|
-
signal?: AbortSignal;
|
|
158
|
-
}) => Promise<void>;
|
|
159
47
|
};
|
|
160
48
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
)
|
|
164
|
-
try {
|
|
165
|
-
const body = (await response.json()) as {
|
|
166
|
-
error?: { code?: string; message?: string };
|
|
167
|
-
message?: string;
|
|
168
|
-
};
|
|
169
|
-
return {
|
|
170
|
-
...(body?.error?.code ? { code: body.error.code } : {}),
|
|
171
|
-
message: body?.error?.message ?? body?.message ?? "",
|
|
172
|
-
};
|
|
173
|
-
} catch {
|
|
174
|
-
return { message: "" };
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
export function createRelayClient(options: RelayClientOptions): RelayClient {
|
|
179
|
-
const baseUrl = normalizeRelayBaseUrl(options.baseUrl);
|
|
180
|
-
const fetchImpl: FetchLike = options.fetchImpl ?? ((input, init) => fetch(input, init));
|
|
181
|
-
const requestTimeoutMs = options.requestTimeoutMs ?? 15_000;
|
|
182
|
-
|
|
183
|
-
const request = async (params: {
|
|
184
|
-
method: string;
|
|
185
|
-
path: string;
|
|
186
|
-
query?: Record<string, string | number | boolean | undefined>;
|
|
187
|
-
body?: unknown;
|
|
188
|
-
headers?: Record<string, string>;
|
|
189
|
-
signal?: AbortSignal;
|
|
190
|
-
timeoutMs?: number;
|
|
191
|
-
}): Promise<Response> => {
|
|
192
|
-
const url = new URL(`${baseUrl}${params.path}`);
|
|
193
|
-
for (const [key, value] of Object.entries(params.query ?? {})) {
|
|
194
|
-
if (value !== undefined) {
|
|
195
|
-
url.searchParams.set(key, String(value));
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
let response: Response;
|
|
199
|
-
const timeoutSignal = AbortSignal.timeout(params.timeoutMs ?? requestTimeoutMs);
|
|
200
|
-
const signal = params.signal
|
|
201
|
-
? AbortSignal.any([params.signal, timeoutSignal])
|
|
202
|
-
: timeoutSignal;
|
|
203
|
-
try {
|
|
204
|
-
response = await fetchImpl(url.toString(), {
|
|
205
|
-
method: params.method,
|
|
206
|
-
headers: {
|
|
207
|
-
authorization: `Bearer ${options.token}`,
|
|
208
|
-
...(params.body === undefined ? {} : { "content-type": "application/json" }),
|
|
209
|
-
...params.headers,
|
|
210
|
-
},
|
|
211
|
-
...(params.body === undefined ? {} : { body: JSON.stringify(params.body) }),
|
|
212
|
-
signal,
|
|
213
|
-
});
|
|
214
|
-
} catch (error) {
|
|
215
|
-
if (timeoutSignal.aborted && !params.signal?.aborted) {
|
|
216
|
-
throw new RelayApiError(
|
|
217
|
-
`relay: ${params.method} ${params.path} timed out after ${params.timeoutMs ?? requestTimeoutMs}ms`,
|
|
218
|
-
{ kind: "retryable" },
|
|
219
|
-
);
|
|
220
|
-
}
|
|
221
|
-
if (isAbortError(error)) {
|
|
222
|
-
throw error;
|
|
223
|
-
}
|
|
224
|
-
// Network-level failure (DNS, reset, offline): always retryable.
|
|
225
|
-
throw new RelayApiError(`relay: network error: ${String(error)}`, { kind: "retryable" });
|
|
226
|
-
}
|
|
227
|
-
if (!response.ok) {
|
|
228
|
-
const detail = await readErrorDetail(response);
|
|
229
|
-
throw new RelayApiError(
|
|
230
|
-
`relay: ${params.method} ${params.path} failed with ${response.status}${detail.message ? `: ${detail.message}` : ""}`,
|
|
231
|
-
{
|
|
232
|
-
status: response.status,
|
|
233
|
-
kind: classifyRelayHttpStatus(response.status),
|
|
234
|
-
...(detail.code ? { code: detail.code } : {}),
|
|
235
|
-
},
|
|
236
|
-
);
|
|
237
|
-
}
|
|
238
|
-
return response;
|
|
239
|
-
};
|
|
240
|
-
|
|
241
|
-
return {
|
|
242
|
-
getMe: async (params) => {
|
|
243
|
-
const response = await request({
|
|
244
|
-
method: "GET",
|
|
245
|
-
path: "/v1/agents/me",
|
|
246
|
-
signal: params?.signal,
|
|
247
|
-
});
|
|
248
|
-
const body = (await response.json()) as { agent: RelayAgentProfile };
|
|
249
|
-
return body.agent;
|
|
250
|
-
},
|
|
251
|
-
|
|
252
|
-
pollEvents: async (params) => {
|
|
253
|
-
const timeoutSeconds = Math.min(Math.max(params.timeoutSeconds ?? 30, 1), 30);
|
|
254
|
-
// Guard against a wedged connection: the server holds <= timeout seconds,
|
|
255
|
-
// so anything past timeout + slack is a dead socket, not a slow poll.
|
|
256
|
-
const response = await request({
|
|
257
|
-
method: "GET",
|
|
258
|
-
path: "/v1/events",
|
|
259
|
-
query: {
|
|
260
|
-
cursor: params.cursor,
|
|
261
|
-
timeout: timeoutSeconds,
|
|
262
|
-
...(params.limit === undefined ? {} : { limit: params.limit }),
|
|
263
|
-
},
|
|
264
|
-
signal: params.signal,
|
|
265
|
-
timeoutMs: (timeoutSeconds + 15) * 1_000,
|
|
266
|
-
});
|
|
267
|
-
const body = (await response.json()) as {
|
|
268
|
-
events?: RelayEventsPage["events"];
|
|
269
|
-
next_cursor?: number;
|
|
270
|
-
};
|
|
271
|
-
const events = Array.isArray(body.events) ? body.events : [];
|
|
272
|
-
const nextCursor =
|
|
273
|
-
typeof body.next_cursor === "number" && Number.isSafeInteger(body.next_cursor)
|
|
274
|
-
? body.next_cursor
|
|
275
|
-
: params.cursor;
|
|
276
|
-
return { events, nextCursor };
|
|
277
|
-
},
|
|
278
|
-
|
|
279
|
-
sendMessage: async (params) => {
|
|
280
|
-
const response = await request({
|
|
281
|
-
method: "POST",
|
|
282
|
-
path: "/v1/messages",
|
|
283
|
-
headers: { "idempotency-key": params.idempotencyKey },
|
|
284
|
-
body: {
|
|
285
|
-
conversation_id: params.conversationId,
|
|
286
|
-
parts: params.parts,
|
|
287
|
-
...(params.replyTo ? { reply_to: params.replyTo } : {}),
|
|
288
|
-
},
|
|
289
|
-
signal: params.signal,
|
|
290
|
-
});
|
|
291
|
-
const body = (await response.json()) as {
|
|
292
|
-
messages: RelaySendResult["messages"];
|
|
293
|
-
};
|
|
294
|
-
return { messages: body.messages };
|
|
295
|
-
},
|
|
296
|
-
|
|
297
|
-
setTyping: async (params) => {
|
|
298
|
-
await request({
|
|
299
|
-
method: "POST",
|
|
300
|
-
path: `/v1/conversations/${encodeURIComponent(params.conversationId)}/typing`,
|
|
301
|
-
body: {
|
|
302
|
-
started: params.started,
|
|
303
|
-
...(params.label ? { label: params.label } : {}),
|
|
304
|
-
},
|
|
305
|
-
signal: params.signal,
|
|
306
|
-
});
|
|
307
|
-
},
|
|
308
|
-
|
|
309
|
-
setResponding: async (params) => {
|
|
310
|
-
await request({
|
|
311
|
-
method: "POST",
|
|
312
|
-
path: `/v1/conversations/${encodeURIComponent(params.conversationId)}/responding`,
|
|
313
|
-
body: {
|
|
314
|
-
message_id: params.messageId,
|
|
315
|
-
...(params.label ? { label: params.label } : {}),
|
|
316
|
-
},
|
|
317
|
-
signal: params.signal,
|
|
318
|
-
});
|
|
319
|
-
},
|
|
320
|
-
|
|
321
|
-
markRead: async (params) => {
|
|
322
|
-
await request({
|
|
323
|
-
method: "POST",
|
|
324
|
-
path: `/v1/conversations/${encodeURIComponent(params.conversationId)}/read`,
|
|
325
|
-
body: { message_id: params.messageId },
|
|
326
|
-
signal: params.signal,
|
|
327
|
-
});
|
|
328
|
-
},
|
|
329
|
-
};
|
|
330
|
-
}
|
|
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
|
}
|