access/src/discord.ts
David Soria Parra c193ff654b
Self-heal Discord role sync on refresh
When a member wasn't in the guild at create() time (or drifted afterwards),
read() only reported the drift. Pulumi would not trigger update() because
the inputs hadn't changed, so the member stayed permanently out of sync.

Call syncMemberRoles() directly from read() when drift is detected so roles
are applied on the next refresh without needing an input change.
2026-04-21 14:44:56 +01:00

551 lines
15 KiB
TypeScript

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;
}
interface DiscordRateLimitResponse {
message: string;
retry_after: number;
global: boolean;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// Cloudflare 5xx and edge-level 429s return plain-text bodies ("upstream connect
// error...", "error code: 1015") that crash a naive response.json().
function tryParseJson<T>(text: string): T | undefined {
try {
return JSON.parse(text) as T;
} catch {
return undefined;
}
}
async function discordFetch<T>(
token: string,
endpoint: string,
options: RequestInit = {},
maxRetries = 10
): Promise<T> {
let lastError: Error | undefined;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const response = await fetch(`${DISCORD_API_BASE}${endpoint}`, {
...options,
headers: {
Authorization: `Bot ${token}`,
'Content-Type': 'application/json',
...options.headers,
},
});
if (response.status === 429) {
const text = await response.text();
const body = tryParseJson<DiscordRateLimitResponse>(text);
const retryAfterSec = body?.retry_after ?? 1;
// Linearly increasing jitter de-syncs the thundering herd when many
// resources refresh in parallel and all receive the same retry_after.
const jitterMs = Math.random() * 1000 * (attempt + 1);
const retryAfterMs = Math.ceil(retryAfterSec * 1000) + jitterMs;
lastError = new Error(
`Discord API rate limited on ${endpoint} (retry_after=${retryAfterSec}s, global=${body?.global ?? false})`
);
if (attempt < maxRetries) {
await sleep(retryAfterMs);
continue;
}
throw lastError;
}
if (response.status >= 500 && response.status < 600) {
const text = await response.text();
lastError = new Error(`Discord API ${response.status} on ${endpoint}: ${text.slice(0, 200)}`);
if (attempt < maxRetries) {
await sleep(2 ** attempt * 500 + Math.random() * 1000);
continue;
}
throw lastError;
}
if (!response.ok) {
const text = await response.text();
const error = tryParseJson<DiscordApiError>(text);
throw new Error(
error
? `Discord API error: ${error.message} (code: ${error.code})`
: `Discord API ${response.status} on ${endpoint}: ${text.slice(0, 200)}`
);
}
// Handle 204 No Content
if (response.status === 204) {
return undefined as T;
}
return response.json() as Promise<T>;
}
throw (
lastError ?? new Error(`Discord API request to ${endpoint} failed after ${maxRetries} retries`)
);
}
// 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<pulumi.dynamic.CreateResult<DiscordRoleOutputs>> {
const role = await discordFetch<DiscordRoleApiResponse>(
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<pulumi.dynamic.ReadResult<DiscordRoleOutputs>> {
try {
const roles = await discordFetch<DiscordRoleApiResponse[]>(
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 (error) {
throw new Error(`Failed to read role ${id}: ${error}`);
}
},
async update(
id: string,
_olds: DiscordRoleOutputs,
news: DiscordRoleInputs
): Promise<pulumi.dynamic.UpdateResult<DiscordRoleOutputs>> {
await discordFetch<DiscordRoleApiResponse>(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<void> {
try {
await discordFetch<void>(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<string>;
public readonly roleName!: pulumi.Output<string>;
public readonly guildId!: pulumi.Output<string>;
constructor(
name: string,
args: {
guildId: pulumi.Input<string>;
roleName: pulumi.Input<string>;
token: pulumi.Input<string>;
},
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<DiscordGuildMemberApiResponse>(
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<void>(
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<void>(
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<pulumi.dynamic.CreateResult<DiscordMemberRoleSyncOutputs>> {
const { addedRoles, removedRoles, memberNotFound } = await syncMemberRoles(inputs);
return {
id: inputs.userId,
outs: {
...inputs,
addedRoles,
removedRoles,
memberNotFound,
},
};
},
async read(
id: string,
props: DiscordMemberRoleSyncOutputs
): Promise<pulumi.dynamic.ReadResult<DiscordMemberRoleSyncOutputs>> {
let member: DiscordGuildMemberApiResponse;
try {
member = await discordFetch<DiscordGuildMemberApiResponse>(
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}: ${error}`);
}
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) {
// Self-heal: apply the expected roles now. Without this, refresh would
// only observe drift, and since inputs are unchanged Pulumi would never
// trigger update() — leaving members who joined after create() stuck.
const { addedRoles, removedRoles, memberNotFound } = await syncMemberRoles(props);
return {
id,
props: {
...props,
addedRoles,
removedRoles,
memberNotFound,
},
};
}
return { id, props: { ...props, memberNotFound: false } };
},
async update(
id: string,
_olds: DiscordMemberRoleSyncOutputs,
news: DiscordMemberRoleSyncInputs
): Promise<pulumi.dynamic.UpdateResult<DiscordMemberRoleSyncOutputs>> {
const { addedRoles, removedRoles, memberNotFound } = await syncMemberRoles(news);
return {
outs: {
...news,
addedRoles,
removedRoles,
memberNotFound,
},
};
},
async delete(id: string, props: DiscordMemberRoleSyncOutputs): Promise<void> {
// When a user is removed from config, remove all their managed roles
for (const roleId of props.expectedRoleIds) {
try {
await discordFetch<void>(
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<string[]>;
public readonly removedRoles!: pulumi.Output<string[]>;
public readonly memberNotFound!: pulumi.Output<boolean>;
constructor(
name: string,
args: {
guildId: pulumi.Input<string>;
userId: pulumi.Input<string>;
expectedRoleIds: pulumi.Input<pulumi.Input<string>[]>;
managedRoleIds: pulumi.Input<pulumi.Input<string>[]>;
token: pulumi.Input<string>;
},
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<string, DiscordRole> = {};
/**
* 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<RoleId> {
const expanded = new Set<RoleId>();
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<string> => 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 };