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/icon.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { HttpRoute } from "pi-roundtable";
|
|
2
|
+
|
|
3
|
+
/** Serves one fixed PNG, so the host can give LINE a stable `iconUrl` for a sender without another service. */
|
|
4
|
+
export interface SenderIconRoute {
|
|
5
|
+
/** Where it is served, such as `/line/sender-icon.png`. */
|
|
6
|
+
path: string;
|
|
7
|
+
/** The PNG's bytes. */
|
|
8
|
+
png: Uint8Array;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
12
|
+
/** What LINE accepts of a sender icon. */
|
|
13
|
+
const ICON_MAX_BYTES = 1024 * 1024;
|
|
14
|
+
|
|
15
|
+
/** Refuses an icon that is not a PNG LINE could show. */
|
|
16
|
+
export function checkIcon(png: Uint8Array): void {
|
|
17
|
+
if (!(png instanceof Uint8Array) || png.length === 0)
|
|
18
|
+
throw new Error("line: senderIconRoute.png must hold the bytes of a PNG");
|
|
19
|
+
if (!PNG_SIGNATURE.every((byte, index) => png[index] === byte))
|
|
20
|
+
throw new Error("line: senderIconRoute.png is not a PNG file");
|
|
21
|
+
if (png.length > ICON_MAX_BYTES)
|
|
22
|
+
throw new Error(
|
|
23
|
+
`line: senderIconRoute.png must be at most ${ICON_MAX_BYTES} bytes`,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The route that serves the icon, to `GET` and `HEAD`, with a cache header for a year. */
|
|
28
|
+
export function iconRoute(
|
|
29
|
+
options: SenderIconRoute,
|
|
30
|
+
listener: string,
|
|
31
|
+
): HttpRoute {
|
|
32
|
+
const bytes = new Uint8Array(options.png);
|
|
33
|
+
const headers = {
|
|
34
|
+
"content-type": "image/png",
|
|
35
|
+
"content-length": String(bytes.length),
|
|
36
|
+
"cache-control": "public, max-age=31536000, immutable",
|
|
37
|
+
};
|
|
38
|
+
return {
|
|
39
|
+
name: "line-sender-icon",
|
|
40
|
+
listener,
|
|
41
|
+
path: { exact: options.path },
|
|
42
|
+
methods: ["GET", "HEAD"],
|
|
43
|
+
handle: (request) =>
|
|
44
|
+
new Response(request.method === "HEAD" ? null : bytes, { headers }),
|
|
45
|
+
};
|
|
46
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,21 +1,55 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
LineContentRequest,
|
|
3
|
+
LineContentResult,
|
|
4
|
+
MediaOptions,
|
|
5
|
+
} from "./content.ts";
|
|
1
6
|
export { errorCode, LineApiError, LineError } from "./errors.ts";
|
|
2
7
|
export type { ParsedEvent, SourceKind } from "./events.ts";
|
|
3
8
|
export { parseSourceKey, sourceKeyOf } from "./events.ts";
|
|
9
|
+
export type { SenderIconRoute } from "./icon.ts";
|
|
4
10
|
export type {
|
|
5
11
|
InboxEvent,
|
|
6
12
|
InboxOptions,
|
|
7
13
|
InboxProcessor,
|
|
8
14
|
InboxResult,
|
|
9
15
|
} from "./inbox.ts";
|
|
16
|
+
export type { JoinNoticeOptions } from "./join.ts";
|
|
17
|
+
export { digestOf } from "./join.ts";
|
|
18
|
+
export type {
|
|
19
|
+
JoinNotice,
|
|
20
|
+
LineContext,
|
|
21
|
+
LineEditedEvent,
|
|
22
|
+
LineEvent,
|
|
23
|
+
LineJoinedEvent,
|
|
24
|
+
LineLeftEvent,
|
|
25
|
+
LineMemberJoinedEvent,
|
|
26
|
+
LineMemberLeftEvent,
|
|
27
|
+
LineMessageBody,
|
|
28
|
+
LineMessageEvent,
|
|
29
|
+
LineMirror,
|
|
30
|
+
LineOutboxResultEvent,
|
|
31
|
+
LineSubscriber,
|
|
32
|
+
LineUnsentEvent,
|
|
33
|
+
} from "./messages.ts";
|
|
10
34
|
export type { LineOptions } from "./options.ts";
|
|
35
|
+
export type {
|
|
36
|
+
EnqueueOptions,
|
|
37
|
+
LineOutbox,
|
|
38
|
+
LineOutMessage,
|
|
39
|
+
OutboxOptions,
|
|
40
|
+
} from "./outbox.ts";
|
|
11
41
|
export { line } from "./plugin.ts";
|
|
12
42
|
export type { LineBridgePort } from "./port.ts";
|
|
13
43
|
export { LINE } from "./port.ts";
|
|
44
|
+
export type { QuotaOptions } from "./quota.ts";
|
|
45
|
+
export type { RetentionOptions } from "./retention.ts";
|
|
46
|
+
export type { LineSender, SenderOptions } from "./sender.ts";
|
|
14
47
|
export type {
|
|
15
48
|
LineSource,
|
|
16
49
|
LineSourceSummary,
|
|
17
50
|
LineSources,
|
|
18
51
|
SourceStatus,
|
|
19
52
|
} from "./sources.ts";
|
|
53
|
+
export { LineSurface } from "./surface.ts";
|
|
20
54
|
export type { LineCredentials, TokenMode } from "./token.ts";
|
|
21
55
|
export { WEBHOOK_MAX_BYTES } from "./webhook.ts";
|
package/src/intake.ts
CHANGED
|
@@ -24,11 +24,11 @@ const MEMBERSHIP = new Set(["join", "leave"]);
|
|
|
24
24
|
* the whole request's transaction and with it every other event stored beside this one. They are
|
|
25
25
|
* replaced with U+FFFD, which loses nothing a chat mirror could use.
|
|
26
26
|
*/
|
|
27
|
-
function clean(text: string): string {
|
|
27
|
+
export function clean(text: string): string {
|
|
28
28
|
return text.replaceAll("\u0000", "\uFFFD").toWellFormed();
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
function scrub(value: unknown): unknown {
|
|
31
|
+
export function scrub(value: unknown): unknown {
|
|
32
32
|
if (typeof value === "string") return clean(value);
|
|
33
33
|
if (Array.isArray(value)) return value.map(scrub);
|
|
34
34
|
if (typeof value === "object" && value !== null)
|
package/src/join.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { SQL } from "bun";
|
|
3
|
+
import type { Logger } from "pi-roundtable";
|
|
4
|
+
import type { LineApi } from "./api.ts";
|
|
5
|
+
import { errorCode } from "./errors.ts";
|
|
6
|
+
import { type JoinNotice, nonEmpty } from "./messages.ts";
|
|
7
|
+
|
|
8
|
+
/** The most characters one LINE text message holds. */
|
|
9
|
+
export const NOTICE_MAX_CHARS = 5000;
|
|
10
|
+
|
|
11
|
+
/** The text sent when the bot joins a chat, and the SHA-256 (hex) of its UTF-8 bytes that gates it. */
|
|
12
|
+
export interface JoinNoticeOptions {
|
|
13
|
+
text: string;
|
|
14
|
+
sha256: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const DIGEST = /^[0-9a-f]{64}$/;
|
|
18
|
+
|
|
19
|
+
/** The SHA-256 of a text's UTF-8 bytes, as lower-case hex. */
|
|
20
|
+
export const digestOf = (text: string): string =>
|
|
21
|
+
createHash("sha256").update(text, "utf8").digest("hex");
|
|
22
|
+
|
|
23
|
+
/** Refuses a join notice that cannot work: a digest that is not 64 hex digits, or a text LINE would refuse. */
|
|
24
|
+
export function checkJoinNotice(notice: JoinNoticeOptions): void {
|
|
25
|
+
if (typeof notice !== "object" || notice === null)
|
|
26
|
+
throw new Error("line: joinNotice is { text, sha256 }");
|
|
27
|
+
if (typeof notice.sha256 !== "string" || !DIGEST.test(notice.sha256))
|
|
28
|
+
throw new Error(
|
|
29
|
+
"line: joinNotice.sha256 must be 64 lower-case hex digits (the SHA-256 of the text's UTF-8 bytes)",
|
|
30
|
+
);
|
|
31
|
+
if (
|
|
32
|
+
typeof notice.text !== "string" ||
|
|
33
|
+
notice.text === "" ||
|
|
34
|
+
notice.text.length > NOTICE_MAX_CHARS
|
|
35
|
+
)
|
|
36
|
+
throw new Error(
|
|
37
|
+
`line: joinNotice.text must be a text of 1 to ${NOTICE_MAX_CHARS} characters`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface JoinNotifierDeps {
|
|
42
|
+
sql: SQL;
|
|
43
|
+
api: LineApi;
|
|
44
|
+
logger: Logger;
|
|
45
|
+
now: () => Date;
|
|
46
|
+
notice?: JoinNoticeOptions;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Answers a `join` event with the configured notice, once, as a Reply with the event's reply token.
|
|
51
|
+
* It sends only text whose SHA-256 is the configured digest, to any chat the bot joins, allowed or
|
|
52
|
+
* not, and never as a push: when LINE refuses the Reply or the token has lapsed the notice is `failed`,
|
|
53
|
+
* and nothing else is sent. A Reply that got no answer is `unconfirmed`, and its record stays
|
|
54
|
+
* `attempting`.
|
|
55
|
+
*
|
|
56
|
+
* The outcome is recorded before the Reply is made, so a join that is handed out again never
|
|
57
|
+
* replies twice: it learns `sent` or `failed`, or `unconfirmed` when the first try was cut short
|
|
58
|
+
* and nobody knows whether LINE got the reply.
|
|
59
|
+
*/
|
|
60
|
+
export class JoinNotifier {
|
|
61
|
+
readonly #sql: SQL;
|
|
62
|
+
readonly #api: LineApi;
|
|
63
|
+
readonly #logger: Logger;
|
|
64
|
+
readonly #now: () => Date;
|
|
65
|
+
readonly #notice: JoinNoticeOptions | undefined;
|
|
66
|
+
readonly #matches: boolean;
|
|
67
|
+
|
|
68
|
+
constructor(deps: JoinNotifierDeps) {
|
|
69
|
+
this.#sql = deps.sql;
|
|
70
|
+
this.#api = deps.api;
|
|
71
|
+
this.#logger = deps.logger;
|
|
72
|
+
this.#now = deps.now;
|
|
73
|
+
this.#notice = deps.notice;
|
|
74
|
+
this.#matches = deps.notice
|
|
75
|
+
? digestOf(deps.notice.text) === deps.notice.sha256
|
|
76
|
+
: false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Whether a notice is configured and its text is the one the digest names. */
|
|
80
|
+
get armed(): boolean {
|
|
81
|
+
return this.#notice !== undefined && this.#matches;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async send(event: {
|
|
85
|
+
id: string;
|
|
86
|
+
payload: Record<string, unknown>;
|
|
87
|
+
}): Promise<JoinNotice> {
|
|
88
|
+
const notice = this.#notice;
|
|
89
|
+
// Nothing configured: nothing is sent, and nobody should take that for a notice.
|
|
90
|
+
if (!notice) return "unconfirmed";
|
|
91
|
+
if (!this.#matches) {
|
|
92
|
+
this.#logger.warn(
|
|
93
|
+
{ code: "join_notice_digest_mismatch" },
|
|
94
|
+
"the LINE join notice text is not the one its digest names, so it was not sent",
|
|
95
|
+
);
|
|
96
|
+
return "failed";
|
|
97
|
+
}
|
|
98
|
+
const claimed = await this
|
|
99
|
+
.#sql`INSERT INTO line_notices (webhook_event_id, outcome, created_at)
|
|
100
|
+
VALUES (${event.id}, 'attempting', ${this.#now()})
|
|
101
|
+
ON CONFLICT (webhook_event_id) DO NOTHING RETURNING 1`;
|
|
102
|
+
if (claimed.length === 0) {
|
|
103
|
+
const [row] = (await this
|
|
104
|
+
.#sql`SELECT outcome FROM line_notices WHERE webhook_event_id = ${event.id}`) as {
|
|
105
|
+
outcome: string;
|
|
106
|
+
}[];
|
|
107
|
+
return row?.outcome === "sent"
|
|
108
|
+
? "sent"
|
|
109
|
+
: row?.outcome === "failed"
|
|
110
|
+
? "failed"
|
|
111
|
+
: "unconfirmed";
|
|
112
|
+
}
|
|
113
|
+
const token = nonEmpty(event.payload.replyToken);
|
|
114
|
+
let outcome: "sent" | "failed" | "attempting" = "failed";
|
|
115
|
+
if (!token) {
|
|
116
|
+
this.#logger.warn(
|
|
117
|
+
{ code: "no_reply_token" },
|
|
118
|
+
"a LINE join came without a reply token, so the notice was not sent",
|
|
119
|
+
);
|
|
120
|
+
} else {
|
|
121
|
+
try {
|
|
122
|
+
await this.#api.reply(token, [{ type: "text", text: notice.text }]);
|
|
123
|
+
outcome = "sent";
|
|
124
|
+
} catch (error) {
|
|
125
|
+
const code = errorCode(error);
|
|
126
|
+
// No answer is not a refusal: LINE may have taken the Reply, so nobody can say it failed.
|
|
127
|
+
if (code === "api_unreachable") outcome = "attempting";
|
|
128
|
+
this.#logger.warn({ code }, "the LINE join notice could not be sent");
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (outcome === "attempting") return "unconfirmed";
|
|
132
|
+
await this
|
|
133
|
+
.#sql`UPDATE line_notices SET outcome = ${outcome} WHERE webhook_event_id = ${event.id}`;
|
|
134
|
+
return outcome;
|
|
135
|
+
}
|
|
136
|
+
}
|
package/src/messages.ts
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { LineError } from "./errors.ts";
|
|
2
|
+
|
|
3
|
+
/** What a message is, reduced to what a mirror needs. Media bytes are fetched apart, with `LINE.content`. */
|
|
4
|
+
export type LineMessageBody =
|
|
5
|
+
| { kind: "text"; text: string }
|
|
6
|
+
| { kind: "image" }
|
|
7
|
+
| { kind: "video"; durationMs?: number }
|
|
8
|
+
| { kind: "audio"; durationMs?: number }
|
|
9
|
+
| { kind: "file"; fileName: string; fileSize?: number }
|
|
10
|
+
| {
|
|
11
|
+
kind: "sticker";
|
|
12
|
+
packageId: string;
|
|
13
|
+
stickerId: string;
|
|
14
|
+
keywords?: string[];
|
|
15
|
+
text?: string;
|
|
16
|
+
}
|
|
17
|
+
| {
|
|
18
|
+
kind: "location";
|
|
19
|
+
title?: string;
|
|
20
|
+
address?: string;
|
|
21
|
+
latitude: number;
|
|
22
|
+
longitude: number;
|
|
23
|
+
}
|
|
24
|
+
/** A message type this version does not know; shown as a placeholder rather than dropped. */
|
|
25
|
+
| { kind: "unsupported"; messageType: string };
|
|
26
|
+
|
|
27
|
+
interface EventBase {
|
|
28
|
+
/** LINE's `webhookEventId`: the same on every redelivery, so a subscriber may use it to deduplicate. */
|
|
29
|
+
eventId: string;
|
|
30
|
+
/** The chat: `line:<group|room|user>:<id>`. */
|
|
31
|
+
sourceKey: string;
|
|
32
|
+
/** LINE's event time, milliseconds since the epoch. */
|
|
33
|
+
timestamp: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A new message. The subscriber mirrors it and answers `{ refs }`, the opaque references to what it made. */
|
|
37
|
+
export interface LineMessageEvent extends EventBase {
|
|
38
|
+
type: "message";
|
|
39
|
+
messageId: string;
|
|
40
|
+
/** The sender's user id, when LINE names one. */
|
|
41
|
+
userId?: string;
|
|
42
|
+
body: LineMessageBody;
|
|
43
|
+
/** The message this one quotes, when it quotes one. */
|
|
44
|
+
quotedMessageId?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The text of a message was edited (groups only; the event with the latest time is the latest state). */
|
|
48
|
+
export interface LineEditedEvent extends EventBase {
|
|
49
|
+
type: "edited";
|
|
50
|
+
messageId: string;
|
|
51
|
+
text: string;
|
|
52
|
+
/** What the subscriber answered for the original message, in the order it gave it. */
|
|
53
|
+
refs: readonly string[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The sender took a message back. By the time the subscriber hears of it the stored payload is gone
|
|
58
|
+
* and a tombstone stops the message from being mirrored again. `refs` are what it answered for the
|
|
59
|
+
* message, in the order it gave them; empty when nothing was mirrored (yet). Heard more than once
|
|
60
|
+
* when the event is redelivered, so deleting must be idempotent.
|
|
61
|
+
*/
|
|
62
|
+
export interface LineUnsentEvent extends EventBase {
|
|
63
|
+
type: "unsent";
|
|
64
|
+
messageId: string;
|
|
65
|
+
refs: readonly string[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Whether the join notice went out: `"sent"` (LINE took the Reply), `"failed"` (the Reply was
|
|
70
|
+
* refused, the token had lapsed or the notice's text is not the one its digest names; nothing else
|
|
71
|
+
* was sent in its place), or `"unconfirmed"` when no notice is configured, or the first try was cut
|
|
72
|
+
* short and nobody knows whether LINE got it.
|
|
73
|
+
*/
|
|
74
|
+
export type JoinNotice = "sent" | "unconfirmed" | "failed";
|
|
75
|
+
|
|
76
|
+
/** The bot was added to a chat. */
|
|
77
|
+
export interface LineJoinedEvent extends EventBase {
|
|
78
|
+
type: "joined";
|
|
79
|
+
notice: JoinNotice;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The bot was removed from a chat. */
|
|
83
|
+
export interface LineLeftEvent extends EventBase {
|
|
84
|
+
type: "left";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface LineMemberJoinedEvent extends EventBase {
|
|
88
|
+
type: "memberJoined";
|
|
89
|
+
userIds: readonly string[];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface LineMemberLeftEvent extends EventBase {
|
|
93
|
+
type: "memberLeft";
|
|
94
|
+
userIds: readonly string[];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* How a push ended, told once per `origin` the push carries (or once with `originRef: null` when it
|
|
99
|
+
* carries none). At least once: a subscriber that fails is told again later, so it must be idempotent.
|
|
100
|
+
* It has no event id or time; the push is not a webhook event.
|
|
101
|
+
*/
|
|
102
|
+
export interface LineOutboxResultEvent {
|
|
103
|
+
type: "outboxResult";
|
|
104
|
+
sourceKey: string;
|
|
105
|
+
/** The `origin` the subscriber gave when it enqueued, or null. */
|
|
106
|
+
originRef: string | null;
|
|
107
|
+
/**
|
|
108
|
+
* `sent` (accepted, or already accepted under the same retry key), `dead` (LINE refused it, or it
|
|
109
|
+
* kept failing, or its retry key lapsed), or `refused` (the quota gate would have been exceeded; nothing was sent).
|
|
110
|
+
*/
|
|
111
|
+
status: "sent" | "dead" | "refused";
|
|
112
|
+
/**
|
|
113
|
+
* Why the push did not go out: LINE's refusal as a code (`http_400`, `sender_name_refused`,
|
|
114
|
+
* `sender_icon_refused`, ...) or the package's reason (`quota_exhausted`, `retry_key_expired`,
|
|
115
|
+
* `source_not_allowed`); null when it was sent.
|
|
116
|
+
*/
|
|
117
|
+
errorCode: string | null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export type LineEvent =
|
|
121
|
+
| LineMessageEvent
|
|
122
|
+
| LineEditedEvent
|
|
123
|
+
| LineUnsentEvent
|
|
124
|
+
| LineJoinedEvent
|
|
125
|
+
| LineLeftEvent
|
|
126
|
+
| LineMemberJoinedEvent
|
|
127
|
+
| LineMemberLeftEvent
|
|
128
|
+
| LineOutboxResultEvent;
|
|
129
|
+
|
|
130
|
+
/** What a subscriber answers a `message` with. Any other event needs no answer. */
|
|
131
|
+
export interface LineMirror {
|
|
132
|
+
/** Opaque references to what the subscriber made for the message (one message may become several posts). */
|
|
133
|
+
refs: readonly string[];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* What a subscriber can do while it handles a `message`. For any other event `keep` rejects with
|
|
138
|
+
* `keep_unavailable`.
|
|
139
|
+
*/
|
|
140
|
+
export interface LineContext {
|
|
141
|
+
/**
|
|
142
|
+
* Keeps the reference of a post the subscriber has just made, at once: stored under the same
|
|
143
|
+
* per-message lock and tombstone check as the references it answers at the end, so the post can
|
|
144
|
+
* still be taken back if the handler fails later (the references stay stored for the retry and for
|
|
145
|
+
* an `unsent`). Calling it again for a kept reference changes nothing. Resolves `{ unsent: true }`
|
|
146
|
+
* when the sender had already taken the message back: the reference has then been handed to the
|
|
147
|
+
* subscriber as `unsent` already, and the subscriber should stop mirroring.
|
|
148
|
+
*/
|
|
149
|
+
keep(ref: string): Promise<{ unsent: boolean }>;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The one consumer of the package. It is called at least once per event, so it must be idempotent;
|
|
154
|
+
* `signal` aborts when the worker stops or the event is no longer its own. To give up, throw: the
|
|
155
|
+
* event is retried with backoff (a returned value is never read as a retry request, and a `message`
|
|
156
|
+
* answer that is not `{ refs }` fails with `bad_refs`).
|
|
157
|
+
*
|
|
158
|
+
* A `message` may become several posts. Call `context.keep(ref)` as each post lands: the references
|
|
159
|
+
* of posts made before a later one fails are then still known to the retry and to an `unsent`, so
|
|
160
|
+
* none is left behind. Refs returned at the end merge with the kept ones. Only a crash between a post
|
|
161
|
+
* and its `keep` can leave a post the package does not know of.
|
|
162
|
+
*/
|
|
163
|
+
export type LineSubscriber = (
|
|
164
|
+
event: LineEvent,
|
|
165
|
+
signal: AbortSignal,
|
|
166
|
+
context: LineContext,
|
|
167
|
+
) => // biome-ignore lint/suspicious/noConfusingVoidType: an async handler that returns nothing is Promise<void>
|
|
168
|
+
Promise<LineMirror | undefined | void>;
|
|
169
|
+
|
|
170
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
171
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
172
|
+
|
|
173
|
+
export const nonEmpty = (value: unknown): string | undefined =>
|
|
174
|
+
typeof value === "string" && value !== "" ? value : undefined;
|
|
175
|
+
|
|
176
|
+
const num = (value: unknown): number | undefined =>
|
|
177
|
+
typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
178
|
+
|
|
179
|
+
const bad = (what: string): LineError =>
|
|
180
|
+
new LineError("bad_message", `line: the event has no valid ${what}`);
|
|
181
|
+
|
|
182
|
+
/** The user id inside an event's `source`, when it names one. */
|
|
183
|
+
export function senderOf(payload: Record<string, unknown>): string | undefined {
|
|
184
|
+
return isRecord(payload.source) ? nonEmpty(payload.source.userId) : undefined;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The `message` object of an event. */
|
|
188
|
+
export function messageOf(
|
|
189
|
+
payload: Record<string, unknown>,
|
|
190
|
+
): Record<string, unknown> {
|
|
191
|
+
if (!isRecord(payload.message)) throw bad("message");
|
|
192
|
+
return payload.message;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The body of a `message` event's message. Throws `bad_message` when a known type lacks what it needs. */
|
|
196
|
+
export function readBody(message: Record<string, unknown>): LineMessageBody {
|
|
197
|
+
const type = nonEmpty(message.type);
|
|
198
|
+
if (!type) throw bad("message type");
|
|
199
|
+
switch (type) {
|
|
200
|
+
case "text": {
|
|
201
|
+
if (typeof message.text !== "string") throw bad("text");
|
|
202
|
+
return { kind: "text", text: message.text };
|
|
203
|
+
}
|
|
204
|
+
case "image":
|
|
205
|
+
return { kind: "image" };
|
|
206
|
+
case "video":
|
|
207
|
+
case "audio": {
|
|
208
|
+
const duration = num(message.duration);
|
|
209
|
+
return duration === undefined
|
|
210
|
+
? { kind: type }
|
|
211
|
+
: { kind: type, durationMs: duration };
|
|
212
|
+
}
|
|
213
|
+
case "file": {
|
|
214
|
+
const fileName = nonEmpty(message.fileName) ?? "file";
|
|
215
|
+
const size = num(message.fileSize);
|
|
216
|
+
return size === undefined
|
|
217
|
+
? { kind: "file", fileName }
|
|
218
|
+
: { kind: "file", fileName, fileSize: size };
|
|
219
|
+
}
|
|
220
|
+
case "sticker": {
|
|
221
|
+
const packageId = nonEmpty(message.packageId);
|
|
222
|
+
const stickerId = nonEmpty(message.stickerId);
|
|
223
|
+
if (!packageId || !stickerId) throw bad("sticker");
|
|
224
|
+
const keywords = Array.isArray(message.keywords)
|
|
225
|
+
? message.keywords.filter((k): k is string => typeof k === "string")
|
|
226
|
+
: undefined;
|
|
227
|
+
const text = nonEmpty(message.text);
|
|
228
|
+
return {
|
|
229
|
+
kind: "sticker",
|
|
230
|
+
packageId,
|
|
231
|
+
stickerId,
|
|
232
|
+
...(keywords && keywords.length > 0 ? { keywords } : {}),
|
|
233
|
+
...(text ? { text } : {}),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
case "location": {
|
|
237
|
+
const latitude = num(message.latitude);
|
|
238
|
+
const longitude = num(message.longitude);
|
|
239
|
+
if (latitude === undefined || longitude === undefined)
|
|
240
|
+
throw bad("location");
|
|
241
|
+
const title = nonEmpty(message.title);
|
|
242
|
+
const address = nonEmpty(message.address);
|
|
243
|
+
return {
|
|
244
|
+
kind: "location",
|
|
245
|
+
latitude,
|
|
246
|
+
longitude,
|
|
247
|
+
...(title ? { title } : {}),
|
|
248
|
+
...(address ? { address } : {}),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
default:
|
|
252
|
+
return { kind: "unsupported", messageType: type.slice(0, 64) };
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** The ids of the members a `memberJoined` or `memberLeft` event names. */
|
|
257
|
+
export function readMembers(
|
|
258
|
+
payload: Record<string, unknown>,
|
|
259
|
+
field: "joined" | "left",
|
|
260
|
+
): string[] {
|
|
261
|
+
const holder = payload[field];
|
|
262
|
+
if (!isRecord(holder) || !Array.isArray(holder.members)) return [];
|
|
263
|
+
return holder.members.flatMap((member) => {
|
|
264
|
+
const id = isRecord(member) ? nonEmpty(member.userId) : undefined;
|
|
265
|
+
return id ? [id] : [];
|
|
266
|
+
});
|
|
267
|
+
}
|
package/src/migrations.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { Migration } from "pi-roundtable";
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* The sources and the inbox. Later migrations add the tables of messages, tombstones and the
|
|
5
|
-
* outbox; this one is never edited once released.
|
|
5
|
+
* outbox; this one is never edited once released, and a migration is only ever appended.
|
|
6
6
|
*/
|
|
7
7
|
export const INBOX_MIGRATION: Migration = {
|
|
8
8
|
name: "inbox",
|
|
@@ -43,3 +43,102 @@ export const INBOX_MIGRATION: Migration = {
|
|
|
43
43
|
ON line_inbox (message_id) WHERE message_id IS NOT NULL`;
|
|
44
44
|
},
|
|
45
45
|
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* What the consumer keeps about a message while it is mirrored: its tokens, the opaque references
|
|
49
|
+
* the subscriber returned, and the tombstones that stop an unsent message from being mirrored.
|
|
50
|
+
* Every row goes after the retention window.
|
|
51
|
+
*/
|
|
52
|
+
export const MESSAGES_MIGRATION: Migration = {
|
|
53
|
+
name: "messages",
|
|
54
|
+
up: async (sql) => {
|
|
55
|
+
await sql`CREATE TABLE IF NOT EXISTS line_messages (
|
|
56
|
+
source_key TEXT NOT NULL,
|
|
57
|
+
message_id TEXT NOT NULL,
|
|
58
|
+
message_type TEXT NOT NULL,
|
|
59
|
+
event_ts BIGINT NOT NULL,
|
|
60
|
+
reply_token TEXT,
|
|
61
|
+
reply_token_at TIMESTAMPTZ,
|
|
62
|
+
quote_token TEXT,
|
|
63
|
+
edit_ts BIGINT NOT NULL DEFAULT 0,
|
|
64
|
+
created_at TIMESTAMPTZ NOT NULL,
|
|
65
|
+
PRIMARY KEY (source_key, message_id)
|
|
66
|
+
)`;
|
|
67
|
+
await sql`CREATE TABLE IF NOT EXISTS line_message_refs (
|
|
68
|
+
source_key TEXT NOT NULL,
|
|
69
|
+
message_id TEXT NOT NULL,
|
|
70
|
+
ref TEXT NOT NULL,
|
|
71
|
+
position INTEGER NOT NULL,
|
|
72
|
+
created_at TIMESTAMPTZ NOT NULL,
|
|
73
|
+
PRIMARY KEY (source_key, message_id, ref)
|
|
74
|
+
)`;
|
|
75
|
+
await sql`CREATE TABLE IF NOT EXISTS line_tombstones (
|
|
76
|
+
source_key TEXT NOT NULL,
|
|
77
|
+
message_id TEXT NOT NULL,
|
|
78
|
+
created_at TIMESTAMPTZ NOT NULL,
|
|
79
|
+
PRIMARY KEY (source_key, message_id)
|
|
80
|
+
)`;
|
|
81
|
+
await sql`CREATE INDEX IF NOT EXISTS line_messages_age ON line_messages (created_at)`;
|
|
82
|
+
await sql`CREATE INDEX IF NOT EXISTS line_message_refs_age ON line_message_refs (created_at)`;
|
|
83
|
+
await sql`CREATE INDEX IF NOT EXISTS line_tombstones_age ON line_tombstones (created_at)`;
|
|
84
|
+
await sql`CREATE INDEX IF NOT EXISTS line_tombstones_message ON line_tombstones (message_id)`;
|
|
85
|
+
// The retention sweeper looks at finished events by their completion time and at waiting ones by age.
|
|
86
|
+
await sql`CREATE INDEX IF NOT EXISTS line_inbox_finished
|
|
87
|
+
ON line_inbox (completed_at) WHERE status IN ('done', 'skipped', 'dead')`;
|
|
88
|
+
await sql`CREATE INDEX IF NOT EXISTS line_inbox_waiting_age
|
|
89
|
+
ON line_inbox (created_at) WHERE status = 'pending'`;
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The outbox: pushes queued for a chat, each with its retry key from the moment it exists, and the
|
|
95
|
+
* outcome of every join notice. The messages of a push are nulled when it is finished; rows go after
|
|
96
|
+
* the retention window.
|
|
97
|
+
*/
|
|
98
|
+
export const OUTBOX_MIGRATION: Migration = {
|
|
99
|
+
name: "outbox",
|
|
100
|
+
up: async (sql) => {
|
|
101
|
+
await sql`CREATE TABLE IF NOT EXISTS line_outbox (
|
|
102
|
+
id BIGSERIAL PRIMARY KEY,
|
|
103
|
+
source_key TEXT NOT NULL,
|
|
104
|
+
sender_name TEXT,
|
|
105
|
+
sender_icon_url TEXT,
|
|
106
|
+
messages JSONB,
|
|
107
|
+
message_count INTEGER NOT NULL,
|
|
108
|
+
origin_refs JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
109
|
+
retry_key UUID NOT NULL,
|
|
110
|
+
recipients INTEGER,
|
|
111
|
+
status TEXT NOT NULL CHECK (status IN ('queued', 'sending', 'sent', 'dead', 'refused')),
|
|
112
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
113
|
+
created_at TIMESTAMPTZ NOT NULL,
|
|
114
|
+
ready_at TIMESTAMPTZ NOT NULL,
|
|
115
|
+
next_attempt_at TIMESTAMPTZ NOT NULL,
|
|
116
|
+
first_attempt_at TIMESTAMPTZ,
|
|
117
|
+
claimed_at TIMESTAMPTZ,
|
|
118
|
+
completed_at TIMESTAMPTZ,
|
|
119
|
+
line_request_id TEXT,
|
|
120
|
+
last_error_code TEXT,
|
|
121
|
+
notified_at TIMESTAMPTZ,
|
|
122
|
+
notify_attempts INTEGER NOT NULL DEFAULT 0,
|
|
123
|
+
notify_after TIMESTAMPTZ
|
|
124
|
+
)`;
|
|
125
|
+
await sql`CREATE INDEX IF NOT EXISTS line_outbox_open
|
|
126
|
+
ON line_outbox (source_key, id) WHERE status IN ('queued', 'sending')`;
|
|
127
|
+
await sql`CREATE INDEX IF NOT EXISTS line_outbox_unnotified
|
|
128
|
+
ON line_outbox (id) WHERE notified_at IS NULL AND status IN ('sent', 'dead', 'refused')`;
|
|
129
|
+
await sql`CREATE INDEX IF NOT EXISTS line_outbox_age ON line_outbox (created_at)`;
|
|
130
|
+
await sql`CREATE TABLE IF NOT EXISTS line_notices (
|
|
131
|
+
webhook_event_id TEXT PRIMARY KEY,
|
|
132
|
+
outcome TEXT NOT NULL CHECK (outcome IN ('attempting', 'sent', 'failed')),
|
|
133
|
+
created_at TIMESTAMPTZ NOT NULL
|
|
134
|
+
)`;
|
|
135
|
+
await sql`CREATE INDEX IF NOT EXISTS line_notices_age ON line_notices (created_at)`;
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/** Every migration of the package, in the order they run. */
|
|
140
|
+
export const MIGRATIONS: Migration[] = [
|
|
141
|
+
INBOX_MIGRATION,
|
|
142
|
+
MESSAGES_MIGRATION,
|
|
143
|
+
OUTBOX_MIGRATION,
|
|
144
|
+
];
|