pi-roundtable-line 0.12.1 → 0.13.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 +11 -0
- package/README.md +22 -1
- package/package.json +3 -3
- package/src/index.ts +1 -0
- package/src/plugin.ts +4 -1
- package/src/port.ts +11 -2
- package/src/webhook.ts +59 -3
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,17 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.13.0] - 2026-10-13
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `LINE.health()` gains `webhooks: WebhookHealth` (new type, exported): `{ accepted, rejected: { signature, destination, malformed }, lastAcceptedAt?, lastRejectedAt? }`, the counts and times of what the webhook has answered since the plugin was set up. `signature` is a missing or wrong signature (401), `destination` a signed webhook for another bot (403), `malformed` a body over the cap or not an events object (413, 400). Nothing of a request but its count is kept.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- `LineBridgePort.health()` returns the new `webhooks` field, so a caller that built a `LineBridgePort` of its own, to test a plugin that reads `LINE`, must add it.
|
|
17
|
+
- Release in lockstep with pi-roundtable 0.13.0. The core peer range moves to `>=0.13.0 <0.14.0`.
|
|
18
|
+
|
|
8
19
|
## [0.12.1] - 2026-10-13
|
|
9
20
|
|
|
10
21
|
- Release in lockstep with pi-roundtable 0.12.1; no package-specific behavior changes. The core peer range stays `>=0.12.0 <0.13.0`.
|
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ It covers intake (the credentials and token source, the webhook, the allowlist o
|
|
|
11
11
|
|
|
12
12
|
## Requirements
|
|
13
13
|
|
|
14
|
-
- Bun 1.3 or later, and pi-roundtable `>=0.
|
|
14
|
+
- Bun 1.3 or later, and pi-roundtable `>=0.13.0 <0.14.0` as a peer dependency.
|
|
15
15
|
- PostgreSQL, which the host already needs.
|
|
16
16
|
- An HTTP listener on the host that LINE can reach over HTTPS (the webhook URL is set in the channel's settings).
|
|
17
17
|
- A Messaging API channel: its channel id and channel secret.
|
|
@@ -81,6 +81,27 @@ An event with a `webhookEventId` already stored is not stored again, so LINE's r
|
|
|
81
81
|
The bot's user id comes from `GET /v2/bot/info` when the host starts.
|
|
82
82
|
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.
|
|
83
83
|
|
|
84
|
+
### Webhook counters
|
|
85
|
+
|
|
86
|
+
`LINE.health()` returns `{ tokensIssued, tokenFailure, webhooks }`, where `webhooks` is what the webhook has answered since the plugin was set up (it starts at zero with each process):
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
webhooks: {
|
|
90
|
+
accepted: number; // answered 200, a Verify included
|
|
91
|
+
rejected: {
|
|
92
|
+
signature: number; // 401: the signature is missing or wrong
|
|
93
|
+
destination: number; // 403: signed, but for another bot
|
|
94
|
+
malformed: number; // 400 and 413: not a LINE events object, or over 1 MiB
|
|
95
|
+
};
|
|
96
|
+
lastAcceptedAt?: string; // ISO time; left out until there was one
|
|
97
|
+
lastRejectedAt?: string;
|
|
98
|
+
};
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
They are counts and times only: no body, address or signature is kept, and a request adds one to a counter and nothing else.
|
|
102
|
+
A `405` and a `500` (the database could not store the events) are in no counter.
|
|
103
|
+
A host that watches for a channel whose webhook URL or secret has drifted can alarm on `rejected.signature` growing while `accepted` does not.
|
|
104
|
+
|
|
84
105
|
## Chats and the allowlist
|
|
85
106
|
|
|
86
107
|
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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-roundtable-line",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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.13.0 <0.14.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.13.0",
|
|
44
44
|
"typebox": "1.3.34",
|
|
45
45
|
"typescript": "7.0.2"
|
|
46
46
|
}
|
package/src/index.ts
CHANGED
package/src/plugin.ts
CHANGED
|
@@ -21,7 +21,7 @@ import { RetentionSweeper } from "./retention.ts";
|
|
|
21
21
|
import { PgSources } from "./sources.ts";
|
|
22
22
|
import { LineSurface } from "./surface.ts";
|
|
23
23
|
import { TokenSource } from "./token.ts";
|
|
24
|
-
import { webhookHandler } from "./webhook.ts";
|
|
24
|
+
import { WebhookStats, webhookHandler } from "./webhook.ts";
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
27
|
* The LINE transport: a webhook on the host's HTTP listener that stores every signed event before
|
|
@@ -44,6 +44,7 @@ export function line(options: LineOptions): RoundtablePlugin {
|
|
|
44
44
|
setup: (context: PluginContext) => {
|
|
45
45
|
const sql = context.database();
|
|
46
46
|
const now = () => context.env.now();
|
|
47
|
+
const webhooks = new WebhookStats();
|
|
47
48
|
const api = new LineApi({
|
|
48
49
|
tokens,
|
|
49
50
|
...(options.fetch ? { fetch: options.fetch } : {}),
|
|
@@ -143,6 +144,7 @@ export function line(options: LineOptions): RoundtablePlugin {
|
|
|
143
144
|
return {
|
|
144
145
|
tokensIssued: health.issued,
|
|
145
146
|
tokenFailure: health.lastFailure,
|
|
147
|
+
webhooks: webhooks.health(),
|
|
146
148
|
};
|
|
147
149
|
},
|
|
148
150
|
subscribe: (handler) => {
|
|
@@ -212,6 +214,7 @@ export function line(options: LineOptions): RoundtablePlugin {
|
|
|
212
214
|
now,
|
|
213
215
|
botUserId: () => bot.current(),
|
|
214
216
|
stored: () => worker.kick(),
|
|
217
|
+
stats: webhooks,
|
|
215
218
|
}),
|
|
216
219
|
},
|
|
217
220
|
],
|
package/src/port.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { LineContentRequest, LineContentResult } from "./content.ts";
|
|
|
3
3
|
import type { LineSubscriber } from "./messages.ts";
|
|
4
4
|
import type { LineOutbox } from "./outbox.ts";
|
|
5
5
|
import type { LineSources } from "./sources.ts";
|
|
6
|
+
import type { WebhookHealth } from "./webhook.ts";
|
|
6
7
|
|
|
7
8
|
/** What other plugins read from this one, through the `LINE` service key. */
|
|
8
9
|
export interface LineBridgePort {
|
|
@@ -12,8 +13,16 @@ export interface LineBridgePort {
|
|
|
12
13
|
readonly outbox: LineOutbox;
|
|
13
14
|
/** The bot's user id once LINE has told it, else undefined. */
|
|
14
15
|
botUserId(): string | undefined;
|
|
15
|
-
/**
|
|
16
|
-
|
|
16
|
+
/**
|
|
17
|
+
* The plugin's health: how many tokens were issued and why the last attempt failed, if it did, and
|
|
18
|
+
* what the webhook has answered since the plugin was set up (`webhooks`: counts and times only,
|
|
19
|
+
* never a body, an address or a signature).
|
|
20
|
+
*/
|
|
21
|
+
health(): {
|
|
22
|
+
tokensIssued: number;
|
|
23
|
+
tokenFailure: string | undefined;
|
|
24
|
+
webhooks: WebhookHealth;
|
|
25
|
+
};
|
|
17
26
|
/**
|
|
18
27
|
* Becomes the one consumer of the package: from now on each stored event of an allowed chat, the
|
|
19
28
|
* `join` and `leave` of any chat that is not blocked, and an `unsend` stored while its chat was
|
package/src/webhook.ts
CHANGED
|
@@ -8,6 +8,48 @@ import type { TokenSource } from "./token.ts";
|
|
|
8
8
|
/** The most a webhook request body may hold: LINE sends far less, and anything near this is not LINE. */
|
|
9
9
|
export const WEBHOOK_MAX_BYTES = 1024 * 1024;
|
|
10
10
|
|
|
11
|
+
/** What the webhook has answered since the plugin was set up: counts and times only, never a body, address or signature. */
|
|
12
|
+
export interface WebhookHealth {
|
|
13
|
+
/** Requests with a valid signature for this bot that were answered `200` (an empty Verify included). */
|
|
14
|
+
accepted: number;
|
|
15
|
+
/**
|
|
16
|
+
* Requests turned away: `signature` is a missing or wrong `x-line-signature`, `destination` is a
|
|
17
|
+
* signed webhook for another bot, `malformed` is a body that is too large or not a webhook.
|
|
18
|
+
*/
|
|
19
|
+
rejected: { signature: number; destination: number; malformed: number };
|
|
20
|
+
/** When the last request was accepted, as an ISO time; left out until one was. */
|
|
21
|
+
lastAcceptedAt?: string;
|
|
22
|
+
/** When the last request was rejected, as an ISO time; left out until one was. */
|
|
23
|
+
lastRejectedAt?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The webhook's counters: each request adds to one of them and nothing else is kept. */
|
|
27
|
+
export class WebhookStats {
|
|
28
|
+
#accepted = 0;
|
|
29
|
+
#rejected = { signature: 0, destination: 0, malformed: 0 };
|
|
30
|
+
#acceptedAt: string | undefined;
|
|
31
|
+
#rejectedAt: string | undefined;
|
|
32
|
+
|
|
33
|
+
accept(at: Date): void {
|
|
34
|
+
this.#accepted++;
|
|
35
|
+
this.#acceptedAt = at.toISOString();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
reject(reason: keyof WebhookHealth["rejected"], at: Date): void {
|
|
39
|
+
this.#rejected[reason]++;
|
|
40
|
+
this.#rejectedAt = at.toISOString();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
health(): WebhookHealth {
|
|
44
|
+
return {
|
|
45
|
+
accepted: this.#accepted,
|
|
46
|
+
rejected: { ...this.#rejected },
|
|
47
|
+
...(this.#acceptedAt ? { lastAcceptedAt: this.#acceptedAt } : {}),
|
|
48
|
+
...(this.#rejectedAt ? { lastRejectedAt: this.#rejectedAt } : {}),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
11
53
|
export interface WebhookOptions {
|
|
12
54
|
sql: SQL;
|
|
13
55
|
tokens: TokenSource;
|
|
@@ -17,6 +59,8 @@ export interface WebhookOptions {
|
|
|
17
59
|
botUserId: () => string | undefined;
|
|
18
60
|
/** Called after events were stored, so the worker looks at the inbox now rather than at its next poll. */
|
|
19
61
|
stored: () => void;
|
|
62
|
+
/** Counts what the webhook answers; the plugin reads it for `LINE.health()`. */
|
|
63
|
+
stats: WebhookStats;
|
|
20
64
|
maxBytes?: number;
|
|
21
65
|
}
|
|
22
66
|
|
|
@@ -60,18 +104,24 @@ async function readCapped(
|
|
|
60
104
|
* LINE's webhook: the raw body is verified against `x-line-signature` before anything is parsed,
|
|
61
105
|
* every event is stored in one transaction, and only then does it answer `200`. A database that
|
|
62
106
|
* is down answers `500`, which makes LINE's redelivery (if the channel has it on) resend.
|
|
63
|
-
* An empty `events` array (LINE's Verify button) is a `200` that stores nothing.
|
|
107
|
+
* An empty `events` array (LINE's Verify button) is a `200` that stores nothing. Each answer adds
|
|
108
|
+
* to `options.stats`, which keeps counts and times only.
|
|
64
109
|
*/
|
|
65
110
|
export function webhookHandler(
|
|
66
111
|
options: WebhookOptions,
|
|
67
112
|
): (request: Request) => Promise<Response> {
|
|
68
113
|
const max = options.maxBytes ?? WEBHOOK_MAX_BYTES;
|
|
114
|
+
const { stats } = options;
|
|
69
115
|
return async (request) => {
|
|
70
116
|
if (request.method !== "POST") return reply(405, "Method Not Allowed");
|
|
71
117
|
const body = await readCapped(request, max);
|
|
72
|
-
if (!body)
|
|
118
|
+
if (!body) {
|
|
119
|
+
stats.reject("malformed", options.now());
|
|
120
|
+
return reply(413, "Payload Too Large");
|
|
121
|
+
}
|
|
73
122
|
const signature = request.headers.get("x-line-signature");
|
|
74
123
|
if (!signature || !options.tokens.verifies(body, signature)) {
|
|
124
|
+
stats.reject("signature", options.now());
|
|
75
125
|
options.logger.warn("a LINE webhook was refused: bad signature");
|
|
76
126
|
return reply(401, "Unauthorized");
|
|
77
127
|
}
|
|
@@ -80,15 +130,20 @@ export function webhookHandler(
|
|
|
80
130
|
parsed = await parseWebhook(body);
|
|
81
131
|
} catch (error) {
|
|
82
132
|
if (!(error instanceof LineError)) throw error;
|
|
133
|
+
stats.reject("malformed", options.now());
|
|
83
134
|
options.logger.warn({ code: error.code }, "a LINE webhook was refused");
|
|
84
135
|
return reply(400, "Bad Request");
|
|
85
136
|
}
|
|
86
137
|
const bot = options.botUserId();
|
|
87
138
|
if (bot !== undefined && parsed.destination !== bot) {
|
|
139
|
+
stats.reject("destination", options.now());
|
|
88
140
|
options.logger.warn("a LINE webhook was refused: it is for another bot");
|
|
89
141
|
return reply(403, "Forbidden");
|
|
90
142
|
}
|
|
91
|
-
if (parsed.events.length === 0)
|
|
143
|
+
if (parsed.events.length === 0) {
|
|
144
|
+
stats.accept(options.now());
|
|
145
|
+
return reply(200, "OK");
|
|
146
|
+
}
|
|
92
147
|
try {
|
|
93
148
|
const result = await persistEvents(
|
|
94
149
|
options.sql,
|
|
@@ -105,6 +160,7 @@ export function webhookHandler(
|
|
|
105
160
|
);
|
|
106
161
|
return reply(500, "Internal Server Error");
|
|
107
162
|
}
|
|
163
|
+
stats.accept(options.now());
|
|
108
164
|
return reply(200, "OK");
|
|
109
165
|
};
|
|
110
166
|
}
|