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/CHANGELOG.md +25 -0
- package/README.md +203 -19
- package/package.json +3 -3
- package/src/api.ts +179 -15
- package/src/consumer.ts +503 -0
- package/src/content.ts +210 -0
- package/src/icon.ts +46 -0
- package/src/index.ts +34 -0
- package/src/intake.ts +2 -2
- package/src/join.ts +136 -0
- package/src/messages.ts +267 -0
- package/src/migrations.ts +100 -1
- package/src/options.ts +86 -2
- package/src/outbox-worker.ts +469 -0
- package/src/outbox.ts +273 -0
- package/src/plugin.ts +125 -9
- package/src/port.ts +23 -6
- package/src/quota.ts +195 -0
- package/src/retention.ts +243 -0
- package/src/route.ts +71 -0
- package/src/sender.ts +110 -0
- package/src/surface.ts +76 -0
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 {
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
if (
|
|
64
|
-
throw new Error("line:
|
|
65
|
-
|
|
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 (
|
|
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 {
|
|
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
|
-
*
|
|
15
|
-
* any chat
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
|
|
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", {
|