@north-light/comms 0.0.0-stage → 0.1.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,356 @@
1
+ // The fake server's in-memory state and rules: contacts, messages, idempotency, and the
2
+ // STOP / START / HELP keywords. No I/O: every operation returns the webhook events and logged
3
+ // sends it produced, and server.ts delivers and prints them.
4
+ import { randomUUID } from "node:crypto";
5
+ import { MAX_TEXT_LENGTH } from "../helpers/format.js";
6
+ /** The fictional app number every fake contact texts. */
7
+ export const FAKE_NUMBER = "+15555550100";
8
+ /** The keyword sets of the spec's "STOP, START, HELP" table. */
9
+ const STOP_WORDS = new Set([
10
+ "stop",
11
+ "end",
12
+ "unsubscribe",
13
+ "cancel",
14
+ "quit",
15
+ "stopall",
16
+ ]);
17
+ const START_WORDS = new Set(["start", "unstop", "yes"]);
18
+ const HELP_WORDS = new Set(["help", "info"]);
19
+ const E164 = /^\+[1-9]\d{6,14}$/;
20
+ const EMAIL = /^[^\s@]+@[^\s@]+$/;
21
+ const SIGN_IN_LINK_TTL_MS = 15 * 60_000;
22
+ /** A refusal the server answers in the shared error envelope. */
23
+ export class FakeApiError extends Error {
24
+ status;
25
+ code;
26
+ param;
27
+ name = "FakeApiError";
28
+ constructor(status, code, message, param) {
29
+ super(message);
30
+ this.status = status;
31
+ this.code = code;
32
+ this.param = param;
33
+ }
34
+ }
35
+ const id = (prefix) => `${prefix}_${randomUUID()}`;
36
+ function body(value) {
37
+ if (typeof value !== "object" || value === null || Array.isArray(value))
38
+ throw new FakeApiError(400, "invalid_request", "The request body must be a JSON object");
39
+ return value;
40
+ }
41
+ function text(source, key, options = {}) {
42
+ const value = source[key];
43
+ if (value === undefined && options.optional)
44
+ return undefined;
45
+ if (typeof value !== "string" || value === "")
46
+ throw new FakeApiError(400, "invalid_request", `\`${key}\` must be a non-empty string`, key);
47
+ return value;
48
+ }
49
+ const bareUser = (userId) => userId.replace(/^user:/, "");
50
+ export class FakeStore {
51
+ #contacts = new Map();
52
+ #byHandle = new Map();
53
+ #messages = new Map();
54
+ #replays = new Map();
55
+ #links = new Map();
56
+ #displayName;
57
+ #helpText;
58
+ #now;
59
+ #users = 0;
60
+ constructor(options = {}) {
61
+ this.#displayName = options.displayName ?? "Fake App";
62
+ this.#helpText =
63
+ options.helpText ??
64
+ "This is the fake Comms server. Text STOP to opt out.";
65
+ this.#now = options.now ?? (() => new Date());
66
+ }
67
+ // ---- Simulated inbound ----------------------------------------------------
68
+ /** A person texts the app's fictional number. `userId` makes the contact a Northlight user. */
69
+ inbound(input) {
70
+ const source = body(input);
71
+ const from = text(source, "from");
72
+ if (!E164.test(from) && !EMAIL.test(from))
73
+ throw new FakeApiError(400, "invalid_request", "`from` must be an E.164 phone number such as +15550001, or an email", "from");
74
+ const message = text(source, "text");
75
+ const userId = text(source, "user_id", { optional: true });
76
+ const contact = this.#contactFor(from);
77
+ if (userId !== undefined)
78
+ contact.user = { id: `user:${bareUser(userId)}` };
79
+ const messageId = id("msg");
80
+ const word = message.trim().toLowerCase();
81
+ const result = (disposition) => ({
82
+ disposition,
83
+ messageId,
84
+ contactId: contact.id,
85
+ events: [],
86
+ sends: [],
87
+ });
88
+ const reply = (result_, reply_) => {
89
+ result_.sends.push(this.#system(contact, reply_));
90
+ };
91
+ if (STOP_WORDS.has(word)) {
92
+ contact.optedOut = true;
93
+ const out = result("stop");
94
+ reply(out, `${this.#displayName}: you won't get more texts from us. Text START to turn them back on.`);
95
+ out.events.push(this.#event("contact.opted_out", { contact_id: contact.id }));
96
+ return out;
97
+ }
98
+ if (contact.optedOut && START_WORDS.has(word)) {
99
+ contact.optedOut = false;
100
+ const out = result("start");
101
+ reply(out, `${this.#displayName}: texts are back on. Text STOP to stop.`);
102
+ out.events.push(this.#event("contact.opted_in", { contact_id: contact.id }));
103
+ return out;
104
+ }
105
+ if (HELP_WORDS.has(word)) {
106
+ const out = result("help");
107
+ reply(out, this.#helpText);
108
+ return out;
109
+ }
110
+ if (contact.optedOut)
111
+ return result("opted_out_ignored");
112
+ this.#remember(messageId, contact.id, "text", "delivered");
113
+ const out = result("forwarded");
114
+ out.events.push(this.#event("message.received", {
115
+ message_id: messageId,
116
+ number: FAKE_NUMBER,
117
+ contact: this.#webhookContact(contact),
118
+ content: { type: "text", text: message },
119
+ reply_to_message_id: null,
120
+ received_at: this.#now().toISOString(),
121
+ }));
122
+ return out;
123
+ }
124
+ // ---- Send API -------------------------------------------------------------
125
+ sendMessage(input) {
126
+ const source = body(input);
127
+ const key = text(source, "idempotency_key");
128
+ const to = body(source["to"]);
129
+ const message = text(source, "text", { optional: true });
130
+ const file = source["file"] === undefined ? undefined : body(source["file"]);
131
+ const fileUrl = file ? text(file, "url") : undefined;
132
+ text(source, "reply_to_message_id", { optional: true });
133
+ if (message === undefined && fileUrl === undefined)
134
+ throw new FakeApiError(400, "invalid_request", "`text` or `file` is required", "text");
135
+ if (message !== undefined && message.length > MAX_TEXT_LENGTH)
136
+ throw new FakeApiError(400, "invalid_request", `\`text\` is at most ${MAX_TEXT_LENGTH} characters`, "text");
137
+ const fingerprint = {
138
+ to,
139
+ text: message,
140
+ file,
141
+ reply_to_message_id: source["reply_to_message_id"],
142
+ };
143
+ const replayed = this.#replay(key, "message", fingerprint);
144
+ if (replayed)
145
+ return { answer: replayed };
146
+ const contact = this.#recipient(to);
147
+ if (contact.optedOut)
148
+ throw new FakeApiError(403, "opted_out", "This contact sent STOP and cannot be texted");
149
+ const messageId = id("msg");
150
+ this.#remember(messageId, contact.id, fileUrl === undefined ? "text" : "attachment", "sent");
151
+ const answer = { message_id: messageId, status: "queued" };
152
+ this.#accept(key, "message", fingerprint, answer);
153
+ return {
154
+ answer,
155
+ send: {
156
+ messageId,
157
+ contactId: contact.id,
158
+ handle: contact.handle,
159
+ kind: "message",
160
+ ...(message === undefined ? {} : { text: message }),
161
+ ...(fileUrl === undefined ? {} : { fileUrl }),
162
+ },
163
+ };
164
+ }
165
+ sendReaction(targetMessageId, input) {
166
+ const source = body(input);
167
+ const key = text(source, "idempotency_key");
168
+ const reaction = text(source, "reaction");
169
+ const kind = `reaction:${targetMessageId}`;
170
+ const replayed = this.#replay(key, kind, { reaction });
171
+ if (replayed)
172
+ return { answer: replayed };
173
+ const target = this.#messages.get(targetMessageId);
174
+ if (!target)
175
+ throw new FakeApiError(404, "message_not_found", "Unknown message");
176
+ if (target.kind === "reaction")
177
+ throw new FakeApiError(422, "invalid_target", "A reaction cannot be the target of a reaction");
178
+ const contact = this.#contacts.get(target.contactId);
179
+ if (contact.optedOut)
180
+ throw new FakeApiError(403, "opted_out", "This contact sent STOP and cannot be texted");
181
+ const messageId = id("msg");
182
+ this.#remember(messageId, contact.id, "reaction", "sent");
183
+ const answer = { message_id: messageId, status: "queued" };
184
+ this.#accept(key, kind, { reaction }, answer);
185
+ return {
186
+ answer,
187
+ send: {
188
+ messageId,
189
+ contactId: contact.id,
190
+ handle: contact.handle,
191
+ kind: "reaction",
192
+ reaction,
193
+ targetMessageId,
194
+ },
195
+ };
196
+ }
197
+ getMessage(messageId) {
198
+ const message = this.#messages.get(messageId);
199
+ if (!message)
200
+ throw new FakeApiError(404, "message_not_found", "Unknown message");
201
+ return {
202
+ message_id: message.id,
203
+ contact_id: message.contactId,
204
+ status: message.status,
205
+ reason: null,
206
+ created_at: message.createdAt,
207
+ };
208
+ }
209
+ getContact(contactId) {
210
+ const contact = this.#contact(contactId);
211
+ return {
212
+ id: contact.id,
213
+ user: contact.user,
214
+ handle_kind: contact.handleKind,
215
+ handle: contact.handle,
216
+ opted_out: contact.optedOut,
217
+ number: FAKE_NUMBER,
218
+ created_at: contact.createdAt,
219
+ };
220
+ }
221
+ setTyping(contactId, input) {
222
+ this.#contact(contactId);
223
+ if (typeof body(input)["on"] !== "boolean")
224
+ throw new FakeApiError(400, "invalid_request", "`on` must be a boolean", "on");
225
+ }
226
+ publicNumber() {
227
+ return FAKE_NUMBER;
228
+ }
229
+ signInLink(contactId, input, origin) {
230
+ const contact = this.#contact(contactId);
231
+ const source = body(input);
232
+ const returnTo = text(source, "return_to", { optional: true });
233
+ if (returnTo !== undefined && !URL.canParse(returnTo))
234
+ throw new FakeApiError(400, "invalid_request", "`return_to` must be an absolute URL", "return_to");
235
+ if (contact.user)
236
+ throw new FakeApiError(409, "already_user", "This contact is a user");
237
+ if (contact.handleKind === "email")
238
+ throw new FakeApiError(409, "no_phone", "This contact has an email handle, not a phone number");
239
+ if (contact.optedOut)
240
+ throw new FakeApiError(403, "opted_out", "This contact sent STOP and cannot be texted");
241
+ const token = randomUUID();
242
+ const expiresAt = new Date(this.#now().getTime() + SIGN_IN_LINK_TTL_MS);
243
+ this.#links.set(token, { contactId, returnTo, expiresAt });
244
+ return {
245
+ url: `${origin}/_fake/sign-in/${token}`,
246
+ expires_at: expiresAt.toISOString(),
247
+ };
248
+ }
249
+ /** Pressing a fake sign-in link links the contact to a fake user. */
250
+ consumeSignInLink(token) {
251
+ const link = this.#links.get(token);
252
+ if (!link || link.expiresAt < this.#now())
253
+ throw new FakeApiError(404, "not_found", "Unknown or expired link");
254
+ this.#links.delete(token);
255
+ const contact = this.#contact(link.contactId);
256
+ contact.user ??= { id: `user:fake-${++this.#users}` };
257
+ return {
258
+ returnTo: link.returnTo,
259
+ event: this.#event("contact.user_linked", {
260
+ contact_id: contact.id,
261
+ user: contact.user,
262
+ }),
263
+ };
264
+ }
265
+ // ---- Internals ------------------------------------------------------------
266
+ #contact(contactId) {
267
+ const contact = this.#contacts.get(contactId);
268
+ if (!contact)
269
+ throw new FakeApiError(404, "contact_not_found", "Unknown contact");
270
+ return contact;
271
+ }
272
+ #recipient(to) {
273
+ const contactId = to["contact_id"];
274
+ const userId = to["user_id"];
275
+ if ((contactId === undefined) === (userId === undefined))
276
+ throw new FakeApiError(400, "invalid_request", "`to` needs exactly one of `contact_id` or `user_id`", "to");
277
+ if (typeof contactId === "string")
278
+ return this.#contact(contactId);
279
+ if (typeof userId !== "string")
280
+ throw new FakeApiError(400, "invalid_request", "`to.user_id` must be a string", "to");
281
+ for (const contact of this.#contacts.values())
282
+ if (contact.user && bareUser(contact.user.id) === bareUser(userId))
283
+ return contact;
284
+ throw new FakeApiError(403, "not_texted_first", "This user has not texted the app");
285
+ }
286
+ /** A repeated key returns the first answer; the same key with another body is a conflict. */
287
+ #replay(key, kind, parts) {
288
+ const seen = this.#replays.get(key);
289
+ if (seen === undefined)
290
+ return undefined;
291
+ if (seen.fingerprint !== JSON.stringify([kind, parts]))
292
+ throw new FakeApiError(409, "idempotency_conflict", "This idempotency_key was used with a different request");
293
+ return seen.answer;
294
+ }
295
+ /** Only an accepted send holds its key; a refused one can be retried under the same key. */
296
+ #accept(key, kind, parts, answer) {
297
+ this.#replays.set(key, {
298
+ fingerprint: JSON.stringify([kind, parts]),
299
+ answer,
300
+ });
301
+ }
302
+ #contactFor(handle) {
303
+ const existing = this.#byHandle.get(handle);
304
+ if (existing)
305
+ return this.#contacts.get(existing);
306
+ const contact = {
307
+ id: id("ct"),
308
+ user: null,
309
+ handleKind: handle.startsWith("+") ? "phone" : "email",
310
+ handle,
311
+ optedOut: false,
312
+ createdAt: this.#now().toISOString(),
313
+ };
314
+ this.#contacts.set(contact.id, contact);
315
+ this.#byHandle.set(handle, contact.id);
316
+ return contact;
317
+ }
318
+ #remember(messageId, contactId, kind, status) {
319
+ this.#messages.set(messageId, {
320
+ id: messageId,
321
+ contactId,
322
+ kind,
323
+ status,
324
+ createdAt: this.#now().toISOString(),
325
+ });
326
+ }
327
+ /** STOP, START and HELP replies: the only sends that reach an opted-out contact. */
328
+ #system(contact, reply) {
329
+ const messageId = id("msg");
330
+ this.#remember(messageId, contact.id, "text", "sent");
331
+ return {
332
+ messageId,
333
+ contactId: contact.id,
334
+ handle: contact.handle,
335
+ kind: "system",
336
+ text: reply,
337
+ };
338
+ }
339
+ #webhookContact(contact) {
340
+ return {
341
+ id: contact.id,
342
+ user: contact.user,
343
+ handle_kind: contact.handleKind,
344
+ handle: contact.handle,
345
+ };
346
+ }
347
+ #event(type, data) {
348
+ return {
349
+ id: id("evt"),
350
+ type,
351
+ environment: "test",
352
+ occurred_at: this.#now().toISOString(),
353
+ data,
354
+ };
355
+ }
356
+ }
@@ -0,0 +1,72 @@
1
+ /** What `runs.start` and `runs.message` return for a message: the id `runs.reply` follows. */
2
+ export interface SentRunMessage {
3
+ run_id: string;
4
+ message_id: string;
5
+ sequence_number: number;
6
+ }
7
+ /** The agent's answer to one message (`@north-light/sdk`'s `RunReply`). Consume it once. */
8
+ export interface RunReplyStream {
9
+ /** The whole reply and how its turn ended (`completed`, `error`, `waiting_on_user` or `settled`). */
10
+ collect(options?: {
11
+ onDelta?: (delta: string) => void;
12
+ }): Promise<{
13
+ text: string;
14
+ ended: "completed" | "error" | "waiting_on_user" | "settled";
15
+ }>;
16
+ /** The reply text alone. */
17
+ text(options?: {
18
+ onDelta?: (delta: string) => void;
19
+ }): Promise<string>;
20
+ }
21
+ /** The part of the runtime client (`Crouter` from `@north-light/sdk`) this helper uses. */
22
+ export interface RuntimeClient {
23
+ runs: {
24
+ start(params: {
25
+ prompt: string;
26
+ message?: string;
27
+ metadata?: Record<string, unknown>;
28
+ }, options?: {
29
+ idempotencyKey?: string;
30
+ }): Promise<{
31
+ run_id: string;
32
+ message?: SentRunMessage;
33
+ }>;
34
+ message(runId: string, message: string, options?: {
35
+ idempotencyKey?: string;
36
+ }): Promise<SentRunMessage>;
37
+ reply(runId: string, sent: SentRunMessage): RunReplyStream;
38
+ };
39
+ }
40
+ /** The standing instructions of a run this helper starts. */
41
+ export declare const DEFAULT_RUN_PROMPT = "You are chatting with a person over text message. Reply briefly, in plain text.";
42
+ export type ContinueInRuntimeOptions = ({
43
+ /** The person's runtime, `https://…`, from their grant. */
44
+ runtimeUrl: string;
45
+ /** Your app's token for that runtime, or a function that returns a fresh one for each request. */
46
+ appToken: string | (() => string | Promise<string>);
47
+ } | {
48
+ /** A client you already hold, instead of `runtimeUrl` and `appToken`. */
49
+ client: RuntimeClient;
50
+ }) & {
51
+ /** What the person said (join a burst with `createBatcher` first). */
52
+ message: string;
53
+ /** The run to continue, as returned by an earlier call. Without it a new run starts. */
54
+ runId?: string;
55
+ /** Standing instructions when a run starts. Ignored when `runId` is set. */
56
+ prompt?: string;
57
+ /** Stored with a run that starts; handy for finding the run again. Ignored when `runId` is set. */
58
+ metadata?: Record<string, unknown>;
59
+ /** Makes a retry of this call deliver the message once. Derive it from your own record of the delivery (the webhook event id). */
60
+ idempotencyKey?: string;
61
+ };
62
+ export interface ContinuedRun {
63
+ runId: string;
64
+ /** The agent's reply to this message. Consume it once: `await reply.collect()` or `await reply.text()`. */
65
+ reply: RunReplyStream;
66
+ }
67
+ /**
68
+ * Starts a run for the message, or sends it to the run named by `runId`, and returns the
69
+ * run id with the agent's reply to it. Errors are the runtime client's own (`APIError` and
70
+ * its subclasses); a retry with the same `idempotencyKey` does not deliver twice.
71
+ */
72
+ export declare function continueInRuntime(options: ContinueInRuntimeOptions): Promise<ContinuedRun>;
@@ -0,0 +1,48 @@
1
+ // Agent bridge: continue a run in the person's runtime. A run is an agent conversation hosted
2
+ // by the person's runtime; the first text of a contact starts one, later texts continue it.
3
+ // `@north-light/sdk` is an optional peer dependency, loaded only when this helper is called.
4
+ // The types below are the slice of its `runs` resource this helper uses, so this package's own
5
+ // declarations never import it.
6
+ /** The standing instructions of a run this helper starts. */
7
+ export const DEFAULT_RUN_PROMPT = "You are chatting with a person over text message. Reply briefly, in plain text.";
8
+ async function connect(options) {
9
+ if ("client" in options)
10
+ return options.client;
11
+ let module;
12
+ try {
13
+ module = await import("@north-light/sdk");
14
+ }
15
+ catch (cause) {
16
+ throw new Error("continueInRuntime needs the optional peer dependency @north-light/sdk; install it with your package manager", { cause });
17
+ }
18
+ const { appToken, runtimeUrl } = options;
19
+ return new module.Crouter(typeof appToken === "string"
20
+ ? { baseURL: runtimeUrl, token: appToken }
21
+ : { baseURL: runtimeUrl, tokenSource: async () => appToken() });
22
+ }
23
+ /**
24
+ * Starts a run for the message, or sends it to the run named by `runId`, and returns the
25
+ * run id with the agent's reply to it. Errors are the runtime client's own (`APIError` and
26
+ * its subclasses); a retry with the same `idempotencyKey` does not deliver twice.
27
+ */
28
+ export async function continueInRuntime(options) {
29
+ const client = await connect(options);
30
+ const send = options.idempotencyKey === undefined
31
+ ? {}
32
+ : { idempotencyKey: options.idempotencyKey };
33
+ if (options.runId !== undefined) {
34
+ const sent = await client.runs.message(options.runId, options.message, send);
35
+ return { runId: sent.run_id, reply: client.runs.reply(sent.run_id, sent) };
36
+ }
37
+ const started = await client.runs.start({
38
+ prompt: options.prompt ?? DEFAULT_RUN_PROMPT,
39
+ message: options.message,
40
+ ...(options.metadata === undefined ? {} : { metadata: options.metadata }),
41
+ }, send);
42
+ if (!started.message)
43
+ throw new Error("The runtime started a run without the first message");
44
+ return {
45
+ runId: started.run_id,
46
+ reply: client.runs.reply(started.run_id, started.message),
47
+ };
48
+ }
@@ -0,0 +1,23 @@
1
+ export interface BatcherOptions<T> {
2
+ /** Receives a key's items once it has been quiet for `windowMs`. Calls for one key never overlap. */
3
+ onBatch: (key: string, items: T[]) => void | Promise<void>;
4
+ /** Receives anything `onBatch` throws; a batch is not retried. */
5
+ onError: (error: unknown, key: string, items: T[]) => void;
6
+ /** Quiet time that closes a batch. Default 2000. */
7
+ windowMs?: number;
8
+ /** A batch closes this long after its first item even if texts keep arriving. Default 30000. */
9
+ maxWaitMs?: number;
10
+ }
11
+ export declare const DEFAULT_BATCH_WINDOW_MS = 2000;
12
+ export declare const DEFAULT_BATCH_MAX_WAIT_MS = 30000;
13
+ export declare class Batcher<T> {
14
+ #private;
15
+ constructor(options: BatcherOptions<T>);
16
+ /** Adds an item (key it by contact id) and restarts the key's quiet window. */
17
+ add(key: string, item: T): void;
18
+ /** Closes the key's batch now. Resolves when its `onBatch` and any earlier one for the key have finished. */
19
+ flush(key: string): Promise<void>;
20
+ /** Closes every open batch now, for a graceful shutdown. */
21
+ flushAll(): Promise<void>;
22
+ }
23
+ export declare function createBatcher<T>(options: BatcherOptions<T>): Batcher<T>;
@@ -0,0 +1,66 @@
1
+ // Batching: texts from one person often arrive as a burst. A batcher holds each key's items
2
+ // until the key has been quiet for `windowMs`, then hands them over together, in order.
3
+ export const DEFAULT_BATCH_WINDOW_MS = 2_000;
4
+ export const DEFAULT_BATCH_MAX_WAIT_MS = 30_000;
5
+ export class Batcher {
6
+ #options;
7
+ #windowMs;
8
+ #maxWaitMs;
9
+ #pending = new Map();
10
+ /** The batch currently being handled per key; the next batch for the key waits for it. */
11
+ #running = new Map();
12
+ constructor(options) {
13
+ this.#options = options;
14
+ this.#windowMs = options.windowMs ?? DEFAULT_BATCH_WINDOW_MS;
15
+ this.#maxWaitMs = options.maxWaitMs ?? DEFAULT_BATCH_MAX_WAIT_MS;
16
+ if (!(this.#windowMs > 0) || !(this.#maxWaitMs >= this.#windowMs))
17
+ throw new RangeError("windowMs must be positive and at most maxWaitMs");
18
+ }
19
+ /** Adds an item (key it by contact id) and restarts the key's quiet window. */
20
+ add(key, item) {
21
+ const now = Date.now();
22
+ const existing = this.#pending.get(key);
23
+ if (existing)
24
+ clearTimeout(existing.timer);
25
+ const startedAt = existing?.startedAt ?? now;
26
+ const wait = Math.min(this.#windowMs, startedAt + this.#maxWaitMs - now);
27
+ const timer = setTimeout(() => void this.flush(key), Math.max(0, wait));
28
+ this.#pending.set(key, {
29
+ items: [...(existing?.items ?? []), item],
30
+ startedAt,
31
+ timer,
32
+ });
33
+ }
34
+ /** Closes the key's batch now. Resolves when its `onBatch` and any earlier one for the key have finished. */
35
+ flush(key) {
36
+ const pending = this.#pending.get(key);
37
+ if (pending) {
38
+ clearTimeout(pending.timer);
39
+ this.#pending.delete(key);
40
+ }
41
+ const previous = this.#running.get(key) ?? Promise.resolve();
42
+ if (!pending)
43
+ return previous;
44
+ const run = previous.then(async () => {
45
+ try {
46
+ await this.#options.onBatch(key, pending.items);
47
+ }
48
+ catch (error) {
49
+ this.#options.onError(error, key, pending.items);
50
+ }
51
+ });
52
+ this.#running.set(key, run);
53
+ void run.then(() => {
54
+ if (this.#running.get(key) === run)
55
+ this.#running.delete(key);
56
+ });
57
+ return run;
58
+ }
59
+ /** Closes every open batch now, for a graceful shutdown. */
60
+ async flushAll() {
61
+ await Promise.all([...new Set([...this.#pending.keys(), ...this.#running.keys()])].map((key) => this.flush(key)));
62
+ }
63
+ }
64
+ export function createBatcher(options) {
65
+ return new Batcher(options);
66
+ }
@@ -0,0 +1,38 @@
1
+ import type { MessageReceivedData, SendMessageRequest } from "../types.js";
2
+ /** The send API refuses a longer `text`. */
3
+ export declare const MAX_TEXT_LENGTH = 10000;
4
+ /**
5
+ * Markdown to the plain text a person should read: emphasis, headings, quotes, code fences and
6
+ * HTML are dropped, `[label](url)` becomes `label (url)`, images disappear, lists keep their
7
+ * `-` and `1.` markers and table rows become `a | b`. Text inside code stays exactly as written.
8
+ */
9
+ export declare function toPlainText(markdown: string): string;
10
+ /**
11
+ * Splits a long text into parts of at most `maxLength` characters, preferring to break at a
12
+ * blank line, then a line, then a space. Each part is sent as its own message, in order.
13
+ */
14
+ export declare function splitText(text: string, maxLength?: number): string[];
15
+ /** An agent's markdown reply as the texts to send, in order. Empty markdown gives no texts. */
16
+ export declare function formatReply(markdown: string): string[];
17
+ /** The six iMessage tapbacks by name. */
18
+ export declare const TAPBACKS: {
19
+ readonly love: "❤️";
20
+ readonly like: "👍";
21
+ readonly dislike: "👎";
22
+ readonly laugh: "😂";
23
+ readonly emphasize: "‼️";
24
+ readonly question: "❓";
25
+ };
26
+ export type TapbackName = keyof typeof TAPBACKS;
27
+ /** The tapback name for an emoji (`❤️` → `love`), or `null` when it is not a tapback. */
28
+ export declare function tapbackName(emoji: string): TapbackName | null;
29
+ /** The emoji to send as a reaction, from a tapback name (`love`) or an emoji. Throws for anything else. */
30
+ export declare function tapbackEmoji(nameOrEmoji: string): string;
31
+ /** What to add to a send so it threads under the message that was received. */
32
+ export declare function replyTo(received: Pick<MessageReceivedData, "message_id">): Pick<SendMessageRequest, "reply_to_message_id">;
33
+ /**
34
+ * A received message as one line of text an agent can read: text as written, a reaction as
35
+ * `[reacted love to message <id>]`, an attachment as `[attachment: name (mime)]` followed by
36
+ * its caption, and a threaded message prefixed with `[replying to message <id>] `.
37
+ */
38
+ export declare function describeReceived(received: Pick<MessageReceivedData, "content" | "reply_to_message_id">): string;