pi-roundtable 0.8.0 → 0.9.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.
- package/CHANGELOG.md +115 -0
- package/README.md +35 -11
- package/README.zh-TW.md +27 -10
- package/docs/migration-0.9.md +247 -0
- package/docs/plugins.md +618 -86
- package/examples/channel-context.test.ts +88 -0
- package/examples/channel-context.ts +62 -0
- package/examples/echo-runtime.test.ts +1 -0
- package/examples/fake-surface.test.ts +69 -6
- package/examples/fake-surface.ts +73 -7
- package/examples/headless.ts +5 -1
- package/examples/prechecks.test.ts +1 -0
- package/examples/principals.test.ts +60 -0
- package/examples/principals.ts +48 -0
- package/examples/prompt.test.ts +1 -1
- package/examples/study-room.test.ts +29 -1
- package/examples/study-room.ts +16 -19
- package/examples/support-desk.test.ts +3 -1
- package/examples/tools.test.ts +6 -1
- package/package.json +5 -2
- package/src/cli/checks/database.ts +11 -0
- package/src/cli/checks/identity.ts +225 -0
- package/src/cli/checks/model.ts +21 -3
- package/src/cli/cli.ts +115 -1
- package/src/cli/config-edit.ts +83 -13
- package/src/cli/doctor.ts +7 -0
- package/src/cli/line-diff.ts +72 -0
- package/src/cli/principal.ts +412 -0
- package/src/cli/project.ts +5 -0
- package/src/cli/report.ts +12 -3
- package/src/cli/upgrade-rewrite.ts +102 -0
- package/src/cli/upgrade-source.ts +430 -0
- package/src/cli/upgrade.ts +176 -0
- package/src/core/agents/agent-claim.ts +56 -34
- package/src/core/agents/team-turns.ts +8 -4
- package/src/core/builtin/agent-server.ts +9 -7
- package/src/core/builtin/discord-admin.ts +4 -1
- package/src/core/builtin/discord.ts +71 -13
- package/src/core/builtin/modules.ts +150 -86
- package/src/core/builtin/stores.ts +40 -21
- package/src/core/config/access.ts +229 -0
- package/src/core/config/adapters.ts +64 -1
- package/src/core/config/config.ts +87 -41
- package/src/core/contract/channel-context.ts +53 -0
- package/src/core/contract/channels.ts +82 -9
- package/src/core/contract/runtime.ts +23 -7
- package/src/core/contract/surface.ts +22 -6
- package/src/core/conversations/conversation-registry.ts +14 -0
- package/src/core/conversations/conversation-store.ts +13 -0
- package/src/core/define-roundtable.ts +42 -11
- package/src/core/discord/agent-panel.ts +2 -2
- package/src/core/discord/card-audience.ts +148 -0
- package/src/core/discord/channel-context.ts +348 -0
- package/src/core/discord/connection.ts +15 -3
- package/src/core/discord/direct-channel.ts +54 -0
- package/src/core/discord/discord-owners.ts +133 -0
- package/src/core/discord/discord-surface.ts +69 -14
- package/src/core/discord/fresh-marker.ts +24 -0
- package/src/core/discord/inbound-message.ts +21 -7
- package/src/core/discord/interaction-module.ts +11 -1
- package/src/core/discord/owner-cards-fixture.ts +80 -0
- package/src/core/discord/owner-cards.ts +58 -97
- package/src/core/discord/owner-command.ts +27 -13
- package/src/core/discord/stop-button.ts +4 -4
- package/src/core/domain/conversation.ts +7 -2
- package/src/core/domain/errors.ts +5 -0
- package/src/core/domain/ports.ts +27 -2
- package/src/core/drain.ts +56 -5
- package/src/core/errors.ts +13 -0
- package/src/core/host-background.ts +56 -0
- package/src/core/host.ts +52 -68
- package/src/core/i18n/cards.ts +8 -4
- package/src/core/i18n/conversation.ts +2 -0
- package/src/core/identity/access-policy.ts +227 -0
- package/src/core/identity/actor-facts.ts +65 -0
- package/src/core/identity/caching-principal-store.ts +111 -0
- package/src/core/identity/config-owner.ts +145 -0
- package/src/core/identity/contact.ts +27 -0
- package/src/core/identity/identity-plugin.ts +56 -0
- package/src/core/identity/identity-schema.ts +282 -0
- package/src/core/identity/identity-service.ts +489 -0
- package/src/core/identity/identity-view.ts +77 -0
- package/src/core/identity/plugin-identities.ts +109 -0
- package/src/core/identity/principal-reads.ts +38 -0
- package/src/core/identity/principal-store.ts +478 -0
- package/src/core/identity/principal-tables.ts +53 -0
- package/src/core/identity/seen-throttle.ts +57 -0
- package/src/core/identity/ulid.ts +36 -0
- package/src/core/identity.ts +34 -5
- package/src/core/interactions/prompts.ts +127 -0
- package/src/core/modules/background/background-turns.ts +56 -17
- package/src/core/modules/background/personal-target.ts +50 -0
- package/src/core/modules/delegation/delegate.ts +28 -14
- package/src/core/modules/delegation/delegator.ts +23 -7
- package/src/core/modules/memory/owner-memory.ts +123 -50
- package/src/core/modules/notify/notify.ts +53 -13
- package/src/core/modules/notify/session-notify.ts +110 -0
- package/src/core/modules/schedules/prechecks.ts +8 -2
- package/src/core/modules/schedules/schedule-prechecks.ts +4 -1
- package/src/core/modules/schedules/schedule-store.ts +51 -20
- package/src/core/modules/schedules/schedule-tools.ts +122 -12
- package/src/core/modules/schedules/scheduler.ts +56 -4
- package/src/core/modules/schedules/schedules.ts +43 -14
- package/src/core/plugin.ts +48 -0
- package/src/core/presence/direct-channels.ts +104 -0
- package/src/core/registry/contributions.ts +63 -3
- package/src/core/routing/channel-queue.ts +18 -1
- package/src/core/routing/channel-router.ts +274 -21
- package/src/core/routing/conversation-turns.ts +45 -10
- package/src/core/routing/surface-port.ts +32 -2
- package/src/core/routing/system-turns.ts +29 -0
- package/src/core/runtime/abort-guard.ts +58 -0
- package/src/core/runtime/bridge-guard.ts +225 -0
- package/src/core/runtime/compaction-tiers.ts +5 -1
- package/src/core/runtime/conversation-sessions.ts +139 -12
- package/src/core/runtime/extensions/ask-user.ts +1 -1
- package/src/core/runtime/extensions/confirmation-gate.ts +28 -14
- package/src/core/runtime/extensions/private-memory.ts +447 -0
- package/src/core/runtime/pending-confirmation-store.ts +30 -5
- package/src/core/runtime/pi-agent-runtime.ts +174 -190
- package/src/core/runtime/preflight-tools.ts +47 -0
- package/src/core/runtime/prompt-slot.ts +4 -4
- package/src/core/runtime/reader-history.ts +129 -0
- package/src/core/runtime/runtime-plugin.ts +51 -5
- package/src/core/runtime/runtime-types.ts +48 -12
- package/src/core/runtime/session-conversation.ts +143 -0
- package/src/core/runtime/session-factory.ts +115 -15
- package/src/core/runtime/session-scope.ts +78 -0
- package/src/core/runtime/steerable-run.ts +21 -3
- package/src/core/runtime/thinking-level.ts +47 -0
- package/src/core/runtime/tool-call-scope.ts +43 -0
- package/src/core/runtime/turn-answer.ts +30 -1
- package/src/core/runtime/turn-wrapup.ts +63 -0
- package/src/core/services.ts +27 -1
- package/src/core/sessions.ts +30 -0
- package/src/core/speakers.ts +21 -19
- package/src/core/testing/background.ts +31 -0
- package/src/core/testing/fixture-database.ts +55 -0
- package/src/core/testing/map-identity.ts +76 -0
- package/src/core/testing/module-sessions.ts +134 -0
- package/src/core/testing/modules.ts +108 -21
- package/src/core/testing/owner.ts +17 -0
- package/src/core/testing/surface-contract.ts +118 -41
- package/src/core/testing/test-host.ts +79 -16
- package/src/core/testing/tool-set.ts +32 -0
- package/src/core/tool-set.snapshot.json +4 -4
- package/src/core/tool-tiers.ts +57 -3
- package/src/discord/index.ts +8 -0
- package/src/index.ts +48 -8
- package/src/kit/compaction.ts +13 -0
- package/src/kit/index.ts +10 -0
- package/src/kit/support.ts +1 -1
- package/src/testing.ts +55 -16
- package/templates/.env.example +1 -1
- package/templates/adapters/web/README.md +7 -0
- package/templates/adapters/web/roundtable.config.ts +14 -10
- package/templates/roundtable.config.ts +12 -1
- package/src/core/domain/owner-prompts.ts +0 -45
|
@@ -18,20 +18,24 @@ import type {
|
|
|
18
18
|
OutboundReply,
|
|
19
19
|
} from "../domain/conversation.ts";
|
|
20
20
|
import type { InterimPosts } from "../domain/interim.ts";
|
|
21
|
-
import type { OwnerPrompts } from "../domain/owner-prompts.ts";
|
|
22
21
|
import type { OwnerNotifier } from "../domain/ports.ts";
|
|
23
22
|
import { messages } from "../i18n/index.ts";
|
|
23
|
+
import type { PromptScope, Prompts } from "../interactions/prompts.ts";
|
|
24
24
|
import type { Logger } from "../log.ts";
|
|
25
25
|
import type { OwnerOperations } from "../modules/discord-admin/discord-admin.ts";
|
|
26
26
|
import { splitReply } from "../presentation/reply-splitter.ts";
|
|
27
|
-
import type { Speaker } from "../speakers.ts";
|
|
28
27
|
import { DiscordAgentChannels, DiscordDashboard } from "./agent-discord.ts";
|
|
28
|
+
import {
|
|
29
|
+
type ContextSourceMessage,
|
|
30
|
+
contextSourceOf,
|
|
31
|
+
} from "./channel-context.ts";
|
|
29
32
|
import { discordChannelExecutor } from "./channel-executor.ts";
|
|
30
33
|
import type { ChannelExecutor } from "./channel-operations.ts";
|
|
31
34
|
import type { ComposedCommands } from "./compose-commands.ts";
|
|
32
35
|
import type { ChannelInfo, DiscordConnection } from "./connection.ts";
|
|
33
36
|
import { DiscordThreadHost } from "./dispatch-thread-host.ts";
|
|
34
37
|
import type { ThreadHost } from "./dispatch-threads.ts";
|
|
38
|
+
import { DEFAULT_FRESH_MARKER, postFreshMarker } from "./fresh-marker.ts";
|
|
35
39
|
import { toInbound } from "./inbound-message.ts";
|
|
36
40
|
import type { CardChannel } from "./owner-cards.ts";
|
|
37
41
|
import { DiscordOwnerOps } from "./owner-discord.ts";
|
|
@@ -47,12 +51,15 @@ const STOP_PANEL_DELAY_MS = 5_000;
|
|
|
47
51
|
|
|
48
52
|
export interface DiscordSurfaceOptions {
|
|
49
53
|
token: string;
|
|
54
|
+
/** The primary owner's Discord user id, whom the admin tools act as and the deprecated owner channel reaches. */
|
|
50
55
|
ownerId: string;
|
|
51
|
-
/** How the owner is named in audit-log reasons and refusals. */
|
|
56
|
+
/** How the primary owner is named in audit-log reasons and refusals. */
|
|
52
57
|
ownerName: string;
|
|
53
|
-
/** The
|
|
54
|
-
prompts: (channel: ChannelKey,
|
|
58
|
+
/** The cards in a Discord channel; the surface hands them out as its `prompts`. */
|
|
59
|
+
prompts: (channel: ChannelKey, scope?: PromptScope) => Prompts | undefined;
|
|
55
60
|
logger: Logger;
|
|
61
|
+
/** The divider posted in a server channel when its conversation starts over; `false` posts none. Default: `DEFAULT_FRESH_MARKER`. */
|
|
62
|
+
freshMarker?: string | false;
|
|
56
63
|
}
|
|
57
64
|
|
|
58
65
|
/**
|
|
@@ -96,7 +103,7 @@ export class DiscordSurface
|
|
|
96
103
|
: undefined;
|
|
97
104
|
}
|
|
98
105
|
|
|
99
|
-
/** Discord reading and management for the owner's agent; calls fail until the connection is ready. */
|
|
106
|
+
/** Discord reading and management for the owner's agent, as the primary owner may; calls fail until the connection is ready. */
|
|
100
107
|
ownerOperations(): OwnerOperations {
|
|
101
108
|
return new DiscordOwnerOps(
|
|
102
109
|
this.#client,
|
|
@@ -150,14 +157,37 @@ export class DiscordSurface
|
|
|
150
157
|
};
|
|
151
158
|
}
|
|
152
159
|
|
|
160
|
+
/**
|
|
161
|
+
* A server text channel's messages before one, as channel context reads them; undefined for a
|
|
162
|
+
* direct message or a channel that holds no messages. Throws while the connection is not ready
|
|
163
|
+
* or when Discord refuses, such as for a channel the bot cannot read.
|
|
164
|
+
*/
|
|
165
|
+
async messagesBefore(
|
|
166
|
+
channelId: string,
|
|
167
|
+
before: string,
|
|
168
|
+
limit: number,
|
|
169
|
+
): Promise<ContextSourceMessage[] | undefined> {
|
|
170
|
+
if (!this.#client.isReady()) throw new Error("Discord is not connected");
|
|
171
|
+
const channel = await this.#client.channels.fetch(channelId);
|
|
172
|
+
if (!channel?.isTextBased() || channel.isDMBased()) return undefined;
|
|
173
|
+
const fetched = await channel.messages.fetch({ before, limit });
|
|
174
|
+
const self = {
|
|
175
|
+
botId: this.#client.user.id,
|
|
176
|
+
applicationId: this.#client.application.id,
|
|
177
|
+
};
|
|
178
|
+
return [...fetched.values()].map((message) =>
|
|
179
|
+
contextSourceOf(message, self),
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
153
183
|
/** Reports deleted server channels by id; register before start. */
|
|
154
184
|
onChannelDeleted(handler: (channelId: string) => void): void {
|
|
155
185
|
this.#client.on(Events.ChannelDelete, (channel) => handler(channel.id));
|
|
156
186
|
}
|
|
157
187
|
|
|
158
|
-
/** The
|
|
159
|
-
prompts(channel: ChannelKey,
|
|
160
|
-
return this.#options.prompts(channel,
|
|
188
|
+
/** The approval and question cards in a Discord channel, for those the scope names. */
|
|
189
|
+
prompts(channel: ChannelKey, scope?: PromptScope): Prompts | undefined {
|
|
190
|
+
return this.#options.prompts(channel, scope);
|
|
161
191
|
}
|
|
162
192
|
|
|
163
193
|
/** The channel's interim posts: ordinary messages, edited in place for the progress line. */
|
|
@@ -244,6 +274,20 @@ export class DiscordSurface
|
|
|
244
274
|
}
|
|
245
275
|
}
|
|
246
276
|
|
|
277
|
+
/** The divider that ends channel context's window; see `postFreshMarker`. */
|
|
278
|
+
async markFresh(channel: ChannelKey): Promise<void> {
|
|
279
|
+
await postFreshMarker(
|
|
280
|
+
this.#options.freshMarker ?? DEFAULT_FRESH_MARKER,
|
|
281
|
+
async () => {
|
|
282
|
+
const target = await this.#sendable(channel);
|
|
283
|
+
return {
|
|
284
|
+
dm: target.isDMBased(),
|
|
285
|
+
send: (text) => target.send({ content: text }),
|
|
286
|
+
};
|
|
287
|
+
},
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
247
291
|
startTyping(channel: ChannelKey): () => void {
|
|
248
292
|
let stopped = false;
|
|
249
293
|
const tick = () => {
|
|
@@ -339,15 +383,26 @@ export class DiscordSurface
|
|
|
339
383
|
};
|
|
340
384
|
}
|
|
341
385
|
|
|
342
|
-
/**
|
|
386
|
+
/** A Discord user's direct-message channel. */
|
|
387
|
+
async directChannel(userId: string): Promise<ChannelKey> {
|
|
388
|
+
const user = await this.#client.users.fetch(userId);
|
|
389
|
+
return `${PREFIX}${(await user.createDM()).id}`;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Sends a Discord user a direct message, split as a reply is. */
|
|
393
|
+
async sendDirect(userId: string, text: string): Promise<void> {
|
|
394
|
+
const user = await this.#client.users.fetch(userId);
|
|
395
|
+
for (const chunk of splitReply(text)) await user.send({ content: chunk });
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** @deprecated The primary owner's direct-message channel; see `DiscordConnection.ownerChannel`. */
|
|
343
399
|
async ownerChannel(): Promise<ChannelKey> {
|
|
344
|
-
|
|
345
|
-
return `${PREFIX}${(await owner.createDM()).id}`;
|
|
400
|
+
return this.directChannel(this.#options.ownerId);
|
|
346
401
|
}
|
|
347
402
|
|
|
403
|
+
/** @deprecated Sends the primary owner a direct message; see `DiscordConnection.notifyOwner`. */
|
|
348
404
|
async notifyOwner(text: string): Promise<void> {
|
|
349
|
-
|
|
350
|
-
for (const chunk of splitReply(text)) await owner.send({ content: chunk });
|
|
405
|
+
await this.sendDirect(this.#options.ownerId, text);
|
|
351
406
|
}
|
|
352
407
|
|
|
353
408
|
async stop(): Promise<void> {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** The divider a guild channel gets when its conversation starts over. */
|
|
2
|
+
export const DEFAULT_FRESH_MARKER = "─── new conversation ───";
|
|
3
|
+
|
|
4
|
+
/** A channel as the divider needs it: whether it is a direct message, and a way to post. */
|
|
5
|
+
export interface MarkerTarget {
|
|
6
|
+
dm: boolean;
|
|
7
|
+
send(text: string): Promise<unknown>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Posts the divider that ends channel context's window after a conversation started over: a
|
|
12
|
+
* public post of the assistant in a server channel or thread, never in a direct message, and
|
|
13
|
+
* nothing for `false`. A channel that cannot be found or posted to is left as it is; a Discord
|
|
14
|
+
* error rejects, for the caller to log.
|
|
15
|
+
*/
|
|
16
|
+
export async function postFreshMarker(
|
|
17
|
+
marker: string | false,
|
|
18
|
+
find: () => Promise<MarkerTarget | undefined>,
|
|
19
|
+
): Promise<void> {
|
|
20
|
+
if (marker === false) return;
|
|
21
|
+
const target = await find();
|
|
22
|
+
if (!target || target.dm) return;
|
|
23
|
+
await target.send(marker);
|
|
24
|
+
}
|
|
@@ -55,18 +55,32 @@ export async function toInbound(
|
|
|
55
55
|
reference?.messageId && !forward
|
|
56
56
|
? await message.fetchReference().catch(() => undefined)
|
|
57
57
|
: undefined;
|
|
58
|
+
const authorName =
|
|
59
|
+
message.member?.displayName ??
|
|
60
|
+
message.author.globalName ??
|
|
61
|
+
message.author.username;
|
|
62
|
+
const roleIds = message.member
|
|
63
|
+
? [...message.member.roles.cache.keys()]
|
|
64
|
+
: undefined;
|
|
58
65
|
return {
|
|
59
66
|
channel: channelKey("discord", message.channelId),
|
|
60
67
|
messageId: message.id,
|
|
68
|
+
// A DM has no member, so its author's roles are unknown, not none.
|
|
69
|
+
actor: {
|
|
70
|
+
provider: "discord",
|
|
71
|
+
subject: message.author.id,
|
|
72
|
+
name: authorName,
|
|
73
|
+
surface: "discord",
|
|
74
|
+
...(roleIds
|
|
75
|
+
? { roles: roleIds.map((role) => `discord:role:${role}`) }
|
|
76
|
+
: {}),
|
|
77
|
+
...(message.guildId ? { space: message.guildId } : {}),
|
|
78
|
+
legacyId: message.author.id,
|
|
79
|
+
},
|
|
61
80
|
authorId: message.author.id,
|
|
62
|
-
authorName
|
|
63
|
-
message.member?.displayName ??
|
|
64
|
-
message.author.globalName ??
|
|
65
|
-
message.author.username,
|
|
81
|
+
authorName,
|
|
66
82
|
authorIsBot: message.author.bot,
|
|
67
|
-
...(
|
|
68
|
-
? { authorRoleIds: [...message.member.roles.cache.keys()] }
|
|
69
|
-
: {}),
|
|
83
|
+
...(roleIds ? { authorRoleIds: roleIds } : {}),
|
|
70
84
|
...(message.webhookId
|
|
71
85
|
? {
|
|
72
86
|
integration: {
|
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
InteractionContextType,
|
|
6
6
|
RESTPostAPIChatInputApplicationCommandsJSONBody,
|
|
7
7
|
} from "discord.js";
|
|
8
|
+
import type { DiscordActor } from "./discord-owners.ts";
|
|
8
9
|
|
|
9
10
|
/** A group of slash commands and the components they post, handled together. */
|
|
10
11
|
export interface InteractionModule {
|
|
@@ -50,7 +51,16 @@ export interface CommandGuard {
|
|
|
50
51
|
* anyone may use; include the space or punctuation your language needs before it.
|
|
51
52
|
*/
|
|
52
53
|
readonly refusalHint?: string;
|
|
53
|
-
/**
|
|
54
|
+
/**
|
|
55
|
+
* Whether the actor may use the owner's commands now: an owner, the primary owner by their user
|
|
56
|
+
* id and any other by their Discord identity, checked again each time. The actor is an
|
|
57
|
+
* interaction, or anything with `user.id`, such as a test's.
|
|
58
|
+
*/
|
|
59
|
+
allows(actor: DiscordActor): Promise<boolean>;
|
|
60
|
+
/**
|
|
61
|
+
* Whether the actor is the primary owner, by user id.
|
|
62
|
+
* @deprecated Since 0.9 every owner uses the owner's commands; ask `allows`. Goes away in 1.0.
|
|
63
|
+
*/
|
|
54
64
|
isOwner(actor: { user: { id: string } }): boolean;
|
|
55
65
|
/** Runs a handler; a failure becomes a panel instead of a hanging interaction. */
|
|
56
66
|
run(interaction: Interaction, run: () => Promise<void>): Promise<void>;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { Interaction } from "discord.js";
|
|
2
|
+
import { CARD_PREFIX, type CardPayload } from "./owner-cards.ts";
|
|
3
|
+
|
|
4
|
+
/** The primary owner of the cards' tests. */
|
|
5
|
+
export const OWNER = "100000000000000001";
|
|
6
|
+
|
|
7
|
+
/** The card's JSON, where its text, custom ids, and disabled flags can be read. */
|
|
8
|
+
export const json = (payload: CardPayload | undefined) =>
|
|
9
|
+
JSON.stringify(payload?.components.map((c) => c.toJSON()));
|
|
10
|
+
|
|
11
|
+
/** A channel, or a thread, that records the cards sent to it and every edit of them. */
|
|
12
|
+
export function fakeChannel(thread = false) {
|
|
13
|
+
const sent: CardPayload[] = [];
|
|
14
|
+
const edits: CardPayload[] = [];
|
|
15
|
+
return {
|
|
16
|
+
sent,
|
|
17
|
+
edits,
|
|
18
|
+
/** The id of the latest card sent. */
|
|
19
|
+
cardId: () =>
|
|
20
|
+
json(sent.at(-1)).match(new RegExp(`${CARD_PREFIX}([0-9a-f-]+):`))?.[1] ??
|
|
21
|
+
"",
|
|
22
|
+
channel: async () => ({
|
|
23
|
+
thread,
|
|
24
|
+
send: async (payload: CardPayload) => {
|
|
25
|
+
sent.push(payload);
|
|
26
|
+
return {
|
|
27
|
+
edit: async (change: CardPayload) => {
|
|
28
|
+
edits.push(change);
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
},
|
|
32
|
+
}),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type Kind = "button" | "select" | "modal";
|
|
37
|
+
|
|
38
|
+
/** A press of a card's control, recording how it was answered. */
|
|
39
|
+
export function press(
|
|
40
|
+
kind: Kind,
|
|
41
|
+
customId: string,
|
|
42
|
+
options: {
|
|
43
|
+
user?: string;
|
|
44
|
+
roles?: string[];
|
|
45
|
+
values?: string[];
|
|
46
|
+
text?: string;
|
|
47
|
+
} = {},
|
|
48
|
+
) {
|
|
49
|
+
const replies: string[] = [];
|
|
50
|
+
const updates: CardPayload[] = [];
|
|
51
|
+
const modals: unknown[] = [];
|
|
52
|
+
// SAFETY: the cards read only these members of an interaction.
|
|
53
|
+
const interaction = {
|
|
54
|
+
isButton: () => kind === "button",
|
|
55
|
+
isStringSelectMenu: () => kind === "select",
|
|
56
|
+
isModalSubmit: () => kind === "modal",
|
|
57
|
+
isFromMessage: () => true,
|
|
58
|
+
customId,
|
|
59
|
+
user: { id: options.user ?? OWNER },
|
|
60
|
+
member: {
|
|
61
|
+
roles: { cache: new Map((options.roles ?? []).map((r) => [r, r])) },
|
|
62
|
+
},
|
|
63
|
+
values: options.values ?? [],
|
|
64
|
+
fields: { getTextInputValue: () => options.text ?? "" },
|
|
65
|
+
reply: async (answer: { content: string }) => {
|
|
66
|
+
replies.push(answer.content);
|
|
67
|
+
},
|
|
68
|
+
update: async (payload: CardPayload) => {
|
|
69
|
+
updates.push(payload);
|
|
70
|
+
},
|
|
71
|
+
showModal: async (modal: unknown) => {
|
|
72
|
+
modals.push(modal);
|
|
73
|
+
},
|
|
74
|
+
deferUpdate: async () => undefined,
|
|
75
|
+
} as unknown as Interaction;
|
|
76
|
+
return { interaction, replies, updates, modals };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Lets a posted card settle. */
|
|
80
|
+
export const tick = () => Bun.sleep(1);
|
|
@@ -14,20 +14,21 @@ import {
|
|
|
14
14
|
} from "discord.js";
|
|
15
15
|
import { parseChannelKey } from "../contract/surface.ts";
|
|
16
16
|
import type { ChannelKey } from "../domain/conversation.ts";
|
|
17
|
+
import { messages } from "../i18n/index.ts";
|
|
17
18
|
import type {
|
|
18
19
|
Approval,
|
|
19
20
|
OwnerAnswer,
|
|
20
|
-
OwnerPrompts,
|
|
21
21
|
OwnerQuestion,
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
PromptScope,
|
|
23
|
+
Prompts,
|
|
24
|
+
} from "../interactions/prompts.ts";
|
|
24
25
|
import type { Logger } from "../log.ts";
|
|
25
26
|
import {
|
|
26
|
-
type
|
|
27
|
-
type
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
} from "
|
|
27
|
+
type Audience,
|
|
28
|
+
type CardAudienceOptions,
|
|
29
|
+
CardAudiences,
|
|
30
|
+
} from "./card-audience.ts";
|
|
31
|
+
import { DiscordOwners, discordUser } from "./discord-owners.ts";
|
|
31
32
|
import type { InteractionModule } from "./interaction-module.ts";
|
|
32
33
|
import { ownerPanel, type PanelContent, plain } from "./owner-panel.ts";
|
|
33
34
|
|
|
@@ -37,7 +38,7 @@ export const CARD_TIMEOUT_MS = 30 * 60_000;
|
|
|
37
38
|
const OTHER = "other";
|
|
38
39
|
const TEXT_FIELD = "text";
|
|
39
40
|
|
|
40
|
-
/** A card's message: the panel, led by a mention of
|
|
41
|
+
/** A card's message: the panel, led in a thread by a mention of those it is for. */
|
|
41
42
|
export type CardPayload = Omit<
|
|
42
43
|
ReturnType<typeof ownerPanel>,
|
|
43
44
|
"components" | "allowedMentions"
|
|
@@ -57,14 +58,18 @@ export interface CardMessage {
|
|
|
57
58
|
/** Where a channel's cards are posted. */
|
|
58
59
|
export interface CardChannel {
|
|
59
60
|
send(payload: CardPayload): Promise<CardMessage>;
|
|
60
|
-
/** A thread, where
|
|
61
|
+
/** A thread, where those a card is for are mentioned so they hear of it; a channel mentions no one. */
|
|
61
62
|
thread?: boolean;
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
export interface OwnerCardsOptions {
|
|
66
|
+
/** The primary owner's Discord user id. */
|
|
65
67
|
ownerId: string;
|
|
66
|
-
/**
|
|
67
|
-
|
|
68
|
+
/**
|
|
69
|
+
* Who holds a tier now, and which owners have a Discord identity, for cards that others than
|
|
70
|
+
* the primary owner may answer; without it only the primary owner answers.
|
|
71
|
+
*/
|
|
72
|
+
identity?: CardAudienceOptions["identity"];
|
|
68
73
|
/** The Discord channel by id; throws when it cannot take messages. */
|
|
69
74
|
channel(channelId: string): Promise<CardChannel>;
|
|
70
75
|
logger: Logger;
|
|
@@ -91,42 +96,41 @@ interface OpenCard {
|
|
|
91
96
|
audience: Audience;
|
|
92
97
|
}
|
|
93
98
|
|
|
94
|
-
/** The people a card is for. */
|
|
95
|
-
interface Audience {
|
|
96
|
-
allows(user: { id: string; roleIds: readonly string[] }): boolean;
|
|
97
|
-
/** The user the card mentions in a thread. */
|
|
98
|
-
mentionId: string;
|
|
99
|
-
/** Shown on the card. */
|
|
100
|
-
note: string;
|
|
101
|
-
refusal: string;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
99
|
/**
|
|
105
|
-
* The
|
|
106
|
-
* answered with buttons, a menu, or a form
|
|
107
|
-
* turn asks, when the card allows (a question always; an
|
|
108
|
-
*
|
|
109
|
-
*
|
|
100
|
+
* The cards in the assistant's channels: approvals of held actions and ask_user questions,
|
|
101
|
+
* answered with buttons, a menu, or a form, by those the turn's prompt scope names (see
|
|
102
|
+
* `CardAudiences`): the speaker whose turn asks, when the card allows (a question always; an
|
|
103
|
+
* approval at its tier), and, in a shared conversation, the owners. An unanswered card expires
|
|
104
|
+
* after 30 minutes and a stopped turn cancels it. Open cards live in memory, so a restart
|
|
105
|
+
* abandons them; pressing one then says it no longer works.
|
|
110
106
|
*/
|
|
111
107
|
export class OwnerCards implements InteractionModule {
|
|
112
108
|
readonly #options: OwnerCardsOptions;
|
|
109
|
+
readonly #audiences: CardAudiences;
|
|
113
110
|
readonly #open = new Map<string, OpenCard>();
|
|
114
111
|
|
|
115
112
|
constructor(options: OwnerCardsOptions) {
|
|
116
113
|
this.#options = options;
|
|
114
|
+
this.#audiences = new CardAudiences(options, new DiscordOwners(options));
|
|
117
115
|
}
|
|
118
116
|
|
|
119
117
|
commands() {
|
|
120
118
|
return [];
|
|
121
119
|
}
|
|
122
120
|
|
|
123
|
-
/**
|
|
124
|
-
|
|
121
|
+
/**
|
|
122
|
+
* The channel's cards for the scope's turn; without a scope they are the owners'. The chat
|
|
123
|
+
* surface port sends only Discord keys here.
|
|
124
|
+
*/
|
|
125
|
+
prompts(channel: ChannelKey, scope?: PromptScope): Prompts | undefined {
|
|
125
126
|
const channelId = parseChannelKey(channel).id;
|
|
126
127
|
return {
|
|
127
|
-
confirm: (title, message, signal, minTier = "owner") =>
|
|
128
|
-
this.#
|
|
129
|
-
|
|
128
|
+
confirm: async (title, message, signal, minTier = "owner") => {
|
|
129
|
+
const audience = await this.#audiences.approval(scope, minTier);
|
|
130
|
+
// A private conversation's call above its person's tier is no one's to approve.
|
|
131
|
+
if (!audience) return "expired";
|
|
132
|
+
return this.#post<Approval>(channelId, signal, "expired", "cancelled", {
|
|
133
|
+
audience,
|
|
130
134
|
title,
|
|
131
135
|
sections: [message],
|
|
132
136
|
rows: (id, disabled) => [
|
|
@@ -146,22 +150,26 @@ export class OwnerCards implements InteractionModule {
|
|
|
146
150
|
),
|
|
147
151
|
],
|
|
148
152
|
footer: messages().cardApprovalFooter,
|
|
149
|
-
})
|
|
150
|
-
|
|
151
|
-
|
|
153
|
+
});
|
|
154
|
+
},
|
|
155
|
+
ask: async (title, question, signal) => {
|
|
156
|
+
const audience = await this.#audiences.question(scope);
|
|
157
|
+
if (!audience) return undefined;
|
|
158
|
+
return this.#post<OwnerAnswer | undefined>(
|
|
152
159
|
channelId,
|
|
153
160
|
signal,
|
|
154
161
|
undefined,
|
|
155
162
|
undefined,
|
|
156
163
|
{
|
|
157
|
-
audience
|
|
164
|
+
audience,
|
|
158
165
|
title,
|
|
159
166
|
sections: [question.question],
|
|
160
167
|
question,
|
|
161
168
|
rows: (id, disabled) => askRows(id, question, disabled),
|
|
162
169
|
footer: messages().cardQuestionFooter,
|
|
163
170
|
},
|
|
164
|
-
)
|
|
171
|
+
);
|
|
172
|
+
},
|
|
165
173
|
};
|
|
166
174
|
}
|
|
167
175
|
|
|
@@ -214,7 +222,7 @@ export class OwnerCards implements InteractionModule {
|
|
|
214
222
|
footer,
|
|
215
223
|
},
|
|
216
224
|
open.mention === true,
|
|
217
|
-
card.audience.
|
|
225
|
+
card.audience.mentions,
|
|
218
226
|
);
|
|
219
227
|
const abort = () => settle(cancelled, messages().cardStopped);
|
|
220
228
|
const timer = setTimeout(
|
|
@@ -250,67 +258,22 @@ export class OwnerCards implements InteractionModule {
|
|
|
250
258
|
}
|
|
251
259
|
|
|
252
260
|
/** The card's panel; in a thread it leads with the mention of who may answer. */
|
|
253
|
-
#panel(
|
|
261
|
+
#panel(
|
|
262
|
+
content: PanelContent,
|
|
263
|
+
mention: boolean,
|
|
264
|
+
userIds: readonly string[],
|
|
265
|
+
): CardPayload {
|
|
254
266
|
const panel = ownerPanel(content);
|
|
255
267
|
if (!mention) return panel;
|
|
256
268
|
return {
|
|
257
269
|
...panel,
|
|
258
270
|
components: [
|
|
259
|
-
new TextDisplayBuilder().setContent(
|
|
271
|
+
new TextDisplayBuilder().setContent(
|
|
272
|
+
userIds.map((id) => `<@${id}>`).join(" "),
|
|
273
|
+
),
|
|
260
274
|
...panel.components,
|
|
261
275
|
],
|
|
262
|
-
allowedMentions: { users: [
|
|
263
|
-
};
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
/**
|
|
267
|
-
* An approval is for the speaker whose turn held the call, when their tier holds it, and for
|
|
268
|
-
* the owner; the owner's alone by default. Nobody else in the channel may approve it.
|
|
269
|
-
*/
|
|
270
|
-
#approvers(minTier: Tier, speaker: Speaker | undefined): Audience {
|
|
271
|
-
const { ownerId, speakers } = this.#options;
|
|
272
|
-
if (
|
|
273
|
-
minTier === "owner" ||
|
|
274
|
-
!speakers ||
|
|
275
|
-
!speaker ||
|
|
276
|
-
speaker.id === ownerId ||
|
|
277
|
-
!tierAtLeast(speaker.tier, minTier)
|
|
278
|
-
)
|
|
279
|
-
return {
|
|
280
|
-
allows: (user) => user.id === ownerId,
|
|
281
|
-
mentionId: ownerId,
|
|
282
|
-
note: "",
|
|
283
|
-
refusal: messages().cardOwnerOnly,
|
|
284
|
-
};
|
|
285
|
-
return {
|
|
286
|
-
allows: (user) => {
|
|
287
|
-
if (user.id === ownerId) return true;
|
|
288
|
-
if (user.id !== speaker.id) return false;
|
|
289
|
-
// Their tier when they press, in case it was lowered during the turn.
|
|
290
|
-
const who = speakers.resolve({ ...user, name: "" });
|
|
291
|
-
return who !== undefined && tierAtLeast(who.tier, minTier);
|
|
292
|
-
},
|
|
293
|
-
mentionId: speaker.id,
|
|
294
|
-
note: messages().cardApproversNote(speaker.id),
|
|
295
|
-
refusal: messages().cardApproversRefusal,
|
|
296
|
-
};
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
/** A question is for the speaker whose turn asks it, and for the owner. */
|
|
300
|
-
#asker(speaker: Speaker | undefined): Audience {
|
|
301
|
-
const { ownerId } = this.#options;
|
|
302
|
-
if (!speaker || speaker.id === ownerId)
|
|
303
|
-
return {
|
|
304
|
-
allows: (user) => user.id === ownerId,
|
|
305
|
-
mentionId: ownerId,
|
|
306
|
-
note: "",
|
|
307
|
-
refusal: messages().cardOwnerOnly,
|
|
308
|
-
};
|
|
309
|
-
return {
|
|
310
|
-
allows: (user) => user.id === speaker.id || user.id === ownerId,
|
|
311
|
-
mentionId: speaker.id,
|
|
312
|
-
note: messages().cardAskerNote(speaker.id),
|
|
313
|
-
refusal: messages().cardAskerRefusal,
|
|
276
|
+
allowedMentions: { users: [...userIds] },
|
|
314
277
|
};
|
|
315
278
|
}
|
|
316
279
|
|
|
@@ -335,10 +298,8 @@ export class OwnerCards implements InteractionModule {
|
|
|
335
298
|
});
|
|
336
299
|
return true;
|
|
337
300
|
}
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
member && "cache" in member.roles ? [...member.roles.cache.keys()] : [];
|
|
341
|
-
if (!card.audience.allows({ id: interaction.user.id, roleIds })) {
|
|
301
|
+
const user = discordUser(interaction);
|
|
302
|
+
if (!(await card.audience.allows(user))) {
|
|
342
303
|
await interaction.reply({
|
|
343
304
|
content: card.audience.refusal,
|
|
344
305
|
flags: MessageFlags.Ephemeral,
|
|
@@ -354,7 +315,7 @@ export class OwnerCards implements InteractionModule {
|
|
|
354
315
|
footer: outcome,
|
|
355
316
|
},
|
|
356
317
|
card.mention === true,
|
|
357
|
-
card.audience.
|
|
318
|
+
card.audience.mentions,
|
|
358
319
|
);
|
|
359
320
|
if (interaction.isButton() && (action === "yes" || action === "no")) {
|
|
360
321
|
const approved = action === "yes";
|