@relaymessenger/chat-sdk-adapter 0.2.1 → 0.3.0-staging.1

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.
Files changed (69) hide show
  1. package/LICENSE +3 -3
  2. package/README.md +218 -98
  3. package/dist/adapter.d.ts +156 -0
  4. package/dist/adapter.d.ts.map +1 -0
  5. package/dist/adapter.js +723 -0
  6. package/dist/adapter.js.map +1 -0
  7. package/dist/client.d.ts +84 -0
  8. package/dist/client.d.ts.map +1 -0
  9. package/dist/client.js +266 -0
  10. package/dist/client.js.map +1 -0
  11. package/dist/content.d.ts +11 -0
  12. package/dist/content.d.ts.map +1 -0
  13. package/dist/content.js +172 -0
  14. package/dist/content.js.map +1 -0
  15. package/dist/credentials.d.ts +13 -0
  16. package/dist/credentials.d.ts.map +1 -0
  17. package/dist/credentials.js +28 -0
  18. package/dist/credentials.js.map +1 -0
  19. package/dist/index.d.ts +17 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +10 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/reactions.d.ts +15 -0
  24. package/dist/reactions.d.ts.map +1 -0
  25. package/dist/reactions.js +58 -0
  26. package/dist/reactions.js.map +1 -0
  27. package/dist/signature.d.ts +22 -0
  28. package/dist/signature.d.ts.map +1 -0
  29. package/dist/signature.js +98 -0
  30. package/dist/signature.js.map +1 -0
  31. package/dist/thread-id.d.ts +8 -0
  32. package/dist/thread-id.d.ts.map +1 -0
  33. package/dist/thread-id.js +27 -0
  34. package/dist/thread-id.js.map +1 -0
  35. package/dist/turn.d.ts +16 -0
  36. package/dist/turn.d.ts.map +1 -0
  37. package/dist/turn.js +22 -0
  38. package/dist/turn.js.map +1 -0
  39. package/dist/types.d.ts +193 -0
  40. package/dist/types.d.ts.map +1 -0
  41. package/dist/types.js +27 -0
  42. package/dist/types.js.map +1 -0
  43. package/dist/webhook.d.ts +9 -0
  44. package/dist/webhook.d.ts.map +1 -0
  45. package/dist/webhook.js +115 -0
  46. package/dist/webhook.js.map +1 -0
  47. package/package.json +52 -32
  48. package/dist/src/adapter.d.ts +0 -125
  49. package/dist/src/adapter.js +0 -724
  50. package/dist/src/chunk.d.ts +0 -25
  51. package/dist/src/chunk.js +0 -108
  52. package/dist/src/client.d.ts +0 -112
  53. package/dist/src/client.js +0 -180
  54. package/dist/src/format.d.ts +0 -43
  55. package/dist/src/format.js +0 -266
  56. package/dist/src/idempotency.d.ts +0 -50
  57. package/dist/src/idempotency.js +0 -75
  58. package/dist/src/index.d.ts +0 -14
  59. package/dist/src/index.js +0 -8
  60. package/dist/src/reactions.d.ts +0 -12
  61. package/dist/src/reactions.js +0 -98
  62. package/dist/src/signature.d.ts +0 -46
  63. package/dist/src/signature.js +0 -95
  64. package/dist/src/threadId.d.ts +0 -12
  65. package/dist/src/threadId.js +0 -28
  66. package/dist/src/turn.d.ts +0 -38
  67. package/dist/src/turn.js +0 -15
  68. package/dist/src/types.d.ts +0 -177
  69. package/dist/src/types.js +0 -7
