From 7aec2ac5fba4885ce365d971c337416679f33190 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 19:40:56 +0000 Subject: [PATCH 1/2] Add Pulumi-managed Discord channels (forum/media support) Add a DiscordChannel dynamic resource to src/discord.ts, driven by a new src/config/channels.ts, so maintainers can declare Discord channels in this repo. Each entry either adopts an existing channel by its ID and enforces the declared settings on it in place, or creates a new channel of type text, forum or media. Forum and media are Discord's only thread-only channel types. The resource uses GET/PATCH /channels/{id} and POST /guilds/{guild}/channels through the existing discordFetch helper. read() is side-effect free and stores the live settings as `state`; diff() compares that state with the declared config, so drift made by hand in Discord is corrected by the next deploy (make up runs `pulumi up --refresh`). Only declared settings are managed, and only those valid for the channel type: forum/media settings are never sent to text channels and defaultForumLayout only to forums. Tag IDs read from Discord are reused on updates so tags are not recreated, and the REQUIRE_TAG flag is merged into the channel's other flag bits. The resource never deletes a channel: delete() only drops the resource from Pulumi state, because deleting a channel destroys its history. Changing an entry's `id` or `type` replaces the resource the same way. Discord cannot convert a text channel into a forum (the API converts text <-> announcement only), so an existing channel can only have its settings managed; making it thread-only means creating a new forum/media channel (no `id`) and retiring the old channel by hand. Adopting a text channel with type 'forum' fails with an error that says so. Validation (npm run validate) checks snowflake formats, duplicate entries, the 20-tag limit, type-specific settings and allowed auto-archive durations; test-config.ts covers the validation and the pure config <-> Discord API mapping. DISCORD_CHANNELS ships empty, so this change is a no-op on deploy. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01SyDWqMwnKtkynCQNahMKxf --- README.md | 25 ++ scripts/test-config.ts | 330 +++++++++++++++++++++ scripts/validate-config.ts | 10 + src/config/channels.ts | 593 +++++++++++++++++++++++++++++++++++++ src/discord.ts | 267 ++++++++++++++++- 5 files changed, 1224 insertions(+), 1 deletion(-) create mode 100644 src/config/channels.ts diff --git a/README.md b/README.md index 88128be0..c3de6aa7 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Infrastructure as Code for managing access to MCP community resources using Pulu - **Google Workspace Groups**: Automatically syncs group memberships for @modelcontextprotocol.io email accounts - **Email Groups**: Groups with `isEmailGroup: true` accept emails from anyone (including external users) and notify all members. External posts are moderated for security. - **Google Workspace User Accounts**: Provisions @modelcontextprotocol.io accounts for members of roles with `provisionUser: true` (directly, or via a role nested under one through `github.parent` β€” e.g. SDK teams under `sdk-maintainers`, working groups under `working-groups`) +- **Discord**: Syncs the Discord roles declared in [`src/config/roles.ts`](src/config/roles.ts) to the members' Discord IDs in `users.ts`, and manages the settings of the channels declared in [`src/config/channels.ts`](src/config/channels.ts), including thread-only forum/media channels. See [Discord channels](#discord-channels) below. - **Cloudflare Access (security-room)**: Syncs the Cloudflare Zero Trust Access policy that decides who can sign in to `securityroom.modelcontextprotocol.io` from the roles declared in [`src/config/accessPolicies.ts`](src/config/accessPolicies.ts). See [Cloudflare Access (security-room)](#cloudflare-access-security-room) below. - **npm & PyPI Package Publishing Access** (declared, not applied): Expected registry access is declared in [`src/config/packageAccess.ts`](src/config/packageAccess.ts) and drift against the live npm registry is detected by CI β€” but changes are applied manually by a maintainer. See [npm & PyPI Package Publishing Access](#npm--pypi-package-publishing-access) below for why and how. @@ -61,6 +62,30 @@ The PR's `pulumi preview` comment shows the repository create. Once merged, the - The `repository` key of a managed entry is also the Pulumi resource name. Renaming it in place archives the old repository and creates a new one. To rename, do three things in order: rename the repository on GitHub, rename the resources in state with `pulumi state rename` (`repository-` to `repository-`, and `repo-` to `repo-`), then change the key. - A repository archived by hand in GitHub stays archived (`archived` is ignored on refresh); un-archiving is a manual org-owner action. +## Discord channels + +Channels listed in [`src/config/channels.ts`](src/config/channels.ts) are managed by the `DiscordChannel` resource in [`src/discord.ts`](src/discord.ts), using the same `discord:botToken`/`discord:guildId` stack config as the role sync (the bot needs the **Manage Channels** permission in the guild). Each entry is either an **existing channel adopted by its ID** or a **new channel created on deploy**: + +```ts +// Adopt an existing channel by ID: the declared settings are enforced in place, +// anything not declared is left as it is on Discord +{ id: '1234567890123456789', name: 'general', type: 'text', rateLimitPerUser: 5 }, + +// Create a thread-only forum channel +{ + name: 'sdk-help', + type: 'forum', + requireTag: true, + availableTags: [{ name: 'typescript' }, { name: 'python' }, { name: 'answered', moderated: true }], + defaultSortOrder: 'latest_activity', +}, +``` + +- **Thread-only channels are a channel type, not a setting.** Discord has no "require threads" switch; thread-only behaviour exists only as `forum` and `media` channels, and the API cannot convert a text channel into one. To make an existing channel thread-only, declare a new `forum`/`media` channel (without `id`) and retire the old channel by hand. Declaring `type: 'forum'` on the `id` of a text channel fails the deploy with an error saying so. +- **Channels are never deleted.** Removing an entry only drops the resource from Pulumi state; the channel and its history stay on Discord. Changing an entry's `id` or `type` likewise adopts/creates the new channel and leaves the old one alone. +- Only declared settings are managed. Validation (`npm run check`) rejects malformed snowflakes, duplicate entries, more than 20 tags, and forum/media settings on text channels (`defaultForumLayout` is forum-only). +- Settings changed by hand in Discord show up as drift on refresh and are reverted by the next deploy (`make up` runs `pulumi up --refresh`). + ## Cloudflare Access (security-room) [securityroom.modelcontextprotocol.io](https://securityroom.modelcontextprotocol.io) is protected by Cloudflare Zero Trust Access with GitHub as the identity provider. The reusable Access policy `Maintainers` that grants sign-in is managed from this repo by [`src/cloudflare.ts`](src/cloudflare.ts), driven by [`src/config/accessPolicies.ts`](src/config/accessPolicies.ts): diff --git a/scripts/test-config.ts b/scripts/test-config.ts index 576edb68..4a2d9ada 100644 --- a/scripts/test-config.ts +++ b/scripts/test-config.ts @@ -19,6 +19,19 @@ import { } from '../src/config/packageAccess'; import { ACCESS_POLICIES, getAccessPolicyTeams } from '../src/config/accessPolicies'; import { REPOSITORY_ACCESS, REPOSITORY_DEFAULTS } from '../src/config/repoAccess'; +import { + DISCORD_CHANNELS, + DISCORD_CHANNEL_FLAG_REQUIRE_TAG, + discordChannelCreatePayloadFromConfig, + discordChannelPayloadFromConfig, + discordChannelResourceName, + discordChannelStateFromApi, + discordChannelTypeName, + getDiscordChannelConfigErrors, + getDiscordChannelDrift, + type DiscordChannelApiResponse, + type DiscordChannelConfig, +} from '../src/config/channels'; let passed = 0; let failed = 0; @@ -233,6 +246,323 @@ test('getAccessPolicyTeams throws for a role without a GitHub team', () => { } }); +// Test Discord channel config (channels.ts) and its pure Discord API mapping +const SNOWFLAKE = '123456789012345678'; +const OTHER_SNOWFLAKE = '987654321098765432'; +const FORUM_CONFIG: DiscordChannelConfig = { + name: 'sdk-help', + type: 'forum', + parentId: OTHER_SNOWFLAKE, + topic: 'One thread per question', + rateLimitPerUser: 5, + defaultAutoArchiveDuration: 10080, + defaultThreadRateLimitPerUser: 10, + requireTag: true, + availableTags: [ + { name: 'typescript', emojiName: '🟦' }, + { name: 'answered', moderated: true }, + ], + defaultReactionEmoji: { emojiName: 'πŸ‘' }, + defaultSortOrder: 'creation_date', + defaultForumLayout: 'gallery', +}; +// What Discord returns for FORUM_CONFIG once applied (tags carry Discord-assigned IDs, +// and an unrelated flag bit is set to check that it survives updates) +const FORUM_API: DiscordChannelApiResponse = { + id: SNOWFLAKE, + type: 15, + guild_id: OTHER_SNOWFLAKE, + name: 'sdk-help', + parent_id: OTHER_SNOWFLAKE, + topic: 'One thread per question', + nsfw: false, + position: 3, + rate_limit_per_user: 5, + default_auto_archive_duration: 10080, + default_thread_rate_limit_per_user: 10, + flags: DISCORD_CHANNEL_FLAG_REQUIRE_TAG | (1 << 15), + available_tags: [ + { + id: '111111111111111111', + name: 'answered', + moderated: true, + emoji_id: null, + emoji_name: null, + }, + { + id: '222222222222222222', + name: 'typescript', + moderated: false, + emoji_id: null, + emoji_name: '🟦', + }, + ], + default_reaction_emoji: { emoji_id: null, emoji_name: 'πŸ‘' }, + default_sort_order: 1, + default_forum_layout: 2, +}; +const TEXT_API: DiscordChannelApiResponse = { + id: SNOWFLAKE, + type: 0, + guild_id: OTHER_SNOWFLAKE, + name: 'general', + parent_id: null, + topic: null, + rate_limit_per_user: 0, +}; +const hasErrorMatching = (channels: DiscordChannelConfig[], pattern: RegExp) => + getDiscordChannelConfigErrors(channels).some((e) => pattern.test(e)); + +test('DISCORD_CHANNELS passes validation', () => + getDiscordChannelConfigErrors(DISCORD_CHANNELS).length === 0); +test('DISCORD_CHANNELS resource names are unique', () => { + const names = DISCORD_CHANNELS.map(discordChannelResourceName); + return names.length === new Set(names).size; +}); +test('Channel validation accepts adopted text, new forum and adopted media entries', () => + getDiscordChannelConfigErrors([ + { id: SNOWFLAKE, name: 'general', type: 'text', rateLimitPerUser: 5 }, + FORUM_CONFIG, + { id: OTHER_SNOWFLAKE, name: 'showcase', type: 'media', requireTag: false }, + ]).length === 0); +test('Channel validation rejects malformed snowflakes', () => + hasErrorMatching( + [{ id: '12345', name: 'a', type: 'text' }], + /has id "12345" which is not a Discord snowflake/ + ) && + hasErrorMatching([{ name: 'a', type: 'text', parentId: 'abc' }], /parentId "abc"/) && + hasErrorMatching( + [{ name: 'a', type: 'forum', defaultReactionEmoji: { emojiId: '1' } }], + /defaultReactionEmoji has emojiId "1"/ + ) && + hasErrorMatching( + [{ name: 'a', type: 'forum', availableTags: [{ name: 't', emojiId: 'x' }] }], + /tag "t" has emojiId "x"/ + )); +test('Channel validation rejects duplicate ids and duplicate names without id', () => + hasErrorMatching( + [ + { id: SNOWFLAKE, name: 'a', type: 'text' }, + { id: SNOWFLAKE, name: 'b', type: 'text' }, + ], + /duplicate id/ + ) && + hasErrorMatching( + [ + { name: 'a', type: 'text' }, + { name: 'a', type: 'forum' }, + ], + /duplicate name without an id/ + ) && + // Same name is fine when one entry is adopted by id (different resource names) + getDiscordChannelConfigErrors([ + { id: SNOWFLAKE, name: 'a', type: 'text' }, + { name: 'a', type: 'forum' }, + ]).length === 0); +test('Channel validation rejects invalid names', () => + hasErrorMatching([{ name: 'SDK Help', type: 'text' }], /invalid name "SDK Help"/) && + hasErrorMatching([{ name: '', type: 'text' }], /invalid name ""/)); +test('Channel validation rejects more than 20 tags and duplicate tag names', () => + hasErrorMatching( + [ + { + name: 'a', + type: 'forum', + availableTags: Array.from({ length: 21 }, (_, i) => ({ name: `tag-${i}` })), + }, + ], + /declares 21 tags/ + ) && + hasErrorMatching( + [{ name: 'a', type: 'forum', availableTags: [{ name: 'x' }, { name: 'x' }] }], + /tag "x" is declared twice/ + )); +test('Channel validation rejects forum/media settings on text channels', () => + (['requireTag', 'availableTags', 'defaultReactionEmoji', 'defaultSortOrder'] as const).every( + (field) => + hasErrorMatching( + [{ name: 'a', type: 'text', [field]: FORUM_CONFIG[field] } as DiscordChannelConfig], + new RegExp(`sets ${field} but is a text channel`) + ) + )); +test('Channel validation rejects defaultForumLayout on non-forum channels', () => + hasErrorMatching([{ name: 'a', type: 'media', defaultForumLayout: 'list' }], /media channel/) && + hasErrorMatching([{ name: 'a', type: 'text', defaultForumLayout: 'list' }], /text channel/) && + getDiscordChannelConfigErrors([{ name: 'a', type: 'forum', defaultForumLayout: 'list' }]) + .length === 0); +test('Channel validation rejects disallowed durations and slowmode values', () => + hasErrorMatching( + [{ name: 'a', type: 'text', defaultAutoArchiveDuration: 120 as 60 }], + /defaultAutoArchiveDuration 120/ + ) && + hasErrorMatching( + [{ name: 'a', type: 'text', rateLimitPerUser: 21601 }], + /rateLimitPerUser 21601/ + ) && + hasErrorMatching( + [{ name: 'a', type: 'text', defaultThreadRateLimitPerUser: -1 }], + /defaultThreadRateLimitPerUser -1/ + )); +test('Channel validation requires exactly one of emojiId/emojiName on defaultReactionEmoji', () => + hasErrorMatching( + [{ name: 'a', type: 'forum', defaultReactionEmoji: {} }], + /neither emojiId nor emojiName/ + ) && + hasErrorMatching( + [{ name: 'a', type: 'forum', defaultReactionEmoji: { emojiId: SNOWFLAKE, emojiName: 'x' } }], + /both emojiId and emojiName/ + )); + +test('discordChannelTypeName maps text/forum/media and nothing else', () => + discordChannelTypeName(0) === 'text' && + discordChannelTypeName(15) === 'forum' && + discordChannelTypeName(16) === 'media' && + discordChannelTypeName(2) === undefined); +test('Text channel payload never contains forum/media fields', () => { + const payload = discordChannelPayloadFromConfig({ + id: SNOWFLAKE, + name: 'general', + type: 'text', + topic: 'Hello', + rateLimitPerUser: 5, + defaultAutoArchiveDuration: 4320, + }); + const forumKeys = [ + 'flags', + 'available_tags', + 'default_reaction_emoji', + 'default_sort_order', + 'default_forum_layout', + ]; + return ( + payload.name === 'general' && + payload.topic === 'Hello' && + payload.rate_limit_per_user === 5 && + payload.default_auto_archive_duration === 4320 && + forumKeys.every((key) => !(key in payload)) + ); +}); +test('Payload omits settings the config does not declare', () => { + const payload = discordChannelPayloadFromConfig({ name: 'general', type: 'text' }); + return Object.keys(payload).length === 1 && payload.name === 'general'; +}); +test('Forum payload maps tags, emoji, sort order, layout and REQUIRE_TAG', () => { + const payload = discordChannelPayloadFromConfig(FORUM_CONFIG); + const tags = payload.available_tags as { id?: string; name: string; moderated: boolean }[]; + return ( + payload.flags === DISCORD_CHANNEL_FLAG_REQUIRE_TAG && + payload.default_sort_order === 1 && + payload.default_forum_layout === 2 && + JSON.stringify(payload.default_reaction_emoji) === + JSON.stringify({ emoji_id: null, emoji_name: 'πŸ‘' }) && + tags.length === 2 && + tags.every((t) => t.id === undefined) && + tags[1].name === 'answered' && + tags[1].moderated === true + ); +}); +test('Forum payload preserves tag IDs and other flag bits from the live state', () => { + const state = discordChannelStateFromApi(FORUM_API); + const payload = discordChannelPayloadFromConfig( + { + ...FORUM_CONFIG, + requireTag: false, + availableTags: [{ name: 'typescript' }, { name: 'new' }], + }, + state + ); + const tags = payload.available_tags as { id?: string; name: string }[]; + return ( + payload.flags === 1 << 15 && tags[0].id === '222222222222222222' && tags[1].id === undefined + ); +}); +test('Media payload never contains default_forum_layout', () => { + const payload = discordChannelPayloadFromConfig({ + name: 'showcase', + type: 'media', + requireTag: true, + defaultSortOrder: 'latest_activity', + // Invalid for media (validation rejects it); the mapping must still not send it + defaultForumLayout: 'list', + }); + return ( + payload.flags === DISCORD_CHANNEL_FLAG_REQUIRE_TAG && + payload.default_sort_order === 0 && + !('default_forum_layout' in payload) + ); +}); +test('Create payload adds the Discord type and leaves out flags', () => { + const payload = discordChannelCreatePayloadFromConfig(FORUM_CONFIG); + return ( + payload.type === 15 && + !('flags' in payload) && + discordChannelCreatePayloadFromConfig({ name: 'a', type: 'text' }).type === 0 && + discordChannelCreatePayloadFromConfig({ name: 'a', type: 'media' }).type === 16 + ); +}); +test('discordChannelStateFromApi maps a forum channel back into config vocabulary', () => { + const state = discordChannelStateFromApi(FORUM_API); + return ( + state.type === 'forum' && + state.requireTag === true && + state.flags === (DISCORD_CHANNEL_FLAG_REQUIRE_TAG | (1 << 15)) && + state.availableTags.length === 2 && + state.availableTags[0].id === '111111111111111111' && + state.availableTags[0].moderated === true && + state.defaultReactionEmoji?.emojiName === 'πŸ‘' && + state.defaultSortOrder === 'creation_date' && + state.defaultForumLayout === 'gallery' && + state.position === 3 + ); +}); +test('discordChannelStateFromApi normalizes a bare text channel', () => { + const state = discordChannelStateFromApi(TEXT_API); + return ( + state.type === 'text' && + state.topic === '' && + state.parentId === null && + state.nsfw === false && + state.requireTag === false && + state.availableTags.length === 0 && + state.defaultReactionEmoji === null && + state.defaultSortOrder === null && + state.defaultForumLayout === null + ); +}); +test('No drift when the live channel matches the declared forum config', () => + getDiscordChannelDrift(FORUM_CONFIG, discordChannelStateFromApi(FORUM_API)).length === 0); +test('Drift names only the declared settings that differ', () => { + const state = discordChannelStateFromApi(FORUM_API); + const drift = getDiscordChannelDrift( + { ...FORUM_CONFIG, topic: 'Changed', requireTag: false, defaultForumLayout: 'list' }, + state + ); + return drift.join(',') === 'topic,requireTag,defaultForumLayout'; +}); +test('Omitted settings never count as drift', () => + getDiscordChannelDrift( + { id: SNOWFLAKE, name: 'sdk-help', type: 'forum' }, + discordChannelStateFromApi(FORUM_API) + ).length === 0 && + getDiscordChannelDrift( + { id: SNOWFLAKE, name: 'general', type: 'text' }, + discordChannelStateFromApi(TEXT_API) + ).length === 0); +test('Drift reports a type mismatch between config and live channel', () => + getDiscordChannelDrift( + { id: SNOWFLAKE, name: 'general', type: 'forum' }, + discordChannelStateFromApi(TEXT_API) + ).join(',') === 'type'); +test('Tag drift ignores Discord tag IDs and ordering but catches changed tags', () => { + const state = discordChannelStateFromApi(FORUM_API); + const reordered = { ...FORUM_CONFIG, availableTags: [...FORUM_CONFIG.availableTags!].reverse() }; + const renamed = { ...FORUM_CONFIG, availableTags: [{ name: 'typescript', emojiName: '🟦' }] }; + return ( + getDiscordChannelDrift(reordered, state).length === 0 && + getDiscordChannelDrift(renamed, state).join(',') === 'availableTags' + ); +}); + // Summary console.log(`\n${passed} passed, ${failed} failed`); process.exit(failed > 0 ? 1 : 0); diff --git a/scripts/validate-config.ts b/scripts/validate-config.ts index 58014bc9..a58381f9 100644 --- a/scripts/validate-config.ts +++ b/scripts/validate-config.ts @@ -15,6 +15,7 @@ import { } from '../src/config/packageAccess'; import { MEMBERS } from '../src/config/users'; import { ACCESS_POLICIES } from '../src/config/accessPolicies'; +import { DISCORD_CHANNELS, getDiscordChannelConfigErrors } from '../src/config/channels'; import { hasProvisionUserRole, resolveGoogleMemberEmail } from '../src/config/utils'; import type { RoleId } from '../src/config/roleIds'; @@ -396,6 +397,15 @@ console.log('Validating Cloudflare Access policies in accessPolicies.ts...'); } } +// Validate Discord channel entries in channels.ts +// Snowflake formats, duplicate entries, settings that Discord rejects for the channel +// type, and Discord's limits β€” all of which would otherwise only fail inside the deploy. +console.log('Validating Discord channel entries in channels.ts...'); +for (const error of getDiscordChannelConfigErrors(DISCORD_CHANNELS)) { + console.error(`ERROR: ${error}`); + hasErrors = true; +} + // Validate parent role references in roles.ts console.log('Validating parent role references in roles.ts...'); for (const role of ROLES) { diff --git a/src/config/channels.ts b/src/config/channels.ts new file mode 100644 index 00000000..c2327c95 --- /dev/null +++ b/src/config/channels.ts @@ -0,0 +1,593 @@ +// Discord channels managed by Pulumi (the DiscordChannel resource in src/discord.ts). +// +// Thread-only channels: Discord has no "require threads" switch on a text channel. +// Thread-only behaviour exists only as the GUILD_FORUM (15) and GUILD_MEDIA (16) +// channel types, where every post opens a thread. The API converts text <-> +// announcement channels only, so an existing text channel can NEVER become a forum. +// To make an existing channel thread-only, declare a NEW forum/media channel here +// (without `id`) and retire the old channel by hand once people have moved over. +// +// Each entry works in one of two modes: +// - `id` set: the existing channel with that snowflake is adopted and the settings +// declared here are enforced on it in place. Settings you omit are left exactly as +// they are on Discord. The declared `type` must match the live channel type; a +// mismatch fails the deploy instead of silently managing the wrong thing. +// - `id` unset: a new channel of `type` is created in the guild on the next deploy. +// +// Removing an entry NEVER deletes the channel on Discord (that would destroy its +// history); the resource is only dropped from Pulumi state. Changing `id` or `type` +// replaces the resource the same way: the new channel is adopted/created and the old +// one is left in place. Delete channels by hand, if at all. +// +// The `id ?? name` of an entry is also its Pulumi resource name +// (`discord-channel-`), so renaming a channel created without `id` creates +// a second channel. Adopt it by its `id` first if you want to rename it in place. + +export type DiscordChannelType = 'text' | 'forum' | 'media'; + +/** Allowed values (minutes) for defaultAutoArchiveDuration. */ +export const DISCORD_AUTO_ARCHIVE_DURATIONS = [60, 1440, 4320, 10080] as const; +export type DiscordAutoArchiveDuration = (typeof DISCORD_AUTO_ARCHIVE_DURATIONS)[number]; + +export type DiscordSortOrder = 'latest_activity' | 'creation_date'; +export type DiscordForumLayout = 'not_set' | 'list' | 'gallery'; + +/** A custom (`emojiId`) or unicode (`emojiName`) emoji; set exactly one. */ +export interface DiscordEmojiConfig { + /** Custom emoji ID (snowflake) */ + emojiId?: string; + /** Unicode emoji character, e.g. 'πŸ›' */ + emojiName?: string; +} + +/** A tag that threads in a forum/media channel can be filed under. */ +export interface DiscordForumTagConfig extends DiscordEmojiConfig { + /** Tag name (1-20 characters), unique within the channel. Used to match the tag across deploys. */ + name: string; + /** If true, only members with MANAGE_THREADS can apply or remove the tag */ + moderated?: boolean; +} + +export interface DiscordChannelConfig { + /** + * Snowflake of an existing channel to adopt. When set, the settings below are + * applied to that channel in place; when unset, a new channel is created. + */ + id?: string; + /** Channel name: lowercase letters, digits, '-' and '_' (Discord normalizes anything else) */ + name: string; + /** 'text' is a normal channel; 'forum' and 'media' are thread-only */ + type: DiscordChannelType; + /** Category (parent channel) snowflake */ + parentId?: string; + /** Channel topic; for forum/media channels this is the post guidelines text */ + topic?: string; + nsfw?: boolean; + /** Sort position within the category */ + position?: number; + /** Slowmode in seconds (0-21600) */ + rateLimitPerUser?: number; + /** Default auto-archive time for new threads, in minutes */ + defaultAutoArchiveDuration?: DiscordAutoArchiveDuration; + /** Slowmode (seconds) applied to newly created threads */ + defaultThreadRateLimitPerUser?: number; + + // --- forum/media only --- + /** Require every new thread to carry at least one tag */ + requireTag?: boolean; + /** Tags threads can be filed under (max 20). `[]` removes all tags; omit to leave them alone. */ + availableTags?: readonly DiscordForumTagConfig[]; + /** Emoji shown as the default reaction on new threads */ + defaultReactionEmoji?: DiscordEmojiConfig; + defaultSortOrder?: DiscordSortOrder; + + // --- forum only --- + defaultForumLayout?: DiscordForumLayout; +} + +/** + * Channels managed by Pulumi. Empty until maintainers add entries, so deploys are a + * no-op. Examples: + * + * // Adopt an existing text channel by ID and pin its slowmode and thread defaults + * { + * id: '1234567890123456789', + * name: 'general', + * type: 'text', + * rateLimitPerUser: 5, + * defaultAutoArchiveDuration: 4320, + * }, + * + * // Create a new thread-only forum channel (an existing text channel cannot be + * // converted; create the forum, then retire the old channel by hand) + * { + * name: 'sdk-help', + * type: 'forum', + * parentId: '1234567890123456789', + * topic: 'One thread per question. Pick the SDK tag that matches.', + * requireTag: true, + * availableTags: [ + * { name: 'typescript', emojiName: '🟦' }, + * { name: 'python', emojiName: '🐍' }, + * { name: 'answered', moderated: true }, + * ], + * defaultReactionEmoji: { emojiName: 'πŸ‘' }, + * defaultSortOrder: 'latest_activity', + * defaultForumLayout: 'list', + * defaultAutoArchiveDuration: 10080, + * }, + * + * // Adopt an existing media (thread-only, image/video first) channel by ID + * { + * id: '1234567890123456789', + * name: 'showcase', + * type: 'media', + * requireTag: false, + * defaultSortOrder: 'creation_date', + * }, + */ +export const DISCORD_CHANNELS: readonly DiscordChannelConfig[] = []; + +/** Pulumi resource name for a channel entry (stable across deploys). */ +export function discordChannelResourceName(channel: DiscordChannelConfig): string { + return `discord-channel-${channel.id ?? channel.name}`; +} + +/** Forum and media channels are thread-only: every post is a thread. */ +export function isThreadOnlyChannelType(type: DiscordChannelType): boolean { + return type === 'forum' || type === 'media'; +} + +// --------------------------------------------------------------------------- +// Validation (used by scripts/validate-config.ts and scripts/test-config.ts) +// --------------------------------------------------------------------------- + +const SNOWFLAKE_PATTERN = /^\d{17,20}$/; +const CHANNEL_NAME_PATTERN = /^[a-z0-9_-]{1,100}$/; +/** Discord's maximum slowmode, in seconds (6 hours) */ +const MAX_RATE_LIMIT_PER_USER = 21600; +const MAX_AVAILABLE_TAGS = 20; +const MAX_TAG_NAME_LENGTH = 20; + +export function isDiscordSnowflake(value: string): boolean { + return SNOWFLAKE_PATTERN.test(value); +} + +function emojiConfigErrors( + label: string, + emoji: DiscordEmojiConfig, + requireOne: boolean +): string[] { + const errors: string[] = []; + const hasId = emoji.emojiId !== undefined; + const hasName = emoji.emojiName !== undefined; + if (hasId && hasName) { + errors.push(`${label} sets both emojiId and emojiName; set exactly one`); + } + if (requireOne && !hasId && !hasName) { + errors.push(`${label} sets neither emojiId nor emojiName; set exactly one`); + } + if (hasId && !isDiscordSnowflake(emoji.emojiId!)) { + errors.push(`${label} has emojiId "${emoji.emojiId}" which is not a Discord snowflake`); + } + if (hasName && emoji.emojiName!.trim() === '') { + errors.push(`${label} has an empty emojiName`); + } + return errors; +} + +/** + * Validate the channel entries. Returns one human-readable message per problem + * (empty when everything is fine) so the validate script can print them and the + * tests can assert on them. Catches what would otherwise only fail inside the + * deploy: malformed snowflakes, duplicate resource names, settings Discord rejects + * for the channel type, and limits Discord enforces (tags, slowmode, durations). + */ +export function getDiscordChannelConfigErrors(channels: readonly DiscordChannelConfig[]): string[] { + const errors: string[] = []; + const seenIds = new Set(); + const seenNamesWithoutId = new Set(); + + for (const channel of channels) { + const label = `Discord channel "${channel.id ?? channel.name}"`; + const threadOnly = isThreadOnlyChannelType(channel.type); + + if (!CHANNEL_NAME_PATTERN.test(channel.name)) { + errors.push( + `${label} has an invalid name "${channel.name}"; use 1-100 lowercase letters, digits, '-' and '_'` + ); + } + + if (channel.id !== undefined) { + if (!isDiscordSnowflake(channel.id)) { + errors.push(`${label} has id "${channel.id}" which is not a Discord snowflake`); + } + if (seenIds.has(channel.id)) { + errors.push(`${label} is declared twice (duplicate id)`); + } + seenIds.add(channel.id); + } else { + // Two entries without id and the same name would declare the same Pulumi resource twice + if (seenNamesWithoutId.has(channel.name)) { + errors.push(`${label} is declared twice (duplicate name without an id)`); + } + seenNamesWithoutId.add(channel.name); + } + + if (channel.parentId !== undefined && !isDiscordSnowflake(channel.parentId)) { + errors.push(`${label} has parentId "${channel.parentId}" which is not a Discord snowflake`); + } + + if ( + channel.position !== undefined && + (!Number.isInteger(channel.position) || channel.position < 0) + ) { + errors.push(`${label} has position ${channel.position}; it must be a non-negative integer`); + } + + for (const [field, value] of [ + ['rateLimitPerUser', channel.rateLimitPerUser], + ['defaultThreadRateLimitPerUser', channel.defaultThreadRateLimitPerUser], + ] as const) { + if ( + value !== undefined && + (!Number.isInteger(value) || value < 0 || value > MAX_RATE_LIMIT_PER_USER) + ) { + errors.push( + `${label} has ${field} ${value}; it must be an integer between 0 and ${MAX_RATE_LIMIT_PER_USER} seconds` + ); + } + } + + if ( + channel.defaultAutoArchiveDuration !== undefined && + !(DISCORD_AUTO_ARCHIVE_DURATIONS as readonly number[]).includes( + channel.defaultAutoArchiveDuration + ) + ) { + errors.push( + `${label} has defaultAutoArchiveDuration ${channel.defaultAutoArchiveDuration}; ` + + `Discord allows ${DISCORD_AUTO_ARCHIVE_DURATIONS.join(', ')}` + ); + } + + if (!threadOnly) { + for (const field of [ + 'requireTag', + 'availableTags', + 'defaultReactionEmoji', + 'defaultSortOrder', + ] as const) { + if (channel[field] !== undefined) { + errors.push( + `${label} sets ${field} but is a text channel; that setting exists only on forum and media channels` + ); + } + } + } + + if (channel.type !== 'forum' && channel.defaultForumLayout !== undefined) { + errors.push( + `${label} sets defaultForumLayout but is a ${channel.type} channel; that setting exists only on forum channels` + ); + } + + if (channel.availableTags !== undefined) { + if (channel.availableTags.length > MAX_AVAILABLE_TAGS) { + errors.push( + `${label} declares ${channel.availableTags.length} tags; Discord allows at most ${MAX_AVAILABLE_TAGS}` + ); + } + const seenTagNames = new Set(); + for (const tag of channel.availableTags) { + const tagLabel = `${label} tag "${tag.name}"`; + if (tag.name.trim() === '' || tag.name.length > MAX_TAG_NAME_LENGTH) { + errors.push(`${tagLabel} must have a name of 1-${MAX_TAG_NAME_LENGTH} characters`); + } + if (seenTagNames.has(tag.name)) { + errors.push(`${tagLabel} is declared twice; tag names must be unique within a channel`); + } + seenTagNames.add(tag.name); + errors.push(...emojiConfigErrors(tagLabel, tag, false)); + } + } + + if (channel.defaultReactionEmoji !== undefined) { + errors.push( + ...emojiConfigErrors(`${label} defaultReactionEmoji`, channel.defaultReactionEmoji, true) + ); + } + } + + return errors; +} + +// --------------------------------------------------------------------------- +// Mapping between this config and the Discord API, kept free of I/O so that +// scripts/test-config.ts can exercise it. src/discord.ts does the HTTP calls. +// --------------------------------------------------------------------------- + +/** Discord channel type IDs: GUILD_TEXT, GUILD_FORUM, GUILD_MEDIA */ +export const DISCORD_CHANNEL_TYPE_IDS: Record = { + text: 0, + forum: 15, + media: 16, +}; +const DISCORD_SORT_ORDER_IDS: Record = { + latest_activity: 0, + creation_date: 1, +}; +const DISCORD_FORUM_LAYOUT_IDS: Record = { + not_set: 0, + list: 1, + gallery: 2, +}; +/** Channel flag bit: new threads in a forum/media channel must carry a tag */ +export const DISCORD_CHANNEL_FLAG_REQUIRE_TAG = 1 << 4; + +function keyForValue( + ids: Record, + value: number | null | undefined +): K | null { + if (value === null || value === undefined) return null; + const match = (Object.keys(ids) as K[]).find((key) => ids[key] === value); + return match ?? null; +} + +/** Name of a Discord channel type ID, or undefined for types this config does not model */ +export function discordChannelTypeName(typeId: number): DiscordChannelType | undefined { + return keyForValue(DISCORD_CHANNEL_TYPE_IDS, typeId) ?? undefined; +} + +/** The subset of Discord's channel object that this module reads */ +export interface DiscordChannelApiResponse { + id: string; + type: number; + guild_id?: string; + name: string; + position?: number; + parent_id?: string | null; + topic?: string | null; + nsfw?: boolean; + rate_limit_per_user?: number; + default_auto_archive_duration?: number | null; + default_thread_rate_limit_per_user?: number | null; + flags?: number; + available_tags?: DiscordForumTagApi[]; + default_reaction_emoji?: DiscordEmojiApi | null; + default_sort_order?: number | null; + default_forum_layout?: number | null; +} + +export interface DiscordForumTagApi { + /** Assigned by Discord; omitted when creating a tag */ + id?: string; + name: string; + moderated: boolean; + emoji_id: string | null; + emoji_name: string | null; +} + +export interface DiscordEmojiApi { + emoji_id: string | null; + emoji_name: string | null; +} + +export interface DiscordEmojiState { + emojiId: string | null; + emojiName: string | null; +} + +export interface DiscordForumTagState extends DiscordEmojiState { + /** Discord's tag ID, carried into updates so a tag keeps its identity (and the threads filed under it) */ + id: string; + name: string; + moderated: boolean; +} + +/** + * The managed settings of a channel as read from Discord, in config vocabulary. + * Stored as the resource's `state` output; the resource's diff compares it against + * the declared config to detect drift after a refresh. + */ +export interface DiscordChannelState { + type: DiscordChannelType | null; + name: string; + parentId: string | null; + topic: string; + nsfw: boolean; + position: number | null; + rateLimitPerUser: number; + defaultAutoArchiveDuration: number | null; + defaultThreadRateLimitPerUser: number; + /** Raw channel flags; bits other than REQUIRE_TAG are preserved verbatim on updates */ + flags: number; + requireTag: boolean; + availableTags: DiscordForumTagState[]; + defaultReactionEmoji: DiscordEmojiState | null; + defaultSortOrder: DiscordSortOrder | null; + defaultForumLayout: DiscordForumLayout | null; +} + +function emojiStateFromApi(emoji: DiscordEmojiApi | null | undefined): DiscordEmojiState | null { + if (!emoji || (emoji.emoji_id === null && emoji.emoji_name === null)) return null; + return { emojiId: emoji.emoji_id ?? null, emojiName: emoji.emoji_name ?? null }; +} + +function emojiApiFromConfig(emoji: DiscordEmojiConfig): DiscordEmojiApi { + return { emoji_id: emoji.emojiId ?? null, emoji_name: emoji.emojiName ?? null }; +} + +/** Map a Discord channel object to the managed state. */ +export function discordChannelStateFromApi( + channel: DiscordChannelApiResponse +): DiscordChannelState { + const flags = channel.flags ?? 0; + return { + type: discordChannelTypeName(channel.type) ?? null, + name: channel.name, + parentId: channel.parent_id ?? null, + topic: channel.topic ?? '', + nsfw: channel.nsfw ?? false, + position: channel.position ?? null, + rateLimitPerUser: channel.rate_limit_per_user ?? 0, + defaultAutoArchiveDuration: channel.default_auto_archive_duration ?? null, + defaultThreadRateLimitPerUser: channel.default_thread_rate_limit_per_user ?? 0, + flags, + requireTag: (flags & DISCORD_CHANNEL_FLAG_REQUIRE_TAG) !== 0, + availableTags: (channel.available_tags ?? []) + .filter((tag): tag is DiscordForumTagApi & { id: string } => tag.id !== undefined) + .map((tag) => ({ + id: tag.id, + name: tag.name, + moderated: tag.moderated ?? false, + emojiId: tag.emoji_id ?? null, + emojiName: tag.emoji_name ?? null, + })), + defaultReactionEmoji: emojiStateFromApi(channel.default_reaction_emoji), + defaultSortOrder: keyForValue(DISCORD_SORT_ORDER_IDS, channel.default_sort_order), + defaultForumLayout: keyForValue(DISCORD_FORUM_LAYOUT_IDS, channel.default_forum_layout), + }; +} + +/** + * Build the body for PATCH /channels/{id} (Modify Channel) from the declared config. + * Only declared settings are sent, and only those valid for the channel type: forum/media + * settings never reach a text channel and defaultForumLayout only reaches a forum. + * `current` is the live state: REQUIRE_TAG is merged into its other flag bits and + * existing tags keep their Discord ID when their name is still declared, so an update + * does not delete and recreate them. + */ +export function discordChannelPayloadFromConfig( + config: DiscordChannelConfig, + current?: DiscordChannelState +): Record { + const payload: Record = { name: config.name }; + + if (config.parentId !== undefined) payload.parent_id = config.parentId; + if (config.topic !== undefined) payload.topic = config.topic; + if (config.nsfw !== undefined) payload.nsfw = config.nsfw; + if (config.position !== undefined) payload.position = config.position; + if (config.rateLimitPerUser !== undefined) payload.rate_limit_per_user = config.rateLimitPerUser; + if (config.defaultAutoArchiveDuration !== undefined) { + payload.default_auto_archive_duration = config.defaultAutoArchiveDuration; + } + if (config.defaultThreadRateLimitPerUser !== undefined) { + payload.default_thread_rate_limit_per_user = config.defaultThreadRateLimitPerUser; + } + + if (!isThreadOnlyChannelType(config.type)) return payload; + + if (config.requireTag !== undefined) { + const otherFlags = (current?.flags ?? 0) & ~DISCORD_CHANNEL_FLAG_REQUIRE_TAG; + payload.flags = config.requireTag ? otherFlags | DISCORD_CHANNEL_FLAG_REQUIRE_TAG : otherFlags; + } + if (config.availableTags !== undefined) { + payload.available_tags = config.availableTags.map((tag): DiscordForumTagApi => { + const existing = current?.availableTags.find((t) => t.name === tag.name); + return { + ...(existing ? { id: existing.id } : {}), + name: tag.name, + moderated: tag.moderated ?? false, + ...emojiApiFromConfig(tag), + }; + }); + } + if (config.defaultReactionEmoji !== undefined) { + payload.default_reaction_emoji = emojiApiFromConfig(config.defaultReactionEmoji); + } + if (config.defaultSortOrder !== undefined) { + payload.default_sort_order = DISCORD_SORT_ORDER_IDS[config.defaultSortOrder]; + } + if (config.type === 'forum' && config.defaultForumLayout !== undefined) { + payload.default_forum_layout = DISCORD_FORUM_LAYOUT_IDS[config.defaultForumLayout]; + } + + return payload; +} + +/** + * Build the body for POST /guilds/{guild}/channels (Create Guild Channel). Same as the + * modify payload plus `type`, minus `flags`, which that endpoint does not accept; + * create() applies requireTag with a follow-up PATCH. + */ +export function discordChannelCreatePayloadFromConfig( + config: DiscordChannelConfig +): Record { + const { flags: _flags, ...payload } = discordChannelPayloadFromConfig(config); + return { type: DISCORD_CHANNEL_TYPE_IDS[config.type], ...payload }; +} + +function sortTagsByName(tags: readonly T[]): T[] { + return [...tags].sort((a, b) => a.name.localeCompare(b.name)); +} + +/** + * Names of the declared settings whose live value differs from the config. Settings the + * config omits are not managed and never count as drift. Empty means in sync. + */ +export function getDiscordChannelDrift( + config: DiscordChannelConfig, + state: DiscordChannelState +): string[] { + const drift: string[] = []; + const check = (field: string, declared: unknown, live: unknown) => { + if (declared !== undefined && JSON.stringify(declared) !== JSON.stringify(live)) { + drift.push(field); + } + }; + + check('type', config.type, state.type); + check('name', config.name, state.name); + check('parentId', config.parentId, state.parentId); + check('topic', config.topic, state.topic); + check('nsfw', config.nsfw, state.nsfw); + check('position', config.position, state.position); + check('rateLimitPerUser', config.rateLimitPerUser, state.rateLimitPerUser); + check( + 'defaultAutoArchiveDuration', + config.defaultAutoArchiveDuration, + state.defaultAutoArchiveDuration + ); + check( + 'defaultThreadRateLimitPerUser', + config.defaultThreadRateLimitPerUser, + state.defaultThreadRateLimitPerUser + ); + + if (!isThreadOnlyChannelType(config.type)) return drift; + + check('requireTag', config.requireTag, state.requireTag); + if (config.availableTags !== undefined) { + // Compare by name, ignoring Discord's tag IDs and ordering + const declaredTags = sortTagsByName(config.availableTags).map((tag) => ({ + name: tag.name, + moderated: tag.moderated ?? false, + emojiId: tag.emojiId ?? null, + emojiName: tag.emojiName ?? null, + })); + const liveTags = sortTagsByName(state.availableTags).map((tag) => ({ + name: tag.name, + moderated: tag.moderated, + emojiId: tag.emojiId, + emojiName: tag.emojiName, + })); + check('availableTags', declaredTags, liveTags); + } + if (config.defaultReactionEmoji !== undefined) { + check( + 'defaultReactionEmoji', + { + emojiId: config.defaultReactionEmoji.emojiId ?? null, + emojiName: config.defaultReactionEmoji.emojiName ?? null, + }, + state.defaultReactionEmoji + ); + } + check('defaultSortOrder', config.defaultSortOrder, state.defaultSortOrder); + if (config.type === 'forum') { + check('defaultForumLayout', config.defaultForumLayout, state.defaultForumLayout); + } + + return drift; +} diff --git a/src/discord.ts b/src/discord.ts index 25b20180..c0e73b84 100644 --- a/src/discord.ts +++ b/src/discord.ts @@ -2,6 +2,19 @@ import * as pulumi from '@pulumi/pulumi'; import { ROLES, type Role, buildRoleLookup } from './config/roles'; import { MEMBERS } from './config/users'; import type { RoleId } from './config/roleIds'; +import { + DISCORD_CHANNELS, + DISCORD_CHANNEL_TYPE_IDS, + discordChannelCreatePayloadFromConfig, + discordChannelPayloadFromConfig, + discordChannelResourceName, + discordChannelStateFromApi, + discordChannelTypeName, + getDiscordChannelDrift, + type DiscordChannelApiResponse, + type DiscordChannelConfig, + type DiscordChannelState, +} from './config/channels'; const config = new pulumi.Config('discord'); // Discord integration is optional - only enabled if botToken and guildId are configured @@ -459,9 +472,252 @@ class DiscordMemberRoleSync extends pulumi.dynamic.Resource { } } +// Discord Channel Dynamic Provider +// Manages the settings of a channel declared in config/channels.ts: either an existing +// channel adopted by its ID, or a new channel created by this provider (the only way to +// get a thread-only forum/media channel, since Discord cannot convert a text channel). +// +// Only the settings the config declares are managed; the rest of the channel is left +// alone. read() is side-effect free and returns the live settings as `state`, and +// diff() compares that state with the declared config, so drift introduced by hand in +// Discord is corrected by the next deploy (`make up` runs `pulumi up --refresh`). +// +// NEVER deletes a channel: removing an entry from channels.ts only drops the resource +// from Pulumi state, because deleting a channel destroys its message history. +interface DiscordChannelInputs { + guildId: string; + channel: DiscordChannelConfig; + token: string; +} + +interface DiscordChannelOutputs extends DiscordChannelInputs { + channelId: string; + /** Managed settings as last read from Discord */ + state: DiscordChannelState; +} + +/** Discord error code for "Unknown Channel" */ +const DISCORD_UNKNOWN_CHANNEL_CODE = 10003; + +function describeDiscordChannelType(typeId: number): string { + const name = discordChannelTypeName(typeId); + return name ? `type ${typeId} (${name})` : `type ${typeId}`; +} + +/** + * Check that a live channel is the one the config means: same guild, same type. + * Discord can only convert text <-> announcement channels, so a type mismatch cannot + * be fixed with a PATCH and the deploy must fail with an actionable message. + */ +function assertDiscordChannelMatches( + guildId: string, + config: DiscordChannelConfig, + live: DiscordChannelApiResponse +): void { + const label = `Discord channel ${live.id} ("${config.name}")`; + if (live.guild_id !== guildId) { + throw new Error( + `${label} belongs to guild ${live.guild_id ?? 'unknown'}, not the configured guild ${guildId}` + ); + } + if (discordChannelTypeName(live.type) !== config.type) { + throw new Error( + `${label} is ${describeDiscordChannelType(live.type)} but channels.ts declares type ` + + `'${config.type}' (${DISCORD_CHANNEL_TYPE_IDS[config.type]}). Discord cannot convert ` + + `between these types: to make an existing channel thread-only, declare a new forum/media ` + + `channel (without id) and retire the old one by hand.` + ); + } +} + +/** + * Apply the declared settings to a live channel if they differ, and return the + * resulting state. Tag IDs and unmanaged flag bits come from `live`. + */ +async function reconcileDiscordChannel( + token: string, + guildId: string, + config: DiscordChannelConfig, + live: DiscordChannelApiResponse +): Promise { + assertDiscordChannelMatches(guildId, config, live); + + const current = discordChannelStateFromApi(live); + const drift = getDiscordChannelDrift(config, current); + if (drift.length === 0) return current; + + const updated = await discordFetch(token, `/channels/${live.id}`, { + method: 'PATCH', + body: JSON.stringify(discordChannelPayloadFromConfig(config, current)), + }); + return discordChannelStateFromApi(updated); +} + +/** Deep equality for the JSON-like values Pulumi hands to the provider (key order ignored) */ +function jsonEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false; + if (Array.isArray(a) !== Array.isArray(b)) return false; + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((value, i) => jsonEqual(value, b[i])); + } + const recordA = a as Record; + const recordB = b as Record; + // undefined properties are dropped during serialization, so treat them as absent + const keysA = Object.keys(recordA).filter((k) => recordA[k] !== undefined); + const keysB = Object.keys(recordB).filter((k) => recordB[k] !== undefined); + return keysA.length === keysB.length && keysA.every((k) => jsonEqual(recordA[k], recordB[k])); +} + +const discordChannelProvider: pulumi.dynamic.ResourceProvider = { + async diff( + _id: string, + olds: DiscordChannelOutputs, + news: DiscordChannelInputs + ): Promise { + const replaces: string[] = []; + if (olds.guildId !== news.guildId) replaces.push('guildId'); + // A different channel, or a type Discord cannot convert to: adopt/create the new + // one and drop the old one from state (delete() never touches Discord). + if (olds.channel?.id !== news.channel.id || olds.channel?.type !== news.channel.type) { + replaces.push('channel'); + } + + const inputsChanged = + olds.guildId !== news.guildId || + olds.token !== news.token || + !jsonEqual(olds.channel, news.channel); + // `state` is the live state from the last create/update/refresh + const drifted = + olds.state === undefined || getDiscordChannelDrift(news.channel, olds.state).length > 0; + + return { + changes: inputsChanged || drifted, + replaces: replaces.length > 0 ? replaces : undefined, + }; + }, + + async create( + inputs: DiscordChannelInputs + ): Promise> { + const { guildId, channel, token } = inputs; + + let live: DiscordChannelApiResponse; + if (channel.id !== undefined) { + // Adopt the existing channel and bring its declared settings in line + live = await discordFetch(token, `/channels/${channel.id}`); + } else { + // Create Guild Channel does not take `flags`; reconcile below applies requireTag + live = await discordFetch(token, `/guilds/${guildId}/channels`, { + method: 'POST', + body: JSON.stringify(discordChannelCreatePayloadFromConfig(channel)), + }); + } + + const state = await reconcileDiscordChannel(token, guildId, channel, live); + + return { + id: live.id, + outs: { + ...inputs, + channelId: live.id, + state, + }, + }; + }, + + async read( + id: string, + props: DiscordChannelOutputs + ): Promise> { + let live: DiscordChannelApiResponse; + try { + live = await discordFetch(props.token, `/channels/${id}`); + } catch (error) { + if ( + error instanceof Error && + error.message.includes(`code: ${DISCORD_UNKNOWN_CHANNEL_CODE}`) + ) { + // Deleted outside Pulumi: a blank id drops the resource from state on refresh. + // The next deploy recreates a channel declared without id, and fails with a + // clear error for one declared by id. + console.warn(`Discord channel ${id} ("${props.channel.name}") no longer exists`); + return { id: '' }; + } + throw new Error(`Failed to read channel ${id}: ${error}`); + } + + // Side-effect free: report the live settings and let diff() turn drift into an update + return { + id, + props: { + ...props, + channelId: live.id, + state: discordChannelStateFromApi(live), + }, + }; + }, + + async update( + id: string, + _olds: DiscordChannelOutputs, + news: DiscordChannelInputs + ): Promise> { + // Re-read before patching so tag IDs and unmanaged flag bits are current even + // when the last refresh is stale + const live = await discordFetch(news.token, `/channels/${id}`); + const state = await reconcileDiscordChannel(news.token, news.guildId, news.channel, live); + + return { + outs: { + ...news, + channelId: id, + state, + }, + }; + }, + + async delete(id: string, props: DiscordChannelOutputs): Promise { + // Never delete a Discord channel: that would destroy its message history. + // Removing an entry from channels.ts only stops managing the channel. + console.warn( + `Discord channel ${id} ("${props.channel.name}") was removed from config; it is left ` + + `in place on Discord and only dropped from Pulumi state. Delete it by hand if intended.` + ); + }, +}; + +class DiscordChannel extends pulumi.dynamic.Resource { + public readonly channelId!: pulumi.Output; + public readonly state!: pulumi.Output; + + constructor( + name: string, + args: { + guildId: pulumi.Input; + channel: pulumi.Input; + token: pulumi.Input; + }, + opts?: pulumi.CustomResourceOptions + ) { + super( + discordChannelProvider, + name, + { + channelId: undefined, + state: undefined, + ...args, + }, + opts + ); + } +} + const roleLookup = buildRoleLookup(); // Discord roles keyed by Discord role name const roles: Record = {}; +// Discord channels keyed by `id ?? name` of their channels.ts entry +const channels: Record = {}; /** * Expand a set of role IDs to include all implied Discord roles. @@ -546,6 +802,15 @@ if (DISCORD_ENABLED) { { dependsOn: Object.values(roles) } ); }); + + // Manage the channels declared in channels.ts (adopted by id, or created) + DISCORD_CHANNELS.forEach((channel) => { + channels[channel.id ?? channel.name] = new DiscordChannel(discordChannelResourceName(channel), { + guildId, + channel, + token: botToken, + }); + }); } -export { roles as discordRoles }; +export { roles as discordRoles, channels as discordChannels }; From 3319c6f2aac5448888e2cf603238038ed65322dc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 20:25:28 +0000 Subject: [PATCH 2/2] Address review feedback on Discord channel resource - name is optional when adopting by id (DiscordChannelConfig is now a union of adopted/new entries); it is sent in the PATCH and compared for drift only when declared, and labels fall back to the id - Name validation follows Discord's rule (1-100 code points); uppercase letters and spaces are a WARNING (printed, never failing the check) - requireTag needs tags: error on a new channel or with availableTags empty, warning on an adopted channel that declares none, and the provider refuses the PATCH when the live channel has no tags either - Topic length is validated: 1024 for text, 4096 for forum/media - position is a create-time hint only (sent on POST, excluded from drift and PATCH), documented in channels.ts and the README - Channel POST/PATCH calls send X-Audit-Log-Reason so the guild audit log shows where changes came from Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01SyDWqMwnKtkynCQNahMKxf --- README.md | 11 +- scripts/test-config.ts | 121 +++++++++++++++++++- scripts/validate-config.ts | 16 ++- src/config/channels.ts | 228 ++++++++++++++++++++++++++++++------- src/discord.ts | 31 ++++- 5 files changed, 345 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index c3de6aa7..b46bd811 100644 --- a/README.md +++ b/README.md @@ -64,12 +64,12 @@ The PR's `pulumi preview` comment shows the repository create. Once merged, the ## Discord channels -Channels listed in [`src/config/channels.ts`](src/config/channels.ts) are managed by the `DiscordChannel` resource in [`src/discord.ts`](src/discord.ts), using the same `discord:botToken`/`discord:guildId` stack config as the role sync (the bot needs the **Manage Channels** permission in the guild). Each entry is either an **existing channel adopted by its ID** or a **new channel created on deploy**: +Channels listed in [`src/config/channels.ts`](src/config/channels.ts) are managed by the `DiscordChannel` resource in [`src/discord.ts`](src/discord.ts), using the same `discord:botToken`/`discord:guildId` stack config as the role sync (the bot needs the **Manage Channels** permission in the guild, plus **View Channel** on any private channel you adopt; creating `forum`/`media` channels requires the guild's Community feature). Each entry is either an **existing channel adopted by its ID** or a **new channel created on deploy**: ```ts // Adopt an existing channel by ID: the declared settings are enforced in place, -// anything not declared is left as it is on Discord -{ id: '1234567890123456789', name: 'general', type: 'text', rateLimitPerUser: 5 }, +// anything not declared (here, the name) is left as it is on Discord +{ id: '1234567890123456789', type: 'text', rateLimitPerUser: 5 }, // Create a thread-only forum channel { @@ -83,8 +83,9 @@ Channels listed in [`src/config/channels.ts`](src/config/channels.ts) are manage - **Thread-only channels are a channel type, not a setting.** Discord has no "require threads" switch; thread-only behaviour exists only as `forum` and `media` channels, and the API cannot convert a text channel into one. To make an existing channel thread-only, declare a new `forum`/`media` channel (without `id`) and retire the old channel by hand. Declaring `type: 'forum'` on the `id` of a text channel fails the deploy with an error saying so. - **Channels are never deleted.** Removing an entry only drops the resource from Pulumi state; the channel and its history stay on Discord. Changing an entry's `id` or `type` likewise adopts/creates the new channel and leaves the old one alone. -- Only declared settings are managed. Validation (`npm run check`) rejects malformed snowflakes, duplicate entries, more than 20 tags, and forum/media settings on text channels (`defaultForumLayout` is forum-only). -- Settings changed by hand in Discord show up as drift on refresh and are reverted by the next deploy (`make up` runs `pulumi up --refresh`). +- Only declared settings are managed. `name` is required when creating a channel and optional when adopting one by `id`; declare it only if you want it enforced. Validation (`npm run check`) rejects a missing name on a new channel, names outside 1-100 characters, topics over Discord's limit (1024 for text, 4096 for forum/media), malformed snowflakes, duplicate entries, more than 20 tags, `requireTag` with no tags to pick from, and forum/media settings on text channels (`defaultForumLayout` is forum-only). It prints a `WARNING:` without failing for names with uppercase letters or spaces (Discord normalizes those, so the declared name would never match) and for `requireTag` on an adopted channel that declares no `availableTags` (it relies on the tags already on Discord; the deploy fails if there are none). +- `position` is applied on creation only. Discord renumbers positions whenever channels move, so it is not checked or re-applied afterwards; reorder channels in Discord. +- Settings changed by hand in Discord show up as drift on refresh and are reverted by the next deploy (`make up` runs `pulumi up --refresh`). Channel creates and updates carry `X-Audit-Log-Reason: modelcontextprotocol/access deploy`, so the guild's audit log shows where a change came from. ## Cloudflare Access (security-room) diff --git a/scripts/test-config.ts b/scripts/test-config.ts index 4a2d9ada..5efc114f 100644 --- a/scripts/test-config.ts +++ b/scripts/test-config.ts @@ -24,13 +24,16 @@ import { DISCORD_CHANNEL_FLAG_REQUIRE_TAG, discordChannelCreatePayloadFromConfig, discordChannelPayloadFromConfig, + discordChannelRequireTagError, discordChannelResourceName, discordChannelStateFromApi, discordChannelTypeName, getDiscordChannelConfigErrors, + getDiscordChannelConfigWarnings, getDiscordChannelDrift, type DiscordChannelApiResponse, type DiscordChannelConfig, + type DiscordNewChannelConfig, } from '../src/config/channels'; let passed = 0; @@ -249,7 +252,7 @@ test('getAccessPolicyTeams throws for a role without a GitHub team', () => { // Test Discord channel config (channels.ts) and its pure Discord API mapping const SNOWFLAKE = '123456789012345678'; const OTHER_SNOWFLAKE = '987654321098765432'; -const FORUM_CONFIG: DiscordChannelConfig = { +const FORUM_CONFIG: DiscordNewChannelConfig = { name: 'sdk-help', type: 'forum', parentId: OTHER_SNOWFLAKE, @@ -312,6 +315,11 @@ const TEXT_API: DiscordChannelApiResponse = { }; const hasErrorMatching = (channels: DiscordChannelConfig[], pattern: RegExp) => getDiscordChannelConfigErrors(channels).some((e) => pattern.test(e)); +const hasWarningMatching = (channels: DiscordChannelConfig[], pattern: RegExp) => + getDiscordChannelConfigWarnings(channels).some((w) => pattern.test(w)); +const isClean = (channels: DiscordChannelConfig[]) => + getDiscordChannelConfigErrors(channels).length === 0 && + getDiscordChannelConfigWarnings(channels).length === 0; test('DISCORD_CHANNELS passes validation', () => getDiscordChannelConfigErrors(DISCORD_CHANNELS).length === 0); @@ -320,11 +328,18 @@ test('DISCORD_CHANNELS resource names are unique', () => { return names.length === new Set(names).size; }); test('Channel validation accepts adopted text, new forum and adopted media entries', () => - getDiscordChannelConfigErrors([ + isClean([ { id: SNOWFLAKE, name: 'general', type: 'text', rateLimitPerUser: 5 }, FORUM_CONFIG, { id: OTHER_SNOWFLAKE, name: 'showcase', type: 'media', requireTag: false }, - ]).length === 0); + ])); +test('Channel name is optional when adopting by id and required when creating', () => + isClean([{ id: SNOWFLAKE, type: 'text', rateLimitPerUser: 5 }]) && + discordChannelResourceName({ id: SNOWFLAKE, type: 'text' }) === `discord-channel-${SNOWFLAKE}` && + hasErrorMatching( + [{ type: 'text' } as unknown as DiscordChannelConfig], + /"" has no name; a channel created without an id needs one/ + )); test('Channel validation rejects malformed snowflakes', () => hasErrorMatching( [{ id: '12345', name: 'a', type: 'text' }], @@ -359,9 +374,59 @@ test('Channel validation rejects duplicate ids and duplicate names without id', { id: SNOWFLAKE, name: 'a', type: 'text' }, { name: 'a', type: 'forum' }, ]).length === 0); -test('Channel validation rejects invalid names', () => - hasErrorMatching([{ name: 'SDK Help', type: 'text' }], /invalid name "SDK Help"/) && - hasErrorMatching([{ name: '', type: 'text' }], /invalid name ""/)); +test('Channel validation accepts any 1-100 character name, unicode and emoji included', () => + isClean([ + { name: 'sdkβ”‚help', type: 'text' }, + { name: 'πŸ›-bugs', type: 'forum', availableTags: [{ name: 'open' }] }, + { name: 'γƒ˜γƒ«γƒ—', type: 'text' }, + // 100 emoji are 100 characters to Discord even though they are 200 UTF-16 code units + { name: '🟦'.repeat(100), type: 'text' }, + ])); +test('Channel validation rejects empty and over-long names', () => + hasErrorMatching([{ name: '', type: 'text' }], /invalid name ""; Discord allows 1-100/) && + hasErrorMatching([{ name: ' ', type: 'text' }], /invalid name " "/) && + hasErrorMatching([{ name: 'a'.repeat(101), type: 'text' }], /invalid name "a{101}"/) && + !hasErrorMatching([{ name: 'a'.repeat(100), type: 'text' }], /invalid name/)); +test('Channel validation warns, without an error, on names Discord would normalize', () => + hasWarningMatching( + [{ name: 'SDK Help', type: 'text' }], + /"SDK Help" with uppercase letters or spaces/ + ) && + !hasErrorMatching([{ name: 'SDK Help', type: 'text' }], /name/) && + hasWarningMatching([{ id: SNOWFLAKE, name: 'General', type: 'text' }], /"General"/) && + hasWarningMatching([{ name: 'sdk help', type: 'text' }], /"sdk help"/) && + getDiscordChannelConfigWarnings([{ name: 'sdk-help_2', type: 'text' }]).length === 0); +test('Channel validation enforces topic length per channel type', () => + hasErrorMatching( + [{ name: 'a', type: 'text', topic: 'x'.repeat(1025) }], + /topic of 1025 characters; Discord allows at most 1024 on a text channel/ + ) && + isClean([{ name: 'a', type: 'text', topic: 'x'.repeat(1024) }]) && + isClean([{ name: 'a', type: 'forum', topic: 'x'.repeat(4096) }]) && + hasErrorMatching( + [{ name: 'a', type: 'media', topic: 'x'.repeat(4097) }], + /topic of 4097 characters; Discord allows at most 4096 on a media channel/ + )); +test('requireTag needs tags: error on a new channel or empty tags, warning on an adopted one', () => + hasErrorMatching( + [{ name: 'a', type: 'forum', requireTag: true }], + /sets requireTag but declares no availableTags; a new channel has no tags/ + ) && + hasErrorMatching( + [{ name: 'a', type: 'forum', requireTag: true, availableTags: [] }], + /sets requireTag but availableTags is empty/ + ) && + hasErrorMatching( + [{ id: SNOWFLAKE, type: 'media', requireTag: true, availableTags: [] }], + /sets requireTag but availableTags is empty/ + ) && + !hasErrorMatching([{ id: SNOWFLAKE, type: 'forum', requireTag: true }], /requireTag/) && + hasWarningMatching( + [{ id: SNOWFLAKE, type: 'forum', requireTag: true }], + /sets requireTag without availableTags, so it relies on the tags already on the channel/ + ) && + isClean([{ id: SNOWFLAKE, type: 'forum', requireTag: false }]) && + isClean([{ name: 'a', type: 'forum', requireTag: true, availableTags: [{ name: 't' }] }])); test('Channel validation rejects more than 20 tags and duplicate tag names', () => hasErrorMatching( [ @@ -446,6 +511,16 @@ test('Payload omits settings the config does not declare', () => { const payload = discordChannelPayloadFromConfig({ name: 'general', type: 'text' }); return Object.keys(payload).length === 1 && payload.name === 'general'; }); +test('Payload leaves out name when an adopted channel does not declare one', () => { + const payload = discordChannelPayloadFromConfig({ id: SNOWFLAKE, type: 'text', topic: 'Hi' }); + return Object.keys(payload).join(',') === 'topic'; +}); +test('position is sent on create only, never in the modify payload', () => { + const config: DiscordNewChannelConfig = { name: 'a', type: 'text', position: 4, nsfw: true }; + const modify = discordChannelPayloadFromConfig(config); + const create = discordChannelCreatePayloadFromConfig(config); + return !('position' in modify) && modify.nsfw === true && create.position === 4; +}); test('Forum payload maps tags, emoji, sort order, layout and REQUIRE_TAG', () => { const payload = discordChannelPayloadFromConfig(FORUM_CONFIG); const tags = payload.available_tags as { id?: string; name: string; moderated: boolean }[]; @@ -548,6 +623,40 @@ test('Omitted settings never count as drift', () => { id: SNOWFLAKE, name: 'general', type: 'text' }, discordChannelStateFromApi(TEXT_API) ).length === 0); +test('Name counts as drift only when declared', () => { + const state = discordChannelStateFromApi(TEXT_API); + return ( + getDiscordChannelDrift({ id: SNOWFLAKE, type: 'text', rateLimitPerUser: 0 }, state).length === + 0 && + getDiscordChannelDrift({ id: SNOWFLAKE, name: 'genral', type: 'text' }, state).join(',') === + 'name' + ); +}); +test('position never counts as drift', () => + getDiscordChannelDrift({ ...FORUM_CONFIG, position: 99 }, discordChannelStateFromApi(FORUM_API)) + .length === 0); +test('discordChannelRequireTagError fires only when REQUIRE_TAG would have no tags', () => { + const withTags = discordChannelStateFromApi(FORUM_API); + const noTags = discordChannelStateFromApi({ ...FORUM_API, available_tags: [] }); + return ( + discordChannelRequireTagError({ id: SNOWFLAKE, type: 'forum', requireTag: true }, withTags) === + undefined && + /no tags on Discord and channels.ts declares none/.test( + discordChannelRequireTagError({ id: SNOWFLAKE, type: 'forum', requireTag: true }, noTags) ?? + '' + ) && + /availableTags is empty/.test( + discordChannelRequireTagError( + { id: SNOWFLAKE, type: 'forum', requireTag: true, availableTags: [] }, + withTags + ) ?? '' + ) && + discordChannelRequireTagError({ ...FORUM_CONFIG }, noTags) === undefined && + discordChannelRequireTagError({ id: SNOWFLAKE, type: 'forum', requireTag: false }, noTags) === + undefined && + discordChannelRequireTagError({ id: SNOWFLAKE, type: 'text' }, noTags) === undefined + ); +}); test('Drift reports a type mismatch between config and live channel', () => getDiscordChannelDrift( { id: SNOWFLAKE, name: 'general', type: 'forum' }, diff --git a/scripts/validate-config.ts b/scripts/validate-config.ts index a58381f9..c818dacb 100644 --- a/scripts/validate-config.ts +++ b/scripts/validate-config.ts @@ -15,7 +15,7 @@ import { } from '../src/config/packageAccess'; import { MEMBERS } from '../src/config/users'; import { ACCESS_POLICIES } from '../src/config/accessPolicies'; -import { DISCORD_CHANNELS, getDiscordChannelConfigErrors } from '../src/config/channels'; +import { DISCORD_CHANNELS, validateDiscordChannels } from '../src/config/channels'; import { hasProvisionUserRole, resolveGoogleMemberEmail } from '../src/config/utils'; import type { RoleId } from '../src/config/roleIds'; @@ -400,10 +400,18 @@ console.log('Validating Cloudflare Access policies in accessPolicies.ts...'); // Validate Discord channel entries in channels.ts // Snowflake formats, duplicate entries, settings that Discord rejects for the channel // type, and Discord's limits β€” all of which would otherwise only fail inside the deploy. +// Warnings (names Discord would normalize, requireTag relying on live tags) are printed +// but do not fail the check. console.log('Validating Discord channel entries in channels.ts...'); -for (const error of getDiscordChannelConfigErrors(DISCORD_CHANNELS)) { - console.error(`ERROR: ${error}`); - hasErrors = true; +{ + const { errors, warnings } = validateDiscordChannels(DISCORD_CHANNELS); + for (const warning of warnings) { + console.warn(`WARNING: ${warning}`); + } + for (const error of errors) { + console.error(`ERROR: ${error}`); + hasErrors = true; + } } // Validate parent role references in roles.ts diff --git a/src/config/channels.ts b/src/config/channels.ts index c2327c95..158a8649 100644 --- a/src/config/channels.ts +++ b/src/config/channels.ts @@ -9,10 +9,12 @@ // // Each entry works in one of two modes: // - `id` set: the existing channel with that snowflake is adopted and the settings -// declared here are enforced on it in place. Settings you omit are left exactly as -// they are on Discord. The declared `type` must match the live channel type; a -// mismatch fails the deploy instead of silently managing the wrong thing. -// - `id` unset: a new channel of `type` is created in the guild on the next deploy. +// declared here are enforced on it in place. Settings you omit (including `name`) +// are left exactly as they are on Discord. The declared `type` must match the live +// channel type; a mismatch fails the deploy instead of silently managing the wrong +// thing. +// - `id` unset: a new channel named `name` of `type` is created in the guild on the +// next deploy. // // Removing an entry NEVER deletes the channel on Discord (that would destroy its // history); the resource is only dropped from Pulumi state. Changing `id` or `type` @@ -48,22 +50,24 @@ export interface DiscordForumTagConfig extends DiscordEmojiConfig { moderated?: boolean; } -export interface DiscordChannelConfig { - /** - * Snowflake of an existing channel to adopt. When set, the settings below are - * applied to that channel in place; when unset, a new channel is created. - */ - id?: string; - /** Channel name: lowercase letters, digits, '-' and '_' (Discord normalizes anything else) */ - name: string; +/** The settings an entry can manage, shared by adopted and newly created channels. */ +interface DiscordChannelSettings { /** 'text' is a normal channel; 'forum' and 'media' are thread-only */ type: DiscordChannelType; /** Category (parent channel) snowflake */ parentId?: string; - /** Channel topic; for forum/media channels this is the post guidelines text */ + /** + * Channel topic (max 1024 characters); for forum/media channels this is the post + * guidelines text (max 4096 characters) + */ topic?: string; nsfw?: boolean; - /** Sort position within the category */ + /** + * Sort position within the category, applied on creation only. Discord renumbers + * positions whenever neighbouring channels move, so a declared position would show + * drift on every deploy; it is neither checked nor re-applied afterwards. Reorder + * channels in Discord. + */ position?: number; /** Slowmode in seconds (0-21600) */ rateLimitPerUser?: number; @@ -73,7 +77,11 @@ export interface DiscordChannelConfig { defaultThreadRateLimitPerUser?: number; // --- forum/media only --- - /** Require every new thread to carry at least one tag */ + /** + * Require every new thread to carry at least one tag. Needs at least one tag, either + * in `availableTags` or (for an adopted channel that omits `availableTags`) already on + * the channel; otherwise the deploy fails, because Discord would reject every post. + */ requireTag?: boolean; /** Tags threads can be filed under (max 20). `[]` removes all tags; omit to leave them alone. */ availableTags?: readonly DiscordForumTagConfig[]; @@ -85,14 +93,39 @@ export interface DiscordChannelConfig { defaultForumLayout?: DiscordForumLayout; } +/** + * An existing channel adopted by its snowflake. Only the declared settings are enforced, + * so `name` is optional: omit it to leave the channel's name alone. + */ +export interface DiscordAdoptedChannelConfig extends DiscordChannelSettings { + id: string; + /** + * Channel name, 1-100 characters. Discord lowercases names of these channel types and + * turns spaces into '-', so declare the normalized form (validation warns otherwise). + */ + name?: string; +} + +/** A channel created on the next deploy, so `name` is required. */ +export interface DiscordNewChannelConfig extends DiscordChannelSettings { + id?: undefined; + /** + * Channel name, 1-100 characters. Discord lowercases names of these channel types and + * turns spaces into '-', so declare the normalized form (validation warns otherwise). + */ + name: string; +} + +export type DiscordChannelConfig = DiscordAdoptedChannelConfig | DiscordNewChannelConfig; + /** * Channels managed by Pulumi. Empty until maintainers add entries, so deploys are a * no-op. Examples: * - * // Adopt an existing text channel by ID and pin its slowmode and thread defaults + * // Adopt an existing text channel by ID and pin its slowmode and thread defaults; + * // `name` is omitted, so the channel keeps whatever it is called on Discord * { * id: '1234567890123456789', - * name: 'general', * type: 'text', * rateLimitPerUser: 5, * defaultAutoArchiveDuration: 4320, @@ -117,7 +150,8 @@ export interface DiscordChannelConfig { * defaultAutoArchiveDuration: 10080, * }, * - * // Adopt an existing media (thread-only, image/video first) channel by ID + * // Adopt an existing media (thread-only, image/video first) channel by ID; the + * // declared `name` is enforced, so the channel is renamed if it differs * { * id: '1234567890123456789', * name: 'showcase', @@ -143,7 +177,16 @@ export function isThreadOnlyChannelType(type: DiscordChannelType): boolean { // --------------------------------------------------------------------------- const SNOWFLAKE_PATTERN = /^\d{17,20}$/; -const CHANNEL_NAME_PATTERN = /^[a-z0-9_-]{1,100}$/; +/** Discord allows any 1-100 characters (unicode letters, emoji, separators like 'β”‚' included) */ +const MAX_CHANNEL_NAME_LENGTH = 100; +/** Discord lowercases these and replaces spaces with '-', so the live name never matches */ +const NORMALIZED_AWAY_PATTERN = /\p{Lu}|\s/u; +/** Topic limits per channel type: 1024 for text, 4096 (post guidelines) for forum/media */ +const MAX_TOPIC_LENGTH: Record = { + text: 1024, + forum: 4096, + media: 4096, +}; /** Discord's maximum slowmode, in seconds (6 hours) */ const MAX_RATE_LIMIT_PER_USER = 21600; const MAX_AVAILABLE_TAGS = 20; @@ -153,6 +196,11 @@ export function isDiscordSnowflake(value: string): boolean { return SNOWFLAKE_PATTERN.test(value); } +/** Length in code points, which is how Discord counts (an emoji or 'β”‚' is one character) */ +function codePointLength(value: string): number { + return Array.from(value).length; +} + function emojiConfigErrors( label: string, emoji: DiscordEmojiConfig, @@ -176,26 +224,54 @@ function emojiConfigErrors( return errors; } +export interface DiscordChannelValidation { + /** Problems that fail validation; one human-readable message each */ + errors: string[]; + /** Entries that deploy but behave surprisingly (perpetual drift, reliance on live state) */ + warnings: string[]; +} + /** * Validate the channel entries. Returns one human-readable message per problem - * (empty when everything is fine) so the validate script can print them and the - * tests can assert on them. Catches what would otherwise only fail inside the - * deploy: malformed snowflakes, duplicate resource names, settings Discord rejects - * for the channel type, and limits Discord enforces (tags, slowmode, durations). + * (both lists empty when everything is fine) so the validate script can print them + * and the tests can assert on them. Errors catch what would otherwise only fail inside + * the deploy: missing names on new channels, malformed snowflakes, duplicate resource + * names, settings Discord rejects for the channel type, and limits Discord enforces + * (name and topic length, tags, slowmode, durations). Warnings flag names Discord would + * normalize (perpetual drift) and `requireTag` that relies on tags already on Discord. */ -export function getDiscordChannelConfigErrors(channels: readonly DiscordChannelConfig[]): string[] { +export function validateDiscordChannels( + channels: readonly DiscordChannelConfig[] +): DiscordChannelValidation { const errors: string[] = []; + const warnings: string[] = []; const seenIds = new Set(); const seenNamesWithoutId = new Set(); for (const channel of channels) { - const label = `Discord channel "${channel.id ?? channel.name}"`; + const label = `Discord channel "${channel.id ?? channel.name ?? ''}"`; const threadOnly = isThreadOnlyChannelType(channel.type); + // Typed as required without id, but the runtime check keeps a cast or a stale + // entry from reaching the deploy with no name to create the channel under + const name: string | undefined = channel.name; - if (!CHANNEL_NAME_PATTERN.test(channel.name)) { - errors.push( - `${label} has an invalid name "${channel.name}"; use 1-100 lowercase letters, digits, '-' and '_'` - ); + if (name === undefined) { + if (channel.id === undefined) { + errors.push(`${label} has no name; a channel created without an id needs one`); + } + } else { + const length = codePointLength(name.trim()); + if (length < 1 || length > MAX_CHANNEL_NAME_LENGTH) { + errors.push( + `${label} has an invalid name "${name}"; Discord allows 1-${MAX_CHANNEL_NAME_LENGTH} characters` + ); + } else if (NORMALIZED_AWAY_PATTERN.test(name)) { + warnings.push( + `${label} has name "${name}" with uppercase letters or spaces; Discord stores it ` + + `lowercased with '-' for spaces, so the declared name never matches the live one ` + + `and shows as drift on every deploy. Declare the normalized name instead.` + ); + } } if (channel.id !== undefined) { @@ -218,6 +294,16 @@ export function getDiscordChannelConfigErrors(channels: readonly DiscordChannelC errors.push(`${label} has parentId "${channel.parentId}" which is not a Discord snowflake`); } + if (channel.topic !== undefined) { + const length = codePointLength(channel.topic); + const max = MAX_TOPIC_LENGTH[channel.type]; + if (length > max) { + errors.push( + `${label} has a topic of ${length} characters; Discord allows at most ${max} on a ${channel.type} channel` + ); + } + } + if ( channel.position !== undefined && (!Number.isInteger(channel.position) || channel.position < 0) @@ -272,6 +358,26 @@ export function getDiscordChannelConfigErrors(channels: readonly DiscordChannelC ); } + if (threadOnly && channel.requireTag === true) { + // REQUIRE_TAG with no tags to pick from makes Discord reject every post + if (channel.availableTags !== undefined) { + if (channel.availableTags.length === 0) { + errors.push( + `${label} sets requireTag but availableTags is empty; Discord would reject every post` + ); + } + } else if (channel.id === undefined) { + errors.push( + `${label} sets requireTag but declares no availableTags; a new channel has no tags, so Discord would reject every post` + ); + } else { + warnings.push( + `${label} sets requireTag without availableTags, so it relies on the tags already on ` + + `the channel; the deploy fails if the live channel has none` + ); + } + } + if (channel.availableTags !== undefined) { if (channel.availableTags.length > MAX_AVAILABLE_TAGS) { errors.push( @@ -299,7 +405,19 @@ export function getDiscordChannelConfigErrors(channels: readonly DiscordChannelC } } - return errors; + return { errors, warnings }; +} + +/** The errors from validateDiscordChannels (fail the check). */ +export function getDiscordChannelConfigErrors(channels: readonly DiscordChannelConfig[]): string[] { + return validateDiscordChannels(channels).errors; +} + +/** The warnings from validateDiscordChannels (printed, never fail the check). */ +export function getDiscordChannelConfigWarnings( + channels: readonly DiscordChannelConfig[] +): string[] { + return validateDiscordChannels(channels).warnings; } // --------------------------------------------------------------------------- @@ -396,6 +514,7 @@ export interface DiscordChannelState { parentId: string | null; topic: string; nsfw: boolean; + /** Informational only: position is applied on creation and never compared for drift */ position: number | null; rateLimitPerUser: number; defaultAutoArchiveDuration: number | null; @@ -452,22 +571,23 @@ export function discordChannelStateFromApi( /** * Build the body for PATCH /channels/{id} (Modify Channel) from the declared config. - * Only declared settings are sent, and only those valid for the channel type: forum/media - * settings never reach a text channel and defaultForumLayout only reaches a forum. - * `current` is the live state: REQUIRE_TAG is merged into its other flag bits and - * existing tags keep their Discord ID when their name is still declared, so an update - * does not delete and recreate them. + * Only declared settings are sent (`name` included: an adopted channel without one keeps + * its name), and only those valid for the channel type: forum/media settings never reach + * a text channel and defaultForumLayout only reaches a forum. `position` is create-only + * and never sent here. `current` is the live state: REQUIRE_TAG is merged into its other + * flag bits and existing tags keep their Discord ID when their name is still declared, + * so an update does not delete and recreate them. */ export function discordChannelPayloadFromConfig( config: DiscordChannelConfig, current?: DiscordChannelState ): Record { - const payload: Record = { name: config.name }; + const payload: Record = {}; + if (config.name !== undefined) payload.name = config.name; if (config.parentId !== undefined) payload.parent_id = config.parentId; if (config.topic !== undefined) payload.topic = config.topic; if (config.nsfw !== undefined) payload.nsfw = config.nsfw; - if (config.position !== undefined) payload.position = config.position; if (config.rateLimitPerUser !== undefined) payload.rate_limit_per_user = config.rateLimitPerUser; if (config.defaultAutoArchiveDuration !== undefined) { payload.default_auto_archive_duration = config.defaultAutoArchiveDuration; @@ -508,14 +628,36 @@ export function discordChannelPayloadFromConfig( /** * Build the body for POST /guilds/{guild}/channels (Create Guild Channel). Same as the - * modify payload plus `type`, minus `flags`, which that endpoint does not accept; - * create() applies requireTag with a follow-up PATCH. + * modify payload plus `type` and the create-only `position`, minus `flags`, which that + * endpoint does not accept; create() applies requireTag with a follow-up PATCH. */ export function discordChannelCreatePayloadFromConfig( - config: DiscordChannelConfig + config: DiscordNewChannelConfig ): Record { const { flags: _flags, ...payload } = discordChannelPayloadFromConfig(config); - return { type: DISCORD_CHANNEL_TYPE_IDS[config.type], ...payload }; + return { + type: DISCORD_CHANNEL_TYPE_IDS[config.type], + ...payload, + ...(config.position !== undefined ? { position: config.position } : {}), + }; +} + +/** + * Why REQUIRE_TAG cannot be applied, or undefined when it can. The tags threads would + * have to pick from are the declared ones or, when `availableTags` is omitted, those + * already on the channel; if that set is empty Discord rejects every post, so the + * deploy must fail with a clear message instead. Checked before every PATCH. + */ +export function discordChannelRequireTagError( + config: DiscordChannelConfig, + current: DiscordChannelState +): string | undefined { + if (config.requireTag !== true || !isThreadOnlyChannelType(config.type)) return undefined; + const tags = config.availableTags ?? current.availableTags; + if (tags.length > 0) return undefined; + return config.availableTags === undefined + ? 'requireTag is set but the channel has no tags on Discord and channels.ts declares none; add availableTags' + : 'requireTag is set but availableTags is empty; declare at least one tag'; } function sortTagsByName(tags: readonly T[]): T[] { @@ -524,7 +666,8 @@ function sortTagsByName(tags: readonly T[]): T[] { /** * Names of the declared settings whose live value differs from the config. Settings the - * config omits are not managed and never count as drift. Empty means in sync. + * config omits (`name` included) are not managed and never count as drift, and `position` + * is create-only, so it is never compared. Empty means in sync. */ export function getDiscordChannelDrift( config: DiscordChannelConfig, @@ -542,7 +685,6 @@ export function getDiscordChannelDrift( check('parentId', config.parentId, state.parentId); check('topic', config.topic, state.topic); check('nsfw', config.nsfw, state.nsfw); - check('position', config.position, state.position); check('rateLimitPerUser', config.rateLimitPerUser, state.rateLimitPerUser); check( 'defaultAutoArchiveDuration', diff --git a/src/discord.ts b/src/discord.ts index c0e73b84..8eccc9c5 100644 --- a/src/discord.ts +++ b/src/discord.ts @@ -7,6 +7,7 @@ import { DISCORD_CHANNEL_TYPE_IDS, discordChannelCreatePayloadFromConfig, discordChannelPayloadFromConfig, + discordChannelRequireTagError, discordChannelResourceName, discordChannelStateFromApi, discordChannelTypeName, @@ -499,11 +500,25 @@ interface DiscordChannelOutputs extends DiscordChannelInputs { /** Discord error code for "Unknown Channel" */ const DISCORD_UNKNOWN_CHANNEL_CODE = 10003; +// Sent on every channel create/modify so the guild's audit log shows where the change +// came from. Discord URL-decodes this header, hence the encoding. Passed per call rather +// than inside discordFetch so the role/member providers' serialized code is unchanged. +const DISCORD_CHANNEL_AUDIT_LOG_HEADERS = { + 'X-Audit-Log-Reason': encodeURIComponent('modelcontextprotocol/access deploy'), +}; + function describeDiscordChannelType(typeId: number): string { const name = discordChannelTypeName(typeId); return name ? `type ${typeId} (${name})` : `type ${typeId}`; } +/** "Discord channel ("")", or just the id when the entry declares no name */ +function discordChannelLabel(id: string, config: DiscordChannelConfig): string { + return config.name !== undefined + ? `Discord channel ${id} ("${config.name}")` + : `Discord channel ${id}`; +} + /** * Check that a live channel is the one the config means: same guild, same type. * Discord can only convert text <-> announcement channels, so a type mismatch cannot @@ -514,7 +529,7 @@ function assertDiscordChannelMatches( config: DiscordChannelConfig, live: DiscordChannelApiResponse ): void { - const label = `Discord channel ${live.id} ("${config.name}")`; + const label = discordChannelLabel(live.id, config); if (live.guild_id !== guildId) { throw new Error( `${label} belongs to guild ${live.guild_id ?? 'unknown'}, not the configured guild ${guildId}` @@ -546,8 +561,15 @@ async function reconcileDiscordChannel( const drift = getDiscordChannelDrift(config, current); if (drift.length === 0) return current; + // Turning on REQUIRE_TAG with no tags to pick from would make Discord reject every post + const requireTagError = discordChannelRequireTagError(config, current); + if (requireTagError !== undefined) { + throw new Error(`${discordChannelLabel(live.id, config)}: ${requireTagError}`); + } + const updated = await discordFetch(token, `/channels/${live.id}`, { method: 'PATCH', + headers: DISCORD_CHANNEL_AUDIT_LOG_HEADERS, body: JSON.stringify(discordChannelPayloadFromConfig(config, current)), }); return discordChannelStateFromApi(updated); @@ -610,6 +632,7 @@ const discordChannelProvider: pulumi.dynamic.ResourceProvider = { // Create Guild Channel does not take `flags`; reconcile below applies requireTag live = await discordFetch(token, `/guilds/${guildId}/channels`, { method: 'POST', + headers: DISCORD_CHANNEL_AUDIT_LOG_HEADERS, body: JSON.stringify(discordChannelCreatePayloadFromConfig(channel)), }); } @@ -641,7 +664,7 @@ const discordChannelProvider: pulumi.dynamic.ResourceProvider = { // Deleted outside Pulumi: a blank id drops the resource from state on refresh. // The next deploy recreates a channel declared without id, and fails with a // clear error for one declared by id. - console.warn(`Discord channel ${id} ("${props.channel.name}") no longer exists`); + console.warn(`${discordChannelLabel(id, props.channel)} no longer exists`); return { id: '' }; } throw new Error(`Failed to read channel ${id}: ${error}`); @@ -681,8 +704,8 @@ const discordChannelProvider: pulumi.dynamic.ResourceProvider = { // Never delete a Discord channel: that would destroy its message history. // Removing an entry from channels.ts only stops managing the channel. console.warn( - `Discord channel ${id} ("${props.channel.name}") was removed from config; it is left ` + - `in place on Discord and only dropped from Pulumi state. Delete it by hand if intended.` + `${discordChannelLabel(id, props.channel)} was removed from config; it is left in place ` + + `on Discord and only dropped from Pulumi state. Delete it by hand if intended.` ); }, };