pi-roundtable 0.31.8 → 0.32.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 +13 -0
- package/README.md +5 -0
- package/docs/plugins.md +9 -2
- package/package.json +1 -1
- package/src/cli/checks/discord.ts +20 -2
- package/src/core/builtin/discord.ts +11 -1
- package/src/core/config/adapters.ts +10 -0
- package/src/core/define-roundtable.ts +1 -0
- package/src/core/discord/channel-info.ts +38 -0
- package/src/core/discord/discord-surface.ts +32 -44
- package/src/core/discord/member-events.ts +44 -0
- package/src/core/discord/poster/poster-errors.ts +6 -2
- package/src/core/discord/poster/poster-roles.ts +167 -0
- package/src/core/discord/poster/poster.ts +5 -1
- package/src/core/discord/poster/rest-poster-core.ts +2 -0
- package/src/core/discord/poster/rest-poster-roles.ts +150 -0
- package/src/core/discord/poster/rest-poster.ts +2 -0
- package/src/core/event-types.ts +27 -0
- package/src/core/events.ts +1 -0
- package/src/core/plugin.ts +10 -14
- package/src/core/testing/fake-poster-roles.ts +155 -0
- package/src/core/testing/fake-poster.ts +12 -2
- package/src/discord/index.ts +3 -0
- package/src/index.ts +1 -0
- package/src/testing.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,19 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.32.1] - 2026-11-04
|
|
9
|
+
|
|
10
|
+
- Release in lockstep with pi-roundtable-web 0.32.1 (optimistic mark-as-read, a Home button at the foot of the room drawer, Discord's -# subtext and line breaks in messages, the shared prompt's description in zh-TW, and the push device list centred); no core behavior changes.
|
|
11
|
+
|
|
12
|
+
## [0.32.0] - 2026-11-03
|
|
13
|
+
|
|
14
|
+
This is a minor release: `DiscordPoster` gains four required methods and `DiscordReason` three members, so an outside implementer of the poster or an exhaustive switch over the reasons must be updated.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- `discord.memberEvents` (default `false`) asks Discord for the privileged Server Members intent and reports each member that joins the server (the `MemberJoinedEvent` type, exported from the main entry) as the new plugin event `memberJoined({ surface: "discord", guildId, userId, bot })`. With it off no intent is added and no join is reported, so a deployment without the Developer Portal switch starts as before. `roundtable doctor` checks the Server Members intent when it is on and says where to turn it on; a login Discord refuses for it names the same switch.
|
|
19
|
+
- `DISCORD.poster` reads and changes member roles: `memberRoles(guildId, userId)`, `addMemberRoles` and `removeMemberRoles(guildId, userId, roleIds, reason)`, and `ensureRole(guildId, name, reason)`. A change gives or takes only the roles named, with the required reason in the audit log, and refuses before giving any the @everyone role, a managed role, and a role at or above the bot's highest. `ensureRole` finds a role by exact name, makes one with no permissions when there is none, and never edits an existing one. `DiscordReason` gains `role_unassignable`, `unknown_role` and `not_member`; `fakeDiscordPoster()` models the same rules (`roles`, `botRoles`, `memberRoleIds`, `roleChanges`, each change a `FakeRoleChange`). `ROLE_LIMITS` (name, reason and per-call bounds), `GuildRole` and `MemberRoles` (the poster's member and role calls) are exported from `pi-roundtable/discord`.
|
|
20
|
+
|
|
8
21
|
## [0.31.8] - 2026-11-03
|
|
9
22
|
|
|
10
23
|
- Release in lockstep with pi-roundtable-web 0.31.8 (the room drawer slides on from where the finger lets it go, without a frame shut); no core behavior changes.
|
package/README.md
CHANGED
|
@@ -246,6 +246,11 @@ When a conversation is started over in a server channel or thread, the assistant
|
|
|
246
246
|
| text | `─── new conversation ───` | The divider's text |
|
|
247
247
|
| `false` | | Post no divider |
|
|
248
248
|
|
|
249
|
+
| `discord.memberEvents` | Default | What it is |
|
|
250
|
+
| --- | --- | --- |
|
|
251
|
+
| `false` | `false` | The bot asks Discord for no privileged intent beyond Message Content |
|
|
252
|
+
| `true` | | Plugins hear each member that joins the server (`memberJoined`). Needs Server Members Intent switched on under Bot in the Developer Portal; `roundtable doctor` checks it |
|
|
253
|
+
|
|
249
254
|
| `discord.formatting` | Default | What it is |
|
|
250
255
|
| --- | --- | --- |
|
|
251
256
|
| text | Markdown renders except tables | What the model is told, in English, about how a reply renders in Discord; appended to the system prompt of every turn that replies there |
|
package/docs/plugins.md
CHANGED
|
@@ -1335,6 +1335,7 @@ export const library = definePlugin({
|
|
|
1335
1335
|
| `turnProgress(event)` | A turn run through `context.turns` wrote text or ran a tool, between its start and its end: `event` is the turn's fields and `progress`, a `TurnProgress` (see the surface's `progress` below); a runtime without live progress reports none |
|
|
1336
1336
|
| `changed()` | The team changed: an agent or group was created, edited, arranged, archived, or started over |
|
|
1337
1337
|
| `action(event)` | A plugin reported something a person did through its surface with `context.events.action?.(...)`: `event` is `{ kind, actor, principalId, channel?, ok, at }`, with `kind` in the reporting plugin's own words (a support inbox says `"assign"`, `"reply"` or `"close"`), `actor` the `Speaker.id`, and `channel` a `ChannelKey` or a `page:<name>`. It carries no content. The core reports none itself; a plugin hears the others' |
|
|
1338
|
+
| `memberJoined(event)` | Someone joined the Discord server: `event` is `{ surface: "discord", guildId, userId, bot }`. Reported only when the host's `discord.memberEvents` is on, which asks Discord for the privileged Server Members intent; otherwise never. A plugin that needs it says so in its own setup notes. Read the member's roles and give or take roles with `DISCORD.poster` ([Member roles](#member-roles)) |
|
|
1338
1339
|
| `shutdown(left)` | The shutdown drain ended, before any service stops; `left` lists the work it gave up on |
|
|
1339
1340
|
|
|
1340
1341
|
The host logs errors thrown by a handler and continues running the others.
|
|
@@ -2911,7 +2912,7 @@ await poster.edit(channelId, posted.messageId, { content: "Hello again" }, ["rel
|
|
|
2911
2912
|
await poster.remove(channelId, posted.messageId, ["relay"]);
|
|
2912
2913
|
```
|
|
2913
2914
|
|
|
2914
|
-
What it covers: webhook posts under names you choose (`post`, `edit`, `remove`, `webhookIds`, `setAvatar`, `onWebhookMade`), cards posted through such a webhook (`postCard`, `editWebhookCard`), the bot's own messages and cards (`say`, `sayCard`, `editCard`, `deleteAsBot`), direct messages (`sendDirect`), pins, reading (`message`, `recentMessages`, `botUserId`, `channelGuild`, `canView`, `memberName`), text channels (`createTextChannel`, `textChannels`, `deleteChannel`, `renameChannel`, `channelKind`), threads (`startThread`, `setThreadArchived`, `threadState`, `threadCloser`) and forums (`forumTags`, `setForumTags`, `createForumPost`, `setPostTags`).
|
|
2915
|
+
What it covers: webhook posts under names you choose (`post`, `edit`, `remove`, `webhookIds`, `setAvatar`, `onWebhookMade`), cards posted through such a webhook (`postCard`, `editWebhookCard`), the bot's own messages and cards (`say`, `sayCard`, `editCard`, `deleteAsBot`), direct messages (`sendDirect`), pins, reading (`message`, `recentMessages`, `botUserId`, `channelGuild`, `canView`, `memberName`), member roles (`memberRoles`, `addMemberRoles`, `removeMemberRoles`, `ensureRole`), text channels (`createTextChannel`, `textChannels`, `deleteChannel`, `renameChannel`, `channelKind`), threads (`startThread`, `setThreadArchived`, `threadState`, `threadCloser`) and forums (`forumTags`, `setForumTags`, `createForumPost`, `setPostTags`).
|
|
2915
2916
|
Use `DISCORD.connection.channelInfo(channelId)` to read what a channel is called now: it comes from the gateway's cache.
|
|
2916
2917
|
|
|
2917
2918
|
The rules the poster keeps, so a plugin does not rediscover them:
|
|
@@ -2945,7 +2946,8 @@ await poster.postCard(channelId, {
|
|
|
2945
2946
|
- **Forums.** `forumTags(forumId)` lists a forum's tags (`{ id, name, moderated, emoji? | emojiId? }`). `setForumTags(forumId, tags)` replaces the whole set (Manage Channels), because Discord only takes it whole: a tag sent with its `id` is kept and may be renamed, one without is made, one left out is deleted, so add a tag with `setForumTags(id, [...(await forumTags(id)), { name: "new" }])`. A forum holds at most 20 tags, a name 1 to 20 characters, a tag has a Unicode `emoji` or a custom `emojiId`, not both. A forum call on a channel that is no forum is `invalid` with `not_forum`. Making a forum channel is not in the poster.
|
|
2946
2947
|
- **Forum posts.** `createForumPost(forumId, { name, card | content, appliedTags?, autoArchive?, mentions? })` returns `{ threadId, messageId }`. Discord takes a post and its first message in one request, so the first message is part of the call: a card (Components V2, or a preview card) or plain text (`mentions` names the users it may ping, as `say`'s). A post's first message has the thread's own id, so `messageId` equals `threadId`; `editCard(threadId, messageId, card)` edits a card first message, `pin`, `say` and `sayCard` take the thread id as a channel's. `appliedTags` is at most five ids the forum offers (a tag it does not offer is `rejected`); `autoArchive` is 60, 1,440, 4,320 or 10,080 minutes, a week by default. `setPostTags(threadId, tagIds)` replaces a post's tags (none clears them); a thread that is not a forum post is `invalid` with `not_forum`. `forbidden` without Create Posts. A forum takes posts, not messages: `say`, `sayCard` and a webhook `post` into the forum's own id are `rejected`. **An archived post.** A post nobody writes in archives itself (a week by default), and Discord refuses every edit of an archived thread (400, code 50083). `setPostTags` and `renameChannel` therefore send `archived: false` in the same request, which opens the post again; `editCard(threadId, threadId, card)` on a post's first message opens an archived post and sends the edit again. `editCard` on any other message of an archived thread is `rejected`: call `setThreadArchived(threadId, false)` first (`say` and `sayCard` already wake an archived thread). A locked post stays `forbidden` without Manage Threads. Scheduled events and moving channels between categories are not in the poster.
|
|
2947
2948
|
- **Threads.** `startThread` makes a public thread on a message that archives itself after a week and wakes on the next post. The bot needs no Manage Threads to archive or open a thread it started; a locked thread needs it to open again (`forbidden`). `threadCloser` reads the guild's audit log (View Audit Log, else `forbidden`): a person's id, the bot, or `unknown` for Discord's own auto-archive. An archive counts only if its entry is within two minutes of the thread's `archive_timestamp`. It reads the newest 50 `THREAD_UPDATE` entries, so on a busy guild an old close may have left the log and read as `unknown`.
|
|
2948
|
-
- **
|
|
2949
|
+
- **Member roles.** <a id="member-roles"></a>`memberRoles(guildId, userId)` lists the ids of the roles a member holds (not @everyone's), or `undefined` for someone who is not a member. `addMemberRoles(guildId, userId, roleIds, reason)` and `removeMemberRoles(...)` give or take exactly the roles named, one by one, and never send or touch the member's other roles; a role the member already holds (or lacks) is left as it is. `reason` is required: 1 to 512 characters, shown in the guild's audit log. A call takes 1 to 20 role ids, and all are checked before the first is given; after that the call is not atomic: a Discord failure (a 5xx, a timeout) may leave the earlier roles of the call changed, the error names the role that failed, and since each change is idempotent you retry the whole call. The poster refuses, giving none: the @everyone role (`invalid`), a managed role (an integration's or a bot's) and a role at or above the bot's highest role (`forbidden`, both with `reason: "role_unassignable"`), and a role the guild does not have (`rejected`, `unknown_role`); a user who is not a member is `rejected` with `not_member`. Manage Roles is needed, else `forbidden`. `ensureRole(guildId, name, reason)` returns the id of the guild's role of that exact name (1 to 100 characters; not @everyone's or a managed one; with several, the oldest) and makes one with no permissions, no colour, not shown apart and not mentionable when there is none. It never edits a role that exists, and two calls for the same name at the same time make one role within one process (separate processes do not coordinate and may make two). A role that already exists is returned even when it sits at or above the bot's highest role; the next `addMemberRoles` for it then refuses with `role_unassignable`, so move the bot's role above it. A role it makes sits just above @everyone, so the bot can give it at once. Joins are reported by the [`memberJoined` event](#events-hear-what-the-core-does) when `discord.memberEvents` is on; reading and changing roles needs no intent.
|
|
2950
|
+
- **Errors.** Every failure is a `DiscordPosterError` with a `kind`: `invalid` (refused before sending), `channel_gone`, `message_gone`, `forbidden` (a missing permission), `rejected` (Discord refused; asking again fails again) and `unavailable` (Discord or the network failed, or a rate limit held the call; `retryable`). `reason` says more for a full category (`category_full`), a channel that is no category (`not_category`), a channel that is no forum or a thread that is no forum post (`not_forum`), one in no guild (`wrong_guild`), a role the bot may not give or take (`role_unassignable`), a role the guild does not have (`unknown_role`) and a user who is not in the guild (`not_member`). A webhook deleted by hand is found or made again once. A person who takes no direct messages is the answer `"closed"`, and a user who is not a member is `false` from `canView` and `undefined` from `memberName`, not failures. A message never holds a URL, a webhook token or the bot token.
|
|
2949
2951
|
|
|
2950
2952
|
`DISCORD_LIMITS` (frozen) holds the limits the poster checks: 2,000 characters a post, 80 for a username, 10 files and 10 MiB a post, 1 MiB for an avatar, 20 mentions, 250 pins, 100 characters for a channel name, 3,800 characters of card text, 256 for the title of a preview card, 5 card buttons and 25 menu choices, 20 forum tags, 20 characters for a tag name and 5 tags on a post.
|
|
2951
2953
|
|
|
@@ -4551,6 +4553,7 @@ Import from the entries listed below; source area files are internal.
|
|
|
4551
4553
|
| `MAX_ATTACHMENT_BYTES` | `pi-roundtable` | value |
|
|
4552
4554
|
| `MODES` | `pi-roundtable` | value |
|
|
4553
4555
|
| `MEMORY_KINDS` | `pi-roundtable` | value |
|
|
4556
|
+
| `MemberJoinedEvent` | `pi-roundtable` | type |
|
|
4554
4557
|
| `Memory` | `pi-roundtable` | type |
|
|
4555
4558
|
| `MemoryError` | `pi-roundtable` | value |
|
|
4556
4559
|
| `MemoryKind` | `pi-roundtable` | type |
|
|
@@ -4784,6 +4787,7 @@ Import from the entries listed below; source area files are internal.
|
|
|
4784
4787
|
| `fakePrechecks` | `pi-roundtable/testing` | value |
|
|
4785
4788
|
| `FakePrecheck` | `pi-roundtable/testing` | type |
|
|
4786
4789
|
| `FakePrecheckAnswer` | `pi-roundtable/testing` | type |
|
|
4790
|
+
| `FakeRoleChange` | `pi-roundtable/testing` | type |
|
|
4787
4791
|
| `fakeScriptRunner` | `pi-roundtable/testing` | value |
|
|
4788
4792
|
| `FakeScriptAnswer` | `pi-roundtable/testing` | type |
|
|
4789
4793
|
| `FakeScriptRunner` | `pi-roundtable/testing` | type |
|
|
@@ -4994,6 +4998,9 @@ Import from the entries listed below; source area files are internal.
|
|
|
4994
4998
|
| `DiscordConnection` | `pi-roundtable/discord` | type |
|
|
4995
4999
|
| `DiscordServices` | `pi-roundtable/discord` | type |
|
|
4996
5000
|
| `DISCORD_LIMITS` | `pi-roundtable/discord` | value |
|
|
5001
|
+
| `ROLE_LIMITS` | `pi-roundtable/discord` | value |
|
|
5002
|
+
| `GuildRole` | `pi-roundtable/discord` | type |
|
|
5003
|
+
| `MemberRoles` | `pi-roundtable/discord` | type |
|
|
4997
5004
|
| `DiscordPosterError` | `pi-roundtable/discord` | value |
|
|
4998
5005
|
| `CardButton` | `pi-roundtable/discord` | type |
|
|
4999
5006
|
| `CardLink` | `pi-roundtable/discord` | type |
|
package/package.json
CHANGED
|
@@ -10,6 +10,8 @@ import { fail, ok, type Result, skipped } from "../report.ts";
|
|
|
10
10
|
|
|
11
11
|
const PORTAL = "https://discord.com/developers/applications";
|
|
12
12
|
const MESSAGE_CONTENT_FLAGS = (1 << 18) | (1 << 19);
|
|
13
|
+
/** The application flags for the Server Members intent, granted or limited. */
|
|
14
|
+
const GUILD_MEMBERS_FLAGS = (1 << 14) | (1 << 15);
|
|
13
15
|
|
|
14
16
|
const object = (value: unknown): Record<string, unknown> =>
|
|
15
17
|
typeof value === "object" && value !== null
|
|
@@ -61,6 +63,12 @@ export class Discord {
|
|
|
61
63
|
return this.#api;
|
|
62
64
|
}
|
|
63
65
|
|
|
66
|
+
/** Whether the configuration asks for member joins (`discord.memberEvents`), which needs the Server Members intent. */
|
|
67
|
+
async wantsMemberEvents(): Promise<boolean> {
|
|
68
|
+
const raw = await this.#project.raw();
|
|
69
|
+
return raw.ok && object(object(raw.value).discord).memberEvents === true;
|
|
70
|
+
}
|
|
71
|
+
|
|
64
72
|
/** Who the token belongs to. */
|
|
65
73
|
bot(): Promise<Step<Bot>> {
|
|
66
74
|
this.#bot ??= this.#identify();
|
|
@@ -129,7 +137,10 @@ export async function checkGuild(
|
|
|
129
137
|
);
|
|
130
138
|
}
|
|
131
139
|
|
|
132
|
-
/**
|
|
140
|
+
/**
|
|
141
|
+
* The Message Content intent is switched on, without which the agents cannot read the channels,
|
|
142
|
+
* and, when `discord.memberEvents` is on, the Server Members intent too.
|
|
143
|
+
*/
|
|
133
144
|
export async function checkIntents(discord: Discord): Promise<Result> {
|
|
134
145
|
const bot = await discord.bot();
|
|
135
146
|
if (!bot.ok) return skipped("the token check failed");
|
|
@@ -148,7 +159,14 @@ export async function checkIntents(discord: Discord): Promise<Result> {
|
|
|
148
159
|
"the Message Content intent is off.",
|
|
149
160
|
`Turn on Message Content Intent under Bot in ${PORTAL}, save, and run the check again.`,
|
|
150
161
|
);
|
|
151
|
-
|
|
162
|
+
if (!(await discord.wantsMemberEvents()))
|
|
163
|
+
return ok("the Message Content intent is on");
|
|
164
|
+
if ((flags & GUILD_MEMBERS_FLAGS) === 0)
|
|
165
|
+
return fail(
|
|
166
|
+
"the Server Members intent is off, and discord.memberEvents asks for it.",
|
|
167
|
+
`Turn on Server Members Intent under Bot in ${PORTAL}, save, and run the check again, or set discord.memberEvents to false.`,
|
|
168
|
+
);
|
|
169
|
+
return ok("the Message Content and Server Members intents are on");
|
|
152
170
|
}
|
|
153
171
|
|
|
154
172
|
/** The entry channel exists in the guild and the bot may do in it what the agents do; skipped when the agent server is off, which has no entry channel. */
|
|
@@ -82,6 +82,12 @@ export interface DiscordOptions {
|
|
|
82
82
|
peerBots?: PeerBotsConfig;
|
|
83
83
|
/** The divider posted in a server channel when its conversation starts over; `false` posts none. Default: a short English divider. */
|
|
84
84
|
freshMarker?: string | false;
|
|
85
|
+
/**
|
|
86
|
+
* Asks Discord for the privileged Server Members intent and reports each member that joins as
|
|
87
|
+
* the `memberJoined` event. Needs the intent switched on under Bot in the Developer Portal, or
|
|
88
|
+
* the connection is refused. Default: off.
|
|
89
|
+
*/
|
|
90
|
+
memberEvents?: boolean;
|
|
85
91
|
/** What the model is told about how replies render in Discord; `false` tells it nothing. Default: Markdown renders, except tables. */
|
|
86
92
|
formatting?: string | false;
|
|
87
93
|
/**
|
|
@@ -120,7 +126,7 @@ export function discordPlugin(options: DiscordOptions): RoundtablePlugin {
|
|
|
120
126
|
collection.compose(ownerRootCommand(options.rootCommand)),
|
|
121
127
|
);
|
|
122
128
|
},
|
|
123
|
-
setup: async ({ conversations, services, logger }) => {
|
|
129
|
+
setup: async ({ conversations, services, logger, events }) => {
|
|
124
130
|
const identity = services.find(IDENTITY);
|
|
125
131
|
// A host that keeps no copy texts on disk drops what an earlier run left.
|
|
126
132
|
if (options.cardCopies === false)
|
|
@@ -174,8 +180,12 @@ export function discordPlugin(options: DiscordOptions): RoundtablePlugin {
|
|
|
174
180
|
...(options.formatting === undefined
|
|
175
181
|
? {}
|
|
176
182
|
: { formatting: options.formatting }),
|
|
183
|
+
...(options.memberEvents ? { memberEvents: true } : {}),
|
|
177
184
|
});
|
|
178
185
|
surface = connected;
|
|
186
|
+
// Joins come only with the Server Members intent, which `memberEvents` asks for.
|
|
187
|
+
if (options.memberEvents)
|
|
188
|
+
connected.onMemberJoined((event) => events.memberJoined?.(event));
|
|
179
189
|
// Every owner may use the owner's commands, by the identity service; the primary owner always.
|
|
180
190
|
const guard = commandGuard({
|
|
181
191
|
ownerId: options.ownerId,
|
|
@@ -66,6 +66,13 @@ export interface DiscordConfig {
|
|
|
66
66
|
* never in a direct message. Default `─── new conversation ───`; `false` posts none.
|
|
67
67
|
*/
|
|
68
68
|
freshMarker?: string | false;
|
|
69
|
+
/**
|
|
70
|
+
* Asks Discord for the privileged Server Members intent, so plugins hear each member that
|
|
71
|
+
* joins the server as the `memberJoined` event. Turn on Server Members Intent under Bot in
|
|
72
|
+
* the Developer Portal first: with it off there, the connection is refused, and `roundtable
|
|
73
|
+
* doctor` says so. Default `false`, which needs no portal change.
|
|
74
|
+
*/
|
|
75
|
+
memberEvents?: boolean;
|
|
69
76
|
/**
|
|
70
77
|
* What the model is told, English, about how a reply renders in Discord, appended to the system
|
|
71
78
|
* prompt of every turn that replies there, after the persona. The default says Markdown
|
|
@@ -113,6 +120,7 @@ export interface ResolvedDiscord {
|
|
|
113
120
|
channelContext: NonNullable<DiscordConfig["channelContext"]>;
|
|
114
121
|
peerBots?: PeerBotsConfig;
|
|
115
122
|
freshMarker?: string | false;
|
|
123
|
+
memberEvents: boolean;
|
|
116
124
|
formatting?: string | false;
|
|
117
125
|
cardGraceSeconds: number;
|
|
118
126
|
/** As configured: `{}` when left out, so the defaults apply, or `false`. */
|
|
@@ -148,6 +156,7 @@ export function resolvedDiscord(
|
|
|
148
156
|
...(discord.freshMarker === undefined
|
|
149
157
|
? {}
|
|
150
158
|
: { freshMarker: discord.freshMarker }),
|
|
159
|
+
memberEvents: discord.memberEvents ?? false,
|
|
151
160
|
...(discord.formatting === undefined
|
|
152
161
|
? {}
|
|
153
162
|
: { formatting: discord.formatting }),
|
|
@@ -195,6 +204,7 @@ export const discordShape = shape({
|
|
|
195
204
|
channelContext: optional(orOff(channelContextShape)),
|
|
196
205
|
peerBots: optional(peerBotsShape),
|
|
197
206
|
freshMarker: optional(orOff(text)),
|
|
207
|
+
memberEvents: optional(bool),
|
|
198
208
|
formatting: optional(orOff(text)),
|
|
199
209
|
cardGraceSeconds: optional(integer(1, 86_400)),
|
|
200
210
|
cardCopies: optional(
|
|
@@ -165,6 +165,7 @@ function discordAssembly(
|
|
|
165
165
|
...(discord.formatting === undefined
|
|
166
166
|
? {}
|
|
167
167
|
: { formatting: discord.formatting }),
|
|
168
|
+
...(discord.memberEvents ? { memberEvents: true } : {}),
|
|
168
169
|
cardGraceMs: discord.cardGraceSeconds * 1_000,
|
|
169
170
|
cardCopies: discord.cardCopies,
|
|
170
171
|
}),
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { ChannelType, type Client, DiscordAPIError } from "discord.js";
|
|
2
|
+
import type { ChannelInfo } from "./connection.ts";
|
|
3
|
+
|
|
4
|
+
/** Discord's error code for a channel that does not exist or cannot be seen. */
|
|
5
|
+
const UNKNOWN_CHANNEL = 10003;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What a channel is called, for the web app's lists: undefined when Discord no longer knows
|
|
9
|
+
* it; throws while the connection is not ready or Discord cannot be reached.
|
|
10
|
+
*/
|
|
11
|
+
export async function channelInfoOf(
|
|
12
|
+
client: Client,
|
|
13
|
+
channelId: string,
|
|
14
|
+
): Promise<ChannelInfo | undefined> {
|
|
15
|
+
if (!client.isReady()) throw new Error("Discord is not connected");
|
|
16
|
+
const channel = await client.channels
|
|
17
|
+
.fetch(channelId)
|
|
18
|
+
.catch((error: unknown) => {
|
|
19
|
+
if (error instanceof DiscordAPIError && error.code === UNKNOWN_CHANNEL)
|
|
20
|
+
return null;
|
|
21
|
+
throw error;
|
|
22
|
+
});
|
|
23
|
+
if (!channel) return undefined;
|
|
24
|
+
if (channel.isDMBased())
|
|
25
|
+
return {
|
|
26
|
+
kind: "dm",
|
|
27
|
+
name:
|
|
28
|
+
channel.type === ChannelType.DM
|
|
29
|
+
? (channel.recipient?.username ?? "")
|
|
30
|
+
: (channel.name ?? ""),
|
|
31
|
+
};
|
|
32
|
+
return {
|
|
33
|
+
kind: "guild",
|
|
34
|
+
name: channel.name,
|
|
35
|
+
guild: channel.guild.name,
|
|
36
|
+
guildId: channel.guild.id,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
AttachmentBuilder,
|
|
3
|
-
ChannelType,
|
|
4
3
|
Client,
|
|
5
|
-
DiscordAPIError,
|
|
6
4
|
Events,
|
|
7
5
|
GatewayIntentBits,
|
|
8
6
|
type Interaction,
|
|
@@ -27,6 +25,7 @@ import type {
|
|
|
27
25
|
} from "../domain/conversation.ts";
|
|
28
26
|
import type { InterimPosts } from "../domain/interim.ts";
|
|
29
27
|
import type { OwnerNotifier } from "../domain/ports.ts";
|
|
28
|
+
import type { MemberJoinedEvent } from "../event-types.ts";
|
|
30
29
|
import { messages } from "../i18n/index.ts";
|
|
31
30
|
import type { PromptScope, Prompts } from "../interactions/prompts.ts";
|
|
32
31
|
import type { Logger } from "../log.ts";
|
|
@@ -39,6 +38,7 @@ import {
|
|
|
39
38
|
contextSourceOf,
|
|
40
39
|
} from "./channel-context.ts";
|
|
41
40
|
import { discordChannelExecutor } from "./channel-executor.ts";
|
|
41
|
+
import { channelInfoOf } from "./channel-info.ts";
|
|
42
42
|
import type { ChannelExecutor } from "./channel-operations.ts";
|
|
43
43
|
import type { ComposedCommands } from "./compose-commands.ts";
|
|
44
44
|
import type { ChannelInfo, DiscordConnection } from "./connection.ts";
|
|
@@ -47,15 +47,17 @@ import { DISCORD_FORMATTING, pickFormatting } from "./formatting.ts";
|
|
|
47
47
|
import { DEFAULT_FRESH_MARKER, postFreshMarker } from "./fresh-marker.ts";
|
|
48
48
|
import { lateAnswerMessage } from "./inbound-message.ts";
|
|
49
49
|
import type { IntercomTexts } from "./intercom-cards.ts";
|
|
50
|
+
import {
|
|
51
|
+
explainIntents,
|
|
52
|
+
listenForJoins,
|
|
53
|
+
memberIntents,
|
|
54
|
+
} from "./member-events.ts";
|
|
50
55
|
import { listenForMessages, reportChanges } from "./message-changes.ts";
|
|
51
56
|
import type { CardChannel, LateTurn } from "./owner-cards.ts";
|
|
52
57
|
import { DiscordOwnerOps } from "./owner-discord.ts";
|
|
53
58
|
import { showStopPanel } from "./stop-button.ts";
|
|
54
59
|
|
|
55
60
|
const PREFIX = "discord:";
|
|
56
|
-
/** Discord's error code for a channel that does not exist or cannot be seen. */
|
|
57
|
-
const UNKNOWN_CHANNEL = 10003;
|
|
58
|
-
|
|
59
61
|
const TYPING_REFRESH_MS = 8_000;
|
|
60
62
|
|
|
61
63
|
export interface DiscordSurfaceOptions {
|
|
@@ -69,6 +71,8 @@ export interface DiscordSurfaceOptions {
|
|
|
69
71
|
logger: Logger;
|
|
70
72
|
/** The divider posted in a server channel when its conversation starts over; `false` posts none. Default: `DEFAULT_FRESH_MARKER`. */
|
|
71
73
|
freshMarker?: string | false;
|
|
74
|
+
/** Asks Discord for the privileged Server Members intent, so `onMemberJoined` hears joins. Default: off. */
|
|
75
|
+
memberEvents?: boolean;
|
|
72
76
|
/** What the model is told about how replies render here; `false` tells it nothing. Default: `DISCORD_FORMATTING`. */
|
|
73
77
|
formatting?: string | false;
|
|
74
78
|
/** Where the agents' message cards keep their whole texts for the open button; without it a card posts as text. */
|
|
@@ -101,22 +105,24 @@ export class DiscordSurface
|
|
|
101
105
|
close: (threadId) => this.threadHost().close(threadId),
|
|
102
106
|
});
|
|
103
107
|
readonly #options: DiscordSurfaceOptions;
|
|
104
|
-
readonly #client
|
|
105
|
-
intents: [
|
|
106
|
-
GatewayIntentBits.Guilds,
|
|
107
|
-
GatewayIntentBits.GuildMessages,
|
|
108
|
-
GatewayIntentBits.DirectMessages,
|
|
109
|
-
GatewayIntentBits.MessageContent,
|
|
110
|
-
],
|
|
111
|
-
partials: [Partials.Channel, Partials.Message],
|
|
112
|
-
allowedMentions: { parse: [] },
|
|
113
|
-
});
|
|
108
|
+
readonly #client: Client;
|
|
114
109
|
|
|
115
110
|
#interactions: ComposedCommands = { commands: [], modules: [] };
|
|
116
111
|
#onMessage: ((message: InboundMessage) => void) | undefined;
|
|
117
112
|
|
|
118
113
|
constructor(options: DiscordSurfaceOptions) {
|
|
119
114
|
this.#options = options;
|
|
115
|
+
this.#client = new Client({
|
|
116
|
+
intents: [
|
|
117
|
+
GatewayIntentBits.Guilds,
|
|
118
|
+
GatewayIntentBits.GuildMessages,
|
|
119
|
+
GatewayIntentBits.DirectMessages,
|
|
120
|
+
GatewayIntentBits.MessageContent,
|
|
121
|
+
...memberIntents(options.memberEvents),
|
|
122
|
+
],
|
|
123
|
+
partials: [Partials.Channel, Partials.Message],
|
|
124
|
+
allowedMentions: { parse: [] },
|
|
125
|
+
});
|
|
120
126
|
this.formatting = pickFormatting(options.formatting, DISCORD_FORMATTING);
|
|
121
127
|
// REST works before the gateway logs in; `login` sets the same token again.
|
|
122
128
|
this.#client.rest.setToken(options.token);
|
|
@@ -174,34 +180,9 @@ export class DiscordSurface
|
|
|
174
180
|
return new DiscordDashboard(this.#client, guildId, this.#options.logger);
|
|
175
181
|
}
|
|
176
182
|
|
|
177
|
-
/**
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
*/
|
|
181
|
-
async channelInfo(channelId: string): Promise<ChannelInfo | undefined> {
|
|
182
|
-
if (!this.#client.isReady()) throw new Error("Discord is not connected");
|
|
183
|
-
const channel = await this.#client.channels
|
|
184
|
-
.fetch(channelId)
|
|
185
|
-
.catch((error: unknown) => {
|
|
186
|
-
if (error instanceof DiscordAPIError && error.code === UNKNOWN_CHANNEL)
|
|
187
|
-
return null;
|
|
188
|
-
throw error;
|
|
189
|
-
});
|
|
190
|
-
if (!channel) return undefined;
|
|
191
|
-
if (channel.isDMBased())
|
|
192
|
-
return {
|
|
193
|
-
kind: "dm",
|
|
194
|
-
name:
|
|
195
|
-
channel.type === ChannelType.DM
|
|
196
|
-
? (channel.recipient?.username ?? "")
|
|
197
|
-
: (channel.name ?? ""),
|
|
198
|
-
};
|
|
199
|
-
return {
|
|
200
|
-
kind: "guild",
|
|
201
|
-
name: channel.name,
|
|
202
|
-
guild: channel.guild.name,
|
|
203
|
-
guildId: channel.guild.id,
|
|
204
|
-
};
|
|
183
|
+
/** See `DiscordConnection.channelInfo`. */
|
|
184
|
+
channelInfo(channelId: string): Promise<ChannelInfo | undefined> {
|
|
185
|
+
return channelInfoOf(this.#client, channelId);
|
|
205
186
|
}
|
|
206
187
|
|
|
207
188
|
/**
|
|
@@ -232,6 +213,11 @@ export class DiscordSurface
|
|
|
232
213
|
this.#client.on(Events.ChannelDelete, (channel) => handler(channel.id));
|
|
233
214
|
}
|
|
234
215
|
|
|
216
|
+
/** Reports members that join a server; register before start. Hears none unless `memberEvents` is on. */
|
|
217
|
+
onMemberJoined(handler: (event: MemberJoinedEvent) => void): void {
|
|
218
|
+
listenForJoins(this.#client, handler, this.#options.logger);
|
|
219
|
+
}
|
|
220
|
+
|
|
235
221
|
/** The approval and question cards in a Discord channel, for those the scope names. */
|
|
236
222
|
prompts(channel: ChannelKey, scope?: PromptScope): Prompts | undefined {
|
|
237
223
|
return this.#options.prompts(channel, scope);
|
|
@@ -289,7 +275,9 @@ export class DiscordSurface
|
|
|
289
275
|
const ready = new Promise<void>((resolve) =>
|
|
290
276
|
this.#client.once(Events.ClientReady, () => resolve()),
|
|
291
277
|
);
|
|
292
|
-
await this.#client.login(token)
|
|
278
|
+
await this.#client.login(token).catch((error: unknown) => {
|
|
279
|
+
throw explainIntents(error, this.#options.memberEvents);
|
|
280
|
+
});
|
|
293
281
|
await ready;
|
|
294
282
|
const { commands } = this.#interactions;
|
|
295
283
|
await this.#client.application?.commands.set(commands);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { type Client, Events, GatewayIntentBits } from "discord.js";
|
|
2
|
+
import type { MemberJoinedEvent } from "../event-types.ts";
|
|
3
|
+
import type { Logger } from "../log.ts";
|
|
4
|
+
|
|
5
|
+
/** The privileged Server Members intent, asked for only when the host turns `discord.memberEvents` on. */
|
|
6
|
+
export function memberIntents(
|
|
7
|
+
enabled: boolean | undefined,
|
|
8
|
+
): GatewayIntentBits[] {
|
|
9
|
+
return enabled ? [GatewayIntentBits.GuildMembers] : [];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Reports each member that joins a server to `handler`; one that throws is logged and never stops the next. */
|
|
13
|
+
export function listenForJoins(
|
|
14
|
+
client: Client,
|
|
15
|
+
handler: (event: MemberJoinedEvent) => void,
|
|
16
|
+
logger: Logger,
|
|
17
|
+
): void {
|
|
18
|
+
client.on(Events.GuildMemberAdd, (member) => {
|
|
19
|
+
try {
|
|
20
|
+
handler({
|
|
21
|
+
surface: "discord",
|
|
22
|
+
guildId: member.guild.id,
|
|
23
|
+
userId: member.id,
|
|
24
|
+
bot: member.user.bot,
|
|
25
|
+
});
|
|
26
|
+
} catch (error) {
|
|
27
|
+
logger.error({ err: error }, "member join handler failed");
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The error a failed login throws, with the portal switch named when Discord refused the Server Members intent. */
|
|
33
|
+
export function explainIntents(
|
|
34
|
+
error: unknown,
|
|
35
|
+
enabled: boolean | undefined,
|
|
36
|
+
): Error {
|
|
37
|
+
const failed = error instanceof Error ? error : new Error(String(error));
|
|
38
|
+
if (enabled && /disallowed intents/i.test(failed.message))
|
|
39
|
+
return new Error(
|
|
40
|
+
"Discord refused the Server Members intent that discord.memberEvents asks for. Turn on Server Members Intent under Bot in https://discord.com/developers/applications, save, and start again, or set discord.memberEvents to false.",
|
|
41
|
+
{ cause: failed },
|
|
42
|
+
);
|
|
43
|
+
return failed;
|
|
44
|
+
}
|
|
@@ -11,13 +11,17 @@ export type DiscordFailure =
|
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* More about why Discord refused, where its code alone does not say: a category that is full (a
|
|
14
|
-
* 400 with a form-body error), a channel that is no category or forum,
|
|
14
|
+
* 400 with a form-body error), a channel that is no category or forum, one in no right guild, and
|
|
15
|
+
* a role that cannot be given or taken.
|
|
15
16
|
*/
|
|
16
17
|
export type DiscordReason =
|
|
17
18
|
| "category_full"
|
|
18
19
|
| "not_category"
|
|
19
20
|
| "not_forum" // a forum call on a channel that is no forum, or a post call on a thread that is no forum post
|
|
20
|
-
| "wrong_guild"
|
|
21
|
+
| "wrong_guild"
|
|
22
|
+
| "role_unassignable" // a role the bot may not give or take: @everyone's, a managed one, or one at or above its own highest
|
|
23
|
+
| "unknown_role" // the guild has no such role
|
|
24
|
+
| "not_member"; // the user is not in the guild
|
|
21
25
|
|
|
22
26
|
/**
|
|
23
27
|
* A failed Discord call. The message names the operation and Discord's own reason, never a URL,
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { checkSnowflake } from "./poster-checks.ts";
|
|
2
|
+
import { DiscordPosterError, invalid } from "./poster-errors.ts";
|
|
3
|
+
|
|
4
|
+
/** What the poster keeps of Discord's limits on roles and audit-log reasons. */
|
|
5
|
+
export const ROLE_LIMITS = Object.freeze({
|
|
6
|
+
/** The longest name Discord gives a role. */
|
|
7
|
+
nameChars: 100,
|
|
8
|
+
/** The longest audit-log reason Discord keeps. */
|
|
9
|
+
reasonChars: 512,
|
|
10
|
+
/** The most roles one `addMemberRoles` or `removeMemberRoles` call takes. */
|
|
11
|
+
perCall: 20,
|
|
12
|
+
} as const);
|
|
13
|
+
|
|
14
|
+
/** A role of a guild, as the poster reasons about whether it may be given. */
|
|
15
|
+
export interface GuildRole {
|
|
16
|
+
id: string;
|
|
17
|
+
name: string;
|
|
18
|
+
/** Discord's order: a higher number sits higher, and @everyone is 0. */
|
|
19
|
+
position: number;
|
|
20
|
+
/** Made and kept by an integration or a bot, so it cannot be given by hand. */
|
|
21
|
+
managed: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The member and role calls of `DiscordPoster`. The poster only ever touches the roles a call
|
|
26
|
+
* names: it adds or removes them one by one, and never sends a member's whole role list.
|
|
27
|
+
*/
|
|
28
|
+
export interface MemberRoles {
|
|
29
|
+
/**
|
|
30
|
+
* The ids of the roles a member holds, as Discord lists them (the @everyone role is not among
|
|
31
|
+
* them); `undefined` for someone who is not a member of the guild.
|
|
32
|
+
*/
|
|
33
|
+
memberRoles(guildId: string, userId: string): Promise<string[] | undefined>;
|
|
34
|
+
/**
|
|
35
|
+
* Gives a member these roles (Manage Roles), with `reason` in the guild's audit log (1 to 512
|
|
36
|
+
* characters, else `invalid`; so are no role, more than 20, or an id that is no Discord id). All
|
|
37
|
+
* are checked before any is given, and a role the member holds is left as it is. Refused, and
|
|
38
|
+
* nothing given, for a role the bot may not hand out: the @everyone role (`invalid`), a managed
|
|
39
|
+
* role, and one at or above the bot's highest role (`forbidden`, both with `role_unassignable`);
|
|
40
|
+
* a role the guild does not have is `rejected` with `unknown_role`, and a user who is not a
|
|
41
|
+
* member `rejected` with `not_member`.
|
|
42
|
+
*
|
|
43
|
+
* Not atomic: the checks run first, but a Discord failure after them (a 5xx, a timeout, a lost
|
|
44
|
+
* permission) may leave the earlier roles of the call changed; the error names the role that
|
|
45
|
+
* failed. Each change is idempotent, so retry the whole call and it converges.
|
|
46
|
+
*/
|
|
47
|
+
addMemberRoles(
|
|
48
|
+
guildId: string,
|
|
49
|
+
userId: string,
|
|
50
|
+
roleIds: readonly string[],
|
|
51
|
+
reason: string,
|
|
52
|
+
): Promise<void>;
|
|
53
|
+
/** Takes these roles from a member, under the same rules as `addMemberRoles`; a role the member lacks is left alone. */
|
|
54
|
+
removeMemberRoles(
|
|
55
|
+
guildId: string,
|
|
56
|
+
userId: string,
|
|
57
|
+
roleIds: readonly string[],
|
|
58
|
+
reason: string,
|
|
59
|
+
): Promise<void>;
|
|
60
|
+
/**
|
|
61
|
+
* The id of the guild's role of that name (1 to 100 characters, exact match, not @everyone's
|
|
62
|
+
* or a managed role), made with no permissions and no colour when there is none (Manage Roles,
|
|
63
|
+
* `reason` in the audit log). A role that exists is never edited: its permissions, colour and
|
|
64
|
+
* position stay as they are. With several of the name, the oldest. Calls for the same name run
|
|
65
|
+
* one after another in this process, so two of them make one role; separate processes do not
|
|
66
|
+
* coordinate and may make two. A role that exists may sit at or above the bot's highest role (or
|
|
67
|
+
* be one the bot cannot hand out): it is returned all the same, and the next `addMemberRoles`
|
|
68
|
+
* refuses it with `role_unassignable`.
|
|
69
|
+
*/
|
|
70
|
+
ensureRole(guildId: string, name: string, reason: string): Promise<string>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** An audit-log reason: 1 to 512 characters. */
|
|
74
|
+
export function checkAuditReason(reason: string): string {
|
|
75
|
+
const trimmed = reason.trim();
|
|
76
|
+
if (trimmed === "") throw invalid("a role change needs an audit-log reason");
|
|
77
|
+
if ([...trimmed].length > ROLE_LIMITS.reasonChars)
|
|
78
|
+
throw invalid(
|
|
79
|
+
`an audit-log reason is at most ${ROLE_LIMITS.reasonChars} characters`,
|
|
80
|
+
);
|
|
81
|
+
return trimmed;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A role's name: 1 to 100 characters, and not one that reads as a mention of everyone. */
|
|
85
|
+
export function checkRoleName(name: string): string {
|
|
86
|
+
const trimmed = name.trim();
|
|
87
|
+
if (trimmed === "" || [...trimmed].length > ROLE_LIMITS.nameChars)
|
|
88
|
+
throw invalid(`a role name is 1 to ${ROLE_LIMITS.nameChars} characters`);
|
|
89
|
+
if (/^@(everyone|here)$/i.test(trimmed))
|
|
90
|
+
throw invalid("a role cannot be named @everyone or @here");
|
|
91
|
+
return trimmed;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The roles a call names: at least one and at most 20, each a Discord id, each once. */
|
|
95
|
+
export function checkRoleIds(roleIds: readonly string[]): string[] {
|
|
96
|
+
const wanted = [...new Set(roleIds)];
|
|
97
|
+
if (wanted.length === 0) throw invalid("name at least one role");
|
|
98
|
+
if (wanted.length > ROLE_LIMITS.perCall)
|
|
99
|
+
throw invalid(`a call takes at most ${ROLE_LIMITS.perCall} roles`);
|
|
100
|
+
return wanted.map((id) => checkSnowflake("a role", id));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Refuses, before anything is sent, a role the bot may not give or take: the @everyone role, a
|
|
105
|
+
* managed role, and a role at or above the bot's highest. The bot's own roles and the guild's
|
|
106
|
+
* come from Discord, so the answer is the one Discord would give, in words that name the cause.
|
|
107
|
+
*/
|
|
108
|
+
export function refuseUnassignable(
|
|
109
|
+
guildId: string,
|
|
110
|
+
roles: readonly GuildRole[],
|
|
111
|
+
botRoleIds: readonly string[],
|
|
112
|
+
wanted: readonly string[],
|
|
113
|
+
): void {
|
|
114
|
+
const byId = new Map(roles.map((role) => [role.id, role]));
|
|
115
|
+
const top = Math.max(
|
|
116
|
+
0,
|
|
117
|
+
...botRoleIds.map((id) => byId.get(id)?.position ?? 0),
|
|
118
|
+
);
|
|
119
|
+
for (const id of wanted) {
|
|
120
|
+
if (id === guildId)
|
|
121
|
+
throw new DiscordPosterError(
|
|
122
|
+
"invalid",
|
|
123
|
+
"the @everyone role cannot be given or taken",
|
|
124
|
+
{ reason: "role_unassignable" },
|
|
125
|
+
);
|
|
126
|
+
const role = byId.get(id);
|
|
127
|
+
if (!role)
|
|
128
|
+
throw new DiscordPosterError("rejected", `the guild has no role ${id}`, {
|
|
129
|
+
reason: "unknown_role",
|
|
130
|
+
});
|
|
131
|
+
if (role.managed)
|
|
132
|
+
throw new DiscordPosterError(
|
|
133
|
+
"forbidden",
|
|
134
|
+
`the role ${id} is managed by an integration and cannot be given by hand`,
|
|
135
|
+
{ reason: "role_unassignable" },
|
|
136
|
+
);
|
|
137
|
+
if (role.position >= top)
|
|
138
|
+
throw new DiscordPosterError(
|
|
139
|
+
"forbidden",
|
|
140
|
+
`the role ${id} is at or above the bot's highest role`,
|
|
141
|
+
{ reason: "role_unassignable" },
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The guild's role of that name that may be found by `ensureRole`: the oldest unmanaged one. */
|
|
147
|
+
export function roleNamed(
|
|
148
|
+
guildId: string,
|
|
149
|
+
roles: readonly GuildRole[],
|
|
150
|
+
name: string,
|
|
151
|
+
): GuildRole | undefined {
|
|
152
|
+
let found: GuildRole | undefined;
|
|
153
|
+
for (const role of roles) {
|
|
154
|
+
if (role.name !== name || role.managed || role.id === guildId) continue;
|
|
155
|
+
if (!found || BigInt(role.id) < BigInt(found.id)) found = role;
|
|
156
|
+
}
|
|
157
|
+
return found;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** What `addMemberRoles` and `removeMemberRoles` throw for a user who is not in the guild. */
|
|
161
|
+
export function notMember(): DiscordPosterError {
|
|
162
|
+
return new DiscordPosterError(
|
|
163
|
+
"rejected",
|
|
164
|
+
"that user is not a member of the guild",
|
|
165
|
+
{ reason: "not_member", discordCode: 10007, status: 404 },
|
|
166
|
+
);
|
|
167
|
+
}
|
|
@@ -5,11 +5,15 @@
|
|
|
5
5
|
* (from `pi-roundtable/testing`) stands in for it in tests, and no test reaches Discord.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import type { MemberRoles } from "./poster-roles.ts";
|
|
9
|
+
|
|
8
10
|
export type {
|
|
9
11
|
DiscordFailure,
|
|
10
12
|
DiscordReason,
|
|
11
13
|
} from "./poster-errors.ts";
|
|
12
14
|
export { DiscordPosterError } from "./poster-errors.ts";
|
|
15
|
+
export type { GuildRole, MemberRoles } from "./poster-roles.ts";
|
|
16
|
+
export { ROLE_LIMITS } from "./poster-roles.ts";
|
|
13
17
|
|
|
14
18
|
/** A file sent with a post. */
|
|
15
19
|
export interface PosterFile {
|
|
@@ -297,7 +301,7 @@ export const DISCORD_LIMITS = Object.freeze({
|
|
|
297
301
|
postTags: 5,
|
|
298
302
|
} as const);
|
|
299
303
|
|
|
300
|
-
export interface DiscordPoster {
|
|
304
|
+
export interface DiscordPoster extends MemberRoles {
|
|
301
305
|
// Webhook posts
|
|
302
306
|
|
|
303
307
|
/** Posts through the channel's webhook of that name, found or made on first use. */
|
|
@@ -11,6 +11,8 @@ export interface RestOptions {
|
|
|
11
11
|
auth?: boolean;
|
|
12
12
|
/** Gives up on the request when it fires (a call that discord.js would otherwise hold for a rate limit). */
|
|
13
13
|
signal?: AbortSignal;
|
|
14
|
+
/** The audit-log reason of a change to a guild; discord.js sends it as `X-Audit-Log-Reason`. */
|
|
15
|
+
reason?: string;
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
/** The part of discord.js' `REST` the poster calls, so a test can answer without a network. */
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { RESTJSONErrorCodes, Routes } from "discord.js";
|
|
2
|
+
import { checkSnowflake } from "./poster-checks.ts";
|
|
3
|
+
import { DiscordPosterError, failure, isCode } from "./poster-errors.ts";
|
|
4
|
+
import {
|
|
5
|
+
checkAuditReason,
|
|
6
|
+
checkRoleIds,
|
|
7
|
+
checkRoleName,
|
|
8
|
+
type GuildRole,
|
|
9
|
+
type MemberRoles,
|
|
10
|
+
notMember,
|
|
11
|
+
refuseUnassignable,
|
|
12
|
+
roleNamed,
|
|
13
|
+
} from "./poster-roles.ts";
|
|
14
|
+
import { object, type RestCore, text } from "./rest-poster-core.ts";
|
|
15
|
+
|
|
16
|
+
/** The poster's member and role calls. */
|
|
17
|
+
export function roleOperations(core: RestCore): MemberRoles {
|
|
18
|
+
const { rest } = core;
|
|
19
|
+
|
|
20
|
+
const guildRoles = async (guildId: string): Promise<GuildRole[]> => {
|
|
21
|
+
const listed = await rest
|
|
22
|
+
.get(Routes.guildRoles(guildId))
|
|
23
|
+
.catch((error: unknown) => {
|
|
24
|
+
throw failure("listing the guild's roles", error);
|
|
25
|
+
});
|
|
26
|
+
return (Array.isArray(listed) ? listed : []).flatMap((entry) => {
|
|
27
|
+
const role = object(entry);
|
|
28
|
+
const id = text(role.id);
|
|
29
|
+
return id
|
|
30
|
+
? [
|
|
31
|
+
{
|
|
32
|
+
id,
|
|
33
|
+
name: text(role.name) ?? "",
|
|
34
|
+
position: typeof role.position === "number" ? role.position : 0,
|
|
35
|
+
managed: role.managed === true,
|
|
36
|
+
},
|
|
37
|
+
]
|
|
38
|
+
: [];
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** The ids of a member's roles; undefined for someone who is not in the guild. */
|
|
43
|
+
const rolesOf = async (
|
|
44
|
+
guildId: string,
|
|
45
|
+
userId: string,
|
|
46
|
+
): Promise<string[] | undefined> => {
|
|
47
|
+
let member: Record<string, unknown>;
|
|
48
|
+
try {
|
|
49
|
+
member = object(await rest.get(Routes.guildMember(guildId, userId)));
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (isCode(error, RESTJSONErrorCodes.UnknownMember)) return undefined;
|
|
52
|
+
throw failure("reading a guild member", error);
|
|
53
|
+
}
|
|
54
|
+
return Array.isArray(member.roles)
|
|
55
|
+
? member.roles.filter((id): id is string => typeof id === "string")
|
|
56
|
+
: [];
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const change = async (
|
|
60
|
+
verb: "add" | "remove",
|
|
61
|
+
guildId: string,
|
|
62
|
+
userId: string,
|
|
63
|
+
roleIds: readonly string[],
|
|
64
|
+
reason: string,
|
|
65
|
+
): Promise<void> => {
|
|
66
|
+
checkSnowflake("a guild", guildId);
|
|
67
|
+
checkSnowflake("a user", userId);
|
|
68
|
+
const wanted = checkRoleIds(roleIds);
|
|
69
|
+
const why = checkAuditReason(reason);
|
|
70
|
+
// Everything is checked before the first role is given, so a refusal gives none; a Discord
|
|
71
|
+
// failure after that leaves the earlier roles changed, and the error names the one that failed.
|
|
72
|
+
const botRoles = await rolesOf(guildId, await core.bot());
|
|
73
|
+
if (!botRoles)
|
|
74
|
+
throw new DiscordPosterError(
|
|
75
|
+
"forbidden",
|
|
76
|
+
"the bot is not a member of that guild",
|
|
77
|
+
);
|
|
78
|
+
refuseUnassignable(guildId, await guildRoles(guildId), botRoles, wanted);
|
|
79
|
+
for (const roleId of wanted) {
|
|
80
|
+
const route = Routes.guildMemberRole(guildId, userId, roleId);
|
|
81
|
+
try {
|
|
82
|
+
if (verb === "add") await rest.put(route, { reason: why });
|
|
83
|
+
else await rest.delete(route, { reason: why });
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (isCode(error, RESTJSONErrorCodes.UnknownMember)) throw notMember();
|
|
86
|
+
throw failure(
|
|
87
|
+
verb === "add"
|
|
88
|
+
? `giving a member the role ${roleId}`
|
|
89
|
+
: `taking the role ${roleId} from a member`,
|
|
90
|
+
error,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const making = new Map<string, Promise<string>>();
|
|
97
|
+
const ensure = async (
|
|
98
|
+
guildId: string,
|
|
99
|
+
name: string,
|
|
100
|
+
reason: string,
|
|
101
|
+
): Promise<string> => {
|
|
102
|
+
const found = roleNamed(guildId, await guildRoles(guildId), name);
|
|
103
|
+
if (found) return found.id;
|
|
104
|
+
// No permissions, no colour, not shown apart and not pingable: a marker, nothing more.
|
|
105
|
+
const made = object(
|
|
106
|
+
await rest
|
|
107
|
+
.post(Routes.guildRoles(guildId), {
|
|
108
|
+
body: {
|
|
109
|
+
name,
|
|
110
|
+
permissions: "0",
|
|
111
|
+
hoist: false,
|
|
112
|
+
mentionable: false,
|
|
113
|
+
},
|
|
114
|
+
reason,
|
|
115
|
+
})
|
|
116
|
+
.catch((error: unknown) => {
|
|
117
|
+
throw failure("making a role", error);
|
|
118
|
+
}),
|
|
119
|
+
);
|
|
120
|
+
const id = text(made.id);
|
|
121
|
+
if (!id) throw new DiscordPosterError("rejected", "Discord made no role");
|
|
122
|
+
return id;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
async memberRoles(guildId, userId) {
|
|
127
|
+
checkSnowflake("a guild", guildId);
|
|
128
|
+
checkSnowflake("a user", userId);
|
|
129
|
+
return rolesOf(guildId, userId);
|
|
130
|
+
},
|
|
131
|
+
addMemberRoles: (guildId, userId, roleIds, reason) =>
|
|
132
|
+
change("add", guildId, userId, roleIds, reason),
|
|
133
|
+
removeMemberRoles: (guildId, userId, roleIds, reason) =>
|
|
134
|
+
change("remove", guildId, userId, roleIds, reason),
|
|
135
|
+
async ensureRole(guildId, name, reason) {
|
|
136
|
+
checkSnowflake("a guild", guildId);
|
|
137
|
+
const wanted = checkRoleName(name);
|
|
138
|
+
const why = checkAuditReason(reason);
|
|
139
|
+
const slot = `${guildId}:${wanted}`;
|
|
140
|
+
let pending = making.get(slot);
|
|
141
|
+
if (!pending) {
|
|
142
|
+
pending = ensure(guildId, wanted, why).finally(() =>
|
|
143
|
+
making.delete(slot),
|
|
144
|
+
);
|
|
145
|
+
making.set(slot, pending);
|
|
146
|
+
}
|
|
147
|
+
return pending;
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
import { forumOperations } from "./rest-poster-forum.ts";
|
|
22
22
|
import { guildOperations } from "./rest-poster-guild.ts";
|
|
23
23
|
import { readOperations } from "./rest-poster-read.ts";
|
|
24
|
+
import { roleOperations } from "./rest-poster-roles.ts";
|
|
24
25
|
|
|
25
26
|
export type { DiscordRest, RestOptions } from "./rest-poster-core.ts";
|
|
26
27
|
export { restOf } from "./rest-poster-core.ts";
|
|
@@ -247,6 +248,7 @@ export function restPoster(
|
|
|
247
248
|
return {
|
|
248
249
|
...guild,
|
|
249
250
|
...forumOperations(core),
|
|
251
|
+
...roleOperations(core),
|
|
250
252
|
async post(channelId, post) {
|
|
251
253
|
checkSnowflake("a channel", channelId);
|
|
252
254
|
const { webhook: name, body, files } = checkedPost(post);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ChannelKey } from "./sessions.ts";
|
|
2
|
+
|
|
3
|
+
/** One thing a person did through a plugin's surface, as a handler hears of it. */
|
|
4
|
+
export interface ActionEvent {
|
|
5
|
+
/** What was done, in the reporting plugin's words, such as "assign" or "close". */
|
|
6
|
+
kind: string;
|
|
7
|
+
/** The `Speaker.id` of who did it. */
|
|
8
|
+
actor: string;
|
|
9
|
+
principalId: string;
|
|
10
|
+
/** The conversation or page it concerns, when there is one. */
|
|
11
|
+
channel?: ChannelKey | `page:${string}`;
|
|
12
|
+
/** Whether it worked. */
|
|
13
|
+
ok: boolean;
|
|
14
|
+
at: Date;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Someone joined a server of a chat network, as a handler hears of it. */
|
|
18
|
+
export interface MemberJoinedEvent {
|
|
19
|
+
/** The chat network the server is on. */
|
|
20
|
+
surface: "discord";
|
|
21
|
+
/** The server's id. */
|
|
22
|
+
guildId: string;
|
|
23
|
+
/** The id of whoever joined. */
|
|
24
|
+
userId: string;
|
|
25
|
+
/** Whether the one who joined is a bot. */
|
|
26
|
+
bot: boolean;
|
|
27
|
+
}
|
package/src/core/events.ts
CHANGED
|
@@ -53,5 +53,6 @@ export class EventBus {
|
|
|
53
53
|
turnProgress: (event) => void this.deliver("turnProgress", event),
|
|
54
54
|
changed: () => void this.deliver("changed"),
|
|
55
55
|
action: (event) => void this.deliver("action", event),
|
|
56
|
+
memberJoined: (event) => void this.deliver("memberJoined", event),
|
|
56
57
|
};
|
|
57
58
|
}
|
package/src/core/plugin.ts
CHANGED
|
@@ -18,6 +18,7 @@ import type { Migration } from "./db/migrations.ts";
|
|
|
18
18
|
import type { ToolContribution } from "./define.ts";
|
|
19
19
|
import type { TurnProgress } from "./domain/progress.ts";
|
|
20
20
|
import { PluginError } from "./errors.ts";
|
|
21
|
+
import type { ActionEvent, MemberJoinedEvent } from "./event-types.ts";
|
|
21
22
|
import type { HoldCheck, HoldRule } from "./holds.ts";
|
|
22
23
|
import type { HttpRoute, ListenerAddress } from "./http/listeners.ts";
|
|
23
24
|
import type { Locale } from "./i18n/index.ts";
|
|
@@ -37,6 +38,8 @@ import type {
|
|
|
37
38
|
import type { Speaker, Tier } from "./speakers.ts";
|
|
38
39
|
import type { ToolTiers } from "./tool-tiers.ts";
|
|
39
40
|
|
|
41
|
+
export type { ActionEvent, MemberJoinedEvent };
|
|
42
|
+
|
|
40
43
|
/** A long-lived part of a plugin. Services start in registration order and stop in reverse, so one registered earlier outlives those after it. */
|
|
41
44
|
export interface Service {
|
|
42
45
|
name: string;
|
|
@@ -113,24 +116,15 @@ export interface EventHandlers {
|
|
|
113
116
|
* the kind, who did it and whether it worked, never the content.
|
|
114
117
|
*/
|
|
115
118
|
action?(event: ActionEvent): Promise<void> | void;
|
|
119
|
+
/**
|
|
120
|
+
* Someone joined the Discord server. Reported only when the host's `discord.memberEvents` is
|
|
121
|
+
* on, which asks Discord for the privileged Server Members intent; otherwise never.
|
|
122
|
+
*/
|
|
123
|
+
memberJoined?(event: MemberJoinedEvent): Promise<void> | void;
|
|
116
124
|
/** The shutdown drain ended, before any service stops; `left` is the work it gave up on. */
|
|
117
125
|
shutdown?(left: readonly string[]): Promise<void> | void;
|
|
118
126
|
}
|
|
119
127
|
|
|
120
|
-
/** One thing a person did through a plugin's surface, as a handler hears of it. */
|
|
121
|
-
export interface ActionEvent {
|
|
122
|
-
/** What was done, in the reporting plugin's words, such as "assign" or "close". */
|
|
123
|
-
kind: string;
|
|
124
|
-
/** The `Speaker.id` of who did it. */
|
|
125
|
-
actor: string;
|
|
126
|
-
principalId: string;
|
|
127
|
-
/** The conversation or page it concerns, when there is one. */
|
|
128
|
-
channel?: ChannelKey | `page:${string}`;
|
|
129
|
-
/** Whether it worked. */
|
|
130
|
-
ok: boolean;
|
|
131
|
-
at: Date;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
128
|
/** What the core reports to the plugins' handlers. */
|
|
135
129
|
export interface EventSink {
|
|
136
130
|
turnStarted(turn: TurnEvent): void;
|
|
@@ -140,6 +134,8 @@ export interface EventSink {
|
|
|
140
134
|
changed(): void;
|
|
141
135
|
/** Optional, as `turnProgress` is: a sink of your own written before it still fits. */
|
|
142
136
|
action?(event: ActionEvent): void;
|
|
137
|
+
/** Optional, as `action` is. */
|
|
138
|
+
memberJoined?(event: MemberJoinedEvent): void;
|
|
143
139
|
}
|
|
144
140
|
|
|
145
141
|
/** What a prompt section is built for: one agent's turn. */
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { checkSnowflake } from "../discord/poster/poster-checks.ts";
|
|
2
|
+
import { DiscordPosterError } from "../discord/poster/poster-errors.ts";
|
|
3
|
+
import {
|
|
4
|
+
checkAuditReason,
|
|
5
|
+
checkRoleIds,
|
|
6
|
+
checkRoleName,
|
|
7
|
+
type GuildRole,
|
|
8
|
+
notMember,
|
|
9
|
+
refuseUnassignable,
|
|
10
|
+
roleNamed,
|
|
11
|
+
} from "../discord/poster/poster-roles.ts";
|
|
12
|
+
import { FakePosterReads } from "./fake-poster-reads.ts";
|
|
13
|
+
|
|
14
|
+
/** One change of a role or of a member's roles, as the fake's audit log holds it. */
|
|
15
|
+
export interface FakeRoleChange {
|
|
16
|
+
action: "add" | "remove" | "create";
|
|
17
|
+
guildId: string;
|
|
18
|
+
roleId: string;
|
|
19
|
+
/** The member, for an `add` or a `remove`. */
|
|
20
|
+
userId?: string;
|
|
21
|
+
reason: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The fake's member and role calls, on top of its channel, thread, forum and read calls. */
|
|
25
|
+
export abstract class FakePosterRoles extends FakePosterReads {
|
|
26
|
+
/** The guild's roles by id, @everyone's left out; `ensureRole` makes more. A test sets them up here. */
|
|
27
|
+
readonly roles = new Map<string, GuildRole>();
|
|
28
|
+
/** The ids of the roles the bot holds; the bot may give only roles below its highest. */
|
|
29
|
+
botRoles: string[] = [];
|
|
30
|
+
/**
|
|
31
|
+
* The ids of the roles each member holds, by user id. A user is a member when `memberNames`
|
|
32
|
+
* or this map lists them (and `nonMembers` does not).
|
|
33
|
+
*/
|
|
34
|
+
readonly memberRoleIds = new Map<string, string[]>();
|
|
35
|
+
/** Every change of roles, in order, with its audit-log reason. */
|
|
36
|
+
readonly roleChanges: FakeRoleChange[] = [];
|
|
37
|
+
protected nextRole = 5_000_000_000_000_000_000n;
|
|
38
|
+
|
|
39
|
+
protected constructor(idOffset: bigint, now: () => number) {
|
|
40
|
+
super(idOffset, now);
|
|
41
|
+
this.nextRole += idOffset;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
#held(guildId: string, userId: string): string[] | undefined {
|
|
45
|
+
if (guildId !== this.guildId || this.nonMembers.has(userId))
|
|
46
|
+
return undefined;
|
|
47
|
+
const held = this.memberRoleIds.get(userId);
|
|
48
|
+
if (!(held || this.memberNames.has(userId))) return undefined;
|
|
49
|
+
return [...(held ?? [])];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async memberRoles(
|
|
53
|
+
guildId: string,
|
|
54
|
+
userId: string,
|
|
55
|
+
): Promise<string[] | undefined> {
|
|
56
|
+
checkSnowflake("a guild", guildId);
|
|
57
|
+
checkSnowflake("a user", userId);
|
|
58
|
+
this.sent("memberRoles");
|
|
59
|
+
return this.#held(guildId, userId);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async addMemberRoles(
|
|
63
|
+
guildId: string,
|
|
64
|
+
userId: string,
|
|
65
|
+
roleIds: readonly string[],
|
|
66
|
+
reason: string,
|
|
67
|
+
): Promise<void> {
|
|
68
|
+
this.#change("add", guildId, userId, roleIds, reason);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async removeMemberRoles(
|
|
72
|
+
guildId: string,
|
|
73
|
+
userId: string,
|
|
74
|
+
roleIds: readonly string[],
|
|
75
|
+
reason: string,
|
|
76
|
+
): Promise<void> {
|
|
77
|
+
this.#change("remove", guildId, userId, roleIds, reason);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
#change(
|
|
81
|
+
action: "add" | "remove",
|
|
82
|
+
guildId: string,
|
|
83
|
+
userId: string,
|
|
84
|
+
roleIds: readonly string[],
|
|
85
|
+
reason: string,
|
|
86
|
+
): void {
|
|
87
|
+
checkSnowflake("a guild", guildId);
|
|
88
|
+
checkSnowflake("a user", userId);
|
|
89
|
+
const wanted = checkRoleIds(roleIds);
|
|
90
|
+
const why = checkAuditReason(reason);
|
|
91
|
+
this.sent(action === "add" ? "addMemberRoles" : "removeMemberRoles");
|
|
92
|
+
if (guildId !== this.guildId)
|
|
93
|
+
throw new DiscordPosterError(
|
|
94
|
+
"forbidden",
|
|
95
|
+
"the bot is not a member of that guild",
|
|
96
|
+
);
|
|
97
|
+
refuseUnassignable(
|
|
98
|
+
guildId,
|
|
99
|
+
[...this.roles.values()],
|
|
100
|
+
this.botRoles,
|
|
101
|
+
wanted,
|
|
102
|
+
);
|
|
103
|
+
const held = this.#held(guildId, userId);
|
|
104
|
+
if (!held) throw notMember();
|
|
105
|
+
let next = held;
|
|
106
|
+
for (const roleId of wanted) {
|
|
107
|
+
next =
|
|
108
|
+
action === "add"
|
|
109
|
+
? next.includes(roleId)
|
|
110
|
+
? next
|
|
111
|
+
: [...next, roleId]
|
|
112
|
+
: next.filter((id) => id !== roleId);
|
|
113
|
+
this.roleChanges.push({
|
|
114
|
+
action,
|
|
115
|
+
guildId,
|
|
116
|
+
roleId,
|
|
117
|
+
userId,
|
|
118
|
+
reason: why,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
this.memberRoleIds.set(userId, next);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async ensureRole(
|
|
125
|
+
guildId: string,
|
|
126
|
+
name: string,
|
|
127
|
+
reason: string,
|
|
128
|
+
): Promise<string> {
|
|
129
|
+
checkSnowflake("a guild", guildId);
|
|
130
|
+
const wanted = checkRoleName(name);
|
|
131
|
+
const why = checkAuditReason(reason);
|
|
132
|
+
this.sent("ensureRole");
|
|
133
|
+
if (guildId !== this.guildId)
|
|
134
|
+
throw new DiscordPosterError(
|
|
135
|
+
"forbidden",
|
|
136
|
+
"the bot is not a member of that guild",
|
|
137
|
+
);
|
|
138
|
+
const found = roleNamed(guildId, [...this.roles.values()], wanted);
|
|
139
|
+
if (found) return found.id;
|
|
140
|
+
// Discord puts a new role just above @everyone and moves the others up.
|
|
141
|
+
for (const [id, role] of this.roles)
|
|
142
|
+
if (role.position >= 1)
|
|
143
|
+
this.roles.set(id, { ...role, position: role.position + 1 });
|
|
144
|
+
this.nextRole += 1n;
|
|
145
|
+
const id = this.nextRole.toString();
|
|
146
|
+
this.roles.set(id, { id, name: wanted, position: 1, managed: false });
|
|
147
|
+
this.roleChanges.push({
|
|
148
|
+
action: "create",
|
|
149
|
+
guildId,
|
|
150
|
+
roleId: id,
|
|
151
|
+
reason: why,
|
|
152
|
+
});
|
|
153
|
+
return id;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
DiscordCard,
|
|
3
3
|
DiscordPoster,
|
|
4
|
+
GuildRole,
|
|
4
5
|
PostedMessage,
|
|
5
6
|
} from "../discord/poster/poster.ts";
|
|
6
7
|
import {
|
|
@@ -21,7 +22,7 @@ import type {
|
|
|
21
22
|
FakePosterChannel,
|
|
22
23
|
FakePosterMessage,
|
|
23
24
|
} from "./fake-poster-guild.ts";
|
|
24
|
-
import {
|
|
25
|
+
import { FakePosterRoles, type FakeRoleChange } from "./fake-poster-roles.ts";
|
|
25
26
|
|
|
26
27
|
export type { FakeForum } from "./fake-poster-forum.ts";
|
|
27
28
|
export type {
|
|
@@ -29,6 +30,7 @@ export type {
|
|
|
29
30
|
FakePosterMessage,
|
|
30
31
|
} from "./fake-poster-guild.ts";
|
|
31
32
|
export { cardText } from "./fake-poster-guild.ts";
|
|
33
|
+
export type { FakeRoleChange } from "./fake-poster-roles.ts";
|
|
32
34
|
|
|
33
35
|
function blobOf(file: { data: Uint8Array; contentType?: string }) {
|
|
34
36
|
return {
|
|
@@ -77,6 +79,14 @@ export interface FakeDiscordPoster extends DiscordPoster {
|
|
|
77
79
|
readonly requests: number;
|
|
78
80
|
/** Makes the next call of `operation` throw `error`. */
|
|
79
81
|
failNext(operation: keyof DiscordPoster, error: DiscordPosterError): void;
|
|
82
|
+
/** The guild's roles by id, @everyone's left out; `ensureRole` makes more. Set them up here. */
|
|
83
|
+
readonly roles: Map<string, GuildRole>;
|
|
84
|
+
/** The ids of the roles the bot holds; it may give only roles below its highest. */
|
|
85
|
+
botRoles: string[];
|
|
86
|
+
/** The ids of the roles each member holds, by user id; a user is a member when `memberNames` or this lists them. */
|
|
87
|
+
readonly memberRoleIds: Map<string, string[]>;
|
|
88
|
+
/** Every change of roles, in order, with its audit-log reason. */
|
|
89
|
+
readonly roleChanges: FakeRoleChange[];
|
|
80
90
|
/** The channel's messages that still exist, oldest first. */
|
|
81
91
|
messagesIn(channelId: string): FakePosterMessage[];
|
|
82
92
|
/** A person writes in the channel (not through a webhook); the message's id. */
|
|
@@ -114,7 +124,7 @@ export interface FakeDiscordPoster extends DiscordPoster {
|
|
|
114
124
|
* the next call or to refuse direct messages from a person. The same scenarios run against it and
|
|
115
125
|
* against the real poster over an emulated Discord, so the two answer alike.
|
|
116
126
|
*/
|
|
117
|
-
class FakePosterImpl extends
|
|
127
|
+
class FakePosterImpl extends FakePosterRoles implements FakeDiscordPoster {
|
|
118
128
|
readonly #webhooks = new Map<string, string>();
|
|
119
129
|
readonly #madeListeners = new Set<
|
|
120
130
|
(channelId: string, webhook: string) => Promise<void>
|
package/src/discord/index.ts
CHANGED
|
@@ -95,6 +95,8 @@ export type {
|
|
|
95
95
|
ForumPostCreated,
|
|
96
96
|
ForumTag,
|
|
97
97
|
ForumTagInput,
|
|
98
|
+
GuildRole,
|
|
99
|
+
MemberRoles,
|
|
98
100
|
PostedMessage,
|
|
99
101
|
PosterFile,
|
|
100
102
|
RecentMessage,
|
|
@@ -107,6 +109,7 @@ export type {
|
|
|
107
109
|
export {
|
|
108
110
|
DISCORD_LIMITS,
|
|
109
111
|
DiscordPosterError,
|
|
112
|
+
ROLE_LIMITS,
|
|
110
113
|
} from "../core/discord/poster/poster.ts";
|
|
111
114
|
export type { OwnerOperations } from "../core/modules/discord-admin/discord-admin.ts";
|
|
112
115
|
export { DISCORD_ADMIN_TOOLS } from "../core/modules/discord-admin/discord-admin.ts";
|
package/src/index.ts
CHANGED
package/src/testing.ts
CHANGED
|
@@ -106,6 +106,7 @@ export type {
|
|
|
106
106
|
FakeForum,
|
|
107
107
|
FakePosterChannel,
|
|
108
108
|
FakePosterMessage,
|
|
109
|
+
FakeRoleChange,
|
|
109
110
|
} from "./core/testing/fake-poster.ts";
|
|
110
111
|
export { fakeDiscordPoster } from "./core/testing/fake-poster.ts";
|
|
111
112
|
export type { TestLocale } from "./core/testing/locale.ts";
|