pi-roundtable 0.29.0 → 0.29.2
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 +10 -0
- package/docs/plugins.md +7 -0
- package/package.json +1 -1
- package/src/core/contract/channels.ts +55 -2
- package/src/core/host-registration.ts +4 -0
- package/src/core/routing/actor-of.ts +40 -0
- package/src/core/routing/background-speaker.ts +50 -0
- package/src/core/routing/channel-router.ts +73 -97
- package/src/core/routing/queued-messages.ts +151 -0
- package/src/index.ts +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,16 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.29.2] - 2026-10-31
|
|
9
|
+
|
|
10
|
+
- Release for the web package's changes (see its changelog); no core behavior changes.
|
|
11
|
+
|
|
12
|
+
## [0.29.1] - 2026-10-31
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- Queued messages can be listed, edited and cancelled. A message sent while a turn runs that the turn did not take waits in the channel's queue (`QUEUED_MARK`); until its own turn starts, `ConversationPort.queued(channel)` lists it (`QueuedMessage`: `messageId`, `principalId`, `text`, `queuedAt`), `cancelQueued(channel, messageId, by)` makes its turn never run (the claim's `Admission.dropped` is called and the mark comes off), and `editQueued(channel, messageId, by, text)` admits it again with the new text, so the turn runs on that. `by` is the author's principal (`QueuedBy`); anyone else gets `forbidden`, and a message that started, was steered or was cancelled is `unknown` (`QueuedChange`). The three are optional on the port type, as `messageDeleted` is. A surface that reports a deleted queued message cancels it, and one that reports an edited one changes its text, through `SurfaceMessageEvents` as before: on Discord, deleting a queued message cancels it and editing it changes what its turn uses. `QUEUED_MARK` and `STEERED_MARK` are exported from the main entry.
|
|
17
|
+
|
|
8
18
|
## [0.29.0] - 2026-10-31
|
|
9
19
|
|
|
10
20
|
### Added
|
package/docs/plugins.md
CHANGED
|
@@ -2436,6 +2436,8 @@ When that record finds the author someone else than the claim admitted, such as
|
|
|
2436
2436
|
|
|
2437
2437
|
A claim may also hear that a message it owns changed, through `onMessageDeleted(channel, messageId)` and `onMessageEdited(message)`, which a surface reports with the second argument of its `start` (`{ deleted, edited }`, a `SurfaceMessageEvents`). Discord reports both, including for a message the bot had not cached; a surface that cannot tell never calls them. The router delivers each as it comes, to the claim that owns the channel and no other, outside the channel's queue, so a running turn does not hold it up; a throw or rejection is logged and never fails the router. A delete carries only ids, and Discord's delete event does not say who wrote the message, so the claim hears the deletion of the bot's own messages too and ignores an id it does not know. An edit carries the message as it reads now, as an `InboundMessage`, without `speaker` or `peerBot` because nobody is resolved or admitted for it; the bot's own edits are not reported, nor is a change that leaves the text, the embeds and the attachments as they were, such as a pin. A bulk delete is reported as one delete per message.
|
|
2438
2438
|
|
|
2439
|
+
A message sent while a turn runs is first offered to that turn (`busy.steer`); one the turn does not take waits in the channel's queue with `QUEUED_MARK`, and until its own turn starts the conversation port can list, cancel and edit it: `queued(channel)` returns its waiting messages oldest first (`messageId`, the author's `principalId`, the `text` its turn will use, `queuedAt`); `cancelQueued(channel, messageId, { principalId })` makes its turn never run, calling the admission's `dropped` and removing the mark; `editQueued(channel, messageId, { principalId }, text)` admits the message again with the new text through the claim's `admit`, drops the old admission, and the turn runs on the new one. Only the message's author may (a different principal gets `forbidden`, and a message with no resolved author can be changed by no one). A message whose turn has started, was steered into the running turn, or was cancelled is `unknown`; an edit the claim would no longer admit is `refused` and the old text stays. The decision is made in one synchronous step, so a cancel or an edit and the start of the turn never both win. The surface's own copy of the message is the caller's to change. When a surface reports a queued message deleted or edited (`onMessageDeleted`, `onMessageEdited` above), the router cancels it or changes its text itself, without the author check since the surface says it happened: that is how deleting a queued Discord message cancels it. The three methods are optional on `ConversationPort`, as `messageDeleted` is.
|
|
2440
|
+
|
|
2439
2441
|
A claim may have `stop(channel)`, which stops the channel's running turn and returns whether one was running; the Stop button, `conversations.stop`, and every other stop go through it.
|
|
2440
2442
|
The router calls only the owning claim's `stop`, returning `false` when that method is absent.
|
|
2441
2443
|
Give `stop` to a claim whose conversations run turns that can be interrupted.
|
|
@@ -4566,6 +4568,11 @@ Import from the entries listed below; source area files are internal.
|
|
|
4566
4568
|
| `Provisioning` | `pi-roundtable` | type |
|
|
4567
4569
|
| `ProvisioningRule` | `pi-roundtable` | type |
|
|
4568
4570
|
| `QueuePort` | `pi-roundtable` | type |
|
|
4571
|
+
| `QueuedBy` | `pi-roundtable` | type |
|
|
4572
|
+
| `QueuedChange` | `pi-roundtable` | type |
|
|
4573
|
+
| `QueuedMessage` | `pi-roundtable` | type |
|
|
4574
|
+
| `QUEUED_MARK` | `pi-roundtable` | value |
|
|
4575
|
+
| `STEERED_MARK` | `pi-roundtable` | value |
|
|
4569
4576
|
| `QUESTIONNAIRE_LIMITS` | `pi-roundtable` | value |
|
|
4570
4577
|
| `Questionnaire` | `pi-roundtable` | type |
|
|
4571
4578
|
| `QuestionnaireAnswer` | `pi-roundtable` | type |
|
package/package.json
CHANGED
|
@@ -109,6 +109,31 @@ export interface InboundMessage {
|
|
|
109
109
|
export const STEERED_MARK = "↪️";
|
|
110
110
|
export const QUEUED_MARK = "⏳";
|
|
111
111
|
|
|
112
|
+
/** A message waiting in a channel's queue for its own turn (`ConversationPort.queued`). */
|
|
113
|
+
export interface QueuedMessage {
|
|
114
|
+
/** The surface's id of the message, as `InboundMessage.messageId`. */
|
|
115
|
+
messageId: string;
|
|
116
|
+
/** The principal of the person who wrote it; absent when the router resolved none. */
|
|
117
|
+
principalId?: string;
|
|
118
|
+
/** What its turn will use: the text as last edited. */
|
|
119
|
+
text: string;
|
|
120
|
+
/** Epoch milliseconds when it was queued. */
|
|
121
|
+
queuedAt: number;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Who asks to change a queued message; only its author may. */
|
|
125
|
+
export interface QueuedBy {
|
|
126
|
+
principalId: string;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* What cancelling or editing a queued message came to. `unknown`: none is queued under that id,
|
|
131
|
+
* such as one whose turn has started, been cancelled, or been steered into the running turn.
|
|
132
|
+
* `forbidden`: it is queued, but not by that principal. `refused` (edit only): the claim would no
|
|
133
|
+
* longer take the message with the new text.
|
|
134
|
+
*/
|
|
135
|
+
export type QueuedChange = "done" | "unknown" | "forbidden" | "refused";
|
|
136
|
+
|
|
112
137
|
/** How a turn nobody wrote in the channel ended. */
|
|
113
138
|
export type ScheduledOutcome =
|
|
114
139
|
| { status: "ran" }
|
|
@@ -205,8 +230,8 @@ export type Admission =
|
|
|
205
230
|
run(): Promise<void>;
|
|
206
231
|
/**
|
|
207
232
|
* Called instead of `run` when the router drops the message the claim admitted, because
|
|
208
|
-
* its author, once recorded, was not who they were when admitted
|
|
209
|
-
* holds for it.
|
|
233
|
+
* its author, once recorded, was not who they were when admitted, or because it was
|
|
234
|
+
* cancelled or replaced by an edit while it waited; frees what the claim holds for it.
|
|
210
235
|
*/
|
|
211
236
|
dropped?(): void;
|
|
212
237
|
/** The log line when the turn throws. */
|
|
@@ -327,6 +352,34 @@ export interface ConversationPort {
|
|
|
327
352
|
): Promise<void>;
|
|
328
353
|
/** Tells the claim that owns the channel a message was edited (`ChannelClaim.onMessageEdited`); never rejects. */
|
|
329
354
|
messageEdited?(message: InboundMessage): Promise<void>;
|
|
355
|
+
/**
|
|
356
|
+
* The messages waiting in the channel for their own turn, oldest first: a message sent during
|
|
357
|
+
* a turn that the running turn did not take (`QUEUED_MARK`). One that started, was steered
|
|
358
|
+
* into the running turn, or was cancelled is not listed. Optional so a port written before 0.28
|
|
359
|
+
* still type-checks; the host's has it.
|
|
360
|
+
*/
|
|
361
|
+
queued?(channel: ChannelKey): readonly QueuedMessage[];
|
|
362
|
+
/**
|
|
363
|
+
* Cancels a queued message so its turn never starts, for its author only. `unknown` once the
|
|
364
|
+
* turn has started, so a caller can tell the person it is too late. The surface is told only
|
|
365
|
+
* through the message's `QUEUED_MARK` being removed.
|
|
366
|
+
*/
|
|
367
|
+
cancelQueued?(
|
|
368
|
+
channel: ChannelKey,
|
|
369
|
+
messageId: string,
|
|
370
|
+
by: QueuedBy,
|
|
371
|
+
): QueuedChange;
|
|
372
|
+
/**
|
|
373
|
+
* Replaces the text a queued message's turn will use, for its author only: the claim that
|
|
374
|
+
* admitted the message admits it again with the new text, and the turn runs on that. The
|
|
375
|
+
* surface's own copy of the message is the caller's to change.
|
|
376
|
+
*/
|
|
377
|
+
editQueued?(
|
|
378
|
+
channel: ChannelKey,
|
|
379
|
+
messageId: string,
|
|
380
|
+
by: QueuedBy,
|
|
381
|
+
text: string,
|
|
382
|
+
): QueuedChange;
|
|
330
383
|
}
|
|
331
384
|
|
|
332
385
|
/**
|
|
@@ -274,6 +274,10 @@ export class Linkage {
|
|
|
274
274
|
messageDeleted: (channel, id, space) =>
|
|
275
275
|
router().messageDeleted(channel, id, space),
|
|
276
276
|
messageEdited: (message) => router().messageEdited(message),
|
|
277
|
+
queued: (channel) => router().queued(channel),
|
|
278
|
+
cancelQueued: (channel, id, by) => router().cancelQueued(channel, id, by),
|
|
279
|
+
editQueued: (channel, id, by, text) =>
|
|
280
|
+
router().editQueued(channel, id, by, text),
|
|
277
281
|
background: (turn) => router().background(turn),
|
|
278
282
|
runsAs: (turn) => router().runsAs(turn),
|
|
279
283
|
target: (name) => router().target(name),
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { InboundMessage } from "../contract/channels.ts";
|
|
2
|
+
import { parseChannelKey } from "../contract/surface.ts";
|
|
3
|
+
import type { ActorFacts } from "../identity/actor-facts.ts";
|
|
4
|
+
import type { Logger } from "../log.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Who wrote the message, as its surface reports them; a surface that reports no `actor` has it
|
|
8
|
+
* read from the author fields under its own name, once warned (`warned` holds the surfaces
|
|
9
|
+
* already warned). None for a bot or an integration, unless the host lists the bot as a peer.
|
|
10
|
+
*/
|
|
11
|
+
export function actorOf(
|
|
12
|
+
message: InboundMessage,
|
|
13
|
+
warned: Set<string>,
|
|
14
|
+
logger: Logger,
|
|
15
|
+
): ActorFacts | undefined {
|
|
16
|
+
if ((message.authorIsBot && !message.peerBot) || message.integration)
|
|
17
|
+
return undefined;
|
|
18
|
+
if (message.actor) return message.actor;
|
|
19
|
+
const { surface } = parseChannelKey(message.channel);
|
|
20
|
+
if (!warned.has(surface)) {
|
|
21
|
+
warned.add(surface);
|
|
22
|
+
logger.warn(
|
|
23
|
+
{ surface },
|
|
24
|
+
`deprecated: the ${surface} surface reports no InboundMessage.actor, so its authors are read from authorId, authorName, and authorRoleIds as ${surface} identities; set actor, as this goes away in 1.0`,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
provider: surface,
|
|
29
|
+
subject: message.authorId,
|
|
30
|
+
name: message.authorName,
|
|
31
|
+
surface,
|
|
32
|
+
...(message.authorRoleIds
|
|
33
|
+
? {
|
|
34
|
+
roles: message.authorRoleIds.map((role) => `${surface}:role:${role}`),
|
|
35
|
+
}
|
|
36
|
+
: {}),
|
|
37
|
+
...(message.space ? { space: message.space } : {}),
|
|
38
|
+
legacyId: message.authorId,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { BackgroundRunsAs, BackgroundTurn } from "../contract/channels.ts";
|
|
2
|
+
import { IdentityError } from "../domain/errors.ts";
|
|
3
|
+
import {
|
|
4
|
+
type IdentityService,
|
|
5
|
+
systemSpeaker,
|
|
6
|
+
} from "../identity/identity-service.ts";
|
|
7
|
+
import { SYSTEM_PRINCIPAL } from "../identity/principal-store.ts";
|
|
8
|
+
import { isSystemTurn } from "./system-turns.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Who a background turn runs as, before its claim has the last word: the system principal for
|
|
12
|
+
* the host's own turn, at its tier; otherwise its author's principal, which the identity service
|
|
13
|
+
* checks and caps at the lower of the turn's tier and theirs. Why not, when it may not run;
|
|
14
|
+
* throws when the identity service cannot tell.
|
|
15
|
+
*/
|
|
16
|
+
export async function speakerOf(
|
|
17
|
+
turn: BackgroundTurn,
|
|
18
|
+
principals: Pick<IdentityService, "speakerFor"> | undefined,
|
|
19
|
+
): Promise<BackgroundRunsAs> {
|
|
20
|
+
const { author, tier } = turn;
|
|
21
|
+
if (!author?.principalId || !tier)
|
|
22
|
+
return {
|
|
23
|
+
skipped:
|
|
24
|
+
"a background turn names its author's principal (author.principalId) and the tier it runs at (tier); this one does not",
|
|
25
|
+
};
|
|
26
|
+
if (author.principalId === SYSTEM_PRINCIPAL)
|
|
27
|
+
return isSystemTurn(turn)
|
|
28
|
+
? { speaker: { ...systemSpeaker(tier), name: author.name } }
|
|
29
|
+
: {
|
|
30
|
+
skipped: `only the host itself starts a turn as the system principal "${SYSTEM_PRINCIPAL}"`,
|
|
31
|
+
};
|
|
32
|
+
if (!principals)
|
|
33
|
+
return {
|
|
34
|
+
skipped: "the host has no identity service to check whom it runs as",
|
|
35
|
+
};
|
|
36
|
+
try {
|
|
37
|
+
const checked = await principals.speakerFor(author.principalId, tier);
|
|
38
|
+
return {
|
|
39
|
+
speaker: {
|
|
40
|
+
id: author.id,
|
|
41
|
+
name: author.name,
|
|
42
|
+
tier: checked.tier,
|
|
43
|
+
principalId: checked.principalId,
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if (!(error instanceof IdentityError)) throw error;
|
|
48
|
+
return { skipped: error.message };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -7,28 +7,27 @@ import {
|
|
|
7
7
|
type ConversationPort,
|
|
8
8
|
type InboundMessage,
|
|
9
9
|
QUEUED_MARK,
|
|
10
|
+
type QueuedBy,
|
|
11
|
+
type QueuedChange,
|
|
12
|
+
type QueuedMessage,
|
|
10
13
|
type ScheduledOutcome,
|
|
11
14
|
STEERED_MARK,
|
|
12
15
|
} from "../contract/channels.ts";
|
|
13
|
-
import {
|
|
14
|
-
import { IdentityError } from "../domain/errors.ts";
|
|
16
|
+
import type { SurfacePort } from "../contract/surface.ts";
|
|
15
17
|
import { HostStoppingError, PluginError } from "../errors.ts";
|
|
16
18
|
import { messages } from "../i18n/index.ts";
|
|
17
|
-
import type { ActorFacts } from "../identity/actor-facts.ts";
|
|
18
19
|
import type { Contact, ContactAssessor } from "../identity/contact.ts";
|
|
19
|
-
import {
|
|
20
|
-
type IdentityService,
|
|
21
|
-
systemSpeaker,
|
|
22
|
-
} from "../identity/identity-service.ts";
|
|
23
|
-
import { SYSTEM_PRINCIPAL } from "../identity/principal-store.ts";
|
|
20
|
+
import type { IdentityService } from "../identity/identity-service.ts";
|
|
24
21
|
import type { Logger } from "../log.ts";
|
|
25
22
|
import type { ChannelKey } from "../sessions.ts";
|
|
23
|
+
import { actorOf } from "./actor-of.ts";
|
|
24
|
+
import { speakerOf } from "./background-speaker.ts";
|
|
26
25
|
import type { ChannelQueue } from "./channel-queue.ts";
|
|
27
26
|
import { tellDeleted, tellEdited } from "./claim-events.ts";
|
|
28
27
|
import { ForwardJoin } from "./forward-join.ts";
|
|
29
28
|
import type { MessageObservers } from "./message-observers.ts";
|
|
30
29
|
import { type PeerBots, peerFacts } from "./peer-bots.ts";
|
|
31
|
-
import {
|
|
30
|
+
import { type QueuedEntry, QueuedMessages } from "./queued-messages.ts";
|
|
32
31
|
|
|
33
32
|
export interface ChannelRouterOptions {
|
|
34
33
|
/** Claims in registration order; the router orders them by priority. */
|
|
@@ -82,6 +81,8 @@ export class ChannelRouter implements ConversationPort {
|
|
|
82
81
|
readonly #options: ChannelRouterOptions;
|
|
83
82
|
readonly #claims: readonly ChannelClaim[];
|
|
84
83
|
readonly #forwards: ForwardJoin;
|
|
84
|
+
/** The messages waiting for their own turn, so one can be cancelled or edited until it starts. */
|
|
85
|
+
readonly #queued: QueuedMessages;
|
|
85
86
|
/** Each channel's messages being admitted, so they reach their claim and the queue in the order they came. */
|
|
86
87
|
readonly #admitting = new Map<ChannelKey, Promise<unknown>>();
|
|
87
88
|
/** The surfaces already warned that their messages carry no actor. */
|
|
@@ -93,6 +94,7 @@ export class ChannelRouter implements ConversationPort {
|
|
|
93
94
|
this.#options = options;
|
|
94
95
|
this.#claims = orderClaims(options.claims);
|
|
95
96
|
this.#forwards = new ForwardJoin(options.forwardJoinMs);
|
|
97
|
+
this.#queued = new QueuedMessages(options.logger);
|
|
96
98
|
}
|
|
97
99
|
|
|
98
100
|
#owner(channel: ChannelKey, space?: string): ChannelClaim | undefined {
|
|
@@ -137,15 +139,16 @@ export class ChannelRouter implements ConversationPort {
|
|
|
137
139
|
);
|
|
138
140
|
return undefined;
|
|
139
141
|
}
|
|
140
|
-
const
|
|
141
|
-
if (!
|
|
142
|
+
const admitted = await this.#admit(claim, message);
|
|
143
|
+
if (!admitted) return undefined;
|
|
144
|
+
const { admission } = admitted;
|
|
142
145
|
if (listed) peers?.answered(message.channel);
|
|
143
146
|
// Once the host shuts down only the turns already running finish: this one starts nothing.
|
|
144
147
|
if (this.#options.queue.closed) {
|
|
145
148
|
await this.#refuse(message, admission);
|
|
146
149
|
return undefined;
|
|
147
150
|
}
|
|
148
|
-
return this.#start(message, admission);
|
|
151
|
+
return this.#start(claim, admitted.message, admission);
|
|
149
152
|
});
|
|
150
153
|
await started?.done;
|
|
151
154
|
}
|
|
@@ -177,10 +180,10 @@ export class ChannelRouter implements ConversationPort {
|
|
|
177
180
|
async #admit(
|
|
178
181
|
claim: ChannelClaim,
|
|
179
182
|
message: InboundMessage,
|
|
180
|
-
): Promise<Admission | undefined> {
|
|
183
|
+
): Promise<{ admission: Admission; message: InboundMessage } | undefined> {
|
|
181
184
|
const { logger, contacts } = this.#options;
|
|
182
185
|
const { speaker: _given, ...facts } = message;
|
|
183
|
-
const actor =
|
|
186
|
+
const actor = actorOf(message, this.#warned, logger);
|
|
184
187
|
let contact: Contact | undefined;
|
|
185
188
|
if (actor && contacts)
|
|
186
189
|
try {
|
|
@@ -193,15 +196,16 @@ export class ChannelRouter implements ConversationPort {
|
|
|
193
196
|
"could not resolve who wrote a message; it reaches its claim as no one",
|
|
194
197
|
);
|
|
195
198
|
}
|
|
196
|
-
const
|
|
197
|
-
contact
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
);
|
|
201
|
-
if (!
|
|
199
|
+
const admittedAs: InboundMessage = contact
|
|
200
|
+
? { ...facts, speaker: Object.freeze({ ...contact.speaker }) }
|
|
201
|
+
: facts;
|
|
202
|
+
const admission = claim.admit(admittedAs);
|
|
203
|
+
if (!admission) return undefined;
|
|
204
|
+
if (!contact) return { admission, message: admittedAs };
|
|
202
205
|
try {
|
|
203
206
|
const taken = await contact.take();
|
|
204
|
-
if (taken?.principalId === contact.speaker.principalId)
|
|
207
|
+
if (taken?.principalId === contact.speaker.principalId)
|
|
208
|
+
return { admission, message: admittedAs };
|
|
205
209
|
logger.warn(
|
|
206
210
|
{ channel: message.channel },
|
|
207
211
|
"the author of a message was linked elsewhere, or refused, while it was admitted; it does not run",
|
|
@@ -221,40 +225,6 @@ export class ChannelRouter implements ConversationPort {
|
|
|
221
225
|
return undefined;
|
|
222
226
|
}
|
|
223
227
|
|
|
224
|
-
/**
|
|
225
|
-
* Who wrote the message, as its surface reports them; a surface that reports no `actor` has it
|
|
226
|
-
* read from the author fields under its own name, once warned. None for a bot or an
|
|
227
|
-
* integration, unless the host lists the bot as a peer.
|
|
228
|
-
*/
|
|
229
|
-
#actorOf(message: InboundMessage): ActorFacts | undefined {
|
|
230
|
-
if ((message.authorIsBot && !message.peerBot) || message.integration)
|
|
231
|
-
return undefined;
|
|
232
|
-
if (message.actor) return message.actor;
|
|
233
|
-
const { surface } = parseChannelKey(message.channel);
|
|
234
|
-
if (!this.#warned.has(surface)) {
|
|
235
|
-
this.#warned.add(surface);
|
|
236
|
-
this.#options.logger.warn(
|
|
237
|
-
{ surface },
|
|
238
|
-
`deprecated: the ${surface} surface reports no InboundMessage.actor, so its authors are read from authorId, authorName, and authorRoleIds as ${surface} identities; set actor, as this goes away in 1.0`,
|
|
239
|
-
);
|
|
240
|
-
}
|
|
241
|
-
return {
|
|
242
|
-
provider: surface,
|
|
243
|
-
subject: message.authorId,
|
|
244
|
-
name: message.authorName,
|
|
245
|
-
surface,
|
|
246
|
-
...(message.authorRoleIds
|
|
247
|
-
? {
|
|
248
|
-
roles: message.authorRoleIds.map(
|
|
249
|
-
(role) => `${surface}:role:${role}`,
|
|
250
|
-
),
|
|
251
|
-
}
|
|
252
|
-
: {}),
|
|
253
|
-
...(message.space ? { space: message.space } : {}),
|
|
254
|
-
legacyId: message.authorId,
|
|
255
|
-
};
|
|
256
|
-
}
|
|
257
|
-
|
|
258
228
|
/**
|
|
259
229
|
* Drops a message the host no longer starts a turn for because it is shutting down, and tells
|
|
260
230
|
* its channel once that it restarts; no one is told for a bot's or an integration's message, such
|
|
@@ -287,6 +257,7 @@ export class ChannelRouter implements ConversationPort {
|
|
|
287
257
|
* during a turn is first offered to it, then marked as waiting.
|
|
288
258
|
*/
|
|
289
259
|
async #start(
|
|
260
|
+
claim: ChannelClaim,
|
|
290
261
|
message: InboundMessage,
|
|
291
262
|
admission: Admission,
|
|
292
263
|
): Promise<{ done: Promise<void> }> {
|
|
@@ -298,39 +269,71 @@ export class ChannelRouter implements ConversationPort {
|
|
|
298
269
|
return { done };
|
|
299
270
|
}
|
|
300
271
|
const { busy } = admission;
|
|
301
|
-
let
|
|
272
|
+
let waiting: QueuedEntry | undefined;
|
|
302
273
|
// A message during a turn is added to it when the turn takes it, otherwise marked as waiting.
|
|
303
274
|
if (busy && queue.size(message.channel) > 0) {
|
|
304
275
|
if (await busy.steer?.()) return { done: busy.react(STEERED_MARK) };
|
|
276
|
+
// Listed before the mark is placed, so a delete or cancel during that wait reaches it.
|
|
277
|
+
waiting = this.#queued.add(claim, message, admission);
|
|
305
278
|
await busy.react(QUEUED_MARK);
|
|
306
|
-
|
|
279
|
+
if (waiting.cancelled) return { done: Promise.resolve() };
|
|
307
280
|
}
|
|
308
281
|
const done = queue
|
|
309
282
|
.run(message.channel, () => {
|
|
310
|
-
if (
|
|
311
|
-
|
|
283
|
+
if (!waiting) return admission.run();
|
|
284
|
+
// From here no cancel or edit reaches it: the turn has started or it was cancelled before.
|
|
285
|
+
if (!this.#queued.remove(waiting)) return Promise.resolve();
|
|
286
|
+
this.#queued.unmark(waiting);
|
|
287
|
+
return waiting.admission.run();
|
|
312
288
|
})
|
|
313
289
|
// pi-lens-ignore: no-unknown-parameters
|
|
314
290
|
.catch(async (error: unknown) => {
|
|
315
291
|
// The host began shutting down while this message waited behind a running turn.
|
|
316
292
|
if (error instanceof HostStoppingError) {
|
|
317
|
-
if (queued)
|
|
318
|
-
|
|
293
|
+
if (waiting && !this.#queued.remove(waiting)) return undefined;
|
|
294
|
+
if (waiting) this.#queued.unmark(waiting);
|
|
295
|
+
return this.#refuse(
|
|
296
|
+
waiting?.message ?? message,
|
|
297
|
+
waiting?.admission ?? admission,
|
|
298
|
+
);
|
|
319
299
|
}
|
|
320
300
|
logger.error(
|
|
321
301
|
{ channel: message.channel, err: error },
|
|
322
|
-
admission.failure,
|
|
302
|
+
(waiting?.admission ?? admission).failure,
|
|
323
303
|
);
|
|
324
304
|
});
|
|
325
305
|
return { done };
|
|
326
306
|
}
|
|
327
307
|
|
|
308
|
+
queued(channel: ChannelKey): readonly QueuedMessage[] {
|
|
309
|
+
return this.#queued.list(channel);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
cancelQueued(
|
|
313
|
+
channel: ChannelKey,
|
|
314
|
+
messageId: string,
|
|
315
|
+
by: QueuedBy,
|
|
316
|
+
): QueuedChange {
|
|
317
|
+
return this.#queued.cancel(channel, messageId, by, true);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
editQueued(
|
|
321
|
+
channel: ChannelKey,
|
|
322
|
+
messageId: string,
|
|
323
|
+
by: QueuedBy,
|
|
324
|
+
text: string,
|
|
325
|
+
): QueuedChange {
|
|
326
|
+
return this.#queued.edit(channel, messageId, by, text);
|
|
327
|
+
}
|
|
328
|
+
|
|
328
329
|
/** Tells the claim that owns the channel a message was deleted; never rejects, never waits for the channel's queue. */
|
|
329
330
|
messageDeleted(
|
|
330
331
|
channel: ChannelKey,
|
|
331
332
|
messageId: string,
|
|
332
333
|
space?: string,
|
|
333
334
|
): Promise<void> {
|
|
335
|
+
// The surface says it is gone: no author check, and no mark to remove from a deleted message.
|
|
336
|
+
this.#queued.cancel(channel, messageId, undefined, false);
|
|
334
337
|
return tellDeleted(
|
|
335
338
|
this.#owner(channel, space),
|
|
336
339
|
channel,
|
|
@@ -341,6 +344,13 @@ export class ChannelRouter implements ConversationPort {
|
|
|
341
344
|
|
|
342
345
|
/** Tells the claim that owns the channel a message was edited; never rejects, never waits for the channel's queue. */
|
|
343
346
|
messageEdited(message: InboundMessage): Promise<void> {
|
|
347
|
+
// Only the author edits a message, so a queued one takes the new text without a check.
|
|
348
|
+
this.#queued.edit(
|
|
349
|
+
message.channel,
|
|
350
|
+
message.messageId,
|
|
351
|
+
undefined,
|
|
352
|
+
message.text,
|
|
353
|
+
);
|
|
344
354
|
return tellEdited(
|
|
345
355
|
this.#owner(message.channel, message.space),
|
|
346
356
|
message,
|
|
@@ -392,7 +402,7 @@ export class ChannelRouter implements ConversationPort {
|
|
|
392
402
|
* service cannot tell.
|
|
393
403
|
*/
|
|
394
404
|
async runsAs(turn: BackgroundTurn): Promise<BackgroundRunsAs> {
|
|
395
|
-
const runs = await
|
|
405
|
+
const runs = await speakerOf(turn, this.#options.principals);
|
|
396
406
|
if ("skipped" in runs) return runs;
|
|
397
407
|
// The claim that owns the channel has the last word, so work that must not start for a turn it
|
|
398
408
|
// would skip, such as a schedule's precheck, does not.
|
|
@@ -403,40 +413,6 @@ export class ChannelRouter implements ConversationPort {
|
|
|
403
413
|
return refusal === undefined ? runs : { skipped: refusal };
|
|
404
414
|
}
|
|
405
415
|
|
|
406
|
-
async #speakerOf(turn: BackgroundTurn): Promise<BackgroundRunsAs> {
|
|
407
|
-
const { author, tier } = turn;
|
|
408
|
-
if (!author?.principalId || !tier)
|
|
409
|
-
return {
|
|
410
|
-
skipped:
|
|
411
|
-
"a background turn names its author's principal (author.principalId) and the tier it runs at (tier); this one does not",
|
|
412
|
-
};
|
|
413
|
-
if (author.principalId === SYSTEM_PRINCIPAL)
|
|
414
|
-
return isSystemTurn(turn)
|
|
415
|
-
? { speaker: { ...systemSpeaker(tier), name: author.name } }
|
|
416
|
-
: {
|
|
417
|
-
skipped: `only the host itself starts a turn as the system principal "${SYSTEM_PRINCIPAL}"`,
|
|
418
|
-
};
|
|
419
|
-
const { principals } = this.#options;
|
|
420
|
-
if (!principals)
|
|
421
|
-
return {
|
|
422
|
-
skipped: "the host has no identity service to check whom it runs as",
|
|
423
|
-
};
|
|
424
|
-
try {
|
|
425
|
-
const checked = await principals.speakerFor(author.principalId, tier);
|
|
426
|
-
return {
|
|
427
|
-
speaker: {
|
|
428
|
-
id: author.id,
|
|
429
|
-
name: author.name,
|
|
430
|
-
tier: checked.tier,
|
|
431
|
-
principalId: checked.principalId,
|
|
432
|
-
},
|
|
433
|
-
};
|
|
434
|
-
} catch (error) {
|
|
435
|
-
if (!(error instanceof IdentityError)) throw error;
|
|
436
|
-
return { skipped: error.message };
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
|
|
440
416
|
startFresh(channel: ChannelKey): Promise<string> {
|
|
441
417
|
return this.#options.queue.run(channel, async () => {
|
|
442
418
|
const claim = this.#owner(channel);
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Admission,
|
|
3
|
+
type ChannelClaim,
|
|
4
|
+
type InboundMessage,
|
|
5
|
+
QUEUED_MARK,
|
|
6
|
+
type QueuedBy,
|
|
7
|
+
type QueuedChange,
|
|
8
|
+
type QueuedMessage,
|
|
9
|
+
} from "../contract/channels.ts";
|
|
10
|
+
import type { Logger } from "../log.ts";
|
|
11
|
+
import type { ChannelKey } from "../sessions.ts";
|
|
12
|
+
|
|
13
|
+
type TurnAdmission = Extract<Admission, { kind: "turn" }>;
|
|
14
|
+
|
|
15
|
+
/** A message waiting for its turn, with what runs it; `admission` and `message` follow its edits. */
|
|
16
|
+
export interface QueuedEntry {
|
|
17
|
+
readonly claim: ChannelClaim;
|
|
18
|
+
/** The message as the claim last admitted it: the speaker the router resolved, the text as edited. */
|
|
19
|
+
message: InboundMessage;
|
|
20
|
+
admission: TurnAdmission;
|
|
21
|
+
readonly queuedAt: number;
|
|
22
|
+
/** Cancelled while it waited: its turn does not run. */
|
|
23
|
+
cancelled: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The messages waiting for their own turn, by channel and message id: the router adds one when
|
|
28
|
+
* the running turn did not take it and takes it out when its turn starts, so a cancel or an edit
|
|
29
|
+
* can only reach a message that has not started. Everything is synchronous, so no cancel or edit
|
|
30
|
+
* races the start of a turn: whichever runs first wins.
|
|
31
|
+
*/
|
|
32
|
+
export class QueuedMessages {
|
|
33
|
+
readonly #entries = new Map<ChannelKey, Map<string, QueuedEntry>>();
|
|
34
|
+
readonly #logger: Logger;
|
|
35
|
+
readonly #now: () => number;
|
|
36
|
+
|
|
37
|
+
constructor(logger: Logger, now: () => number = Date.now) {
|
|
38
|
+
this.#logger = logger;
|
|
39
|
+
this.#now = now;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
add(
|
|
43
|
+
claim: ChannelClaim,
|
|
44
|
+
message: InboundMessage,
|
|
45
|
+
admission: TurnAdmission,
|
|
46
|
+
): QueuedEntry {
|
|
47
|
+
const entry: QueuedEntry = {
|
|
48
|
+
claim,
|
|
49
|
+
message,
|
|
50
|
+
admission,
|
|
51
|
+
queuedAt: this.#now(),
|
|
52
|
+
cancelled: false,
|
|
53
|
+
};
|
|
54
|
+
const channel = this.#entries.get(message.channel) ?? new Map();
|
|
55
|
+
channel.set(message.messageId, entry);
|
|
56
|
+
this.#entries.set(message.channel, channel);
|
|
57
|
+
return entry;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Takes the entry out of the queue's books as its turn starts or the host drops it; false when already out. */
|
|
61
|
+
remove(entry: QueuedEntry): boolean {
|
|
62
|
+
const { channel, messageId } = entry.message;
|
|
63
|
+
const held = this.#entries.get(channel);
|
|
64
|
+
if (held?.get(messageId) !== entry) return false;
|
|
65
|
+
held.delete(messageId);
|
|
66
|
+
if (held.size === 0) this.#entries.delete(channel);
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
list(channel: ChannelKey): readonly QueuedMessage[] {
|
|
71
|
+
return [...(this.#entries.get(channel)?.values() ?? [])].map((entry) => ({
|
|
72
|
+
messageId: entry.message.messageId,
|
|
73
|
+
...(entry.message.speaker
|
|
74
|
+
? { principalId: entry.message.speaker.principalId }
|
|
75
|
+
: {}),
|
|
76
|
+
text: entry.message.text,
|
|
77
|
+
queuedAt: entry.queuedAt,
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Cancels the message so its turn never starts. `by` is checked against its author, and absent
|
|
83
|
+
* when the surface itself says the message is gone, such as a deleted one. `unmark` removes the
|
|
84
|
+
* waiting mark from the message, which a deleted message cannot take.
|
|
85
|
+
*/
|
|
86
|
+
cancel(
|
|
87
|
+
channel: ChannelKey,
|
|
88
|
+
messageId: string,
|
|
89
|
+
by: QueuedBy | undefined,
|
|
90
|
+
unmark: boolean,
|
|
91
|
+
): QueuedChange {
|
|
92
|
+
const entry = this.#entries.get(channel)?.get(messageId);
|
|
93
|
+
if (!entry) return "unknown";
|
|
94
|
+
if (by && !this.#authored(entry, by)) return "forbidden";
|
|
95
|
+
this.remove(entry);
|
|
96
|
+
entry.cancelled = true;
|
|
97
|
+
entry.admission.dropped?.();
|
|
98
|
+
if (unmark) this.unmark(entry);
|
|
99
|
+
return "done";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Gives the message's turn the new text: the claim admits the message again with it. When the
|
|
104
|
+
* claim no longer takes it, the old admission stays and the edit is refused.
|
|
105
|
+
*/
|
|
106
|
+
edit(
|
|
107
|
+
channel: ChannelKey,
|
|
108
|
+
messageId: string,
|
|
109
|
+
by: QueuedBy | undefined,
|
|
110
|
+
text: string,
|
|
111
|
+
): QueuedChange {
|
|
112
|
+
const entry = this.#entries.get(channel)?.get(messageId);
|
|
113
|
+
if (!entry) return "unknown";
|
|
114
|
+
if (by && !this.#authored(entry, by)) return "forbidden";
|
|
115
|
+
if (entry.message.text === text) return "done";
|
|
116
|
+
const message = { ...entry.message, text };
|
|
117
|
+
let next: Admission | undefined;
|
|
118
|
+
try {
|
|
119
|
+
next = entry.claim.admit(message);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
this.#logger.warn(
|
|
122
|
+
{ channel, err: error },
|
|
123
|
+
"a claim failed to admit an edited queued message; the old text stays",
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
if (next?.kind !== "turn") return "refused";
|
|
127
|
+
const old = entry.admission;
|
|
128
|
+
entry.message = message;
|
|
129
|
+
entry.admission = next;
|
|
130
|
+
old.dropped?.();
|
|
131
|
+
return "done";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Removes the waiting mark; a failure is logged, since the message may be gone. */
|
|
135
|
+
unmark(entry: QueuedEntry): void {
|
|
136
|
+
const { channel } = entry.message;
|
|
137
|
+
entry.admission.busy
|
|
138
|
+
?.unreact(QUEUED_MARK)
|
|
139
|
+
.catch((error: unknown) =>
|
|
140
|
+
this.#logger.warn(
|
|
141
|
+
{ channel, err: error },
|
|
142
|
+
"the waiting mark of a queued message was not removed",
|
|
143
|
+
),
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
#authored(entry: QueuedEntry, by: QueuedBy): boolean {
|
|
148
|
+
const author = entry.message.speaker?.principalId;
|
|
149
|
+
return author !== undefined && author === by.principalId;
|
|
150
|
+
}
|
|
151
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -93,9 +93,13 @@ export type {
|
|
|
93
93
|
InboundMessage,
|
|
94
94
|
MessageObserver,
|
|
95
95
|
ObserveFilter,
|
|
96
|
+
QueuedBy,
|
|
97
|
+
QueuedChange,
|
|
98
|
+
QueuedMessage,
|
|
96
99
|
QueuePort,
|
|
97
100
|
ScheduledOutcome,
|
|
98
101
|
} from "./core/contract/channels.ts";
|
|
102
|
+
export { QUEUED_MARK, STEERED_MARK } from "./core/contract/channels.ts";
|
|
99
103
|
export type {
|
|
100
104
|
ImageDrawer,
|
|
101
105
|
Judge,
|