Implement self-role panel management with BullMQ integration
- Introduced a new `selfroles` queue to handle self-role panel creation, updates, and deletions via BullMQ jobs. - Added types for self-role panel jobs, enhancing type safety and clarity in job handling. - Implemented functions to create, update, and delete self-role panels, ensuring synchronization between the WebUI and Discord. - Enhanced error handling for self-role panel operations, providing better feedback in case of failures. - Updated the WebUI API to support self-role panel management, including error responses for synchronization issues.
This commit is contained in:
@@ -12,7 +12,13 @@ import { activateLockdown, deactivateLockdown } from './modules/automod/actions.
|
||||
import { completeVerification } from './modules/verification/handlers.js';
|
||||
import { closePoll } from './modules/utility/poll.js';
|
||||
import { sendReminder } from './modules/utility/reminders.js';
|
||||
import { expireSelfRole } from './modules/selfroles/service.js';
|
||||
import {
|
||||
expireSelfRole,
|
||||
runSelfRolePanelCreateJob,
|
||||
runSelfRolePanelUpdateJob,
|
||||
runSelfRolePanelDeleteJob
|
||||
} from './modules/selfroles/index.js';
|
||||
import type { SelfRoleBehavior, SelfRoleEntry, SelfRoleMode } from '@nexumi/shared';
|
||||
import {
|
||||
endGiveaway,
|
||||
runGiveawayCreateJob,
|
||||
@@ -48,6 +54,7 @@ import {
|
||||
retentionQueue,
|
||||
retentionQueueName,
|
||||
scheduleQueueName,
|
||||
selfrolesQueueName,
|
||||
suggestionsQueueName,
|
||||
ticketQueue,
|
||||
ticketQueueName,
|
||||
@@ -132,6 +139,36 @@ type SuggestionStatusUpdateJob = {
|
||||
reason: string;
|
||||
};
|
||||
|
||||
type SelfRolePanelCreateJob = {
|
||||
guildId: string;
|
||||
channelId: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
mode: SelfRoleMode;
|
||||
behavior: SelfRoleBehavior;
|
||||
roles: SelfRoleEntry[];
|
||||
temporaryDurationMs?: number;
|
||||
expiresAt?: string | null;
|
||||
};
|
||||
|
||||
type SelfRolePanelUpdateJob = {
|
||||
panelId: string;
|
||||
guildId: string;
|
||||
channelId?: string;
|
||||
title?: string;
|
||||
description?: string | null;
|
||||
mode?: SelfRoleMode;
|
||||
behavior?: SelfRoleBehavior;
|
||||
roles?: SelfRoleEntry[];
|
||||
temporaryDurationMs?: number;
|
||||
expiresAt?: string | null;
|
||||
};
|
||||
|
||||
type SelfRolePanelDeleteJob = {
|
||||
panelId: string;
|
||||
guildId: string;
|
||||
};
|
||||
|
||||
export function startWorkers(context: BotContext): Worker[] {
|
||||
const moderationWorker = new Worker<TempBanJob>(
|
||||
moderationQueueName,
|
||||
@@ -316,6 +353,29 @@ export function startWorkers(context: BotContext): Worker[] {
|
||||
logger.error({ jobId: job?.id, error }, 'Ticket queue job failed');
|
||||
});
|
||||
|
||||
const selfrolesWorker = new Worker<
|
||||
SelfRolePanelCreateJob | SelfRolePanelUpdateJob | SelfRolePanelDeleteJob
|
||||
>(
|
||||
selfrolesQueueName,
|
||||
async (job) => {
|
||||
if (job.name === 'selfRolePanelCreate') {
|
||||
return runSelfRolePanelCreateJob(context, job.data as SelfRolePanelCreateJob);
|
||||
}
|
||||
if (job.name === 'selfRolePanelUpdate') {
|
||||
return runSelfRolePanelUpdateJob(context, job.data as SelfRolePanelUpdateJob);
|
||||
}
|
||||
if (job.name === 'selfRolePanelDelete') {
|
||||
const data = job.data as SelfRolePanelDeleteJob;
|
||||
await runSelfRolePanelDeleteJob(context, data.panelId, data.guildId);
|
||||
}
|
||||
},
|
||||
{ connection: redis }
|
||||
);
|
||||
|
||||
selfrolesWorker.on('failed', (job, error) => {
|
||||
logger.error({ jobId: job?.id, error }, 'Self-roles queue job failed');
|
||||
});
|
||||
|
||||
const giveawayWorker = new Worker<
|
||||
GiveawayEndJob | GiveawayCreateJob | GiveawayPauseJob | GiveawayDeleteJob
|
||||
>(
|
||||
@@ -489,6 +549,7 @@ export function startWorkers(context: BotContext): Worker[] {
|
||||
verificationWorker,
|
||||
reminderWorker,
|
||||
ticketWorker,
|
||||
selfrolesWorker,
|
||||
giveawayWorker,
|
||||
birthdayWorker,
|
||||
statsWorker,
|
||||
|
||||
@@ -4,5 +4,9 @@ export { registerSelfRoleEvents } from './reactions.js';
|
||||
export {
|
||||
expireSelfRole,
|
||||
scheduleSelfRoleExpiry,
|
||||
cancelSelfRoleExpiry
|
||||
cancelSelfRoleExpiry,
|
||||
runSelfRolePanelCreateJob,
|
||||
runSelfRolePanelUpdateJob,
|
||||
runSelfRolePanelDeleteJob,
|
||||
SelfRoleError
|
||||
} from './service.js';
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from '@nexumi/shared';
|
||||
import type { SelfRolePanel } from '@prisma/client';
|
||||
import { ensureGuild } from '../../guild.js';
|
||||
import { getGuildLocale } from '../../i18n.js';
|
||||
import { logger } from '../../logger.js';
|
||||
import { safeRemoveBullJob } from '../../lib/safe-remove-job.js';
|
||||
import { ticketQueue } from '../../queues.js';
|
||||
@@ -422,6 +423,69 @@ export async function findPanelByMessageId(
|
||||
});
|
||||
}
|
||||
|
||||
async function postOrEditPanelMessage(
|
||||
context: BotContext,
|
||||
panel: SelfRolePanel,
|
||||
locale: 'de' | 'en',
|
||||
previous?: { channelId: string; messageId: string | null }
|
||||
): Promise<SelfRolePanel> {
|
||||
const mode = SelfRoleModeSchema.parse(panel.mode);
|
||||
const { entries } = parseRolesJson(panel.roles);
|
||||
const channelChanged =
|
||||
previous !== undefined && previous.channelId !== panel.channelId;
|
||||
|
||||
if (panel.messageId && !channelChanged) {
|
||||
try {
|
||||
const channel = (await context.client.channels.fetch(panel.channelId)) as TextChannel;
|
||||
const message = await channel.messages.fetch(panel.messageId);
|
||||
await message.edit({
|
||||
embeds: [buildPanelEmbed(panel, locale)],
|
||||
components: buildPanelComponents(panel)
|
||||
});
|
||||
|
||||
if (mode === 'REACTIONS') {
|
||||
for (const reaction of message.reactions.cache.values()) {
|
||||
await reaction.remove().catch(() => undefined);
|
||||
}
|
||||
await applyReactionEmojis(message, entries);
|
||||
}
|
||||
|
||||
return panel;
|
||||
} catch (error) {
|
||||
logger.warn({ error, panelId: panel.id }, 'Failed to edit self-role panel message; recreating');
|
||||
}
|
||||
}
|
||||
|
||||
if (previous?.messageId && channelChanged) {
|
||||
try {
|
||||
const oldChannel = (await context.client.channels.fetch(previous.channelId)) as TextChannel;
|
||||
const oldMessage = await oldChannel.messages.fetch(previous.messageId);
|
||||
await oldMessage.delete();
|
||||
} catch {
|
||||
// Previous message may already be gone
|
||||
}
|
||||
}
|
||||
|
||||
const channel = await context.client.channels.fetch(panel.channelId);
|
||||
if (!channel || !channel.isTextBased() || channel.isDMBased()) {
|
||||
throw new SelfRoleError('invalid_channel');
|
||||
}
|
||||
|
||||
const message = await channel.send({
|
||||
embeds: [buildPanelEmbed(panel, locale)],
|
||||
components: buildPanelComponents(panel)
|
||||
});
|
||||
|
||||
if (mode === 'REACTIONS') {
|
||||
await applyReactionEmojis(message, entries);
|
||||
}
|
||||
|
||||
return context.prisma.selfRolePanel.update({
|
||||
where: { id: panel.id },
|
||||
data: { messageId: message.id }
|
||||
});
|
||||
}
|
||||
|
||||
export async function createSelfRolePanel(
|
||||
context: BotContext,
|
||||
params: {
|
||||
@@ -433,6 +497,7 @@ export async function createSelfRolePanel(
|
||||
behavior: SelfRoleBehavior;
|
||||
roles: SelfRoleEntry[];
|
||||
temporaryDurationMs?: number;
|
||||
expiresAt?: Date | null;
|
||||
},
|
||||
locale: 'de' | 'en'
|
||||
): Promise<SelfRolePanel> {
|
||||
@@ -457,24 +522,12 @@ export async function createSelfRolePanel(
|
||||
description: params.description ?? null,
|
||||
mode: params.mode,
|
||||
behavior: params.behavior,
|
||||
roles: serializeRolesJson(params.roles, params.temporaryDurationMs)
|
||||
roles: serializeRolesJson(params.roles, params.temporaryDurationMs),
|
||||
expiresAt: params.expiresAt ?? null
|
||||
}
|
||||
});
|
||||
|
||||
const channel = (await context.client.channels.fetch(params.channelId)) as TextChannel;
|
||||
const message = await channel.send({
|
||||
embeds: [buildPanelEmbed(panel, locale)],
|
||||
components: buildPanelComponents(panel)
|
||||
});
|
||||
|
||||
if (params.mode === 'REACTIONS') {
|
||||
await applyReactionEmojis(message, params.roles);
|
||||
}
|
||||
|
||||
return context.prisma.selfRolePanel.update({
|
||||
where: { id: panel.id },
|
||||
data: { messageId: message.id }
|
||||
});
|
||||
return postOrEditPanelMessage(context, panel, locale);
|
||||
}
|
||||
|
||||
export async function updateSelfRolePanel(
|
||||
@@ -482,12 +535,14 @@ export async function updateSelfRolePanel(
|
||||
panelId: string,
|
||||
guildId: string,
|
||||
updates: {
|
||||
channelId?: string;
|
||||
title?: string;
|
||||
description?: string | null;
|
||||
mode?: SelfRoleMode;
|
||||
behavior?: SelfRoleBehavior;
|
||||
roles?: SelfRoleEntry[];
|
||||
temporaryDurationMs?: number;
|
||||
expiresAt?: Date | null;
|
||||
},
|
||||
locale: 'de' | 'en'
|
||||
): Promise<SelfRolePanel> {
|
||||
@@ -513,39 +568,100 @@ export async function updateSelfRolePanel(
|
||||
throw new SelfRoleError('reaction_emoji_required');
|
||||
}
|
||||
|
||||
const previous = { channelId: existing.channelId, messageId: existing.messageId };
|
||||
const panel = await context.prisma.selfRolePanel.update({
|
||||
where: { id: panelId },
|
||||
data: {
|
||||
channelId: updates.channelId ?? existing.channelId,
|
||||
title: updates.title ?? existing.title,
|
||||
description: updates.description !== undefined ? updates.description : existing.description,
|
||||
mode,
|
||||
behavior,
|
||||
roles: serializeRolesJson(roles, temporaryDurationMs)
|
||||
roles: serializeRolesJson(roles, temporaryDurationMs),
|
||||
...(updates.expiresAt !== undefined ? { expiresAt: updates.expiresAt } : {})
|
||||
}
|
||||
});
|
||||
|
||||
if (panel.messageId) {
|
||||
try {
|
||||
const channel = (await context.client.channels.fetch(panel.channelId)) as TextChannel;
|
||||
const message = await channel.messages.fetch(panel.messageId);
|
||||
await message.edit({
|
||||
embeds: [buildPanelEmbed(panel, locale)],
|
||||
components: buildPanelComponents(panel)
|
||||
});
|
||||
return postOrEditPanelMessage(context, panel, locale, previous);
|
||||
}
|
||||
|
||||
if (mode === 'REACTIONS') {
|
||||
const existingReactions = message.reactions.cache;
|
||||
for (const reaction of existingReactions.values()) {
|
||||
await reaction.remove().catch(() => undefined);
|
||||
}
|
||||
await applyReactionEmojis(message, roles);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn({ error, panelId }, 'Failed to update self-role panel message');
|
||||
}
|
||||
/**
|
||||
* BullMQ entry points for dashboard create/update/delete. The WebUI has no
|
||||
* discord.js Client, so panel posting is delegated here (same pattern as
|
||||
* giveaways / dashboard messages).
|
||||
*/
|
||||
export async function runSelfRolePanelCreateJob(
|
||||
context: BotContext,
|
||||
params: {
|
||||
guildId: string;
|
||||
channelId: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
mode: SelfRoleMode;
|
||||
behavior: SelfRoleBehavior;
|
||||
roles: SelfRoleEntry[];
|
||||
temporaryDurationMs?: number;
|
||||
expiresAt?: string | null;
|
||||
}
|
||||
): Promise<{ id: string }> {
|
||||
const locale = await getGuildLocale(context.prisma, params.guildId);
|
||||
const panel = await createSelfRolePanel(
|
||||
context,
|
||||
{
|
||||
...params,
|
||||
expiresAt: params.expiresAt ? new Date(params.expiresAt) : null
|
||||
},
|
||||
locale
|
||||
);
|
||||
return { id: panel.id };
|
||||
}
|
||||
|
||||
return panel;
|
||||
export async function runSelfRolePanelUpdateJob(
|
||||
context: BotContext,
|
||||
params: {
|
||||
panelId: string;
|
||||
guildId: string;
|
||||
channelId?: string;
|
||||
title?: string;
|
||||
description?: string | null;
|
||||
mode?: SelfRoleMode;
|
||||
behavior?: SelfRoleBehavior;
|
||||
roles?: SelfRoleEntry[];
|
||||
temporaryDurationMs?: number;
|
||||
expiresAt?: string | null;
|
||||
}
|
||||
): Promise<{ id: string }> {
|
||||
const locale = await getGuildLocale(context.prisma, params.guildId);
|
||||
const panel = await updateSelfRolePanel(
|
||||
context,
|
||||
params.panelId,
|
||||
params.guildId,
|
||||
{
|
||||
channelId: params.channelId,
|
||||
title: params.title,
|
||||
description: params.description,
|
||||
mode: params.mode,
|
||||
behavior: params.behavior,
|
||||
roles: params.roles,
|
||||
temporaryDurationMs: params.temporaryDurationMs,
|
||||
expiresAt:
|
||||
params.expiresAt === undefined
|
||||
? undefined
|
||||
: params.expiresAt
|
||||
? new Date(params.expiresAt)
|
||||
: null
|
||||
},
|
||||
locale
|
||||
);
|
||||
return { id: panel.id };
|
||||
}
|
||||
|
||||
export async function runSelfRolePanelDeleteJob(
|
||||
context: BotContext,
|
||||
panelId: string,
|
||||
guildId: string
|
||||
): Promise<void> {
|
||||
await deleteSelfRolePanel(context, panelId, guildId);
|
||||
}
|
||||
|
||||
export async function deleteSelfRolePanel(
|
||||
|
||||
@@ -15,6 +15,8 @@ export const giveawayQueueName = 'giveaways';
|
||||
export const giveawayQueue = new Queue(giveawayQueueName, { connection: redis });
|
||||
export const ticketQueueName = 'tickets';
|
||||
export const ticketQueue = new Queue(ticketQueueName, { connection: redis });
|
||||
export const selfrolesQueueName = 'selfroles';
|
||||
export const selfrolesQueue = new Queue(selfrolesQueueName, { connection: redis });
|
||||
export const birthdayQueueName = 'birthdays';
|
||||
export const birthdayQueue = new Queue(birthdayQueueName, { connection: redis });
|
||||
export const statsQueueName = 'stats';
|
||||
|
||||
Reference in New Issue
Block a user