pi-roundtable-line 0.9.13 → 0.11.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.
package/src/outbox.ts ADDED
@@ -0,0 +1,273 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { SQL } from "bun";
3
+ import { LineError } from "./errors.ts";
4
+ import { parseSourceKey } from "./events.ts";
5
+ import { scrub } from "./intake.ts";
6
+ import {
7
+ type LineSender,
8
+ SENDER_DEFAULTS,
9
+ type SenderOptions,
10
+ validateSender,
11
+ } from "./sender.ts";
12
+ import type { LineSources } from "./sources.ts";
13
+
14
+ /** A message object as LINE takes it, such as `{ type: "text", text: "hello" }`. */
15
+ export type LineOutMessage = { type: string } & Record<string, unknown>;
16
+
17
+ export interface EnqueueOptions {
18
+ /** The name and icon the push is shown under; validated before anything is queued. */
19
+ sender?: LineSender;
20
+ /**
21
+ * Your own reference for this enqueue (at most 200 characters), given back as the `originRef` of
22
+ * the `outboxResult` event of the push that carries it.
23
+ */
24
+ origin?: string;
25
+ }
26
+
27
+ /** Pushes to the chats the allowlist admits. */
28
+ export interface LineOutbox {
29
+ /**
30
+ * Queues message objects for a chat. Objects queued for the same chat and the same sender within
31
+ * `outbox.coalesceMs` (2 seconds) share a push of at most five, in the order they were queued; a
32
+ * push is held back behind earlier pushes to the chat. Each push has its retry key from the
33
+ * moment it exists. How a push ends comes back to the subscriber as an `outboxResult` event.
34
+ *
35
+ * Throws `LineError` before queuing anything: `bad_sender` for a sender LINE would refuse,
36
+ * `bad_messages` for an object that cannot be sent, `bad_source_key`, and `source_not_allowed`
37
+ * for a chat that is not on the allowlist.
38
+ */
39
+ enqueue(
40
+ sourceKey: string,
41
+ messages: readonly LineOutMessage[],
42
+ options?: EnqueueOptions,
43
+ ): Promise<void>;
44
+ }
45
+
46
+ export interface OutboxOptions {
47
+ /** How long the objects of one chat and sender are gathered into one push; default 2 seconds. */
48
+ coalesceMs: number;
49
+ /** Attempts after which a push that keeps failing is dead; default 12. */
50
+ maxAttempts: number;
51
+ /** First backoff; each failure doubles it up to `backoffCapMs`; default 5 seconds. */
52
+ backoffBaseMs: number;
53
+ /** Longest backoff; default 1 hour. */
54
+ backoffCapMs: number;
55
+ /**
56
+ * How long a retry key is reused after the first attempt; default 24 hours, LINE's limit. A push
57
+ * still unsent then is dead, and is never sent under a new key.
58
+ */
59
+ retryKeyMs: number;
60
+ /** How long the worker waits between looks at the outbox when nothing woke it; default 1 second. */
61
+ pollMs: number;
62
+ /** How long a push may be in hand before another worker may take it; default 2 minutes. */
63
+ leaseMs: number;
64
+ /** Pushes sent at once, always to different chats; default 2. */
65
+ concurrency: number;
66
+ }
67
+
68
+ export const OUTBOX_DEFAULTS: OutboxOptions = {
69
+ coalesceMs: 2_000,
70
+ maxAttempts: 12,
71
+ backoffBaseMs: 5_000,
72
+ backoffCapMs: 60 * 60_000,
73
+ retryKeyMs: 24 * 60 * 60_000,
74
+ pollMs: 1_000,
75
+ leaseMs: 2 * 60_000,
76
+ concurrency: 2,
77
+ };
78
+
79
+ const MAX_TIMER_MS = 2_147_483_647;
80
+
81
+ /** Refuses a mistake in the outbox options, naming the field. */
82
+ export function checkOutboxOptions(options: Partial<OutboxOptions>): void {
83
+ for (const [key, value] of Object.entries(options)) {
84
+ if (!(key in OUTBOX_DEFAULTS))
85
+ throw new Error(`line: outbox.${key} is not an outbox option`);
86
+ const min = key === "coalesceMs" ? 0 : 1;
87
+ if (!Number.isInteger(value) || (value as number) < min)
88
+ throw new Error(
89
+ `line: outbox.${key} must be a whole number of at least ${min}; got ${String(value)}`,
90
+ );
91
+ if (
92
+ ["coalesceMs", "pollMs", "leaseMs"].includes(key) &&
93
+ (value as number) > MAX_TIMER_MS
94
+ )
95
+ throw new Error(
96
+ `line: outbox.${key} must be at most ${MAX_TIMER_MS} milliseconds; got ${String(value)}`,
97
+ );
98
+ }
99
+ }
100
+
101
+ /** The most message objects one push holds. */
102
+ export const PUSH_MAX_MESSAGES = 5;
103
+ const MAX_CALL_MESSAGES = 100;
104
+ const MAX_TEXT_CHARS = 5000;
105
+ const MAX_MESSAGE_BYTES = 50_000;
106
+ const MAX_ORIGIN_CHARS = 200;
107
+
108
+ const badMessages = (what: string): LineError =>
109
+ new LineError("bad_messages", `line: ${what}`);
110
+
111
+ /** The message objects as they will be stored: plain, serialisable, with no sender of their own. */
112
+ function checkMessages(messages: readonly LineOutMessage[]): LineOutMessage[] {
113
+ if (
114
+ !Array.isArray(messages) ||
115
+ messages.length === 0 ||
116
+ messages.length > MAX_CALL_MESSAGES
117
+ )
118
+ throw badMessages(
119
+ `enqueue takes 1 to ${MAX_CALL_MESSAGES} message objects at a time`,
120
+ );
121
+ return messages.map((message, index) => {
122
+ if (
123
+ typeof message !== "object" ||
124
+ message === null ||
125
+ Array.isArray(message) ||
126
+ typeof message.type !== "string" ||
127
+ message.type === ""
128
+ )
129
+ throw badMessages(`message ${index} is not a LINE message object`);
130
+ if ("sender" in message)
131
+ throw badMessages(
132
+ `message ${index} names a sender; give the sender to enqueue`,
133
+ );
134
+ if (
135
+ message.type === "text" &&
136
+ (typeof message.text !== "string" ||
137
+ message.text.trim() === "" ||
138
+ message.text.length > MAX_TEXT_CHARS)
139
+ )
140
+ throw badMessages(
141
+ `message ${index} is text of 1 to ${MAX_TEXT_CHARS} characters`,
142
+ );
143
+ let cleaned: LineOutMessage;
144
+ let size: number;
145
+ try {
146
+ cleaned = scrub(message) as LineOutMessage;
147
+ size = JSON.stringify(cleaned).length;
148
+ } catch {
149
+ throw badMessages(`message ${index} cannot be serialised`);
150
+ }
151
+ if (size > MAX_MESSAGE_BYTES)
152
+ throw badMessages(`message ${index} is over ${MAX_MESSAGE_BYTES} bytes`);
153
+ return cleaned;
154
+ });
155
+ }
156
+
157
+ function checkOrigin(origin: string | undefined): string | null {
158
+ if (origin === undefined) return null;
159
+ if (
160
+ typeof origin !== "string" ||
161
+ origin === "" ||
162
+ origin.length > MAX_ORIGIN_CHARS
163
+ )
164
+ throw badMessages(
165
+ `origin is a string of 1 to ${MAX_ORIGIN_CHARS} characters`,
166
+ );
167
+ return scrub(origin) as string;
168
+ }
169
+
170
+ export interface PgOutboxDeps {
171
+ sql: SQL;
172
+ now: () => Date;
173
+ sources: LineSources;
174
+ options?: Partial<OutboxOptions>;
175
+ sender?: Partial<SenderOptions>;
176
+ /** Told after a push is queued, with how long until it is ready to go, so the worker looks then. */
177
+ queued?: (readyInMs: number) => void;
178
+ }
179
+
180
+ /**
181
+ * The queue. A push exists from its first object, with its retry key already stored; later objects
182
+ * join it while it is open (gathered for `coalesceMs`, fewer than five objects, still the last push to
183
+ * its chat, not yet tried), then it is sealed by being tried.
184
+ */
185
+ export class PgOutbox implements LineOutbox {
186
+ readonly #sql: SQL;
187
+ readonly #now: () => Date;
188
+ readonly #sources: LineSources;
189
+ readonly #senderOptions: SenderOptions;
190
+ readonly #coalesceMs: number;
191
+ readonly #queued: ((readyInMs: number) => void) | undefined;
192
+
193
+ constructor(deps: PgOutboxDeps) {
194
+ this.#sql = deps.sql;
195
+ this.#now = deps.now;
196
+ this.#sources = deps.sources;
197
+ this.#senderOptions = { ...SENDER_DEFAULTS, ...deps.sender };
198
+ this.#coalesceMs = deps.options?.coalesceMs ?? OUTBOX_DEFAULTS.coalesceMs;
199
+ this.#queued = deps.queued;
200
+ }
201
+
202
+ async enqueue(
203
+ sourceKey: string,
204
+ messages: readonly LineOutMessage[],
205
+ options: EnqueueOptions = {},
206
+ ): Promise<void> {
207
+ if (!parseSourceKey(sourceKey))
208
+ throw new LineError(
209
+ "bad_source_key",
210
+ "line: a source key is line:<group|room|user>:<id>",
211
+ );
212
+ const sender = validateSender(options.sender, this.#senderOptions);
213
+ let rest = checkMessages(messages);
214
+ const origin = checkOrigin(options.origin);
215
+ if ((await this.#sources.get(sourceKey))?.status !== "allowed")
216
+ throw new LineError(
217
+ "source_not_allowed",
218
+ "line: a push goes only to a chat on the allowlist",
219
+ );
220
+ const name = sender?.name ?? null;
221
+ const icon = sender?.iconUrl ?? null;
222
+ const now = this.#now();
223
+ const window = new Date(now.getTime() + this.#coalesceMs);
224
+ // A push that fills goes at once; one that is still gathering is ready when its window ends.
225
+ let filled = false;
226
+ let gathering = false;
227
+ await this.#sql.begin(async (tx) => {
228
+ // One enqueue at a time per chat, so pushes of a chat are formed in order.
229
+ await tx`SELECT pg_advisory_xact_lock(hashtextextended(${sourceKey}, 13))`;
230
+ const [open] =
231
+ (await tx`SELECT id, message_count, origin_refs, ready_at FROM line_outbox
232
+ WHERE source_key = ${sourceKey}
233
+ AND id = (SELECT max(id) FROM line_outbox WHERE source_key = ${sourceKey})
234
+ AND sender_name IS NOT DISTINCT FROM ${name}::text
235
+ AND sender_icon_url IS NOT DISTINCT FROM ${icon}::text
236
+ AND status = 'queued' AND first_attempt_at IS NULL AND ready_at > ${now}
237
+ FOR UPDATE`) as {
238
+ id: string | number;
239
+ message_count: number;
240
+ origin_refs: (string | null)[];
241
+ ready_at: Date;
242
+ }[];
243
+ if (open && open.message_count < PUSH_MAX_MESSAGES) {
244
+ const take = rest.slice(0, PUSH_MAX_MESSAGES - open.message_count);
245
+ rest = rest.slice(take.length);
246
+ const origins = [...new Set([...open.origin_refs, origin])];
247
+ const full = open.message_count + take.length >= PUSH_MAX_MESSAGES;
248
+ filled ||= full;
249
+ await tx`UPDATE line_outbox
250
+ SET messages = messages || ${JSON.stringify(take)}::text::jsonb,
251
+ message_count = message_count + ${take.length},
252
+ origin_refs = ${JSON.stringify(origins)}::text::jsonb,
253
+ ready_at = ${full ? now : open.ready_at}
254
+ WHERE id = ${open.id}`;
255
+ }
256
+ while (rest.length > 0) {
257
+ const take = rest.slice(0, PUSH_MAX_MESSAGES);
258
+ rest = rest.slice(take.length);
259
+ const full = take.length >= PUSH_MAX_MESSAGES;
260
+ if (full) filled = true;
261
+ else gathering = true;
262
+ await tx`INSERT INTO line_outbox
263
+ (source_key, sender_name, sender_icon_url, messages, message_count, origin_refs, retry_key,
264
+ status, created_at, ready_at, next_attempt_at)
265
+ VALUES (${sourceKey}, ${name}, ${icon}, ${JSON.stringify(take)}::text::jsonb, ${take.length},
266
+ ${JSON.stringify([origin])}::text::jsonb, ${randomUUID()}::uuid, 'queued', ${now},
267
+ ${full ? now : window}, ${now})`;
268
+ }
269
+ });
270
+ if (filled) this.#queued?.(0);
271
+ if (gathering || !filled) this.#queued?.(this.#coalesceMs);
272
+ }
273
+ }
package/src/plugin.ts CHANGED
@@ -5,11 +5,21 @@ import {
5
5
  } from "pi-roundtable";
6
6
  import { LineApi } from "./api.ts";
7
7
  import { BotIdentity } from "./bot.ts";
8
+ import { LineConsumer, NO_KEEP } from "./consumer.ts";
9
+ import { MessageContent } from "./content.ts";
10
+ import { iconRoute } from "./icon.ts";
8
11
  import { type InboxProcessor, InboxWorker } from "./inbox.ts";
9
- import { INBOX_MIGRATION } from "./migrations.ts";
12
+ import { JoinNotifier } from "./join.ts";
13
+ import type { LineSubscriber } from "./messages.ts";
14
+ import { MIGRATIONS } from "./migrations.ts";
10
15
  import { checkOptions, type LineOptions } from "./options.ts";
16
+ import { PgOutbox } from "./outbox.ts";
17
+ import { OutboxWorker } from "./outbox-worker.ts";
11
18
  import { LINE, type LineBridgePort } from "./port.ts";
19
+ import { QuotaGate } from "./quota.ts";
20
+ import { RetentionSweeper } from "./retention.ts";
12
21
  import { PgSources } from "./sources.ts";
22
+ import { LineSurface } from "./surface.ts";
13
23
  import { TokenSource } from "./token.ts";
14
24
  import { webhookHandler } from "./webhook.ts";
15
25
 
@@ -29,7 +39,7 @@ export function line(options: LineOptions): RoundtablePlugin {
29
39
  });
30
40
  return definePlugin({
31
41
  name: "line",
32
- migrations: [INBOX_MIGRATION],
42
+ migrations: MIGRATIONS,
33
43
  provides: [LINE],
34
44
  setup: (context: PluginContext) => {
35
45
  const sql = context.database();
@@ -40,17 +50,93 @@ export function line(options: LineOptions): RoundtablePlugin {
40
50
  });
41
51
  const bot = new BotIdentity({ api, logger: context.logger, now });
42
52
  const sources = new PgSources(sql, () => api, now);
43
- let processor: InboxProcessor | undefined;
53
+ const quota = new QuotaGate({
54
+ api,
55
+ now,
56
+ ...(options.quota ? { options: options.quota } : {}),
57
+ });
58
+ // Told when a push is queued, so the worker looks as its gathering window ends.
59
+ let outboxWorker: OutboxWorker | undefined;
60
+ const outbox = new PgOutbox({
61
+ sql,
62
+ now,
63
+ sources,
64
+ ...(options.outbox ? { options: options.outbox } : {}),
65
+ ...(options.sender ? { sender: options.sender } : {}),
66
+ queued: (readyInMs) => outboxWorker?.kickIn(readyInMs),
67
+ });
68
+ const surface = new LineSurface(outbox);
69
+ const joins = new JoinNotifier({
70
+ sql,
71
+ api,
72
+ logger: context.logger,
73
+ now,
74
+ ...(options.joinNotice ? { notice: options.joinNotice } : {}),
75
+ });
76
+ if (options.joinNotice && !joins.armed)
77
+ context.logger.warn(
78
+ { code: "join_notice_digest_mismatch" },
79
+ "the LINE join notice text is not the one its digest names; no notice will be sent",
80
+ );
81
+ const consumer = new LineConsumer({
82
+ sql,
83
+ logger: context.logger,
84
+ now,
85
+ joins,
86
+ ...(options.route
87
+ ? { route: (message) => surface.deliver(message) }
88
+ : {}),
89
+ });
90
+ const content = new MessageContent(sql, api, options.media);
91
+ // The subscriber and the processor made for it; the worker hands events out only while both are set.
92
+ let subscription:
93
+ | { handler: LineSubscriber; processor: InboxProcessor }
94
+ | undefined;
95
+ // Aborted when the retention service stops, so a hand-back in progress does not outlive the host.
96
+ let sweeping = new AbortController();
97
+ const retention = new RetentionSweeper({
98
+ sql,
99
+ logger: context.logger,
100
+ now,
101
+ strays: async (before) => {
102
+ const attached = subscription;
103
+ return attached
104
+ ? consumer.handBackStrays(attached.handler, sweeping.signal, before)
105
+ : 0;
106
+ },
107
+ ...(options.retention ? { options: options.retention } : {}),
108
+ });
109
+ // With routing on, the inbox is served without a subscriber too: the router needs the text.
110
+ const routeOnly = options.route
111
+ ? consumer.processorFor(async () => undefined)
112
+ : undefined;
44
113
  const worker = new InboxWorker({
45
114
  sql,
46
115
  logger: context.logger,
47
116
  now,
48
117
  sources,
49
- processor: () => processor,
118
+ processor: () => subscription?.processor ?? routeOnly,
50
119
  ...(options.inbox ? { options: options.inbox } : {}),
51
120
  });
121
+ outboxWorker = new OutboxWorker({
122
+ sql,
123
+ logger: context.logger,
124
+ now,
125
+ api,
126
+ quota,
127
+ sources,
128
+ emit: async (event, signal) => {
129
+ const attached = subscription;
130
+ if (!attached) return false;
131
+ await attached.handler(event, signal, NO_KEEP);
132
+ return true;
133
+ },
134
+ ...(options.outbox ? { options: options.outbox } : {}),
135
+ });
136
+ const sending = outboxWorker;
52
137
  const port: LineBridgePort = {
53
138
  sources,
139
+ outbox,
54
140
  botUserId: () => bot.current(),
55
141
  health: () => {
56
142
  const health = tokens.health();
@@ -59,15 +145,21 @@ export function line(options: LineOptions): RoundtablePlugin {
59
145
  tokenFailure: health.lastFailure,
60
146
  };
61
147
  },
62
- attachProcessor: (attached) => {
63
- if (processor)
64
- throw new Error("line: an inbox processor is already attached");
65
- processor = attached;
148
+ subscribe: (handler) => {
149
+ if (subscription)
150
+ throw new Error("line: a subscriber is already attached");
151
+ const attached = {
152
+ handler,
153
+ processor: consumer.processorFor(handler),
154
+ };
155
+ subscription = attached;
66
156
  worker.kick();
157
+ sending.kick();
67
158
  return () => {
68
- if (processor === attached) processor = undefined;
159
+ if (subscription === attached) subscription = undefined;
69
160
  };
70
161
  },
162
+ content: (messageId, request) => content.fetch(messageId, request),
71
163
  };
72
164
  context.services.provide(LINE, port);
73
165
  return {
@@ -82,8 +174,32 @@ export function line(options: LineOptions): RoundtablePlugin {
82
174
  stop: () => worker.stop(),
83
175
  busy: () => worker.busy(),
84
176
  },
177
+ {
178
+ name: "line-outbox",
179
+ start: () => sending.start(),
180
+ stop: () => sending.stop(),
181
+ busy: () => sending.busy(),
182
+ },
183
+ {
184
+ name: "line-retention",
185
+ start: () => {
186
+ sweeping = new AbortController();
187
+ retention.start();
188
+ },
189
+ stop: async () => {
190
+ sweeping.abort(
191
+ new Error("line: the retention service is stopping"),
192
+ );
193
+ await retention.stop();
194
+ },
195
+ busy: () => retention.busy(),
196
+ },
85
197
  ],
198
+ surfaces: [surface],
86
199
  http: [
200
+ ...(options.senderIconRoute
201
+ ? [iconRoute(options.senderIconRoute, options.listener ?? "public")]
202
+ : []),
87
203
  {
88
204
  name: "line-webhook",
89
205
  listener: options.listener ?? "public",
package/src/port.ts CHANGED
@@ -1,22 +1,39 @@
1
1
  import { serviceKey } from "pi-roundtable";
2
- import type { InboxProcessor } from "./inbox.ts";
2
+ import type { LineContentRequest, LineContentResult } from "./content.ts";
3
+ import type { LineSubscriber } from "./messages.ts";
4
+ import type { LineOutbox } from "./outbox.ts";
3
5
  import type { LineSources } from "./sources.ts";
4
6
 
5
7
  /** What other plugins read from this one, through the `LINE` service key. */
6
8
  export interface LineBridgePort {
7
9
  /** The chats the bot has seen, and the allowlist over them. */
8
10
  readonly sources: LineSources;
11
+ /** Pushes to the chats on the allowlist, with a sender, a retry key and the quota gate. */
12
+ readonly outbox: LineOutbox;
9
13
  /** The bot's user id once LINE has told it, else undefined. */
10
14
  botUserId(): string | undefined;
11
15
  /** The token source's health: how many tokens were issued and why the last attempt failed, if it did. */
12
16
  health(): { tokensIssued: number; tokenFailure: string | undefined };
13
17
  /**
14
- * Takes the inbox: from now on each stored event of an allowed chat (and the join and leave of
15
- * any chat) is handed to `processor`, oldest first within a chat. Only one processor may be
16
- * attached; returns the function that detaches it. Until one is attached events wait in the
17
- * inbox, payload and all, until retention removes them.
18
+ * Becomes the one consumer of the package: from now on each stored event of an allowed chat, the
19
+ * `join` and `leave` of any chat that is not blocked, and an `unsend` stored while its chat was
20
+ * allowed (also after the bot has left it, so a mirror is still taken down), is turned into a typed
21
+ * event and handed to `handler`, oldest first within a chat, at least once. A second subscriber is
22
+ * refused (the call throws). Returns the function that detaches this one. With none attached
23
+ * events wait in the inbox, payload and all, until retention removes them: a subscriber that stays
24
+ * detached longer than the retention window loses the unsends that were waiting for it.
18
25
  */
19
- attachProcessor(processor: InboxProcessor): () => void;
26
+ subscribe(handler: LineSubscriber): () => void;
27
+ /**
28
+ * The content of a message, held in memory only: `{ bytes, contentType }`, `{ tooLarge }` past
29
+ * `media.maxBytes`, or `{ expired }` when LINE no longer has it (or the message was unsent).
30
+ * Pass the event's `kind` so a video or audio is waited for until LINE has transcoded it, and the
31
+ * handler's signal so the download stops when the event is given up.
32
+ */
33
+ content(
34
+ messageId: string,
35
+ request?: LineContentRequest,
36
+ ): Promise<LineContentResult>;
20
37
  }
21
38
 
22
39
  export const LINE = serviceKey<LineBridgePort>("roundtable.line", {