pi-roundtable-line 0.15.3 → 0.16.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 +28 -0
- package/README.md +62 -5
- package/package.json +3 -3
- package/src/api-port.ts +254 -0
- package/src/api.ts +8 -3
- package/src/index.ts +18 -1
- package/src/messages.ts +7 -0
- package/src/migrations.ts +12 -0
- package/src/outbox-worker.ts +13 -4
- package/src/plugin.ts +15 -0
- package/src/port.ts +10 -0
- package/src/quota.ts +21 -3
- package/src/screen.ts +14 -0
- package/src/sender.ts +52 -22
- package/src/token.ts +69 -7
- package/src/webhook.ts +58 -9
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,34 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.16.0] - 2026-10-16
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `LINE.screen(handler)` and the types `LineScreen` and `LineScreenVerdict`: one screen of the webhook, asked for every event of a request whose signature and destination passed, in order and before anything is stored, that answers `"keep"` or `"consume"`. A consumed event is never stored, routed or handed to the subscriber, and a request whose events are all consumed is a `200` that counts as accepted. A screen that throws or answers anything else fails the request with `500` and stores nothing. The screen is handed a deeply frozen copy of the event, so it cannot change what is stored. A second `screen()` while one is attached throws; until one is attached every event is kept.
|
|
13
|
+
- `LINE.api` and the types `LineApiPort`, `LineCallOptions`, `LineProfile`, `LineQuotaStanding` and `LineBotInfo`: LINE's API through the plugin's own token source, timeout and `401` retry. `profile` (a member's or a one-to-one user's display name and picture; undefined for a `404`), `memberCount`, `quota` (the gate's reading with the host's `cap`), `botInfo` (`userId`, `basicId`, name and picture, asked once and kept; callers that ask together share one request and its timeout), `reply` (1 to 5 messages on a reply token), `probeToken` and `onTokenFailure`. Every call takes `{ signal, timeoutMs }`.
|
|
14
|
+
- `onTokenFailure(listener)` on `LINE.api`: told the code (`token_refused`, `token_unreachable`, `token_malformed`) of each failed issue of the channel access token, whichever part of the plugin asked, once per issue however many callers shared it. `probeToken()` issues quietly and tells no listener. A listener that throws, or returns a rejecting promise, is ignored.
|
|
15
|
+
- `senderNameProblem(name, options?)`, the type `SenderNameProblem` (`empty`, `edge_space`, `too_long`, `control_character`, `forbidden_word`), and the values `SENDER_NAME_MAX` and `SENDER_DEFAULTS`: why LINE would refuse a sender name, for a host that checks a name before it queues a push. `validateSender` uses it and keeps its messages.
|
|
16
|
+
- `LineBridgePort.api` and `LineBridgePort.screen`.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- `SENDER_DEFAULTS` and its `forbiddenWords` list are frozen, and `senderNameProblem` reads options without a word list as the defaults.
|
|
21
|
+
- A sender name with a C1 control character (U+0080 to U+009F) is now refused as `bad_sender`, like C0 and DEL already were.
|
|
22
|
+
|
|
23
|
+
## [0.15.4] - 2026-10-15
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- `LineOutboxResultEvent.attempts`: how many tries the push took, the one that ended it included (1 for a push LINE refused at once, 2 after a `5xx` and then a refusal), so a subscriber no longer reads `line_outbox`.
|
|
28
|
+
- Migration `outbox-requested` adds the nullable `line_outbox.requested_at`, set just before a push's request is made and cleared when the push is claimed again.
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
- A stop no longer gives back an attempt whose request may have reached LINE. A push interrupted by the worker stopping used to be put back with its attempt uncounted even when the request was already out, so the count (and `outbox.maxAttempts`) understated the tries; it is now given back only when the stop came before the request.
|
|
33
|
+
|
|
34
|
+
The core peer range stays `>=0.15.0 <0.16.0`.
|
|
35
|
+
|
|
8
36
|
## [0.15.3] - 2026-10-15
|
|
9
37
|
|
|
10
38
|
- Release in lockstep with pi-roundtable 0.15.3; no package-specific behavior changes. The core peer range stays `>=0.15.0 <0.16.0`.
|
package/README.md
CHANGED
|
@@ -66,13 +66,13 @@ Errors carry a short code (`token_refused`, `api_unreachable`, `http_429`, ...)
|
|
|
66
66
|
|
|
67
67
|
| Status | When |
|
|
68
68
|
|---|---|
|
|
69
|
-
| `200` | the events were stored (or there were none: LINE's Verify button) |
|
|
69
|
+
| `200` | the events were stored (or there were none: LINE's Verify button, or a screen consumed them all) |
|
|
70
70
|
| `400` | the body is signed but is not a LINE events object |
|
|
71
71
|
| `401` | `x-line-signature` is missing or is not the HMAC-SHA256 of the raw body under the channel secret |
|
|
72
72
|
| `403` | the body's `destination` is not this bot (checked once the bot's user id is known) |
|
|
73
73
|
| `405` | not a `POST` |
|
|
74
74
|
| `413` | the body is over 1 MiB |
|
|
75
|
-
| `500` | the database could not store the events |
|
|
75
|
+
| `500` | the database could not store the events, or the attached [screen](#screening-events) failed |
|
|
76
76
|
|
|
77
77
|
The signature is checked on the raw bytes, in constant time, before the body is parsed.
|
|
78
78
|
All events of a request are stored in one transaction, and `200` follows the commit.
|
|
@@ -100,11 +100,67 @@ webhooks: {
|
|
|
100
100
|
```
|
|
101
101
|
|
|
102
102
|
They are counts and times only: no body, address or signature is kept, and a request adds one to a counter and nothing else.
|
|
103
|
-
A `405` and a `500` (the database could not store the events) are in no counter.
|
|
103
|
+
A `405` and a `500` (the database could not store the events, or a screen failed) are in no counter.
|
|
104
104
|
A host that watches for a channel whose webhook URL or secret has drifted can alarm on `rejected.signatureForBot` growing while `accepted` does not: a scanner that finds the URL does not know the bot's user id, so a request that fails its signature yet names this bot as its `destination` is far more likely LINE with a wrong channel secret (or a rotated one) than noise.
|
|
105
105
|
To tell, the handler reads the `destination` of a body that failed its signature, only within the 1 MiB limit, and never logs or keeps it; `signatureForBot` also counts in `signature`.
|
|
106
106
|
Until the plugin knows the bot's user id it cannot tell, and `signatureForBot` stays at zero.
|
|
107
107
|
|
|
108
|
+
## Screening events
|
|
109
|
+
|
|
110
|
+
A host can look at every event before the plugin stores it, and take some out: a code a person sends to link their account, say, which must reach no inbox, mirror or log.
|
|
111
|
+
`LINE.screen(handler)` attaches the one screen of the webhook and returns the function that detaches it; a second `screen()` while one is attached throws.
|
|
112
|
+
Attach it during setup: the listeners open only after every plugin is set up, so no request arrives before it.
|
|
113
|
+
Until one is attached, and after it is detached, every event is kept.
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
import { definePlugin } from "pi-roundtable";
|
|
117
|
+
import { LINE, type LineScreen } from "pi-roundtable-line";
|
|
118
|
+
|
|
119
|
+
const screen: LineScreen = async (event) =>
|
|
120
|
+
isLinkCode(event.raw) ? "consume" : "keep";
|
|
121
|
+
|
|
122
|
+
export const linkCodes = definePlugin({
|
|
123
|
+
name: "link-codes",
|
|
124
|
+
requires: [LINE], // so line() must come before this plugin in `plugins`
|
|
125
|
+
setup: (context) => {
|
|
126
|
+
context.services.get(LINE).screen(screen);
|
|
127
|
+
return {};
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Read `LINE` with `services.get` in `setup`, and declare it in `requires` so the host refuses a plugin list that puts `line()` after it; `services.lazy(LINE)` answers only after every plugin is set up, so it does not suit attaching a screen there.
|
|
133
|
+
|
|
134
|
+
The screen is asked after the signature and the `destination` passed, and before anything is stored, for every event of the request in order, one at a time (the events of a request that failed before that, a Verify with no events, and a `405` never reach it).
|
|
135
|
+
Every kind of event is asked about, standby and chat-less ones included; the handler filters.
|
|
136
|
+
It gets the `ParsedEvent` with LINE's event as `raw`, and answers `"keep"` or `"consume"`.
|
|
137
|
+
A consumed event is not stored, not routed, not handed to the subscriber and does not record its chat as pending.
|
|
138
|
+
A request whose events are all consumed is a `200` and counts as accepted, like Verify.
|
|
139
|
+
|
|
140
|
+
A screen that throws, or answers anything else, fails the whole request with `500` and stores nothing, not even the events it had kept; LINE redelivers where it can.
|
|
141
|
+
The log line holds a code, never the event.
|
|
142
|
+
The screen gets a deeply frozen copy of the event, so it cannot change what is stored. It sees what the plugin does not keep for chats that are not connected, so it must not log or keep what it does not consume.
|
|
143
|
+
|
|
144
|
+
## Calling LINE through the plugin
|
|
145
|
+
|
|
146
|
+
`LINE.api` calls LINE with the plugin's own token source, timeout and `401` retry, so a host that needs a profile or a reply does not open a second token source.
|
|
147
|
+
Failures throw `LineApiError` (the `status` and an `http_<status>` code) or `LineError` with a short code; no message holds a response body.
|
|
148
|
+
Every call takes `{ signal, timeoutMs }`, where `timeoutMs` replaces the 15-second default.
|
|
149
|
+
|
|
150
|
+
| Call | Does |
|
|
151
|
+
|---|---|
|
|
152
|
+
| `profile(sourceKey, userId)` | the display name and picture of a group or room member, or of the user of a one-to-one chat; `undefined` when LINE answers `404` (not in the chat), and for someone else in a one-to-one chat without a request |
|
|
153
|
+
| `memberCount(sourceKey)` | how many people a push to the chat reaches (1 for a one-to-one chat), from the quota gate's one-minute reading |
|
|
154
|
+
| `quota()` | `{ limit, used, cap }`: the plan's monthly limit, consumption (at most a minute old; 0 when there is neither a limit nor a `quota.monthlyCap`, which are then not read) and the host's cap |
|
|
155
|
+
| `botInfo()` | the bot's `userId`, `basicId` and, when LINE says, `displayName` and `pictureUrl`; asked once and kept; a call made while the first read is running shares that read and its timeout, so use `signal` to give up sooner |
|
|
156
|
+
| `reply(replyToken, messages)` | answers an event with its single-use reply token: 1 to 5 messages, free of the push quota, not through the outbox |
|
|
157
|
+
| `probeToken()` | `undefined` when a token is held or can be issued now, else the failure's code; tells no failure listener |
|
|
158
|
+
| `onTokenFailure(listener)` | told the code (`token_refused`, `token_unreachable`, `token_malformed`) of each failed issue of the channel access token, whichever part of the plugin asked, once per issue however many callers shared it, never for a probe; returns the function that detaches it |
|
|
159
|
+
|
|
160
|
+
`senderNameProblem(name, options?)` answers why LINE would refuse a sender name (`empty`, `edge_space`, `too_long`, `control_character`, `forbidden_word`) or `undefined`, so a host can check a name before it queues a push.
|
|
161
|
+
`SENDER_NAME_MAX` and `SENDER_DEFAULTS` are the limit and the default forbidden words it uses.
|
|
162
|
+
A control character is anything in U+0000 to U+001F, U+007F or U+0080 to U+009F.
|
|
163
|
+
|
|
108
164
|
## Chats and the allowlist
|
|
109
165
|
|
|
110
166
|
The first event from a chat the bot has not seen records it as `pending`: its key, when it was first and last seen, and counts.
|
|
@@ -184,7 +240,7 @@ The processor that serves it is the package's own; there is no way to read raw i
|
|
|
184
240
|
| `joined` | `notice`: `"sent"`, `"failed"` or `"unconfirmed"`, truthfully (see [The join notice](#the-join-notice)) |
|
|
185
241
|
| `left` | |
|
|
186
242
|
| `memberJoined`, `memberLeft` | `userIds` |
|
|
187
|
-
| `outboxResult` | `sourceKey`, `originRef`, `status` (`sent`, `dead`, `refused`) and `
|
|
243
|
+
| `outboxResult` | `sourceKey`, `originRef`, `status` (`sent`, `dead`, `refused`), `errorCode` and `attempts` (see [Sending](#sending)) |
|
|
188
244
|
|
|
189
245
|
Every event but `outboxResult` has `eventId` (LINE's `webhookEventId`, the same on every redelivery), `sourceKey` and `timestamp`; a push is not a webhook event, so `outboxResult` has none of the three.
|
|
190
246
|
No event carries a reply token, a quote token or a URL.
|
|
@@ -277,7 +333,8 @@ Message objects hold what you send, so they live in `line_outbox.messages` until
|
|
|
277
333
|
- **Pushes of at most five objects, per chat and sender.** Objects queued for the same chat and the same sender within `outbox.coalesceMs` (2 seconds, counted from the first object, not from the last) share one push, in the order they were queued; a sixth object starts the next push, and a push that holds five goes at once. Different senders never share a push, and a push is never joined if a later push of the chat exists (that would reorder the chat). The chat's pushes go out strictly in order: a push that is backing off holds the ones behind it (a dead or refused one does not).
|
|
278
334
|
- **A retry key from the start.** Each push gets a UUID the moment its row exists, before any attempt, and every attempt and every retry after a crash sends it as `X-Line-Retry-Key`. LINE keeps a key for 24 hours (`outbox.retryKeyMs`): a push still unsent then is `dead` with `retry_key_expired` and is never sent under a new key.
|
|
279
335
|
- **Outcomes.** `2xx` and `409` (LINE already has this key) are `sent`. `429`, `5xx` and no answer back off (5 seconds doubling to 1 hour, at least `Retry-After`) and retry, up to `outbox.maxAttempts` (12), then `dead` with the last code. Any other `4xx` is `dead` at once with a code: `sender_name_refused` or `sender_icon_refused` when LINE names that property of the request, else `http_<status>`; nothing else of LINE's answer is kept. A push the quota gate refuses is `refused` and is never sent. A push for a chat that stopped being allowed is `dead` with `source_not_allowed`.
|
|
280
|
-
- **Results.** The subscriber hears an `outboxResult` for each push once it has ended, once per distinct `origin` the push carries (once with `originRef: null` when none was given), at least once: a result waits while no subscriber is attached and is told again later, up to 12 times, if the subscriber throws.
|
|
336
|
+
- **Results.** The subscriber hears an `outboxResult` for each push once it has ended, once per distinct `origin` the push carries (once with `originRef: null` when none was given), at least once: a result waits while no subscriber is attached and is told again later, up to 12 times, if the subscriber throws. The event carries `attempts`, how many tries the push took, the one that ended it included (1 for a push LINE refused at once, 2 after a `5xx` and then a refusal), so there is no need to read `line_outbox`.
|
|
337
|
+
- **What counts as a try.** An attempt is counted when a push is taken up and is given back only when the worker stops before the request was made; the migration `outbox-requested` adds `line_outbox.requested_at`, set just before the request goes out. A stop after it cannot say LINE did not take the request, so that try stays counted (and it counts towards `outbox.maxAttempts`).
|
|
281
338
|
- **Crashes.** A push in hand when the process dies has a 2-minute lease; the worker puts it back and sends it again under the same key, which LINE answers with `409` if it had taken the first request.
|
|
282
339
|
- **Service.** The `line-outbox` service runs the worker and counts as `busy` while a push is in flight, so the shutdown waits for it; a push still in flight when the host stops is put back as it was.
|
|
283
340
|
- **Nothing sensitive in logs.** The worker logs push ids, attempt numbers, status words and codes; never message objects, sender names, retry keys or tokens.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-roundtable-line",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "A LINE Messaging API transport for pi-roundtable: a signed webhook that persists events before it answers, a durable inbox worker, and an allowlist of the groups and chats it mirrors",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -33,14 +33,14 @@
|
|
|
33
33
|
"test": "bun test"
|
|
34
34
|
},
|
|
35
35
|
"peerDependencies": {
|
|
36
|
-
"pi-roundtable": ">=0.
|
|
36
|
+
"pi-roundtable": ">=0.16.0 <0.17.0"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"@biomejs/biome": "2.5.15",
|
|
40
40
|
"@earendil-works/pi-ai": ">=1.0.0 <2",
|
|
41
41
|
"@earendil-works/pi-coding-agent": ">=1.0.0 <2",
|
|
42
42
|
"@types/bun": "1.4.2",
|
|
43
|
-
"pi-roundtable": "0.
|
|
43
|
+
"pi-roundtable": "0.16.0",
|
|
44
44
|
"typebox": "1.3.34",
|
|
45
45
|
"typescript": "7.0.2"
|
|
46
46
|
}
|
package/src/api-port.ts
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import type { LineApi, RequestOptions } from "./api.ts";
|
|
2
|
+
import { LineApiError, LineError } from "./errors.ts";
|
|
3
|
+
import { parseSourceKey } from "./events.ts";
|
|
4
|
+
import type { LineOutMessage } from "./outbox.ts";
|
|
5
|
+
import type { QuotaGate } from "./quota.ts";
|
|
6
|
+
import type { TokenSource } from "./token.ts";
|
|
7
|
+
|
|
8
|
+
export interface LineCallOptions {
|
|
9
|
+
signal?: AbortSignal;
|
|
10
|
+
/** Replaces the client's 15-second per-request timeout. */
|
|
11
|
+
timeoutMs?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** What LINE shows for a person in a chat. */
|
|
15
|
+
export interface LineProfile {
|
|
16
|
+
displayName: string;
|
|
17
|
+
pictureUrl?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The month as the quota gate last read it (at most a minute old), and the host's own cap. */
|
|
21
|
+
export interface LineQuotaStanding {
|
|
22
|
+
/** The plan's monthly limit; undefined for a plan without one. */
|
|
23
|
+
limit: number | undefined;
|
|
24
|
+
/** Messages used this month; 0 when there is neither a plan limit nor a `quota.monthlyCap`, which are then not read. */
|
|
25
|
+
used: number;
|
|
26
|
+
/** `quota.monthlyCap` from the options; undefined when unset. */
|
|
27
|
+
cap: number | undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface LineBotInfo {
|
|
31
|
+
userId: string;
|
|
32
|
+
/** The `@…` basic id. */
|
|
33
|
+
basicId: string;
|
|
34
|
+
displayName?: string;
|
|
35
|
+
pictureUrl?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* LINE's API through the plugin's own token source, timeout and 401-retry. Failures throw
|
|
40
|
+
* `LineApiError` (status and `http_<status>`) or `LineError` with a short code; no message holds a
|
|
41
|
+
* response body.
|
|
42
|
+
*/
|
|
43
|
+
export interface LineApiPort {
|
|
44
|
+
/**
|
|
45
|
+
* A group or room member's profile (`/v2/bot/{group|room}/{id}/member/{userId}`), or, in a
|
|
46
|
+
* one-to-one chat, that user's own (`/v2/bot/profile/{userId}`). Undefined when LINE answers 404
|
|
47
|
+
* (not in the chat), and without a request for a one-to-one chat with someone else.
|
|
48
|
+
*/
|
|
49
|
+
profile(
|
|
50
|
+
sourceKey: string,
|
|
51
|
+
userId: string,
|
|
52
|
+
options?: LineCallOptions,
|
|
53
|
+
): Promise<LineProfile | undefined>;
|
|
54
|
+
/** How many people a push to the chat reaches: the member count, 1 for a one-to-one chat. Shares the quota gate's one-minute reading. */
|
|
55
|
+
memberCount(sourceKey: string, options?: LineCallOptions): Promise<number>;
|
|
56
|
+
/** The quota gate's reading, refreshed when older than a minute. */
|
|
57
|
+
quota(): Promise<LineQuotaStanding>;
|
|
58
|
+
/** `/v2/bot/info`, asked once and kept (the ids do not change); a failure is asked again next time. */
|
|
59
|
+
botInfo(options?: LineCallOptions): Promise<LineBotInfo>;
|
|
60
|
+
/** Answers an event with its single-use reply token: 1 to 5 messages, free of the push quota, not through the outbox. */
|
|
61
|
+
reply(
|
|
62
|
+
replyToken: string,
|
|
63
|
+
messages: readonly LineOutMessage[],
|
|
64
|
+
options?: LineCallOptions,
|
|
65
|
+
): Promise<void>;
|
|
66
|
+
/** Undefined when a token is held or can be issued now, else the failure's code. Tells no failure listener. */
|
|
67
|
+
probeToken(): Promise<string | undefined>;
|
|
68
|
+
/**
|
|
69
|
+
* Told the code (`token_refused`, `token_unreachable`, `token_malformed`) of each failed issue of
|
|
70
|
+
* the channel access token, whichever part of the plugin asked; once per issue however many callers
|
|
71
|
+
* shared it; never for a probe. Returns the function that detaches it. It must not throw.
|
|
72
|
+
*/
|
|
73
|
+
onTokenFailure(listener: (code: string) => void): () => void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The most message objects one reply holds. */
|
|
77
|
+
const REPLY_MAX_MESSAGES = 5;
|
|
78
|
+
|
|
79
|
+
export interface LineApiPortDeps {
|
|
80
|
+
api: LineApi;
|
|
81
|
+
tokens: TokenSource;
|
|
82
|
+
quota: QuotaGate;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A call's options as the client takes them; a timeout that is not a positive number is refused. */
|
|
86
|
+
function callOf(
|
|
87
|
+
options: LineCallOptions | undefined,
|
|
88
|
+
): Pick<RequestOptions, "signal" | "timeoutMs"> {
|
|
89
|
+
if (!options) return {};
|
|
90
|
+
const { signal, timeoutMs } = options;
|
|
91
|
+
if (
|
|
92
|
+
timeoutMs !== undefined &&
|
|
93
|
+
!(
|
|
94
|
+
typeof timeoutMs === "number" &&
|
|
95
|
+
Number.isFinite(timeoutMs) &&
|
|
96
|
+
timeoutMs > 0
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
throw new LineError(
|
|
100
|
+
"bad_options",
|
|
101
|
+
"line: timeoutMs is a positive number of milliseconds",
|
|
102
|
+
);
|
|
103
|
+
return {
|
|
104
|
+
...(signal ? { signal } : {}),
|
|
105
|
+
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const text = (value: unknown): string | undefined =>
|
|
110
|
+
typeof value === "string" && value !== "" ? value : undefined;
|
|
111
|
+
|
|
112
|
+
/** The port other plugins call LINE through: the plugin's token source, timeout and 401-retry. */
|
|
113
|
+
export function lineApiPort(deps: LineApiPortDeps): LineApiPort {
|
|
114
|
+
const { api, tokens, quota } = deps;
|
|
115
|
+
let botInfo: LineBotInfo | undefined;
|
|
116
|
+
/** The first read in progress, shared by callers that arrive before it ends. */
|
|
117
|
+
let asking: Promise<LineBotInfo> | undefined;
|
|
118
|
+
const readBotInfo = async (
|
|
119
|
+
call: Pick<RequestOptions, "timeoutMs">,
|
|
120
|
+
): Promise<LineBotInfo> => {
|
|
121
|
+
const found = (await api.json("/v2/bot/info", call)) as
|
|
122
|
+
| {
|
|
123
|
+
userId?: unknown;
|
|
124
|
+
basicId?: unknown;
|
|
125
|
+
displayName?: unknown;
|
|
126
|
+
pictureUrl?: unknown;
|
|
127
|
+
}
|
|
128
|
+
| undefined;
|
|
129
|
+
const userId = text(found?.userId);
|
|
130
|
+
const basicId = text(found?.basicId);
|
|
131
|
+
if (!userId || !basicId || !/^@\S+$/.test(basicId))
|
|
132
|
+
throw new LineError(
|
|
133
|
+
"bot_info_malformed",
|
|
134
|
+
"line: bot info has no userId or basicId",
|
|
135
|
+
);
|
|
136
|
+
const displayName = text(found?.displayName);
|
|
137
|
+
const pictureUrl = text(found?.pictureUrl);
|
|
138
|
+
botInfo = {
|
|
139
|
+
userId,
|
|
140
|
+
basicId,
|
|
141
|
+
...(displayName ? { displayName } : {}),
|
|
142
|
+
...(pictureUrl ? { pictureUrl } : {}),
|
|
143
|
+
};
|
|
144
|
+
return botInfo;
|
|
145
|
+
};
|
|
146
|
+
return {
|
|
147
|
+
async profile(sourceKey, userId, options) {
|
|
148
|
+
const call = callOf(options);
|
|
149
|
+
const source = parseSourceKey(sourceKey);
|
|
150
|
+
if (!source)
|
|
151
|
+
throw new LineError("bad_source_key", "line: not a source key");
|
|
152
|
+
if (typeof userId !== "string" || userId === "")
|
|
153
|
+
throw new LineError(
|
|
154
|
+
"bad_user_id",
|
|
155
|
+
"line: a user id is a non-empty string",
|
|
156
|
+
);
|
|
157
|
+
// A one-to-one chat has one member; LINE is not asked about anyone else in it.
|
|
158
|
+
if (source.kind === "user" && source.id !== userId) return undefined;
|
|
159
|
+
const user = encodeURIComponent(userId);
|
|
160
|
+
const path =
|
|
161
|
+
source.kind === "user"
|
|
162
|
+
? `/v2/bot/profile/${user}`
|
|
163
|
+
: `/v2/bot/${source.kind}/${encodeURIComponent(source.id)}/member/${user}`;
|
|
164
|
+
const response = await api.exchange(path, call);
|
|
165
|
+
if (response.status === 404) {
|
|
166
|
+
await response.body?.cancel().catch(() => undefined);
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
if (!response.ok) {
|
|
170
|
+
await response.body?.cancel().catch(() => undefined);
|
|
171
|
+
throw new LineApiError(
|
|
172
|
+
response.status,
|
|
173
|
+
`http_${response.status}`,
|
|
174
|
+
`line: a profile lookup answered ${response.status}`,
|
|
175
|
+
response.headers.get("x-line-request-id") ?? undefined,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
const found = (await response.json().catch(() => undefined)) as
|
|
179
|
+
| { displayName?: unknown; pictureUrl?: unknown }
|
|
180
|
+
| undefined;
|
|
181
|
+
if (
|
|
182
|
+
typeof found?.displayName !== "string" ||
|
|
183
|
+
found.displayName.trim() === ""
|
|
184
|
+
)
|
|
185
|
+
throw new LineError(
|
|
186
|
+
"profile_malformed",
|
|
187
|
+
"line: the profile has no displayName",
|
|
188
|
+
);
|
|
189
|
+
const pictureUrl = text(found.pictureUrl);
|
|
190
|
+
return {
|
|
191
|
+
displayName: found.displayName,
|
|
192
|
+
...(pictureUrl ? { pictureUrl } : {}),
|
|
193
|
+
};
|
|
194
|
+
},
|
|
195
|
+
memberCount: (sourceKey, options) =>
|
|
196
|
+
quota.recipients(sourceKey, callOf(options)),
|
|
197
|
+
async quota() {
|
|
198
|
+
const { limit, used } = await quota.standing();
|
|
199
|
+
return { limit, used, cap: quota.cap };
|
|
200
|
+
},
|
|
201
|
+
async botInfo(options) {
|
|
202
|
+
const { signal, timeoutMs } = callOf(options);
|
|
203
|
+
signal?.throwIfAborted();
|
|
204
|
+
if (botInfo) return { ...botInfo };
|
|
205
|
+
// Callers that arrive while a read is in progress share it, and so its timeout (the first
|
|
206
|
+
// caller's); each still hears its own signal.
|
|
207
|
+
asking ??= readBotInfo(
|
|
208
|
+
timeoutMs === undefined ? {} : { timeoutMs },
|
|
209
|
+
).finally(() => {
|
|
210
|
+
asking = undefined;
|
|
211
|
+
});
|
|
212
|
+
const read = asking;
|
|
213
|
+
if (!signal) return { ...(await read) };
|
|
214
|
+
let onAbort: (() => void) | undefined;
|
|
215
|
+
const aborted = new Promise<never>((_, reject) => {
|
|
216
|
+
onAbort = () => reject(signal.reason);
|
|
217
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
218
|
+
});
|
|
219
|
+
try {
|
|
220
|
+
return { ...(await Promise.race([read, aborted])) };
|
|
221
|
+
} finally {
|
|
222
|
+
if (onAbort) signal.removeEventListener("abort", onAbort);
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
async reply(replyToken, messages, options) {
|
|
226
|
+
const call = callOf(options);
|
|
227
|
+
if (typeof replyToken !== "string" || replyToken === "")
|
|
228
|
+
throw new LineError(
|
|
229
|
+
"bad_reply_token",
|
|
230
|
+
"line: a reply token is a non-empty string",
|
|
231
|
+
);
|
|
232
|
+
if (
|
|
233
|
+
!Array.isArray(messages) ||
|
|
234
|
+
messages.length === 0 ||
|
|
235
|
+
messages.length > REPLY_MAX_MESSAGES ||
|
|
236
|
+
messages.some(
|
|
237
|
+
(message) =>
|
|
238
|
+
typeof message !== "object" ||
|
|
239
|
+
message === null ||
|
|
240
|
+
Array.isArray(message) ||
|
|
241
|
+
typeof message.type !== "string" ||
|
|
242
|
+
message.type === "",
|
|
243
|
+
)
|
|
244
|
+
)
|
|
245
|
+
throw new LineError(
|
|
246
|
+
"bad_messages",
|
|
247
|
+
`line: a reply takes 1 to ${REPLY_MAX_MESSAGES} message objects`,
|
|
248
|
+
);
|
|
249
|
+
await api.reply(replyToken, messages, call);
|
|
250
|
+
},
|
|
251
|
+
probeToken: () => tokens.probe(),
|
|
252
|
+
onTokenFailure: (listener) => tokens.onFailure(listener),
|
|
253
|
+
};
|
|
254
|
+
}
|
package/src/api.ts
CHANGED
|
@@ -201,21 +201,26 @@ export class LineApi {
|
|
|
201
201
|
async reply(
|
|
202
202
|
replyToken: string,
|
|
203
203
|
messages: readonly unknown[],
|
|
204
|
-
signal
|
|
204
|
+
call: Pick<RequestOptions, "signal" | "timeoutMs"> = {},
|
|
205
205
|
): Promise<void> {
|
|
206
206
|
await this.request("/v2/bot/message/reply", {
|
|
207
207
|
method: "POST",
|
|
208
208
|
headers: { "content-type": "application/json" },
|
|
209
209
|
body: JSON.stringify({ replyToken, messages }),
|
|
210
|
-
...
|
|
210
|
+
...call,
|
|
211
211
|
});
|
|
212
212
|
}
|
|
213
213
|
|
|
214
214
|
/** How many people a push to a chat reaches: the member count of a group or room, else one. */
|
|
215
|
-
async recipients(
|
|
215
|
+
async recipients(
|
|
216
|
+
kind: SourceKind,
|
|
217
|
+
id: string,
|
|
218
|
+
call: Pick<RequestOptions, "signal" | "timeoutMs"> = {},
|
|
219
|
+
): Promise<number> {
|
|
216
220
|
if (kind === "user") return 1;
|
|
217
221
|
const found = (await this.json(
|
|
218
222
|
`/v2/bot/${kind}/${encodeURIComponent(id)}/members/count`,
|
|
223
|
+
call,
|
|
219
224
|
)) as { count?: unknown };
|
|
220
225
|
if (typeof found?.count !== "number" || !Number.isInteger(found.count))
|
|
221
226
|
throw new LineError(
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
LineApiPort,
|
|
3
|
+
LineBotInfo,
|
|
4
|
+
LineCallOptions,
|
|
5
|
+
LineProfile,
|
|
6
|
+
LineQuotaStanding,
|
|
7
|
+
} from "./api-port.ts";
|
|
1
8
|
export type {
|
|
2
9
|
LineContentRequest,
|
|
3
10
|
LineContentResult,
|
|
@@ -43,7 +50,17 @@ export type { LineBridgePort } from "./port.ts";
|
|
|
43
50
|
export { LINE } from "./port.ts";
|
|
44
51
|
export type { QuotaOptions } from "./quota.ts";
|
|
45
52
|
export type { RetentionOptions } from "./retention.ts";
|
|
46
|
-
export type {
|
|
53
|
+
export type { LineScreen, LineScreenVerdict } from "./screen.ts";
|
|
54
|
+
export type {
|
|
55
|
+
LineSender,
|
|
56
|
+
SenderNameProblem,
|
|
57
|
+
SenderOptions,
|
|
58
|
+
} from "./sender.ts";
|
|
59
|
+
export {
|
|
60
|
+
SENDER_DEFAULTS,
|
|
61
|
+
SENDER_NAME_MAX,
|
|
62
|
+
senderNameProblem,
|
|
63
|
+
} from "./sender.ts";
|
|
47
64
|
export type {
|
|
48
65
|
LineSource,
|
|
49
66
|
LineSourceSummary,
|
package/src/messages.ts
CHANGED
|
@@ -115,6 +115,13 @@ export interface LineOutboxResultEvent {
|
|
|
115
115
|
* `source_not_allowed`); null when it was sent.
|
|
116
116
|
*/
|
|
117
117
|
errorCode: string | null;
|
|
118
|
+
/**
|
|
119
|
+
* How many tries the push took, the one that ended it included: 1 for a push LINE refused at once,
|
|
120
|
+
* 2 after a `5xx` and then a refusal. A try counts once its request has been made to LINE, also
|
|
121
|
+
* when a stop cut it short, and once the worker took the push up only to end it without a request
|
|
122
|
+
* (`refused`, `source_not_allowed`); a stop before the request does not count.
|
|
123
|
+
*/
|
|
124
|
+
attempts: number;
|
|
118
125
|
}
|
|
119
126
|
|
|
120
127
|
export type LineEvent =
|
package/src/migrations.ts
CHANGED
|
@@ -136,9 +136,21 @@ export const OUTBOX_MIGRATION: Migration = {
|
|
|
136
136
|
},
|
|
137
137
|
};
|
|
138
138
|
|
|
139
|
+
/**
|
|
140
|
+
* When the request of the push's current attempt was handed to the network. A stop that comes after
|
|
141
|
+
* it cannot say LINE did not take the request, so the attempt stays counted.
|
|
142
|
+
*/
|
|
143
|
+
export const OUTBOX_REQUESTED_MIGRATION: Migration = {
|
|
144
|
+
name: "outbox-requested",
|
|
145
|
+
up: async (sql) => {
|
|
146
|
+
await sql`ALTER TABLE line_outbox ADD COLUMN IF NOT EXISTS requested_at TIMESTAMPTZ`;
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
|
|
139
150
|
/** Every migration of the package, in the order they run. */
|
|
140
151
|
export const MIGRATIONS: Migration[] = [
|
|
141
152
|
INBOX_MIGRATION,
|
|
142
153
|
MESSAGES_MIGRATION,
|
|
143
154
|
OUTBOX_MIGRATION,
|
|
155
|
+
OUTBOX_REQUESTED_MIGRATION,
|
|
144
156
|
];
|
package/src/outbox-worker.ts
CHANGED
|
@@ -138,7 +138,7 @@ export class OutboxWorker {
|
|
|
138
138
|
ORDER BY id LIMIT 1`) as { id: string | number }[];
|
|
139
139
|
if (!head) return undefined;
|
|
140
140
|
const [claimed] = (await tx`UPDATE line_outbox
|
|
141
|
-
SET status = 'sending', claimed_at = ${now}, attempts = attempts + 1,
|
|
141
|
+
SET status = 'sending', claimed_at = ${now}, requested_at = NULL, attempts = attempts + 1,
|
|
142
142
|
first_attempt_at = COALESCE(first_attempt_at, ${now})
|
|
143
143
|
WHERE id = (SELECT id FROM line_outbox WHERE id = ${head.id} AND status = 'queued' FOR UPDATE SKIP LOCKED)
|
|
144
144
|
RETURNING id, source_key, sender_name, sender_icon_url, messages, retry_key, attempts,
|
|
@@ -214,6 +214,11 @@ export class OutboxWorker {
|
|
|
214
214
|
const messages = row.messages.map((message) =>
|
|
215
215
|
Object.keys(sender).length > 0 ? { ...message, sender } : message,
|
|
216
216
|
);
|
|
217
|
+
// A stop that comes before this leaves the attempt uncounted; one after it cannot say LINE
|
|
218
|
+
// did not take the request.
|
|
219
|
+
controller.signal.throwIfAborted();
|
|
220
|
+
await this.#sql`UPDATE line_outbox SET requested_at = ${this.#now()}
|
|
221
|
+
WHERE id = ${row.id} AND status = 'sending' AND claimed_at = ${row.claimed_at}`;
|
|
217
222
|
const pushed = await this.#api.push(
|
|
218
223
|
source.id,
|
|
219
224
|
messages,
|
|
@@ -245,7 +250,8 @@ export class OutboxWorker {
|
|
|
245
250
|
this.#quota.settle(reserved.reservation, sent);
|
|
246
251
|
}
|
|
247
252
|
} catch (error) {
|
|
248
|
-
// Giving up because the worker stops is the restart's doing: the push goes back as it was
|
|
253
|
+
// Giving up because the worker stops is the restart's doing: the push goes back as it was,
|
|
254
|
+
// unless its request had already been made.
|
|
249
255
|
if (this.#stopping && controller.signal.aborted) {
|
|
250
256
|
await this.#later(row, 0, "worker_stopped", false);
|
|
251
257
|
return;
|
|
@@ -307,7 +313,8 @@ export class OutboxWorker {
|
|
|
307
313
|
counted: boolean,
|
|
308
314
|
): Promise<void> {
|
|
309
315
|
await this.#sql`UPDATE line_outbox
|
|
310
|
-
SET status = 'queued', claimed_at = NULL,
|
|
316
|
+
SET status = 'queued', claimed_at = NULL,
|
|
317
|
+
attempts = attempts - CASE WHEN ${counted ? 1 : 0}::int = 0 AND requested_at IS NULL THEN 1 ELSE 0 END,
|
|
311
318
|
next_attempt_at = ${new Date(this.#now().getTime() + delayMs)}, last_error_code = ${code}
|
|
312
319
|
WHERE id = ${row.id} AND status = 'sending' AND claimed_at = ${row.claimed_at}`;
|
|
313
320
|
}
|
|
@@ -332,8 +339,9 @@ export class OutboxWorker {
|
|
|
332
339
|
last_error_code: string | null;
|
|
333
340
|
origin_refs: (string | null)[];
|
|
334
341
|
notify_attempts: number;
|
|
342
|
+
attempts: number;
|
|
335
343
|
}[] = await this
|
|
336
|
-
.#sql`SELECT id, source_key, status, last_error_code, origin_refs, notify_attempts
|
|
344
|
+
.#sql`SELECT id, source_key, status, last_error_code, origin_refs, notify_attempts, attempts
|
|
337
345
|
FROM line_outbox
|
|
338
346
|
WHERE notified_at IS NULL AND status IN ('sent', 'dead', 'refused')
|
|
339
347
|
AND (notify_after IS NULL OR notify_after <= ${now})
|
|
@@ -356,6 +364,7 @@ export class OutboxWorker {
|
|
|
356
364
|
originRef,
|
|
357
365
|
status: row.status,
|
|
358
366
|
errorCode: row.last_error_code,
|
|
367
|
+
attempts: row.attempts,
|
|
359
368
|
},
|
|
360
369
|
controller.signal,
|
|
361
370
|
);
|
package/src/plugin.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
type RoundtablePlugin,
|
|
5
5
|
} from "pi-roundtable";
|
|
6
6
|
import { LineApi } from "./api.ts";
|
|
7
|
+
import { lineApiPort } from "./api-port.ts";
|
|
7
8
|
import { BotIdentity } from "./bot.ts";
|
|
8
9
|
import { LineConsumer, NO_KEEP } from "./consumer.ts";
|
|
9
10
|
import { MessageContent } from "./content.ts";
|
|
@@ -18,6 +19,7 @@ import { OutboxWorker } from "./outbox-worker.ts";
|
|
|
18
19
|
import { LINE, type LineBridgePort } from "./port.ts";
|
|
19
20
|
import { QuotaGate } from "./quota.ts";
|
|
20
21
|
import { RetentionSweeper } from "./retention.ts";
|
|
22
|
+
import type { LineScreen } from "./screen.ts";
|
|
21
23
|
import { PgSources } from "./sources.ts";
|
|
22
24
|
import { LineSurface } from "./surface.ts";
|
|
23
25
|
import { TokenSource } from "./token.ts";
|
|
@@ -135,6 +137,8 @@ export function line(options: LineOptions): RoundtablePlugin {
|
|
|
135
137
|
...(options.outbox ? { options: options.outbox } : {}),
|
|
136
138
|
});
|
|
137
139
|
const sending = outboxWorker;
|
|
140
|
+
// The one screen of the webhook; read at each request, so a detach takes effect at once.
|
|
141
|
+
let screen: LineScreen | undefined;
|
|
138
142
|
const port: LineBridgePort = {
|
|
139
143
|
sources,
|
|
140
144
|
outbox,
|
|
@@ -162,6 +166,16 @@ export function line(options: LineOptions): RoundtablePlugin {
|
|
|
162
166
|
};
|
|
163
167
|
},
|
|
164
168
|
content: (messageId, request) => content.fetch(messageId, request),
|
|
169
|
+
api: lineApiPort({ api, tokens, quota }),
|
|
170
|
+
screen: (handler) => {
|
|
171
|
+
if (typeof handler !== "function")
|
|
172
|
+
throw new TypeError("line: a screen is a function");
|
|
173
|
+
if (screen) throw new Error("line: a screen is already attached");
|
|
174
|
+
screen = handler;
|
|
175
|
+
return () => {
|
|
176
|
+
if (screen === handler) screen = undefined;
|
|
177
|
+
};
|
|
178
|
+
},
|
|
165
179
|
};
|
|
166
180
|
context.services.provide(LINE, port);
|
|
167
181
|
return {
|
|
@@ -215,6 +229,7 @@ export function line(options: LineOptions): RoundtablePlugin {
|
|
|
215
229
|
botUserId: () => bot.current(),
|
|
216
230
|
stored: () => worker.kick(),
|
|
217
231
|
stats: webhooks,
|
|
232
|
+
screen: () => screen,
|
|
218
233
|
}),
|
|
219
234
|
},
|
|
220
235
|
],
|
package/src/port.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { serviceKey } from "pi-roundtable";
|
|
2
|
+
import type { LineApiPort } from "./api-port.ts";
|
|
2
3
|
import type { LineContentRequest, LineContentResult } from "./content.ts";
|
|
3
4
|
import type { LineSubscriber } from "./messages.ts";
|
|
4
5
|
import type { LineOutbox } from "./outbox.ts";
|
|
6
|
+
import type { LineScreen } from "./screen.ts";
|
|
5
7
|
import type { LineSources } from "./sources.ts";
|
|
6
8
|
import type { WebhookHealth } from "./webhook.ts";
|
|
7
9
|
|
|
@@ -43,6 +45,14 @@ export interface LineBridgePort {
|
|
|
43
45
|
messageId: string,
|
|
44
46
|
request?: LineContentRequest,
|
|
45
47
|
): Promise<LineContentResult>;
|
|
48
|
+
/** LINE's API through the plugin's token; see `LineApiPort`. */
|
|
49
|
+
readonly api: LineApiPort;
|
|
50
|
+
/**
|
|
51
|
+
* Attaches the one screen of the webhook; a second is refused (throws). Attach it during setup:
|
|
52
|
+
* the listeners open only after every plugin is set up, so no request arrives before it. Until one
|
|
53
|
+
* is attached, and after it is detached, every event is kept. Returns the function that detaches it.
|
|
54
|
+
*/
|
|
55
|
+
screen(handler: LineScreen): () => void;
|
|
46
56
|
}
|
|
47
57
|
|
|
48
58
|
export const LINE = serviceKey<LineBridgePort>("roundtable.line", {
|
package/src/quota.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { LineApi } from "./api.ts";
|
|
1
|
+
import type { LineApi, RequestOptions } from "./api.ts";
|
|
2
2
|
import { LineError } from "./errors.ts";
|
|
3
3
|
import { parseSourceKey } from "./events.ts";
|
|
4
4
|
|
|
@@ -90,7 +90,10 @@ export class QuotaGate {
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
/** How many people a push to the chat reaches; a count is trusted for a minute. */
|
|
93
|
-
async recipients(
|
|
93
|
+
async recipients(
|
|
94
|
+
sourceKey: string,
|
|
95
|
+
call: Pick<RequestOptions, "signal" | "timeoutMs"> = {},
|
|
96
|
+
): Promise<number> {
|
|
94
97
|
const source = parseSourceKey(sourceKey);
|
|
95
98
|
if (!source)
|
|
96
99
|
throw new LineError("bad_source_key", "line: not a source key");
|
|
@@ -98,11 +101,26 @@ export class QuotaGate {
|
|
|
98
101
|
const now = this.#now().getTime();
|
|
99
102
|
const held = this.#counts.get(sourceKey);
|
|
100
103
|
if (held && now - held.at < this.#cacheMs) return held.n;
|
|
101
|
-
const n = await this.#api.recipients(source.kind, source.id);
|
|
104
|
+
const n = await this.#api.recipients(source.kind, source.id, call);
|
|
102
105
|
this.#counts.set(sourceKey, { n, at: now });
|
|
103
106
|
return n;
|
|
104
107
|
}
|
|
105
108
|
|
|
109
|
+
/**
|
|
110
|
+
* The month as the gate last read it, refreshed when older than a minute: the plan's limit
|
|
111
|
+
* (undefined without one) and the consumption, which is 0 when there is neither a limit nor a cap
|
|
112
|
+
* of our own, because nothing is read then.
|
|
113
|
+
*/
|
|
114
|
+
async standing(): Promise<{ limit: number | undefined; used: number }> {
|
|
115
|
+
const { limit, used } = await this.#read();
|
|
116
|
+
return { limit, used };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The `monthlyCap` of the options, if one is set. */
|
|
120
|
+
get cap(): number | undefined {
|
|
121
|
+
return this.#cap;
|
|
122
|
+
}
|
|
123
|
+
|
|
106
124
|
async #read(): Promise<Reading> {
|
|
107
125
|
const now = this.#now().getTime();
|
|
108
126
|
if (this.#reading && now - this.#reading.at < this.#cacheMs)
|
package/src/screen.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ParsedEvent } from "./events.ts";
|
|
2
|
+
|
|
3
|
+
/** What the webhook does with one signed event of this bot: store it, or drop it unstored. */
|
|
4
|
+
export type LineScreenVerdict = "keep" | "consume";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Asked, in order, for every event of a request whose signature and destination passed, before the
|
|
8
|
+
* request's events are stored. A consumed event is never stored, routed or handed to the subscriber.
|
|
9
|
+
* A screen that throws, or answers anything but "keep" or "consume", fails the whole request with
|
|
10
|
+
* 500 and stores nothing (LINE redelivers where it can). It sees the event's `raw` body: it must not
|
|
11
|
+
* log or keep what it does not consume. The event is a deeply frozen copy: a screen cannot change
|
|
12
|
+
* what is stored.
|
|
13
|
+
*/
|
|
14
|
+
export type LineScreen = (event: ParsedEvent) => Promise<LineScreenVerdict>;
|
package/src/sender.ts
CHANGED
|
@@ -20,9 +20,9 @@ export interface SenderOptions {
|
|
|
20
20
|
forbiddenWords: readonly string[];
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
export const SENDER_DEFAULTS: SenderOptions = {
|
|
24
|
-
forbiddenWords: ["LINE", "LY"],
|
|
25
|
-
};
|
|
23
|
+
export const SENDER_DEFAULTS: SenderOptions = Object.freeze({
|
|
24
|
+
forbiddenWords: Object.freeze(["LINE", "LY"]),
|
|
25
|
+
});
|
|
26
26
|
|
|
27
27
|
export const SENDER_NAME_MAX = 20;
|
|
28
28
|
export const SENDER_ICON_MAX = 2000;
|
|
@@ -51,6 +51,52 @@ export function checkSenderOptions(options: Partial<SenderOptions>): void {
|
|
|
51
51
|
const WORD =
|
|
52
52
|
/(?:\p{Script=Latin}|\p{M}|\p{N})+|(?:(?!\p{Script=Latin})\p{L})+/gu;
|
|
53
53
|
|
|
54
|
+
/** Why LINE would refuse a sender name. */
|
|
55
|
+
export type SenderNameProblem =
|
|
56
|
+
| "empty"
|
|
57
|
+
| "edge_space"
|
|
58
|
+
| "control_character"
|
|
59
|
+
| "too_long"
|
|
60
|
+
| "forbidden_word";
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Why LINE would refuse `name` as a sender name, or undefined when it would take it. The first
|
|
64
|
+
* problem found is the one returned, checked in the order `empty`, `edge_space`,
|
|
65
|
+
* `too_long`, `control_character`, `forbidden_word`. `control_character` covers C0, DEL and C1
|
|
66
|
+
* (U+0080 to U+009F). Never throws: a value that is not a string is `empty`.
|
|
67
|
+
*/
|
|
68
|
+
export function senderNameProblem(
|
|
69
|
+
name: string,
|
|
70
|
+
options: SenderOptions = SENDER_DEFAULTS,
|
|
71
|
+
): SenderNameProblem | undefined {
|
|
72
|
+
if (typeof name !== "string" || name.trim() === "") return "empty";
|
|
73
|
+
if (name !== name.trim()) return "edge_space";
|
|
74
|
+
if (Array.from(name).length > SENDER_NAME_MAX) return "too_long";
|
|
75
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: control characters are what is refused
|
|
76
|
+
if (/[\u0000-\u001f\u007f-\u009f]/.test(name)) return "control_character";
|
|
77
|
+
// Full-width letters are the letters they look like.
|
|
78
|
+
const words = (name.normalize("NFKC").match(WORD) ?? []).map((w) =>
|
|
79
|
+
w.toLowerCase(),
|
|
80
|
+
);
|
|
81
|
+
// An options object without the list (a cast, or untyped JavaScript) means the default list.
|
|
82
|
+
const forbidden = (
|
|
83
|
+
Array.isArray(options?.forbiddenWords)
|
|
84
|
+
? options.forbiddenWords
|
|
85
|
+
: SENDER_DEFAULTS.forbiddenWords
|
|
86
|
+
).map((w) => w.normalize("NFKC").toLowerCase());
|
|
87
|
+
return words.some((w) => forbidden.includes(w))
|
|
88
|
+
? "forbidden_word"
|
|
89
|
+
: undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const NAME_PROBLEM_TEXT: Record<SenderNameProblem, string> = {
|
|
93
|
+
empty: "a sender name is a non-empty string",
|
|
94
|
+
edge_space: "a sender name has no leading or trailing space",
|
|
95
|
+
too_long: `a sender name is at most ${SENDER_NAME_MAX} characters`,
|
|
96
|
+
control_character: "a sender name has no control characters",
|
|
97
|
+
forbidden_word: "a sender name may not use a word LINE refuses",
|
|
98
|
+
};
|
|
99
|
+
|
|
54
100
|
/**
|
|
55
101
|
* Checks a sender before anything is queued and returns it as it will be sent. Throws `LineError`
|
|
56
102
|
* `bad_sender` for a name that is empty, over 20 characters, holds a control character or a
|
|
@@ -71,25 +117,9 @@ export function validateSender(
|
|
|
71
117
|
throw bad("a sender needs a name or an iconUrl");
|
|
72
118
|
const checked: LineSender = {};
|
|
73
119
|
if (name !== undefined) {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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;
|
|
120
|
+
const problem = senderNameProblem(name as string, options);
|
|
121
|
+
if (problem) throw bad(NAME_PROBLEM_TEXT[problem]);
|
|
122
|
+
checked.name = name as string;
|
|
93
123
|
}
|
|
94
124
|
if (iconUrl !== undefined) {
|
|
95
125
|
if (typeof iconUrl !== "string" || iconUrl.length > SENDER_ICON_MAX)
|
package/src/token.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { scrubDiagnostic } from "pi-roundtable/kit";
|
|
3
|
-
import { LineApiError, LineError } from "./errors.ts";
|
|
3
|
+
import { errorCode, LineApiError, LineError } from "./errors.ts";
|
|
4
4
|
|
|
5
5
|
/** How the package gets the channel access token it calls LINE with. */
|
|
6
6
|
export type TokenMode = "stateless" | { static: string };
|
|
@@ -34,6 +34,12 @@ export interface TokenSourceOptions {
|
|
|
34
34
|
baseUrl?: string;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/** One issue of a stateless token in progress; `quiet` until a caller other than a probe shares it. */
|
|
38
|
+
interface Flight {
|
|
39
|
+
promise: Promise<Issued>;
|
|
40
|
+
quiet: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
37
43
|
interface Issued {
|
|
38
44
|
token: string;
|
|
39
45
|
/** Epoch milliseconds after which the token is replaced. */
|
|
@@ -44,7 +50,8 @@ interface Issued {
|
|
|
44
50
|
* Hands out the channel access token. It issues stateless tokens one at a time (concurrent callers
|
|
45
51
|
* share one request), keeps a token in memory only until a minute before it lapses, and forgets
|
|
46
52
|
* one LINE rejected. The token and the secret live in private fields: they are in no log, no error
|
|
47
|
-
* message and no serialized form of this object.
|
|
53
|
+
* message and no serialized form of this object. A failed issue is told, by its code, to the
|
|
54
|
+
* listeners of {@link TokenSource.onFailure}.
|
|
48
55
|
*/
|
|
49
56
|
export class TokenSource {
|
|
50
57
|
readonly #id: string;
|
|
@@ -54,9 +61,10 @@ export class TokenSource {
|
|
|
54
61
|
readonly #now: () => number;
|
|
55
62
|
readonly #base: string;
|
|
56
63
|
#current: Issued | undefined;
|
|
57
|
-
#inflight:
|
|
64
|
+
#inflight: Flight | undefined;
|
|
58
65
|
#issued = 0;
|
|
59
66
|
#lastFailure: string | undefined;
|
|
67
|
+
readonly #listeners = new Set<(code: string) => void>();
|
|
60
68
|
|
|
61
69
|
constructor(options: TokenSourceOptions) {
|
|
62
70
|
const {
|
|
@@ -118,10 +126,64 @@ export class TokenSource {
|
|
|
118
126
|
if (this.#static) return this.#static;
|
|
119
127
|
const held = this.#current;
|
|
120
128
|
if (held && held.validUntil > this.#now()) return held.token;
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
129
|
+
return (await this.#join(false)).token;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Undefined when a token is held or can be issued now, else the code of the failure. An issue it
|
|
134
|
+
* starts is told to no listener, unless a caller of `token()` shares it meanwhile.
|
|
135
|
+
*/
|
|
136
|
+
async probe(): Promise<string | undefined> {
|
|
137
|
+
if (this.#static) return undefined;
|
|
138
|
+
const held = this.#current;
|
|
139
|
+
if (held && held.validUntil > this.#now()) return undefined;
|
|
140
|
+
try {
|
|
141
|
+
await this.#join(true);
|
|
142
|
+
return undefined;
|
|
143
|
+
} catch (error) {
|
|
144
|
+
return errorCode(error);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Calls `listener` with the code of each failed issue (`token_refused`, `token_unreachable`,
|
|
150
|
+
* `token_malformed`), once per issue however many callers shared it. Returns the function that
|
|
151
|
+
* detaches it. A listener that throws, or returns a promise that rejects, is ignored.
|
|
152
|
+
*/
|
|
153
|
+
onFailure(listener: (code: string) => void): () => void {
|
|
154
|
+
this.#listeners.add(listener);
|
|
155
|
+
return () => void this.#listeners.delete(listener);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The issue in progress, started when there is none. A caller that is not a probe makes it audible. */
|
|
159
|
+
#join(quiet: boolean): Promise<Issued> {
|
|
160
|
+
if (this.#inflight) {
|
|
161
|
+
if (!quiet) this.#inflight.quiet = false;
|
|
162
|
+
return this.#inflight.promise;
|
|
163
|
+
}
|
|
164
|
+
const flight: Flight = {
|
|
165
|
+
quiet,
|
|
166
|
+
promise: this.#issue()
|
|
167
|
+
.catch((error: unknown) => {
|
|
168
|
+
if (!flight.quiet) this.#notify(errorCode(error));
|
|
169
|
+
throw error;
|
|
170
|
+
})
|
|
171
|
+
.finally(() => {
|
|
172
|
+
this.#inflight = undefined;
|
|
173
|
+
}),
|
|
174
|
+
};
|
|
175
|
+
this.#inflight = flight;
|
|
176
|
+
return flight.promise;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
#notify(code: string): void {
|
|
180
|
+
for (const listener of [...this.#listeners])
|
|
181
|
+
try {
|
|
182
|
+
// An async listener that rejects is as harmless as one that throws.
|
|
183
|
+
void Promise.resolve(listener(code)).catch(() => undefined);
|
|
184
|
+
} catch {
|
|
185
|
+
// A listener must not throw; one that does costs the others nothing.
|
|
186
|
+
}
|
|
125
187
|
}
|
|
126
188
|
|
|
127
189
|
/**
|
package/src/webhook.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { SQL } from "bun";
|
|
2
2
|
import type { Logger } from "pi-roundtable";
|
|
3
3
|
import { errorCode, LineError } from "./errors.ts";
|
|
4
|
-
import { parseWebhook } from "./events.ts";
|
|
4
|
+
import { type ParsedEvent, parseWebhook } from "./events.ts";
|
|
5
5
|
import { persistEvents } from "./intake.ts";
|
|
6
|
+
import type { LineScreen } from "./screen.ts";
|
|
6
7
|
import type { TokenSource } from "./token.ts";
|
|
7
8
|
|
|
8
9
|
/** The most a webhook request body may hold: LINE sends far less, and anything near this is not LINE. */
|
|
@@ -83,6 +84,8 @@ export interface WebhookOptions {
|
|
|
83
84
|
stored: () => void;
|
|
84
85
|
/** Counts what the webhook answers; the plugin reads it for `LINE.health()`. */
|
|
85
86
|
stats: WebhookStats;
|
|
87
|
+
/** The screen attached at the moment of the request, if any; asked about each event before it is stored. */
|
|
88
|
+
screen?: () => LineScreen | undefined;
|
|
86
89
|
maxBytes?: number;
|
|
87
90
|
}
|
|
88
91
|
|
|
@@ -140,12 +143,48 @@ function claimedDestination(body: Uint8Array): string | undefined {
|
|
|
140
143
|
}
|
|
141
144
|
}
|
|
142
145
|
|
|
146
|
+
function deepFreeze<T>(value: T): T {
|
|
147
|
+
if (typeof value === "object" && value !== null) {
|
|
148
|
+
for (const inner of Object.values(value)) deepFreeze(inner);
|
|
149
|
+
Object.freeze(value);
|
|
150
|
+
}
|
|
151
|
+
return value;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const frozenCopy = (event: ParsedEvent): ParsedEvent =>
|
|
155
|
+
deepFreeze(structuredClone(event));
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The events the screen keeps, asked about each in order. Rejects when the screen throws or answers
|
|
159
|
+
* anything but "keep" or "consume", so no event of the request is stored on a half-made decision.
|
|
160
|
+
*/
|
|
161
|
+
async function screened(
|
|
162
|
+
screen: LineScreen,
|
|
163
|
+
events: readonly ParsedEvent[],
|
|
164
|
+
): Promise<ParsedEvent[]> {
|
|
165
|
+
const kept: ParsedEvent[] = [];
|
|
166
|
+
for (const event of events) {
|
|
167
|
+
// A frozen copy: whatever the screen does to it, the event that is stored is LINE's.
|
|
168
|
+
const verdict: unknown = await screen(frozenCopy(event));
|
|
169
|
+
if (verdict === "keep") kept.push(event);
|
|
170
|
+
else if (verdict !== "consume")
|
|
171
|
+
throw new LineError(
|
|
172
|
+
"screen_verdict_invalid",
|
|
173
|
+
'line: a screen answers "keep" or "consume"',
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
return kept;
|
|
177
|
+
}
|
|
178
|
+
|
|
143
179
|
/**
|
|
144
180
|
* LINE's webhook: the raw body is verified against `x-line-signature` before anything is parsed,
|
|
145
181
|
* every event is stored in one transaction, and only then does it answer `200`. A database that
|
|
146
182
|
* is down answers `500`, which makes LINE's redelivery (if the channel has it on) resend.
|
|
147
|
-
* An empty `events` array (LINE's Verify button) is a `200` that stores nothing.
|
|
148
|
-
*
|
|
183
|
+
* An empty `events` array (LINE's Verify button) is a `200` that stores nothing. When a screen is
|
|
184
|
+
* attached it is asked about each event, once the signature and destination have passed, and a
|
|
185
|
+
* consumed event is never stored; a request whose events are all consumed is a `200` like Verify,
|
|
186
|
+
* and a screen that fails is a `500` that stores nothing. Each answer adds to `options.stats`,
|
|
187
|
+
* which keeps counts and times only.
|
|
149
188
|
*/
|
|
150
189
|
export function webhookHandler(
|
|
151
190
|
options: WebhookOptions,
|
|
@@ -184,16 +223,26 @@ export function webhookHandler(
|
|
|
184
223
|
options.logger.warn("a LINE webhook was refused: it is for another bot");
|
|
185
224
|
return reply(403, "Forbidden");
|
|
186
225
|
}
|
|
187
|
-
|
|
226
|
+
let events = parsed.events;
|
|
227
|
+
const screen = options.screen?.();
|
|
228
|
+
if (screen && events.length > 0) {
|
|
229
|
+
try {
|
|
230
|
+
events = await screened(screen, events);
|
|
231
|
+
} catch (error) {
|
|
232
|
+
// The code only: the message could quote the event that was screened.
|
|
233
|
+
options.logger.error(
|
|
234
|
+
{ code: errorCode(error) },
|
|
235
|
+
"a LINE event could not be screened; the request was refused",
|
|
236
|
+
);
|
|
237
|
+
return reply(500, "Internal Server Error");
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (events.length === 0) {
|
|
188
241
|
stats.accept(options.now());
|
|
189
242
|
return reply(200, "OK");
|
|
190
243
|
}
|
|
191
244
|
try {
|
|
192
|
-
const result = await persistEvents(
|
|
193
|
-
options.sql,
|
|
194
|
-
parsed.events,
|
|
195
|
-
options.now(),
|
|
196
|
-
);
|
|
245
|
+
const result = await persistEvents(options.sql, events, options.now());
|
|
197
246
|
options.logger.info(result, "LINE events stored");
|
|
198
247
|
if (result.queued > 0) options.stored();
|
|
199
248
|
} catch (error) {
|