pi-roundtable-line 0.9.13 → 0.10.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 +21 -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/quota.ts
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import type { LineApi } from "./api.ts";
|
|
2
|
+
import { LineError } from "./errors.ts";
|
|
3
|
+
import { parseSourceKey } from "./events.ts";
|
|
4
|
+
|
|
5
|
+
export interface QuotaOptions {
|
|
6
|
+
/**
|
|
7
|
+
* The most messages this month a push may bring consumption to, however much of the plan's quota is
|
|
8
|
+
* left; the gate keeps to the lower of the two. Unset: the plan's quota alone.
|
|
9
|
+
*/
|
|
10
|
+
monthlyCap?: number;
|
|
11
|
+
/**
|
|
12
|
+
* How long, in milliseconds, a push that was sent (or may have been) stays reserved when LINE's
|
|
13
|
+
* consumption has not grown by its recipients by then. LINE calls its figure approximate and may
|
|
14
|
+
* be slow to count; this bounds how long the gate waits for it. Default 10 minutes.
|
|
15
|
+
*/
|
|
16
|
+
holdMs?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** How long a sent push stays reserved if LINE's consumption never shows it. */
|
|
20
|
+
export const QUOTA_HOLD_MS = 10 * 60_000;
|
|
21
|
+
|
|
22
|
+
/** How long a reading of LINE's quota, consumption and a chat's member count is trusted. */
|
|
23
|
+
export const QUOTA_CACHE_MS = 60_000;
|
|
24
|
+
|
|
25
|
+
/** A push the gate let through, until it is settled. */
|
|
26
|
+
export interface Reservation {
|
|
27
|
+
/** The recipients it counts for. */
|
|
28
|
+
readonly recipients: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type Reserved =
|
|
32
|
+
| { ok: true; reservation: Reservation }
|
|
33
|
+
| {
|
|
34
|
+
ok: false;
|
|
35
|
+
/** The reading the push was refused on, for the log: counts only. */
|
|
36
|
+
used: number;
|
|
37
|
+
reserved: number;
|
|
38
|
+
recipients: number;
|
|
39
|
+
cap: number;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
interface Reading {
|
|
43
|
+
/** The plan's monthly limit, undefined when it has none. */
|
|
44
|
+
limit: number | undefined;
|
|
45
|
+
used: number;
|
|
46
|
+
/** When the reading began, in milliseconds. */
|
|
47
|
+
at: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface QuotaGateDeps {
|
|
51
|
+
api: LineApi;
|
|
52
|
+
now: () => Date;
|
|
53
|
+
options?: QuotaOptions;
|
|
54
|
+
cacheMs?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Keeps pushes inside the month's budget. A push is let through only while LINE's consumption, what
|
|
59
|
+
* this process has reserved for pushes it has sent or is sending, and the push's own recipients (one,
|
|
60
|
+
* or the member count of a group or room) stay within the lower of the plan's quota and `monthlyCap`.
|
|
61
|
+
*
|
|
62
|
+
* LINE's quota and consumption are read at most once a minute. LINE calls the figure approximate,
|
|
63
|
+
* so a reservation for a push that went out (or may have: a timeout or a 5xx) stays counted until
|
|
64
|
+
* the consumption read has grown by its recipients since the push was sent, or `holdMs` has passed;
|
|
65
|
+
* one for a push that did not go out is dropped at once. Only this process's reservations are known: with several
|
|
66
|
+
* processes, set `monthlyCap` below the quota by their share.
|
|
67
|
+
*/
|
|
68
|
+
export class QuotaGate {
|
|
69
|
+
readonly #api: LineApi;
|
|
70
|
+
readonly #now: () => Date;
|
|
71
|
+
readonly #cap: number | undefined;
|
|
72
|
+
readonly #cacheMs: number;
|
|
73
|
+
readonly #holdMs: number;
|
|
74
|
+
#reading: Reading | undefined;
|
|
75
|
+
#inflight: Promise<Reading> | undefined;
|
|
76
|
+
readonly #reservations = new Map<
|
|
77
|
+
Reservation,
|
|
78
|
+
{ settledAt: number | undefined; unseen: number }
|
|
79
|
+
>();
|
|
80
|
+
/** The consumption of the previous reading, to see how much it has grown. */
|
|
81
|
+
#lastUsed: number | undefined;
|
|
82
|
+
readonly #counts = new Map<string, { n: number; at: number }>();
|
|
83
|
+
|
|
84
|
+
constructor(deps: QuotaGateDeps) {
|
|
85
|
+
this.#api = deps.api;
|
|
86
|
+
this.#now = deps.now;
|
|
87
|
+
this.#cap = deps.options?.monthlyCap;
|
|
88
|
+
this.#cacheMs = deps.cacheMs ?? QUOTA_CACHE_MS;
|
|
89
|
+
this.#holdMs = deps.options?.holdMs ?? QUOTA_HOLD_MS;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** How many people a push to the chat reaches; a count is trusted for a minute. */
|
|
93
|
+
async recipients(sourceKey: string): Promise<number> {
|
|
94
|
+
const source = parseSourceKey(sourceKey);
|
|
95
|
+
if (!source)
|
|
96
|
+
throw new LineError("bad_source_key", "line: not a source key");
|
|
97
|
+
if (source.kind === "user") return 1;
|
|
98
|
+
const now = this.#now().getTime();
|
|
99
|
+
const held = this.#counts.get(sourceKey);
|
|
100
|
+
if (held && now - held.at < this.#cacheMs) return held.n;
|
|
101
|
+
const n = await this.#api.recipients(source.kind, source.id);
|
|
102
|
+
this.#counts.set(sourceKey, { n, at: now });
|
|
103
|
+
return n;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async #read(): Promise<Reading> {
|
|
107
|
+
const now = this.#now().getTime();
|
|
108
|
+
if (this.#reading && now - this.#reading.at < this.#cacheMs)
|
|
109
|
+
return this.#reading;
|
|
110
|
+
this.#inflight ??= (async () => {
|
|
111
|
+
const at = this.#now().getTime();
|
|
112
|
+
const limit = await this.#api.messageQuota();
|
|
113
|
+
// With no limit and no cap of our own there is nothing to compare with.
|
|
114
|
+
const used =
|
|
115
|
+
limit === undefined && this.#cap === undefined
|
|
116
|
+
? 0
|
|
117
|
+
: await this.#api.messageConsumption();
|
|
118
|
+
const reading = { limit, used, at };
|
|
119
|
+
this.#reading = reading;
|
|
120
|
+
// What consumption has grown by since the last reading is what LINE has counted of the pushes
|
|
121
|
+
// sent before this reading began, oldest first; a push is let go once all of it is counted.
|
|
122
|
+
let grown =
|
|
123
|
+
this.#lastUsed === undefined ? 0 : Math.max(0, used - this.#lastUsed);
|
|
124
|
+
this.#lastUsed = used;
|
|
125
|
+
const counted = [...this.#reservations]
|
|
126
|
+
.filter(
|
|
127
|
+
([, state]) => state.settledAt !== undefined && state.settledAt <= at,
|
|
128
|
+
)
|
|
129
|
+
.sort(([, a], [, b]) => (a.settledAt ?? 0) - (b.settledAt ?? 0));
|
|
130
|
+
for (const [reservation, state] of counted) {
|
|
131
|
+
const take = Math.min(grown, state.unseen);
|
|
132
|
+
grown -= take;
|
|
133
|
+
state.unseen -= take;
|
|
134
|
+
if (state.unseen <= 0) this.#reservations.delete(reservation);
|
|
135
|
+
}
|
|
136
|
+
return reading;
|
|
137
|
+
})().finally(() => {
|
|
138
|
+
this.#inflight = undefined;
|
|
139
|
+
});
|
|
140
|
+
return this.#inflight;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Lets a push of `recipients` through or refuses it. Rejects when LINE's quota cannot be read, so a
|
|
145
|
+
* caller waits instead of sending without knowing.
|
|
146
|
+
*/
|
|
147
|
+
async reserve(recipients: number): Promise<Reserved> {
|
|
148
|
+
const reading = await this.#read();
|
|
149
|
+
this.#expire();
|
|
150
|
+
const caps = [reading.limit, this.#cap].filter(
|
|
151
|
+
(cap): cap is number => cap !== undefined,
|
|
152
|
+
);
|
|
153
|
+
const cap = caps.length > 0 ? Math.min(...caps) : Number.POSITIVE_INFINITY;
|
|
154
|
+
const reserved = this.reserved();
|
|
155
|
+
if (reading.used + reserved + recipients > cap)
|
|
156
|
+
return { ok: false, used: reading.used, reserved, recipients, cap };
|
|
157
|
+
const reservation: Reservation = { recipients };
|
|
158
|
+
this.#reservations.set(reservation, {
|
|
159
|
+
settledAt: undefined,
|
|
160
|
+
unseen: recipients,
|
|
161
|
+
});
|
|
162
|
+
return { ok: true, reservation };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Ends a reservation: `sent` (also for a push whose outcome is not known) keeps it counted until
|
|
167
|
+
* LINE's consumption has grown by its recipients or `holdMs` has passed, otherwise it is dropped
|
|
168
|
+
* now.
|
|
169
|
+
*/
|
|
170
|
+
settle(reservation: Reservation, sent: boolean): void {
|
|
171
|
+
const state = this.#reservations.get(reservation);
|
|
172
|
+
if (!state) return;
|
|
173
|
+
if (sent) state.settledAt = this.#now().getTime();
|
|
174
|
+
else this.#reservations.delete(reservation);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Lets go of pushes that have been reserved for `holdMs` since they were sent. */
|
|
178
|
+
#expire(): void {
|
|
179
|
+
const now = this.#now().getTime();
|
|
180
|
+
for (const [reservation, state] of this.#reservations)
|
|
181
|
+
if (
|
|
182
|
+
state.settledAt !== undefined &&
|
|
183
|
+
now - state.settledAt >= this.#holdMs
|
|
184
|
+
)
|
|
185
|
+
this.#reservations.delete(reservation);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Messages reserved and not yet in LINE's consumption, for tests and status. */
|
|
189
|
+
reserved(): number {
|
|
190
|
+
this.#expire();
|
|
191
|
+
let total = 0;
|
|
192
|
+
for (const state of this.#reservations.values()) total += state.unseen;
|
|
193
|
+
return total;
|
|
194
|
+
}
|
|
195
|
+
}
|
package/src/retention.ts
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import type { SQL } from "bun";
|
|
2
|
+
import type { Logger } from "pi-roundtable";
|
|
3
|
+
import { errorCode } from "./errors.ts";
|
|
4
|
+
|
|
5
|
+
const DAY_MS = 24 * 60 * 60_000;
|
|
6
|
+
|
|
7
|
+
export interface RetentionOptions {
|
|
8
|
+
/**
|
|
9
|
+
* How long events are kept after they are done, skipped or dead, and how long one that never got
|
|
10
|
+
* handled (no subscriber was attached) waits before it is dropped with its payload; default 7 days.
|
|
11
|
+
*/
|
|
12
|
+
inboxMs: number;
|
|
13
|
+
/** How long a message's metadata and tokens, its references and its tombstone are kept; default 7 days. */
|
|
14
|
+
messagesMs: number;
|
|
15
|
+
/**
|
|
16
|
+
* How long finished pushes (and a push that never got sent), and the record of a join notice, are
|
|
17
|
+
* kept; default 7 days. A push loses its message objects when it finishes, long before this.
|
|
18
|
+
*/
|
|
19
|
+
outboxMs: number;
|
|
20
|
+
/**
|
|
21
|
+
* How long a ref of an unsent message may wait before the sweep hands it back to the subscriber as
|
|
22
|
+
* `unsent`; default 15 minutes. Such a ref is a stray: a handler that outlived its lease kept it
|
|
23
|
+
* after the unsend had run.
|
|
24
|
+
*/
|
|
25
|
+
strayMs: number;
|
|
26
|
+
/** How often the sweeper runs; default 1 hour. At most 2_147_483_647. */
|
|
27
|
+
intervalMs: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const RETENTION_DEFAULTS: RetentionOptions = {
|
|
31
|
+
inboxMs: 7 * DAY_MS,
|
|
32
|
+
messagesMs: 7 * DAY_MS,
|
|
33
|
+
outboxMs: 7 * DAY_MS,
|
|
34
|
+
strayMs: 15 * 60_000,
|
|
35
|
+
intervalMs: 60 * 60_000,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** The longest delay a timer holds. */
|
|
39
|
+
const MAX_TIMER_MS = 2_147_483_647;
|
|
40
|
+
|
|
41
|
+
/** Refuses a mistake in the retention options, naming the field. */
|
|
42
|
+
export function checkRetention(options: Partial<RetentionOptions>): void {
|
|
43
|
+
for (const [key, value] of Object.entries(options)) {
|
|
44
|
+
if (!(key in RETENTION_DEFAULTS))
|
|
45
|
+
throw new Error(`line: retention.${key} is not a retention option`);
|
|
46
|
+
if (!Number.isInteger(value) || (value as number) < 1)
|
|
47
|
+
throw new Error(
|
|
48
|
+
`line: retention.${key} must be a positive whole number of milliseconds; got ${String(value)}`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
if ((options.intervalMs ?? 0) > MAX_TIMER_MS)
|
|
52
|
+
throw new Error(
|
|
53
|
+
`line: retention.intervalMs must be at most ${MAX_TIMER_MS} milliseconds (about 24 days); got ${String(options.intervalMs)}`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* One table the sweeper trims. `window` names the retention option that says how old a row may get;
|
|
59
|
+
* `run` deletes the rows older than `cutoff` and answers how many went.
|
|
60
|
+
*/
|
|
61
|
+
interface RetentionTask {
|
|
62
|
+
name: string;
|
|
63
|
+
window: "inboxMs" | "messagesMs" | "outboxMs";
|
|
64
|
+
run(sql: SQL, cutoff: Date): Promise<number>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Deletes with `DELETE ... RETURNING` and counts the rows that went. */
|
|
68
|
+
async function count(
|
|
69
|
+
rows: PromiseLike<{ n: number | string }[]>,
|
|
70
|
+
): Promise<number> {
|
|
71
|
+
return Number((await rows)[0]?.n ?? 0);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const TASKS: readonly RetentionTask[] = [
|
|
75
|
+
{
|
|
76
|
+
name: "inbox",
|
|
77
|
+
window: "inboxMs",
|
|
78
|
+
run: (sql, cutoff) =>
|
|
79
|
+
count(sql`WITH gone AS (
|
|
80
|
+
DELETE FROM line_inbox
|
|
81
|
+
WHERE status IN ('done', 'skipped', 'dead') AND completed_at < ${cutoff}
|
|
82
|
+
RETURNING 1) SELECT count(*)::int AS n FROM gone`),
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
// Waiting events keep their payload; with no subscriber they would keep it for ever.
|
|
86
|
+
name: "inbox_waiting",
|
|
87
|
+
window: "inboxMs",
|
|
88
|
+
run: (sql, cutoff) =>
|
|
89
|
+
count(sql`WITH gone AS (
|
|
90
|
+
DELETE FROM line_inbox WHERE status = 'pending' AND created_at < ${cutoff}
|
|
91
|
+
RETURNING 1) SELECT count(*)::int AS n FROM gone`),
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
name: "messages",
|
|
95
|
+
window: "messagesMs",
|
|
96
|
+
run: (sql, cutoff) =>
|
|
97
|
+
count(sql`WITH gone AS (
|
|
98
|
+
DELETE FROM line_messages WHERE created_at < ${cutoff}
|
|
99
|
+
RETURNING 1) SELECT count(*)::int AS n FROM gone`),
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: "refs",
|
|
103
|
+
window: "messagesMs",
|
|
104
|
+
run: (sql, cutoff) =>
|
|
105
|
+
count(sql`WITH gone AS (
|
|
106
|
+
DELETE FROM line_message_refs WHERE created_at < ${cutoff}
|
|
107
|
+
RETURNING 1) SELECT count(*)::int AS n FROM gone`),
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
name: "tombstones",
|
|
111
|
+
window: "messagesMs",
|
|
112
|
+
run: (sql, cutoff) =>
|
|
113
|
+
count(sql`WITH gone AS (
|
|
114
|
+
DELETE FROM line_tombstones WHERE created_at < ${cutoff}
|
|
115
|
+
RETURNING 1) SELECT count(*)::int AS n FROM gone`),
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
// A push that finished, and one that never did (a chat that stopped being allowed, say).
|
|
119
|
+
name: "outbox",
|
|
120
|
+
window: "outboxMs",
|
|
121
|
+
run: (sql, cutoff) =>
|
|
122
|
+
count(sql`WITH gone AS (
|
|
123
|
+
DELETE FROM line_outbox
|
|
124
|
+
WHERE (status IN ('sent', 'dead', 'refused') AND completed_at < ${cutoff})
|
|
125
|
+
OR (status IN ('queued', 'sending') AND created_at < ${cutoff})
|
|
126
|
+
RETURNING 1) SELECT count(*)::int AS n FROM gone`),
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
name: "notices",
|
|
130
|
+
window: "outboxMs",
|
|
131
|
+
run: (sql, cutoff) =>
|
|
132
|
+
count(sql`WITH gone AS (
|
|
133
|
+
DELETE FROM line_notices WHERE created_at < ${cutoff}
|
|
134
|
+
RETURNING 1) SELECT count(*)::int AS n FROM gone`),
|
|
135
|
+
},
|
|
136
|
+
];
|
|
137
|
+
|
|
138
|
+
export interface RetentionDeps {
|
|
139
|
+
sql: SQL;
|
|
140
|
+
logger: Logger;
|
|
141
|
+
now: () => Date;
|
|
142
|
+
options?: Partial<RetentionOptions>;
|
|
143
|
+
/**
|
|
144
|
+
* Hands the refs of unsent messages that nobody took back to the subscriber, those stored before
|
|
145
|
+
* `before`; answers how many messages it handed back. Runs first in every sweep, so a stray is
|
|
146
|
+
* taken back before retention would forget it.
|
|
147
|
+
*/
|
|
148
|
+
strays?: (before: Date) => Promise<number>;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Trims the package's tables once an hour: events that are finished or never handled, and the
|
|
153
|
+
* metadata, references and tombstones of messages. It logs counts only. A table that cannot be
|
|
154
|
+
* trimmed is logged by its code and the others go on.
|
|
155
|
+
*/
|
|
156
|
+
export class RetentionSweeper {
|
|
157
|
+
readonly #sql: SQL;
|
|
158
|
+
readonly #logger: Logger;
|
|
159
|
+
readonly #now: () => Date;
|
|
160
|
+
readonly #strays: ((before: Date) => Promise<number>) | undefined;
|
|
161
|
+
readonly options: RetentionOptions;
|
|
162
|
+
#timer: ReturnType<typeof setTimeout> | undefined;
|
|
163
|
+
#running = false;
|
|
164
|
+
#sweeping: Promise<Record<string, number>> | undefined;
|
|
165
|
+
|
|
166
|
+
constructor(deps: RetentionDeps) {
|
|
167
|
+
this.#sql = deps.sql;
|
|
168
|
+
this.#logger = deps.logger;
|
|
169
|
+
this.#now = deps.now;
|
|
170
|
+
this.#strays = deps.strays;
|
|
171
|
+
this.options = { ...RETENTION_DEFAULTS, ...deps.options };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** `"retention"` while a sweep runs, for the shutdown drain. */
|
|
175
|
+
busy(): string[] {
|
|
176
|
+
return this.#sweeping ? ["retention"] : [];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** One sweep now; rows older than their window are gone. Returns the count deleted from each table. */
|
|
180
|
+
sweepOnce(): Promise<Record<string, number>> {
|
|
181
|
+
this.#sweeping ??= this.#sweep().finally(() => {
|
|
182
|
+
this.#sweeping = undefined;
|
|
183
|
+
});
|
|
184
|
+
return this.#sweeping;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async #sweep(): Promise<Record<string, number>> {
|
|
188
|
+
const counts: Record<string, number> = {};
|
|
189
|
+
const now = this.#now().getTime();
|
|
190
|
+
if (this.#strays) {
|
|
191
|
+
try {
|
|
192
|
+
counts.strays = await this.#strays(
|
|
193
|
+
new Date(now - this.options.strayMs),
|
|
194
|
+
);
|
|
195
|
+
} catch (error) {
|
|
196
|
+
counts.strays = 0;
|
|
197
|
+
this.#logger.warn(
|
|
198
|
+
{ table: "strays", code: errorCode(error) },
|
|
199
|
+
"a LINE retention sweep could not hand back stray refs",
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
for (const task of TASKS) {
|
|
204
|
+
const cutoff = new Date(now - this.options[task.window]);
|
|
205
|
+
try {
|
|
206
|
+
counts[task.name] = await task.run(this.#sql, cutoff);
|
|
207
|
+
} catch (error) {
|
|
208
|
+
counts[task.name] = 0;
|
|
209
|
+
this.#logger.warn(
|
|
210
|
+
{ table: task.name, code: errorCode(error) },
|
|
211
|
+
"a LINE retention sweep could not trim a table",
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (Object.values(counts).some((n) => n > 0))
|
|
216
|
+
this.#logger.info(counts, "LINE retention removed rows");
|
|
217
|
+
return counts;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Sweeps now, then every `intervalMs`. */
|
|
221
|
+
start(): void {
|
|
222
|
+
if (this.#running) return;
|
|
223
|
+
this.#running = true;
|
|
224
|
+
this.#schedule(0);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
#schedule(delayMs: number): void {
|
|
228
|
+
this.#timer = setTimeout(() => {
|
|
229
|
+
void this.sweepOnce().finally(() => {
|
|
230
|
+
if (this.#running) this.#schedule(this.options.intervalMs);
|
|
231
|
+
});
|
|
232
|
+
}, delayMs);
|
|
233
|
+
this.#timer.unref?.();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Stops the schedule and waits for a sweep in progress. */
|
|
237
|
+
async stop(): Promise<void> {
|
|
238
|
+
this.#running = false;
|
|
239
|
+
if (this.#timer) clearTimeout(this.#timer);
|
|
240
|
+
this.#timer = undefined;
|
|
241
|
+
await this.#sweeping?.catch(() => undefined);
|
|
242
|
+
}
|
|
243
|
+
}
|
package/src/route.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { InboundMessage } from "pi-roundtable";
|
|
2
|
+
import { parseSourceKey } from "./events.ts";
|
|
3
|
+
import type { LineMessageEvent } from "./messages.ts";
|
|
4
|
+
|
|
5
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
6
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The text of a LINE message with the bot's own mentions cut out, and whether it had any. LINE
|
|
10
|
+
* names each mention by position and length in the text.
|
|
11
|
+
*/
|
|
12
|
+
export function stripSelfMentions(
|
|
13
|
+
text: string,
|
|
14
|
+
message: Record<string, unknown>,
|
|
15
|
+
): { text: string; mentionsBot: boolean } {
|
|
16
|
+
const mentionees = isRecord(message.mention)
|
|
17
|
+
? message.mention.mentionees
|
|
18
|
+
: [];
|
|
19
|
+
const spans = (Array.isArray(mentionees) ? mentionees : [])
|
|
20
|
+
.filter(
|
|
21
|
+
(m): m is Record<string, unknown> => isRecord(m) && m.isSelf === true,
|
|
22
|
+
)
|
|
23
|
+
.map((m) => ({ start: Number(m.index), length: Number(m.length) }))
|
|
24
|
+
.filter(
|
|
25
|
+
(s) =>
|
|
26
|
+
Number.isInteger(s.start) &&
|
|
27
|
+
Number.isInteger(s.length) &&
|
|
28
|
+
s.start >= 0 &&
|
|
29
|
+
s.length > 0 &&
|
|
30
|
+
s.start + s.length <= text.length,
|
|
31
|
+
)
|
|
32
|
+
.sort((a, b) => b.start - a.start);
|
|
33
|
+
let stripped = text;
|
|
34
|
+
for (const span of spans)
|
|
35
|
+
stripped =
|
|
36
|
+
stripped.slice(0, span.start) + stripped.slice(span.start + span.length);
|
|
37
|
+
return { text: stripped.trim(), mentionsBot: spans.length > 0 };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A text message of an allowed chat as the host's router takes it, or undefined when it is not one
|
|
42
|
+
* the router can serve: not text, or from a sender LINE did not name, who cannot be resolved to a
|
|
43
|
+
* person.
|
|
44
|
+
*/
|
|
45
|
+
export function inboundOf(
|
|
46
|
+
event: LineMessageEvent,
|
|
47
|
+
message: Record<string, unknown>,
|
|
48
|
+
): InboundMessage | undefined {
|
|
49
|
+
if (event.body.kind !== "text" || !event.userId) return undefined;
|
|
50
|
+
const source = parseSourceKey(event.sourceKey);
|
|
51
|
+
if (!source) return undefined;
|
|
52
|
+
const { text, mentionsBot } = stripSelfMentions(event.body.text, message);
|
|
53
|
+
return {
|
|
54
|
+
channel: `line:${source.kind}:${source.id}`,
|
|
55
|
+
messageId: event.messageId,
|
|
56
|
+
actor: {
|
|
57
|
+
provider: "line",
|
|
58
|
+
subject: event.userId,
|
|
59
|
+
name: event.userId,
|
|
60
|
+
surface: "line",
|
|
61
|
+
},
|
|
62
|
+
authorId: event.userId,
|
|
63
|
+
authorName: event.userId,
|
|
64
|
+
authorIsBot: false,
|
|
65
|
+
isDirect: source.kind === "user",
|
|
66
|
+
mentionsBot,
|
|
67
|
+
repliesToBot: false,
|
|
68
|
+
text,
|
|
69
|
+
attachments: [],
|
|
70
|
+
};
|
|
71
|
+
}
|
package/src/sender.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { LineError } from "./errors.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The name and icon a push is shown under, in place of the channel's own. LINE shows the name as
|
|
5
|
+
* "<name> from '<channel>'". Either part may be given; a push with none uses the channel's.
|
|
6
|
+
*/
|
|
7
|
+
export interface LineSender {
|
|
8
|
+
/** 1 to 20 characters, none of the forbidden words. */
|
|
9
|
+
name?: string;
|
|
10
|
+
/** An `https` URL of at most 2000 characters that LINE can fetch. */
|
|
11
|
+
iconUrl?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SenderOptions {
|
|
15
|
+
/**
|
|
16
|
+
* Words a sender name may not contain, compared without regard to case or width and only as whole words
|
|
17
|
+
* (a word is a run of Latin letters and digits, or a run of letters of another script, so "LINE"
|
|
18
|
+
* next to kana or hanzi is still the word). Default `["LINE", "LY"]`, the words LINE refuses.
|
|
19
|
+
*/
|
|
20
|
+
forbiddenWords: readonly string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const SENDER_DEFAULTS: SenderOptions = {
|
|
24
|
+
forbiddenWords: ["LINE", "LY"],
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const SENDER_NAME_MAX = 20;
|
|
28
|
+
export const SENDER_ICON_MAX = 2000;
|
|
29
|
+
|
|
30
|
+
const bad = (message: string): LineError =>
|
|
31
|
+
new LineError("bad_sender", `line: ${message}`);
|
|
32
|
+
|
|
33
|
+
/** Refuses a mistake in the sender options, naming the field. */
|
|
34
|
+
export function checkSenderOptions(options: Partial<SenderOptions>): void {
|
|
35
|
+
for (const key of Object.keys(options))
|
|
36
|
+
if (key !== "forbiddenWords")
|
|
37
|
+
throw new Error(`line: sender.${key} is not a sender option`);
|
|
38
|
+
const words = options.forbiddenWords;
|
|
39
|
+
if (words === undefined) return;
|
|
40
|
+
if (
|
|
41
|
+
!Array.isArray(words) ||
|
|
42
|
+
words.some((w) => typeof w !== "string" || w.trim() === "")
|
|
43
|
+
)
|
|
44
|
+
throw new Error(
|
|
45
|
+
"line: sender.forbiddenWords must be a list of non-empty words",
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// A word is a run of Latin letters and digits, or a run of letters of another script: a boundary
|
|
50
|
+
// between the two breaks a word, so a name like "LINE" followed by kana still holds the word.
|
|
51
|
+
const WORD =
|
|
52
|
+
/(?:\p{Script=Latin}|\p{M}|\p{N})+|(?:(?!\p{Script=Latin})\p{L})+/gu;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Checks a sender before anything is queued and returns it as it will be sent. Throws `LineError`
|
|
56
|
+
* `bad_sender` for a name that is empty, over 20 characters, holds a control character or a
|
|
57
|
+
* forbidden word, or an icon that is not an `https` URL of at most 2000 characters; the message
|
|
58
|
+
* names the rule, never the value.
|
|
59
|
+
*/
|
|
60
|
+
export function validateSender(
|
|
61
|
+
sender: LineSender | undefined,
|
|
62
|
+
options: SenderOptions = SENDER_DEFAULTS,
|
|
63
|
+
): LineSender | undefined {
|
|
64
|
+
if (sender === undefined) return undefined;
|
|
65
|
+
if (typeof sender !== "object" || sender === null || Array.isArray(sender))
|
|
66
|
+
throw bad("a sender is { name, iconUrl }");
|
|
67
|
+
const { name, iconUrl, ...rest } = sender as Record<string, unknown>;
|
|
68
|
+
if (Object.keys(rest).length > 0)
|
|
69
|
+
throw bad("a sender has a name and an iconUrl, nothing else");
|
|
70
|
+
if (name === undefined && iconUrl === undefined)
|
|
71
|
+
throw bad("a sender needs a name or an iconUrl");
|
|
72
|
+
const checked: LineSender = {};
|
|
73
|
+
if (name !== undefined) {
|
|
74
|
+
if (typeof name !== "string" || name.trim() === "")
|
|
75
|
+
throw bad("a sender name is a non-empty string");
|
|
76
|
+
if (name !== name.trim())
|
|
77
|
+
throw bad("a sender name has no leading or trailing space");
|
|
78
|
+
if (Array.from(name).length > SENDER_NAME_MAX)
|
|
79
|
+
throw bad(`a sender name is at most ${SENDER_NAME_MAX} characters`);
|
|
80
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: control characters are what is refused
|
|
81
|
+
if (/[\u0000-\u001f\u007f]/.test(name))
|
|
82
|
+
throw bad("a sender name has no control characters");
|
|
83
|
+
// Full-width letters are the letters they look like.
|
|
84
|
+
const words = (name.normalize("NFKC").match(WORD) ?? []).map((w) =>
|
|
85
|
+
w.toLowerCase(),
|
|
86
|
+
);
|
|
87
|
+
const forbidden = options.forbiddenWords.map((w) =>
|
|
88
|
+
w.normalize("NFKC").toLowerCase(),
|
|
89
|
+
);
|
|
90
|
+
if (words.some((w) => forbidden.includes(w)))
|
|
91
|
+
throw bad("a sender name may not use a word LINE refuses");
|
|
92
|
+
checked.name = name;
|
|
93
|
+
}
|
|
94
|
+
if (iconUrl !== undefined) {
|
|
95
|
+
if (typeof iconUrl !== "string" || iconUrl.length > SENDER_ICON_MAX)
|
|
96
|
+
throw bad(
|
|
97
|
+
`a sender iconUrl is a string of at most ${SENDER_ICON_MAX} characters`,
|
|
98
|
+
);
|
|
99
|
+
let url: URL;
|
|
100
|
+
try {
|
|
101
|
+
url = new URL(iconUrl);
|
|
102
|
+
} catch {
|
|
103
|
+
throw bad("a sender iconUrl is a URL");
|
|
104
|
+
}
|
|
105
|
+
if (url.protocol !== "https:" || url.hostname === "" || url.username)
|
|
106
|
+
throw bad("a sender iconUrl is an https URL without credentials");
|
|
107
|
+
checked.iconUrl = iconUrl;
|
|
108
|
+
}
|
|
109
|
+
return checked;
|
|
110
|
+
}
|
package/src/surface.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ChannelKey,
|
|
3
|
+
type ChatSurface,
|
|
4
|
+
type InboundMessage,
|
|
5
|
+
type OutboundReply,
|
|
6
|
+
PluginError,
|
|
7
|
+
parseChannelKey,
|
|
8
|
+
} from "pi-roundtable";
|
|
9
|
+
import { LineError } from "./errors.ts";
|
|
10
|
+
import { parseSourceKey } from "./events.ts";
|
|
11
|
+
import type { LineOutbox, LineOutMessage } from "./outbox.ts";
|
|
12
|
+
|
|
13
|
+
/** The longest text one LINE message holds. */
|
|
14
|
+
const TEXT_MAX = 5000;
|
|
15
|
+
|
|
16
|
+
/** `text` cut into pieces LINE takes, never inside a surrogate pair. */
|
|
17
|
+
function pieces(text: string): string[] {
|
|
18
|
+
const parts: string[] = [];
|
|
19
|
+
let rest = text;
|
|
20
|
+
while (rest.length > TEXT_MAX) {
|
|
21
|
+
let cut = TEXT_MAX;
|
|
22
|
+
const last = rest.charCodeAt(cut - 1);
|
|
23
|
+
if (last >= 0xd800 && last <= 0xdbff) cut--;
|
|
24
|
+
parts.push(rest.slice(0, cut));
|
|
25
|
+
rest = rest.slice(cut);
|
|
26
|
+
}
|
|
27
|
+
if (rest.trim() !== "") parts.push(rest);
|
|
28
|
+
return parts;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The chats of LINE as a chat surface, under the prefix `line`: the channel of a chat is its source
|
|
33
|
+
* key, `line:group:<id>`, `line:room:<id>` or `line:user:<id>`. What the host writes in a channel is
|
|
34
|
+
* queued in the outbox as text messages, one per chunk, and goes out as a push; it reaches only a
|
|
35
|
+
* chat on the allowlist. Messages come in only when the plugin routes them (`route: true`), as the
|
|
36
|
+
* text of allowed chats. The surface offers no typing indicator, stop control, progress, files or
|
|
37
|
+
* approval prompts: LINE has none the bot can show, and an approval would have to be answered by
|
|
38
|
+
* the people of the chat.
|
|
39
|
+
*/
|
|
40
|
+
export class LineSurface implements ChatSurface {
|
|
41
|
+
readonly surface = "line";
|
|
42
|
+
readonly #outbox: LineOutbox;
|
|
43
|
+
#deliver: ((message: InboundMessage) => void) | undefined;
|
|
44
|
+
|
|
45
|
+
constructor(outbox: LineOutbox) {
|
|
46
|
+
this.#outbox = outbox;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async start(deliver: (message: InboundMessage) => void): Promise<void> {
|
|
50
|
+
this.#deliver = deliver;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async stop(): Promise<void> {
|
|
54
|
+
this.#deliver = undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Hands a message to the host's router; throws until the host has started the surface. */
|
|
58
|
+
deliver(message: InboundMessage): void {
|
|
59
|
+
if (!this.#deliver)
|
|
60
|
+
throw new LineError(
|
|
61
|
+
"surface_not_started",
|
|
62
|
+
"line: the surface has not started; messages are routed once the host runs",
|
|
63
|
+
);
|
|
64
|
+
this.#deliver(message);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async sendReply(channel: ChannelKey, reply: OutboundReply): Promise<void> {
|
|
68
|
+
const { surface } = parseChannelKey(channel);
|
|
69
|
+
if (surface !== this.surface || !parseSourceKey(channel))
|
|
70
|
+
throw new PluginError(`${channel} is not a LINE chat`);
|
|
71
|
+
const messages: LineOutMessage[] = reply.chunks
|
|
72
|
+
.flatMap(pieces)
|
|
73
|
+
.map((text) => ({ type: "text", text }));
|
|
74
|
+
if (messages.length > 0) await this.#outbox.enqueue(channel, messages);
|
|
75
|
+
}
|
|
76
|
+
}
|