pi-roundtable 0.7.19 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +140 -0
- package/README.md +72 -15
- package/README.zh-TW.md +65 -13
- package/docs/migrating-0.8.md +132 -0
- package/docs/migration-0.9.md +247 -0
- package/docs/plugins.md +853 -81
- package/examples/channel-context.test.ts +88 -0
- package/examples/channel-context.ts +62 -0
- package/examples/discord-adapter.test.ts +42 -0
- package/examples/discord-adapter.ts +26 -0
- package/examples/echo-runtime.test.ts +1 -0
- package/examples/echo-runtime.ts +1 -1
- package/examples/fake-surface.test.ts +69 -6
- package/examples/fake-surface.ts +73 -7
- package/examples/headless.test.ts +72 -0
- package/examples/headless.ts +35 -0
- package/examples/http.test.ts +31 -1
- package/examples/http.ts +39 -0
- 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 +7 -3
- 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 +130 -5
- package/src/cli/config-edit.ts +83 -13
- package/src/cli/doctor.ts +28 -5
- package/src/cli/init.ts +9 -1
- package/src/cli/line-diff.ts +72 -0
- package/src/cli/principal.ts +412 -0
- package/src/cli/project.ts +33 -2
- package/src/cli/report.ts +12 -3
- package/src/cli/templates.ts +46 -14
- 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/agent-team-fixture.ts +6 -0
- package/src/core/agents/team-turns.ts +10 -1
- package/src/core/builtin/agent-server.ts +38 -101
- package/src/core/builtin/discord-admin.ts +4 -1
- package/src/core/builtin/discord.ts +71 -13
- package/src/core/builtin/modules.ts +197 -43
- package/src/core/builtin/stores.ts +40 -21
- package/src/core/config/access.ts +229 -0
- package/src/core/config/adapters.ts +120 -0
- package/src/core/config/config.ts +228 -94
- package/src/core/contract/channel-context.ts +53 -0
- package/src/core/contract/channels.ts +81 -9
- package/src/core/contract/providers.ts +3 -2
- package/src/core/contract/runtime.ts +31 -11
- package/src/core/contract/surface.ts +35 -6
- package/src/core/conversations/conversation-registry.ts +56 -0
- package/src/core/conversations/conversation-store.ts +126 -0
- package/src/core/conversations/conversations-plugin.ts +29 -0
- package/src/core/define-roundtable.ts +157 -57
- 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-adapter.ts +9 -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 -86
- package/src/core/discord/owner-command.ts +27 -13
- package/src/core/discord/stop-button.ts +4 -4
- package/src/core/domain/conversation.ts +10 -0
- package/src/core/domain/errors.ts +5 -0
- package/src/core/domain/ports.ts +33 -2
- package/src/core/domain/progress.ts +10 -0
- package/src/core/events.ts +1 -0
- package/src/core/host.ts +32 -10
- package/src/core/http/listeners.ts +174 -21
- package/src/core/http/websocket.ts +316 -0
- package/src/core/i18n/cards.ts +11 -9
- 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 +43 -2
- package/src/core/modules/schedules/schedules.ts +43 -14
- package/src/core/ops/error-reporter.ts +24 -12
- package/src/core/plugin.ts +62 -0
- package/src/core/presence/direct-channels.ts +104 -0
- package/src/core/registry/contributions.ts +63 -3
- package/src/core/registry/providers.ts +1 -1
- package/src/core/routing/channel-router.ts +226 -17
- package/src/core/routing/conversation-turns.ts +85 -1
- package/src/core/routing/surface-port.ts +35 -2
- package/src/core/routing/system-turns.ts +29 -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 +124 -12
- package/src/core/runtime/extensions/ask-user.ts +1 -1
- package/src/core/runtime/extensions/confirmation-gate.ts +32 -10
- package/src/core/runtime/extensions/private-memory.ts +447 -0
- package/src/core/runtime/extensions/self-compact-guard.ts +11 -0
- package/src/core/runtime/pending-confirmation-store.ts +67 -4
- package/src/core/runtime/pi-agent-runtime.ts +143 -135
- package/src/core/runtime/preflight-tools.ts +47 -0
- package/src/core/runtime/prompt-slot.ts +6 -6
- package/src/core/runtime/reader-history.ts +129 -0
- package/src/core/runtime/runtime-plugin.ts +205 -0
- package/src/core/runtime/runtime-types.ts +40 -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 +9 -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-progress.ts +116 -0
- package/src/core/services.ts +42 -2
- package/src/core/sessions.ts +30 -0
- package/src/core/shared/unix-server.ts +35 -5
- 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 +149 -38
- package/src/core/testing/owner.ts +17 -0
- package/src/core/testing/surface-contract.ts +460 -0
- package/src/core/testing/test-host.ts +100 -19
- 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 +10 -0
- package/src/index.ts +65 -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 +96 -21
- package/templates/.env.example +1 -1
- package/templates/adapters/web/.env.example +24 -0
- package/templates/adapters/web/README.md +22 -0
- package/templates/adapters/web/package.json.tmpl +26 -0
- package/templates/adapters/web/persona/assistant.md +2 -0
- package/templates/adapters/web/roundtable.config.ts +59 -0
- package/templates/package.json.tmpl +1 -0
- package/templates/plugins/self-compact.ts +11 -0
- package/templates/roundtable.config.ts +14 -2
- 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,41 +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
|
-
*
|
|
108
|
-
*
|
|
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.
|
|
109
106
|
*/
|
|
110
107
|
export class OwnerCards implements InteractionModule {
|
|
111
108
|
readonly #options: OwnerCardsOptions;
|
|
109
|
+
readonly #audiences: CardAudiences;
|
|
112
110
|
readonly #open = new Map<string, OpenCard>();
|
|
113
111
|
|
|
114
112
|
constructor(options: OwnerCardsOptions) {
|
|
115
113
|
this.#options = options;
|
|
114
|
+
this.#audiences = new CardAudiences(options, new DiscordOwners(options));
|
|
116
115
|
}
|
|
117
116
|
|
|
118
117
|
commands() {
|
|
119
118
|
return [];
|
|
120
119
|
}
|
|
121
120
|
|
|
122
|
-
/**
|
|
123
|
-
|
|
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 {
|
|
124
126
|
const channelId = parseChannelKey(channel).id;
|
|
125
127
|
return {
|
|
126
|
-
confirm: (title, message, signal, minTier = "owner") =>
|
|
127
|
-
this.#
|
|
128
|
-
|
|
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,
|
|
129
134
|
title,
|
|
130
135
|
sections: [message],
|
|
131
136
|
rows: (id, disabled) => [
|
|
@@ -145,22 +150,26 @@ export class OwnerCards implements InteractionModule {
|
|
|
145
150
|
),
|
|
146
151
|
],
|
|
147
152
|
footer: messages().cardApprovalFooter,
|
|
148
|
-
})
|
|
149
|
-
|
|
150
|
-
|
|
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>(
|
|
151
159
|
channelId,
|
|
152
160
|
signal,
|
|
153
161
|
undefined,
|
|
154
162
|
undefined,
|
|
155
163
|
{
|
|
156
|
-
audience
|
|
164
|
+
audience,
|
|
157
165
|
title,
|
|
158
166
|
sections: [question.question],
|
|
159
167
|
question,
|
|
160
168
|
rows: (id, disabled) => askRows(id, question, disabled),
|
|
161
169
|
footer: messages().cardQuestionFooter,
|
|
162
170
|
},
|
|
163
|
-
)
|
|
171
|
+
);
|
|
172
|
+
},
|
|
164
173
|
};
|
|
165
174
|
}
|
|
166
175
|
|
|
@@ -213,7 +222,7 @@ export class OwnerCards implements InteractionModule {
|
|
|
213
222
|
footer,
|
|
214
223
|
},
|
|
215
224
|
open.mention === true,
|
|
216
|
-
card.audience.
|
|
225
|
+
card.audience.mentions,
|
|
217
226
|
);
|
|
218
227
|
const abort = () => settle(cancelled, messages().cardStopped);
|
|
219
228
|
const timer = setTimeout(
|
|
@@ -249,57 +258,22 @@ export class OwnerCards implements InteractionModule {
|
|
|
249
258
|
}
|
|
250
259
|
|
|
251
260
|
/** The card's panel; in a thread it leads with the mention of who may answer. */
|
|
252
|
-
#panel(
|
|
261
|
+
#panel(
|
|
262
|
+
content: PanelContent,
|
|
263
|
+
mention: boolean,
|
|
264
|
+
userIds: readonly string[],
|
|
265
|
+
): CardPayload {
|
|
253
266
|
const panel = ownerPanel(content);
|
|
254
267
|
if (!mention) return panel;
|
|
255
268
|
return {
|
|
256
269
|
...panel,
|
|
257
270
|
components: [
|
|
258
|
-
new TextDisplayBuilder().setContent(
|
|
271
|
+
new TextDisplayBuilder().setContent(
|
|
272
|
+
userIds.map((id) => `<@${id}>`).join(" "),
|
|
273
|
+
),
|
|
259
274
|
...panel.components,
|
|
260
275
|
],
|
|
261
|
-
allowedMentions: { users: [
|
|
262
|
-
};
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
/** An approval is for the speakers whose tier holds the tool, the owner alone by default. */
|
|
266
|
-
#approvers(minTier: Tier, speaker: Speaker | undefined): Audience {
|
|
267
|
-
const { ownerId, speakers } = this.#options;
|
|
268
|
-
const mentionId =
|
|
269
|
-
speaker && tierAtLeast(speaker.tier, minTier) ? speaker.id : ownerId;
|
|
270
|
-
if (minTier === "owner" || !speakers)
|
|
271
|
-
return {
|
|
272
|
-
allows: (user) => user.id === ownerId,
|
|
273
|
-
mentionId: ownerId,
|
|
274
|
-
note: "",
|
|
275
|
-
refusal: messages().cardOwnerOnly,
|
|
276
|
-
};
|
|
277
|
-
return {
|
|
278
|
-
allows: (user) => {
|
|
279
|
-
const who = speakers.resolve({ ...user, name: "" });
|
|
280
|
-
return who !== undefined && tierAtLeast(who.tier, minTier);
|
|
281
|
-
},
|
|
282
|
-
mentionId,
|
|
283
|
-
note: messages().cardApproversNote(minTier),
|
|
284
|
-
refusal: messages().cardApproversRefusal(minTier),
|
|
285
|
-
};
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
/** A question is for the speaker whose turn asks it, and for the owner. */
|
|
289
|
-
#asker(speaker: Speaker | undefined): Audience {
|
|
290
|
-
const { ownerId } = this.#options;
|
|
291
|
-
if (!speaker || speaker.id === ownerId)
|
|
292
|
-
return {
|
|
293
|
-
allows: (user) => user.id === ownerId,
|
|
294
|
-
mentionId: ownerId,
|
|
295
|
-
note: "",
|
|
296
|
-
refusal: messages().cardOwnerOnly,
|
|
297
|
-
};
|
|
298
|
-
return {
|
|
299
|
-
allows: (user) => user.id === speaker.id || user.id === ownerId,
|
|
300
|
-
mentionId: speaker.id,
|
|
301
|
-
note: messages().cardAskerNote(speaker.id),
|
|
302
|
-
refusal: messages().cardAskerRefusal,
|
|
276
|
+
allowedMentions: { users: [...userIds] },
|
|
303
277
|
};
|
|
304
278
|
}
|
|
305
279
|
|
|
@@ -324,10 +298,8 @@ export class OwnerCards implements InteractionModule {
|
|
|
324
298
|
});
|
|
325
299
|
return true;
|
|
326
300
|
}
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
member && "cache" in member.roles ? [...member.roles.cache.keys()] : [];
|
|
330
|
-
if (!card.audience.allows({ id: interaction.user.id, roleIds })) {
|
|
301
|
+
const user = discordUser(interaction);
|
|
302
|
+
if (!(await card.audience.allows(user))) {
|
|
331
303
|
await interaction.reply({
|
|
332
304
|
content: card.audience.refusal,
|
|
333
305
|
flags: MessageFlags.Ephemeral,
|
|
@@ -343,7 +315,7 @@ export class OwnerCards implements InteractionModule {
|
|
|
343
315
|
footer: outcome,
|
|
344
316
|
},
|
|
345
317
|
card.mention === true,
|
|
346
|
-
card.audience.
|
|
318
|
+
card.audience.mentions,
|
|
347
319
|
);
|
|
348
320
|
if (interaction.isButton() && (action === "yes" || action === "no")) {
|
|
349
321
|
const approved = action === "yes";
|
|
@@ -7,7 +7,13 @@ import {
|
|
|
7
7
|
SlashCommandSubcommandGroupBuilder,
|
|
8
8
|
} from "discord.js";
|
|
9
9
|
import { messages } from "../i18n/index.ts";
|
|
10
|
+
import type { IdentityService } from "../identity/identity-service.ts";
|
|
10
11
|
import type { Logger } from "../log.ts";
|
|
12
|
+
import {
|
|
13
|
+
type DiscordActor,
|
|
14
|
+
DiscordOwners,
|
|
15
|
+
discordUser,
|
|
16
|
+
} from "./discord-owners.ts";
|
|
11
17
|
import type {
|
|
12
18
|
CommandGuard,
|
|
13
19
|
CommandRoot,
|
|
@@ -33,31 +39,36 @@ export const groupOption = (
|
|
|
33
39
|
): RootOption => build(new SlashCommandSubcommandGroupBuilder()).toJSON();
|
|
34
40
|
|
|
35
41
|
/**
|
|
36
|
-
* Only
|
|
37
|
-
*
|
|
42
|
+
* Only an owner may use the root command: the primary owner by user ID, and, with the identity
|
|
43
|
+
* service, every other owner principal's Discord identity, checked again each time. Every answer
|
|
44
|
+
* is visible only to them, and a failure becomes a panel instead of a hanging interaction.
|
|
38
45
|
*/
|
|
39
|
-
// pi-lens-ignore: large-class —
|
|
46
|
+
// pi-lens-ignore: large-class — three methods: the owner checks and the failure panel
|
|
40
47
|
export class OwnerGuard implements CommandGuard {
|
|
41
|
-
readonly #
|
|
48
|
+
readonly #owners: DiscordOwners;
|
|
42
49
|
readonly #logger: Logger;
|
|
43
50
|
/** The name of the root command the guard serves, without the slash. */
|
|
44
51
|
readonly root: string;
|
|
45
52
|
readonly refusalHint: string | undefined;
|
|
46
53
|
|
|
47
54
|
constructor(
|
|
48
|
-
|
|
55
|
+
owners: DiscordOwners,
|
|
49
56
|
logger: Logger,
|
|
50
57
|
root: string,
|
|
51
58
|
refusalHint?: string,
|
|
52
59
|
) {
|
|
53
|
-
this.#
|
|
60
|
+
this.#owners = owners;
|
|
54
61
|
this.#logger = logger;
|
|
55
62
|
this.root = root;
|
|
56
63
|
this.refusalHint = refusalHint;
|
|
57
64
|
}
|
|
58
65
|
|
|
66
|
+
allows(actor: DiscordActor): Promise<boolean> {
|
|
67
|
+
return this.#owners.isOwner(discordUser(actor));
|
|
68
|
+
}
|
|
69
|
+
|
|
59
70
|
isOwner(actor: { user: { id: string } }): boolean {
|
|
60
|
-
return actor.user.id === this.#
|
|
71
|
+
return actor.user.id === this.#owners.primary;
|
|
61
72
|
}
|
|
62
73
|
|
|
63
74
|
/** Runs a handler; failures become a panel instead of a hanging interaction. */
|
|
@@ -82,8 +93,10 @@ export class OwnerGuard implements CommandGuard {
|
|
|
82
93
|
|
|
83
94
|
/** What `commandGuard` needs. */
|
|
84
95
|
export interface CommandGuardOptions {
|
|
85
|
-
/** The
|
|
96
|
+
/** The primary owner's Discord user id, who may always use the owner's commands. */
|
|
86
97
|
ownerId: string;
|
|
98
|
+
/** Which other owners there are and whether they still are; without it only `ownerId` may. */
|
|
99
|
+
identity?: Pick<IdentityService, "resolve" | "owners" | "identities">;
|
|
87
100
|
/** The name of the root command the guard serves, without the slash. */
|
|
88
101
|
root: string;
|
|
89
102
|
/** Where a failing command is logged. */
|
|
@@ -94,9 +107,10 @@ export interface CommandGuardOptions {
|
|
|
94
107
|
|
|
95
108
|
/** A guard for the owner's commands, such as the one a test hands a module; the Discord plugin makes its own. */
|
|
96
109
|
export function commandGuard(options: CommandGuardOptions): CommandGuard {
|
|
110
|
+
const { ownerId, identity, logger } = options;
|
|
97
111
|
return new OwnerGuard(
|
|
98
|
-
|
|
99
|
-
|
|
112
|
+
new DiscordOwners({ ownerId, ...(identity ? { identity } : {}), logger }),
|
|
113
|
+
logger,
|
|
100
114
|
options.root,
|
|
101
115
|
options.refusalHint,
|
|
102
116
|
);
|
|
@@ -121,7 +135,7 @@ const refusal = (hint: string | undefined) =>
|
|
|
121
135
|
sections: [`${messages().ownerRefusalBody}${hint ?? ""}`],
|
|
122
136
|
});
|
|
123
137
|
|
|
124
|
-
/** Answers a feature's root subcommands and components, for the
|
|
138
|
+
/** Answers a feature's root subcommands and components, for the owners only. */
|
|
125
139
|
export function ownerCommandModule(
|
|
126
140
|
guard: CommandGuard,
|
|
127
141
|
handlers: OwnerCommandHandlers,
|
|
@@ -139,14 +153,14 @@ export function ownerCommandModule(
|
|
|
139
153
|
async handle(interaction) {
|
|
140
154
|
if (interaction.isAutocomplete()) {
|
|
141
155
|
if (!owned(interaction)) return false;
|
|
142
|
-
if (!guard.
|
|
156
|
+
if (!(await guard.allows(interaction))) await interaction.respond([]);
|
|
143
157
|
else await handlers.autocomplete?.(interaction);
|
|
144
158
|
return true;
|
|
145
159
|
}
|
|
146
160
|
if (await handlers.component?.(interaction)) return true;
|
|
147
161
|
if (!interaction.isChatInputCommand() || !owned(interaction))
|
|
148
162
|
return false;
|
|
149
|
-
if (!guard.
|
|
163
|
+
if (!(await guard.allows(interaction))) {
|
|
150
164
|
await interaction.reply(refusal(guard.refusalHint));
|
|
151
165
|
return true;
|
|
152
166
|
}
|