diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d493cdf3..b105ef453 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -108,6 +108,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Guild automation API routes now map known precondition failures to actionable 4xx responses instead of opaque 500s (`manifest missing`, `capture required`, `apply lock active`) (PR #171) +- Guild automation backend apply/reconcile endpoints now execute real Discord + and DB mutations through a shared execution pipeline (capture -> plan -> + protected-op gate -> execute -> persisted final status) +- `/api/guilds/:guildId/automation/apply` and `/reconcile` now return explicit + infrastructure failures when the distributed lock backend is unavailable + (fail-closed contract) - Guild automation diff now marks permission-tightening updates as protected operations so `allowProtected` gating applies to destructive updates (PR #171) - Guild cutover role cleanup now only mutates bots explicitly flagged @@ -135,6 +141,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 require `view` and mutating requests require `manage` - Bot Jest config now maps relative `.js` imports to source modules during test execution, matching the ESM build import style +- Guild automation reconcile now uses ID-first matching with deterministic + fallback for roles/channels and persists remapped manifest IDs for future + convergent plans +- Shared guild automation lock flow now uses Redis token-based distributed locks + (`SET NX PX` + safe token release) instead of in-memory instance-local locks ## [2.6.11] - 2026-03-12 diff --git a/README.md b/README.md index 348495478..a3a187056 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,12 @@ packages/ - Reaction roles, role management - Centralized guild automation (`/guildconfig`) with manifest capture, drift plans, reconcile/apply flows, and cutover checklist tracking +- Guild automation API apply/reconcile now execute real mutation runs + (`capture -> plan -> apply`) with persisted run outcomes (`completed`, + `blocked`, `failed`) +- Guild automation reconciliation uses ID-first mapping with deterministic + fallback (role/channel keys) and persists remapped manifest IDs to prevent + repeated create/delete drift loops - Twitch stream notifications (EventSub WebSocket) - Last.fm scrobbling integration @@ -106,6 +112,8 @@ packages/ - Request logging middleware - Auth readiness health contract at `GET /api/health/auth-config` (includes `clientId` and generated `authorizeUrlPreview`, without secrets) +- Guild automation execution locking is Redis-backed and fail-closed when lock + infrastructure is unavailable - 421 tests (361 backend + 60 frontend), 96% statement coverage ## Quick Start diff --git a/packages/backend/src/routes/guildAutomation.ts b/packages/backend/src/routes/guildAutomation.ts index 29c469110..b1e23ab5e 100644 --- a/packages/backend/src/routes/guildAutomation.ts +++ b/packages/backend/src/routes/guildAutomation.ts @@ -8,12 +8,19 @@ import { managementSchemas as s } from '../schemas/management' import { guildAutomationService, validateGuildAutomationManifest, + type GuildAutomationManifestDocument, + type GuildAutomationPlan, } from '@lucky/shared/services' - -const MANIFEST_NOT_FOUND_MESSAGE = 'No automation manifest found for this guild' -const CAPTURE_REQUIRED_MESSAGE = - 'No captured guild state available. Run capture before plan/apply.' -const APPLY_LOCKED_MESSAGE = 'Another automation apply operation is already running' +import { + GuildAutomationApplyLockedError, + GuildAutomationCaptureRequiredError, + GuildAutomationLockUnavailableError, + GuildAutomationManifestNotFoundError, +} from '@lucky/shared/types' +import { + GuildAutomationExecutionError, + guildAutomationExecutionService, +} from '../services/GuildAutomationExecutionService' function p(val: string | string[]): string { return typeof val === 'string' ? val : val[0] @@ -32,23 +39,31 @@ function mapAutomationServiceError(error: unknown): never { throw error } - if (error instanceof Error) { - if (error.message === MANIFEST_NOT_FOUND_MESSAGE) { - throw AppError.notFound('Automation manifest not found') - } + if (error instanceof GuildAutomationManifestNotFoundError) { + throw AppError.notFound('Automation manifest not found') + } - if (error.message === CAPTURE_REQUIRED_MESSAGE) { - throw AppError.badRequest( - 'No captured guild state available. Run capture before plan/apply.', - ) - } + if (error instanceof GuildAutomationCaptureRequiredError) { + throw AppError.badRequest( + 'No captured guild state available. Run capture before plan/apply.', + ) + } - if (error.message === APPLY_LOCKED_MESSAGE) { - throw AppError.badRequest( - 'Another automation apply operation is already running', - ) - } + if (error instanceof GuildAutomationApplyLockedError) { + throw AppError.badRequest( + 'Another automation apply operation is already running', + ) + } + + if (error instanceof GuildAutomationLockUnavailableError) { + throw new AppError(503, 'Guild automation lock backend is unavailable') + } + if (error instanceof GuildAutomationExecutionError) { + throw new AppError(error.statusCode, error.message) + } + + if (error instanceof Error) { throw error } @@ -161,9 +176,16 @@ export function setupGuildAutomationRoutes(app: Express): void { actualState?: unknown allowProtected?: boolean } - const actualState = body.actualState - ? validateGuildAutomationManifest(body.actualState) - : undefined + let actualState: GuildAutomationManifestDocument | undefined + try { + actualState = body.actualState + ? validateGuildAutomationManifest(body.actualState) + : await guildAutomationExecutionService.captureGuildAutomationState( + guildId, + ) + } catch (error) { + mapAutomationServiceError(error) + } let result try { @@ -172,6 +194,22 @@ export function setupGuildAutomationRoutes(app: Express): void { initiatedBy: userId, allowProtected: body.allowProtected, runType: 'apply', + executor: async (params: { + guildId: string + runId: string + plan: GuildAutomationPlan + desired: GuildAutomationManifestDocument + actual: GuildAutomationManifestDocument + allowProtected: boolean + }) => { + return guildAutomationExecutionService.executeApplyPlan({ + guildId: params.guildId, + plan: params.plan, + desired: params.desired, + actual: params.actual, + allowProtected: params.allowProtected, + }) + }, }) } catch (error) { mapAutomationServiceError(error) @@ -194,9 +232,16 @@ export function setupGuildAutomationRoutes(app: Express): void { actualState?: unknown allowProtected?: boolean } - const actualState = body.actualState - ? validateGuildAutomationManifest(body.actualState) - : undefined + let actualState: GuildAutomationManifestDocument | undefined + try { + actualState = body.actualState + ? validateGuildAutomationManifest(body.actualState) + : await guildAutomationExecutionService.captureGuildAutomationState( + guildId, + ) + } catch (error) { + mapAutomationServiceError(error) + } let result try { @@ -205,6 +250,22 @@ export function setupGuildAutomationRoutes(app: Express): void { initiatedBy: userId, allowProtected: body.allowProtected, runType: 'reconcile', + executor: async (params: { + guildId: string + runId: string + plan: GuildAutomationPlan + desired: GuildAutomationManifestDocument + actual: GuildAutomationManifestDocument + allowProtected: boolean + }) => { + return guildAutomationExecutionService.executeApplyPlan({ + guildId: params.guildId, + plan: params.plan, + desired: params.desired, + actual: params.actual, + allowProtected: params.allowProtected, + }) + }, }) } catch (error) { mapAutomationServiceError(error) diff --git a/packages/backend/src/services/GuildAutomationExecutionService.ts b/packages/backend/src/services/GuildAutomationExecutionService.ts new file mode 100644 index 000000000..16a0f8162 --- /dev/null +++ b/packages/backend/src/services/GuildAutomationExecutionService.ts @@ -0,0 +1,1060 @@ +import { + autoMessageService, + autoModService, + getModerationSettings, + guildAutomationService, + guildRoleAccessService, + reactionRolesService, + roleManagementService, + updateModerationSettings, + type GuildAutomationManifestDocument, + type GuildAutomationPlan, +} from '@lucky/shared/services' +import { debugLog } from '@lucky/shared/utils' + +const DISCORD_API_BASE_URL = 'https://discord.com/api/v10' +const DEFAULT_SOURCE = 'discord-capture' + +type AutoModUpdatePayload = Parameters[1] +type ModerationUpdatePayload = Parameters[1] + +type ManagedAutoMessage = { + enabled?: boolean + channelId?: string + message?: string +} + +type DiscordGuildResponse = { + id: string + name: string +} + +type DiscordRoleResponse = { + id: string + name: string + color?: number + hoist?: boolean + mentionable?: boolean + permissions?: string + managed?: boolean +} + +type DiscordChannelResponse = { + id: string + name: string + type: number + parent_id?: string | null + topic?: string | null +} + +type DiscordEmojiResponse = { + id?: string | null + name?: string | null +} + +type DiscordOnboardingPromptOptionResponse = { + id?: string + title?: string + description?: string | null + channel_ids?: string[] + role_ids?: string[] + emoji?: DiscordEmojiResponse | null +} + +type DiscordOnboardingPromptResponse = { + id?: string + title?: string + single_select?: boolean + required?: boolean + in_onboarding?: boolean + type?: number + options?: DiscordOnboardingPromptOptionResponse[] +} + +type DiscordOnboardingResponse = { + enabled?: boolean + mode?: number + default_channel_ids?: string[] + prompts?: DiscordOnboardingPromptResponse[] +} + +type RoleRemap = Map +type ChannelRemap = Map +type ManifestRoles = NonNullable +type ManifestRole = ManifestRoles['roles'][number] +type ManifestChannel = ManifestRoles['channels'][number] + +const SUPPORTED_CHANNEL_TYPES = new Set([0, 2, 4, 5, 13, 15]) + +export class GuildAutomationExecutionError extends Error { + constructor( + message: string, + public readonly statusCode = 500, + ) { + super(message) + this.name = 'GuildAutomationExecutionError' + } +} + +function normalizeName(value: string): string { + return value.trim().toLowerCase().replace(/\s+/g, ' ') +} + +function asObject(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null + } + + return value as Record +} + +function toAutoModPayload( + value: GuildAutomationManifestDocument['moderation'] extends { + automod?: infer T + } + ? T + : unknown, +): AutoModUpdatePayload | null { + return asObject(value) ? (value as AutoModUpdatePayload) : null +} + +function toModerationPayload( + value: GuildAutomationManifestDocument['moderation'] extends { + moderationSettings?: infer T + } + ? T + : unknown, +): ModerationUpdatePayload | null { + return asObject(value) ? (value as ModerationUpdatePayload) : null +} + +function isExpectedDeleteError(error: unknown): boolean { + if (!(error instanceof GuildAutomationExecutionError)) { + return false + } + + return error.statusCode === 403 || error.statusCode === 404 +} + +function isOnboardingUnavailable(error: unknown): boolean { + if (!(error instanceof GuildAutomationExecutionError)) { + return false + } + + return error.statusCode === 403 || error.statusCode === 404 +} + +function mapChannelType(type: number): string { + switch (type) { + case 4: + return 'GuildCategory' + case 2: + return 'GuildVoice' + case 5: + return 'GuildAnnouncement' + case 15: + return 'GuildForum' + case 13: + return 'GuildStageVoice' + default: + return 'GuildText' + } +} + +function toDiscordChannelType(type: string): number { + switch (type) { + case 'GuildCategory': + return 4 + case 'GuildVoice': + return 2 + case 'GuildAnnouncement': + return 5 + case 'GuildForum': + return 15 + case 'GuildStageVoice': + return 13 + default: + return 0 + } +} + +function shouldApplyModule( + plan: GuildAutomationPlan, + module: GuildAutomationPlan['operations'][number]['module'], + allowProtected: boolean, +): boolean { + return plan.operations.some( + (operation) => + operation.module === module && + (allowProtected || operation.protected === false), + ) +} + +function defaultParityChecklist() { + return [ + { + key: 'onboarding-native', + label: 'Native onboarding is configured in Lucky manifest', + done: false, + }, + { + key: 'moderation-parity', + label: 'Moderation and automod parity verified', + done: false, + }, + { + key: 'roles-parity', + label: 'Roles/channels parity verified in shadow mode', + done: false, + }, + { + key: 'external-bots-removed', + label: 'Legacy bot permissions/invites removed', + done: false, + }, + ] +} + +class GuildAutomationExecutionService { + private getBotToken(): string { + const token = process.env.DISCORD_TOKEN?.trim() + + if (!token) { + throw new GuildAutomationExecutionError( + 'DISCORD_TOKEN is required for automation execution', + 503, + ) + } + + return token + } + + private async discordRequest(params: { + token: string + endpoint: string + method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE' + body?: Record + }): Promise { + const method = params.method ?? 'GET' + const url = `${DISCORD_API_BASE_URL}${params.endpoint}` + + let response: Response + try { + response = await fetch(url, { + method, + headers: { + Authorization: `Bot ${params.token}`, + 'Content-Type': 'application/json', + }, + body: params.body ? JSON.stringify(params.body) : undefined, + }) + } catch (_error) { + throw new GuildAutomationExecutionError( + `Discord request failed for ${method} ${params.endpoint}`, + 502, + ) + } + + if (!response.ok) { + const responseBody = await response.text() + throw new GuildAutomationExecutionError( + `Discord request failed for ${method} ${params.endpoint}: ${response.status} ${responseBody}`, + response.status, + ) + } + + if (response.status === 204) { + return undefined as T + } + + return (await response.json()) as T + } + + private toOnboardingManifest( + onboarding: DiscordOnboardingResponse | null, + ): GuildAutomationManifestDocument['onboarding'] { + if (!onboarding) { + return undefined + } + + return { + enabled: onboarding.enabled ?? false, + mode: onboarding.mode ?? 0, + defaultChannelIds: onboarding.default_channel_ids ?? [], + prompts: (onboarding.prompts ?? []).map((prompt) => ({ + id: prompt.id, + title: prompt.title ?? '', + singleSelect: prompt.single_select, + required: prompt.required, + inOnboarding: prompt.in_onboarding, + type: prompt.type, + options: (prompt.options ?? []).map((option) => ({ + id: option.id, + title: option.title ?? '', + description: option.description ?? null, + channelIds: option.channel_ids ?? [], + roleIds: option.role_ids ?? [], + emoji: option.emoji?.id ?? option.emoji?.name ?? null, + })), + })), + } + } + + private async fetchOnboarding( + guildId: string, + token: string, + ): Promise { + try { + return await this.discordRequest({ + token, + endpoint: `/guilds/${guildId}/onboarding`, + }) + } catch (error) { + if (isOnboardingUnavailable(error)) { + debugLog({ + message: + 'Onboarding unavailable while capturing guild automation state', + data: { guildId }, + error, + }) + return null + } + + throw error + } + } + + private normalizeRoleKey(role: { name: string }): string { + return normalizeName(role.name) + } + + private normalizeChannelKey(channel: { + name: string + type: string + parentId?: string | null + }): string { + const parentKey = channel.parentId ?? 'root' + return [ + normalizeName(channel.name), + channel.type, + parentKey, + ].join('|') + } + + private resolveRoleTargetId(params: { + desiredRoleId: string + desiredRoleName: string + actualRoles: ManifestRole[] + usedActualRoleIds: Set + }): string | null { + const desiredById = params.actualRoles.find( + (role) => role.id === params.desiredRoleId, + ) + if (desiredById) { + return desiredById.id + } + + const desiredKey = this.normalizeRoleKey({ name: params.desiredRoleName }) + const fallback = [...params.actualRoles] + .filter((role) => !params.usedActualRoleIds.has(role.id)) + .sort((a, b) => a.id.localeCompare(b.id)) + .find((role) => this.normalizeRoleKey({ name: role.name }) === desiredKey) + + return fallback?.id ?? null + } + + private resolveChannelTargetId(params: { + desiredChannelId: string + desiredChannelName: string + desiredChannelType: string + desiredParentId?: string | null + actualChannels: ManifestChannel[] + usedActualChannelIds: Set + }): string | null { + const byId = params.actualChannels.find( + (channel) => channel.id === params.desiredChannelId, + ) + + if (byId) { + return byId.id + } + + const desiredKey = this.normalizeChannelKey({ + name: params.desiredChannelName, + type: params.desiredChannelType, + parentId: params.desiredParentId, + }) + + const fallback = [...params.actualChannels] + .filter((channel) => !params.usedActualChannelIds.has(channel.id)) + .sort((a, b) => a.id.localeCompare(b.id)) + .find( + (channel) => + this.normalizeChannelKey({ + name: channel.name, + type: channel.type, + parentId: channel.parentId, + }) === desiredKey, + ) + + return fallback?.id ?? null + } + + private remapManifestEntityIds(params: { + manifest: GuildAutomationManifestDocument + roleRemap: RoleRemap + channelRemap: ChannelRemap + }): GuildAutomationManifestDocument { + const next = structuredClone(params.manifest) + const remapRole = (roleId: string | undefined | null): string | undefined | null => { + if (!roleId) { + return roleId + } + + return params.roleRemap.get(roleId) ?? roleId + } + const remapChannel = ( + channelId: string | undefined | null, + ): string | undefined | null => { + if (!channelId) { + return channelId + } + + return params.channelRemap.get(channelId) ?? channelId + } + + if (next.roles) { + next.roles.roles = next.roles.roles.map((role) => ({ + ...role, + id: params.roleRemap.get(role.id) ?? role.id, + })) + + next.roles.channels = next.roles.channels.map((channel) => ({ + ...channel, + id: params.channelRemap.get(channel.id) ?? channel.id, + parentId: remapChannel(channel.parentId) ?? null, + })) + } + + if (next.onboarding) { + next.onboarding.defaultChannelIds = next.onboarding.defaultChannelIds + .map((channelId) => remapChannel(channelId)) + .filter((channelId): channelId is string => Boolean(channelId)) + + next.onboarding.prompts = next.onboarding.prompts.map((prompt) => ({ + ...prompt, + options: prompt.options.map((option) => ({ + ...option, + channelIds: option.channelIds + ?.map((channelId) => remapChannel(channelId)) + .filter((channelId): channelId is string => + Boolean(channelId), + ), + roleIds: option.roleIds + ?.map((roleId) => remapRole(roleId)) + .filter((roleId): roleId is string => Boolean(roleId)), + })), + })) + } + + if (next.moderation?.automod) { + next.moderation.automod.exemptRoles = + next.moderation.automod.exemptRoles + ?.map((roleId) => remapRole(roleId)) + .filter((roleId): roleId is string => Boolean(roleId)) + + next.moderation.automod.exemptChannels = + next.moderation.automod.exemptChannels + ?.map((channelId) => remapChannel(channelId)) + .filter((channelId): channelId is string => Boolean(channelId)) + } + + if (next.moderation?.moderationSettings) { + next.moderation.moderationSettings.muteRoleId = + remapRole(next.moderation.moderationSettings.muteRoleId) ?? null + + next.moderation.moderationSettings.modRoleIds = + next.moderation.moderationSettings.modRoleIds + ?.map((roleId) => remapRole(roleId)) + .filter((roleId): roleId is string => Boolean(roleId)) + + next.moderation.moderationSettings.adminRoleIds = + next.moderation.moderationSettings.adminRoleIds + ?.map((roleId) => remapRole(roleId)) + .filter((roleId): roleId is string => Boolean(roleId)) + } + + if (next.automessages?.welcome) { + next.automessages.welcome.channelId = + remapChannel(next.automessages.welcome.channelId) ?? undefined + } + + if (next.automessages?.leave) { + next.automessages.leave.channelId = + remapChannel(next.automessages.leave.channelId) ?? undefined + } + + if (next.reactionroles) { + next.reactionroles.messages = next.reactionroles.messages?.map( + (message) => ({ + ...message, + channelId: remapChannel(message.channelId) ?? undefined, + mappings: message.mappings?.map((mapping) => ({ + ...mapping, + roleId: remapRole(mapping.roleId) ?? mapping.roleId, + })), + }), + ) + + next.reactionroles.exclusiveRoles = + next.reactionroles.exclusiveRoles?.map((item) => ({ + roleId: remapRole(item.roleId) ?? item.roleId, + excludedRoleId: + remapRole(item.excludedRoleId) ?? item.excludedRoleId, + })) + } + + if (next.commandaccess) { + next.commandaccess.grants = next.commandaccess.grants.map((grant) => ({ + ...grant, + roleId: remapRole(grant.roleId) ?? grant.roleId, + })) + } + + return next + } + + private async upsertAutoMessage( + guildId: string, + type: 'welcome' | 'leave', + payload: ManagedAutoMessage | undefined, + ): Promise { + if (!payload?.message) { + return + } + + const existing = + type === 'welcome' + ? await autoMessageService.getWelcomeMessage(guildId) + : await autoMessageService.getLeaveMessage(guildId) + + if (!existing) { + await autoMessageService.createMessage( + guildId, + type, + { + message: payload.message, + }, + { + channelId: payload.channelId, + }, + ) + return + } + + await autoMessageService.updateMessage(existing.id, { + message: payload.message, + channelId: payload.channelId, + enabled: payload.enabled, + }) + } + + private async applyRolesAndChannels(params: { + token: string + guildId: string + desired: GuildAutomationManifestDocument + actual: GuildAutomationManifestDocument + allowProtected: boolean + roleRemap: RoleRemap + channelRemap: ChannelRemap + }): Promise { + const desiredRoles = params.desired.roles?.roles ?? [] + const desiredChannels = params.desired.roles?.channels ?? [] + const actualRoles = params.actual.roles?.roles ?? [] + const actualChannels = params.actual.roles?.channels ?? [] + const usedActualRoleIds = new Set() + const usedActualChannelIds = new Set() + + for (const role of desiredRoles) { + const targetRoleId = this.resolveRoleTargetId({ + desiredRoleId: role.id, + desiredRoleName: role.name, + actualRoles, + usedActualRoleIds, + }) + + const payload = { + name: role.name, + color: role.color, + hoist: role.hoist, + mentionable: role.mentionable, + permissions: role.permissions, + } + + let resolvedRoleId = targetRoleId + if (targetRoleId) { + await this.discordRequest({ + token: params.token, + endpoint: `/guilds/${params.guildId}/roles/${targetRoleId}`, + method: 'PATCH', + body: payload, + }) + } else { + const created = await this.discordRequest({ + token: params.token, + endpoint: `/guilds/${params.guildId}/roles`, + method: 'POST', + body: payload, + }) + resolvedRoleId = created.id + } + + if (!resolvedRoleId) { + continue + } + + usedActualRoleIds.add(resolvedRoleId) + if (resolvedRoleId !== role.id) { + params.roleRemap.set(role.id, resolvedRoleId) + } + } + + const sortedChannels = [...desiredChannels].sort((a, b) => { + const aPriority = a.type === 'GuildCategory' ? 0 : 1 + const bPriority = b.type === 'GuildCategory' ? 0 : 1 + if (aPriority !== bPriority) { + return aPriority - bPriority + } + + return a.id.localeCompare(b.id) + }) + + for (const channel of sortedChannels) { + const remappedParentId = + (channel.parentId + ? params.channelRemap.get(channel.parentId) + : undefined) ?? channel.parentId + + const targetChannelId = this.resolveChannelTargetId({ + desiredChannelId: channel.id, + desiredChannelName: channel.name, + desiredChannelType: channel.type, + desiredParentId: remappedParentId, + actualChannels, + usedActualChannelIds, + }) + + const payload = { + name: channel.name, + type: toDiscordChannelType(channel.type), + parent_id: remappedParentId ?? null, + topic: channel.topic ?? null, + } + + let resolvedChannelId = targetChannelId + if (targetChannelId) { + await this.discordRequest({ + token: params.token, + endpoint: `/channels/${targetChannelId}`, + method: 'PATCH', + body: payload, + }) + } else { + const created = await this.discordRequest({ + token: params.token, + endpoint: `/guilds/${params.guildId}/channels`, + method: 'POST', + body: payload, + }) + resolvedChannelId = created.id + } + + if (!resolvedChannelId) { + continue + } + + usedActualChannelIds.add(resolvedChannelId) + if (resolvedChannelId !== channel.id) { + params.channelRemap.set(channel.id, resolvedChannelId) + } + } + + if (!params.allowProtected) { + return + } + + const latestRoles = await this.discordRequest({ + token: params.token, + endpoint: `/guilds/${params.guildId}/roles`, + }) + const desiredRoleIds = new Set( + desiredRoles.map((role) => params.roleRemap.get(role.id) ?? role.id), + ) + + for (const role of latestRoles) { + if (role.id === params.guildId || role.managed) { + continue + } + + if (desiredRoleIds.has(role.id)) { + continue + } + + try { + await this.discordRequest({ + token: params.token, + endpoint: `/guilds/${params.guildId}/roles/${role.id}`, + method: 'DELETE', + }) + } catch (error) { + if (!isExpectedDeleteError(error)) { + throw error + } + } + } + + const latestChannels = await this.discordRequest({ + token: params.token, + endpoint: `/guilds/${params.guildId}/channels`, + }) + const desiredChannelIds = new Set( + desiredChannels.map( + (channel) => params.channelRemap.get(channel.id) ?? channel.id, + ), + ) + + for (const channel of latestChannels) { + if (desiredChannelIds.has(channel.id)) { + continue + } + + try { + await this.discordRequest({ + token: params.token, + endpoint: `/channels/${channel.id}`, + method: 'DELETE', + }) + } catch (error) { + if (!isExpectedDeleteError(error)) { + throw error + } + } + } + } + + private async applyReactionRoleRules( + guildId: string, + desired: GuildAutomationManifestDocument, + ): Promise { + const nextPairs = new Set( + (desired.reactionroles?.exclusiveRoles ?? []).map( + (item) => `${item.roleId}:${item.excludedRoleId}`, + ), + ) + + const existing = await roleManagementService.listExclusiveRoles(guildId) + + for (const item of existing) { + const key = `${item.roleId}:${item.excludedRoleId}` + if (!nextPairs.has(key)) { + await roleManagementService.removeExclusiveRole( + guildId, + item.roleId, + item.excludedRoleId, + ) + } + } + + for (const item of desired.reactionroles?.exclusiveRoles ?? []) { + await roleManagementService.setExclusiveRole( + guildId, + item.roleId, + item.excludedRoleId, + ) + } + } + + async captureGuildAutomationState( + guildId: string, + ): Promise { + const token = this.getBotToken() + + const [guild, roles, channels, onboarding, manifest, automodSettings, moderationSettings, welcomeMessage, leaveMessage, reactionRoleMessages, exclusiveRoles, roleGrants] = + await Promise.all([ + this.discordRequest({ + token, + endpoint: `/guilds/${guildId}`, + }), + this.discordRequest({ + token, + endpoint: `/guilds/${guildId}/roles`, + }), + this.discordRequest({ + token, + endpoint: `/guilds/${guildId}/channels`, + }), + this.fetchOnboarding(guildId, token), + guildAutomationService.getManifest(guildId), + autoModService.getSettings(guildId), + getModerationSettings(guildId), + autoMessageService.getWelcomeMessage(guildId), + autoMessageService.getLeaveMessage(guildId), + reactionRolesService.listReactionRoleMessages(guildId), + roleManagementService.listExclusiveRoles(guildId), + guildRoleAccessService.listRoleGrants(guildId), + ]) + + const manifestRoles = roles + .filter((role) => role.id !== guildId) + .map((role) => ({ + id: role.id, + name: role.name, + color: role.color, + hoist: role.hoist, + mentionable: role.mentionable, + permissions: role.permissions, + })) + + const manifestChannels = channels + .filter((channel) => SUPPORTED_CHANNEL_TYPES.has(channel.type)) + .map((channel) => ({ + id: channel.id, + name: channel.name, + type: mapChannelType(channel.type), + parentId: channel.parent_id ?? null, + topic: channel.topic ?? null, + readonly: false, + })) + + const parity = + manifest?.manifest.parity ?? { + shadowMode: true, + externalBots: [], + checklist: defaultParityChecklist(), + cutoverReady: false, + } + + return { + version: manifest?.manifest.version ?? 1, + guild: { + id: guild.id, + name: guild.name, + }, + onboarding: this.toOnboardingManifest(onboarding), + roles: { + roles: manifestRoles, + channels: manifestChannels, + }, + moderation: { + automod: toAutoModPayload( + (automodSettings as Record | null) ?? null, + ) ?? undefined, + moderationSettings: + toModerationPayload( + (moderationSettings as Record | null) ?? + null, + ) ?? undefined, + }, + automessages: { + welcome: welcomeMessage + ? { + enabled: welcomeMessage.enabled, + channelId: welcomeMessage.channelId ?? undefined, + message: welcomeMessage.message ?? undefined, + } + : undefined, + leave: leaveMessage + ? { + enabled: leaveMessage.enabled, + channelId: leaveMessage.channelId ?? undefined, + message: leaveMessage.message ?? undefined, + } + : undefined, + }, + reactionroles: { + messages: reactionRoleMessages.map((message) => ({ + id: message.id, + messageId: message.messageId, + channelId: message.channelId, + mappings: message.mappings.map((mapping) => ({ + roleId: mapping.roleId, + label: mapping.label ?? mapping.roleId, + emoji: mapping.emoji ?? undefined, + style: mapping.style ?? undefined, + })), + })), + exclusiveRoles: exclusiveRoles.map((item) => ({ + roleId: item.roleId, + excludedRoleId: item.excludedRoleId, + })), + }, + commandaccess: { + grants: roleGrants.map((grant) => ({ + roleId: grant.roleId, + module: grant.module, + mode: grant.mode, + })), + }, + parity, + source: DEFAULT_SOURCE, + capturedAt: new Date().toISOString(), + } + } + + async executeApplyPlan(params: { + guildId: string + plan: GuildAutomationPlan + desired: GuildAutomationManifestDocument + actual: GuildAutomationManifestDocument + allowProtected: boolean + }): Promise<{ + diagnostics: Record + remappedManifest?: GuildAutomationManifestDocument + }> { + const token = this.getBotToken() + const appliedModules: string[] = [] + const skippedModules: string[] = [] + const roleRemap: RoleRemap = new Map() + const channelRemap: ChannelRemap = new Map() + let effectiveDesired = params.desired + + if (shouldApplyModule(params.plan, 'onboarding', params.allowProtected)) { + const onboarding = effectiveDesired.onboarding + if (onboarding) { + await this.discordRequest({ + token, + endpoint: `/guilds/${params.guildId}/onboarding`, + method: 'PUT', + body: { + enabled: onboarding.enabled, + mode: onboarding.mode, + default_channel_ids: onboarding.defaultChannelIds, + prompts: onboarding.prompts.map((prompt) => ({ + id: prompt.id, + title: prompt.title, + single_select: prompt.singleSelect, + required: prompt.required, + in_onboarding: prompt.inOnboarding, + type: prompt.type, + options: prompt.options.map((option) => ({ + id: option.id ?? null, + title: option.title, + description: option.description ?? null, + channel_ids: option.channelIds, + role_ids: option.roleIds, + emoji: option.emoji ?? null, + })), + })), + }, + }) + appliedModules.push('onboarding') + } + } + + if (shouldApplyModule(params.plan, 'roles', params.allowProtected)) { + await this.applyRolesAndChannels({ + token, + guildId: params.guildId, + desired: effectiveDesired, + actual: params.actual, + allowProtected: params.allowProtected, + roleRemap, + channelRemap, + }) + + if (roleRemap.size > 0 || channelRemap.size > 0) { + effectiveDesired = this.remapManifestEntityIds({ + manifest: effectiveDesired, + roleRemap, + channelRemap, + }) + } + + appliedModules.push('roles') + } + + if (shouldApplyModule(params.plan, 'moderation', params.allowProtected)) { + const automodPayload = toAutoModPayload( + effectiveDesired.moderation?.automod, + ) + if (automodPayload) { + await autoModService.updateSettings(params.guildId, automodPayload) + } + + const moderationPayload = toModerationPayload( + effectiveDesired.moderation?.moderationSettings, + ) + if (moderationPayload) { + await updateModerationSettings(params.guildId, moderationPayload) + } + + appliedModules.push('moderation') + } + + if (shouldApplyModule(params.plan, 'automessages', params.allowProtected)) { + await this.upsertAutoMessage( + params.guildId, + 'welcome', + effectiveDesired.automessages?.welcome, + ) + await this.upsertAutoMessage( + params.guildId, + 'leave', + effectiveDesired.automessages?.leave, + ) + appliedModules.push('automessages') + } + + if (shouldApplyModule(params.plan, 'reactionroles', params.allowProtected)) { + await this.applyReactionRoleRules(params.guildId, effectiveDesired) + + if ((effectiveDesired.reactionroles?.messages?.length ?? 0) > 0) { + skippedModules.push( + 'reactionroles.messages requires manual message-template publish', + ) + } + + appliedModules.push('reactionroles') + } + + if (shouldApplyModule(params.plan, 'commandaccess', params.allowProtected)) { + await guildRoleAccessService.replaceRoleGrants( + params.guildId, + effectiveDesired.commandaccess?.grants ?? [], + ) + appliedModules.push('commandaccess') + } + + if (shouldApplyModule(params.plan, 'parity', params.allowProtected)) { + skippedModules.push('parity requires checklist/cutover workflow') + } + + const diagnostics: Record = { + appliedModules, + skippedModules, + roleIdRemaps: Object.fromEntries(roleRemap.entries()), + channelIdRemaps: Object.fromEntries(channelRemap.entries()), + } + + debugLog({ + message: 'Guild automation apply execution completed', + data: { + guildId: params.guildId, + appliedModules, + skippedModules, + remappedRoles: roleRemap.size, + remappedChannels: channelRemap.size, + }, + }) + + return { + diagnostics, + remappedManifest: + roleRemap.size > 0 || channelRemap.size > 0 + ? effectiveDesired + : undefined, + } + } +} + +export const guildAutomationExecutionService = new GuildAutomationExecutionService() diff --git a/packages/backend/tests/integration/routes/guildAutomation.test.ts b/packages/backend/tests/integration/routes/guildAutomation.test.ts index 2e69f57ce..27bae3cd7 100644 --- a/packages/backend/tests/integration/routes/guildAutomation.test.ts +++ b/packages/backend/tests/integration/routes/guildAutomation.test.ts @@ -7,6 +7,12 @@ import { errorHandler } from '../../../src/middleware/errorHandler' import { AppError } from '../../../src/errors/AppError' import { sessionService } from '../../../src/services/SessionService' import { MOCK_SESSION_DATA } from '../../fixtures/mock-data' +import { guildAutomationManifestSchema } from '@lucky/shared/services/guildAutomation/manifestSchema' +import { + GuildAutomationLockUnavailableError, + GuildAutomationCaptureRequiredError, + GuildAutomationManifestNotFoundError, +} from '@lucky/shared/types' jest.mock('../../../src/services/SessionService', () => ({ sessionService: { @@ -29,10 +35,26 @@ jest.mock('@lucky/shared/services', () => ({ validateGuildAutomationManifest: jest.fn((input: unknown) => input), })) +jest.mock('../../../src/services/GuildAutomationExecutionService', () => ({ + guildAutomationExecutionService: { + captureGuildAutomationState: jest.fn(), + executeApplyPlan: jest.fn(), + }, + GuildAutomationExecutionError: class GuildAutomationExecutionError extends Error { + public readonly statusCode: number + + constructor(message: string, statusCode = 500) { + super(message) + this.statusCode = statusCode + } + }, +})) + import { guildAutomationService, validateGuildAutomationManifest, } from '@lucky/shared/services' +import { guildAutomationExecutionService } from '../../../src/services/GuildAutomationExecutionService' describe('Guild Automation Routes', () => { let app: express.Express @@ -49,6 +71,20 @@ describe('Guild Automation Routes', () => { typeof sessionService > mockedSessionService.getSession.mockResolvedValue(MOCK_SESSION_DATA) + + const mockedExecutionService = + guildAutomationExecutionService as jest.Mocked< + typeof guildAutomationExecutionService + > + mockedExecutionService.captureGuildAutomationState.mockResolvedValue({ + version: 1, + guild: { id: '111111111111111111' }, + } as any) + mockedExecutionService.executeApplyPlan.mockResolvedValue({ + diagnostics: { + appliedModules: ['roles'], + }, + }) }) test('GET manifest returns 404 when not found', async () => { @@ -125,7 +161,7 @@ describe('Guild Automation Routes', () => { typeof guildAutomationService > mockedService.createPlan.mockRejectedValue( - new Error('No automation manifest found for this guild'), + new GuildAutomationManifestNotFoundError('111111111111111111'), ) const response = await request(app) @@ -144,9 +180,7 @@ describe('Guild Automation Routes', () => { typeof guildAutomationService > mockedService.createApplyRun.mockRejectedValue( - new Error( - 'No captured guild state available. Run capture before plan/apply.', - ), + new GuildAutomationCaptureRequiredError('111111111111111111'), ) const response = await request(app) @@ -180,6 +214,29 @@ describe('Guild Automation Routes', () => { ) }) + test('PUT manifest keeps one route contract test on the real parser', async () => { + const validator = validateGuildAutomationManifest as jest.Mock + validator.mockImplementation((input: unknown) => + guildAutomationManifestSchema.parse(input), + ) + + const response = await request(app) + .put('/api/guilds/111111111111111111/automation/manifest') + .set('Cookie', ['sessionId=valid_session_id']) + .send({ + version: 1, + guild: { id: '111111111111111111' }, + roles: { roles: 'invalid', channels: [] }, + }) + .expect(400) + + expect(response.body).toEqual( + expect.objectContaining({ + error: 'Validation failed', + }), + ) + }) + test('POST apply delegates with allowProtected option', async () => { const mockedService = guildAutomationService as jest.Mocked< typeof guildAutomationService @@ -202,14 +259,22 @@ describe('Guild Automation Routes', () => { .send({ allowProtected: true }) .expect(200) + const mockedExecutionService = + guildAutomationExecutionService as jest.Mocked< + typeof guildAutomationExecutionService + > + + expect(mockedExecutionService.captureGuildAutomationState).toHaveBeenCalledWith( + '111111111111111111', + ) expect(mockedService.createApplyRun).toHaveBeenCalledWith( '111111111111111111', - { - actualState: undefined, + expect.objectContaining({ initiatedBy: MOCK_SESSION_DATA.userId, allowProtected: true, runType: 'apply', - }, + executor: expect.any(Function), + }), ) }) @@ -243,12 +308,50 @@ describe('Guild Automation Routes', () => { }) }) - test('POST reconcile delegates to apply-run with reconcile type', async () => { + test('POST apply does not capture when actualState is provided', async () => { const mockedService = guildAutomationService as jest.Mocked< typeof guildAutomationService > + const mockedExecutionService = + guildAutomationExecutionService as jest.Mocked< + typeof guildAutomationExecutionService + > mockedService.createApplyRun.mockResolvedValue({ - runId: 'run-reconcile-1', + runId: 'run-3', + status: 'completed', + blockedByProtected: false, + plan: { + operations: [], + protectedOperations: [], + summary: { total: 0, safe: 0, protected: 0 }, + }, + } as any) + + await request(app) + .post('/api/guilds/111111111111111111/automation/apply') + .set('Cookie', ['sessionId=valid_session_id']) + .send({ + allowProtected: false, + actualState: { + version: 1, + guild: { id: '111111111111111111' }, + }, + }) + .expect(200) + + expect(mockedExecutionService.captureGuildAutomationState).not.toHaveBeenCalled() + }) + + test('POST reconcile delegates with execution pipeline and reconcile run type', async () => { + const mockedService = guildAutomationService as jest.Mocked< + typeof guildAutomationService + > + const mockedExecutionService = + guildAutomationExecutionService as jest.Mocked< + typeof guildAutomationExecutionService + > + mockedService.createApplyRun.mockResolvedValue({ + runId: 'run-4', status: 'completed', blockedByProtected: false, plan: { @@ -261,17 +364,20 @@ describe('Guild Automation Routes', () => { await request(app) .post('/api/guilds/111111111111111111/automation/reconcile') .set('Cookie', ['sessionId=valid_session_id']) - .send({ allowProtected: false }) + .send({ allowProtected: true }) .expect(200) + expect(mockedExecutionService.captureGuildAutomationState).toHaveBeenCalledWith( + '111111111111111111', + ) expect(mockedService.createApplyRun).toHaveBeenCalledWith( '111111111111111111', - { - actualState: undefined, + expect.objectContaining({ initiatedBy: MOCK_SESSION_DATA.userId, - allowProtected: false, + allowProtected: true, runType: 'reconcile', - }, + executor: expect.any(Function), + }), ) }) @@ -300,22 +406,22 @@ describe('Guild Automation Routes', () => { ) }) - test('POST apply maps lock precondition to 400', async () => { + test('POST apply maps lock backend unavailable to 503', async () => { const mockedService = guildAutomationService as jest.Mocked< typeof guildAutomationService > mockedService.createApplyRun.mockRejectedValue( - new Error('Another automation apply operation is already running'), + new GuildAutomationLockUnavailableError('111111111111111111'), ) const response = await request(app) .post('/api/guilds/111111111111111111/automation/apply') .set('Cookie', ['sessionId=valid_session_id']) .send({}) - .expect(400) + .expect(503) expect(response.body).toEqual({ - error: 'Another automation apply operation is already running', + error: 'Guild automation lock backend is unavailable', }) }) diff --git a/packages/bot/src/functions/automod/commands/index.ts b/packages/bot/src/functions/automod/commands/index.ts index a980ac870..d6ab27564 100644 --- a/packages/bot/src/functions/automod/commands/index.ts +++ b/packages/bot/src/functions/automod/commands/index.ts @@ -1,6 +1,6 @@ import { getCommandsFromDirectory } from '../../../utils/command/getCommandsFromDirectory' -import path from 'path' -import { fileURLToPath } from 'url' +import path from 'node:path' +import { fileURLToPath } from 'node:url' import { debugLog, errorLog } from '@lucky/shared/utils' async function getAutoModCommands() { diff --git a/packages/bot/src/functions/moderation/commands/index.ts b/packages/bot/src/functions/moderation/commands/index.ts index 99c4c4fdd..34b49026c 100644 --- a/packages/bot/src/functions/moderation/commands/index.ts +++ b/packages/bot/src/functions/moderation/commands/index.ts @@ -1,6 +1,6 @@ import { getCommandsFromDirectory } from '../../../utils/command/getCommandsFromDirectory' -import path from 'path' -import { fileURLToPath } from 'url' +import path from 'node:path' +import { fileURLToPath } from 'node:url' import { debugLog, errorLog } from '@lucky/shared/utils' async function getModerationCommands() { diff --git a/packages/bot/src/utils/guildAutomation/applyPlan.ts b/packages/bot/src/utils/guildAutomation/applyPlan.ts index 8c416bff0..4513de9bc 100644 --- a/packages/bot/src/utils/guildAutomation/applyPlan.ts +++ b/packages/bot/src/utils/guildAutomation/applyPlan.ts @@ -140,6 +140,22 @@ async function applyRolesAndChannels( const desiredRoles = desired.roles?.roles ?? [] const desiredChannels = desired.roles?.channels ?? [] + await syncRoles(guild, desiredRoles) + await syncChannels(guild, desiredChannels) + + if (!allowProtected) { + return + } + + const desiredRoleIds = new Set(desiredRoles.map((role) => role.id)) + const desiredChannelIds = new Set(desiredChannels.map((channel) => channel.id)) + await deleteUnmanagedEntities(guild, desiredRoleIds, desiredChannelIds) +} + +async function syncRoles( + guild: Guild, + desiredRoles: NonNullable['roles'], +): Promise { for (const role of desiredRoles) { const existing = guild.roles.cache.get(role.id) if (!existing) { @@ -163,7 +179,12 @@ async function applyRolesAndChannels( reason: 'Lucky guild automation reconcile', }) } +} +async function syncChannels( + guild: Guild, + desiredChannels: NonNullable['channels'], +): Promise { for (const channel of desiredChannels) { const existing = findChannel(guild, channel.id) @@ -185,14 +206,13 @@ async function applyRolesAndChannels( reason: 'Lucky guild automation reconcile', }) } +} - if (!allowProtected) { - return - } - - const desiredRoleIds = new Set(desiredRoles.map((role) => role.id)) - const desiredChannelIds = new Set(desiredChannels.map((channel) => channel.id)) - +async function deleteUnmanagedEntities( + guild: Guild, + desiredRoleIds: Set, + desiredChannelIds: Set, +): Promise { for (const role of guild.roles.cache.values()) { if (role.id === guild.id || desiredRoleIds.has(role.id)) { continue @@ -266,71 +286,36 @@ export async function applyAutomationModules(params: { plan: GuildAutomationPlan allowProtected: boolean }): Promise { - const { guild, desired, plan, allowProtected } = params + const { plan, allowProtected } = params const appliedModules: string[] = [] const skippedModules: string[] = [] if (shouldApplyModule(plan, 'onboarding', allowProtected)) { - const payload = manifestOnboardingToDiscordEdit(desired.onboarding) - if (payload) { - await guild.editOnboarding(payload) - appliedModules.push('onboarding') - } + await handleOnboardingModule(params, appliedModules) } - if (shouldApplyModule(plan, 'roles', allowProtected) && desired.roles) { - await applyRolesAndChannels(guild, desired, allowProtected) - appliedModules.push('roles') + if (shouldApplyModule(plan, 'roles', allowProtected)) { + await handleRolesModule(params, appliedModules) } if (shouldApplyModule(plan, 'moderation', allowProtected)) { - const automodPayload = toAutoModUpdatePayload(desired.moderation?.automod) - if (automodPayload) { - await autoModService.updateSettings( - guild.id, - automodPayload, - ) - } - - const moderationPayload = toModerationUpdatePayload( - desired.moderation?.moderationSettings, - ) - if (moderationPayload) { - await updateModerationSettings( - guild.id, - moderationPayload, - ) - } - - appliedModules.push('moderation') + await handleModerationModule(params, appliedModules) } if (shouldApplyModule(plan, 'automessages', allowProtected)) { - await upsertAutoMessage(guild.id, 'welcome', desired.automessages?.welcome) - await upsertAutoMessage(guild.id, 'leave', desired.automessages?.leave) - appliedModules.push('automessages') + await handleAutomessagesModule(params, appliedModules) } if (shouldApplyModule(plan, 'reactionroles', allowProtected)) { - await applyReactionRoleRules(guild.id, desired) - if ((desired.reactionroles?.messages?.length ?? 0) > 0) { - skippedModules.push( - 'reactionroles.messages requires manual message-template publish', - ) - } - appliedModules.push('reactionroles') + await handleReactionRolesModule(params, appliedModules, skippedModules) } if (shouldApplyModule(plan, 'commandaccess', allowProtected)) { - await guildRoleAccessService.replaceRoleGrants( - guild.id, - desired.commandaccess?.grants ?? [], - ) - appliedModules.push('commandaccess') + await handleCommandAccessModule(params, appliedModules) } if (shouldApplyModule(plan, 'parity', allowProtected)) { - skippedModules.push('parity requires checklist/cutover workflow') + handleParityModule(skippedModules) } return { @@ -338,3 +323,101 @@ export async function applyAutomationModules(params: { skippedModules, } } + +type ApplyContext = { + guild: Guild + desired: GuildAutomationManifestDocument + allowProtected: boolean +} + +async function handleOnboardingModule( + params: ApplyContext, + appliedModules: string[], +): Promise { + const payload = manifestOnboardingToDiscordEdit(params.desired.onboarding) + if (!payload) { + return + } + + await params.guild.editOnboarding(payload) + appliedModules.push('onboarding') +} + +async function handleRolesModule( + params: ApplyContext, + appliedModules: string[], +): Promise { + if (!params.desired.roles) { + return + } + + await applyRolesAndChannels(params.guild, params.desired, params.allowProtected) + appliedModules.push('roles') +} + +async function handleModerationModule( + params: ApplyContext, + appliedModules: string[], +): Promise { + const automodPayload = toAutoModUpdatePayload( + params.desired.moderation?.automod, + ) + if (automodPayload) { + await autoModService.updateSettings(params.guild.id, automodPayload) + } + + const moderationPayload = toModerationUpdatePayload( + params.desired.moderation?.moderationSettings, + ) + if (moderationPayload) { + await updateModerationSettings(params.guild.id, moderationPayload) + } + + appliedModules.push('moderation') +} + +async function handleAutomessagesModule( + params: ApplyContext, + appliedModules: string[], +): Promise { + await upsertAutoMessage( + params.guild.id, + 'welcome', + params.desired.automessages?.welcome, + ) + await upsertAutoMessage( + params.guild.id, + 'leave', + params.desired.automessages?.leave, + ) + appliedModules.push('automessages') +} + +async function handleReactionRolesModule( + params: ApplyContext, + appliedModules: string[], + skippedModules: string[], +): Promise { + await applyReactionRoleRules(params.guild.id, params.desired) + if ((params.desired.reactionroles?.messages?.length ?? 0) > 0) { + skippedModules.push( + 'reactionroles.messages requires manual message-template publish', + ) + } + appliedModules.push('reactionroles') +} + +async function handleCommandAccessModule( + params: ApplyContext, + appliedModules: string[], +): Promise { + await guildRoleAccessService.replaceRoleGrants( + params.guild.id, + params.desired.commandaccess?.grants ?? [], + ) + appliedModules.push('commandaccess') +} + +function handleParityModule(skippedModules: string[]): void { + skippedModules.push('parity requires checklist/cutover workflow') +} diff --git a/packages/shared/src/services/guildAutomation/service.ts b/packages/shared/src/services/guildAutomation/service.ts index 631616067..c87101077 100644 --- a/packages/shared/src/services/guildAutomation/service.ts +++ b/packages/shared/src/services/guildAutomation/service.ts @@ -1,23 +1,33 @@ import { Prisma } from '../../generated/prisma/client.js' import { getPrismaClient } from '../../utils/database/prismaClient.js' import { errorLog, debugLog } from '../../utils/general/log.js' +import { redisClient } from '../redis/index.js' import { guildAutomationManifestSchema, type GuildAutomationManifestInput, } from './manifestSchema.js' import { createAutomationPlan } from './diff.js' +import { + GuildAutomationApplyLockedError, + GuildAutomationCaptureRequiredError, + GuildAutomationInvalidManifestPayloadError, + GuildAutomationLockUnavailableError, + GuildAutomationManifestNotFoundError, +} from '../../types/errors/guildAutomation.js' import type { AutomationModule, AutomationRunStatus, AutomationRunType, + DriftSeverity, GuildAutomationManifestDocument, + GuildAutomationPlan, GuildAutomationStatus, } from './types.js' +import { randomUUID } from 'node:crypto' const prisma = getPrismaClient() const LOCK_TTL_MS = 60_000 - -const locks = new Map() +const LOCK_KEY_PREFIX = 'guild-automation:lock' function toJsonValue(value: unknown): Prisma.InputJsonValue { return value as Prisma.InputJsonValue @@ -29,37 +39,56 @@ function isObject(value: unknown): value is Record { function toManifestDocument(value: unknown): GuildAutomationManifestDocument { if (!isObject(value)) { - throw new Error('Manifest payload is invalid') + throw new GuildAutomationInvalidManifestPayloadError() } return guildAutomationManifestSchema.parse(value) } -function cleanupLocks(): void { - const now = Date.now() - for (const [guildId, lock] of locks.entries()) { - if (lock.expiresAt <= now) { - locks.delete(guildId) - } +function computeSeverity(count: number): DriftSeverity { + if (count === 0) { + return 'none' + } + + if (count < 3) { + return 'low' } + + if (count < 8) { + return 'medium' + } + + return 'high' } class GuildAutomationService { - private acquireLock(guildId: string): boolean { - cleanupLocks() - if (locks.has(guildId)) { - return false + private getLockKey(guildId: string): string { + return `${LOCK_KEY_PREFIX}:${guildId}` + } + + private async acquireLock(guildId: string): Promise { + if (!redisClient.isHealthy()) { + throw new GuildAutomationLockUnavailableError(guildId) } - locks.set(guildId, { - expiresAt: Date.now() + LOCK_TTL_MS, - }) + const key = this.getLockKey(guildId) + const token = randomUUID() + const acquired = await redisClient.setNxPx(key, token, LOCK_TTL_MS) + + if (!acquired) { + throw new GuildAutomationApplyLockedError(guildId) + } - return true + return token } - private releaseLock(guildId: string): void { - locks.delete(guildId) + private async releaseLock(guildId: string, token: string): Promise { + if (!redisClient.isHealthy()) { + return + } + + const key = this.getLockKey(guildId) + await redisClient.delIfValueMatches(key, token) } async saveManifest( @@ -165,6 +194,24 @@ class GuildAutomationService { } } + private resolveActualState( + guildId: string, + manifestRow: { + lastCapturedState: Prisma.JsonValue | null + }, + options?: { actualState?: GuildAutomationManifestInput }, + ): GuildAutomationManifestDocument { + if (options?.actualState) { + return guildAutomationManifestSchema.parse(options.actualState) + } + + if (manifestRow.lastCapturedState) { + return toManifestDocument(manifestRow.lastCapturedState) + } + + throw new GuildAutomationCaptureRequiredError(guildId) + } + async createPlan( guildId: string, options?: { @@ -178,22 +225,11 @@ class GuildAutomationService { }) if (!manifestRow) { - throw new Error('No automation manifest found for this guild') + throw new GuildAutomationManifestNotFoundError(guildId) } const desired = toManifestDocument(manifestRow.manifest) - - const actual = options?.actualState - ? guildAutomationManifestSchema.parse(options.actualState) - : manifestRow.lastCapturedState - ? toManifestDocument(manifestRow.lastCapturedState) - : null - - if (!actual) { - throw new Error( - 'No captured guild state available. Run capture before plan/apply.', - ) - } + const actual = this.resolveActualState(guildId, manifestRow, options) const plan = createAutomationPlan({ desired, @@ -205,14 +241,7 @@ class GuildAutomationService { runType === 'plan' ? 'completed' : 'running' for (const [moduleName, count] of Object.entries(plan.summary.byModule)) { - const severity: 'none' | 'low' | 'medium' | 'high' = - count === 0 - ? 'none' - : count < 3 - ? 'low' - : count < 8 - ? 'medium' - : 'high' + const severity = computeSeverity(count) await prisma.guildAutomationDrift.upsert({ where: { @@ -274,11 +303,20 @@ class GuildAutomationService { initiatedBy?: string allowProtected?: boolean runType?: Extract + executor?: (params: { + guildId: string + runId: string + plan: GuildAutomationPlan + desired: GuildAutomationManifestDocument + actual: GuildAutomationManifestDocument + allowProtected: boolean + }) => Promise<{ + diagnostics?: Record + remappedManifest?: GuildAutomationManifestDocument + }> }, ) { - if (!this.acquireLock(guildId)) { - throw new Error('Another automation apply operation is already running') - } + const lockToken = await this.acquireLock(guildId) try { const planResult = await this.createPlan(guildId, { @@ -291,35 +329,90 @@ class GuildAutomationService { (options?.allowProtected ?? false) === false && planResult.plan.protectedOperations.length > 0 - const status: AutomationRunStatus = blockedByProtected - ? 'blocked' - : 'completed' + const allowProtected = options?.allowProtected ?? false + const baseDiagnostics: Record = { + allowProtected, + blockedByProtected, + } - const run = await prisma.guildAutomationRun.update({ - where: { id: planResult.runId }, - data: { - status, - diagnostics: toJsonValue({ - allowProtected: options?.allowProtected ?? false, - blockedByProtected, - autoAppliedOperations: blockedByProtected - ? [] - : planResult.plan.operations.filter( - (operation) => !operation.protected, - ), - }), - completedAt: new Date(), + if (blockedByProtected) { + const run = await this.updateRunStatus({ + runId: planResult.runId, + status: 'blocked', + diagnostics: baseDiagnostics, + }) + + return { + runId: run.id, + status: 'blocked' as const, + plan: planResult.plan, + blockedByProtected, + } + } + + if (options?.executor) { + try { + const execution = await options.executor({ + guildId, + runId: planResult.runId, + plan: planResult.plan, + desired: planResult.desired, + actual: planResult.actual, + allowProtected, + }) + + if (execution.remappedManifest) { + await this.saveManifest(guildId, execution.remappedManifest, { + createdBy: options.initiatedBy, + version: execution.remappedManifest.version, + }) + } + + const run = await this.updateRunStatus({ + runId: planResult.runId, + status: 'completed', + diagnostics: { + ...baseDiagnostics, + ...(execution.diagnostics ?? {}), + }, + }) + + return { + runId: run.id, + status: 'completed' as const, + plan: planResult.plan, + blockedByProtected: false, + } + } catch (error) { + await this.updateRunStatus({ + runId: planResult.runId, + status: 'failed', + error: error instanceof Error ? error.message : String(error), + diagnostics: baseDiagnostics, + }) + throw error + } + } + + const run = await this.updateRunStatus({ + runId: planResult.runId, + status: 'completed', + diagnostics: { + ...baseDiagnostics, + autoAppliedOperations: planResult.plan.operations.filter( + (operation) => !operation.protected, + ), }, }) return { runId: run.id, - status, + status: run.status, plan: planResult.plan, blockedByProtected, } } finally { - this.releaseLock(guildId) + await this.releaseLock(guildId, lockToken) } } @@ -390,14 +483,14 @@ class GuildAutomationService { latestRun: latestRun ? { id: latestRun.id, - type: latestRun.type, - status: latestRun.status, + type: latestRun.type as AutomationRunType, + status: latestRun.status as AutomationRunStatus, createdAt: latestRun.createdAt, } : null, drifts: drifts.map((drift) => ({ - module: drift.module, - severity: drift.severity, + module: drift.module as AutomationModule, + severity: drift.severity as DriftSeverity, updatedAt: drift.updatedAt, })), } @@ -415,7 +508,7 @@ class GuildAutomationService { }) if (!row) { - throw new Error('No automation manifest found for this guild') + throw new GuildAutomationManifestNotFoundError(guildId) } const manifest = toManifestDocument(row.manifest) diff --git a/packages/shared/src/services/guildAutomation/types.ts b/packages/shared/src/services/guildAutomation/types.ts index 605447f78..948058b67 100644 --- a/packages/shared/src/services/guildAutomation/types.ts +++ b/packages/shared/src/services/guildAutomation/types.ts @@ -195,13 +195,13 @@ export interface GuildAutomationStatus { } | null latestRun: { id: string - type: string - status: string + type: AutomationRunType + status: AutomationRunStatus createdAt: Date } | null drifts: Array<{ - module: string - severity: string + module: AutomationModule + severity: DriftSeverity updatedAt: Date }> } diff --git a/packages/shared/src/services/redis/client.ts b/packages/shared/src/services/redis/client.ts index 7d84baa0f..384111c1e 100644 --- a/packages/shared/src/services/redis/client.ts +++ b/packages/shared/src/services/redis/client.ts @@ -147,6 +147,48 @@ export class RedisClient implements IRedisClient { return this.operations?.ltrim(key, start, stop) ?? false } + async setNxPx(key: string, value: string, ttlMs: number): Promise { + if (!this.client) { + return false + } + + try { + const result = await this.client.set(key, value, 'PX', ttlMs, 'NX') + return result === 'OK' + } catch (error) { + errorLog({ + message: 'Failed to set Redis NX PX lock key', + error, + data: { key }, + }) + return false + } + } + + async delIfValueMatches( + key: string, + expectedValue: string, + ): Promise { + if (!this.client) { + return false + } + + const script = + 'if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) else return 0 end' + + try { + const result = await this.client.eval(script, 1, key, expectedValue) + return result === 1 + } catch (error) { + errorLog({ + message: 'Failed to release Redis lock key', + error, + data: { key }, + }) + return false + } + } + async ttl(key: string): Promise { return this.operations?.ttl(key) ?? -2 } diff --git a/packages/shared/src/services/redis/types.ts b/packages/shared/src/services/redis/types.ts index f0392ccf9..6adb50bd0 100644 --- a/packages/shared/src/services/redis/types.ts +++ b/packages/shared/src/services/redis/types.ts @@ -37,5 +37,7 @@ export interface IRedisClient { llen(key: string): Promise lindex(key: string, index: number): Promise ltrim(key: string, start: number, stop: number): Promise + setNxPx(key: string, value: string, ttlMs: number): Promise + delIfValueMatches(key: string, expectedValue: string): Promise shutdown(): Promise } diff --git a/packages/shared/src/types/errors/errors.spec.ts b/packages/shared/src/types/errors/errors.spec.ts index a7cf8dabd..b13fa89cf 100644 --- a/packages/shared/src/types/errors/errors.spec.ts +++ b/packages/shared/src/types/errors/errors.spec.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from '@jest/globals' -import { MusicError, ConfigurationError } from './index' +import { + MusicError, + ConfigurationError, + GuildAutomationManifestNotFoundError, + GUILD_AUTOMATION_ERROR_CODES, +} from './index' describe('Error Types', () => { describe('MusicError', () => { @@ -47,4 +52,18 @@ describe('Error Types', () => { expect(error instanceof Error).toBe(true) }) }) + + describe('GuildAutomationError', () => { + it('should expose code and context', () => { + const error = new GuildAutomationManifestNotFoundError('guild-123') + + expect(error.message).toBe( + 'No automation manifest found for this guild', + ) + expect(error.code).toBe( + GUILD_AUTOMATION_ERROR_CODES.GUILD_AUTOMATION_MANIFEST_NOT_FOUND, + ) + expect(error.context.guildId).toBe('guild-123') + }) + }) }) diff --git a/packages/shared/src/types/errors/guildAutomation.ts b/packages/shared/src/types/errors/guildAutomation.ts new file mode 100644 index 000000000..0103cf693 --- /dev/null +++ b/packages/shared/src/types/errors/guildAutomation.ts @@ -0,0 +1,93 @@ +export const GUILD_AUTOMATION_ERROR_CODES = { + GUILD_AUTOMATION_INVALID_MANIFEST_PAYLOAD: + 'ERR_GUILD_AUTOMATION_INVALID_MANIFEST_PAYLOAD', + GUILD_AUTOMATION_MANIFEST_NOT_FOUND: + 'ERR_GUILD_AUTOMATION_MANIFEST_NOT_FOUND', + GUILD_AUTOMATION_CAPTURE_REQUIRED: + 'ERR_GUILD_AUTOMATION_CAPTURE_REQUIRED', + GUILD_AUTOMATION_APPLY_LOCKED: 'ERR_GUILD_AUTOMATION_APPLY_LOCKED', + GUILD_AUTOMATION_LOCK_UNAVAILABLE: + 'ERR_GUILD_AUTOMATION_LOCK_UNAVAILABLE', +} as const + +export type GuildAutomationErrorCode = + (typeof GUILD_AUTOMATION_ERROR_CODES)[keyof typeof GUILD_AUTOMATION_ERROR_CODES] + +type GuildAutomationErrorContext = { + guildId?: string + runId?: string + details?: Record +} + +export class GuildAutomationError extends Error { + public readonly code: GuildAutomationErrorCode + public readonly retryable: boolean + public readonly context: GuildAutomationErrorContext + + constructor(params: { + message: string + code: GuildAutomationErrorCode + retryable?: boolean + context?: GuildAutomationErrorContext + }) { + super(params.message) + this.name = 'GuildAutomationError' + this.code = params.code + this.retryable = params.retryable ?? false + this.context = params.context ?? {} + } +} + +export class GuildAutomationInvalidManifestPayloadError extends GuildAutomationError { + constructor() { + super({ + message: 'Manifest payload is invalid', + code: GUILD_AUTOMATION_ERROR_CODES.GUILD_AUTOMATION_INVALID_MANIFEST_PAYLOAD, + retryable: false, + }) + } +} + +export class GuildAutomationManifestNotFoundError extends GuildAutomationError { + constructor(guildId: string) { + super({ + message: 'No automation manifest found for this guild', + code: GUILD_AUTOMATION_ERROR_CODES.GUILD_AUTOMATION_MANIFEST_NOT_FOUND, + retryable: false, + context: { guildId }, + }) + } +} + +export class GuildAutomationCaptureRequiredError extends GuildAutomationError { + constructor(guildId: string) { + super({ + message: 'No captured guild state available. Run capture before plan/apply.', + code: GUILD_AUTOMATION_ERROR_CODES.GUILD_AUTOMATION_CAPTURE_REQUIRED, + retryable: false, + context: { guildId }, + }) + } +} + +export class GuildAutomationApplyLockedError extends GuildAutomationError { + constructor(guildId: string) { + super({ + message: 'Another automation apply operation is already running', + code: GUILD_AUTOMATION_ERROR_CODES.GUILD_AUTOMATION_APPLY_LOCKED, + retryable: true, + context: { guildId }, + }) + } +} + +export class GuildAutomationLockUnavailableError extends GuildAutomationError { + constructor(guildId: string) { + super({ + message: 'Guild automation lock backend is unavailable', + code: GUILD_AUTOMATION_ERROR_CODES.GUILD_AUTOMATION_LOCK_UNAVAILABLE, + retryable: true, + context: { guildId }, + }) + } +} diff --git a/packages/shared/src/types/errors/index.ts b/packages/shared/src/types/errors/index.ts index 5c82b5db8..21b28cf62 100644 --- a/packages/shared/src/types/errors/index.ts +++ b/packages/shared/src/types/errors/index.ts @@ -7,3 +7,4 @@ export * from './database' export * from './validation' export * from './system' export * from './custom' +export * from './guildAutomation'