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/CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
5
|
+
|
|
6
|
+
## [Unreleased]
|
|
7
|
+
|
|
8
|
+
## [0.9.13] - 2026-10-13
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- First slice of the package: `line(options)`, the `LINE` service, a signed webhook that stores every event before it answers, an allowlist of chats, and a durable inbox worker. Stateless channel access tokens (`client_credentials` with the channel id and secret) are issued on demand, kept in memory and never logged. The inbox claims with `FOR UPDATE SKIP LOCKED` per chat, recovers expired leases, retries with backoff and sets an event aside as dead after 12 attempts. Delivery is at least once: a claim is renewed while its handler runs and fences every write that ends it, edits and unsends of one message run strictly in order and wait for an original that is backing off, and nothing of a blocked chat is handed out again. A handler is bounded by `maxHandleMs` (10 minutes: its signal is aborted and the event fails with `handler_timeout`), the worker refills free slots on a kick, a poll or a finished handler while others are still running, and what a chat the bot left still has waiting is dropped with its payload, apart from `unsend` events, which are still delivered so a message the customer took back is taken down. The processor's signal is also aborted when the worker stops or loses the event's lease, the processor is looked up after the claim, and `maxHandleMs`, `leaseMs` and `pollMs` above 2147483647 ms are refused.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 wayne930242
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,146 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-roundtable-line
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A LINE Messaging API transport for [pi-roundtable][roundtable].
|
|
4
|
+
It receives the events of a LINE Official Account's group chats on the host's HTTP listener, stores each one before it answers, and hands the stored events of the chats you allow to one consumer, in order and at least once.
|
|
5
|
+
Source lives in [`packages/line`][source] in the pi-roundtable repository and releases in lockstep with the core.
|
|
6
|
+
|
|
7
|
+
[roundtable]: https://www.npmjs.com/package/pi-roundtable
|
|
8
|
+
[source]: https://github.com/wayne930242/pi-roundtable/tree/master/packages/line
|
|
9
|
+
|
|
10
|
+
This first slice covers intake: the credentials and token source, the webhook, the allowlist of chats, and the inbox worker.
|
|
11
|
+
Mirroring messages to a consumer, sending, quota and the join notice follow in later releases of this package.
|
|
12
|
+
|
|
13
|
+
## Requirements
|
|
14
|
+
|
|
15
|
+
- Bun 1.3 or later, and pi-roundtable `>=0.9.0 <0.10.0` as a peer dependency.
|
|
16
|
+
- PostgreSQL, which the host already needs.
|
|
17
|
+
- An HTTP listener on the host that LINE can reach over HTTPS (the webhook URL is set in the channel's settings).
|
|
18
|
+
- A Messaging API channel: its channel id and channel secret.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
bun add pi-roundtable-line
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import type { RoundtableConfig } from "pi-roundtable";
|
|
28
|
+
import { line } from "pi-roundtable-line";
|
|
29
|
+
|
|
30
|
+
export default {
|
|
31
|
+
// ...
|
|
32
|
+
http: { port: 3000, hostname: "127.0.0.1" },
|
|
33
|
+
plugins: [
|
|
34
|
+
line({
|
|
35
|
+
credentials: {
|
|
36
|
+
channelId: process.env.LINE_CHANNEL_ID ?? "",
|
|
37
|
+
channelSecret: process.env.LINE_CHANNEL_SECRET ?? "",
|
|
38
|
+
},
|
|
39
|
+
}),
|
|
40
|
+
],
|
|
41
|
+
} satisfies RoundtableConfig;
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Point the channel's webhook URL at `https://<your host>/line/webhook` and turn the webhook on.
|
|
45
|
+
The route attaches to the `public` listener; set `listener` for another, and `webhookPath` to move it.
|
|
46
|
+
|
|
47
|
+
## Credentials
|
|
48
|
+
|
|
49
|
+
The channel id and secret are all a stateless setup needs.
|
|
50
|
+
The package asks LINE for a channel access token (`POST /oauth2/v3/token`, `grant_type=client_credentials`), keeps it in memory until a minute before it lapses (about 15 minutes), asks again on demand, and asks again once when LINE answers a call with `401`.
|
|
51
|
+
Concurrent callers share one request.
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
credentials: { channelId, channelSecret, token: "stateless" } // the default
|
|
55
|
+
credentials: { channelId, channelSecret, token: { static: longLivedToken } } // a token a console issued
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
A static token cannot be replaced, so a `401` on it is final.
|
|
59
|
+
The channel secret also verifies the webhook signature.
|
|
60
|
+
|
|
61
|
+
Neither the secret nor a token is written to disk, logged, or put in an error message, and neither shows in `JSON.stringify` or `console.log` of the objects that hold them.
|
|
62
|
+
Errors carry a short code (`token_refused`, `api_unreachable`, `http_429`, ...) and LINE's request id, never a response body.
|
|
63
|
+
|
|
64
|
+
## The webhook
|
|
65
|
+
|
|
66
|
+
`POST /line/webhook` answers:
|
|
67
|
+
|
|
68
|
+
| Status | When |
|
|
69
|
+
|---|---|
|
|
70
|
+
| `200` | the events were stored (or there were none: LINE's Verify button) |
|
|
71
|
+
| `400` | the body is signed but is not a LINE events object |
|
|
72
|
+
| `401` | `x-line-signature` is missing or is not the HMAC-SHA256 of the raw body under the channel secret |
|
|
73
|
+
| `403` | the body's `destination` is not this bot (checked once the bot's user id is known) |
|
|
74
|
+
| `405` | not a `POST` |
|
|
75
|
+
| `413` | the body is over 1 MiB |
|
|
76
|
+
| `500` | the database could not store the events |
|
|
77
|
+
|
|
78
|
+
The signature is checked on the raw bytes, in constant time, before the body is parsed.
|
|
79
|
+
All events of a request are stored in one transaction, and `200` follows the commit.
|
|
80
|
+
An event with a `webhookEventId` already stored is not stored again, so LINE's redelivery (a channel setting of its own) is safe.
|
|
81
|
+
|
|
82
|
+
The bot's user id comes from `GET /v2/bot/info` when the host starts.
|
|
83
|
+
If LINE cannot be reached then, the plugin logs a warning with a code and keeps trying (at most every 30 seconds, when a webhook arrives); until it knows the id a webhook is vouched for by its signature alone.
|
|
84
|
+
|
|
85
|
+
## Chats and the allowlist
|
|
86
|
+
|
|
87
|
+
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.
|
|
88
|
+
Nothing a member wrote is stored, and the event's payload is dropped.
|
|
89
|
+
A chat is a key `line:group:<id>`, `line:room:<id>` or `line:user:<id>`.
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
import { LINE } from "pi-roundtable-line";
|
|
93
|
+
|
|
94
|
+
const { sources } = context.services.get(LINE);
|
|
95
|
+
await sources.pending(); // chats waiting for a decision
|
|
96
|
+
await sources.allow("line:group:C...");
|
|
97
|
+
await sources.block("line:group:C...");
|
|
98
|
+
await sources.summary("line:group:C..."); // adds the group's name, asked of LINE
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
| Status | Meaning |
|
|
102
|
+
|---|---|
|
|
103
|
+
| `pending` | seen, not decided; events are dropped unread |
|
|
104
|
+
| `allowed` | events are stored and handed to the consumer |
|
|
105
|
+
| `blocked` | events are dropped; `block` also drops what was waiting |
|
|
106
|
+
| `left` | the bot was removed; a `join` makes it `pending` again, because a new group needs a new decision |
|
|
107
|
+
|
|
108
|
+
The `join` and `leave` events carry no customer text and are kept for every chat that is not blocked.
|
|
109
|
+
Allowing a chat does not recover earlier events: they were dropped unread.
|
|
110
|
+
This package never creates anything for a chat; what a chat becomes is the consumer's business.
|
|
111
|
+
|
|
112
|
+
## The inbox worker
|
|
113
|
+
|
|
114
|
+
Stored events wait in `line_inbox`.
|
|
115
|
+
The worker runs inside the host as the `line-inbox` service and hands events to the processor attached through the service:
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
const detach = context.services.get(LINE).attachProcessor(async (event) => {
|
|
119
|
+
// event.type, event.sourceKey, event.messageId, event.timestamp, event.payload
|
|
120
|
+
// return nothing when done, or { retryInMs } to look at it again later
|
|
121
|
+
});
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Until a processor is attached, events stay in the inbox with their payload.
|
|
125
|
+
|
|
126
|
+
- Delivery is at least once. A handler that outlives its lease (its process died, or it could not renew) may run again for the same event, so a processor must be idempotent.
|
|
127
|
+
- A claim is `FOR UPDATE SKIP LOCKED` under a per-chat lock: while leases are kept no chat has two events in hand at once, across workers and processes, and events of a chat go out in order of LINE's event time among those that are due. An event that is backing off does not hold the ones behind it.
|
|
128
|
+
- A claimed event has a 2-minute lease that the worker renews every 40 seconds while the handler runs, so a slow handler is not taken for a crashed one. Every write that ends a handling is fenced by its claim: a handler that lost the event cannot change what the next claim wrote. The worker puts back events whose lease ran out when it starts and every minute after, other than ones it is handling itself, and that counts as a handing out, so an event that keeps crashing its worker ends up dead.
|
|
129
|
+
- A handler runs for at most `maxHandleMs` (10 minutes). The processor receives an `AbortSignal` as its second argument that is aborted then; the lease is no longer renewed and the event fails with `handler_timeout` like any thrown error, so a handler that never settles cannot hold its chat or the shutdown. What it settles with later is ignored. The signal is also aborted when the worker stops (`signal.reason` has the code `worker_stopped`; a handler that then throws the reason or an abort error puts the event back without counting as a failure, while any other error it throws counts) and when the worker finds the event is no longer its own (`lease_lost`). Only a handler that honours the signal gives up: one that ignores it may still be running while the same chat's next event, or the event's own retry, is handed out, so a processor must be safe against its own overlap. To give up, throw (for example `throw signal.reason`) or return `{ retryInMs }`: a handler that returns `undefined` has handled the event, and it is marked done. The processor is looked up again just before it is called, so one detached or replaced while the event waited gets nothing. `maxHandleMs`, `leaseMs` and `pollMs` are at most 2147483647 (about 24 days), the longest delay a timer holds.
|
|
130
|
+
- The worker looks at the inbox on a kick, on every poll and whenever a handler ends, also while handlers are running, so a slow handler holds one of the `concurrency` slots and nothing else; expired leases are put back on the same schedule.
|
|
131
|
+
- A thrown error retries with exponential backoff and jitter (5 seconds doubling to 1 hour); after 12 handings out the event is `dead`. Only the error's lower-case `code` word is stored, never its message, because messages may quote what a customer wrote.
|
|
132
|
+
- An `unsend` or `messageEdited` event runs strictly after the message it acts on and after the earlier events on that message: it waits, however long, while any of them is still pending, being handled or backing off, and goes to the processor once they are done or dead. The wait does not count against `maxRequeues`. It waits until the soonest of them is due again, and at least `dependencyRetryMs` (5 seconds). A message that has not reached the inbox at all is waited for up to 10 minutes.
|
|
133
|
+
- Only events of `allowed` chats, and the `join`, `leave` and `unsend` of a chat that is not blocked, are handed out. When a chat is blocked, what it still has waiting is dropped with its payload, and an event that was in hand at that moment is dropped instead of retried. The same goes for a chat the bot left: when its `leave` is applied, what else it has waiting is dropped with its payload, and an event of a `left` or undecided chat is dropped at the claim, so nothing surfaces after a rejoin. An `unsend` is the exception, because it carries no content and only takes a mirrored message down: it is kept and delivered even after the bot left, while `block` drops it like anything else.
|
|
134
|
+
- A finished event keeps its row and loses its payload. U+0000 and lone surrogates in an event are replaced with U+FFFD when it is stored, so one odd event never costs the others of its request.
|
|
135
|
+
|
|
136
|
+
Options tune the worker: `inbox: { leaseMs, maxHandleMs, maxAttempts, backoffBaseMs, backoffCapMs, concurrency, pollMs, ... }`.
|
|
137
|
+
|
|
138
|
+
## Safety
|
|
139
|
+
|
|
140
|
+
Do not admit a `line` surface through the core's `everyone` access: anyone who can add the bot to a group would reach the assistant.
|
|
141
|
+
This slice contributes no surface and starts no turn.
|
|
142
|
+
|
|
143
|
+
## Tests
|
|
144
|
+
|
|
145
|
+
PostgreSQL-backed tests run when `ROUNDTABLE_TEST_DATABASE_URL` is set and are skipped otherwise.
|
|
146
|
+
No test reaches LINE: the plugin takes a `fetch` option, and the tests inject a fake.
|
package/package.json
CHANGED
|
@@ -1,6 +1,47 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "pi-roundtable-line",
|
|
3
|
+
"version": "0.9.13",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"engines": {
|
|
8
|
+
"bun": ">=1.3.0"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/index.ts"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"src",
|
|
15
|
+
"!src/**/*.test.ts",
|
|
16
|
+
"!src/testing",
|
|
17
|
+
"LICENSE",
|
|
18
|
+
"README.md",
|
|
19
|
+
"CHANGELOG.md"
|
|
20
|
+
],
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/wayne930242/pi-roundtable.git",
|
|
24
|
+
"directory": "packages/line"
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public",
|
|
28
|
+
"provenance": true
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"typecheck": "tsc --noEmit",
|
|
32
|
+
"lint": "biome check .",
|
|
33
|
+
"test": "bun test"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"pi-roundtable": ">=0.9.0 <0.10.0"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@biomejs/biome": "2.5.15",
|
|
40
|
+
"@earendil-works/pi-ai": ">=1.0.0 <2",
|
|
41
|
+
"@earendil-works/pi-coding-agent": ">=1.0.0 <2",
|
|
42
|
+
"@types/bun": "1.4.2",
|
|
43
|
+
"pi-roundtable": "0.9.13",
|
|
44
|
+
"typebox": "1.3.34",
|
|
45
|
+
"typescript": "7.0.2"
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/api.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { LineApiError, LineError } from "./errors.ts";
|
|
2
|
+
import { LINE_API, type TokenSource } from "./token.ts";
|
|
3
|
+
|
|
4
|
+
export interface LineApiOptions {
|
|
5
|
+
tokens: TokenSource;
|
|
6
|
+
fetch?: typeof fetch;
|
|
7
|
+
baseUrl?: string;
|
|
8
|
+
/** Per-request timeout in milliseconds; default 15 seconds. */
|
|
9
|
+
timeoutMs?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface RequestOptions {
|
|
13
|
+
method?: string;
|
|
14
|
+
headers?: Record<string, string>;
|
|
15
|
+
body?: string | Uint8Array;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* LINE's API with the channel access token on every call. A `401` makes the token source forget
|
|
20
|
+
* the token and the call is repeated once with a new one; a second `401` is an error. The error
|
|
21
|
+
* says the status and LINE's request id, never the response body.
|
|
22
|
+
*/
|
|
23
|
+
export class LineApi {
|
|
24
|
+
readonly #tokens: TokenSource;
|
|
25
|
+
readonly #fetch: typeof fetch;
|
|
26
|
+
readonly #base: string;
|
|
27
|
+
readonly #timeoutMs: number;
|
|
28
|
+
|
|
29
|
+
constructor(options: LineApiOptions) {
|
|
30
|
+
this.#tokens = options.tokens;
|
|
31
|
+
this.#fetch = options.fetch ?? fetch;
|
|
32
|
+
this.#base = options.baseUrl ?? LINE_API;
|
|
33
|
+
this.#timeoutMs = options.timeoutMs ?? 15_000;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The response of a successful call; its body is the caller's to read. */
|
|
37
|
+
async request(path: string, options: RequestOptions = {}): Promise<Response> {
|
|
38
|
+
for (let attempt = 0; ; attempt++) {
|
|
39
|
+
const token = await this.#tokens.token();
|
|
40
|
+
let response: Response;
|
|
41
|
+
try {
|
|
42
|
+
response = await this.#fetch(`${this.#base}${path}`, {
|
|
43
|
+
method: options.method ?? "GET",
|
|
44
|
+
headers: { ...options.headers, authorization: `Bearer ${token}` },
|
|
45
|
+
...(options.body === undefined ? {} : { body: options.body }),
|
|
46
|
+
signal: AbortSignal.timeout(this.#timeoutMs),
|
|
47
|
+
});
|
|
48
|
+
} catch (error) {
|
|
49
|
+
throw new LineError(
|
|
50
|
+
"api_unreachable",
|
|
51
|
+
`line: ${path} did not answer (${this.#tokens.redact(
|
|
52
|
+
error instanceof Error ? error.message : String(error),
|
|
53
|
+
)})`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
if (
|
|
57
|
+
response.status === 401 &&
|
|
58
|
+
attempt === 0 &&
|
|
59
|
+
this.#tokens.reject(token)
|
|
60
|
+
) {
|
|
61
|
+
await response.body?.cancel().catch(() => undefined);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (!response.ok) {
|
|
65
|
+
await response.body?.cancel().catch(() => undefined);
|
|
66
|
+
throw new LineApiError(
|
|
67
|
+
response.status,
|
|
68
|
+
`http_${response.status}`,
|
|
69
|
+
`line: ${options.method ?? "GET"} ${path} answered ${response.status}`,
|
|
70
|
+
response.headers.get("x-line-request-id") ?? undefined,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
return response;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async json(path: string, options?: RequestOptions): Promise<unknown> {
|
|
78
|
+
return (await this.request(path, options)).json();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The bot's user id, which a webhook's `destination` must equal. */
|
|
82
|
+
async botUserId(): Promise<string> {
|
|
83
|
+
const info = (await this.json("/v2/bot/info")) as { userId?: unknown };
|
|
84
|
+
if (typeof info?.userId !== "string" || info.userId === "")
|
|
85
|
+
throw new LineError("bot_info_malformed", "line: bot info has no userId");
|
|
86
|
+
return info.userId;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The name LINE shows for a group. */
|
|
90
|
+
async groupName(groupId: string): Promise<string> {
|
|
91
|
+
const summary = (await this.json(
|
|
92
|
+
`/v2/bot/group/${encodeURIComponent(groupId)}/summary`,
|
|
93
|
+
)) as { groupName?: unknown };
|
|
94
|
+
if (typeof summary?.groupName !== "string")
|
|
95
|
+
throw new LineError(
|
|
96
|
+
"group_summary_malformed",
|
|
97
|
+
"line: group summary has no name",
|
|
98
|
+
);
|
|
99
|
+
return summary.groupName;
|
|
100
|
+
}
|
|
101
|
+
}
|
package/src/bot.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { Logger } from "pi-roundtable";
|
|
2
|
+
import type { LineApi } from "./api.ts";
|
|
3
|
+
import { errorCode } from "./errors.ts";
|
|
4
|
+
|
|
5
|
+
/** A failed lookup is not repeated sooner than this, however many webhooks ask. */
|
|
6
|
+
const RETRY_MS = 30_000;
|
|
7
|
+
|
|
8
|
+
export interface BotIdentityOptions {
|
|
9
|
+
api: LineApi;
|
|
10
|
+
logger: Logger;
|
|
11
|
+
now: () => Date;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The bot's user id, which every webhook's `destination` must equal. It is read from LINE when the
|
|
16
|
+
* host starts and again, at most every 30 seconds, while it is still unknown and a webhook asks.
|
|
17
|
+
* Until it is known a webhook is vouched for by its signature alone: the channel secret is the
|
|
18
|
+
* only thing that can produce one.
|
|
19
|
+
*/
|
|
20
|
+
export class BotIdentity {
|
|
21
|
+
readonly #api: LineApi;
|
|
22
|
+
readonly #logger: Logger;
|
|
23
|
+
readonly #now: () => Date;
|
|
24
|
+
#id: string | undefined;
|
|
25
|
+
#inflight: Promise<void> | undefined;
|
|
26
|
+
#lastTry = Number.NEGATIVE_INFINITY;
|
|
27
|
+
|
|
28
|
+
constructor(options: BotIdentityOptions) {
|
|
29
|
+
this.#api = options.api;
|
|
30
|
+
this.#logger = options.logger;
|
|
31
|
+
this.#now = options.now;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The id, or undefined while it is unknown; asking also starts a lookup when one is due. */
|
|
35
|
+
current(): string | undefined {
|
|
36
|
+
if (
|
|
37
|
+
this.#id === undefined &&
|
|
38
|
+
this.#now().getTime() - this.#lastTry >= RETRY_MS
|
|
39
|
+
)
|
|
40
|
+
void this.refresh();
|
|
41
|
+
return this.#id;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Asks LINE; a failure is logged by its code and leaves the id unknown. Never rejects. */
|
|
45
|
+
refresh(): Promise<void> {
|
|
46
|
+
this.#inflight ??= this.#ask().finally(() => {
|
|
47
|
+
this.#inflight = undefined;
|
|
48
|
+
});
|
|
49
|
+
return this.#inflight;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async #ask(): Promise<void> {
|
|
53
|
+
this.#lastTry = this.#now().getTime();
|
|
54
|
+
try {
|
|
55
|
+
this.#id = await this.#api.botUserId();
|
|
56
|
+
this.#logger.info("the LINE bot identity is known");
|
|
57
|
+
} catch (error) {
|
|
58
|
+
this.#logger.warn(
|
|
59
|
+
{ code: errorCode(error) },
|
|
60
|
+
"the LINE bot identity could not be read; webhooks are checked by signature alone until it can",
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** A failure of this package, named by a short code that is safe to store and log. */
|
|
2
|
+
export class LineError extends Error {
|
|
3
|
+
override name = "LineError";
|
|
4
|
+
readonly code: string;
|
|
5
|
+
|
|
6
|
+
constructor(code: string, message: string) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.code = code;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** LINE answered a request with a status that is not a success. The message never holds a response body. */
|
|
13
|
+
export class LineApiError extends LineError {
|
|
14
|
+
override name = "LineApiError";
|
|
15
|
+
readonly status: number;
|
|
16
|
+
/** `x-line-request-id` of the failed call, for LINE's support. */
|
|
17
|
+
readonly requestId: string | undefined;
|
|
18
|
+
|
|
19
|
+
constructor(
|
|
20
|
+
status: number,
|
|
21
|
+
code: string,
|
|
22
|
+
message: string,
|
|
23
|
+
requestId?: string,
|
|
24
|
+
) {
|
|
25
|
+
super(code, message);
|
|
26
|
+
this.status = status;
|
|
27
|
+
this.requestId = requestId;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const CODE = /^[a-z][a-z0-9_]{0,63}$/;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The code to store for a failure: its own when it is a `LineError` or carries a lower-case `code`
|
|
35
|
+
* word, else `handler_error`. The message is never stored, because it may quote what a customer wrote.
|
|
36
|
+
*/
|
|
37
|
+
export function errorCode(error: unknown): string {
|
|
38
|
+
if (typeof error === "object" && error !== null && "code" in error) {
|
|
39
|
+
const code = (error as { code: unknown }).code;
|
|
40
|
+
if (typeof code === "string" && CODE.test(code)) return code;
|
|
41
|
+
}
|
|
42
|
+
return "handler_error";
|
|
43
|
+
}
|
package/src/events.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { LineError } from "./errors.ts";
|
|
2
|
+
|
|
3
|
+
export type SourceKind = "group" | "room" | "user";
|
|
4
|
+
|
|
5
|
+
const ID = /^[A-Za-z0-9_-]{1,64}$/;
|
|
6
|
+
|
|
7
|
+
/** `line:<group|room|user>:<id>`, the channel key's tail every other part of the package names a chat by. */
|
|
8
|
+
export function sourceKeyOf(kind: SourceKind, id: string): string {
|
|
9
|
+
return `line:${kind}:${id}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The kind and id inside a source key, or undefined when the text is not one. */
|
|
13
|
+
export function parseSourceKey(
|
|
14
|
+
key: string,
|
|
15
|
+
): { kind: SourceKind; id: string } | undefined {
|
|
16
|
+
const match = /^line:(group|room|user):([A-Za-z0-9_-]{1,64})$/.exec(key);
|
|
17
|
+
return match
|
|
18
|
+
? { kind: match[1] as SourceKind, id: match[2] as string }
|
|
19
|
+
: undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** One webhook event, reduced to what the inbox keeps in columns; the event itself is `raw`. */
|
|
23
|
+
export interface ParsedEvent {
|
|
24
|
+
/** LINE's `webhookEventId`, or a digest of the event when it has none. */
|
|
25
|
+
id: string;
|
|
26
|
+
type: string;
|
|
27
|
+
timestamp: number;
|
|
28
|
+
/** Null when the event names no chat the package knows. */
|
|
29
|
+
sourceKey: string | null;
|
|
30
|
+
messageId: string | null;
|
|
31
|
+
/** False for events of a bot in standby mode, which are never mirrored. */
|
|
32
|
+
active: boolean;
|
|
33
|
+
raw: Record<string, unknown>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ParsedWebhook {
|
|
37
|
+
destination: string | null;
|
|
38
|
+
events: ParsedEvent[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
42
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
43
|
+
|
|
44
|
+
const text = (value: unknown): string | null =>
|
|
45
|
+
typeof value === "string" && value !== "" ? value : null;
|
|
46
|
+
|
|
47
|
+
/** The key of the chat an event's `source` names. */
|
|
48
|
+
function sourceOf(source: unknown): string | null {
|
|
49
|
+
if (!isRecord(source)) return null;
|
|
50
|
+
for (const [kind, field] of [
|
|
51
|
+
["group", "groupId"],
|
|
52
|
+
["room", "roomId"],
|
|
53
|
+
["user", "userId"],
|
|
54
|
+
] as const) {
|
|
55
|
+
if (source.type !== kind) continue;
|
|
56
|
+
const id = text(source[field]);
|
|
57
|
+
return id && ID.test(id) ? sourceKeyOf(kind, id) : null;
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function digest(value: string): Promise<string> {
|
|
63
|
+
const bytes = new TextEncoder().encode(value);
|
|
64
|
+
const hash = await crypto.subtle.digest("SHA-256", bytes);
|
|
65
|
+
return Buffer.from(hash).toString("hex");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Reads a webhook body. Throws `LineError("bad_webhook")` when it is not JSON or has no `events`
|
|
70
|
+
* array; an event of an unexpected shape is kept (as an event with no chat) rather than refused,
|
|
71
|
+
* so one odd event never costs the others of its request.
|
|
72
|
+
*/
|
|
73
|
+
export async function parseWebhook(body: Uint8Array): Promise<ParsedWebhook> {
|
|
74
|
+
let value: unknown;
|
|
75
|
+
try {
|
|
76
|
+
value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(body));
|
|
77
|
+
} catch {
|
|
78
|
+
throw new LineError("bad_webhook", "line: the webhook body is not JSON");
|
|
79
|
+
}
|
|
80
|
+
if (!isRecord(value) || !Array.isArray(value.events))
|
|
81
|
+
throw new LineError(
|
|
82
|
+
"bad_webhook",
|
|
83
|
+
"line: the webhook body has no events array",
|
|
84
|
+
);
|
|
85
|
+
const events: ParsedEvent[] = [];
|
|
86
|
+
for (const raw of value.events) {
|
|
87
|
+
if (!isRecord(raw)) continue;
|
|
88
|
+
const message = isRecord(raw.message) ? raw.message : undefined;
|
|
89
|
+
const unsend = isRecord(raw.unsend) ? raw.unsend : undefined;
|
|
90
|
+
const timestamp = Number(raw.timestamp);
|
|
91
|
+
events.push({
|
|
92
|
+
id:
|
|
93
|
+
text(raw.webhookEventId) ??
|
|
94
|
+
`derived:${await digest(JSON.stringify(raw))}`,
|
|
95
|
+
type: text(raw.type) ?? "unknown",
|
|
96
|
+
timestamp: Number.isSafeInteger(timestamp) ? timestamp : 0,
|
|
97
|
+
sourceKey: sourceOf(raw.source),
|
|
98
|
+
messageId: text(message?.id) ?? text(unsend?.messageId),
|
|
99
|
+
active: raw.mode === undefined || raw.mode === "active",
|
|
100
|
+
raw,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return { destination: text(value.destination), events };
|
|
104
|
+
}
|