@relaymessenger/chat-sdk-adapter 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,724 @@
1
+ import { Message, NotImplementedError, cardChildToFallbackText, emphasis, inlineCode, isCardElement, paragraph, root, strikethrough, strong, text as textNode, } from "chat";
2
+ import { MAX_PARTS_PER_MESSAGE, MAX_TEXT_PART_BYTES, chunkRenderedText, } from "./chunk.js";
3
+ import { RelayApiError, RelayClient } from "./client.js";
4
+ import { renderAst, renderMarkdown, renderRawText, } from "./format.js";
5
+ import { DedupeWindow, deriveIdempotencyKey, unkeyedIdempotencyKey, } from "./idempotency.js";
6
+ import { toRelayReaction } from "./reactions.js";
7
+ import { decodeWebhookSecret, verifyWebhookSignature, WebhookVerificationError, } from "./signature.js";
8
+ import { decodeRelayThreadId, encodeRelayThreadId, relayChannelIdFromThreadId, } from "./threadId.js";
9
+ import { activeTurn, runInTurn } from "./turn.js";
10
+ export const RELAY_ADAPTER_NAME = "relay";
11
+ const DEFAULT_DEDUPE_WINDOW = 4096;
12
+ const DEFAULT_HISTORY_LIMIT = 50;
13
+ /** `GET /v1/conversations/{id}/messages` clamps `limit` to this server side. */
14
+ const MAX_HISTORY_LIMIT = 100;
15
+ /**
16
+ * A group turn tried to commit a second message. Relay's invocation is single
17
+ * use, so there is nothing valid for the second message to cite and the server
18
+ * would answer 403.
19
+ */
20
+ export class RelayInvocationSpentError extends Error {
21
+ constructor(message) {
22
+ super(message);
23
+ this.name = "RelayInvocationSpentError";
24
+ }
25
+ }
26
+ function json(status, body) {
27
+ return new Response(JSON.stringify(body), {
28
+ status,
29
+ headers: { "Content-Type": "application/json" },
30
+ });
31
+ }
32
+ async function toBytes(data) {
33
+ if (data instanceof Uint8Array)
34
+ return new Uint8Array(data);
35
+ if (data instanceof ArrayBuffer)
36
+ return new Uint8Array(data);
37
+ return new Uint8Array(await data.arrayBuffer());
38
+ }
39
+ function mediaKindToAttachmentType(part) {
40
+ if (part.type === "voice_memo")
41
+ return "audio";
42
+ switch (part.media_kind) {
43
+ case "image":
44
+ case "video":
45
+ case "audio":
46
+ return part.media_kind;
47
+ default:
48
+ return "file";
49
+ }
50
+ }
51
+ /** Text parts joined into one string, with every style range rebased onto it. */
52
+ const TEXT_PART_SEPARATOR = "\n\n";
53
+ /**
54
+ * Flatten a message's text parts into the single string the Chat SDK reads,
55
+ * carrying each part's style ranges across at their new offsets.
56
+ *
57
+ * A message routinely holds several text parts: this adapter itself produces
58
+ * them whenever a reply is longer than Relay's 8 KB per-part ceiling. Reading
59
+ * only the first part's ranges would land a chunked reply back as one flat
60
+ * paragraph, and applying them to the joined string unshifted would put the
61
+ * emphasis on the wrong words.
62
+ */
63
+ function joinTextParts(parts) {
64
+ const pieces = [];
65
+ const styles = [];
66
+ let offset = 0;
67
+ for (const part of parts) {
68
+ const text = part.text ?? "";
69
+ // An empty part contributes no text, so it must not contribute a separator
70
+ // either: that would shift every later range by two.
71
+ if (!text)
72
+ continue;
73
+ if (pieces.length > 0)
74
+ offset += TEXT_PART_SEPARATOR.length;
75
+ for (const run of part.styles ?? []) {
76
+ styles.push({
77
+ start: run.start + offset,
78
+ length: run.length,
79
+ styles: run.styles,
80
+ });
81
+ }
82
+ pieces.push(text);
83
+ offset += text.length;
84
+ }
85
+ return { text: pieces.join(TEXT_PART_SEPARATOR), styles };
86
+ }
87
+ /**
88
+ * Rebuild an inline mdast run sequence from Relay's text plus style ranges, so
89
+ * `message.formatted` carries the emphasis the sender actually applied instead
90
+ * of a flat string. `underline` and `spoiler` have no mdast node, so those runs
91
+ * keep their words and lose only the decoration.
92
+ */
93
+ function formattedFromParts(value, styles) {
94
+ if (!value)
95
+ return root([]);
96
+ const ranges = (styles ?? []).filter((run) => run.length > 0);
97
+ if (ranges.length === 0)
98
+ return root([paragraph([textNode(value)])]);
99
+ const children = [];
100
+ let cursor = 0;
101
+ for (const range of ranges) {
102
+ if (range.start > cursor) {
103
+ children.push(textNode(value.slice(cursor, range.start)));
104
+ }
105
+ const slice = value.slice(range.start, range.start + range.length);
106
+ let node = range.styles.includes("monospace")
107
+ ? inlineCode(slice)
108
+ : textNode(slice);
109
+ if (range.styles.includes("strikethrough"))
110
+ node = strikethrough([node]);
111
+ if (range.styles.includes("italic"))
112
+ node = emphasis([node]);
113
+ if (range.styles.includes("bold"))
114
+ node = strong([node]);
115
+ children.push(node);
116
+ cursor = range.start + range.length;
117
+ }
118
+ if (cursor < value.length)
119
+ children.push(textNode(value.slice(cursor)));
120
+ return root([paragraph(children)]);
121
+ }
122
+ /**
123
+ * Relay adapter for the Vercel Chat SDK.
124
+ *
125
+ * Relay is a consumer messenger where people talk to agents as contacts, so
126
+ * this adapter maps the Chat SDK's thread model onto Relay conversations one
127
+ * to one: a Relay conversation has no enclosing channel, and a thread id is
128
+ * `relay:{conversation_id}`.
129
+ *
130
+ * Two Relay rules shape the surface. A streamed turn commits exactly one
131
+ * canonical message, so `stream` buffers and posts once rather than editing a
132
+ * draft bubble into place. And a group reply is scoped to the single-use
133
+ * invocation that produced the inbound event, so the first send of a turn
134
+ * carries it and a second cannot.
135
+ */
136
+ export class RelayAdapter {
137
+ name = RELAY_ADAPTER_NAME;
138
+ userName;
139
+ botUserId;
140
+ lockScope = "thread";
141
+ /** Relay serves history from `GET /v1/conversations/{id}/messages`. */
142
+ persistThreadHistory = false;
143
+ client;
144
+ webhookSecret;
145
+ toleranceSeconds;
146
+ dedupe;
147
+ chat;
148
+ constructor(options = {}) {
149
+ const token = options.token ?? process.env.RELAY_AGENT_TOKEN;
150
+ this.client =
151
+ options.client ??
152
+ new RelayClient({
153
+ token: token ?? "",
154
+ baseUrl: options.baseUrl,
155
+ fetch: options.fetch,
156
+ });
157
+ this.webhookSecret =
158
+ options.webhookSecret ?? process.env.RELAY_WEBHOOK_SECRET;
159
+ // Decode once, here. An unusable secret raised from inside verification is
160
+ // a 500 on every delivery, and Relay reads a 500 as transient and
161
+ // redelivers ten times; raised here it is a startup failure naming the
162
+ // option that is wrong.
163
+ if (this.webhookSecret)
164
+ decodeWebhookSecret(this.webhookSecret);
165
+ this.userName = options.userName ?? "Relay Agent";
166
+ this.botUserId = options.agentId;
167
+ this.toleranceSeconds = options.toleranceSeconds;
168
+ this.dedupe = new DedupeWindow(options.dedupeWindow ?? DEFAULT_DEDUPE_WINDOW);
169
+ }
170
+ async initialize(chat) {
171
+ this.chat = chat;
172
+ }
173
+ encodeThreadId(platformData) {
174
+ return encodeRelayThreadId(platformData);
175
+ }
176
+ decodeThreadId(threadId) {
177
+ return decodeRelayThreadId(threadId);
178
+ }
179
+ channelIdFromThreadId(threadId) {
180
+ return relayChannelIdFromThreadId(threadId);
181
+ }
182
+ renderFormatted(content) {
183
+ return renderAst(content).text;
184
+ }
185
+ parseMessage(raw) {
186
+ const message = raw.message;
187
+ const threadId = this.encodeThreadId({ conversationId: message.conversation_id });
188
+ const parts = message.parts ?? [];
189
+ const textParts = parts.filter((part) => part.type === "text");
190
+ const joined = joinTextParts(textParts);
191
+ const value = joined.text || message.fallback_text || "";
192
+ // Ranges index into the joined part text. When the parts carried no text
193
+ // and the fallback stands in, they would index into a different string.
194
+ const styles = joined.text ? joined.styles : undefined;
195
+ const attachments = parts
196
+ .filter((part) => part.type === "media" || part.type === "voice_memo")
197
+ .map((part) => ({
198
+ type: mediaKindToAttachmentType(part),
199
+ url: part.url,
200
+ name: part.filename,
201
+ mimeType: part.content_type,
202
+ size: part.size_bytes,
203
+ width: part.width,
204
+ height: part.height,
205
+ ...(part.attachment_id
206
+ ? { fetchMetadata: { attachmentId: part.attachment_id } }
207
+ : {}),
208
+ }));
209
+ const links = parts
210
+ .filter((part) => part.type === "link_preview" && part.url)
211
+ .map((part) => ({
212
+ url: part.url,
213
+ title: part.title,
214
+ description: part.description,
215
+ }));
216
+ return new Message({
217
+ id: message.id,
218
+ threadId,
219
+ text: value,
220
+ formatted: formattedFromParts(value, styles),
221
+ raw,
222
+ author: {
223
+ userId: message.sender.id,
224
+ // Relay's event carries the sender's id and kind but no display name.
225
+ // Resolve one with `getUser(message.author.userId)` when you need it.
226
+ userName: message.sender.id,
227
+ fullName: message.sender.id,
228
+ isBot: message.sender.kind === "agent",
229
+ isMe: message.is_from_me ?? false,
230
+ ...(message.sender.kind === "system" ? { isSystem: true } : {}),
231
+ },
232
+ metadata: {
233
+ dateSent: new Date(message.created_at),
234
+ edited: Boolean(message.edited_at),
235
+ ...(message.edited_at ? { editedAt: new Date(message.edited_at) } : {}),
236
+ },
237
+ attachments,
238
+ ...(links.length > 0 ? { links } : {}),
239
+ // Relay delivers `message.received` to an agent only when the message is
240
+ // addressed to it: always in a direct conversation, and in a group only
241
+ // to agents with an invocation relationship to that message.
242
+ isMention: true,
243
+ });
244
+ }
245
+ async postMessage(threadId, message) {
246
+ const { conversationId } = this.decodeThreadId(threadId);
247
+ const parts = await this.buildParts(message);
248
+ if (parts.length === 0) {
249
+ throw new Error("a Relay message needs at least one part");
250
+ }
251
+ return this.sendParts(conversationId, threadId, parts);
252
+ }
253
+ async editMessage(threadId, messageId, message) {
254
+ this.decodeThreadId(threadId);
255
+ const parts = await this.buildParts(message);
256
+ // Relay rejects attachment-bearing edits outright, so name that here
257
+ // rather than letting the server answer 422 with no context.
258
+ if (parts.some((part) => part.type === "media" || part.type === "voice_memo")) {
259
+ throw new NotImplementedError("Relay edits must stay text-bearing: media and voice memo parts are rejected", "editMessage");
260
+ }
261
+ // Relay accepts exactly one text part per edit: an edit replaces one
262
+ // message's text, and one text part carries at most 8 KB. Text long
263
+ // enough to chunk cannot be an edit of one message, so refuse it here
264
+ // rather than sending a multi-part PATCH the server answers 422 to, or
265
+ // silently truncating the caller's content.
266
+ if (parts.length !== 1) {
267
+ throw new NotImplementedError("a Relay edit replaces one message with one text part of at most 8 KB; shorten the edit or post a new message", "editMessage");
268
+ }
269
+ const result = await this.client.edit(messageId, parts);
270
+ return {
271
+ id: result.message.id,
272
+ threadId,
273
+ raw: { message: result.message },
274
+ };
275
+ }
276
+ async deleteMessage(threadId, messageId) {
277
+ this.decodeThreadId(threadId);
278
+ await this.client.unsend(messageId);
279
+ }
280
+ async addReaction(threadId, messageId, emoji) {
281
+ this.decodeThreadId(threadId);
282
+ const reaction = toRelayReaction(emoji);
283
+ await this.client.react({ messageId, operation: "add", ...reaction });
284
+ }
285
+ async removeReaction(threadId, messageId, emoji) {
286
+ this.decodeThreadId(threadId);
287
+ const reaction = toRelayReaction(emoji);
288
+ await this.client.react({ messageId, operation: "remove", ...reaction });
289
+ }
290
+ /**
291
+ * Relay's typing indicator is ephemeral and carries an optional label of up
292
+ * to 80 characters. The invocation is peeked rather than consumed: typing is
293
+ * not the group reply the invocation is spent on.
294
+ *
295
+ * Failures are swallowed. Group typing without a live pending invocation is a
296
+ * 403 (`Relay-Server/server/src/domain/typing.ts:70-73`), which is exactly
297
+ * what typing after the first send of a group turn looks like. The Chat SDK
298
+ * treats this call as best effort and has no `stopTyping` to strand, so a
299
+ * hint that cannot be shown must never take the reply down with it.
300
+ */
301
+ async startTyping(threadId, status) {
302
+ const { conversationId } = this.decodeThreadId(threadId);
303
+ const turn = activeTurn(conversationId);
304
+ try {
305
+ await this.client.typing({
306
+ conversationId,
307
+ started: true,
308
+ ...(status ? { label: status.slice(0, 80) } : {}),
309
+ ...(turn?.invocationId && !turn.invocationUsed
310
+ ? { invocationId: turn.invocationId }
311
+ : {}),
312
+ });
313
+ }
314
+ catch {
315
+ // The indicator is decoration. The turn continues.
316
+ }
317
+ }
318
+ async markAsRead(threadId, messageId) {
319
+ const { conversationId } = this.decodeThreadId(threadId);
320
+ await this.client.markRead(conversationId, messageId);
321
+ }
322
+ async getUser(userId) {
323
+ try {
324
+ const { user } = await this.client.user(userId);
325
+ return {
326
+ userId: user.id,
327
+ userName: user.name,
328
+ fullName: user.name,
329
+ isBot: false,
330
+ ...(user.avatar_url ? { avatarUrl: user.avatar_url } : {}),
331
+ };
332
+ }
333
+ catch (error) {
334
+ if (typeof error === "object" &&
335
+ error !== null &&
336
+ error.status === 404) {
337
+ return null;
338
+ }
339
+ throw error;
340
+ }
341
+ }
342
+ /**
343
+ * Relay pages history backwards with `before_sequence` and returns newest
344
+ * first; the Chat SDK wants each page in chronological order. There is no
345
+ * forward cursor on the route, so `direction: "forward"` has nothing to call.
346
+ */
347
+ async fetchMessages(threadId, options) {
348
+ if (options?.direction === "forward") {
349
+ throw new NotImplementedError("Relay history pages backwards only: GET /v1/conversations/{id}/messages takes before_sequence and has no forward cursor", "fetchMessages");
350
+ }
351
+ const { conversationId } = this.decodeThreadId(threadId);
352
+ // The route clamps `limit` to 1..100 server side
353
+ // (`Relay-Server/server/src/routes/messages.ts:418`). Asking for 200 and
354
+ // then testing the 100 rows that come back against 200 would read as "the
355
+ // conversation ended here" and silently truncate the history.
356
+ const limit = Math.min(Math.max(Math.trunc(options?.limit ?? DEFAULT_HISTORY_LIMIT), 1), MAX_HISTORY_LIMIT);
357
+ const beforeSequence = options?.cursor ? Number(options.cursor) : undefined;
358
+ if (beforeSequence !== undefined && !Number.isFinite(beforeSequence)) {
359
+ throw new Error(`not a Relay history cursor: ${options?.cursor}`);
360
+ }
361
+ const { messages } = await this.client.history({
362
+ conversationId,
363
+ limit,
364
+ ...(beforeSequence !== undefined ? { beforeSequence } : {}),
365
+ });
366
+ const chronological = [...messages].sort((a, b) => a.sequence - b.sequence);
367
+ const parsed = chronological.map((message) => this.parseMessage({ message }));
368
+ const oldest = chronological[0];
369
+ return {
370
+ messages: parsed,
371
+ ...(messages.length >= limit && oldest
372
+ ? { nextCursor: String(oldest.sequence) }
373
+ : {}),
374
+ };
375
+ }
376
+ async fetchThread(threadId) {
377
+ const { conversationId } = this.decodeThreadId(threadId);
378
+ const { conversation } = await this.client.conversation(conversationId);
379
+ return {
380
+ id: threadId,
381
+ channelId: this.channelIdFromThreadId(threadId),
382
+ isDM: conversation.kind === "direct",
383
+ // Relay conversations are private to their participants; there is no
384
+ // workspace or externally shared scope above them.
385
+ channelVisibility: "private",
386
+ ...(conversation.title ??
387
+ conversation.counterpart_user?.display_name
388
+ ? {
389
+ channelName: conversation.title ??
390
+ conversation.counterpart_user?.display_name,
391
+ }
392
+ : {}),
393
+ metadata: {
394
+ kind: conversation.kind,
395
+ participantCount: conversation.participant_count,
396
+ lastSequence: conversation.last_sequence,
397
+ counterpartUserId: conversation.counterpart_user?.id,
398
+ },
399
+ };
400
+ }
401
+ /**
402
+ * Relay commits one canonical message per turn and has no draft bubble to
403
+ * edit, so the stream is buffered and posted once. Nothing partial ever
404
+ * reaches a recipient, and no cleanup is needed if the stream fails midway.
405
+ */
406
+ async stream(threadId, textStream) {
407
+ const pieces = [];
408
+ for await (const chunk of textStream) {
409
+ if (typeof chunk === "string") {
410
+ pieces.push(chunk);
411
+ continue;
412
+ }
413
+ if (chunk.type === "markdown_text")
414
+ pieces.push(chunk.text);
415
+ // Task and plan chunks describe in-flight progress. Relay shows progress
416
+ // through the typing label instead, so they do not enter the message.
417
+ }
418
+ const markdown = pieces.join("");
419
+ if (!markdown.trim())
420
+ return null;
421
+ return this.postMessage(threadId, { markdown });
422
+ }
423
+ /**
424
+ * Verify the Standard Webhooks signature over the exact raw body, refuse a
425
+ * replay by `event_id`, and hand the event to the Chat SDK. Relay redelivers
426
+ * on 5xx, so a dispatch failure must not answer 2xx.
427
+ */
428
+ async handleWebhook(request, options) {
429
+ if (request.method !== "POST") {
430
+ return json(405, { error: { code: "method_not_allowed" } });
431
+ }
432
+ if (!this.webhookSecret) {
433
+ throw new Error("webhookSecret is required: pass it to createRelayAdapter or set RELAY_WEBHOOK_SECRET");
434
+ }
435
+ if (!this.chat) {
436
+ throw new Error("the Relay adapter received a webhook before initialize()");
437
+ }
438
+ const payload = await request.text();
439
+ try {
440
+ await verifyWebhookSignature({
441
+ secret: this.webhookSecret,
442
+ payload,
443
+ headers: {
444
+ "webhook-id": request.headers.get("webhook-id"),
445
+ "webhook-timestamp": request.headers.get("webhook-timestamp"),
446
+ "webhook-signature": request.headers.get("webhook-signature"),
447
+ },
448
+ options: { toleranceSeconds: this.toleranceSeconds },
449
+ });
450
+ }
451
+ catch (error) {
452
+ if (error instanceof WebhookVerificationError) {
453
+ return json(401, {
454
+ error: { code: "invalid_signature", message: error.message },
455
+ });
456
+ }
457
+ throw error;
458
+ }
459
+ let envelope;
460
+ try {
461
+ envelope = JSON.parse(payload);
462
+ }
463
+ catch {
464
+ return json(422, {
465
+ error: { code: "invalid_request", message: "body is not JSON" },
466
+ });
467
+ }
468
+ if (!envelope.event_id || !envelope.event_type) {
469
+ return json(422, {
470
+ error: { code: "invalid_request", message: "not an event envelope" },
471
+ });
472
+ }
473
+ // Claim before dispatching, not after. Two redeliveries of one event can
474
+ // be in flight at once, and a check that only records on the way out lets
475
+ // both of them past.
476
+ if (!this.dedupe.claim(envelope.event_id)) {
477
+ return json(200, { deduplicated: true });
478
+ }
479
+ try {
480
+ await this.dispatch(envelope, options);
481
+ }
482
+ catch (error) {
483
+ // Give the claim back. A thrown handler answers 5xx, which is Relay's
484
+ // signal to redeliver, and a retained claim would turn every redelivery
485
+ // into a 200 that did nothing: the message would be lost outright. The
486
+ // `Idempotency-Key` on each send is what keeps the retry from posting
487
+ // twice whatever the first attempt already committed.
488
+ this.dedupe.release(envelope.event_id);
489
+ throw error;
490
+ }
491
+ return json(200, { handled: true });
492
+ }
493
+ async dispatch(envelope, options) {
494
+ const chat = this.chat;
495
+ switch (envelope.event_type) {
496
+ case "message.received": {
497
+ const data = envelope.data;
498
+ const raw = {
499
+ message: data.message,
500
+ invocation_id: data.invocation_id,
501
+ event_id: envelope.event_id,
502
+ event_type: envelope.event_type,
503
+ };
504
+ const threadId = this.encodeThreadId({
505
+ conversationId: data.message.conversation_id,
506
+ });
507
+ const turn = {
508
+ conversationId: data.message.conversation_id,
509
+ eventId: envelope.event_id,
510
+ invocationId: data.invocation_id,
511
+ invocationUsed: false,
512
+ sent: 0,
513
+ };
514
+ // Bind the turn to this dispatch's async context, so a second event on
515
+ // the same conversation cannot take it over while this one waits.
516
+ await runInTurn(turn, () => chat.processMessage(this, threadId, this.parseMessage(raw), options));
517
+ return;
518
+ }
519
+ case "message.edited": {
520
+ const data = envelope.data;
521
+ const raw = {
522
+ message: data.message,
523
+ event_id: envelope.event_id,
524
+ event_type: envelope.event_type,
525
+ };
526
+ await chat.processMessageUpdated({
527
+ adapter: this,
528
+ threadId: this.encodeThreadId({
529
+ conversationId: data.message.conversation_id,
530
+ }),
531
+ message: this.parseMessage(raw),
532
+ }, options);
533
+ return;
534
+ }
535
+ case "message.unsent": {
536
+ const data = envelope.data;
537
+ const threadId = this.encodeThreadId({
538
+ conversationId: data.conversation_id,
539
+ });
540
+ await chat.processMessageDeleted({
541
+ adapter: this,
542
+ threadId,
543
+ channelId: this.channelIdFromThreadId(threadId),
544
+ messageId: data.message_id,
545
+ deletedAt: new Date(envelope.created_at),
546
+ raw: envelope,
547
+ }, options);
548
+ return;
549
+ }
550
+ default:
551
+ // Reaction, receipt, conversation, and group invite events acknowledge
552
+ // without dispatch. `reaction.added` carries an undocumented `reaction`
553
+ // object in the OpenAPI, so there is nothing stable to map onto the
554
+ // Chat SDK's ReactionEvent yet.
555
+ return;
556
+ }
557
+ }
558
+ async buildParts(message) {
559
+ if (typeof message === "string") {
560
+ return this.textParts(renderRawText(message));
561
+ }
562
+ if (isCardElement(message)) {
563
+ return this.textParts(renderRawText(this.cardToText(message)));
564
+ }
565
+ let rendered;
566
+ if ("raw" in message) {
567
+ rendered = renderRawText(message.raw);
568
+ }
569
+ else if ("markdown" in message) {
570
+ rendered = renderMarkdown(message.markdown);
571
+ }
572
+ else if ("ast" in message) {
573
+ rendered = renderAst(message.ast);
574
+ }
575
+ else {
576
+ rendered = renderRawText(this.cardToText(message.card, message.fallbackText));
577
+ }
578
+ const parts = this.textParts(rendered);
579
+ const attachments = "attachments" in message && message.attachments ? message.attachments : [];
580
+ for (const attachment of attachments) {
581
+ parts.push(await this.attachmentPart(attachment));
582
+ }
583
+ const files = "files" in message && message.files ? message.files : [];
584
+ for (const file of files) {
585
+ parts.push(await this.filePart(file));
586
+ }
587
+ return parts;
588
+ }
589
+ textParts(rendered) {
590
+ return chunkRenderedText(rendered, MAX_TEXT_PART_BYTES).map((chunk) => ({
591
+ type: "text",
592
+ text: chunk.text,
593
+ // An empty array is meaningful to Relay: it marks structured plain text
594
+ // rather than a legacy Markdown body.
595
+ styles: chunk.styles,
596
+ }));
597
+ }
598
+ /**
599
+ * Relay has no card surface: interactive components were removed from the
600
+ * app, so buttons cannot render and a human cannot answer one in Relay. The
601
+ * card's words are still delivered, as text, rather than dropped.
602
+ */
603
+ cardToText(card, fallbackText) {
604
+ if (fallbackText?.trim())
605
+ return fallbackText;
606
+ const lines = [];
607
+ if (card.title)
608
+ lines.push(card.title);
609
+ if (card.subtitle)
610
+ lines.push(card.subtitle);
611
+ for (const child of card.children ?? []) {
612
+ const rendered = cardChildToFallbackText(child);
613
+ if (rendered)
614
+ lines.push(rendered);
615
+ }
616
+ const value = lines.join("\n\n").trim();
617
+ if (!value) {
618
+ throw new NotImplementedError("Relay has no card surface and this card carried no text to fall back to", "postMessage");
619
+ }
620
+ return value;
621
+ }
622
+ async attachmentPart(attachment) {
623
+ if (attachment.url?.startsWith("https://")) {
624
+ return {
625
+ type: "media",
626
+ url: attachment.url,
627
+ ...(attachment.mimeType ? { content_type: attachment.mimeType } : {}),
628
+ };
629
+ }
630
+ const data = attachment.data ?? (await attachment.fetchData?.());
631
+ if (!data) {
632
+ throw new Error(`attachment ${attachment.name ?? "(unnamed)"} has neither an https URL nor bytes, so Relay has nothing to store`);
633
+ }
634
+ const stored = await this.client.upload({
635
+ body: await toBytes(data),
636
+ ...(attachment.mimeType ? { contentType: attachment.mimeType } : {}),
637
+ ...(attachment.name ? { filename: attachment.name } : {}),
638
+ });
639
+ return { type: "media", attachment_id: stored.id };
640
+ }
641
+ async filePart(file) {
642
+ const stored = await this.client.upload({
643
+ body: await toBytes(file.data),
644
+ ...(file.mimeType ? { contentType: file.mimeType } : {}),
645
+ filename: file.filename,
646
+ });
647
+ return { type: "media", attachment_id: stored.id };
648
+ }
649
+ /**
650
+ * Send the parts in one call when they fit, and as follow-up calls when
651
+ * they do not. The server splits each call at ingest into one or more
652
+ * messages, and the one call that carries the invocation owns every message
653
+ * it commits. A group turn cannot overflow, and cannot POST twice: Relay's
654
+ * invocation is single use per call, so a second POST has nothing valid to
655
+ * cite and the server answers 403.
656
+ */
657
+ async sendParts(conversationId, threadId, parts) {
658
+ const turn = activeTurn(conversationId);
659
+ const batches = [];
660
+ for (let i = 0; i < parts.length; i += MAX_PARTS_PER_MESSAGE) {
661
+ batches.push(parts.slice(i, i + MAX_PARTS_PER_MESSAGE));
662
+ }
663
+ // Relay carries an invocation only on a group event, so a turn holding one
664
+ // is a group turn for as long as it lives. Test that rather than testing
665
+ // whether an invocation is still available: once it is spent, the second
666
+ // send would otherwise go out bare and be refused by the server.
667
+ const groupTurn = turn?.invocationId !== undefined ? turn : undefined;
668
+ if (groupTurn?.invocationUsed) {
669
+ throw new RelayInvocationSpentError("this group turn already replied, and one Relay invocation permits one message");
670
+ }
671
+ const invocationId = groupTurn?.invocationId;
672
+ if (batches.length > 1 && invocationId) {
673
+ throw new RelayInvocationSpentError(`this reply needs ${batches.length} Relay send calls, and one Relay invocation permits one call`);
674
+ }
675
+ // The ordinal is the send's logical position in the turn, so a retry lands
676
+ // on the key the first attempt used and Relay replays it instead of
677
+ // posting a second message. The base counts what this turn has already
678
+ // committed and is read once, before the loop: it advances only on a
679
+ // successful send, so an attempt that threw does not push the next key
680
+ // along and a redelivery of the whole event starts from zero again.
681
+ const base = turn?.sent ?? 0;
682
+ let first;
683
+ for (const [index, batch] of batches.entries()) {
684
+ const idempotencyKey = turn
685
+ ? deriveIdempotencyKey(turn.eventId, base + index)
686
+ : unkeyedIdempotencyKey(conversationId);
687
+ const result = await this.client.send({
688
+ conversationId,
689
+ parts: batch,
690
+ idempotencyKey,
691
+ ...(first === undefined && invocationId ? { invocationId } : {}),
692
+ });
693
+ if (turn) {
694
+ turn.sent += 1;
695
+ if (first === undefined && invocationId)
696
+ turn.invocationUsed = true;
697
+ }
698
+ // One call commits one or more messages; the Chat SDK's post contract
699
+ // names a single raw message, so the first committed one stands for the
700
+ // whole send. A 202 that carries none is a server contract violation:
701
+ // surface it as a 502 so a generic status-classing retry treats it as
702
+ // transient, instead of handing the caller undefined as a message.
703
+ const [committed] = result.messages;
704
+ if (!committed) {
705
+ throw new RelayApiError(502, "empty_send", "relay: 202 carried no messages");
706
+ }
707
+ if (first === undefined) {
708
+ first = {
709
+ id: committed.id,
710
+ threadId,
711
+ raw: { message: committed },
712
+ };
713
+ }
714
+ }
715
+ if (!first) {
716
+ throw new RelayApiError(502, "empty_send", "relay: send committed no messages");
717
+ }
718
+ return first;
719
+ }
720
+ }
721
+ /** Build a Relay adapter for the Vercel Chat SDK. */
722
+ export function createRelayAdapter(options = {}) {
723
+ return new RelayAdapter(options);
724
+ }