@@ -0,0 +1,723 @@
1
+ import { ValidationError } from "@chat-adapter/shared";
2
+ import { Message, NotImplementedError, paragraph, root, text, toPlainText, } from "chat";
3
+ import { RelayApiError, RelayClient } from "./client.js";
4
+ import { relayEnv, validateStaticCredential, } from "./credentials.js";
5
+ import { buildRelayParts, hasPostableContent, } from "./content.js";
6
+ import { fromRelayReaction, toRelayReaction, } from "./reactions.js";
7
+ import { decodeWebhookSecret, verifyWebhookSignature, WebhookVerificationError, } from "./signature.js";
8
+ import { assertRelayUuid, decodeRelayThreadId, encodeRelayThreadId, relayChannelIdFromThreadId, } from "./thread-id.js";
9
+ import { inboundIdempotencyKey, RelayTurnContext, } from "./turn.js";
10
+ import { assertExhaustiveEvent, parseReactionEvent, parseWebhookEnvelope, parseWebhookMessageEvent, readWebhookBody, } from "./webhook.js";
11
+ export const RELAY_ADAPTER_NAME = "relay";
12
+ function json(status, body) {
13
+ return Response.json(body, { status });
14
+ }
15
+ function isWebhookMessage(message) {
16
+ return message !== null && "chat" in message;
17
+ }
18
+ function isRestMessage(message) {
19
+ return message !== null && "chat_id" in message;
20
+ }
21
+ function messageParts(message) {
22
+ if (message === null)
23
+ return [];
24
+ return (message.parts ?? []);
25
+ }
26
+ function textAndLinks(parts) {
27
+ const pieces = [];
28
+ const links = [];
29
+ for (const part of parts) {
30
+ if (part.type === "text" ||
31
+ part.type === "system") {
32
+ if (part.value)
33
+ pieces.push(part.value);
34
+ }
35
+ else if (part.type === "link") {
36
+ pieces.push(part.value);
37
+ links.push({ url: part.value });
38
+ }
39
+ }
40
+ return { links, value: pieces.join("\n\n") };
41
+ }
42
+ function attachmentType(mimeType) {
43
+ if (mimeType.startsWith("image/"))
44
+ return "image";
45
+ if (mimeType.startsWith("video/"))
46
+ return "video";
47
+ if (mimeType.startsWith("audio/"))
48
+ return "audio";
49
+ return "file";
50
+ }
51
+ function messageHandle(message) {
52
+ if (message === null)
53
+ return undefined;
54
+ if (isWebhookMessage(message))
55
+ return message.sender_handle;
56
+ return message.from_handle;
57
+ }
58
+ function messageDate(raw) {
59
+ const message = raw.message;
60
+ if (message === null)
61
+ return new Date(0);
62
+ const candidate = isWebhookMessage(message)
63
+ ? message.sent_at ?? raw.createdAt
64
+ : message.created_at;
65
+ const date = new Date(candidate ?? 0);
66
+ return Number.isFinite(date.getTime()) ? date : new Date(0);
67
+ }
68
+ function messageIsMe(message) {
69
+ if (message === null)
70
+ return true;
71
+ if (isWebhookMessage(message)) {
72
+ return message.direction === "outbound";
73
+ }
74
+ if (isRestMessage(message))
75
+ return message.is_from_me;
76
+ return true;
77
+ }
78
+ /**
79
+ * Relay adapter for `chat@4.39.0`, limited to the locked public v1 contract.
80
+ */
81
+ export class RelayAdapter {
82
+ name = RELAY_ADAPTER_NAME;
83
+ userName;
84
+ botUserId;
85
+ lockScope = "thread";
86
+ persistThreadHistory = false;
87
+ typing;
88
+ client;
89
+ signatureToleranceSeconds;
90
+ webhookSecret;
91
+ idempotencyKeyResolver;
92
+ turns = new RelayTurnContext();
93
+ /**
94
+ * Request-scoped Chat kind hints. Entries exist only while an inbound
95
+ * dispatch is active; this is not persistence or a delivery-idempotency
96
+ * store.
97
+ */
98
+ activeChatKinds = new Map();
99
+ chat;
100
+ constructor(options = {}) {
101
+ const token = options.token ?? relayEnv("RELAY_AGENT_TOKEN");
102
+ const webhookSecret = options.webhookSecret ?? relayEnv("RELAY_WEBHOOK_SECRET");
103
+ validateStaticCredential(token, "Relay Agent Token");
104
+ validateStaticCredential(webhookSecret, "Relay webhook signing secret");
105
+ if (typeof webhookSecret === "string") {
106
+ decodeWebhookSecret(webhookSecret);
107
+ }
108
+ if (options.agentId) {
109
+ assertRelayUuid(options.agentId, "agentId");
110
+ this.botUserId = options.agentId;
111
+ }
112
+ this.client =
113
+ options.client ??
114
+ new RelayClient({
115
+ token,
116
+ ...(options.baseUrl ? { baseUrl: options.baseUrl } : {}),
117
+ ...(options.fetch ? { fetch: options.fetch } : {}),
118
+ });
119
+ this.webhookSecret = webhookSecret;
120
+ this.userName = options.userName ?? "Relay Agent";
121
+ this.typing = options.typing ?? true;
122
+ this.idempotencyKeyResolver = options.idempotencyKeyResolver;
123
+ this.signatureToleranceSeconds =
124
+ options.signatureToleranceSeconds;
125
+ }
126
+ async initialize(chat) {
127
+ this.chat = chat;
128
+ }
129
+ encodeThreadId(data) {
130
+ return encodeRelayThreadId(data);
131
+ }
132
+ decodeThreadId(threadId) {
133
+ return decodeRelayThreadId(threadId);
134
+ }
135
+ channelIdFromThreadId(threadId) {
136
+ return relayChannelIdFromThreadId(threadId);
137
+ }
138
+ renderFormatted(content) {
139
+ return toPlainText(content);
140
+ }
141
+ /**
142
+ * Build the Chat SDK `fetchData` closure for one Relay media part.
143
+ *
144
+ * The download runs through this adapter's own Relay client, so a consumer
145
+ * supplied `fetch` (Workers, a proxy, a test double) serves attachment bytes
146
+ * exactly as it serves every other Relay call.
147
+ */
148
+ attachmentData(url, attachmentId) {
149
+ return () => this.client.downloadAttachment({
150
+ ...(attachmentId ? { attachmentId } : {}),
151
+ url,
152
+ });
153
+ }
154
+ /**
155
+ * Mint a replacement download URL for an attachment id.
156
+ *
157
+ * Returns `undefined` when the call cannot produce one and the caller still
158
+ * holds a fallback URL; rethrows when there is nothing to fall back to, so a
159
+ * genuine authorization or not-found failure is never swallowed.
160
+ */
161
+ async freshDownloadUrl(attachmentId, hasFallback) {
162
+ try {
163
+ const attachment = await this.client.getAttachment(attachmentId);
164
+ if (attachment.download_url)
165
+ return attachment.download_url;
166
+ }
167
+ catch (error) {
168
+ if (!hasFallback)
169
+ throw error;
170
+ return undefined;
171
+ }
172
+ if (!hasFallback) {
173
+ throw new ValidationError("relay", `Relay attachment ${attachmentId} has no download URL`);
174
+ }
175
+ return undefined;
176
+ }
177
+ /**
178
+ * Build the `fetchData` closure for an attachment that survived a queue.
179
+ *
180
+ * The stored URL may already have expired, so the id is tried first: Relay
181
+ * mints a new download link on every `GET /v1/attachments/{attachmentId}`.
182
+ * The serialized URL is the fallback for the one case the id cannot cover —
183
+ * an attachment whose `fetchMetadata` predates this adapter version.
184
+ */
185
+ rehydratedAttachmentData(attachmentId, url) {
186
+ return async () => {
187
+ const fresh = attachmentId
188
+ ? await this.freshDownloadUrl(attachmentId, url !== undefined)
189
+ : undefined;
190
+ const target = fresh ?? url;
191
+ if (!target) {
192
+ throw new ValidationError("relay", "A rehydrated Relay attachment needs fetchMetadata.attachmentId "
193
+ + "or a URL");
194
+ }
195
+ return this.client.downloadAttachment({
196
+ ...(attachmentId ? { attachmentId } : {}),
197
+ url: target,
198
+ });
199
+ };
200
+ }
201
+ /**
202
+ * Rebuild `fetchData` on an attachment that survived serialization.
203
+ *
204
+ * `Message.toJSON()` drops `data` and `fetchData`, so Chat SDK calls this
205
+ * when a queue or debounce strategy rehydrates a Message. A Relay download
206
+ * URL expires 60 minutes after Relay minted it, and a queue can hold a
207
+ * Message for longer, so this re-mints from `fetchMetadata.attachmentId` and
208
+ * falls back to the stored URL only when the id call cannot serve one.
209
+ */
210
+ rehydrateAttachment(attachment) {
211
+ const attachmentId = attachment.fetchMetadata?.attachmentId;
212
+ const url = attachment.fetchMetadata?.url ?? attachment.url;
213
+ if (!attachmentId && !url)
214
+ return attachment;
215
+ return {
216
+ ...attachment,
217
+ fetchData: this.rehydratedAttachmentData(attachmentId, url),
218
+ };
219
+ }
220
+ parseMessage(raw) {
221
+ const threadId = this.encodeThreadId({ chatId: raw.chatId });
222
+ const message = raw.message;
223
+ if (message === null) {
224
+ throw new ValidationError("relay", "A no-op Relay result is not an inbound message");
225
+ }
226
+ const parts = messageParts(message);
227
+ const content = textAndLinks(parts);
228
+ const handle = messageHandle(message);
229
+ const system = isRestMessage(message) && message.is_system_message;
230
+ const userId = handle?.id ??
231
+ (system ? "relay-system" : this.botUserId ?? "relay-agent");
232
+ const userName = handle?.handle ??
233
+ (system ? "relay-system" : this.userName);
234
+ const fullName = handle?.display_name ?? userName;
235
+ const attachments = parts
236
+ .filter((part) => part.type === "media")
237
+ .map((part) => ({
238
+ fetchData: this.attachmentData(part.url, part.id),
239
+ fetchMetadata: { attachmentId: part.id, url: part.url },
240
+ ...(part.height != null ? { height: part.height } : {}),
241
+ mimeType: part.mime_type,
242
+ name: part.filename,
243
+ size: part.size_bytes,
244
+ type: attachmentType(part.mime_type),
245
+ url: part.url,
246
+ ...(part.width != null ? { width: part.width } : {}),
247
+ }));
248
+ return new Message({
249
+ attachments,
250
+ author: {
251
+ fullName,
252
+ isBot: system ? false : handle?.kind === "agent",
253
+ isMe: messageIsMe(message),
254
+ ...(system ? { isSystem: true } : {}),
255
+ userId,
256
+ userName,
257
+ },
258
+ formatted: content.value
259
+ ? root([paragraph([text(content.value)])])
260
+ : root([]),
261
+ id: message.id,
262
+ ...(raw.eventType === "message.received" &&
263
+ isWebhookMessage(message)
264
+ ? { isMention: this.isMentioned(message) }
265
+ : {}),
266
+ ...(content.links.length ? { links: content.links } : {}),
267
+ metadata: {
268
+ dateSent: messageDate(raw),
269
+ edited: false,
270
+ },
271
+ raw,
272
+ text: content.value,
273
+ threadId,
274
+ });
275
+ }
276
+ unsupported(feature, detail) {
277
+ throw new NotImplementedError(`${detail} The locked Relay v1 contract does not expose ${feature}.`, feature);
278
+ }
279
+ async send(threadId, message, replyToMessageId) {
280
+ const { chatId } = this.decodeThreadId(threadId);
281
+ const turn = this.turns.active();
282
+ if (!turn &&
283
+ !this.idempotencyKeyResolver &&
284
+ hasPostableContent(message)) {
285
+ throw new ValidationError("relay", "Relay posts outside an inbound webhook require idempotencyKeyResolver");
286
+ }
287
+ const parts = await buildRelayParts(message);
288
+ if (parts.length === 0) {
289
+ return this.noopResult(chatId, threadId);
290
+ }
291
+ const send = async (idempotencyKey) => this.client.sendMessage({
292
+ chatId,
293
+ idempotencyKey,
294
+ parts,
295
+ ...(replyToMessageId
296
+ ? { replyTo: { messageId: replyToMessageId } }
297
+ : {}),
298
+ });
299
+ const result = turn
300
+ ? await this.sendInTurn(turn, send)
301
+ : await send(await this.resolveExternalIdempotencyKey({
302
+ chatId,
303
+ parts,
304
+ ...(replyToMessageId
305
+ ? { replyToMessageId }
306
+ : {}),
307
+ threadId,
308
+ }));
309
+ if (result.chat_id !== chatId) {
310
+ throw new RelayApiError(502, "chat_mismatch", "Relay send response returned a different chat_id", result);
311
+ }
312
+ return {
313
+ id: result.message.id,
314
+ raw: {
315
+ chatId,
316
+ message: result.message,
317
+ },
318
+ threadId,
319
+ };
320
+ }
321
+ async sendInTurn(turn, send) {
322
+ const previous = turn.tail;
323
+ let release = () => undefined;
324
+ turn.tail = new Promise((resolve) => {
325
+ release = resolve;
326
+ });
327
+ await previous;
328
+ const ordinal = turn.sent;
329
+ turn.sent += 1;
330
+ try {
331
+ const result = await send(inboundIdempotencyKey(turn.eventId, ordinal));
332
+ return result;
333
+ }
334
+ finally {
335
+ release();
336
+ }
337
+ }
338
+ async resolveExternalIdempotencyKey(context) {
339
+ if (!this.idempotencyKeyResolver) {
340
+ throw new ValidationError("relay", "Relay posts outside an inbound webhook require idempotencyKeyResolver");
341
+ }
342
+ const key = await this.idempotencyKeyResolver(context);
343
+ if (!key || key.length > 255) {
344
+ throw new ValidationError("relay", "idempotencyKeyResolver must return 1–255 characters");
345
+ }
346
+ return key;
347
+ }
348
+ noopResult(chatId, threadId) {
349
+ return {
350
+ // Chat SDK requires a RawMessage to suppress its post+edit fallback.
351
+ // This UUID is local to the returned no-op SentMessage and is never sent
352
+ // to Relay or retained by the adapter.
353
+ id: crypto.randomUUID(),
354
+ raw: { chatId, message: null, noop: true },
355
+ threadId,
356
+ };
357
+ }
358
+ isMentioned(message) {
359
+ if (message.chat.is_group !== true)
360
+ return false;
361
+ const owner = message.chat.owner_handle;
362
+ if (!owner)
363
+ return false;
364
+ if (this.botUserId && owner.id !== this.botUserId)
365
+ return false;
366
+ return message.parts.some((part) => part.type === "text" &&
367
+ part.mention === owner.handle);
368
+ }
369
+ enterChatKind(chatId, isGroup) {
370
+ const counts = this.activeChatKinds.get(chatId) ?? {
371
+ direct: 0,
372
+ group: 0,
373
+ };
374
+ if (isGroup)
375
+ counts.group += 1;
376
+ else
377
+ counts.direct += 1;
378
+ this.activeChatKinds.set(chatId, counts);
379
+ return () => {
380
+ const current = this.activeChatKinds.get(chatId);
381
+ if (!current)
382
+ return;
383
+ if (isGroup)
384
+ current.group -= 1;
385
+ else
386
+ current.direct -= 1;
387
+ if (current.direct === 0 && current.group === 0) {
388
+ this.activeChatKinds.delete(chatId);
389
+ }
390
+ };
391
+ }
392
+ /**
393
+ * Chat SDK asks synchronously while dispatching. Relay's stable thread ID
394
+ * intentionally contains only the Chat UUID, so the direct/group hint is
395
+ * scoped to the active webhook dispatch and never persisted.
396
+ */
397
+ isDM(threadId) {
398
+ const { chatId } = this.decodeThreadId(threadId);
399
+ const counts = this.activeChatKinds.get(chatId);
400
+ return Boolean(counts && counts.direct > 0 && counts.group === 0);
401
+ }
402
+ async postMessage(threadId, message) {
403
+ return this.send(threadId, message);
404
+ }
405
+ async postChannelMessage(channelId, message) {
406
+ return this.send(this.channelIdFromThreadId(channelId), message);
407
+ }
408
+ async reply(threadId, messageId, message) {
409
+ assertRelayUuid(messageId, "messageId");
410
+ return this.send(threadId, message, messageId);
411
+ }
412
+ async editMessage(threadId, messageId, _message) {
413
+ this.decodeThreadId(threadId);
414
+ assertRelayUuid(messageId, "messageId");
415
+ return this.unsupported("editMessage", "Relay messages cannot be edited through the public API.");
416
+ }
417
+ async deleteMessage(threadId, messageId) {
418
+ this.decodeThreadId(threadId);
419
+ assertRelayUuid(messageId, "messageId");
420
+ this.unsupported("deleteMessage", "Relay messages cannot be deleted through the public API.");
421
+ }
422
+ async addReaction(threadId, messageId, emoji) {
423
+ this.decodeThreadId(threadId);
424
+ const reaction = toRelayReaction(emoji);
425
+ await this.client.react({
426
+ ...(reaction.customEmoji
427
+ ? { customEmoji: reaction.customEmoji }
428
+ : {}),
429
+ messageId,
430
+ operation: "add",
431
+ type: reaction.type,
432
+ });
433
+ }
434
+ async removeReaction(threadId, messageId, emoji) {
435
+ this.decodeThreadId(threadId);
436
+ const reaction = toRelayReaction(emoji);
437
+ await this.client.react({
438
+ ...(reaction.customEmoji
439
+ ? { customEmoji: reaction.customEmoji }
440
+ : {}),
441
+ messageId,
442
+ operation: "remove",
443
+ type: reaction.type,
444
+ });
445
+ }
446
+ async startTyping(threadId) {
447
+ const { chatId } = this.decodeThreadId(threadId);
448
+ if (!this.typing)
449
+ return;
450
+ await this.client.setTyping(chatId, true);
451
+ }
452
+ async endTyping(threadId) {
453
+ const { chatId } = this.decodeThreadId(threadId);
454
+ if (!this.typing)
455
+ return;
456
+ await this.client.setTyping(chatId, false);
457
+ }
458
+ async markAsRead(threadId, messageId) {
459
+ const { chatId } = this.decodeThreadId(threadId);
460
+ assertRelayUuid(messageId, "messageId");
461
+ await this.client.markChatRead(chatId);
462
+ }
463
+ /**
464
+ * Relay's public chat cursor advances oldest-to-newest. Chat SDK's backward
465
+ * cursor contract cannot be represented, so only explicit forward reads are
466
+ * supported rather than faking backward pagination.
467
+ */
468
+ async fetchMessages(threadId, options) {
469
+ if (options?.direction !== "forward") {
470
+ return this.unsupported("fetchMessages(backward)", "Relay chat history exposes only an opaque forward cursor.");
471
+ }
472
+ const { chatId } = this.decodeThreadId(threadId);
473
+ const limit = Math.min(100, Math.max(1, Math.trunc(options.limit ?? 50)));
474
+ const result = await this.client.getMessages({
475
+ chatId,
476
+ limit,
477
+ ...(options.cursor ? { cursor: options.cursor } : {}),
478
+ });
479
+ return {
480
+ messages: result.messages.map((message) => this.parseMessage({ chatId, message })),
481
+ ...(result.next_cursor
482
+ ? { nextCursor: result.next_cursor }
483
+ : {}),
484
+ };
485
+ }
486
+ async fetchChannelMessages(channelId, options) {
487
+ return this.fetchMessages(this.channelIdFromThreadId(channelId), options);
488
+ }
489
+ async fetchMessage(threadId, messageId) {
490
+ const { chatId } = this.decodeThreadId(threadId);
491
+ try {
492
+ const message = await this.client.getMessage(messageId);
493
+ if (message.chat_id !== chatId) {
494
+ throw new ValidationError("relay", "The requested Relay message belongs to a different chat");
495
+ }
496
+ return this.parseMessage({ chatId, message });
497
+ }
498
+ catch (error) {
499
+ if (error instanceof RelayApiError &&
500
+ error.status === 404) {
501
+ return null;
502
+ }
503
+ throw error;
504
+ }
505
+ }
506
+ async fetchThread(threadId) {
507
+ const { chatId } = this.decodeThreadId(threadId);
508
+ const chat = await this.client.getChat(chatId);
509
+ return {
510
+ channelId: threadId,
511
+ ...(chat.display_name
512
+ ? { channelName: chat.display_name }
513
+ : {}),
514
+ channelVisibility: "private",
515
+ id: threadId,
516
+ isDM: !chat.is_group,
517
+ metadata: {
518
+ groupChatIcon: chat.group_chat_icon ?? null,
519
+ handles: chat.handles,
520
+ isGroup: chat.is_group,
521
+ },
522
+ };
523
+ }
524
+ async fetchChannelInfo(channelId) {
525
+ const thread = await this.fetchThread(this.channelIdFromThreadId(channelId));
526
+ return {
527
+ channelVisibility: thread.channelVisibility,
528
+ id: thread.channelId,
529
+ isDM: thread.isDM,
530
+ metadata: thread.metadata,
531
+ ...(thread.channelName ? { name: thread.channelName } : {}),
532
+ };
533
+ }
534
+ getChannelVisibility() {
535
+ return "private";
536
+ }
537
+ async getUser(_userId) {
538
+ return this.unsupported("getUser", "Relay v1 has no public Contact lookup endpoint.");
539
+ }
540
+ async openDM(_userId) {
541
+ return this.unsupported("openDM", "Relay chat creation requires an initial message and cannot implement an open-only DM operation.");
542
+ }
543
+ async stream(threadId, stream) {
544
+ const chunks = [];
545
+ for await (const chunk of stream) {
546
+ if (typeof chunk === "string")
547
+ chunks.push(chunk);
548
+ else if (chunk.type === "markdown_text")
549
+ chunks.push(chunk.text);
550
+ // Task/plan updates are transient progress, not canonical message text.
551
+ }
552
+ const markdown = chunks.join("");
553
+ if (!markdown) {
554
+ const { chatId } = this.decodeThreadId(threadId);
555
+ return this.noopResult(chatId, threadId);
556
+ }
557
+ // Relay has no partial bubble or editable draft. Buffer first, then commit
558
+ // exactly one canonical Message through the normal send path.
559
+ return this.postMessage(threadId, { markdown });
560
+ }
561
+ async handleWebhook(request, options) {
562
+ if (request.method !== "POST") {
563
+ return json(405, {
564
+ error: { code: "method_not_allowed" },
565
+ });
566
+ }
567
+ if (!this.webhookSecret) {
568
+ throw new ValidationError("relay", "webhookSecret is required to receive Relay webhooks");
569
+ }
570
+ let payload;
571
+ try {
572
+ payload = await readWebhookBody(request);
573
+ }
574
+ catch (error) {
575
+ if (error instanceof RangeError) {
576
+ return json(413, {
577
+ error: { code: "payload_too_large" },
578
+ });
579
+ }
580
+ return json(400, {
581
+ error: { code: "invalid_utf8" },
582
+ });
583
+ }
584
+ try {
585
+ await verifyWebhookSignature({
586
+ headers: request.headers,
587
+ payload,
588
+ secret: this.webhookSecret,
589
+ options: {
590
+ ...(this.signatureToleranceSeconds !== undefined
591
+ ? {
592
+ toleranceSeconds: this.signatureToleranceSeconds,
593
+ }
594
+ : {}),
595
+ },
596
+ });
597
+ }
598
+ catch (error) {
599
+ if (error instanceof WebhookVerificationError) {
600
+ return json(401, {
601
+ error: { code: "invalid_signature" },
602
+ });
603
+ }
604
+ throw error;
605
+ }
606
+ let decoded;
607
+ try {
608
+ decoded = JSON.parse(payload);
609
+ }
610
+ catch {
611
+ return json(400, {
612
+ error: { code: "invalid_json" },
613
+ });
614
+ }
615
+ let envelope;
616
+ try {
617
+ envelope = parseWebhookEnvelope(decoded);
618
+ await this.turns.run(envelope.event_id, () => this.dispatch(envelope, options));
619
+ }
620
+ catch (error) {
621
+ if (error instanceof ValidationError) {
622
+ return json(422, {
623
+ error: { code: "invalid_event" },
624
+ });
625
+ }
626
+ throw error;
627
+ }
628
+ return json(200, {
629
+ acknowledged: true,
630
+ event_id: envelope.event_id,
631
+ event_type: envelope.event_type,
632
+ });
633
+ }
634
+ initializedChat() {
635
+ if (!this.chat) {
636
+ throw new Error("RelayAdapter must be initialized by Chat before dispatch");
637
+ }
638
+ return this.chat;
639
+ }
640
+ async dispatch(envelope, options) {
641
+ switch (envelope.event_type) {
642
+ case "message.received": {
643
+ const data = parseWebhookMessageEvent(envelope.data);
644
+ if (data.direction === "outbound" ||
645
+ data.sender_handle.is_me === true) {
646
+ return;
647
+ }
648
+ const raw = {
649
+ chatId: data.chat.id,
650
+ createdAt: envelope.created_at,
651
+ eventId: envelope.event_id,
652
+ eventType: envelope.event_type,
653
+ message: data,
654
+ };
655
+ const threadId = this.encodeThreadId({
656
+ chatId: data.chat.id,
657
+ });
658
+ const leaveChatKind = this.enterChatKind(data.chat.id, data.chat.is_group === true);
659
+ try {
660
+ await this.initializedChat().processMessage(this, threadId, this.parseMessage(raw), options);
661
+ }
662
+ finally {
663
+ leaveChatKind();
664
+ }
665
+ return;
666
+ }
667
+ case "message.sent":
668
+ case "message.read":
669
+ case "message.delivered":
670
+ parseWebhookMessageEvent(envelope.data);
671
+ return;
672
+ case "reaction.added":
673
+ case "reaction.removed": {
674
+ const data = parseReactionEvent(envelope.data);
675
+ if (data.is_from_me)
676
+ return;
677
+ const normalized = fromRelayReaction({
678
+ ...(data.custom_emoji
679
+ ? { customEmoji: data.custom_emoji }
680
+ : {}),
681
+ type: data.reaction_type,
682
+ });
683
+ const threadId = this.encodeThreadId({
684
+ chatId: data.chat_id,
685
+ });
686
+ this.initializedChat().processReaction({
687
+ adapter: this,
688
+ added: envelope.event_type === "reaction.added",
689
+ emoji: normalized.emoji,
690
+ messageId: data.message_id,
691
+ raw: envelope,
692
+ rawEmoji: normalized.rawEmoji,
693
+ threadId,
694
+ user: {
695
+ fullName: data.from_handle.display_name ??
696
+ data.from_handle.handle,
697
+ isBot: data.from_handle.kind === "agent",
698
+ isMe: false,
699
+ userId: data.from_handle.id,
700
+ userName: data.from_handle.handle,
701
+ },
702
+ }, options);
703
+ return;
704
+ }
705
+ case "participant.added":
706
+ case "participant.removed":
707
+ case "chat.created":
708
+ case "chat.group_name_updated":
709
+ case "chat.group_icon_updated":
710
+ case "chat.typing_indicator.started":
711
+ case "chat.typing_indicator.stopped":
712
+ case "contact.added":
713
+ case "contact.removed":
714
+ return;
715
+ default:
716
+ return assertExhaustiveEvent(envelope.event_type);
717
+ }
718
+ }
719
+ }
720
+ export function createRelayAdapter(options = {}) {
721
+ return new RelayAdapter(options);
722
+ }
723
+ //# sourceMappingURL=adapter.js.map