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'; const config = new pulumi.Config('discord'); // Discord integration is optional - only enabled if botToken and guildId are configured const DISCORD_BOT_TOKEN = config.getSecret('botToken'); const DISCORD_GUILD_ID = config.get('guildId'); const DISCORD_ENABLED = DISCORD_BOT_TOKEN !== undefined && DISCORD_GUILD_ID !== undefined; if (!DISCORD_ENABLED) { pulumi.log.info('Discord integration disabled: botToken or guildId not configured'); } const DISCORD_API_BASE = 'https://discord.com/api/v10'; interface DiscordApiError { code: number; message: string; } async function discordFetch( token: string, endpoint: string, options: RequestInit = {} ): Promise { const response = await fetch(`${DISCORD_API_BASE}${endpoint}`, { ...options, headers: { Authorization: `Bot ${token}`, 'Content-Type': 'application/json', ...options.headers, }, }); if (!response.ok) { const error = (await response.json()) as DiscordApiError; throw new Error(`Discord API error: ${error.message} (code: ${error.code})`); } // Handle 204 No Content if (response.status === 204) { return undefined as T; } return response.json() as Promise; } // Discord API response types interface DiscordRoleApiResponse { id: string; name: string; position: number; permissions: string; managed: boolean; } interface DiscordGuildMemberApiResponse { roles: string[]; } // Discord Role Dynamic Provider interface DiscordRoleInputs { guildId: string; roleName: string; token: string; } interface DiscordRoleOutputs extends DiscordRoleInputs { roleId: string; } const discordRoleProvider: pulumi.dynamic.ResourceProvider = { async create( inputs: DiscordRoleInputs ): Promise> { const role = await discordFetch( inputs.token, `/guilds/${inputs.guildId}/roles`, { method: 'POST', body: JSON.stringify({ name: inputs.roleName, permissions: '0', // No special permissions - roles are for organization only mentionable: false, hoist: false, }), } ); return { id: role.id, outs: { ...inputs, roleId: role.id, }, }; }, async read( id: string, props: DiscordRoleOutputs ): Promise> { try { const roles = await discordFetch( props.token, `/guilds/${props.guildId}/roles` ); const role = roles.find((r) => r.id === id); if (!role) { // Role was deleted externally throw new Error(`Role ${id} not found`); } return { id, props: { ...props, roleName: role.name, roleId: role.id, }, }; } catch { throw new Error(`Failed to read role ${id}`); } }, async update( id: string, _olds: DiscordRoleOutputs, news: DiscordRoleInputs ): Promise> { await discordFetch(news.token, `/guilds/${news.guildId}/roles/${id}`, { method: 'PATCH', body: JSON.stringify({ name: news.roleName, }), }); return { outs: { ...news, roleId: id, }, }; }, async delete(id: string, props: DiscordRoleOutputs): Promise { try { await discordFetch(props.token, `/guilds/${props.guildId}/roles/${id}`, { method: 'DELETE', }); } catch (error) { // Ignore errors if role is already deleted console.warn(`Failed to delete role ${id}: ${error}`); } }, }; class DiscordRole extends pulumi.dynamic.Resource { public readonly roleId!: pulumi.Output; public readonly roleName!: pulumi.Output; public readonly guildId!: pulumi.Output; constructor( name: string, args: { guildId: pulumi.Input; roleName: pulumi.Input; token: pulumi.Input; }, opts?: pulumi.CustomResourceOptions ) { super( discordRoleProvider, name, { roleId: undefined, ...args, }, opts ); } } // Discord Member Role Sync Dynamic Provider // This provider reconciles a user's roles to match exactly what's defined in config // It adds missing roles AND removes extra roles (only for roles we manage) interface DiscordMemberRoleSyncInputs { guildId: string; userId: string; /** Role IDs that this user SHOULD have (managed roles only) */ expectedRoleIds: string[]; /** All role IDs that we manage (to know which ones to potentially remove) */ managedRoleIds: string[]; token: string; } interface DiscordMemberRoleSyncOutputs extends DiscordMemberRoleSyncInputs { /** Roles that were added during last sync */ addedRoles: string[]; /** Roles that were removed during last sync */ removedRoles: string[]; /** True if the member was not found on the Discord server */ memberNotFound: boolean; } async function syncMemberRoles( inputs: DiscordMemberRoleSyncInputs ): Promise<{ addedRoles: string[]; removedRoles: string[]; memberNotFound: boolean }> { // Get the user's current roles let member: DiscordGuildMemberApiResponse; try { member = await discordFetch( inputs.token, `/guilds/${inputs.guildId}/members/${inputs.userId}` ); } catch (error) { // If the member isn't on the server, skip gracefully if (error instanceof Error && error.message.includes('code: 10007')) { console.warn( `Discord member ${inputs.userId} not found on server - skipping role sync. ` + `They may have left the server or the Discord ID may be incorrect.` ); return { addedRoles: [], removedRoles: [], memberNotFound: true }; } throw error; } const currentRoles = new Set(member.roles); const expectedRoles = new Set(inputs.expectedRoleIds); const managedRoles = new Set(inputs.managedRoleIds); const addedRoles: string[] = []; const removedRoles: string[] = []; // Add missing roles for (const roleId of Array.from(expectedRoles)) { if (!currentRoles.has(roleId)) { await discordFetch( inputs.token, `/guilds/${inputs.guildId}/members/${inputs.userId}/roles/${roleId}`, { method: 'PUT' } ); addedRoles.push(roleId); } } // Remove roles that the user has but shouldn't (only managed roles) for (const roleId of Array.from(currentRoles)) { if (managedRoles.has(roleId) && !expectedRoles.has(roleId)) { await discordFetch( inputs.token, `/guilds/${inputs.guildId}/members/${inputs.userId}/roles/${roleId}`, { method: 'DELETE' } ); removedRoles.push(roleId); } } return { addedRoles, removedRoles, memberNotFound: false }; } const discordMemberRoleSyncProvider: pulumi.dynamic.ResourceProvider = { async create( inputs: DiscordMemberRoleSyncInputs ): Promise> { const { addedRoles, removedRoles, memberNotFound } = await syncMemberRoles(inputs); return { id: inputs.userId, outs: { ...inputs, addedRoles, removedRoles, memberNotFound, }, }; }, async read( id: string, props: DiscordMemberRoleSyncOutputs ): Promise> { let member: DiscordGuildMemberApiResponse; try { member = await discordFetch( props.token, `/guilds/${props.guildId}/members/${props.userId}` ); } catch (error) { // If the member isn't on the server, return state indicating they're not found if (error instanceof Error && error.message.includes('code: 10007')) { return { id, props: { ...props, addedRoles: [], removedRoles: [], memberNotFound: true, }, }; } throw new Error(`Failed to read member roles for ${id}`); } const currentRoles = new Set(member.roles); const expectedRoles = new Set(props.expectedRoleIds); const managedRoles = new Set(props.managedRoleIds); // Check if roles are in sync (only considering managed roles) const outOfSync = Array.from(expectedRoles).some((r) => !currentRoles.has(r)) || Array.from(currentRoles).some((r) => managedRoles.has(r) && !expectedRoles.has(r)); if (outOfSync) { // Return current state but note it needs update return { id, props: { ...props, addedRoles: [], removedRoles: [], memberNotFound: false, }, }; } return { id, props: { ...props, memberNotFound: false } }; }, async update( id: string, _olds: DiscordMemberRoleSyncOutputs, news: DiscordMemberRoleSyncInputs ): Promise> { const { addedRoles, removedRoles, memberNotFound } = await syncMemberRoles(news); return { outs: { ...news, addedRoles, removedRoles, memberNotFound, }, }; }, async delete(id: string, props: DiscordMemberRoleSyncOutputs): Promise { // When a user is removed from config, remove all their managed roles for (const roleId of props.expectedRoleIds) { try { await discordFetch( props.token, `/guilds/${props.guildId}/members/${props.userId}/roles/${roleId}`, { method: 'DELETE' } ); } catch (error) { console.warn(`Failed to remove role ${roleId} from user ${id}: ${error}`); } } }, }; class DiscordMemberRoleSync extends pulumi.dynamic.Resource { public readonly addedRoles!: pulumi.Output; public readonly removedRoles!: pulumi.Output; public readonly memberNotFound!: pulumi.Output; constructor( name: string, args: { guildId: pulumi.Input; userId: pulumi.Input; expectedRoleIds: pulumi.Input[]>; managedRoleIds: pulumi.Input[]>; token: pulumi.Input; }, opts?: pulumi.CustomResourceOptions ) { super( discordMemberRoleSyncProvider, name, { addedRoles: undefined, removedRoles: undefined, memberNotFound: undefined, ...args, }, opts ); } } const roleLookup = buildRoleLookup(); // Discord roles keyed by Discord role name const roles: Record = {}; /** * Expand a set of role IDs to include all implied Discord roles. * This traverses: * 1. GitHub parent relationships (e.g., GO_SDK -> SDK_MAINTAINERS) * 2. discordImplies relationships (e.g., SDK_MAINTAINERS -> MAINTAINERS) */ function expandDiscordRoles(roleIds: readonly RoleId[]): Set { const expanded = new Set(); const toProcess = [...roleIds]; while (toProcess.length > 0) { const roleId = toProcess.pop()!; if (expanded.has(roleId)) continue; expanded.add(roleId); const role = roleLookup.get(roleId); if (!role) continue; // Follow GitHub parent relationship if (role.github?.parent) { toProcess.push(role.github.parent); } // Follow discordImplies relationships if (role.discordImplies) { toProcess.push(...role.discordImplies); } } return expanded; } // Only create Discord resources if Discord is enabled if (DISCORD_ENABLED) { // These are guaranteed to be defined when DISCORD_ENABLED is true const guildId = DISCORD_GUILD_ID!; const botToken = DISCORD_BOT_TOKEN!; // Create Discord roles for roles that have Discord config ROLES.forEach((role: Role) => { if (!role.discord) return; roles[role.discord.role] = new DiscordRole(`discord-role-${role.id}`, { guildId, roleName: role.discord.role, token: botToken, }); }); // Collect all managed role IDs (roles that have Discord config) const allManagedRoleIds = ROLES.filter((r) => r.discord).map( (r) => roles[r.discord!.role].roleId ); // Sync roles for each member MEMBERS.forEach((member) => { if (!member.discord) return; // Expand roles to include parents and implied roles const expandedRoleIds = expandDiscordRoles(member.memberOf); // Get the Discord role IDs this member should have const expectedRoleIds = Array.from(expandedRoleIds) .map((roleId: RoleId) => { const role = roleLookup.get(roleId); if (!role?.discord) return null; return roles[role.discord.role].roleId; }) .filter((id): id is pulumi.Output => id !== null); // Create a sync resource for this member new DiscordMemberRoleSync( `discord-member-sync-${member.discord}`, { guildId, userId: member.discord!, expectedRoleIds, managedRoleIds: allManagedRoleIds, token: botToken, }, { dependsOn: Object.values(roles) } ); }); } export { roles as discordRoles };