pi-roundtable 0.28.0 → 0.29.1

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.
@@ -1,13 +1,13 @@
1
1
  import type { SurfacePort } from "../contract/surface.ts";
2
2
  import type { ChannelKey } from "../domain/conversation.ts";
3
- import type { Notifier } from "../domain/ports.ts";
4
3
  import type { Logger } from "../log.ts";
5
- import { splitReply } from "../presentation/reply-splitter.ts";
4
+ import { deliveryRouter } from "./delivery-router.ts";
6
5
 
7
6
  /**
8
7
  * A way to reach a person on their own, such as a chat network's direct messages or a web inbox.
9
- * A plugin contributes it as `directChannels`; the host reaches a person through the first one,
10
- * in contribution order, that reaches them.
8
+ * A plugin contributes it as `directChannels`; the host reaches a person for a piece of work
9
+ * through a provider on the surface the work came from, and through another only when none there
10
+ * reaches them (`DeliveryRouter`).
11
11
  */
12
12
  export interface DirectChannelProvider {
13
13
  /** Unique across every plugin, such as `"discord"`. */
@@ -17,6 +17,12 @@ export interface DirectChannelProvider {
17
17
  * `"a direct message on Discord"`.
18
18
  */
19
19
  label: string;
20
+ /**
21
+ * The surface whose conversations it reaches, the key prefix of the channels `reaches` names,
22
+ * such as `"discord"` or the web app's `"app"`; work from a conversation on that surface reaches
23
+ * the person here first. Without it, its `name`.
24
+ */
25
+ surface?: string;
20
26
  /**
21
27
  * The person's own conversation here, where a notice to them is posted and where their
22
28
  * schedules and delegated reports from a conversation no chat surface carries run; undefined
@@ -38,8 +44,8 @@ export interface DirectChannelProvider {
38
44
  /**
39
45
  * Called with the notice after another provider delivered it, so this one can echo it, such as
40
46
  * a web app pushing a prompt to a phone that Discord's direct message already reached. It runs
41
- * only after a delivery, on every provider but the one that delivered; its failure is logged
42
- * and never changes what `notify` returns.
47
+ * only after a delivery, on every provider but the one that delivered, and only ever to the same
48
+ * person; its failure is logged and never changes what `notify` returns.
43
49
  */
44
50
  mirror?(principalId: string, text: string): Promise<void>;
45
51
  }
@@ -51,24 +57,36 @@ export interface DirectReach {
51
57
  }
52
58
 
53
59
  /**
54
- * Every plugin's direct channels, as plugins and the core use them. Each call before the host
55
- * links its plugins throws NotLinkedError, as for `conversations`.
60
+ * Every plugin's direct channels, as plugins and the core use them. Each lookup takes the origin of
61
+ * the work, the conversation it came from, and goes through the delivery router: a provider on the
62
+ * origin's surface first, another surface only when none there reaches the person. Each call before
63
+ * the host links its plugins throws NotLinkedError, as for `conversations`.
56
64
  */
57
- export interface DirectChannels extends Notifier {
65
+ export interface DirectChannels {
58
66
  /** Every contributed provider, in contribution order; empty when no plugin contributes one. */
59
67
  providers(): readonly DirectChannelProvider[];
60
- /** The person's own conversation on the first provider that reaches them; undefined when none does. */
61
- reach(principalId: string): Promise<DirectReach | undefined>;
68
+ /** The person's own conversation for work from `origin`; undefined when no provider reaches them. */
69
+ reach(
70
+ origin: ChannelKey,
71
+ principalId: string,
72
+ ): Promise<DirectReach | undefined>;
62
73
  /**
63
- * The first provider that knows the person, as `DirectChannelProvider.knows` tells without the
64
- * network (or `reaches`, for a provider without it); undefined when none does.
74
+ * The provider that knows the person for work from `origin`, as `DirectChannelProvider.knows`
75
+ * tells without the network (or `reaches`, for a provider without it); undefined when none does.
65
76
  */
66
- known(principalId: string): Promise<DirectChannelProvider | undefined>;
77
+ known(
78
+ origin: ChannelKey,
79
+ principalId: string,
80
+ ): Promise<DirectChannelProvider | undefined>;
67
81
  /**
68
- * Sends the person `text` through the first provider that reaches them; false when none does.
69
- * A provider's failure, such as a network error, rejects.
82
+ * Sends the person `text` for work from `origin`, on its surface when a provider there reaches
83
+ * them; false when none anywhere does. A provider's failure, such as a network error, rejects.
70
84
  */
71
- notify(principalId: string, text: string): Promise<boolean>;
85
+ notify(
86
+ origin: ChannelKey,
87
+ principalId: string,
88
+ text: string,
89
+ ): Promise<boolean>;
72
90
  }
73
91
 
74
92
  /** The direct channels over the providers the host linked, posting through its surfaces where a provider does not deliver itself. */
@@ -77,58 +95,11 @@ export function directChannelsPort(
77
95
  surfaces: Pick<SurfacePort, "sendReply">,
78
96
  logger?: Pick<Logger, "warn">,
79
97
  ): DirectChannels {
80
- const reach = async (
81
- principalId: string,
82
- ): Promise<DirectReach | undefined> => {
83
- for (const provider of providers()) {
84
- const channel = await provider.reaches(principalId);
85
- if (channel !== undefined) return { provider, channel };
86
- }
87
- return undefined;
88
- };
89
- const mirrored = async (
90
- delivered: DirectChannelProvider,
91
- principalId: string,
92
- text: string,
93
- ): Promise<void> => {
94
- await Promise.all(
95
- providers()
96
- .filter((provider) => provider !== delivered && provider.mirror)
97
- .map(async (provider) => {
98
- try {
99
- await provider.mirror?.(principalId, text);
100
- } catch (error) {
101
- logger?.warn(
102
- { err: error, provider: provider.name },
103
- "a direct channel did not mirror a notice",
104
- );
105
- }
106
- }),
107
- );
108
- };
98
+ const router = deliveryRouter(providers, surfaces, logger);
109
99
  return {
110
100
  providers: () => providers(),
111
- reach,
112
- known: async (principalId) => {
113
- for (const provider of providers()) {
114
- const knows = provider.knows
115
- ? await provider.knows(principalId)
116
- : (await provider.reaches(principalId)) !== undefined;
117
- if (knows) return provider;
118
- }
119
- return undefined;
120
- },
121
- notify: async (principalId, text) => {
122
- const reached = await reach(principalId);
123
- if (!reached) return false;
124
- if (reached.provider.deliver)
125
- await reached.provider.deliver(principalId, text);
126
- else
127
- await surfaces.sendReply(reached.channel, {
128
- chunks: splitReply(text),
129
- });
130
- await mirrored(reached.provider, principalId, text);
131
- return true;
132
- },
101
+ reach: router.reach,
102
+ known: router.known,
103
+ notify: router.notify,
133
104
  };
134
105
  }
@@ -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 { parseChannelKey, type SurfacePort } from "../contract/surface.ts";
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 { isSystemTurn } from "./system-turns.ts";
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 admission = await this.#admit(claim, message);
141
- if (!admission) return undefined;
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 = this.#actorOf(message);
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 admission = claim.admit(
197
- contact
198
- ? { ...facts, speaker: Object.freeze({ ...contact.speaker }) }
199
- : facts,
200
- );
201
- if (!admission || !contact) return admission;
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) return admission;
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 queued = false;
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
- queued = true;
279
+ if (waiting.cancelled) return { done: Promise.resolve() };
307
280
  }
308
281
  const done = queue
309
282
  .run(message.channel, () => {
310
- if (queued) void busy?.unreact(QUEUED_MARK);
311
- return admission.run();
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) void busy?.unreact(QUEUED_MARK);
318
- return this.#refuse(message, admission);
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 this.#speakerOf(turn);
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);