pi-roundtable-line 0.9.13 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,31 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.11.0] - 2026-10-13
9
+
10
+ - Release in lockstep with pi-roundtable 0.11.0; no package-specific behavior changes. The core peer range moves to `>=0.11.0 <0.12.0`.
11
+
12
+ ## [0.10.0] - 2026-10-13
13
+
14
+ ### Added
15
+
16
+ - The consumer. `LINE.subscribe(handler)` takes the one subscriber (a second is refused; the returned function detaches it) and the package's own inbox processor hands it typed events: `message` (text, image, video, audio, file, sticker, location, and `unsupported` for a type it does not know), `edited`, `unsent`, `joined` (`notice` says whether the join notice went out), `left`, `memberJoined`, `memberLeft`, and `outboxResult`. A `message` is answered with `{ refs }` (anything else fails with `bad_refs`), opaque references stored one to many in `line_message_refs` and given back on `edited` and `unsent`; a redelivery of a recorded message is not handed out again and stores nothing twice. The subscriber's third argument has `keep(ref)`, to store the reference of each post as it lands under the same lock and tombstone check as the final store (a ref kept after an unsend is handed back as `unsent` at once and `keep` resolves `{ unsent: true }`); a handler that throws after keeping some refs leaves them stored for the retry and for a later `unsend`.
17
+ - Tombstones. An `unsend` writes a tombstone, nulls every inbox payload of the message and deletes its metadata in one transaction under a per-message lock, then hands the refs to the subscriber and forgets them once it returns. A `message` checks the tombstone before it calls the subscriber and again wherever its refs are stored (`keep` or the final store); an unsend that lands meanwhile has the new refs handed straight back. Only a crash between a post and its `keep` can leave a post the package does not know of. Edits arrive out of order and the latest one wins.
18
+ - `LINE.content(messageId, { kind, signal })`: message content into memory only, capped by `media.maxBytes` (default 10 MiB) and answered as `{ bytes, contentType }`, `{ tooLarge }` or `{ expired, reason }`; a video or audio waits for `/content/transcoding` for at most `media.transcodingMaxMs` in all, requests included (each request is given what is left of the wait). It uses the content host, reissues the token once on a `401`, stops on the signal, and never fetches a message that was unsent.
19
+ - Reply and quote tokens are kept per message in `line_messages` for a later reply mode, never logged or handed to the subscriber, and deleted with the message.
20
+ - The `line-retention` service and the `retention` option: inbox events that are done, skipped, dead or never handled, and message metadata, refs and tombstones, are removed after 7 days by default; it logs counts only. A subscriber that stays detached longer than the window loses the unsends and refs that waited for it. New migration `messages` (the released `inbox` migration is untouched).
21
+
22
+ - The outbox. `LINE.outbox.enqueue(sourceKey, messages, { sender?, origin? })` queues message objects for a chat on the allowlist. `sender` (`{ name, iconUrl }`) is validated before anything is queued: a name of 1 to 20 characters without a forbidden word (`sender.forbiddenWords`, default `LINE` and `LY`, whole words in any case and width, where a change of script between Latin and other letters ends a word; LINE still has the last word), an `https` icon URL of at most 2000 characters. Objects of one chat and sender queued within `outbox.coalesceMs` (2 seconds) share a push of at most five, in order; the pushes of a chat go out in order. Every push has a UUID retry key stored when its row is created, reused on every attempt for 24 hours and never replaced (a push still unsent then is `dead`, counted from its first request even if a stop cut that request short). `2xx` and `409` are `sent`, `429`, `5xx` and no answer back off and retry (up to `outbox.maxAttempts`), any other `4xx` is `dead` with a code (`sender_name_refused`, `sender_icon_refused`, or `http_<status>`). A quota gate reads LINE's quota and consumption (cached 60 seconds), counts the recipients (1, or the member count of a group or room) and this process's reservations, and refuses a push that would pass the lower of the quota and `quota.monthlyCap`. LINE calls its consumption figure approximate, so a sent push (or one whose outcome is unknown) stays reserved until consumption has grown by its recipients or `quota.holdMs` (10 minutes) has passed; keep `monthlyCap` below the quota with headroom. The subscriber hears an `outboxResult` (`sent`, `dead`, `refused`) per `origin`, at least once. The `line-outbox` service, migration `outbox` (tables `line_outbox` and `line_notices`) and `retention.outboxMs` (7 days) are new; message objects are nulled when a push finishes.
23
+ - The join notice. `joinNotice: { text, sha256 }` answers a `join` with one Reply, on the event's reply token, only when the SHA-256 of the text's UTF-8 bytes equals `sha256`, in any chat the bot joins, allowlisted or not; never as a push. A malformed digest throws when the plugin is made; a digest that does not match sends nothing. `joined.notice` is now `"sent"`, `"failed"` (LINE refused the Reply, its token lapsed, or the digest did not match) or `"unconfirmed"` (no notice configured, the Reply got no answer, or the first try was cut short), and the outcome is recorded before the Reply so a redelivered join never replies twice. `digestOf(text)` is exported.
24
+ - `route: true` hands the text of messages of allowed chats to the host's router as messages of the `line` surface, inside the transaction that records the message and never for one unsent meanwhile (at least once: only a failed commit right after the hand-over can repeat it) (off by default; the README warns never to admit `line` through `everyone`).
25
+ - The `line` chat surface (channel = source key), which queues what the host writes as text pushes and passes `describeSurfaceContract`, and `senderIconRoute: { path, png }`, which serves fixed PNG bytes with a one-year cache header so a sender can name a stable `iconUrl`.
26
+ - The retention sweep hands strays back first: refs stored for a message that has a tombstone, more than `retention.strayMs` (default 15 minutes) ago, whose tombstone is that old too, go to the subscriber as `unsent` until they are gone. A handler that outlived its lease could keep a ref after the unsend had run and its take-back was refused, and nothing else would have handed the ref over.
27
+
28
+ ### Changed
29
+
30
+ - A ref a handler kept and also returns at the end is no longer handed back a second time when the message was unsent meanwhile.
31
+ - **Breaking (published in 0.9.13):** `LINE.attachProcessor` is removed in favour of `LINE.subscribe`: a raw processor could read events without the tombstone rules. There is no deprecated shim. Code that attached a processor must subscribe instead; the next lockstep release is 0.10.0. `InboxProcessor` and its types stay exported for the inbox options.
32
+
8
33
  ## [0.9.13] - 2026-10-13
