deploy #2
@@ -12,7 +12,13 @@ import { activateLockdown, deactivateLockdown } from './modules/automod/actions.
|
|||||||
import { completeVerification } from './modules/verification/handlers.js';
|
import { completeVerification } from './modules/verification/handlers.js';
|
||||||
import { closePoll } from './modules/utility/poll.js';
|
import { closePoll } from './modules/utility/poll.js';
|
||||||
import { sendReminder } from './modules/utility/reminders.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 {
|
import {
|
||||||
endGiveaway,
|
endGiveaway,
|
||||||
runGiveawayCreateJob,
|
runGiveawayCreateJob,
|
||||||
@@ -48,6 +54,7 @@ import {
|
|||||||
retentionQueue,
|
retentionQueue,
|
||||||
retentionQueueName,
|
retentionQueueName,
|
||||||
scheduleQueueName,
|
scheduleQueueName,
|
||||||
|
selfrolesQueueName,
|
||||||
suggestionsQueueName,
|
suggestionsQueueName,
|
||||||
ticketQueue,
|
ticketQueue,
|
||||||
ticketQueueName,
|
ticketQueueName,
|
||||||
@@ -132,6 +139,36 @@ type SuggestionStatusUpdateJob = {
|
|||||||
reason: string;
|
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[] {
|
export function startWorkers(context: BotContext): Worker[] {
|
||||||
const moderationWorker = new Worker<TempBanJob>(
|
const moderationWorker = new Worker<TempBanJob>(
|
||||||
moderationQueueName,
|
moderationQueueName,
|
||||||
@@ -316,6 +353,29 @@ export function startWorkers(context: BotContext): Worker[] {
|
|||||||
logger.error({ jobId: job?.id, error }, 'Ticket queue job failed');
|
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<
|
const giveawayWorker = new Worker<
|
||||||
GiveawayEndJob | GiveawayCreateJob | GiveawayPauseJob | GiveawayDeleteJob
|
GiveawayEndJob | GiveawayCreateJob | GiveawayPauseJob | GiveawayDeleteJob
|
||||||
>(
|
>(
|
||||||
@@ -489,6 +549,7 @@ export function startWorkers(context: BotContext): Worker[] {
|
|||||||
verificationWorker,
|
verificationWorker,
|
||||||
reminderWorker,
|
reminderWorker,
|
||||||
ticketWorker,
|
ticketWorker,
|
||||||
|
selfrolesWorker,
|
||||||
giveawayWorker,
|
giveawayWorker,
|
||||||
birthdayWorker,
|
birthdayWorker,
|
||||||
statsWorker,
|
statsWorker,
|
||||||
|
|||||||
@@ -4,5 +4,9 @@ export { registerSelfRoleEvents } from './reactions.js';
|
|||||||
export {
|
export {
|
||||||
expireSelfRole,
|
expireSelfRole,
|
||||||
scheduleSelfRoleExpiry,
|
scheduleSelfRoleExpiry,
|
||||||
cancelSelfRoleExpiry
|
cancelSelfRoleExpiry,
|
||||||
|
runSelfRolePanelCreateJob,
|
||||||
|
runSelfRolePanelUpdateJob,
|
||||||
|
runSelfRolePanelDeleteJob,
|
||||||
|
SelfRoleError
|
||||||
} from './service.js';
|
} from './service.js';
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
} from '@nexumi/shared';
|
} from '@nexumi/shared';
|
||||||
import type { SelfRolePanel } from '@prisma/client';
|
import type { SelfRolePanel } from '@prisma/client';
|
||||||
import { ensureGuild } from '../../guild.js';
|
import { ensureGuild } from '../../guild.js';
|
||||||
|
import { getGuildLocale } from '../../i18n.js';
|
||||||
import { logger } from '../../logger.js';
|
import { logger } from '../../logger.js';
|
||||||
import { safeRemoveBullJob } from '../../lib/safe-remove-job.js';
|
import { safeRemoveBullJob } from '../../lib/safe-remove-job.js';
|
||||||
import { ticketQueue } from '../../queues.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(
|
export async function createSelfRolePanel(
|
||||||
context: BotContext,
|
context: BotContext,
|
||||||
params: {
|
params: {
|
||||||
@@ -433,6 +497,7 @@ export async function createSelfRolePanel(
|
|||||||
behavior: SelfRoleBehavior;
|
behavior: SelfRoleBehavior;
|
||||||
roles: SelfRoleEntry[];
|
roles: SelfRoleEntry[];
|
||||||
temporaryDurationMs?: number;
|
temporaryDurationMs?: number;
|
||||||
|
expiresAt?: Date | null;
|
||||||
},
|
},
|
||||||
locale: 'de' | 'en'
|
locale: 'de' | 'en'
|
||||||
): Promise<SelfRolePanel> {
|
): Promise<SelfRolePanel> {
|
||||||
@@ -457,24 +522,12 @@ export async function createSelfRolePanel(
|
|||||||
description: params.description ?? null,
|
description: params.description ?? null,
|
||||||
mode: params.mode,
|
mode: params.mode,
|
||||||
behavior: params.behavior,
|
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;
|
return postOrEditPanelMessage(context, panel, locale);
|
||||||
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 }
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateSelfRolePanel(
|
export async function updateSelfRolePanel(
|
||||||
@@ -482,12 +535,14 @@ export async function updateSelfRolePanel(
|
|||||||
panelId: string,
|
panelId: string,
|
||||||
guildId: string,
|
guildId: string,
|
||||||
updates: {
|
updates: {
|
||||||
|
channelId?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
mode?: SelfRoleMode;
|
mode?: SelfRoleMode;
|
||||||
behavior?: SelfRoleBehavior;
|
behavior?: SelfRoleBehavior;
|
||||||
roles?: SelfRoleEntry[];
|
roles?: SelfRoleEntry[];
|
||||||
temporaryDurationMs?: number;
|
temporaryDurationMs?: number;
|
||||||
|
expiresAt?: Date | null;
|
||||||
},
|
},
|
||||||
locale: 'de' | 'en'
|
locale: 'de' | 'en'
|
||||||
): Promise<SelfRolePanel> {
|
): Promise<SelfRolePanel> {
|
||||||
@@ -513,39 +568,100 @@ export async function updateSelfRolePanel(
|
|||||||
throw new SelfRoleError('reaction_emoji_required');
|
throw new SelfRoleError('reaction_emoji_required');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const previous = { channelId: existing.channelId, messageId: existing.messageId };
|
||||||
const panel = await context.prisma.selfRolePanel.update({
|
const panel = await context.prisma.selfRolePanel.update({
|
||||||
where: { id: panelId },
|
where: { id: panelId },
|
||||||
data: {
|
data: {
|
||||||
|
channelId: updates.channelId ?? existing.channelId,
|
||||||
title: updates.title ?? existing.title,
|
title: updates.title ?? existing.title,
|
||||||
description: updates.description !== undefined ? updates.description : existing.description,
|
description: updates.description !== undefined ? updates.description : existing.description,
|
||||||
mode,
|
mode,
|
||||||
behavior,
|
behavior,
|
||||||
roles: serializeRolesJson(roles, temporaryDurationMs)
|
roles: serializeRolesJson(roles, temporaryDurationMs),
|
||||||
|
...(updates.expiresAt !== undefined ? { expiresAt: updates.expiresAt } : {})
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (panel.messageId) {
|
return postOrEditPanelMessage(context, panel, locale, previous);
|
||||||
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') {
|
/**
|
||||||
const existingReactions = message.reactions.cache;
|
* BullMQ entry points for dashboard create/update/delete. The WebUI has no
|
||||||
for (const reaction of existingReactions.values()) {
|
* discord.js Client, so panel posting is delegated here (same pattern as
|
||||||
await reaction.remove().catch(() => undefined);
|
* giveaways / dashboard messages).
|
||||||
}
|
*/
|
||||||
await applyReactionEmojis(message, roles);
|
export async function runSelfRolePanelCreateJob(
|
||||||
}
|
context: BotContext,
|
||||||
} catch (error) {
|
params: {
|
||||||
logger.warn({ error, panelId }, 'Failed to update self-role panel message');
|
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(
|
export async function deleteSelfRolePanel(
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export const giveawayQueueName = 'giveaways';
|
|||||||
export const giveawayQueue = new Queue(giveawayQueueName, { connection: redis });
|
export const giveawayQueue = new Queue(giveawayQueueName, { connection: redis });
|
||||||
export const ticketQueueName = 'tickets';
|
export const ticketQueueName = 'tickets';
|
||||||
export const ticketQueue = new Queue(ticketQueueName, { connection: redis });
|
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 birthdayQueueName = 'birthdays';
|
||||||
export const birthdayQueue = new Queue(birthdayQueueName, { connection: redis });
|
export const birthdayQueue = new Queue(birthdayQueueName, { connection: redis });
|
||||||
export const statsQueueName = 'stats';
|
export const statsQueueName = 'stats';
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import { SelfRolePanelDashboardUpdateSchema } from '@nexumi/shared';
|
|||||||
import { type NextRequest, NextResponse } from 'next/server';
|
import { type NextRequest, NextResponse } from 'next/server';
|
||||||
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
|
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
|
||||||
import { writeDashboardAudit } from '@/lib/audit';
|
import { writeDashboardAudit } from '@/lib/audit';
|
||||||
import { deleteSelfRolePanel, updateSelfRolePanel } from '@/lib/module-configs/selfroles';
|
import {
|
||||||
|
deleteSelfRolePanel,
|
||||||
|
SelfRolePanelSyncError,
|
||||||
|
updateSelfRolePanel
|
||||||
|
} from '@/lib/module-configs/selfroles';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
interface RouteParams {
|
interface RouteParams {
|
||||||
@@ -31,6 +35,9 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
|||||||
|
|
||||||
return NextResponse.json(updated);
|
return NextResponse.json(updated);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof SelfRolePanelSyncError) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 504 });
|
||||||
|
}
|
||||||
return toApiErrorResponse(error);
|
return toApiErrorResponse(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -54,6 +61,9 @@ export async function DELETE(_request: NextRequest, { params }: RouteParams) {
|
|||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof SelfRolePanelSyncError) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 504 });
|
||||||
|
}
|
||||||
return toApiErrorResponse(error);
|
return toApiErrorResponse(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import { SelfRolePanelDashboardCreateSchema } from '@nexumi/shared';
|
|||||||
import { type NextRequest, NextResponse } from 'next/server';
|
import { type NextRequest, NextResponse } from 'next/server';
|
||||||
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
|
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
|
||||||
import { writeDashboardAudit } from '@/lib/audit';
|
import { writeDashboardAudit } from '@/lib/audit';
|
||||||
import { createSelfRolePanel, listSelfRolePanels } from '@/lib/module-configs/selfroles';
|
import {
|
||||||
|
createSelfRolePanel,
|
||||||
|
listSelfRolePanels,
|
||||||
|
SelfRolePanelSyncError
|
||||||
|
} from '@/lib/module-configs/selfroles';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
interface RouteParams {
|
interface RouteParams {
|
||||||
@@ -39,6 +43,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
|
|
||||||
return NextResponse.json(panel, { status: 201 });
|
return NextResponse.json(panel, { status: 201 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof SelfRolePanelSyncError) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 504 });
|
||||||
|
}
|
||||||
return toApiErrorResponse(error);
|
return toApiErrorResponse(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,17 +4,30 @@ import type {
|
|||||||
SelfRolePanelDashboardCreate,
|
SelfRolePanelDashboardCreate,
|
||||||
SelfRolePanelDashboardUpdate
|
SelfRolePanelDashboardUpdate
|
||||||
} from '@nexumi/shared';
|
} from '@nexumi/shared';
|
||||||
import type { Prisma, SelfRolePanel } from '@prisma/client';
|
import type { SelfRolePanel } from '@prisma/client';
|
||||||
import { prisma } from '../prisma';
|
import { prisma } from '../prisma';
|
||||||
|
import { addJobAndAwait, getSelfrolesQueue, getSelfrolesQueueEvents } from '../queues';
|
||||||
|
|
||||||
|
export class SelfRolePanelSyncError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'SelfRolePanelSyncError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function toRolesArray(raw: unknown): SelfRoleEntry[] {
|
function toRolesArray(raw: unknown): SelfRoleEntry[] {
|
||||||
if (!Array.isArray(raw)) {
|
if (Array.isArray(raw)) {
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return raw.filter(
|
return raw.filter(
|
||||||
(entry): entry is SelfRoleEntry =>
|
(entry): entry is SelfRoleEntry =>
|
||||||
Boolean(entry) && typeof entry === 'object' && typeof (entry as SelfRoleEntry).roleId === 'string'
|
Boolean(entry) && typeof entry === 'object' && typeof (entry as SelfRoleEntry).roleId === 'string'
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw && typeof raw === 'object' && Array.isArray((raw as { entries?: unknown }).entries)) {
|
||||||
|
return toRolesArray((raw as { entries: unknown }).entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function toDashboard(panel: SelfRolePanel): SelfRolePanelDashboard {
|
function toDashboard(panel: SelfRolePanel): SelfRolePanelDashboard {
|
||||||
@@ -30,6 +43,14 @@ function toDashboard(panel: SelfRolePanel): SelfRolePanelDashboard {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function temporaryDurationFromExpiresAt(expiresAt: string | null | undefined): number | undefined {
|
||||||
|
if (!expiresAt) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const ms = new Date(expiresAt).getTime() - Date.now();
|
||||||
|
return ms > 0 ? ms : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export async function listSelfRolePanels(guildId: string): Promise<SelfRolePanelDashboard[]> {
|
export async function listSelfRolePanels(guildId: string): Promise<SelfRolePanelDashboard[]> {
|
||||||
const panels = await prisma.selfRolePanel.findMany({
|
const panels = await prisma.selfRolePanel.findMany({
|
||||||
where: { guildId },
|
where: { guildId },
|
||||||
@@ -38,23 +59,57 @@ export async function listSelfRolePanels(guildId: string): Promise<SelfRolePanel
|
|||||||
return panels.map(toDashboard);
|
return panels.map(toDashboard);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboard create has no discord.js Client, so panel posting is delegated to
|
||||||
|
* the bot via the `selfroles` BullMQ queue (`selfRolePanelCreate`).
|
||||||
|
*/
|
||||||
export async function createSelfRolePanel(
|
export async function createSelfRolePanel(
|
||||||
guildId: string,
|
guildId: string,
|
||||||
input: SelfRolePanelDashboardCreate
|
input: SelfRolePanelDashboardCreate
|
||||||
): Promise<SelfRolePanelDashboard> {
|
): Promise<SelfRolePanelDashboard> {
|
||||||
await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
|
await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
|
||||||
const panel = await prisma.selfRolePanel.create({
|
|
||||||
data: {
|
const temporaryDurationMs =
|
||||||
|
input.behavior === 'TEMPORARY' ? temporaryDurationFromExpiresAt(input.expiresAt) : undefined;
|
||||||
|
if (input.behavior === 'TEMPORARY' && (!temporaryDurationMs || temporaryDurationMs < 60_000)) {
|
||||||
|
throw new SelfRolePanelSyncError(
|
||||||
|
'Temporary panels require expiresAt at least 1 minute in the future (role duration).'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { confirmed, result } = await addJobAndAwait<{ id: string }>(
|
||||||
|
getSelfrolesQueue(),
|
||||||
|
getSelfrolesQueueEvents(),
|
||||||
|
'selfRolePanelCreate',
|
||||||
|
{
|
||||||
guildId,
|
guildId,
|
||||||
channelId: input.channelId,
|
channelId: input.channelId,
|
||||||
title: input.title,
|
title: input.title,
|
||||||
description: input.description ?? null,
|
description: input.description ?? null,
|
||||||
mode: input.mode,
|
mode: input.mode,
|
||||||
behavior: input.behavior,
|
behavior: input.behavior,
|
||||||
roles: input.roles as unknown as Prisma.InputJsonValue,
|
roles: input.roles,
|
||||||
expiresAt: input.expiresAt ? new Date(input.expiresAt) : null
|
temporaryDurationMs,
|
||||||
|
expiresAt: input.expiresAt ?? null
|
||||||
|
},
|
||||||
|
{ removeOnComplete: true, removeOnFail: 25 },
|
||||||
|
30_000
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!confirmed || !result) {
|
||||||
|
throw new SelfRolePanelSyncError(
|
||||||
|
'The bot did not confirm the self-role panel in time. It may still be posting — check the channel shortly.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const panel = await prisma.selfRolePanel.findUnique({ where: { id: result.id } });
|
||||||
|
if (!panel?.messageId) {
|
||||||
|
throw new SelfRolePanelSyncError(
|
||||||
|
confirmed
|
||||||
|
? 'Self-role panel job finished without posting a Discord message'
|
||||||
|
: 'Self-role panel create timed out – the bot may still be processing; refresh and retry'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
return toDashboard(panel);
|
return toDashboard(panel);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,24 +123,90 @@ export async function updateSelfRolePanel(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await prisma.selfRolePanel.update({
|
const nextBehavior = patch.behavior ?? (existing.behavior as SelfRolePanelDashboard['behavior']);
|
||||||
where: { id: panelId },
|
const temporaryDurationMs =
|
||||||
data: {
|
nextBehavior === 'TEMPORARY'
|
||||||
...(patch.channelId !== undefined ? { channelId: patch.channelId } : {}),
|
? temporaryDurationFromExpiresAt(
|
||||||
...(patch.title !== undefined ? { title: patch.title } : {}),
|
patch.expiresAt !== undefined ? patch.expiresAt : existing.expiresAt?.toISOString()
|
||||||
...(patch.description !== undefined ? { description: patch.description } : {}),
|
)
|
||||||
...(patch.mode !== undefined ? { mode: patch.mode } : {}),
|
: undefined;
|
||||||
...(patch.behavior !== undefined ? { behavior: patch.behavior } : {}),
|
|
||||||
...(patch.roles !== undefined ? { roles: patch.roles as unknown as Prisma.InputJsonValue } : {}),
|
if (nextBehavior === 'TEMPORARY' && patch.behavior === 'TEMPORARY') {
|
||||||
...(patch.expiresAt !== undefined
|
const storedDuration =
|
||||||
? { expiresAt: patch.expiresAt ? new Date(patch.expiresAt) : null }
|
existing.roles &&
|
||||||
: {})
|
typeof existing.roles === 'object' &&
|
||||||
|
!Array.isArray(existing.roles) &&
|
||||||
|
typeof (existing.roles as { temporaryDurationMs?: unknown }).temporaryDurationMs === 'number'
|
||||||
|
? (existing.roles as { temporaryDurationMs: number }).temporaryDurationMs
|
||||||
|
: undefined;
|
||||||
|
if (!temporaryDurationMs && !storedDuration) {
|
||||||
|
throw new SelfRolePanelSyncError(
|
||||||
|
'Temporary panels require expiresAt at least 1 minute in the future (role duration).'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { confirmed } = await addJobAndAwait(
|
||||||
|
getSelfrolesQueue(),
|
||||||
|
getSelfrolesQueueEvents(),
|
||||||
|
'selfRolePanelUpdate',
|
||||||
|
{
|
||||||
|
panelId,
|
||||||
|
guildId,
|
||||||
|
channelId: patch.channelId,
|
||||||
|
title: patch.title,
|
||||||
|
description: patch.description,
|
||||||
|
mode: patch.mode,
|
||||||
|
behavior: patch.behavior,
|
||||||
|
roles: patch.roles,
|
||||||
|
temporaryDurationMs,
|
||||||
|
expiresAt: patch.expiresAt
|
||||||
|
},
|
||||||
|
{
|
||||||
|
jobId: `selfrole-update-${panelId}-${Date.now()}`,
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: 25
|
||||||
|
},
|
||||||
|
30_000
|
||||||
|
);
|
||||||
|
|
||||||
|
const updated = await prisma.selfRolePanel.findUnique({ where: { id: panelId } });
|
||||||
|
if (!updated?.messageId) {
|
||||||
|
throw new SelfRolePanelSyncError(
|
||||||
|
confirmed
|
||||||
|
? 'Self-role panel update finished without a Discord message'
|
||||||
|
: 'Self-role panel update timed out – the bot may still be processing; refresh and retry'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
return toDashboard(updated);
|
return toDashboard(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteSelfRolePanel(guildId: string, panelId: string): Promise<boolean> {
|
export async function deleteSelfRolePanel(guildId: string, panelId: string): Promise<boolean> {
|
||||||
const result = await prisma.selfRolePanel.deleteMany({ where: { id: panelId, guildId } });
|
const existing = await prisma.selfRolePanel.findFirst({ where: { id: panelId, guildId } });
|
||||||
return result.count > 0;
|
if (!existing) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { confirmed } = await addJobAndAwait(
|
||||||
|
getSelfrolesQueue(),
|
||||||
|
getSelfrolesQueueEvents(),
|
||||||
|
'selfRolePanelDelete',
|
||||||
|
{ panelId, guildId },
|
||||||
|
{
|
||||||
|
jobId: `selfrole-delete-${panelId}-${Date.now()}`,
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: 25
|
||||||
|
},
|
||||||
|
30_000
|
||||||
|
);
|
||||||
|
|
||||||
|
const remaining = await prisma.selfRolePanel.findUnique({ where: { id: panelId } });
|
||||||
|
if (remaining) {
|
||||||
|
throw new SelfRolePanelSyncError(
|
||||||
|
confirmed
|
||||||
|
? 'Self-role panel delete finished without deleting the panel'
|
||||||
|
: 'Self-role panel delete timed out – the bot may still be processing; refresh and retry'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const messagesQueueName = 'messages';
|
|||||||
export const verificationQueueName = 'verification';
|
export const verificationQueueName = 'verification';
|
||||||
export const automodQueueName = 'automod';
|
export const automodQueueName = 'automod';
|
||||||
export const presenceQueueName = 'presence';
|
export const presenceQueueName = 'presence';
|
||||||
|
export const selfrolesQueueName = 'selfroles';
|
||||||
|
|
||||||
const globalForQueues = globalThis as unknown as {
|
const globalForQueues = globalThis as unknown as {
|
||||||
__nexumiGiveawayQueue?: Queue;
|
__nexumiGiveawayQueue?: Queue;
|
||||||
@@ -32,10 +33,12 @@ const globalForQueues = globalThis as unknown as {
|
|||||||
__nexumiVerificationQueue?: Queue;
|
__nexumiVerificationQueue?: Queue;
|
||||||
__nexumiAutomodQueue?: Queue;
|
__nexumiAutomodQueue?: Queue;
|
||||||
__nexumiPresenceQueue?: Queue;
|
__nexumiPresenceQueue?: Queue;
|
||||||
|
__nexumiSelfrolesQueue?: Queue;
|
||||||
__nexumiGiveawayQueueEvents?: QueueEvents;
|
__nexumiGiveawayQueueEvents?: QueueEvents;
|
||||||
__nexumiGuildBackupQueueEvents?: QueueEvents;
|
__nexumiGuildBackupQueueEvents?: QueueEvents;
|
||||||
__nexumiSuggestionsQueueEvents?: QueueEvents;
|
__nexumiSuggestionsQueueEvents?: QueueEvents;
|
||||||
__nexumiMessagesQueueEvents?: QueueEvents;
|
__nexumiMessagesQueueEvents?: QueueEvents;
|
||||||
|
__nexumiSelfrolesQueueEvents?: QueueEvents;
|
||||||
};
|
};
|
||||||
|
|
||||||
function queueConnection() {
|
function queueConnection() {
|
||||||
@@ -103,6 +106,13 @@ export function getPresenceQueue(): Queue {
|
|||||||
return globalForQueues.__nexumiPresenceQueue;
|
return globalForQueues.__nexumiPresenceQueue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getSelfrolesQueue(): Queue {
|
||||||
|
globalForQueues.__nexumiSelfrolesQueue ??= new Queue(selfrolesQueueName, {
|
||||||
|
connection: queueConnection()
|
||||||
|
});
|
||||||
|
return globalForQueues.__nexumiSelfrolesQueue;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* QueueEvents instances are only needed for jobs where the dashboard needs
|
* QueueEvents instances are only needed for jobs where the dashboard needs
|
||||||
* to wait for the bot to finish (e.g. ending a giveaway so the winners list
|
* to wait for the bot to finish (e.g. ending a giveaway so the winners list
|
||||||
@@ -136,6 +146,13 @@ export function getMessagesQueueEvents(): QueueEvents {
|
|||||||
return globalForQueues.__nexumiMessagesQueueEvents;
|
return globalForQueues.__nexumiMessagesQueueEvents;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getSelfrolesQueueEvents(): QueueEvents {
|
||||||
|
globalForQueues.__nexumiSelfrolesQueueEvents ??= new QueueEvents(selfrolesQueueName, {
|
||||||
|
connection: queueEventsConnection()
|
||||||
|
});
|
||||||
|
return globalForQueues.__nexumiSelfrolesQueueEvents;
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_WAIT_TIMEOUT_MS = 15_000;
|
const DEFAULT_WAIT_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -426,6 +426,24 @@
|
|||||||
- [ ] Cases: Grund editieren / löschen; Warnings entfernen
|
- [ ] Cases: Grund editieren / löschen; Warnings entfernen
|
||||||
- [ ] `/lock server:true` und `/unlock server:true`
|
- [ ] `/lock server:true` und `/unlock server:true`
|
||||||
|
|
||||||
|
## Post-Phase – Selfroles WebUI → Discord Sync (Status: implementiert)
|
||||||
|
|
||||||
|
### Abgeschlossen (Code)
|
||||||
|
|
||||||
|
- Root cause: Dashboard speicherte Selfrole-Panels nur in Postgres, ohne BullMQ-Job an den Bot
|
||||||
|
- Neue Queue `selfroles` mit Jobs `selfRolePanelCreate` / `Update` / `Delete`
|
||||||
|
- WebUI wartet (bounded) auf Bot-Bestätigung; Panel hat danach `messageId`
|
||||||
|
- Update zieht fehlende Discord-Nachricht nach (auch für Alt-Panels ohne `messageId`)
|
||||||
|
- Roles-JSON `{ entries }` wird im Dashboard korrekt gelesen
|
||||||
|
|
||||||
|
### Manuell testen
|
||||||
|
|
||||||
|
- [ ] WebUI: Panel erstellen → Embed/Buttons erscheinen im gewählten Channel
|
||||||
|
- [ ] Panel editieren (Titel/Rollen) → Discord-Nachricht aktualisiert sich
|
||||||
|
- [ ] Channel wechseln → alte Nachricht weg, neue im Zielkanal
|
||||||
|
- [ ] Panel löschen → Discord-Nachricht entfernt
|
||||||
|
- [ ] Alt-Panel ohne Message speichern → Nachricht wird nachgezogen
|
||||||
|
|
||||||
## Nächster geplanter Schritt
|
## Nächster geplanter Schritt
|
||||||
|
|
||||||
- Deploy auf Traefik-Server manuell verifizieren; danach ggf. `deploy` → `main` mergen (nur auf Freigabe).
|
- Deploy auf Traefik-Server manuell verifizieren; danach ggf. `deploy` → `main` mergen (nur auf Freigabe).
|
||||||
|
|||||||
Reference in New Issue
Block a user