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