pi-roundtable 0.31.7 → 0.32.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 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.0] - 2026-11-03
9
+
10
+ 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.
11
+
12
+ ### Added
13
+
14
+ - `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.
15
+ - `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`.
16
+
17
+ ## [0.31.8] - 2026-11-03
18
+
19
+ - 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.
20
+
8
21
  ## [0.31.7] - 2026-11-02
9
22
 
10
23
  - Release in lockstep with pi-roundtable-web 0.31.7 (menu rows hover in the quiet fill with 12 px padding wherever they are used; the message box draws no scrollbar below its tallest; a room row swiped right marks the room read); 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
- - **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`) and one in no guild (`wrong_guild`). 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
+ - **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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.31.7",
3
+ "version": "0.32.0",
4
4
  "description": "A plugin-driven Pi agent server for one owner, reached through Discord or the web",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
- /** The Message Content intent is switched on, without which the agents cannot read the channels. */
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
- return ok("the Message Content intent is on");
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 = new 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
- * What a channel is called, for the web app's lists: undefined when Discord no longer knows
179
- * it; throws while the connection is not ready or Discord cannot be reached.
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, and one in no right guild.
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
+ }
@@ -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
  }
@@ -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 { FakePosterReads } from "./fake-poster-reads.ts";
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 FakePosterReads implements FakeDiscordPoster {
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>
@@ -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
@@ -351,6 +351,7 @@ export type {
351
351
  EventSink,
352
352
  HostEnv,
353
353
  LinkedSessions,
354
+ MemberJoinedEvent,
354
355
  Persona,
355
356
  PluginContext,
356
357
  PluginIdentity,
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";