pi-roundtable-line 0.0.0-stage → 0.9.13
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 +12 -0
- package/LICENSE +21 -0
- package/README.md +145 -2
- package/package.json +46 -5
- package/src/api.ts +101 -0
- package/src/bot.ts +64 -0
- package/src/errors.ts +43 -0
- package/src/events.ts +104 -0
- package/src/inbox.ts +760 -0
- package/src/index.ts +21 -0
- package/src/intake.ts +119 -0
- package/src/migrations.ts +45 -0
- package/src/options.ts +63 -0
- package/src/plugin.ts +105 -0
- package/src/port.ts +25 -0
- package/src/sources.ts +186 -0
- package/src/token.ts +215 -0
- package/src/webhook.ts +110 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export { errorCode, LineApiError, LineError } from "./errors.ts";
|
|
2
|
+
export type { ParsedEvent, SourceKind } from "./events.ts";
|
|
3
|
+
export { parseSourceKey, sourceKeyOf } from "./events.ts";
|
|
4
|
+
export type {
|
|
5
|
+
InboxEvent,
|
|
6
|
+
InboxOptions,
|
|
7
|
+
InboxProcessor,
|
|
8
|
+
InboxResult,
|
|
9
|
+
} from "./inbox.ts";
|
|
10
|
+
export type { LineOptions } from "./options.ts";
|
|
11
|
+
export { line } from "./plugin.ts";
|
|
12
|
+
export type { LineBridgePort } from "./port.ts";
|
|
13
|
+
export { LINE } from "./port.ts";
|
|
14
|
+
export type {
|
|
15
|
+
LineSource,
|
|
16
|
+
LineSourceSummary,
|
|
17
|
+
LineSources,
|
|
18
|
+
SourceStatus,
|
|
19
|
+
} from "./sources.ts";
|
|
20
|
+
export type { LineCredentials, TokenMode } from "./token.ts";
|
|
21
|
+
export { WEBHOOK_MAX_BYTES } from "./webhook.ts";
|
package/src/intake.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { SQL } from "bun";
|
|
2
|
+
import { type ParsedEvent, parseSourceKey } from "./events.ts";
|
|
3
|
+
import type { SourceStatus } from "./sources.ts";
|
|
4
|
+
|
|
5
|
+
/** What persisting one request did, in counts. */
|
|
6
|
+
export interface IntakeResult {
|
|
7
|
+
/** Events kept for the inbox worker. */
|
|
8
|
+
queued: number;
|
|
9
|
+
/** Events dropped unread because their chat is not allowed (or they name none). */
|
|
10
|
+
skipped: number;
|
|
11
|
+
/** Events an earlier delivery had already stored. */
|
|
12
|
+
duplicates: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Events that carry no customer content, only the fact that the bot came or went. They are kept for
|
|
17
|
+
* a chat that is not allowed too: a join is answered with a notice whatever the allowlist says, and
|
|
18
|
+
* a leave keeps the chat's status true.
|
|
19
|
+
*/
|
|
20
|
+
const MEMBERSHIP = new Set(["join", "leave"]);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* PostgreSQL refuses U+0000 in text and in JSONB, and a lone surrogate in JSONB; either would fail
|
|
24
|
+
* the whole request's transaction and with it every other event stored beside this one. They are
|
|
25
|
+
* replaced with U+FFFD, which loses nothing a chat mirror could use.
|
|
26
|
+
*/
|
|
27
|
+
function clean(text: string): string {
|
|
28
|
+
return text.replaceAll("\u0000", "\uFFFD").toWellFormed();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function scrub(value: unknown): unknown {
|
|
32
|
+
if (typeof value === "string") return clean(value);
|
|
33
|
+
if (Array.isArray(value)) return value.map(scrub);
|
|
34
|
+
if (typeof value === "object" && value !== null)
|
|
35
|
+
return Object.fromEntries(
|
|
36
|
+
Object.entries(value).map(([key, item]) => [clean(key), scrub(item)]),
|
|
37
|
+
);
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Why an event is dropped, or undefined when it is kept. */
|
|
42
|
+
function dropReason(
|
|
43
|
+
event: ParsedEvent,
|
|
44
|
+
status: SourceStatus | undefined,
|
|
45
|
+
): string | undefined {
|
|
46
|
+
if (!event.active) return "standby";
|
|
47
|
+
if (status === undefined) return "no_source";
|
|
48
|
+
if (status === "allowed") return undefined;
|
|
49
|
+
if (status !== "blocked" && MEMBERSHIP.has(event.type)) return undefined;
|
|
50
|
+
return status === "blocked" ? "source_blocked" : "source_not_allowed";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Stores a request's events in one transaction, so the caller may answer `200` once this returns.
|
|
55
|
+
* An unknown chat is recorded as pending with counts only; the content of an event of a chat that
|
|
56
|
+
* is not allowed is never written. An event already stored (same `webhookEventId`) is left as it
|
|
57
|
+
* is, so a redelivery is processed once.
|
|
58
|
+
*/
|
|
59
|
+
export async function persistEvents(
|
|
60
|
+
sql: SQL,
|
|
61
|
+
events: readonly ParsedEvent[],
|
|
62
|
+
now: Date,
|
|
63
|
+
): Promise<IntakeResult> {
|
|
64
|
+
const result: IntakeResult = { queued: 0, skipped: 0, duplicates: 0 };
|
|
65
|
+
// Sorted, so two requests that share chats take their locks in one order and cannot deadlock.
|
|
66
|
+
const keys = [
|
|
67
|
+
...new Set(events.flatMap((e) => (e.sourceKey ? [e.sourceKey] : []))),
|
|
68
|
+
].sort();
|
|
69
|
+
await sql.begin(async (tx) => {
|
|
70
|
+
const statuses = new Map<string, SourceStatus>();
|
|
71
|
+
for (const key of keys) {
|
|
72
|
+
const source = parseSourceKey(key);
|
|
73
|
+
if (!source) continue;
|
|
74
|
+
await tx`INSERT INTO line_sources
|
|
75
|
+
(source_key, kind, status, first_seen_at, last_seen_at, status_changed_at)
|
|
76
|
+
VALUES (${key}, ${source.kind}, 'pending', ${now}, ${now}, ${now})
|
|
77
|
+
ON CONFLICT (source_key) DO NOTHING`;
|
|
78
|
+
}
|
|
79
|
+
if (keys.length > 0) {
|
|
80
|
+
// The row locks last to the end of the request: allow() and block() wait, so each event meets one status.
|
|
81
|
+
const rows: { source_key: string; status: SourceStatus }[] =
|
|
82
|
+
await tx`SELECT source_key, status FROM line_sources
|
|
83
|
+
WHERE source_key IN ${tx(keys)} ORDER BY source_key FOR UPDATE`;
|
|
84
|
+
for (const row of rows) statuses.set(row.source_key, row.status);
|
|
85
|
+
}
|
|
86
|
+
for (const event of events) {
|
|
87
|
+
const status = event.sourceKey
|
|
88
|
+
? statuses.get(event.sourceKey)
|
|
89
|
+
: undefined;
|
|
90
|
+
const reason = dropReason(event, status);
|
|
91
|
+
const payload = reason ? null : JSON.stringify(scrub(event.raw));
|
|
92
|
+
const id = clean(event.id);
|
|
93
|
+
const type = clean(event.type);
|
|
94
|
+
const messageId =
|
|
95
|
+
event.messageId === null ? null : clean(event.messageId);
|
|
96
|
+
const inserted: { webhook_event_id: string }[] =
|
|
97
|
+
await tx`INSERT INTO line_inbox
|
|
98
|
+
(webhook_event_id, source_key, event_type, message_id, event_ts, payload, status,
|
|
99
|
+
next_attempt_at, last_error_code, created_at, completed_at)
|
|
100
|
+
VALUES (${id}, ${event.sourceKey}, ${type}, ${messageId}, ${event.timestamp},
|
|
101
|
+
${payload}::text::jsonb, ${reason ? "skipped" : "pending"}, ${now}, ${reason ?? null}, ${now},
|
|
102
|
+
${reason ? now : null})
|
|
103
|
+
ON CONFLICT (webhook_event_id) DO NOTHING
|
|
104
|
+
RETURNING webhook_event_id`;
|
|
105
|
+
if (inserted.length === 0) {
|
|
106
|
+
result.duplicates++;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (reason) result.skipped++;
|
|
110
|
+
else result.queued++;
|
|
111
|
+
if (event.sourceKey)
|
|
112
|
+
await tx`UPDATE line_sources
|
|
113
|
+
SET last_seen_at = ${now}, event_count = event_count + 1,
|
|
114
|
+
message_count = message_count + ${event.type === "message" ? 1 : 0}
|
|
115
|
+
WHERE source_key = ${event.sourceKey}`;
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Migration } from "pi-roundtable";
|
|
2
|
+
|
|
3
|
+
/**
|
|
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.
|
|
6
|
+
*/
|
|
7
|
+
export const INBOX_MIGRATION: Migration = {
|
|
8
|
+
name: "inbox",
|
|
9
|
+
up: async (sql) => {
|
|
10
|
+
await sql`CREATE TABLE IF NOT EXISTS line_sources (
|
|
11
|
+
source_key TEXT PRIMARY KEY,
|
|
12
|
+
kind TEXT NOT NULL CHECK (kind IN ('group', 'room', 'user')),
|
|
13
|
+
status TEXT NOT NULL CHECK (status IN ('pending', 'allowed', 'left', 'blocked')),
|
|
14
|
+
first_seen_at TIMESTAMPTZ NOT NULL,
|
|
15
|
+
last_seen_at TIMESTAMPTZ NOT NULL,
|
|
16
|
+
status_changed_at TIMESTAMPTZ NOT NULL,
|
|
17
|
+
event_count BIGINT NOT NULL DEFAULT 0,
|
|
18
|
+
message_count BIGINT NOT NULL DEFAULT 0,
|
|
19
|
+
membership_ts BIGINT NOT NULL DEFAULT 0
|
|
20
|
+
)`;
|
|
21
|
+
await sql`CREATE TABLE IF NOT EXISTS line_inbox (
|
|
22
|
+
seq BIGSERIAL NOT NULL,
|
|
23
|
+
webhook_event_id TEXT PRIMARY KEY,
|
|
24
|
+
source_key TEXT,
|
|
25
|
+
event_type TEXT NOT NULL,
|
|
26
|
+
message_id TEXT,
|
|
27
|
+
event_ts BIGINT NOT NULL,
|
|
28
|
+
payload JSONB,
|
|
29
|
+
status TEXT NOT NULL CHECK (status IN ('pending', 'processing', 'done', 'skipped', 'dead')),
|
|
30
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
31
|
+
requeues INTEGER NOT NULL DEFAULT 0,
|
|
32
|
+
next_attempt_at TIMESTAMPTZ NOT NULL,
|
|
33
|
+
claimed_at TIMESTAMPTZ,
|
|
34
|
+
last_error_code TEXT,
|
|
35
|
+
created_at TIMESTAMPTZ NOT NULL,
|
|
36
|
+
completed_at TIMESTAMPTZ
|
|
37
|
+
)`;
|
|
38
|
+
await sql`CREATE INDEX IF NOT EXISTS line_inbox_claim
|
|
39
|
+
ON line_inbox (event_ts, seq) WHERE status = 'pending'`;
|
|
40
|
+
await sql`CREATE INDEX IF NOT EXISTS line_inbox_processing
|
|
41
|
+
ON line_inbox (source_key) WHERE status = 'processing'`;
|
|
42
|
+
await sql`CREATE INDEX IF NOT EXISTS line_inbox_message
|
|
43
|
+
ON line_inbox (message_id) WHERE message_id IS NOT NULL`;
|
|
44
|
+
},
|
|
45
|
+
};
|
package/src/options.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { InboxOptions } from "./inbox.ts";
|
|
2
|
+
import type { LineCredentials } from "./token.ts";
|
|
3
|
+
|
|
4
|
+
export interface LineOptions {
|
|
5
|
+
/** The channel id and secret, and how the access token is obtained. */
|
|
6
|
+
credentials: LineCredentials;
|
|
7
|
+
/** Where LINE posts events; default `/line/webhook`. Set this URL in the channel's settings. */
|
|
8
|
+
webhookPath?: string;
|
|
9
|
+
/** The configured listener the route attaches to; default `public`. */
|
|
10
|
+
listener?: string;
|
|
11
|
+
/** Tunes the inbox worker. */
|
|
12
|
+
inbox?: Partial<InboxOptions>;
|
|
13
|
+
/** Replaces `fetch` for every call to LINE, for tests. */
|
|
14
|
+
fetch?: typeof fetch;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const INBOX_KEYS: readonly (keyof InboxOptions)[] = [
|
|
18
|
+
"leaseMs",
|
|
19
|
+
"maxHandleMs",
|
|
20
|
+
"maxAttempts",
|
|
21
|
+
"maxRequeues",
|
|
22
|
+
"backoffBaseMs",
|
|
23
|
+
"backoffCapMs",
|
|
24
|
+
"concurrency",
|
|
25
|
+
"pollMs",
|
|
26
|
+
"messageGraceMs",
|
|
27
|
+
"dependencyRetryMs",
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/** The longest delay `setTimeout` and `setInterval` hold; past it they fire at once. */
|
|
31
|
+
const MAX_TIMER_MS = 2_147_483_647;
|
|
32
|
+
|
|
33
|
+
/** The options that become a timer's delay. */
|
|
34
|
+
const TIMER_KEYS: readonly (keyof InboxOptions)[] = [
|
|
35
|
+
"leaseMs",
|
|
36
|
+
"maxHandleMs",
|
|
37
|
+
"pollMs",
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/** Refuses a mistake in the options, naming the field; the host stops before it starts. */
|
|
41
|
+
export function checkOptions(options: LineOptions): { path: string } {
|
|
42
|
+
const path = options.webhookPath ?? "/line/webhook";
|
|
43
|
+
if (!/^\/[A-Za-z0-9._~/-]*[A-Za-z0-9._~-]$/.test(path))
|
|
44
|
+
throw new Error(
|
|
45
|
+
`line: webhookPath ${JSON.stringify(path)} must start with / and not end with one, such as /line/webhook`,
|
|
46
|
+
);
|
|
47
|
+
for (const [key, value] of Object.entries(options.inbox ?? {})) {
|
|
48
|
+
if (!INBOX_KEYS.includes(key as keyof InboxOptions))
|
|
49
|
+
throw new Error(`line: inbox.${key} is not an inbox option`);
|
|
50
|
+
if (!Number.isInteger(value) || (value as number) < 1)
|
|
51
|
+
throw new Error(
|
|
52
|
+
`line: inbox.${key} must be a positive whole number; got ${String(value)}`,
|
|
53
|
+
);
|
|
54
|
+
if (
|
|
55
|
+
TIMER_KEYS.includes(key as keyof InboxOptions) &&
|
|
56
|
+
(value as number) > MAX_TIMER_MS
|
|
57
|
+
)
|
|
58
|
+
throw new Error(
|
|
59
|
+
`line: inbox.${key} must be at most ${MAX_TIMER_MS} milliseconds (about 24 days); got ${String(value)}`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
return { path };
|
|
63
|
+
}
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import {
|
|
2
|
+
definePlugin,
|
|
3
|
+
type PluginContext,
|
|
4
|
+
type RoundtablePlugin,
|
|
5
|
+
} from "pi-roundtable";
|
|
6
|
+
import { LineApi } from "./api.ts";
|
|
7
|
+
import { BotIdentity } from "./bot.ts";
|
|
8
|
+
import { type InboxProcessor, InboxWorker } from "./inbox.ts";
|
|
9
|
+
import { INBOX_MIGRATION } from "./migrations.ts";
|
|
10
|
+
import { checkOptions, type LineOptions } from "./options.ts";
|
|
11
|
+
import { LINE, type LineBridgePort } from "./port.ts";
|
|
12
|
+
import { PgSources } from "./sources.ts";
|
|
13
|
+
import { TokenSource } from "./token.ts";
|
|
14
|
+
import { webhookHandler } from "./webhook.ts";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The LINE transport: a webhook on the host's HTTP listener that stores every signed event before
|
|
18
|
+
* it answers, an inbox worker that hands the stored events of allowed chats to one processor, and
|
|
19
|
+
* the `LINE` service other plugins read the allowlist and health from. A chat the bot has not been
|
|
20
|
+
* told to mirror is recorded as pending with counts only; nothing of what its members write is kept.
|
|
21
|
+
* Configuration mistakes throw here, before the host starts.
|
|
22
|
+
*/
|
|
23
|
+
export function line(options: LineOptions): RoundtablePlugin {
|
|
24
|
+
const { path } = checkOptions(options);
|
|
25
|
+
// Built now so bad credentials stop the configuration rather than the boot.
|
|
26
|
+
const tokens = new TokenSource({
|
|
27
|
+
credentials: options.credentials,
|
|
28
|
+
...(options.fetch ? { fetch: options.fetch } : {}),
|
|
29
|
+
});
|
|
30
|
+
return definePlugin({
|
|
31
|
+
name: "line",
|
|
32
|
+
migrations: [INBOX_MIGRATION],
|
|
33
|
+
provides: [LINE],
|
|
34
|
+
setup: (context: PluginContext) => {
|
|
35
|
+
const sql = context.database();
|
|
36
|
+
const now = () => context.env.now();
|
|
37
|
+
const api = new LineApi({
|
|
38
|
+
tokens,
|
|
39
|
+
...(options.fetch ? { fetch: options.fetch } : {}),
|
|
40
|
+
});
|
|
41
|
+
const bot = new BotIdentity({ api, logger: context.logger, now });
|
|
42
|
+
const sources = new PgSources(sql, () => api, now);
|
|
43
|
+
let processor: InboxProcessor | undefined;
|
|
44
|
+
const worker = new InboxWorker({
|
|
45
|
+
sql,
|
|
46
|
+
logger: context.logger,
|
|
47
|
+
now,
|
|
48
|
+
sources,
|
|
49
|
+
processor: () => processor,
|
|
50
|
+
...(options.inbox ? { options: options.inbox } : {}),
|
|
51
|
+
});
|
|
52
|
+
const port: LineBridgePort = {
|
|
53
|
+
sources,
|
|
54
|
+
botUserId: () => bot.current(),
|
|
55
|
+
health: () => {
|
|
56
|
+
const health = tokens.health();
|
|
57
|
+
return {
|
|
58
|
+
tokensIssued: health.issued,
|
|
59
|
+
tokenFailure: health.lastFailure,
|
|
60
|
+
};
|
|
61
|
+
},
|
|
62
|
+
attachProcessor: (attached) => {
|
|
63
|
+
if (processor)
|
|
64
|
+
throw new Error("line: an inbox processor is already attached");
|
|
65
|
+
processor = attached;
|
|
66
|
+
worker.kick();
|
|
67
|
+
return () => {
|
|
68
|
+
if (processor === attached) processor = undefined;
|
|
69
|
+
};
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
context.services.provide(LINE, port);
|
|
73
|
+
return {
|
|
74
|
+
services: [
|
|
75
|
+
{
|
|
76
|
+
name: "line-bot",
|
|
77
|
+
startInBackground: () => bot.refresh(),
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: "line-inbox",
|
|
81
|
+
start: () => worker.start(),
|
|
82
|
+
stop: () => worker.stop(),
|
|
83
|
+
busy: () => worker.busy(),
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
http: [
|
|
87
|
+
{
|
|
88
|
+
name: "line-webhook",
|
|
89
|
+
listener: options.listener ?? "public",
|
|
90
|
+
path: { exact: path },
|
|
91
|
+
methods: ["POST"],
|
|
92
|
+
handle: webhookHandler({
|
|
93
|
+
sql,
|
|
94
|
+
tokens,
|
|
95
|
+
logger: context.logger,
|
|
96
|
+
now,
|
|
97
|
+
botUserId: () => bot.current(),
|
|
98
|
+
stored: () => worker.kick(),
|
|
99
|
+
}),
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
};
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
}
|
package/src/port.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { serviceKey } from "pi-roundtable";
|
|
2
|
+
import type { InboxProcessor } from "./inbox.ts";
|
|
3
|
+
import type { LineSources } from "./sources.ts";
|
|
4
|
+
|
|
5
|
+
/** What other plugins read from this one, through the `LINE` service key. */
|
|
6
|
+
export interface LineBridgePort {
|
|
7
|
+
/** The chats the bot has seen, and the allowlist over them. */
|
|
8
|
+
readonly sources: LineSources;
|
|
9
|
+
/** The bot's user id once LINE has told it, else undefined. */
|
|
10
|
+
botUserId(): string | undefined;
|
|
11
|
+
/** The token source's health: how many tokens were issued and why the last attempt failed, if it did. */
|
|
12
|
+
health(): { tokensIssued: number; tokenFailure: string | undefined };
|
|
13
|
+
/**
|
|
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
|
+
*/
|
|
19
|
+
attachProcessor(processor: InboxProcessor): () => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const LINE = serviceKey<LineBridgePort>("roundtable.line", {
|
|
23
|
+
absent:
|
|
24
|
+
"add the line() plugin from pi-roundtable-line to the host's plugins before the plugin that reads it",
|
|
25
|
+
});
|
package/src/sources.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import type { SQL } from "bun";
|
|
2
|
+
import type { LineApi } from "./api.ts";
|
|
3
|
+
import { LineError } from "./errors.ts";
|
|
4
|
+
import { parseSourceKey, type SourceKind } from "./events.ts";
|
|
5
|
+
|
|
6
|
+
export type SourceStatus = "pending" | "allowed" | "left" | "blocked";
|
|
7
|
+
|
|
8
|
+
/** A chat the bot has seen. Only counts and times are kept for a chat that is not allowed. */
|
|
9
|
+
export interface LineSource {
|
|
10
|
+
key: string;
|
|
11
|
+
kind: SourceKind;
|
|
12
|
+
status: SourceStatus;
|
|
13
|
+
firstSeenAt: string;
|
|
14
|
+
lastSeenAt: string;
|
|
15
|
+
statusChangedAt: string;
|
|
16
|
+
eventCount: number;
|
|
17
|
+
messageCount: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface LineSourceSummary extends LineSource {
|
|
21
|
+
/** The group's name as LINE shows it; null for a room or a person, which have none. */
|
|
22
|
+
name: string | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface Row {
|
|
26
|
+
source_key: string;
|
|
27
|
+
kind: SourceKind;
|
|
28
|
+
status: SourceStatus;
|
|
29
|
+
first_seen_at: Date;
|
|
30
|
+
last_seen_at: Date;
|
|
31
|
+
status_changed_at: Date;
|
|
32
|
+
event_count: string | number;
|
|
33
|
+
message_count: string | number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const sourceOf = (row: Row): LineSource => ({
|
|
37
|
+
key: row.source_key,
|
|
38
|
+
kind: row.kind,
|
|
39
|
+
status: row.status,
|
|
40
|
+
firstSeenAt: row.first_seen_at.toISOString(),
|
|
41
|
+
lastSeenAt: row.last_seen_at.toISOString(),
|
|
42
|
+
statusChangedAt: row.status_changed_at.toISOString(),
|
|
43
|
+
eventCount: Number(row.event_count),
|
|
44
|
+
messageCount: Number(row.message_count),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/** What the host does with the chats the bot has seen: list them, allow or block them. It never creates a channel. */
|
|
48
|
+
export interface LineSources {
|
|
49
|
+
/** Chats waiting for a decision, oldest first. */
|
|
50
|
+
pending(): Promise<LineSource[]>;
|
|
51
|
+
/** Every chat, or only those with `status`, oldest first. */
|
|
52
|
+
list(status?: SourceStatus): Promise<LineSource[]>;
|
|
53
|
+
/** One chat, or undefined when the bot has not seen it. */
|
|
54
|
+
get(key: string): Promise<LineSource | undefined>;
|
|
55
|
+
/**
|
|
56
|
+
* Mirrors a chat from now on. Events that arrived before are gone: they were dropped unread.
|
|
57
|
+
* Throws `LineError` `unknown_source` for a chat never seen, and `source_left` for one the
|
|
58
|
+
* bot has left.
|
|
59
|
+
*/
|
|
60
|
+
allow(key: string): Promise<LineSource>;
|
|
61
|
+
/** Stops mirroring a chat and drops the events of it still waiting in the inbox. Throws `unknown_source` for a chat never seen. */
|
|
62
|
+
block(key: string): Promise<LineSource>;
|
|
63
|
+
/** The chat with its group name, asked of LINE; throws when LINE refuses or does not answer. */
|
|
64
|
+
summary(key: string): Promise<LineSourceSummary>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function checkedKey(key: string): { kind: SourceKind; id: string } {
|
|
68
|
+
const parsed = parseSourceKey(key);
|
|
69
|
+
if (!parsed)
|
|
70
|
+
throw new LineError(
|
|
71
|
+
"bad_source_key",
|
|
72
|
+
"line: a source key is line:<group|room|user>:<id>",
|
|
73
|
+
);
|
|
74
|
+
return parsed;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export class PgSources implements LineSources {
|
|
78
|
+
readonly #sql: SQL;
|
|
79
|
+
readonly #api: () => LineApi;
|
|
80
|
+
readonly #now: () => Date;
|
|
81
|
+
|
|
82
|
+
constructor(sql: SQL, api: () => LineApi, now: () => Date) {
|
|
83
|
+
this.#sql = sql;
|
|
84
|
+
this.#api = api;
|
|
85
|
+
this.#now = now;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async pending(): Promise<LineSource[]> {
|
|
89
|
+
return this.list("pending");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async list(status?: SourceStatus): Promise<LineSource[]> {
|
|
93
|
+
const rows: Row[] = status
|
|
94
|
+
? await this
|
|
95
|
+
.#sql`SELECT * FROM line_sources WHERE status = ${status} ORDER BY first_seen_at, source_key`
|
|
96
|
+
: await this
|
|
97
|
+
.#sql`SELECT * FROM line_sources ORDER BY first_seen_at, source_key`;
|
|
98
|
+
return rows.map(sourceOf);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async get(key: string): Promise<LineSource | undefined> {
|
|
102
|
+
checkedKey(key);
|
|
103
|
+
const rows: Row[] = await this
|
|
104
|
+
.#sql`SELECT * FROM line_sources WHERE source_key = ${key}`;
|
|
105
|
+
return rows[0] ? sourceOf(rows[0]) : undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async allow(key: string): Promise<LineSource> {
|
|
109
|
+
checkedKey(key);
|
|
110
|
+
const now = this.#now();
|
|
111
|
+
const rows: Row[] = await this.#sql`UPDATE line_sources
|
|
112
|
+
SET status = 'allowed', status_changed_at = ${now}
|
|
113
|
+
WHERE source_key = ${key} AND status IN ('pending', 'blocked', 'allowed')
|
|
114
|
+
RETURNING *`;
|
|
115
|
+
if (rows[0]) return sourceOf(rows[0]);
|
|
116
|
+
throw (await this.get(key))
|
|
117
|
+
? new LineError("source_left", "line: the bot has left that chat")
|
|
118
|
+
: new LineError("unknown_source", "line: the bot has not seen that chat");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async block(key: string): Promise<LineSource> {
|
|
122
|
+
checkedKey(key);
|
|
123
|
+
const now = this.#now();
|
|
124
|
+
return this.#sql.begin(async (sql) => {
|
|
125
|
+
const rows: Row[] = await sql`UPDATE line_sources
|
|
126
|
+
SET status = 'blocked', status_changed_at = ${now}
|
|
127
|
+
WHERE source_key = ${key} RETURNING *`;
|
|
128
|
+
if (!rows[0])
|
|
129
|
+
throw new LineError(
|
|
130
|
+
"unknown_source",
|
|
131
|
+
"line: the bot has not seen that chat",
|
|
132
|
+
);
|
|
133
|
+
// What waits unread is dropped with its payload; an event being handled finishes.
|
|
134
|
+
await sql`UPDATE line_inbox
|
|
135
|
+
SET status = 'skipped', payload = NULL, last_error_code = 'source_blocked', completed_at = ${now}
|
|
136
|
+
WHERE source_key = ${key} AND status = 'pending'`;
|
|
137
|
+
return sourceOf(rows[0]);
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async summary(key: string): Promise<LineSourceSummary> {
|
|
142
|
+
const { kind, id } = checkedKey(key);
|
|
143
|
+
const source = await this.get(key);
|
|
144
|
+
if (!source)
|
|
145
|
+
throw new LineError(
|
|
146
|
+
"unknown_source",
|
|
147
|
+
"line: the bot has not seen that chat",
|
|
148
|
+
);
|
|
149
|
+
const name = kind === "group" ? await this.#api().groupName(id) : null;
|
|
150
|
+
return { ...source, name };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Records that the bot is in a chat (a `join` event): a chat it had left needs a new decision,
|
|
155
|
+
* so it goes back to pending. Ignores an event older than the last membership change applied.
|
|
156
|
+
*/
|
|
157
|
+
async markJoined(key: string, timestamp: number): Promise<void> {
|
|
158
|
+
await this.#sql`UPDATE line_sources
|
|
159
|
+
SET membership_ts = ${timestamp},
|
|
160
|
+
status = CASE WHEN status = 'left' THEN 'pending' ELSE status END,
|
|
161
|
+
status_changed_at = CASE WHEN status = 'left' THEN ${this.#now()} ELSE status_changed_at END
|
|
162
|
+
WHERE source_key = ${key} AND membership_ts < ${timestamp}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Records that the bot was removed from a chat (a `leave` event); a blocked chat stays blocked.
|
|
167
|
+
* Whatever else the chat has waiting (an event backing off, say) is dropped with its payload,
|
|
168
|
+
* apart from its join and leave and its unsends (which carry nothing and only take content down),
|
|
169
|
+
* so it cannot be handed out long after, when the bot is back.
|
|
170
|
+
*/
|
|
171
|
+
async markLeft(key: string, timestamp: number): Promise<void> {
|
|
172
|
+
const now = this.#now();
|
|
173
|
+
await this.#sql.begin(async (sql) => {
|
|
174
|
+
const rows: { status: SourceStatus }[] = await sql`UPDATE line_sources
|
|
175
|
+
SET membership_ts = ${timestamp},
|
|
176
|
+
status = CASE WHEN status = 'blocked' THEN status ELSE 'left' END,
|
|
177
|
+
status_changed_at = CASE WHEN status = 'blocked' THEN status_changed_at ELSE ${now} END
|
|
178
|
+
WHERE source_key = ${key} AND membership_ts < ${timestamp}
|
|
179
|
+
RETURNING status`;
|
|
180
|
+
if (rows[0]?.status !== "left") return;
|
|
181
|
+
await sql`UPDATE line_inbox
|
|
182
|
+
SET status = 'skipped', payload = NULL, last_error_code = 'source_left', completed_at = ${now}
|
|
183
|
+
WHERE source_key = ${key} AND status = 'pending' AND event_type NOT IN ('join', 'leave', 'unsend')`;
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|