9
34
 
10
35
  ### Added
package/README.md CHANGED
@@ -1,18 +1,17 @@
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.
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, hands the stored events of the chats you allow to one consumer, in order and at least once, and sends pushes back to those chats under a sender name, a retry key and a monthly budget.
5
5
  Source lives in [`packages/line`][source] in the pi-roundtable repository and releases in lockstep with the core.
6
6
 
7
7
  [roundtable]: https://www.npmjs.com/package/pi-roundtable
8
8
  [source]: https://github.com/wayne930242/pi-roundtable/tree/master/packages/line
9
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.
10
+ It covers intake (the credentials and token source, the webhook, the allowlist of chats, and the inbox worker), the consumer side (typed events for one subscriber, tombstones for unsent messages, message content, and retention) and the sending side: an outbox with per-chat ordering and coalescing, sender names, persisted retry keys and a quota gate, the join notice, an optional router delivery, a `line` chat surface and a route that serves a sender icon.
12
11
 
13
12
  ## Requirements
14
13
 
15
- - Bun 1.3 or later, and pi-roundtable `>=0.9.0 <0.10.0` as a peer dependency.
14
+ - Bun 1.3 or later, and pi-roundtable `>=0.11.0 <0.12.0` as a peer dependency.
16
15
  - PostgreSQL, which the host already needs.
