@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
@@ -1,266 +0,0 @@
1
- import { parseMarkdown, tableToAscii } from "chat";
2
- /** Relay accepts at most 200 style ranges on one text part. */
3
- export const MAX_STYLE_RANGES = 200;
4
- function sameStyles(left, right) {
5
- if (left.length !== right.length)
6
- return false;
7
- return left.every((style, index) => style === right[index]);
8
- }
9
- class TextBuilder {
10
- out = "";
11
- runs = [];
12
- get length() {
13
- return this.out.length;
14
- }
15
- append(value, styles) {
16
- if (!value)
17
- return;
18
- const start = this.out.length;
19
- this.out += value;
20
- if (styles.length > 0) {
21
- this.runs.push({ start, length: value.length, styles: [...styles] });
22
- }
23
- }
24
- /** Splice an already-rendered fragment, rebasing its style offsets. */
25
- appendRendered(rendered, styles) {
26
- if (!rendered.text)
27
- return;
28
- const base = this.out.length;
29
- this.out += rendered.text;
30
- if (styles.length > 0) {
31
- this.runs.push({
32
- start: base,
33
- length: rendered.text.length,
34
- styles: [...styles],
35
- });
36
- }
37
- for (const run of rendered.styles) {
38
- this.runs.push({
39
- start: base + run.start,
40
- length: run.length,
41
- styles: [...run.styles],
42
- });
43
- }
44
- }
45
- finish() {
46
- return { text: this.out, styles: normalizeStyles(this.runs) };
47
- }
48
- }
49
- /**
50
- * Collapse the raw run list into what Relay's contract requires: sorted by
51
- * start, non-overlapping, and no more than 200 entries. Nested emphasis
52
- * produces overlapping runs, so they are flattened onto a per-character style
53
- * set and then re-run-length-encoded.
54
- */
55
- export function normalizeStyles(runs) {
56
- if (runs.length === 0)
57
- return [];
58
- let end = 0;
59
- for (const run of runs)
60
- end = Math.max(end, run.start + run.length);
61
- const perCharacter = Array.from({ length: end }, () => []);
62
- for (const run of runs) {
63
- for (let i = run.start; i < run.start + run.length; i += 1) {
64
- const slot = perCharacter[i];
65
- if (!slot)
66
- continue;
67
- for (const style of run.styles) {
68
- if (!slot.includes(style))
69
- slot.push(style);
70
- }
71
- }
72
- }
73
- const merged = [];
74
- let index = 0;
75
- while (index < end) {
76
- const styles = perCharacter[index] ?? [];
77
- if (styles.length === 0) {
78
- index += 1;
79
- continue;
80
- }
81
- let span = index + 1;
82
- while (span < end && sameStyles(perCharacter[span] ?? [], styles))
83
- span += 1;
84
- merged.push({ start: index, length: span - index, styles: [...styles].sort() });
85
- index = span;
86
- }
87
- // Styles are presentation only, so an overflowing document keeps every word
88
- // and loses only the runs past Relay's ceiling.
89
- return merged.slice(0, MAX_STYLE_RANGES);
90
- }
91
- /**
92
- * Insert a prefix at the start of every line, remapping style offsets so the
93
- * runs still cover the same characters.
94
- */
95
- export function prefixLines(rendered, prefix, firstPrefix = prefix) {
96
- if (!rendered.text)
97
- return { text: firstPrefix, styles: [] };
98
- const map = new Array(rendered.text.length + 1);
99
- let out = firstPrefix;
100
- let atLineStart = false;
101
- for (let i = 0; i < rendered.text.length; i += 1) {
102
- if (atLineStart) {
103
- out += prefix;
104
- atLineStart = false;
105
- }
106
- map[i] = out.length;
107
- const character = rendered.text[i];
108
- out += character;
109
- if (character === "\n")
110
- atLineStart = true;
111
- }
112
- map[rendered.text.length] = out.length;
113
- const styles = rendered.styles.map((run) => {
114
- const start = map[run.start];
115
- const stop = map[run.start + run.length];
116
- return { start, length: stop - start, styles: run.styles };
117
- });
118
- return { text: out, styles };
119
- }
120
- function nodeText(node) {
121
- if ("value" in node && typeof node.value === "string")
122
- return node.value;
123
- if ("children" in node && Array.isArray(node.children)) {
124
- return node.children.map(nodeText).join("");
125
- }
126
- return "";
127
- }
128
- function renderInline(nodes, builder, styles) {
129
- for (const node of nodes) {
130
- switch (node.type) {
131
- case "text":
132
- builder.append(node.value, styles);
133
- break;
134
- case "strong":
135
- renderInline(node.children, builder, [...styles, "bold"]);
136
- break;
137
- case "emphasis":
138
- renderInline(node.children, builder, [...styles, "italic"]);
139
- break;
140
- case "delete":
141
- renderInline(node.children, builder, [
142
- ...styles,
143
- "strikethrough",
144
- ]);
145
- break;
146
- case "inlineCode":
147
- builder.append(node.value, [...styles, "monospace"]);
148
- break;
149
- case "break":
150
- builder.append("\n", []);
151
- break;
152
- case "link": {
153
- const label = nodeText(node);
154
- renderInline(node.children, builder, styles);
155
- // Relay has no link style, so the destination is written out rather
156
- // than dropped whenever the label does not already carry it.
157
- if (node.url && node.url !== label)
158
- builder.append(` (${node.url})`, []);
159
- break;
160
- }
161
- case "image": {
162
- const alt = node.alt ?? "";
163
- builder.append(alt ? `${alt} (${node.url})` : node.url, styles);
164
- break;
165
- }
166
- case "html":
167
- builder.append(node.value, styles);
168
- break;
169
- case "footnoteReference":
170
- builder.append(`[^${node.identifier}]`, styles);
171
- break;
172
- default: {
173
- if ("children" in node && Array.isArray(node.children)) {
174
- renderInline(node.children, builder, styles);
175
- }
176
- else if ("value" in node && typeof node.value === "string") {
177
- builder.append(node.value, styles);
178
- }
179
- }
180
- }
181
- }
182
- }
183
- function renderBlock(node) {
184
- switch (node.type) {
185
- case "paragraph": {
186
- const builder = new TextBuilder();
187
- renderInline(node.children, builder, []);
188
- return builder.finish();
189
- }
190
- case "heading": {
191
- const builder = new TextBuilder();
192
- renderInline(node.children, builder, ["bold"]);
193
- return builder.finish();
194
- }
195
- case "code": {
196
- const builder = new TextBuilder();
197
- builder.append(node.value, ["monospace"]);
198
- return builder.finish();
199
- }
200
- case "blockquote": {
201
- const inner = renderBlocks(node.children);
202
- return prefixLines(inner, "> ");
203
- }
204
- case "list": {
205
- const parts = [];
206
- let counter = node.start ?? 1;
207
- for (const item of node.children) {
208
- const marker = node.ordered ? `${counter}. ` : "- ";
209
- counter += 1;
210
- const inner = item.type === "listItem"
211
- ? renderBlocks(item.children)
212
- : renderBlock(item);
213
- parts.push(prefixLines(inner, " ".repeat(marker.length), marker));
214
- }
215
- return joinRendered(parts, "\n");
216
- }
217
- case "table":
218
- return { text: tableToAscii(node), styles: [] };
219
- case "thematicBreak":
220
- return { text: "---", styles: [] };
221
- case "html":
222
- return { text: node.value, styles: [] };
223
- default: {
224
- const builder = new TextBuilder();
225
- if ("children" in node && Array.isArray(node.children)) {
226
- renderInline(node.children, builder, []);
227
- }
228
- else if ("value" in node && typeof node.value === "string") {
229
- builder.append(node.value, []);
230
- }
231
- return builder.finish();
232
- }
233
- }
234
- }
235
- function joinRendered(parts, separator) {
236
- const builder = new TextBuilder();
237
- let first = true;
238
- for (const part of parts) {
239
- if (!part.text)
240
- continue;
241
- if (!first)
242
- builder.append(separator, []);
243
- builder.appendRendered(part, []);
244
- first = false;
245
- }
246
- return builder.finish();
247
- }
248
- function renderBlocks(nodes) {
249
- return joinRendered(nodes.map(renderBlock), "\n\n");
250
- }
251
- /** Flatten an mdast tree into Relay text plus style runs. */
252
- export function renderAst(ast) {
253
- return renderBlocks(ast.children);
254
- }
255
- /** Flatten a Markdown string into Relay text plus style runs. */
256
- export function renderMarkdown(markdown) {
257
- return renderAst(parseMarkdown(markdown));
258
- }
259
- /**
260
- * Text a caller supplied verbatim. The empty `styles` array is meaningful to
261
- * Relay: it marks the part as structured plain text so no client tries to read
262
- * it as a legacy Markdown body.
263
- */
264
- export function renderRawText(value) {
265
- return { text: value, styles: [] };
266
- }
@@ -1,50 +0,0 @@
1
- /**
2
- * Deterministic `Idempotency-Key` derivation for outbound sends, and the
3
- * `event_id` window that keeps at-least-once delivery from dispatching the
4
- * same inbound event twice.
5
- */
6
- /**
7
- * Key one outbound send against the inbound event that caused it: the event id
8
- * and the send's position in the turn, and nothing else.
9
- *
10
- * The content is deliberately not in the key. Relay hashes the whole request
11
- * server side, stores that hash beside the key, replays the stored response
12
- * when a retry carries the same hash, and answers 409 `idempotency_conflict`
13
- * when it does not. Folding the content into the key would change the key
14
- * whenever the body changed, so the conflict could never fire and a handler
15
- * whose model wrote different words the second time would post a genuine
16
- * second message to the person. A key that names only the position is what
17
- * lets Relay replay a faithful retry and refuse a diverging one.
18
- *
19
- * The prefix keeps the key at or above the 8 character floor even for an empty
20
- * event id, and the event id is bounded so the whole key stays under 255.
21
- */
22
- export declare function deriveIdempotencyKey(eventId: string, ordinal: number): string;
23
- /**
24
- * Key a send that no inbound event caused, such as a proactive session opened
25
- * against a stored thread. There is no event to key against and no way to tell
26
- * a deliberate repeat of the same words from a retry, so the key is unique per
27
- * call: it makes the request well formed without claiming a replay guarantee
28
- * the caller cannot have.
29
- */
30
- export declare function unkeyedIdempotencyKey(conversationId: string): string;
31
- /**
32
- * Bounded insertion-ordered set of handled `event_id` values.
33
- *
34
- * `claim` is the only way in, and it both tests and inserts with nothing
35
- * awaited in between, so two redeliveries of one event racing each other in
36
- * the same process cannot both win. The window lives in memory in one process:
37
- * a restart, or a second instance behind the same webhook URL, has no claim to
38
- * lose and will dispatch the event again. The `Idempotency-Key` on every send
39
- * is what makes that second dispatch harmless.
40
- */
41
- export declare class DedupeWindow {
42
- private readonly capacity;
43
- private readonly seen;
44
- constructor(capacity: number);
45
- has(id: string): boolean;
46
- /** Take the event id. Answers false when it was already taken. */
47
- claim(id: string): boolean;
48
- /** Give the event id back, so a later delivery of it is dispatched. */
49
- release(id: string): void;
50
- }
@@ -1,75 +0,0 @@
1
- /**
2
- * Deterministic `Idempotency-Key` derivation for outbound sends, and the
3
- * `event_id` window that keeps at-least-once delivery from dispatching the
4
- * same inbound event twice.
5
- */
6
- const KEY_PREFIX = "relay:";
7
- /** Relay requires 8 to 255 characters on the `Idempotency-Key` header. */
8
- const MAX_KEY_LENGTH = 255;
9
- /**
10
- * Key one outbound send against the inbound event that caused it: the event id
11
- * and the send's position in the turn, and nothing else.
12
- *
13
- * The content is deliberately not in the key. Relay hashes the whole request
14
- * server side, stores that hash beside the key, replays the stored response
15
- * when a retry carries the same hash, and answers 409 `idempotency_conflict`
16
- * when it does not. Folding the content into the key would change the key
17
- * whenever the body changed, so the conflict could never fire and a handler
18
- * whose model wrote different words the second time would post a genuine
19
- * second message to the person. A key that names only the position is what
20
- * lets Relay replay a faithful retry and refuse a diverging one.
21
- *
22
- * The prefix keeps the key at or above the 8 character floor even for an empty
23
- * event id, and the event id is bounded so the whole key stays under 255.
24
- */
25
- export function deriveIdempotencyKey(eventId, ordinal) {
26
- const suffix = `:${ordinal}`;
27
- const room = MAX_KEY_LENGTH - KEY_PREFIX.length - suffix.length;
28
- return `${KEY_PREFIX}${eventId.slice(0, room)}${suffix}`;
29
- }
30
- /**
31
- * Key a send that no inbound event caused, such as a proactive session opened
32
- * against a stored thread. There is no event to key against and no way to tell
33
- * a deliberate repeat of the same words from a retry, so the key is unique per
34
- * call: it makes the request well formed without claiming a replay guarantee
35
- * the caller cannot have.
36
- */
37
- export function unkeyedIdempotencyKey(conversationId) {
38
- return `${KEY_PREFIX}${conversationId}:${crypto.randomUUID()}`;
39
- }
40
- /**
41
- * Bounded insertion-ordered set of handled `event_id` values.
42
- *
43
- * `claim` is the only way in, and it both tests and inserts with nothing
44
- * awaited in between, so two redeliveries of one event racing each other in
45
- * the same process cannot both win. The window lives in memory in one process:
46
- * a restart, or a second instance behind the same webhook URL, has no claim to
47
- * lose and will dispatch the event again. The `Idempotency-Key` on every send
48
- * is what makes that second dispatch harmless.
49
- */
50
- export class DedupeWindow {
51
- capacity;
52
- seen = new Set();
53
- constructor(capacity) {
54
- this.capacity = capacity;
55
- }
56
- has(id) {
57
- return this.seen.has(id);
58
- }
59
- /** Take the event id. Answers false when it was already taken. */
60
- claim(id) {
61
- if (this.seen.has(id))
62
- return false;
63
- this.seen.add(id);
64
- if (this.seen.size > this.capacity) {
65
- const oldest = this.seen.values().next().value;
66
- if (oldest !== undefined)
67
- this.seen.delete(oldest);
68
- }
69
- return true;
70
- }
71
- /** Give the event id back, so a later delivery of it is dispatched. */
72
- release(id) {
73
- this.seen.delete(id);
74
- }
75
- }
@@ -1,14 +0,0 @@
1
- export { createRelayAdapter, RelayAdapter, RelayInvocationSpentError, RELAY_ADAPTER_NAME, } from "./adapter.js";
2
- export type { RelayAdapterOptions } from "./adapter.js";
3
- export { RelayApiError, RelayClient } from "./client.js";
4
- export type { RelayClientOptions, RelayHistoryOptions, RelayReactionOptions, RelaySendOptions, RelayUploadOptions, } from "./client.js";
5
- export { MAX_PARTS_PER_MESSAGE, MAX_TEXT_PART_BYTES, chunkRenderedText, utf8Length, } from "./chunk.js";
6
- export { MAX_STYLE_RANGES, renderAst, renderMarkdown, renderRawText, } from "./format.js";
7
- export type { RenderedText } from "./format.js";
8
- export { DedupeWindow, deriveIdempotencyKey, unkeyedIdempotencyKey, } from "./idempotency.js";
9
- export { toRelayReaction } from "./reactions.js";
10
- export type { RelayReaction } from "./reactions.js";
11
- export { decodeWebhookSecret, verifyWebhookSignature, WebhookSecretError, WebhookVerificationError, } from "./signature.js";
12
- export type { VerifyOptions } from "./signature.js";
13
- export { decodeRelayThreadId, encodeRelayThreadId, RELAY_THREAD_PREFIX, relayChannelIdFromThreadId, } from "./threadId.js";
14
- export type * from "./types.js";
package/dist/src/index.js DELETED
@@ -1,8 +0,0 @@
1
- export { createRelayAdapter, RelayAdapter, RelayInvocationSpentError, RELAY_ADAPTER_NAME, } from "./adapter.js";
2
- export { RelayApiError, RelayClient } from "./client.js";
3
- export { MAX_PARTS_PER_MESSAGE, MAX_TEXT_PART_BYTES, chunkRenderedText, utf8Length, } from "./chunk.js";
4
- export { MAX_STYLE_RANGES, renderAst, renderMarkdown, renderRawText, } from "./format.js";
5
- export { DedupeWindow, deriveIdempotencyKey, unkeyedIdempotencyKey, } from "./idempotency.js";
6
- export { toRelayReaction } from "./reactions.js";
7
- export { decodeWebhookSecret, verifyWebhookSignature, WebhookSecretError, WebhookVerificationError, } from "./signature.js";
8
- export { decodeRelayThreadId, encodeRelayThreadId, RELAY_THREAD_PREFIX, relayChannelIdFromThreadId, } from "./threadId.js";
@@ -1,12 +0,0 @@
1
- import type { EmojiValue } from "chat";
2
- import type { RelayReactionType } from "./types.js";
3
- export interface RelayReaction {
4
- type: RelayReactionType;
5
- emoji?: string;
6
- }
7
- /**
8
- * Resolve a Chat SDK reaction into Relay's `{ type, emoji }` pair. Accepts an
9
- * `EmojiValue`, a `:shortcode:`, a bare well-known name, the `{{emoji:name}}`
10
- * placeholder an `EmojiValue` stringifies to, or a literal character.
11
- */
12
- export declare function toRelayReaction(input: EmojiValue | string): RelayReaction;
@@ -1,98 +0,0 @@
1
- import { DEFAULT_EMOJI_MAP } from "chat";
2
- /**
3
- * A Relay reaction is an emoji character. Relay does render it as an
4
- * iMessage-style balloon, but the balloon draws whatever string is stored, so
5
- * the character is what has to be sent. Relay once also accepted six named
6
- * types (love, like, dislike, laugh, emphasize, question) and stored the name
7
- * itself in the emoji column, which put the word "like" inside the balloon.
8
- * Those names are gone from the API and this adapter never produces one.
9
- */
10
- const PLACEHOLDER = /^\{\{emoji:([^}]+)\}\}$/;
11
- const SHORTCODE = /^:([a-z0-9_+-]+):$/i;
12
- /** Variation selector 16 and the five skin tone modifiers. */
13
- const DECORATION = /[️\u{1F3FB}-\u{1F3FF}]/gu;
14
- function firstFormat(value) {
15
- return Array.isArray(value) ? value[0] : value;
16
- }
17
- function bare(value) {
18
- return value.replace(DECORATION, "");
19
- }
20
- let literalIndex;
21
- /** Lazily invert the emoji map so a literal character resolves to its name. */
22
- function nameForLiteral(literal) {
23
- if (!literalIndex) {
24
- literalIndex = new Map();
25
- for (const [name, formats] of Object.entries(DEFAULT_EMOJI_MAP)) {
26
- const unicode = Array.isArray(formats.gchat) ? formats.gchat : [formats.gchat];
27
- for (const candidate of unicode) {
28
- const key = bare(candidate);
29
- if (!literalIndex.has(key))
30
- literalIndex.set(key, name);
31
- }
32
- }
33
- }
34
- return literalIndex.get(bare(literal));
35
- }
36
- let slackIndex;
37
- function nameForAlias(alias) {
38
- if (!slackIndex) {
39
- slackIndex = new Map();
40
- for (const [name, formats] of Object.entries(DEFAULT_EMOJI_MAP)) {
41
- const aliases = Array.isArray(formats.slack) ? formats.slack : [formats.slack];
42
- for (const candidate of aliases) {
43
- if (!slackIndex.has(candidate))
44
- slackIndex.set(candidate, name);
45
- }
46
- }
47
- }
48
- return slackIndex.get(alias);
49
- }
50
- function isEmojiValue(value) {
51
- return typeof value === "object" && value !== null && "name" in value;
52
- }
53
- /**
54
- * Resolve a Chat SDK reaction into Relay's `{ type, emoji }` pair. Accepts an
55
- * `EmojiValue`, a `:shortcode:`, a bare well-known name, the `{{emoji:name}}`
56
- * placeholder an `EmojiValue` stringifies to, or a literal character.
57
- */
58
- export function toRelayReaction(input) {
59
- let name;
60
- let literal;
61
- if (isEmojiValue(input)) {
62
- name = input.name;
63
- }
64
- else {
65
- const trimmed = input.trim();
66
- const placeholder = PLACEHOLDER.exec(trimmed);
67
- const shortcode = SHORTCODE.exec(trimmed);
68
- const candidate = placeholder?.[1] ?? shortcode?.[1] ?? trimmed;
69
- if (candidate in DEFAULT_EMOJI_MAP) {
70
- name = candidate;
71
- }
72
- else if (nameForAlias(candidate)) {
73
- name = nameForAlias(candidate);
74
- }
75
- else if (placeholder || shortcode) {
76
- throw new Error(`unknown emoji name for a Relay reaction: ${trimmed}`);
77
- }
78
- else {
79
- literal = trimmed;
80
- name = nameForLiteral(trimmed);
81
- }
82
- }
83
- // One normalization, on both the add and the remove path, because Relay
84
- // deletes a reaction by exact string match
85
- // (`Relay-Server/server/src/routes/platform.ts:571-572`). Without it,
86
- // `addReaction("👍🏽")` followed by `removeReaction("👍")` answers 200 and
87
- // deletes nothing. A known character resolves to the map's canonical form,
88
- // which keeps a variation selector where the canonical form has one; an
89
- // unknown character keeps its own bare form.
90
- if (name) {
91
- const formats = DEFAULT_EMOJI_MAP[name];
92
- if (formats)
93
- return { type: "emoji", emoji: firstFormat(formats.gchat) };
94
- }
95
- if (literal)
96
- return { type: "emoji", emoji: bare(literal) };
97
- throw new Error("a Relay reaction needs an emoji character or a well-known emoji name");
98
- }
@@ -1,46 +0,0 @@
1
- export interface VerifyOptions {
2
- /** Maximum allowed clock skew for `webhook-timestamp`, in seconds. */
3
- toleranceSeconds?: number;
4
- /** Override "now" for verification (seconds since epoch). Test hook. */
5
- nowSeconds?: number;
6
- }
7
- export declare class WebhookVerificationError extends Error {
8
- constructor(message: string);
9
- }
10
- /**
11
- * The configured signing secret is unusable. This is a deployment mistake, not
12
- * a bad delivery, so it is a separate error: catch it where the secret is
13
- * configured and fail there.
14
- */
15
- export declare class WebhookSecretError extends Error {
16
- constructor(message: string);
17
- }
18
- /**
19
- * Decode the signing secret Relay issued, with or without its `whsec_` prefix.
20
- *
21
- * `atob` answers a bare `InvalidCharacterError` on a secret that is not
22
- * base64, which no caller recognizes: it escapes signature verification, the
23
- * mount answers 500, and Relay reads that as a transient failure and redelivers
24
- * ten times. Name it as a `WebhookSecretError` so it can be caught once, where
25
- * the secret is configured, instead of on every delivery.
26
- */
27
- export declare function decodeWebhookSecret(secret: string): Uint8Array<ArrayBuffer>;
28
- /**
29
- * Verify a Standard Webhooks signature exactly as Relay signs deliveries:
30
- * HMAC-SHA256 over `${webhook-id}.${webhook-timestamp}.${raw body}` with the
31
- * base64 secret, compared against every `v1,` candidate in
32
- * `webhook-signature` (rotation sends two). Throws `WebhookVerificationError`
33
- * on any failure, and `WebhookSecretError` when the configured secret itself
34
- * is unusable. Uses only Web platform APIs (`crypto.subtle`, `atob`), so it
35
- * runs on Node 22+, Vercel Edge, and Cloudflare Workers unchanged.
36
- */
37
- export declare function verifyWebhookSignature(input: {
38
- secret: string;
39
- payload: string;
40
- headers: {
41
- "webhook-id": string | null | undefined;
42
- "webhook-timestamp": string | null | undefined;
43
- "webhook-signature": string | null | undefined;
44
- };
45
- options?: VerifyOptions;
46
- }): Promise<void>;
@@ -1,95 +0,0 @@
1
- export class WebhookVerificationError extends Error {
2
- constructor(message) {
3
- super(message);
4
- this.name = "WebhookVerificationError";
5
- }
6
- }
7
- /**
8
- * The configured signing secret is unusable. This is a deployment mistake, not
9
- * a bad delivery, so it is a separate error: catch it where the secret is
10
- * configured and fail there.
11
- */
12
- export class WebhookSecretError extends Error {
13
- constructor(message) {
14
- super(message);
15
- this.name = "WebhookSecretError";
16
- }
17
- }
18
- const DEFAULT_TOLERANCE_SECONDS = 5 * 60;
19
- function base64ToBytes(value) {
20
- const binary = atob(value);
21
- const bytes = new Uint8Array(binary.length);
22
- for (let i = 0; i < binary.length; i += 1)
23
- bytes[i] = binary.charCodeAt(i);
24
- return bytes;
25
- }
26
- /**
27
- * Decode the signing secret Relay issued, with or without its `whsec_` prefix.
28
- *
29
- * `atob` answers a bare `InvalidCharacterError` on a secret that is not
30
- * base64, which no caller recognizes: it escapes signature verification, the
31
- * mount answers 500, and Relay reads that as a transient failure and redelivers
32
- * ten times. Name it as a `WebhookSecretError` so it can be caught once, where
33
- * the secret is configured, instead of on every delivery.
34
- */
35
- export function decodeWebhookSecret(secret) {
36
- const raw = secret.startsWith("whsec_") ? secret.slice("whsec_".length) : secret;
37
- try {
38
- return base64ToBytes(raw);
39
- }
40
- catch {
41
- throw new WebhookSecretError("the webhook signing secret is not base64: pass the whsec_ value Relay issued");
42
- }
43
- }
44
- function constantTimeEqual(a, b) {
45
- if (a.length !== b.length)
46
- return false;
47
- let diff = 0;
48
- for (let i = 0; i < a.length; i += 1)
49
- diff |= a[i] ^ b[i];
50
- return diff === 0;
51
- }
52
- /**
53
- * Verify a Standard Webhooks signature exactly as Relay signs deliveries:
54
- * HMAC-SHA256 over `${webhook-id}.${webhook-timestamp}.${raw body}` with the
55
- * base64 secret, compared against every `v1,` candidate in
56
- * `webhook-signature` (rotation sends two). Throws `WebhookVerificationError`
57
- * on any failure, and `WebhookSecretError` when the configured secret itself
58
- * is unusable. Uses only Web platform APIs (`crypto.subtle`, `atob`), so it
59
- * runs on Node 22+, Vercel Edge, and Cloudflare Workers unchanged.
60
- */
61
- export async function verifyWebhookSignature(input) {
62
- const id = input.headers["webhook-id"];
63
- const timestamp = input.headers["webhook-timestamp"];
64
- const signatureHeader = input.headers["webhook-signature"];
65
- if (!id || !timestamp || !signatureHeader) {
66
- throw new WebhookVerificationError("missing webhook signature headers");
67
- }
68
- const tolerance = input.options?.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS;
69
- const now = input.options?.nowSeconds ?? Math.floor(Date.now() / 1000);
70
- const ts = Number(timestamp);
71
- if (!Number.isFinite(ts)) {
72
- throw new WebhookVerificationError("invalid webhook-timestamp");
73
- }
74
- if (Math.abs(now - ts) > tolerance) {
75
- throw new WebhookVerificationError("webhook-timestamp outside tolerance");
76
- }
77
- const key = await crypto.subtle.importKey("raw", decodeWebhookSecret(input.secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
78
- const signedContent = `${id}.${timestamp}.${input.payload}`;
79
- const expected = new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(signedContent)));
80
- for (const candidate of signatureHeader.split(" ")) {
81
- const [version, value] = candidate.split(",", 2);
82
- if (version !== "v1" || !value)
83
- continue;
84
- let provided;
85
- try {
86
- provided = base64ToBytes(value);
87
- }
88
- catch {
89
- continue;
90
- }
91
- if (constantTimeEqual(expected, provided))
92
- return;
93
- }
94
- throw new WebhookVerificationError("no matching v1 signature");
95
- }
@@ -1,12 +0,0 @@
1
- import type { RelayThreadId } from "./types.js";
2
- /**
3
- * Relay thread ids are `relay:{conversation_id}`. A Relay conversation has no
4
- * enclosing channel, so the channel id is the same string. Both are written
5
- * out here rather than left to the Chat SDK defaults, because the default
6
- * channel derivation keeps the first two colon-separated segments and that
7
- * only happens to be correct for this shape.
8
- */
9
- export declare const RELAY_THREAD_PREFIX = "relay:";
10
- export declare function encodeRelayThreadId(platformData: RelayThreadId): string;
11
- export declare function decodeRelayThreadId(threadId: string): RelayThreadId;
12
- export declare function relayChannelIdFromThreadId(threadId: string): string;