@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/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
- // Admission, runtime resolution, route/session lookup, envelope building,
288
- // and context finalization above are replay-safe. The durable attempt starts
289
- // immediately before OpenClaw can invoke the agent or its tools.
290
- await markRespondingBeforeAttempt({
291
- client: params.client,
292
- conversationId: facts.conversationId,
293
- messageId: facts.messageId,
294
- label: "OpenClaw",
295
- markAttempt: params.markAttempt,
296
- });
297
- await runtime.channel.inbound.dispatchReply({
298
- cfg: params.cfg,
299
- channel: RELAY_CHANNEL_ID,
300
- accountId: account.accountId,
301
- agentId: route.agentId,
302
- routeSessionKey: route.sessionKey,
303
- storePath,
304
- ctxPayload,
305
- recordInboundSession: runtime.channel.session.recordInboundSession,
306
- dispatchReplyWithBufferedBlockDispatcher:
307
- runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
308
- delivery: {
309
- // Final replies go through the durable message adapter: core renders
310
- // and chunks them (chunker + textChunkLimit) and tracks the send as a
311
- // durable queue intent. Requiring reconcileUnknownSend forces
312
- // `durability: "required"`, so single-payload finals carry a stable
313
- // deliveryQueueId into send.text (stable idempotency key + exact
314
- // replay), and multi-chunk finals get core's queue-level crash
315
- // recovery. Replies land as plain messages, not quotes
316
- // (`replyToId: null`).
317
- durable: {
318
- to: facts.conversationId,
319
- replyToId: null,
320
- requiredCapabilities: { reconcileUnknownSend: true },
321
- },
322
- // Fallback for payloads the durable path does not carry (non-final
323
- // visible blocks). The event id + block/chunk ordinals identify each
324
- // logical send: retries reuse it while identical intentional blocks and
325
- // chunks remain distinct.
326
- deliver: async (payload) => {
327
- const text =
328
- payload && typeof payload === "object" && "text" in payload
329
- ? ((payload as { text?: string }).text ?? "")
330
- : "";
331
- if (!text.trim()) {
332
- return;
333
- }
334
- const logicalBlockId = `${facts.eventId}:block:${fallbackDeliveryIndex}`;
335
- fallbackDeliveryIndex += 1;
336
- try {
337
- let chunkIndex = 0;
338
- for (const chunk of chunkText(text, RELAY_TEXT_CHUNK_LIMIT)) {
339
- await sendRelayText({
340
- client: params.client,
341
- conversationId: facts.conversationId,
342
- text: chunk,
343
- idempotencyKey: deriveRelayIdempotencyKey({
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
- } catch (error) {
351
- recordDeliveryError(error);
352
- throw error;
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
- onError: recordDeliveryError,
356
- },
357
- replyPipeline: {},
358
- });
359
- if (deliveryError) {
360
- throw deliveryError instanceof Error
361
- ? deliveryError
362
- : new Error(`relay reply delivery failed: ${String(deliveryError)}`);
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
- if (error instanceof RelayApiError && error.terminal) {
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
- // 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
- import type {
6
- RelayAgentProfile,
7
- RelayEventsPage,
8
- RelayPart,
9
- RelayReplyRef,
10
- RelaySendResult,
11
- } from "./types.js";
12
-
13
- export const DEFAULT_RELAY_BASE_URL = "https://api.relayapp.im";
14
-
15
- function isLoopbackHostname(hostname: string): boolean {
16
- const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
17
- const ipVersion = isIP(normalized);
18
- if (ipVersion === 4) {
19
- return normalized.split(".")[0] === "127";
20
- }
21
- if (ipVersion === 6) {
22
- return normalized === "::1";
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
- * Validate and canonicalize the API origin before a bearer token can be sent
32
- * to it. Production/custom remote origins must use HTTPS. Plain HTTP remains
33
- * available only for an explicit loopback development server.
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 function normalizeRelayBaseUrl(raw?: string): string {
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
- * 409 from the webhook XOR rule: an enabled webhook endpoint makes long
89
- * polling unavailable until the operator disables it (server code
90
- * `conflict`, distinct from `terminated_by_other_consumer`).
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 function isRelayWebhookConflict(error: unknown): error is RelayApiError {
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
- async function readErrorDetail(
162
- response: Response,
163
- ): Promise<{ code?: string; message: string }> {
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
  }