17
16
  - An HTTP listener on the host that LINE can reach over HTTPS (the webhook URL is set in the channel's settings).
18
17
  - A Messaging API channel: its channel id and channel secret.
@@ -105,28 +104,20 @@ await sources.summary("line:group:C..."); // adds the group's name, asked of LIN
105
104
  | `blocked` | events are dropped; `block` also drops what was waiting |
106
105
  | `left` | the bot was removed; a `join` makes it `pending` again, because a new group needs a new decision |
107
106
 
108
- The `join` and `leave` events carry no customer text and are kept for every chat that is not blocked.
107
+ The `join` and `leave` events carry no customer text and are kept for every chat that is not blocked. An `unsend` is stored only while its chat is allowed, but one that is already stored is still delivered after the bot has left the chat (see [The inbox worker](#the-inbox-worker)).
109
108
  Allowing a chat does not recover earlier events: they were dropped unread.
110
109
  This package never creates anything for a chat; what a chat becomes is the consumer's business.
111
110
 
112
111
  ## The inbox worker
113
112
 
114
113
  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:
114
+ The worker runs inside the host as the `line-inbox` service and hands each event to the package's own processor, which turns it into a typed event for the one subscriber (see [The consumer](#the-consumer)).
115
+ Until a subscriber is attached, events stay in the inbox with their payload, are not counted as handed out, and are removed by retention if they wait past its window.
116
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.
117
+ - 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 subscriber must be idempotent.
127
118
  - 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
119
  - 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.
120
+ - A handler runs for at most `maxHandleMs` (10 minutes). The processor (and so the subscriber) 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 subscriber must be safe against its own overlap. To give up, throw (for example `throw signal.reason`): a handler that returns has handled the event, and it is marked done. A subscriber's result is never read as a retry request; a `message` answer that is neither nothing nor `{ refs }` (a `{ retryInMs }`, say) fails with `bad_refs`. The subscriber 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
121
  - 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
122
  - 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
123
  - 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.
@@ -135,10 +126,203 @@ Until a processor is attached, events stay in the inbox with their payload.
135
126
 
136
127
  Options tune the worker: `inbox: { leaseMs, maxHandleMs, maxAttempts, backoffBaseMs, backoffCapMs, concurrency, pollMs, ... }`.
137
128
 
129
+ ## The consumer
130
+
131
+ ```ts
132
+ const detach = context.services.get(LINE).subscribe(async (event, signal, line) => {
133
+ switch (event.type) {
134
+ case "message": {
135
+ // mirror event.body; keep the opaque reference of each post as it lands
136
+ await line.keep("discord-message-1");
137
+ await line.keep("discord-message-2");
138
+ return { refs: ["discord-message-1", "discord-message-2"] };
139
+ }
140
+ case "edited": // event.text, event.refs (the original's)
141
+ case "unsent": // event.refs: take these down; may be heard more than once
142
+ case "joined": // event.notice: "sent" | "unconfirmed" | "failed"
143
+ case "left":
144
+ case "memberJoined": // event.userIds
145
+ case "memberLeft":
146
+ case "outboxResult": // how a push you queued ended
147
+ }
148
+ });
149
+ ```
150
+
151
+ There is exactly one subscriber: a second `subscribe` throws, and the function it returns detaches the first.
152
+ With none attached the inbox worker claims nothing (events wait, uncounted).
153
+ The processor that serves it is the package's own; there is no way to read raw inbox events through the service, so the rules below cannot be bypassed.
154
+
155
+ | Event | What it carries |
156
+ |---|---|
157
+ | `message` | `messageId`, `userId`, `quotedMessageId`, and a `body`: `text`, `image`, `video`, `audio` (with `durationMs`), `file` (`fileName`, `fileSize`), `sticker` (`packageId`, `stickerId`, `keywords`), `location` (`title`, `address`, `latitude`, `longitude`), or `unsupported` for a type this version does not know, so nothing disappears silently |
158
+ | `edited` | the new `text` and the `refs` of the original, in the order the subscriber gave them; groups only, and only text can be edited |
159
+ | `unsent` | the `refs` to take down |
160
+ | `joined` | `notice`: `"sent"`, `"failed"` or `"unconfirmed"`, truthfully (see [The join notice](#the-join-notice)) |
161
+ | `left` | |
162
+ | `memberJoined`, `memberLeft` | `userIds` |
163
+ | `outboxResult` | `sourceKey`, `originRef`, `status` (`sent`, `dead`, `refused`) and `errorCode` (see [Sending](#sending)) |
164
+
165
+ 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.
166
+ No event carries a reply token, a quote token or a URL.
167
+
168
+ A `message` is answered with `{ refs }`: opaque strings of at most 512 characters (at most 100 per message, kept and answered together) that the package stores one to many in `line_message_refs`, in order, and gives back on `edited` and `unsent`.
169
+ Answering nothing is allowed; the message is then still recorded as mirrored. Any other answer (an object without `refs`, a `{ retryInMs }`) fails the event with `bad_refs`; to give up, throw.
170
+ A redelivery of a message that is already recorded is not handed to the subscriber again.
171
+
172
+ One message may become several posts (a long text is cut into chunks, media and text are posted apart), and the last one can fail.
173
+ So the third argument of the subscriber, `line`, has `keep(ref)`: call it as each post lands.
174
+ It stores the reference at once, under the same per-message lock and tombstone check as the answer at the end, and resolves `{ unsent }`.
175
+ Refs answered at the end merge with the kept ones (one already stored keeps its place), so keeping and answering the same ref is fine; a ref that was kept is never handed back a second time because it is answered again.
176
+ A handler that throws after keeping some refs leaves them stored: the retry mirrors the message again, and an `unsent` hands over the posts of every attempt, so none is left behind.
177
+ If the sender had already taken the message back, `keep` stores the ref, hands it to the subscriber as `unsent` at once (the same take-back the final store does) and resolves `{ unsent: true }`: stop posting.
178
+ Keep each post as it lands, and throw to give up.
179
+ Only a crash between a post and its `keep` can leave a post the package does not know of; the message is then mirrored again, and the subscriber should deduplicate on `eventId` if that matters.
180
+
181
+ ### Unsent messages
182
+
183
+ An `unsend` event writes a tombstone for the message, nulls the stored payload of every event that mentions the message (its own, edits of it), and deletes the message's metadata, tokens included, in one transaction under a per-message lock.
184
+ Only then are the stored refs handed to the subscriber as `unsent`; they are forgotten once it has returned, so a subscriber that failed halfway is handed the same refs again.
185
+
186
+ - A `message` checks the tombstone before it calls the subscriber: an unsent message is never mirrored. Refs left over from a mirror that was cut short are still handed back.
187
+ - The refs a subscriber keeps or returns are stored under the same lock and the tombstone is checked again there. If the sender took the message back while the subscriber was working, the new refs are stored, handed straight back as `unsent` (at the `keep` for a kept ref, at the end for the rest), and the message record is not written.
188
+ - An `unsend` for a message the package never saw (it arrived first, or is older than retention) still writes the tombstone and is heard with `refs: []`.
189
+ - An edit of a message that was unsent, was never mirrored, or is older than the last edit applied is skipped; edits arrive out of order, and the one with the latest time wins.
190
+
191
+ ## Message content
192
+
193
+ ```ts
194
+ const result = await context.services.get(LINE).content(event.messageId, {
195
+ kind: event.body.kind === "text" ? undefined : event.body.kind,
196
+ signal,
197
+ });
198
+ if ("bytes" in result) upload(result.bytes, result.contentType);
199
+ else if ("tooLarge" in result) placeholder("too large");
200
+ else placeholder(result.reason); // "expired" | "unsent" | "transcoding_failed"
201
+ ```
202
+
203
+ The content is read into memory, never to disk, never logged, and never exposed at a URL.
204
+ `media: { maxBytes }` (default 10 MiB) caps it: a `content-length` over the cap is refused before reading, and a body without one is counted as it streams and dropped past the cap, giving `{ tooLarge: true }`.
205
+ LINE keeps content for a limited time; a missing one is `{ expired: true }`.
206
+ Pass the event's `kind` for a video or audio: the call then polls `/v2/bot/message/{id}/content/transcoding` (every `media.transcodingPollMs`, 2 seconds, until `media.transcodingMaxMs`, 60 seconds, have passed since the call began: a real deadline, so each request gets only what is left of it) until it has `succeeded`; `failed` is `{ expired: true, reason: "transcoding_failed" }` and running out of time is a `transcoding_timeout` error, which the inbox retries.
207
+ The call uses the same token source as every other call (a `401` reissues the token once) and the content host `api-data.line.me`.
208
+ Pass the handler's `signal` and an aborted event stops the download.
209
+ A message that was unsent is never fetched.
210
+
211
+ ## Reply and quote tokens
212
+
213
+ The reply token and the quote token of a mirrored message are kept in `line_messages` (`reply_token`, `reply_token_at`, `quote_token`) for a later reply mode.
214
+ They are never logged and never passed to the subscriber, and they are deleted with the message when it is unsent or when retention runs.
215
+
216
+ ## Retention
217
+
218
+ The `line-retention` service sweeps once at start and every hour (`retention.intervalMs`):
219
+
220
+ | What | Removed after (option) |
221
+ |---|---|
222
+ | inbox events that are `done`, `skipped` or `dead` | 7 days (`retention.inboxMs`) |
223
+ | inbox events still `pending`, which no subscriber handled | 7 days (`retention.inboxMs`), payload and all |
224
+ | message metadata and tokens, refs, tombstones | 7 days (`retention.messagesMs`) |
225
+ | pushes that finished, pushes that never got sent, join notice records | 7 days (`retention.outboxMs`) |
226
+
227
+ Before it trims, every sweep hands back strays: refs still stored for a message that has a tombstone, where both the ref and the tombstone are more than `retention.strayMs` (15 minutes) old, go to the subscriber as `unsent`, and are forgotten once it returns.
228
+ A stray happens when a handler that outlived its lease keeps a ref after its message was unsent and the take-back is refused, so nobody else would hand the ref over.
229
+ A take-back that fails is logged by its code and retried at the next sweep, until the ref is gone; with no subscriber attached the refs wait.
230
+
231
+ A subscriber that stays detached longer than the window loses what was waiting for it: an `unsend` still pending is removed with the rest, and the refs of the message it names are removed too, so that mirror can no longer be taken back. Keep a subscriber attached, or raise `retention.inboxMs` and `retention.messagesMs` beyond the longest outage.
232
+
233
+ It logs counts only, never ids, text or tokens, and counts as `busy` while a sweep runs so the shutdown waits for it.
234
+ A table that cannot be trimmed is logged by its code and the others go on.
235
+
236
+ ## Sending
237
+
238
+ ```ts
239
+ const { outbox } = context.services.get(LINE);
240
+ await outbox.enqueue(
241
+ "line:group:C...",
242
+ [{ type: "text", text: "On it." }],
243
+ {
244
+ sender: { name: "Ada | Support", iconUrl: "https://example.com/line/staff-icon.png" },
245
+ origin: "draft-42", // your own reference, given back in the outboxResult
246
+ },
247
+ );
248
+ ```
249
+
250
+ `enqueue` queues LINE message objects for a chat on the allowlist; it throws before queuing anything when the sender is one LINE would refuse (`bad_sender`), an object cannot be sent (`bad_messages`: not an object with a `type`, an empty or over-long text, a `sender` of its own, over 50,000 bytes, more than 100 in one call), the key is not a source key (`bad_source_key`), or the chat is not allowed (`source_not_allowed`).
251
+ Message objects hold what you send, so they live in `line_outbox.messages` until the push finishes and are then set to null; the row itself goes after the retention window.
252
+
253
+ - **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).
254
+ - **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.
255
+ - **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`.
256
+ - **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.
257
+ - **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.
258
+ - **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.
259
+ - **Nothing sensitive in logs.** The worker logs push ids, attempt numbers, status words and codes; never message objects, sender names, retry keys or tokens.
260
+
261
+ ### Senders
262
+
263
+ A sender is `{ name?, iconUrl? }`, shown by LINE as "<name> from '<channel>'".
264
+ It is checked when you enqueue: the name is 1 to 20 characters (counted as characters, so an emoji is one), has no leading or trailing space or control character, and contains none of the forbidden words (`sender.forbiddenWords`, default `["LINE", "LY"]`, compared case-insensitively, with full-width letters read as plain ones, and as whole words, where a word is a run of Latin letters and digits or a run of letters of another script, so "Online" and "Sally" pass while "LINE" next to kana or hanzi does not); the icon is an `https` URL of at most 2000 characters without credentials.
265
+ LINE has the last word: a name it refuses anyway comes back as a dead push with `sender_name_refused`.
266
+
267
+ ### Quota
268
+
269
+ Before a push is sent the gate reads LINE's monthly limit (`/v2/bot/message/quota`) and consumption (`/v2/bot/message/quota/consumption`), cached for 60 seconds, and counts the push's recipients: 1 for a user chat, the member count (`/members/count`, cached for 60 seconds) for a group or room.
270
+ The push goes only if consumption, plus what this process has already reserved for pushes it sent or is sending, plus its own recipients, stays within the lower of the plan's quota and `quota.monthlyCap`; otherwise it is `refused` with `quota_exhausted`.
271
+ A reservation for a push that went out stays counted until the consumption LINE reports has grown by at least the push's recipients since it was sent, or until `quota.holdMs` (10 minutes by default) has passed. A push whose outcome is unknown (a timeout, no answer, or a `5xx`) is held as if it had been sent; one LINE refused with a `4xx` or `429` is dropped at once. When the quota cannot be read the push backs off and retries; it is not refused and not sent blind.
272
+ This rests on an assumption: LINE calls its consumption figure approximate, and the gate treats it as approximate. It may count late, so keep `monthlyCap` below the plan's quota with some headroom, and keep it lower still when other processes, or the LINE Official Account Manager, send too. Only this process's reservations are known, and they are lost on a restart.
273
+
274
+ ```ts
275
+ line({ credentials, quota: { monthlyCap: 400 } })
276
+ ```
277
+
278
+ ## The join notice
279
+
280
+ ```ts
281
+ line({
282
+ credentials,
283
+ joinNotice: { text: NOTICE_TEXT, sha256: NOTICE_SHA256 },
284
+ })
285
+ ```
286
+
287
+ When the bot joins a chat the package answers the `join` event with **one Reply**, using the event's reply token, if and only if the SHA-256 of the text's UTF-8 bytes (lower-case hex) equals `joinNotice.sha256`.
288
+ `digestOf(text)` computes it. A digest that is not 64 lower-case hex digits, or a text over 5000 characters, throws when the plugin is made; a well-formed digest that does not match the text does not stop the host, but nothing is ever sent and every join is `failed` (and a warning is logged once at start).
289
+ Without `joinNotice` nothing is ever sent.
290
+
291
+ - It applies to any chat the bot joins that is not blocked, allowlisted or not.
292
+ - It is never a push. When LINE refuses the Reply, or its token has lapsed (reply tokens live a short time), the notice is `failed` and nothing is sent in its place. When the Reply gets no answer (a timeout or an unreachable LINE) nobody knows whether LINE took it, so the notice is `unconfirmed` and is not tried again.
293
+ - The subscriber's `joined` event says what happened: `"sent"`, `"failed"`, or `"unconfirmed"` (no notice configured, or the Reply got no answer, or the first try was cut short, and nobody knows whether LINE got the Reply). The outcome is recorded before the Reply is made, so a join that is handed out again never replies twice.
294
+ - The inbox worker only runs while a subscriber is attached (or `route` is on), so attach one before the bot can be added to chats; a join that waits past its reply token's life is `failed`.
295
+
296
+ ## Router delivery
297
+
298
+ `route: true` hands the text of messages of allowed chats to the host's router, as messages of the `line` surface (channel `line:group:<id>`, author the LINE user id, `mentionsBot` when the bot is @mentioned, with the bot's mention cut out of the text), after the subscriber has mirrored them, and never for a message that was unsent meanwhile.
299
+ The hand-over happens in the same transaction that records the message, so a router that throws makes the event retry and a failed store hands over nothing. Delivery is at least once: only a failure of the commit itself, right after the router took the text, can hand the same text over a second time.
300
+ Images, stickers and other kinds are not routed, nor are messages LINE did not name a sender for.
301
+ It is off by default.
302
+ **Never admit `line` through the core's `everyone` access**: anyone who can add the bot to a group, or write in a chat you allow, would reach the assistant. Admit only identities you have linked to a person.
303
+ With `route` on the inbox is served even when no subscriber is attached.
304
+
305
+ ## The `line` chat surface
306
+
307
+ The plugin contributes a chat surface under the prefix `line`; its channel is a source key (`line:group:<id>`, `line:room:<id>`, `line:user:<id>`).
308
+ What the host writes there (`sendReply`) is queued as text objects, one per chunk (a chunk over 5000 characters is cut, never inside a surrogate pair), and goes out as a push with no sender; it reaches only a chat on the allowlist.
309
+ The surface shows no typing indicator, stop control, progress, files or approval prompts: LINE has none the bot can show.
310
+ It passes `describeSurfaceContract` (`pi-roundtable/testing`).
311
+
312
+ ## The sender icon route
313
+
314
+ ```ts
315
+ line({ credentials, senderIconRoute: { path: "/line/staff-icon.png", png: readFileSync("staff-icon.png") } })
316
+ ```
317
+
318
+ Serves fixed PNG bytes at `path` to `GET` and `HEAD` with `cache-control: public, max-age=31536000, immutable`, on the same listener as the webhook, so the host can give LINE a stable `iconUrl` (`https://<your host>/line/staff-icon.png`) without another service.
319
+ The bytes are copied when the plugin is made and checked: they must be a PNG of at most 1 MiB, and `path` must differ from `webhookPath`.
320
+
138
321
  ## Safety
139
322
 
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.
323
+ 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 (see [Router delivery](#router-delivery)).
324
+ A push goes only to a chat on the allowlist, under a sender that was checked before it was queued, inside the month's budget.
325
+ The access token, the channel secret, reply and quote tokens, retry keys and message text appear in no log line and no error.
142
326
 
143
327
  ## Tests
144
328
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable-line",
3
- "version": "0.9.13",
3
+ "version": "0.11.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.9.0 <0.10.0"
36
+ "pi-roundtable": ">=0.11.0 <0.12.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.9.13",
43
+ "pi-roundtable": "0.11.0",
44
44
  "typebox": "1.3.34",
45
45
  "typescript": "7.0.2"
46
46
  }
package/src/api.ts CHANGED
@@ -1,10 +1,49 @@
1
1
  import { LineApiError, LineError } from "./errors.ts";
2
+ import type { SourceKind } from "./events.ts";
2
3
  import { LINE_API, type TokenSource } from "./token.ts";
3
4
 
5
+ /** The host LINE serves message content from; every other call goes to {@link LINE_API}. */
6
+ const LINE_DATA_API = "https://api-data.line.me";
7
+
8
+ /** How a push ended, as far as the caller needs to act on it. */
9
+ export type PushOutcome =
10
+ | { outcome: "sent"; duplicate: boolean; requestId: string | undefined }
11
+ | {
12
+ outcome: "failed";
13
+ status: number;
14
+ /** A short lower-case word: `http_<status>`, or the property LINE refused when it names one. */
15
+ code: string;
16
+ requestId: string | undefined;
17
+ retryAfterMs?: number;
18
+ };
19
+
20
+ /**
21
+ * The code to keep for a refused push. LINE names the property it refused in `details`; the ones a
22
+ * sender can cause are named so the channel can say what to fix, everything else is the status.
23
+ */
24
+ function refusalCode(status: number, detail: unknown): string {
25
+ const details =
26
+ typeof detail === "object" && detail !== null && "details" in detail
27
+ ? (detail as { details: unknown }).details
28
+ : undefined;
29
+ if (Array.isArray(details))
30
+ for (const item of details) {
31
+ const property =
32
+ typeof item === "object" && item !== null && "property" in item
33
+ ? String((item as { property: unknown }).property)
34
+ : "";
35
+ if (/sender\.name/i.test(property)) return "sender_name_refused";
36
+ if (/sender\.iconUrl/i.test(property)) return "sender_icon_refused";
37
+ }
38
+ return `http_${status}`;
39
+ }
40
+
4
41
  export interface LineApiOptions {
5
42
  tokens: TokenSource;
6
43
  fetch?: typeof fetch;
7
44
  baseUrl?: string;
45
+ /** Where message content is fetched from; default `https://api-data.line.me`. */
46
+ dataBaseUrl?: string;
8
47
  /** Per-request timeout in milliseconds; default 15 seconds. */
9
48
  timeoutMs?: number;
10
49
  }
@@ -13,6 +52,12 @@ export interface RequestOptions {
13
52
  method?: string;
14
53
  headers?: Record<string, string>;
15
54
  body?: string | Uint8Array;
55
+ /** `"data"` calls the content host; default `"api"`. */
56
+ host?: "api" | "data";
57
+ /** Aborts the call; an aborted call rejects with the signal's reason, not with a wrapped error. */
58
+ signal?: AbortSignal;
59
+ /** Replaces the client's per-request timeout, which also bounds reading the body. */
60
+ timeoutMs?: number;
16
61
  }
17
62
 
18
63
  /**
@@ -24,28 +69,60 @@ export class LineApi {
24
69
  readonly #tokens: TokenSource;
25
70
  readonly #fetch: typeof fetch;
26
71
  readonly #base: string;
72
+ readonly #dataBase: string;
27
73
  readonly #timeoutMs: number;
28
74
 
29
75
  constructor(options: LineApiOptions) {
30
76
  this.#tokens = options.tokens;
31
77
  this.#fetch = options.fetch ?? fetch;
32
78
  this.#base = options.baseUrl ?? LINE_API;
79
+ this.#dataBase = options.dataBaseUrl ?? LINE_DATA_API;
33
80
  this.#timeoutMs = options.timeoutMs ?? 15_000;
34
81
  }
35
82
 
36
83
  /** The response of a successful call; its body is the caller's to read. */
37
84
  async request(path: string, options: RequestOptions = {}): Promise<Response> {
85
+ const response = await this.exchange(path, options);
86
+ if (!response.ok) {
87
+ await response.body?.cancel().catch(() => undefined);
88
+ throw new LineApiError(
89
+ response.status,
90
+ `http_${response.status}`,
91
+ `line: ${options.method ?? "GET"} ${path} answered ${response.status}`,
92
+ response.headers.get("x-line-request-id") ?? undefined,
93
+ );
94
+ }
95
+ return response;
96
+ }
97
+
98
+ /**
99
+ * The response of a call whatever its status, for a caller that acts on the status itself. A
100
+ * `401` is repeated once with a new token first. The body is the caller's to read or cancel.
101
+ */
102
+ async exchange(
103
+ path: string,
104
+ options: RequestOptions = {},
105
+ ): Promise<Response> {
38
106
  for (let attempt = 0; ; attempt++) {
107
+ options.signal?.throwIfAborted();
39
108
  const token = await this.#tokens.token();
40
109
  let response: Response;
110
+ const timeout = AbortSignal.timeout(options.timeoutMs ?? this.#timeoutMs);
41
111
  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
- });
112
+ response = await this.#fetch(
113
+ `${options.host === "data" ? this.#dataBase : this.#base}${path}`,
114
+ {
115
+ method: options.method ?? "GET",
116
+ headers: { ...options.headers, authorization: `Bearer ${token}` },
117
+ ...(options.body === undefined ? {} : { body: options.body }),
118
+ signal: options.signal
119
+ ? AbortSignal.any([options.signal, timeout])
120
+ : timeout,
121
+ },
122
+ );
48
123
  } catch (error) {
124
+ // The caller gave up: it hears its own reason, which the inbox worker knows how to read.
125
+ if (options.signal?.aborted) throw options.signal.reason;
49
126
  throw new LineError(
50
127
  "api_unreachable",
51
128
  `line: ${path} did not answer (${this.#tokens.redact(
@@ -61,15 +138,6 @@ export class LineApi {
61
138
  await response.body?.cancel().catch(() => undefined);
62
139
  continue;
63
140
  }
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
141
  return response;
74
142
  }
75
143
  }
@@ -86,6 +154,102 @@ export class LineApi {
86
154
  return info.userId;
87
155
  }
88
156
 
157
+ /**
158
+ * Pushes up to five message objects to a chat under a retry key. A `2xx` is `sent`; a `409` says
159
+ * LINE already accepted a request with this key, which is `sent` too. Any other status comes back
160
+ * as `failed` with the status, a code (LINE's own refusal of a sender is named) and, for a `429`
161
+ * or `5xx`, how long LINE asked to wait. A call that gets no answer rejects with `api_unreachable`.
162
+ * No part of LINE's answer is kept but those.
163
+ */
164
+ async push(
165
+ to: string,
166
+ messages: readonly unknown[],
167
+ retryKey: string,
168
+ signal?: AbortSignal,
169
+ ): Promise<PushOutcome> {
170
+ const response = await this.exchange("/v2/bot/message/push", {
171
+ method: "POST",
172
+ headers: {
173
+ "content-type": "application/json",
174
+ "x-line-retry-key": retryKey,
175
+ },
176
+ body: JSON.stringify({ to, messages }),
177
+ ...(signal ? { signal } : {}),
178
+ });
179
+ const requestId = response.headers.get("x-line-request-id") ?? undefined;
180
+ if (response.ok || response.status === 409) {
181
+ await response.body?.cancel().catch(() => undefined);
182
+ return { outcome: "sent", duplicate: response.status === 409, requestId };
183
+ }
184
+ const detail = await response
185
+ .text()
186
+ .then((text) => JSON.parse(text.slice(0, 8192)) as unknown)
187
+ .catch(() => undefined);
188
+ const retryAfter = Number(response.headers.get("retry-after"));
189
+ return {
190
+ outcome: "failed",
191
+ status: response.status,
192
+ code: refusalCode(response.status, detail),
193
+ requestId,
194
+ ...(Number.isFinite(retryAfter) && retryAfter > 0
195
+ ? { retryAfterMs: Math.min(retryAfter, 3600) * 1000 }
196
+ : {}),
197
+ };
198
+ }
199
+
200
+ /** Answers an event with its reply token; the token is single use and short lived. */
201
+ async reply(
202
+ replyToken: string,
203
+ messages: readonly unknown[],
204
+ signal?: AbortSignal,
205
+ ): Promise<void> {
206
+ await this.request("/v2/bot/message/reply", {
207
+ method: "POST",
208
+ headers: { "content-type": "application/json" },
209
+ body: JSON.stringify({ replyToken, messages }),
210
+ ...(signal ? { signal } : {}),
211
+ });
212
+ }
213
+
214
+ /** How many people a push to a chat reaches: the member count of a group or room, else one. */
215
+ async recipients(kind: SourceKind, id: string): Promise<number> {
216
+ if (kind === "user") return 1;
217
+ const found = (await this.json(
218
+ `/v2/bot/${kind}/${encodeURIComponent(id)}/members/count`,
219
+ )) as { count?: unknown };
220
+ if (typeof found?.count !== "number" || !Number.isInteger(found.count))
221
+ throw new LineError(
222
+ "member_count_malformed",
223
+ "line: the member count has no count",
224
+ );
225
+ return Math.max(1, found.count);
226
+ }
227
+
228
+ /** The monthly message limit of the plan, or undefined when the plan has none. */
229
+ async messageQuota(): Promise<number | undefined> {
230
+ const found = (await this.json("/v2/bot/message/quota")) as {
231
+ type?: unknown;
232
+ value?: unknown;
233
+ };
234
+ if (found?.type === "none") return undefined;
235
+ if (found?.type !== "limited" || typeof found.value !== "number")
236
+ throw new LineError("quota_malformed", "line: the quota is malformed");
237
+ return found.value;
238
+ }
239
+
240
+ /** How many messages this month has used. */
241
+ async messageConsumption(): Promise<number> {
242
+ const found = (await this.json("/v2/bot/message/quota/consumption")) as {
243
+ totalUsage?: unknown;
244
+ };
245
+ if (typeof found?.totalUsage !== "number")
246
+ throw new LineError(
247
+ "consumption_malformed",
248
+ "line: the consumption has no totalUsage",
249
+ );
250
+ return found.totalUsage;
251
+ }
252
+
89
253
  /** The name LINE shows for a group. */
90
254
  async groupName(groupId: string): Promise<string> {
91
255
  const summary = (await this.json(