pi-roundtable 0.8.0 → 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 +102 -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 +606 -78
- 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 +140 -85
- 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 +78 -41
- package/src/core/contract/channel-context.ts +53 -0
- package/src/core/contract/channels.ts +77 -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 +41 -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/host.ts +27 -8
- package/src/core/i18n/cards.ts +8 -4
- 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/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-router.ts +218 -17
- package/src/core/routing/conversation-turns.ts +38 -8
- package/src/core/routing/surface-port.ts +32 -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 +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 +135 -134
- 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 +50 -5
- 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/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 +47 -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
|
@@ -11,9 +11,17 @@ export type ChannelInfo =
|
|
|
11
11
|
|
|
12
12
|
/** The Discord connection as plugins use it, apart from the chat surface the host drives. */
|
|
13
13
|
export interface DiscordConnection {
|
|
14
|
-
/**
|
|
14
|
+
/**
|
|
15
|
+
* The primary owner's direct-message channel.
|
|
16
|
+
* @deprecated Since 0.9 there may be more than one owner; this is the primary owner's, kept for
|
|
17
|
+
* plugins written for 0.8. Reach a principal through their own channel instead. Goes away in 1.0.
|
|
18
|
+
*/
|
|
15
19
|
ownerChannel(): Promise<ChannelKey>;
|
|
16
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* Sends the primary owner a direct message.
|
|
22
|
+
* @deprecated Since 0.9 there may be more than one owner; this is the primary owner's, kept for
|
|
23
|
+
* plugins written for 0.8. Goes away in 1.0.
|
|
24
|
+
*/
|
|
17
25
|
notifyOwner(text: string): Promise<void>;
|
|
18
26
|
/**
|
|
19
27
|
* What a channel is called: undefined when Discord no longer knows it; throws while the
|
|
@@ -30,6 +38,10 @@ export interface DiscordConnection {
|
|
|
30
38
|
agentDashboard(guildId: string): DashboardBoard;
|
|
31
39
|
/** Threads for background dispatches, in the channel that started each. */
|
|
32
40
|
threadHost(): ThreadHost;
|
|
33
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* Discord reading and management for the owner's agent, checked against what the primary owner
|
|
43
|
+
* may do on the server, whichever owner's turn calls them; calls fail until the connection is
|
|
44
|
+
* ready.
|
|
45
|
+
*/
|
|
34
46
|
ownerOperations(): OwnerOperations;
|
|
35
47
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { ChannelKey } from "../domain/conversation.ts";
|
|
2
|
+
import type { IdentityService } from "../identity/identity-service.ts";
|
|
3
|
+
import type { DirectChannelProvider } from "../presence/direct-channels.ts";
|
|
4
|
+
|
|
5
|
+
export interface DiscordDirectChannelOptions {
|
|
6
|
+
/** A Discord user's direct-message channel; throws when Discord cannot be reached. */
|
|
7
|
+
directChannel(userId: string): Promise<ChannelKey>;
|
|
8
|
+
/** Sends a Discord user a direct message. */
|
|
9
|
+
sendDirect(userId: string, text: string): Promise<void>;
|
|
10
|
+
/**
|
|
11
|
+
* Whom a principal is on Discord, by the Discord identities linked to them. Without it only the
|
|
12
|
+
* primary owner is reached, whose principal 0.8 knew by their Discord user id.
|
|
13
|
+
*/
|
|
14
|
+
identity?: Pick<IdentityService, "identities">;
|
|
15
|
+
/**
|
|
16
|
+
* The primary owner's Discord user id: of a principal's Discord identities, the one reached
|
|
17
|
+
* first, so a single owner is reached in the direct messages 0.8 used.
|
|
18
|
+
*/
|
|
19
|
+
ownerId: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Reaches a person in their Discord direct messages, through a Discord identity linked to their
|
|
24
|
+
* principal; a principal with none is not reached here.
|
|
25
|
+
*/
|
|
26
|
+
export function discordDirectChannel(
|
|
27
|
+
options: DiscordDirectChannelOptions,
|
|
28
|
+
): DirectChannelProvider {
|
|
29
|
+
const { identity, ownerId } = options;
|
|
30
|
+
const userOf = async (principalId: string): Promise<string | undefined> => {
|
|
31
|
+
if (!identity) return principalId === ownerId ? ownerId : undefined;
|
|
32
|
+
const users = (await identity.identities(principalId))
|
|
33
|
+
.filter((link) => link.provider === "discord")
|
|
34
|
+
.map((link) => link.subject);
|
|
35
|
+
return users.includes(ownerId) ? ownerId : users[0];
|
|
36
|
+
};
|
|
37
|
+
return {
|
|
38
|
+
name: "discord",
|
|
39
|
+
label: "a direct message on Discord",
|
|
40
|
+
knows: async (principalId) => (await userOf(principalId)) !== undefined,
|
|
41
|
+
reaches: async (principalId) => {
|
|
42
|
+
const user = await userOf(principalId);
|
|
43
|
+
return user === undefined ? undefined : options.directChannel(user);
|
|
44
|
+
},
|
|
45
|
+
deliver: async (principalId, text) => {
|
|
46
|
+
const user = await userOf(principalId);
|
|
47
|
+
if (user === undefined)
|
|
48
|
+
throw new Error(
|
|
49
|
+
`principal ${principalId} has no Discord identity to send a direct message to`,
|
|
50
|
+
);
|
|
51
|
+
await options.sendDirect(user, text);
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import type { IdentityService } from "../identity/identity-service.ts";
|
|
2
|
+
import type { Logger } from "../log.ts";
|
|
3
|
+
import { type Tier, tierAtLeast } from "../speakers.ts";
|
|
4
|
+
|
|
5
|
+
/** A Discord user who pressed or ran something; their roles are unknown where the interaction carries no member. */
|
|
6
|
+
export interface DiscordUser {
|
|
7
|
+
id: string;
|
|
8
|
+
name: string;
|
|
9
|
+
roleIds?: readonly string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** What a Discord interaction tells of who made it; a test may give only `user.id`. */
|
|
13
|
+
export interface DiscordActor {
|
|
14
|
+
user: { id: string; username?: string; globalName?: string | null };
|
|
15
|
+
member?: unknown;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The user of an interaction, with their roles where its member carries them. */
|
|
19
|
+
export function discordUser(actor: DiscordActor): DiscordUser {
|
|
20
|
+
const { user, member } = actor;
|
|
21
|
+
const roles =
|
|
22
|
+
typeof member === "object" && member !== null && "roles" in member
|
|
23
|
+
? member.roles
|
|
24
|
+
: undefined;
|
|
25
|
+
const roleIds =
|
|
26
|
+
typeof roles === "object" &&
|
|
27
|
+
roles !== null &&
|
|
28
|
+
"cache" in roles &&
|
|
29
|
+
roles.cache instanceof Map
|
|
30
|
+
? [...roles.cache.keys()].map(String)
|
|
31
|
+
: undefined;
|
|
32
|
+
return {
|
|
33
|
+
id: user.id,
|
|
34
|
+
name: user.globalName ?? user.username ?? user.id,
|
|
35
|
+
...(roleIds ? { roleIds } : {}),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface DiscordOwnersOptions {
|
|
40
|
+
/** The primary owner's Discord user id, an owner whatever the identity service says. */
|
|
41
|
+
ownerId: string;
|
|
42
|
+
/** Who holds a tier and which owners have a Discord identity; without it the primary owner is the only owner. */
|
|
43
|
+
identity?: Pick<IdentityService, "resolve" | "owners" | "identities">;
|
|
44
|
+
logger: Logger;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The owners as Discord knows them: the primary owner by their user id, and every other owner
|
|
49
|
+
* principal's Discord identities, each checked again as they act. Only an owner's own identity is
|
|
50
|
+
* ever resolved, so someone else's press or command admits no one.
|
|
51
|
+
*/
|
|
52
|
+
export class DiscordOwners {
|
|
53
|
+
readonly #options: DiscordOwnersOptions;
|
|
54
|
+
|
|
55
|
+
constructor(options: DiscordOwnersOptions) {
|
|
56
|
+
this.#options = options;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The primary owner's Discord user id. */
|
|
60
|
+
get primary(): string {
|
|
61
|
+
return this.#options.ownerId;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The primary owner, then every other owner's Discord user ids. */
|
|
65
|
+
async ids(): Promise<string[]> {
|
|
66
|
+
const { ownerId, identity, logger } = this.#options;
|
|
67
|
+
if (!identity) return [ownerId];
|
|
68
|
+
const ids = new Set([ownerId]);
|
|
69
|
+
try {
|
|
70
|
+
for (const owner of await identity.owners())
|
|
71
|
+
for (const link of await identity.identities(owner.id))
|
|
72
|
+
if (link.provider === "discord") ids.add(link.subject);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
logger.warn(
|
|
75
|
+
{ err: error },
|
|
76
|
+
"could not read the owners' identities; only the primary owner counts",
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
return [...ids];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Whether the user is an owner now: the primary owner, or another owner's Discord identity at the owner tier. */
|
|
83
|
+
async isOwner(user: DiscordUser): Promise<boolean> {
|
|
84
|
+
if (user.id === this.#options.ownerId) return true;
|
|
85
|
+
if (!(await this.ids()).includes(user.id)) return false;
|
|
86
|
+
return this.holds(user, "owner");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Whether the principal is an owner's; false when it cannot be read. */
|
|
90
|
+
async isOwnerPrincipal(principalId: string): Promise<boolean> {
|
|
91
|
+
const { identity, logger } = this.#options;
|
|
92
|
+
if (!identity) return false;
|
|
93
|
+
try {
|
|
94
|
+
const owners = await identity.owners();
|
|
95
|
+
return owners.some((owner) => owner.id === principalId);
|
|
96
|
+
} catch (error) {
|
|
97
|
+
logger.warn(
|
|
98
|
+
{ principal: principalId, err: error },
|
|
99
|
+
"could not read the owners; the principal is not counted as one",
|
|
100
|
+
);
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Whether the user holds `minTier` as they act, as the principal named when there is one;
|
|
107
|
+
* without `minTier` anyone does. Call it only for someone already known, so it admits no one.
|
|
108
|
+
*/
|
|
109
|
+
async holds(
|
|
110
|
+
user: DiscordUser,
|
|
111
|
+
minTier: Tier | undefined,
|
|
112
|
+
principalId?: string,
|
|
113
|
+
): Promise<boolean> {
|
|
114
|
+
const { identity } = this.#options;
|
|
115
|
+
if (!minTier) return true;
|
|
116
|
+
if (!identity) return false;
|
|
117
|
+
const who = await identity.resolve({
|
|
118
|
+
provider: "discord",
|
|
119
|
+
subject: user.id,
|
|
120
|
+
name: user.name,
|
|
121
|
+
surface: "discord",
|
|
122
|
+
...(user.roleIds
|
|
123
|
+
? { roles: user.roleIds.map((role) => `discord:role:${role}`) }
|
|
124
|
+
: {}),
|
|
125
|
+
legacyId: user.id,
|
|
126
|
+
});
|
|
127
|
+
return (
|
|
128
|
+
who !== undefined &&
|
|
129
|
+
(principalId === undefined || who.principalId === principalId) &&
|
|
130
|
+
tierAtLeast(who.tier, minTier)
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -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);
|