Add stats, feeds, scheduler, and guild backup features to the bot

- Introduced new models in the Prisma schema for statistics, social feeds, scheduled messages, and guild backups, enhancing the bot's functionality.
- Implemented command modules for managing stats channels, feeds, scheduling messages, and creating/restoring backups, improving user engagement and server management.
- Updated job handling to include dedicated workers for stats updates, feed polling, scheduling, and backup restoration, enhancing automation.
- Enhanced command localization for new features in both German and English.
- Documented the new features and their setup processes in PHASE-TRACKING.md for clarity on implementation steps.
This commit is contained in:
smueller
2026-07-22 13:36:39 +02:00
parent 52b10c9536
commit 12066befa8
36 changed files with 4032 additions and 42 deletions

View File

@@ -13,3 +13,5 @@ BACKUP_CRON=0 3 * * *
BACKUP_DIR=/backups
HEALTH_PORT=8080
PUBLIC_BASE_URL=http://localhost:8080
TWITCH_CLIENT_ID=
TWITCH_CLIENT_SECRET=

View File

@@ -0,0 +1,116 @@
CREATE TABLE "StatsConfig" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT false,
"membersChannelId" TEXT,
"onlineChannelId" TEXT,
"boostsChannelId" TEXT,
"membersTemplate" TEXT NOT NULL DEFAULT 'Members: {count}',
"onlineTemplate" TEXT NOT NULL DEFAULT 'Online: {count}',
"boostsTemplate" TEXT NOT NULL DEFAULT 'Boosts: {count}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "StatsConfig_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "InviteStat" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"inviterId" TEXT NOT NULL,
"code" TEXT,
"joins" INTEGER NOT NULL DEFAULT 0,
"leaves" INTEGER NOT NULL DEFAULT 0,
"fakes" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "InviteStat_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "InviteJoin" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"inviterId" TEXT,
"code" TEXT,
"isFake" BOOLEAN NOT NULL DEFAULT false,
"leftAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "InviteJoin_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "ActivityStat" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"channelId" TEXT,
"messageCount" INTEGER NOT NULL DEFAULT 0,
"voiceMinutes" INTEGER NOT NULL DEFAULT 0,
"day" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ActivityStat_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "SocialFeed" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"sourceId" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
"rolePingId" TEXT,
"template" TEXT,
"lastItemId" TEXT,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SocialFeed_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "ScheduledMessage" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
"creatorId" TEXT NOT NULL,
"content" TEXT,
"embed" JSONB,
"rolePingId" TEXT,
"cron" TEXT,
"runAt" TIMESTAMP(3),
"jobId" TEXT,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ScheduledMessage_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "GuildBackup" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"createdById" TEXT NOT NULL,
"payload" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "GuildBackup_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "StatsConfig_guildId_key" ON "StatsConfig"("guildId");
CREATE UNIQUE INDEX "InviteStat_guildId_inviterId_key" ON "InviteStat"("guildId", "inviterId");
CREATE INDEX "InviteStat_guildId_joins_idx" ON "InviteStat"("guildId", "joins");
CREATE UNIQUE INDEX "InviteJoin_guildId_userId_key" ON "InviteJoin"("guildId", "userId");
CREATE INDEX "InviteJoin_guildId_inviterId_idx" ON "InviteJoin"("guildId", "inviterId");
CREATE UNIQUE INDEX "ActivityStat_guildId_userId_channelId_day_key" ON "ActivityStat"("guildId", "userId", "channelId", "day");
CREATE INDEX "ActivityStat_guildId_day_idx" ON "ActivityStat"("guildId", "day");
CREATE INDEX "SocialFeed_guildId_type_idx" ON "SocialFeed"("guildId", "type");
CREATE INDEX "SocialFeed_enabled_idx" ON "SocialFeed"("enabled");
CREATE INDEX "ScheduledMessage_guildId_enabled_idx" ON "ScheduledMessage"("guildId", "enabled");
CREATE INDEX "GuildBackup_guildId_createdAt_idx" ON "GuildBackup"("guildId", "createdAt");
ALTER TABLE "StatsConfig" ADD CONSTRAINT "StatsConfig_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "InviteStat" ADD CONSTRAINT "InviteStat_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "InviteJoin" ADD CONSTRAINT "InviteJoin_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ActivityStat" ADD CONSTRAINT "ActivityStat_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "SocialFeed" ADD CONSTRAINT "SocialFeed_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ScheduledMessage" ADD CONSTRAINT "ScheduledMessage_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "GuildBackup" ADD CONSTRAINT "GuildBackup_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -44,6 +44,13 @@ model Guild {
birthdayConfig BirthdayConfig?
tempVoiceConfig TempVoiceConfig?
tempVoiceChannels TempVoiceChannel[]
statsConfig StatsConfig?
inviteStats InviteStat[]
inviteJoins InviteJoin[]
activityStats ActivityStat[]
socialFeeds SocialFeed[]
scheduledMessages ScheduledMessage[]
guildBackups GuildBackup[]
}
model GuildSettings {
@@ -639,3 +646,116 @@ model TempVoiceChannel {
@@index([guildId, ownerId])
}
model StatsConfig {
id String @id @default(cuid())
guildId String @unique
enabled Boolean @default(false)
membersChannelId String?
onlineChannelId String?
boostsChannelId String?
membersTemplate String @default("Members: {count}")
onlineTemplate String @default("Online: {count}")
boostsTemplate String @default("Boosts: {count}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
}
model InviteStat {
id String @id @default(cuid())
guildId String
inviterId String
code String?
joins Int @default(0)
leaves Int @default(0)
fakes Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
@@unique([guildId, inviterId])
@@index([guildId, joins])
}
model InviteJoin {
id String @id @default(cuid())
guildId String
userId String
inviterId String?
code String?
isFake Boolean @default(false)
leftAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
@@unique([guildId, userId])
@@index([guildId, inviterId])
}
model ActivityStat {
id String @id @default(cuid())
guildId String
userId String
channelId String?
messageCount Int @default(0)
voiceMinutes Int @default(0)
day DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
@@unique([guildId, userId, channelId, day])
@@index([guildId, day])
}
model SocialFeed {
id String @id @default(cuid())
guildId String
type String
sourceId String
channelId String
rolePingId String?
template String?
lastItemId String?
enabled Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
@@index([guildId, type])
@@index([enabled])
}
model ScheduledMessage {
id String @id @default(cuid())
guildId String
channelId String
creatorId String
content String?
embed Json?
rolePingId String?
cron String?
runAt DateTime?
jobId String?
enabled Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
@@index([guildId, enabled])
}
model GuildBackup {
id String @id @default(cuid())
guildId String
name String
createdById String
payload Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
@@index([guildId, createdAt])
}

View File

@@ -24,6 +24,10 @@ import { starboardCommands } from './modules/starboard/index.js';
import { suggestionsCommands } from './modules/suggestions/index.js';
import { birthdayCommands } from './modules/birthdays/index.js';
import { tempVoiceCommands } from './modules/tempvoice/index.js';
import { statsCommands } from './modules/stats/index.js';
import { feedsCommands } from './modules/feeds/index.js';
import { scheduleCommands } from './modules/scheduler/index.js';
import { guildBackupCommands } from './modules/guildbackup/index.js';
import type { BotContext, SlashCommand } from './types.js';
const commands: SlashCommand[] = [
@@ -42,7 +46,11 @@ const commands: SlashCommand[] = [
...starboardCommands,
...suggestionsCommands,
...birthdayCommands,
...tempVoiceCommands
...tempVoiceCommands,
...statsCommands,
...feedsCommands,
...scheduleCommands,
...guildBackupCommands
];
const map = new Map(commands.map((c) => [c.data.name, c]));
const contextMenuMap = new Map(utilityContextMenus.map((c) => [c.data.name, c]));

View File

@@ -17,7 +17,9 @@ const EnvSchema = z.object({
BACKUP_DIR: z.string().default('/backups'),
METRICS_TOKEN: z.string().optional(),
HEALTH_PORT: z.coerce.number().int().positive().default(8080),
PUBLIC_BASE_URL: z.string().url().default('http://localhost:8080')
PUBLIC_BASE_URL: z.string().url().default('http://localhost:8080'),
TWITCH_CLIENT_ID: z.string().min(1).optional(),
TWITCH_CLIENT_SECRET: z.string().min(1).optional()
});
export const env = EnvSchema.parse(process.env);

View File

@@ -62,6 +62,8 @@ import {
isTempVoiceModal,
registerTempVoiceEvents
} from './modules/tempvoice/index.js';
import { registerStatsEvents } from './modules/stats/index.js';
import { handleGuildBackupButton, isGuildBackupButton } from './modules/guildbackup/index.js';
const isShard = process.argv.includes('--shard');
@@ -108,6 +110,7 @@ if (!isShard) {
registerTagEvents(client, context);
registerStarboardEvents(context);
registerTempVoiceEvents(context);
registerStatsEvents(context);
client.on(Events.ClientReady, async () => {
logger.info({ tag: client.user?.tag }, 'Bot ready');
@@ -201,6 +204,11 @@ if (!isShard) {
return;
}
if (interaction.isButton() && isGuildBackupButton(interaction.customId)) {
await handleGuildBackupButton(interaction, context);
return;
}
if (interaction.isModalSubmit() && isEmbedModal(interaction.customId)) {
await handleEmbedModal(interaction, context);
return;

View File

@@ -15,6 +15,10 @@ import { expireSelfRole } from './modules/selfroles/service.js';
import { endGiveaway } from './modules/giveaways/index.js';
import { closeInactiveTickets } from './modules/tickets/index.js';
import { expireBirthdayRole, runBirthdayCheck } from './modules/birthdays/index.js';
import { ensureStatsRecurringJobs, startStatsWorker } from './modules/stats/index.js';
import { pollAllFeeds } from './modules/feeds/index.js';
import { sendScheduledMessage } from './modules/scheduler/index.js';
import { runGuildBackupRestoreJob } from './modules/guildbackup/index.js';
import {
automodQueue,
automodQueueName,
@@ -22,9 +26,13 @@ import {
backupQueueName,
birthdayQueue,
birthdayQueueName,
feedsQueue,
feedsQueueName,
giveawayQueueName,
guildBackupQueueName,
moderationQueueName,
reminderQueueName,
scheduleQueueName,
ticketQueue,
ticketQueueName,
verificationQueueName
@@ -213,6 +221,50 @@ export function startWorkers(context: BotContext): Worker[] {
logger.error({ jobId: job?.id, error }, 'Birthday job failed');
});
const statsWorker = startStatsWorker(context);
const feedsWorker = new Worker(
feedsQueueName,
async (job) => {
if (job.name !== 'pollFeeds') {
return;
}
await pollAllFeeds(context);
},
{ connection: redis }
);
feedsWorker.on('failed', (job, error) => {
logger.error({ jobId: job?.id, error }, 'Feeds job failed');
});
const scheduleWorker = new Worker<{ scheduleId: string }>(
scheduleQueueName,
async (job) => {
if (job.name !== 'scheduleSend') {
return;
}
await sendScheduledMessage(context, job.data.scheduleId);
},
{ connection: redis }
);
scheduleWorker.on('failed', (job, error) => {
logger.error({ jobId: job?.id, error }, 'Schedule job failed');
});
const guildBackupWorker = new Worker<{ backupId: string; guildId: string }>(
guildBackupQueueName,
async (job) => {
if (job.name !== 'restoreGuildBackup') {
return;
}
await runGuildBackupRestoreJob(context, job.data.backupId, job.data.guildId);
},
{ connection: redis }
);
guildBackupWorker.on('failed', (job, error) => {
logger.error({ jobId: job?.id, error }, 'Guild backup restore job failed');
});
return [
moderationWorker,
backupWorker,
@@ -221,7 +273,11 @@ export function startWorkers(context: BotContext): Worker[] {
reminderWorker,
ticketWorker,
giveawayWorker,
birthdayWorker
birthdayWorker,
statsWorker,
feedsWorker,
scheduleWorker,
guildBackupWorker
];
}
@@ -267,6 +323,18 @@ export async function ensureRecurringJobs(): Promise<void> {
removeOnFail: 20
}
);
await ensureStatsRecurringJobs();
await feedsQueue.add(
'pollFeeds',
{},
{
repeat: { pattern: '*/5 * * * *' },
removeOnComplete: 20,
removeOnFail: 20
}
);
}
async function runPgDumpBackup(): Promise<void> {

View File

@@ -0,0 +1,54 @@
import { ChannelType } from 'discord.js';
import {
applyCommandDescription,
applyOptionDescription,
localizedChoice
} from '@nexumi/shared';
import { SlashCommandBuilder } from 'discord.js';
const feedTypeChoices = [
localizedChoice('feeds.choice.type.twitch', 'TWITCH'),
localizedChoice('feeds.choice.type.youtube', 'YOUTUBE'),
localizedChoice('feeds.choice.type.rss', 'RSS'),
localizedChoice('feeds.choice.type.reddit', 'REDDIT')
] as const;
export const feedsCommandData = applyCommandDescription(
new SlashCommandBuilder()
.setName('feeds')
.addSubcommand((sub) =>
applyCommandDescription(sub.setName('add'), 'feeds.add.description')
.addStringOption((o) =>
applyOptionDescription(o.setName('type').setRequired(true), 'feeds.add.options.type').addChoices(
...feedTypeChoices
)
)
.addStringOption((o) =>
applyOptionDescription(o.setName('source').setRequired(true), 'feeds.add.options.source')
)
.addChannelOption((o) =>
applyOptionDescription(
o.setName('channel').addChannelTypes(ChannelType.GuildText).setRequired(true),
'feeds.add.options.channel'
)
)
.addRoleOption((o) =>
applyOptionDescription(o.setName('role'), 'feeds.add.options.role')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('template'), 'feeds.add.options.template')
)
)
.addSubcommand((sub) =>
applyCommandDescription(sub.setName('remove'), 'feeds.remove.description').addStringOption((o) =>
applyOptionDescription(o.setName('id').setRequired(true), 'feeds.remove.options.id')
)
)
.addSubcommand((sub) => applyCommandDescription(sub.setName('list'), 'feeds.list.description'))
.addSubcommand((sub) =>
applyCommandDescription(sub.setName('test'), 'feeds.test.description').addStringOption((o) =>
applyOptionDescription(o.setName('id').setRequired(true), 'feeds.test.options.id')
)
),
'feeds.description'
);

View File

@@ -0,0 +1,141 @@
import { PermissionFlagsBits } from 'discord.js';
import { SocialFeedTypeSchema, t, tf } from '@nexumi/shared';
import type { SlashCommand } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { requirePermission } from '../../permissions.js';
import { feedsCommandData } from './command-definitions.js';
import {
addFeed,
FeedAddSchema,
FeedError,
getFeedById,
listFeeds,
removeFeed,
testFeed
} from './service.js';
const feedsCommand: SlashCommand = {
data: feedsCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!interaction.inGuild()) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return;
}
const sub = interaction.options.getSubcommand(true);
const guildId = interaction.guildId!;
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) {
return;
}
try {
if (sub === 'add') {
const input = FeedAddSchema.parse({
type: SocialFeedTypeSchema.parse(interaction.options.getString('type', true)),
source: interaction.options.getString('source', true),
channelId: interaction.options.getChannel('channel', true).id,
rolePingId: interaction.options.getRole('role')?.id,
template: interaction.options.getString('template') ?? undefined
});
const feed = await addFeed(context.prisma, guildId, input);
await interaction.reply({
content: tf(locale, 'feeds.added', { id: feed.id, type: feed.type }),
ephemeral: true
});
return;
}
if (sub === 'remove') {
const feedId = interaction.options.getString('id', true);
const removed = await removeFeed(context.prisma, guildId, feedId);
if (!removed) {
await interaction.reply({ content: t(locale, 'feeds.error.not_found'), ephemeral: true });
return;
}
await interaction.reply({
content: tf(locale, 'feeds.removed', { id: removed.id }),
ephemeral: true
});
return;
}
if (sub === 'list') {
const feeds = await listFeeds(context.prisma, guildId);
if (feeds.length === 0) {
await interaction.reply({ content: t(locale, 'feeds.list.empty'), ephemeral: true });
return;
}
const lines = feeds.map((feed) =>
tf(locale, 'feeds.list.line', {
id: feed.id,
type: feed.type,
source: feed.sourceId,
channel: `<#${feed.channelId}>`
})
);
await interaction.reply({
content: `${t(locale, 'feeds.list.header')}\n${lines.join('\n')}`,
ephemeral: true
});
return;
}
if (sub === 'test') {
const feedId = interaction.options.getString('id', true);
const feed = await getFeedById(context.prisma, guildId, feedId);
if (!feed) {
await interaction.reply({ content: t(locale, 'feeds.error.not_found'), ephemeral: true });
return;
}
const result = await testFeed(context, feed);
if (result.skipped) {
const skipKey =
result.message === 'twitch_credentials_missing'
? 'feeds.test.twitchSkipped'
: 'feeds.test.skipped';
await interaction.reply({
content: t(locale, skipKey),
ephemeral: true
});
return;
}
if (result.message && result.posted === 0) {
await interaction.reply({
content: tf(locale, 'feeds.test.failed', { reason: result.message }),
ephemeral: true
});
return;
}
if (result.posted === 0) {
await interaction.reply({ content: t(locale, 'feeds.test.noItems'), ephemeral: true });
return;
}
await interaction.reply({
content: tf(locale, 'feeds.test.posted', { count: result.posted }),
ephemeral: true
});
}
} catch (error) {
if (error instanceof FeedError) {
await interaction.reply({
content: t(locale, `feeds.error.${error.code}`),
ephemeral: true
});
return;
}
throw error;
}
}
};
export const feedsCommands: SlashCommand[] = [feedsCommand];

View File

@@ -0,0 +1,277 @@
import { env } from '../../env.js';
export type FeedItem = {
id: string;
title: string;
url: string;
};
export type FetchResult =
| { ok: true; items: FeedItem[] }
| { ok: false; skipped?: boolean; message: string };
const USER_AGENT = 'NexumiBot/1.0';
const YOUTUBE_CHANNEL_ID_RE = /^UC[\w-]{10,}$/i;
function decodeXmlEntities(value: string): string {
return value
.replaceAll('&amp;', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&apos;', "'")
.replaceAll('&#39;', "'");
}
function stripCdata(value: string): string {
return value.replace(/^<!\[CDATA\[([\s\S]*?)\]\]>$/i, '$1').trim();
}
function extractTag(block: string, tagNames: string[]): string | null {
for (const tag of tagNames) {
const match = block.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'i'));
if (match?.[1]) {
return decodeXmlEntities(stripCdata(match[1].trim()));
}
}
return null;
}
function extractLink(block: string): string | null {
const atomLink = block.match(/<link[^>]*href=["']([^"']+)["'][^>]*\/?>/i);
if (atomLink?.[1]) {
return atomLink[1];
}
const rssLink = extractTag(block, ['link']);
return rssLink;
}
function extractGuid(block: string, link: string | null): string | null {
const guid = extractTag(block, ['guid', 'id']);
if (guid) {
return guid;
}
return link;
}
export function parseRssXml(xml: string): FeedItem[] {
const items: FeedItem[] = [];
const blockRegex = /<(?:item|entry)\b[\s\S]*?<\/(?:item|entry)>/gi;
const blocks = xml.match(blockRegex) ?? [];
for (const block of blocks) {
const title = extractTag(block, ['title']);
const link = extractLink(block);
const id = extractGuid(block, link);
if (!title || !link || !id) {
continue;
}
items.push({ id, title, url: link });
}
return items;
}
async function fetchText(url: string): Promise<string> {
const response = await fetch(url, {
headers: { 'User-Agent': USER_AGENT }
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.text();
}
export async function fetchRssFeed(sourceUrl: string): Promise<FetchResult> {
try {
const xml = await fetchText(sourceUrl);
const items = parseRssXml(xml);
return { ok: true, items };
} catch (error) {
return {
ok: false,
message: error instanceof Error ? error.message : 'fetch_failed'
};
}
}
export async function fetchYouTubeFeed(source: string): Promise<FetchResult> {
const trimmed = source.trim();
const channelIdMatch = trimmed.match(/channel_id=([\w-]+)/i);
const channelId = YOUTUBE_CHANNEL_ID_RE.test(trimmed)
? trimmed
: (channelIdMatch?.[1] ?? null);
if (channelId) {
return fetchRssFeed(`https://www.youtube.com/feeds/videos.xml?channel_id=${channelId}`);
}
if (/^https?:\/\//i.test(trimmed)) {
return fetchRssFeed(trimmed);
}
return { ok: false, message: 'invalid_youtube_source' };
}
export async function fetchRedditFeed(subreddit: string): Promise<FetchResult> {
const name = subreddit.replace(/^r\//i, '').trim();
if (!name) {
return { ok: false, message: 'invalid_subreddit' };
}
try {
const url = `https://www.reddit.com/r/${encodeURIComponent(name)}/new.json?limit=5`;
const response = await fetch(url, {
headers: { 'User-Agent': USER_AGENT }
});
if (!response.ok) {
return { ok: false, message: `HTTP ${response.status}` };
}
const data = (await response.json()) as {
data?: { children?: Array<{ data?: { id?: string; title?: string; permalink?: string } }> };
};
const items: FeedItem[] = [];
for (const child of data.data?.children ?? []) {
const post = child.data;
if (!post?.id || !post.title || !post.permalink) {
continue;
}
items.push({
id: post.id,
title: post.title,
url: post.permalink.startsWith('http')
? post.permalink
: `https://www.reddit.com${post.permalink}`
});
}
return { ok: true, items };
} catch (error) {
return {
ok: false,
message: error instanceof Error ? error.message : 'fetch_failed'
};
}
}
let twitchAppToken: { token: string; expiresAt: number } | null = null;
async function getTwitchAppToken(): Promise<string | null> {
const clientId = env.TWITCH_CLIENT_ID;
const clientSecret = env.TWITCH_CLIENT_SECRET;
if (!clientId || !clientSecret) {
return null;
}
if (twitchAppToken && twitchAppToken.expiresAt > Date.now() + 60_000) {
return twitchAppToken.token;
}
const params = new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
grant_type: 'client_credentials'
});
const response = await fetch(`https://id.twitch.tv/oauth2/token?${params.toString()}`, {
method: 'POST'
});
if (!response.ok) {
throw new Error(`twitch_token_http_${response.status}`);
}
const body = (await response.json()) as { access_token?: string; expires_in?: number };
if (!body.access_token) {
throw new Error('twitch_token_missing');
}
twitchAppToken = {
token: body.access_token,
expiresAt: Date.now() + (body.expires_in ?? 3600) * 1000
};
return twitchAppToken.token;
}
export async function fetchTwitchFeed(login: string): Promise<FetchResult> {
const userLogin = login.trim().toLowerCase();
if (!userLogin) {
return { ok: false, message: 'invalid_twitch_login' };
}
const clientId = env.TWITCH_CLIENT_ID;
const clientSecret = env.TWITCH_CLIENT_SECRET;
if (!clientId || !clientSecret) {
return {
ok: false,
skipped: true,
message: 'twitch_credentials_missing'
};
}
try {
const token = await getTwitchAppToken();
if (!token) {
return {
ok: false,
skipped: true,
message: 'twitch_credentials_missing'
};
}
const url = `https://api.twitch.tv/helix/streams?user_login=${encodeURIComponent(userLogin)}`;
const response = await fetch(url, {
headers: {
'Client-ID': clientId,
Authorization: `Bearer ${token}`
}
});
if (!response.ok) {
return { ok: false, message: `HTTP ${response.status}` };
}
const body = (await response.json()) as {
data?: Array<{ id?: string; title?: string; user_login?: string }>;
};
const stream = body.data?.[0];
if (!stream?.id || !stream.title) {
return { ok: true, items: [] };
}
const streamLogin = stream.user_login ?? userLogin;
return {
ok: true,
items: [
{
id: stream.id,
title: stream.title,
url: `https://twitch.tv/${streamLogin}`
}
]
};
} catch (error) {
return {
ok: false,
message: error instanceof Error ? error.message : 'fetch_failed'
};
}
}
export async function fetchFeedItems(type: string, sourceId: string): Promise<FetchResult> {
switch (type) {
case 'RSS':
return fetchRssFeed(sourceId);
case 'YOUTUBE':
return fetchYouTubeFeed(sourceId);
case 'REDDIT':
return fetchRedditFeed(sourceId);
case 'TWITCH':
return fetchTwitchFeed(sourceId);
default:
return { ok: false, message: 'unsupported_type' };
}
}

View File

@@ -0,0 +1,2 @@
export { feedsCommands } from './commands.js';
export { pollAllFeeds } from './service.js';

View File

@@ -0,0 +1,228 @@
import { PermissionFlagsBits, type TextChannel } from 'discord.js';
import type { SocialFeed } from '@prisma/client';
import { applyFeedTemplate, SocialFeedTypeSchema } from '@nexumi/shared';
import { z } from 'zod';
import { ensureGuild } from '../../guild.js';
import { logger } from '../../logger.js';
import type { BotContext } from '../../types.js';
import { fetchFeedItems, type FeedItem, type FetchResult } from './fetchers.js';
export const FeedAddSchema = z.object({
type: SocialFeedTypeSchema,
source: z.string().min(1).max(512),
channelId: z.string().min(1),
rolePingId: z.string().min(1).optional(),
template: z.string().min(1).max(2000).optional()
});
export type FeedAddInput = z.infer<typeof FeedAddSchema>;
export class FeedError extends Error {
constructor(public readonly code: string) {
super(code);
this.name = 'FeedError';
}
}
const DEFAULT_TEMPLATE = '{title}\n{url}';
export function normalizeFeedSource(type: z.infer<typeof SocialFeedTypeSchema>, source: string): string {
const trimmed = source.trim();
switch (type) {
case 'TWITCH':
return trimmed.replace(/^@/, '').toLowerCase();
case 'REDDIT':
return trimmed.replace(/^r\//i, '').toLowerCase();
case 'YOUTUBE':
case 'RSS':
return trimmed;
default:
return trimmed;
}
}
export async function addFeed(
prisma: BotContext['prisma'],
guildId: string,
input: FeedAddInput
): Promise<SocialFeed> {
await ensureGuild(prisma, guildId);
const sourceId = normalizeFeedSource(input.type, input.source);
return prisma.socialFeed.create({
data: {
guildId,
type: input.type,
sourceId,
channelId: input.channelId,
rolePingId: input.rolePingId ?? null,
template: input.template ?? null
}
});
}
export async function removeFeed(
prisma: BotContext['prisma'],
guildId: string,
feedId: string
): Promise<SocialFeed | null> {
const existing = await prisma.socialFeed.findFirst({
where: { id: feedId, guildId }
});
if (!existing) {
return null;
}
await prisma.socialFeed.delete({ where: { id: feedId } });
return existing;
}
export async function listFeeds(prisma: BotContext['prisma'], guildId: string): Promise<SocialFeed[]> {
await ensureGuild(prisma, guildId);
return prisma.socialFeed.findMany({
where: { guildId },
orderBy: { createdAt: 'asc' }
});
}
export async function getFeedById(
prisma: BotContext['prisma'],
guildId: string,
feedId: string
): Promise<SocialFeed | null> {
return prisma.socialFeed.findFirst({
where: { id: feedId, guildId }
});
}
function buildFeedMessage(feed: SocialFeed, item: FeedItem): string {
const body = applyFeedTemplate(feed.template?.trim() || DEFAULT_TEMPLATE, {
title: item.title,
url: item.url
});
if (feed.rolePingId) {
return `<@&${feed.rolePingId}>\n${body}`;
}
return body;
}
async function resolveTextChannel(
context: BotContext,
guildId: string,
channelId: string
): Promise<TextChannel | null> {
const guild = await context.client.guilds.fetch(guildId).catch(() => null);
if (!guild) {
return null;
}
const channel = await guild.channels.fetch(channelId).catch(() => null);
if (!channel?.isTextBased() || channel.isDMBased()) {
return null;
}
const me = guild.members.me;
if (!me?.permissionsIn(channel).has(PermissionFlagsBits.SendMessages)) {
return null;
}
return channel as TextChannel;
}
async function postFeedItem(
context: BotContext,
feed: SocialFeed,
item: FeedItem
): Promise<boolean> {
const channel = await resolveTextChannel(context, feed.guildId, feed.channelId);
if (!channel) {
logger.warn({ feedId: feed.id, channelId: feed.channelId }, 'Feed channel invalid');
return false;
}
await channel.send({ content: buildFeedMessage(feed, item) });
return true;
}
function selectNewItems(items: FeedItem[], lastItemId: string | null): FeedItem[] {
if (items.length === 0) {
return [];
}
if (!lastItemId) {
return [items[0]!];
}
const lastIndex = items.findIndex((item) => item.id === lastItemId);
if (lastIndex === -1) {
return [items[0]!];
}
return items.slice(0, lastIndex).reverse();
}
export async function processFeedFetch(
context: BotContext,
feed: SocialFeed,
result: FetchResult,
options: { forceLatest?: boolean } = {}
): Promise<{ posted: number; skipped?: boolean; message?: string }> {
if (!result.ok) {
return {
posted: 0,
skipped: result.skipped,
message: result.message
};
}
const items = result.items;
if (items.length === 0) {
return { posted: 0 };
}
const candidates = options.forceLatest ? [items[0]!] : selectNewItems(items, feed.lastItemId);
let posted = 0;
for (const item of candidates) {
const sent = await postFeedItem(context, feed, item);
if (sent) {
posted += 1;
}
}
const newestId = items[0]!.id;
if (posted > 0 || feed.lastItemId !== newestId) {
await context.prisma.socialFeed.update({
where: { id: feed.id },
data: { lastItemId: newestId }
});
}
return { posted };
}
export async function pollFeed(context: BotContext, feed: SocialFeed): Promise<void> {
const result = await fetchFeedItems(feed.type, feed.sourceId);
await processFeedFetch(context, feed, result);
}
export async function testFeed(
context: BotContext,
feed: SocialFeed
): Promise<{ posted: number; skipped?: boolean; message?: string }> {
const result = await fetchFeedItems(feed.type, feed.sourceId);
return processFeedFetch(context, feed, result, { forceLatest: true });
}
export async function pollAllFeeds(context: BotContext): Promise<void> {
const feeds = await context.prisma.socialFeed.findMany({
where: { enabled: true }
});
for (const feed of feeds) {
try {
await pollFeed(context, feed);
} catch (error) {
logger.error({ error, feedId: feed.id, guildId: feed.guildId }, 'Feed poll failed');
}
}
}

View File

@@ -0,0 +1,32 @@
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
import { SlashCommandBuilder } from 'discord.js';
export const backupCommandData = applyCommandDescription(
new SlashCommandBuilder()
.setName('backup')
.addSubcommand((s) =>
applyCommandDescription(s.setName('create'), 'guildbackup.create.description').addStringOption(
(o) => applyOptionDescription(o.setName('name'), 'guildbackup.create.options.name')
)
)
.addSubcommand((s) => applyCommandDescription(s.setName('list'), 'guildbackup.list.description'))
.addSubcommand((s) =>
applyCommandDescription(s.setName('info'), 'guildbackup.info.description').addStringOption(
(o) =>
applyOptionDescription(o.setName('id').setRequired(true), 'guildbackup.common.options.id')
)
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('delete'), 'guildbackup.delete.description').addStringOption(
(o) =>
applyOptionDescription(o.setName('id').setRequired(true), 'guildbackup.common.options.id')
)
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('restore'), 'guildbackup.restore.description').addStringOption(
(o) =>
applyOptionDescription(o.setName('id').setRequired(true), 'guildbackup.common.options.id')
)
),
'guildbackup.description'
);

View File

@@ -0,0 +1,203 @@
import { PermissionFlagsBits } from 'discord.js';
import { t, tf } from '@nexumi/shared';
import type { SlashCommand } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { requirePermission } from '../../permissions.js';
import { backupCommandData } from './command-definitions.js';
import {
createGuildBackup,
ensureBotCanRestore,
getGuildBackup,
GuildBackupError,
listGuildBackups,
parseBackupPayload
} from './service.js';
import { promptDeleteConfirmation, promptRestoreConfirmation } from './confirmations.js';
async function ensureManageGuild(
interaction: Parameters<SlashCommand['execute']>[0],
locale: 'de' | 'en'
): Promise<boolean> {
if (!interaction.inGuild()) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return false;
}
return requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale);
}
function defaultBackupName(): string {
return `backup-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}`;
}
const backupCommand: SlashCommand = {
data: backupCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!interaction.inGuild()) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return;
}
const sub = interaction.options.getSubcommand();
const guild = interaction.guild!;
if (sub === 'create') {
if (!(await ensureManageGuild(interaction, locale))) {
return;
}
const name = interaction.options.getString('name')?.trim() || defaultBackupName();
if (name.length < 1 || name.length > 100) {
await interaction.reply({
content: t(locale, 'guildbackup.error.invalid_name'),
ephemeral: true
});
return;
}
try {
const backup = await createGuildBackup(context, guild, name, interaction.user.id);
await interaction.reply({
content: tf(locale, 'guildbackup.created', { id: backup.id, name: backup.name }),
ephemeral: true
});
} catch (error) {
if (error instanceof GuildBackupError) {
await interaction.reply({
content: t(locale, `guildbackup.error.${error.code}`),
ephemeral: true
});
return;
}
throw error;
}
return;
}
if (sub === 'list') {
const backups = await listGuildBackups(context, guild.id);
if (backups.length === 0) {
await interaction.reply({
content: t(locale, 'guildbackup.list.empty'),
ephemeral: true
});
return;
}
const lines = backups.map((backup) =>
tf(locale, 'guildbackup.list.line', {
id: backup.id,
name: backup.name,
date: backup.createdAt.toISOString().slice(0, 10)
})
);
await interaction.reply({
content: `${t(locale, 'guildbackup.list.header')}\n${lines.join('\n')}`,
ephemeral: true
});
return;
}
if (sub === 'info') {
const backupId = interaction.options.getString('id', true);
const backup = await getGuildBackup(context, guild.id, backupId);
if (!backup) {
await interaction.reply({
content: t(locale, 'guildbackup.error.not_found'),
ephemeral: true
});
return;
}
let roleCount = 0;
let channelCount = 0;
try {
const payload = parseBackupPayload(backup.payload);
roleCount = payload.roles.length;
channelCount = payload.channels.length;
} catch {
await interaction.reply({
content: t(locale, 'guildbackup.error.invalid_payload'),
ephemeral: true
});
return;
}
await interaction.reply({
content: [
tf(locale, 'guildbackup.info.id', { id: backup.id }),
tf(locale, 'guildbackup.info.name', { name: backup.name }),
tf(locale, 'guildbackup.info.created', { date: backup.createdAt.toISOString() }),
tf(locale, 'guildbackup.info.createdBy', { userId: backup.createdById }),
tf(locale, 'guildbackup.info.roles', { count: roleCount }),
tf(locale, 'guildbackup.info.channels', { count: channelCount }),
t(locale, 'guildbackup.info.limitations')
].join('\n'),
ephemeral: true
});
return;
}
if (sub === 'delete') {
if (!(await ensureManageGuild(interaction, locale))) {
return;
}
const backupId = interaction.options.getString('id', true);
const backup = await getGuildBackup(context, guild.id, backupId);
if (!backup) {
await interaction.reply({
content: t(locale, 'guildbackup.error.not_found'),
ephemeral: true
});
return;
}
await promptDeleteConfirmation(interaction, locale, backupId);
return;
}
if (sub === 'restore') {
if (guild.ownerId !== interaction.user.id) {
await interaction.reply({
content: t(locale, 'guildbackup.error.owner_only'),
ephemeral: true
});
return;
}
if (!ensureBotCanRestore(guild)) {
await interaction.reply({
content: t(locale, 'guildbackup.error.bot_missing_permissions'),
ephemeral: true
});
return;
}
const backupId = interaction.options.getString('id', true);
const backup = await getGuildBackup(context, guild.id, backupId);
if (!backup) {
await interaction.reply({
content: t(locale, 'guildbackup.error.not_found'),
ephemeral: true
});
return;
}
try {
parseBackupPayload(backup.payload);
} catch {
await interaction.reply({
content: t(locale, 'guildbackup.error.invalid_payload'),
ephemeral: true
});
return;
}
await promptRestoreConfirmation(interaction, locale, backupId);
}
}
};
export const guildBackupCommands = [backupCommand];

View File

@@ -0,0 +1,285 @@
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
type ButtonInteraction,
type ChatInputCommandInteraction
} from 'discord.js';
import { randomUUID } from 'node:crypto';
import { type Locale, t, tf } from '@nexumi/shared';
import type { BotContext } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import {
deleteGuildBackup,
enqueueGuildBackupRestore,
getGuildBackup,
GuildBackupError,
parseBackupPayload,
restoreGuildBackup,
shouldQueueRestore
} from './service.js';
const DELETE_CONFIRM_PREFIX = 'gbackup:confirm:delete:';
const RESTORE_CONFIRM_PREFIX = 'gbackup:confirm:restore:';
const CANCEL_PREFIX = 'gbackup:cancel:';
const TTL_MS = 120_000;
type PendingDelete = {
action: 'delete';
backupId: string;
userId: string;
guildId: string;
locale: Locale;
expiresAt: number;
};
type PendingRestore = {
action: 'restore';
step: 1 | 2;
backupId: string;
userId: string;
guildId: string;
locale: Locale;
expiresAt: number;
};
type PendingEntry = PendingDelete | PendingRestore;
const pending = new Map<string, PendingEntry>();
function pruneExpired(): void {
const now = Date.now();
for (const [token, entry] of pending) {
if (entry.expiresAt <= now) {
pending.delete(token);
}
}
}
function buildRows(token: string, locale: Locale, confirmPrefix: string): ActionRowBuilder<ButtonBuilder>[] {
return [
new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId(`${confirmPrefix}${token}`)
.setLabel(t(locale, 'guildbackup.confirm.button.confirm'))
.setStyle(ButtonStyle.Danger),
new ButtonBuilder()
.setCustomId(`${CANCEL_PREFIX}${token}`)
.setLabel(t(locale, 'guildbackup.confirm.button.cancel'))
.setStyle(ButtonStyle.Secondary)
)
];
}
export async function promptDeleteConfirmation(
interaction: ChatInputCommandInteraction,
locale: Locale,
backupId: string
): Promise<void> {
pruneExpired();
const token = randomUUID();
pending.set(token, {
action: 'delete',
backupId,
userId: interaction.user.id,
guildId: interaction.guildId!,
locale,
expiresAt: Date.now() + TTL_MS
});
await interaction.reply({
content: tf(locale, 'guildbackup.confirm.delete', { id: backupId }),
components: buildRows(token, locale, DELETE_CONFIRM_PREFIX),
ephemeral: true
});
}
export async function promptRestoreConfirmation(
interaction: ChatInputCommandInteraction,
locale: Locale,
backupId: string
): Promise<void> {
pruneExpired();
const token = randomUUID();
pending.set(token, {
action: 'restore',
step: 1,
backupId,
userId: interaction.user.id,
guildId: interaction.guildId!,
locale,
expiresAt: Date.now() + TTL_MS
});
await interaction.reply({
content: tf(locale, 'guildbackup.confirm.restore.step1', { id: backupId }),
components: buildRows(token, locale, RESTORE_CONFIRM_PREFIX),
ephemeral: true
});
}
export async function handleGuildBackupButton(
interaction: ButtonInteraction,
context: BotContext
): Promise<void> {
pruneExpired();
const customId = interaction.customId;
const isDeleteConfirm = customId.startsWith(DELETE_CONFIRM_PREFIX);
const isRestoreConfirm = customId.startsWith(RESTORE_CONFIRM_PREFIX);
const isCancel = customId.startsWith(CANCEL_PREFIX);
if (!isDeleteConfirm && !isRestoreConfirm && !isCancel) {
return;
}
const token = customId.slice(
isDeleteConfirm
? DELETE_CONFIRM_PREFIX.length
: isRestoreConfirm
? RESTORE_CONFIRM_PREFIX.length
: CANCEL_PREFIX.length
);
const entry = pending.get(token);
if (!entry) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
await interaction.reply({ content: t(locale, 'guildbackup.confirm.expired'), ephemeral: true });
return;
}
if (interaction.user.id !== entry.userId) {
await interaction.reply({
content: t(entry.locale, 'guildbackup.confirm.unauthorized'),
ephemeral: true
});
return;
}
if (isCancel) {
pending.delete(token);
await interaction.update({
content: t(entry.locale, 'guildbackup.confirm.cancelled'),
components: []
});
return;
}
if (entry.action === 'delete') {
pending.delete(token);
await executeDelete(interaction, context, entry);
return;
}
if (entry.step === 1) {
entry.step = 2;
entry.expiresAt = Date.now() + TTL_MS;
pending.set(token, entry);
await interaction.update({
content: tf(entry.locale, 'guildbackup.confirm.restore.step2', { id: entry.backupId }),
components: buildRows(token, entry.locale, RESTORE_CONFIRM_PREFIX)
});
return;
}
pending.delete(token);
await executeRestore(interaction, context, entry);
}
async function executeDelete(
interaction: ButtonInteraction,
context: BotContext,
entry: PendingDelete
): Promise<void> {
const deleted = await deleteGuildBackup(context, entry.guildId, entry.backupId);
if (!deleted) {
await interaction.update({
content: t(entry.locale, 'guildbackup.error.not_found'),
components: []
});
return;
}
await interaction.update({
content: tf(entry.locale, 'guildbackup.deleted', { id: entry.backupId }),
components: []
});
}
async function executeRestore(
interaction: ButtonInteraction,
context: BotContext,
entry: PendingRestore
): Promise<void> {
const guild = interaction.guild;
if (!guild) {
await interaction.update({
content: t(entry.locale, 'generic.guildOnly'),
components: []
});
return;
}
const backup = await getGuildBackup(context, entry.guildId, entry.backupId);
if (!backup) {
await interaction.update({
content: t(entry.locale, 'guildbackup.error.not_found'),
components: []
});
return;
}
let payload;
try {
payload = parseBackupPayload(backup.payload);
} catch {
await interaction.update({
content: t(entry.locale, 'guildbackup.error.invalid_payload'),
components: []
});
return;
}
if (shouldQueueRestore(payload)) {
await enqueueGuildBackupRestore(entry.backupId, entry.guildId, entry.userId);
await interaction.update({
content: tf(entry.locale, 'guildbackup.restore.queued', { id: entry.backupId }),
components: []
});
return;
}
await interaction.deferUpdate();
try {
const summary = await restoreGuildBackup(guild, payload);
await interaction.editReply({
content: tf(entry.locale, 'guildbackup.restore.done', {
id: entry.backupId,
rolesCreated: summary.rolesCreated,
rolesSkipped: summary.rolesSkipped,
channelsCreated: summary.channelsCreated,
channelsUpdated: summary.channelsUpdated,
overwritesApplied: summary.overwritesApplied
}),
components: []
});
} catch (error) {
if (error instanceof GuildBackupError) {
await interaction.editReply({
content: t(entry.locale, `guildbackup.error.${error.code}`),
components: []
});
return;
}
throw error;
}
}
export function isGuildBackupButton(customId: string): boolean {
return (
customId.startsWith(DELETE_CONFIRM_PREFIX) ||
customId.startsWith(RESTORE_CONFIRM_PREFIX) ||
customId.startsWith(CANCEL_PREFIX)
);
}

View File

@@ -0,0 +1,11 @@
export { guildBackupCommands } from './commands.js';
export { isGuildBackupButton, handleGuildBackupButton } from './confirmations.js';
export {
createGuildBackup,
deleteGuildBackup,
getGuildBackup,
listGuildBackups,
restoreGuildBackup,
runGuildBackupRestoreJob,
shouldQueueRestore
} from './service.js';

View File

@@ -0,0 +1,632 @@
import {
ChannelType,
OverwriteType,
PermissionFlagsBits,
type Guild,
type GuildChannel,
type NonThreadGuildBasedChannel,
type OverwriteResolvable
} from 'discord.js';
import { GuildBackupPayloadSchema, type GuildBackupPayload } from '@nexumi/shared';
import type { GuildBackup } from '@prisma/client';
import { ensureGuild } from '../../guild.js';
import { logger } from '../../logger.js';
import { guildBackupQueue } from '../../queues.js';
import type { BotContext } from '../../types.js';
export class GuildBackupError extends Error {
constructor(public readonly code: string) {
super(code);
}
}
const LARGE_RESTORE_THRESHOLD = 50;
const RESTORE_REASON = 'Nexumi guild backup restore';
export type RestoreSummary = {
rolesCreated: number;
rolesSkipped: number;
channelsCreated: number;
channelsUpdated: number;
overwritesApplied: number;
settingsApplied: string[];
};
export async function createGuildBackup(
context: BotContext,
guild: Guild,
name: string,
createdById: string
): Promise<GuildBackup> {
await guild.roles.fetch();
await guild.channels.fetch();
const payload = buildPayloadFromGuild(guild);
GuildBackupPayloadSchema.parse(payload);
await ensureGuild(context.prisma, guild.id);
return context.prisma.guildBackup.create({
data: {
guildId: guild.id,
name,
createdById,
payload
}
});
}
export function buildPayloadFromGuild(guild: Guild): GuildBackupPayload {
const roles = [...guild.roles.cache.values()]
.sort((a, b) => a.position - b.position)
.map((role) => ({
id: role.id,
name: role.name,
color: role.color,
hoist: role.hoist,
mentionable: role.mentionable,
permissions: role.permissions.bitfield.toString(),
position: role.position
}));
const channels = [...guild.channels.cache.values()]
.filter(
(channel): channel is NonThreadGuildBasedChannel =>
!channel.isThread() && 'permissionOverwrites' in channel
)
.sort((a, b) => {
if (a.parentId !== b.parentId) {
return (a.parentId ?? '').localeCompare(b.parentId ?? '');
}
return a.rawPosition - b.rawPosition;
})
.map((channel) => {
const base = {
id: channel.id,
name: channel.name,
type: channel.type,
parentId: channel.parentId,
position: channel.rawPosition,
permissionOverwrites: channel.permissionOverwrites.cache.map((overwrite) => ({
id: overwrite.id,
type: overwrite.type,
allow: overwrite.allow.bitfield.toString(),
deny: overwrite.deny.bitfield.toString()
}))
};
if (channel.type === ChannelType.GuildText || channel.type === ChannelType.GuildAnnouncement) {
return {
...base,
topic: channel.topic ?? undefined,
nsfw: channel.nsfw,
rateLimitPerUser: channel.rateLimitPerUser ?? undefined
};
}
if (channel.type === ChannelType.GuildVoice || channel.type === ChannelType.GuildStageVoice) {
return {
...base,
bitrate: channel.bitrate ?? undefined,
userLimit: channel.userLimit ?? undefined
};
}
if (channel.type === ChannelType.GuildForum) {
return {
...base,
topic: channel.topic ?? undefined,
nsfw: channel.nsfw,
rateLimitPerUser: channel.rateLimitPerUser ?? undefined
};
}
return base;
});
return {
version: 1,
guildName: guild.name,
roles,
channels,
settings: {
verificationLevel: guild.verificationLevel,
explicitContentFilter: guild.explicitContentFilter,
defaultMessageNotifications: guild.defaultMessageNotifications,
afkTimeout: guild.afkTimeout
}
};
}
export async function listGuildBackups(
context: BotContext,
guildId: string,
limit = 15
): Promise<GuildBackup[]> {
return context.prisma.guildBackup.findMany({
where: { guildId },
orderBy: { createdAt: 'desc' },
take: limit
});
}
export async function getGuildBackup(
context: BotContext,
guildId: string,
backupId: string
): Promise<GuildBackup | null> {
return context.prisma.guildBackup.findFirst({
where: { id: backupId, guildId }
});
}
export async function deleteGuildBackup(
context: BotContext,
guildId: string,
backupId: string
): Promise<boolean> {
const result = await context.prisma.guildBackup.deleteMany({
where: { id: backupId, guildId }
});
return result.count > 0;
}
export function parseBackupPayload(raw: unknown): GuildBackupPayload {
const parsed = GuildBackupPayloadSchema.safeParse(raw);
if (!parsed.success) {
throw new GuildBackupError('invalid_payload');
}
return parsed.data;
}
export function shouldQueueRestore(payload: GuildBackupPayload): boolean {
return payload.roles.length + payload.channels.length >= LARGE_RESTORE_THRESHOLD;
}
export async function enqueueGuildBackupRestore(
backupId: string,
guildId: string,
requestedById: string
): Promise<void> {
await guildBackupQueue.add(
'restoreGuildBackup',
{ backupId, guildId, requestedById },
{
jobId: `guild-backup-restore-${backupId}`,
removeOnComplete: true,
removeOnFail: 25
}
);
}
export async function runGuildBackupRestoreJob(
context: BotContext,
backupId: string,
guildId: string
): Promise<RestoreSummary> {
const backup = await getGuildBackup(context, guildId, backupId);
if (!backup) {
throw new GuildBackupError('not_found');
}
const guild = await context.client.guilds.fetch(guildId);
const payload = parseBackupPayload(backup.payload);
return restoreGuildBackup(guild, payload);
}
export async function restoreGuildBackup(
guild: Guild,
payload: GuildBackupPayload
): Promise<RestoreSummary> {
await guild.roles.fetch();
await guild.channels.fetch();
const summary: RestoreSummary = {
rolesCreated: 0,
rolesSkipped: 0,
channelsCreated: 0,
channelsUpdated: 0,
overwritesApplied: 0,
settingsApplied: []
};
const roleIdMap = buildRoleIdMap(guild, payload);
await createMissingRoles(guild, payload, roleIdMap, summary);
await applyRolePositions(guild, payload, roleIdMap);
const channelIdMap = new Map<string, string>();
await restoreCategories(guild, payload, roleIdMap, channelIdMap, summary);
await restoreOtherChannels(guild, payload, roleIdMap, channelIdMap, summary);
await applyGuildSettings(guild, payload, summary);
logger.info(
{
guildId: guild.id,
backupGuildName: payload.guildName,
...summary
},
'Guild backup restore completed'
);
return summary;
}
function buildRoleIdMap(guild: Guild, payload: GuildBackupPayload): Map<string, string> {
const map = new Map<string, string>();
map.set(guild.id, guild.id);
for (const roleData of payload.roles) {
if (roleData.id === guild.id) {
continue;
}
const existing = guild.roles.cache.find(
(role) => role.name.toLowerCase() === roleData.name.toLowerCase()
);
if (existing) {
map.set(roleData.id, existing.id);
}
}
return map;
}
async function createMissingRoles(
guild: Guild,
payload: GuildBackupPayload,
roleIdMap: Map<string, string>,
summary: RestoreSummary
): Promise<void> {
const sortedRoles = [...payload.roles].sort((a, b) => a.position - b.position);
for (const roleData of sortedRoles) {
if (roleData.id === guild.id) {
continue;
}
if (roleIdMap.has(roleData.id)) {
summary.rolesSkipped += 1;
continue;
}
const existingByName = guild.roles.cache.find(
(role) => role.name.toLowerCase() === roleData.name.toLowerCase()
);
if (existingByName) {
roleIdMap.set(roleData.id, existingByName.id);
summary.rolesSkipped += 1;
continue;
}
try {
const created = await guild.roles.create({
name: roleData.name,
color: roleData.color,
hoist: roleData.hoist,
mentionable: roleData.mentionable,
permissions: BigInt(roleData.permissions),
reason: RESTORE_REASON
});
roleIdMap.set(roleData.id, created.id);
summary.rolesCreated += 1;
} catch (error) {
logger.warn({ error, roleName: roleData.name, guildId: guild.id }, 'Failed to create role');
summary.rolesSkipped += 1;
}
}
}
async function applyRolePositions(
guild: Guild,
payload: GuildBackupPayload,
roleIdMap: Map<string, string>
): Promise<void> {
const positions = payload.roles
.map((roleData) => {
const mappedId = roleIdMap.get(roleData.id);
if (!mappedId || mappedId === guild.id) {
return null;
}
return { role: mappedId, position: roleData.position };
})
.filter((entry): entry is { role: string; position: number } => entry !== null);
if (positions.length === 0) {
return;
}
try {
await guild.roles.setPositions(positions);
} catch (error) {
logger.warn({ error, guildId: guild.id }, 'Failed to apply role positions during restore');
}
}
function resolveParentId(
payload: GuildBackupPayload,
channelData: GuildBackupPayload['channels'][number],
channelIdMap: Map<string, string>,
guild: Guild
): string | undefined {
if (!channelData.parentId) {
return undefined;
}
const mappedParentId = channelIdMap.get(channelData.parentId);
if (mappedParentId) {
return mappedParentId;
}
const parentData = payload.channels.find((channel) => channel.id === channelData.parentId);
if (!parentData) {
return undefined;
}
const existingParent = findChannelByName(guild, parentData.name, undefined);
return existingParent?.id;
}
function findChannelByName(
guild: Guild,
name: string,
parentId: string | undefined
): GuildChannel | undefined {
return guild.channels.cache.find((channel) => {
if (!('name' in channel) || channel.name.toLowerCase() !== name.toLowerCase()) {
return false;
}
if (parentId === undefined) {
return channel.parentId === null;
}
return channel.parentId === parentId;
}) as GuildChannel | undefined;
}
function mapOverwrites(
payloadOverwrites: GuildBackupPayload['channels'][number]['permissionOverwrites'],
roleIdMap: Map<string, string>
): OverwriteResolvable[] {
const mapped: OverwriteResolvable[] = [];
for (const overwrite of payloadOverwrites) {
let targetId = overwrite.id;
if (overwrite.type === OverwriteType.Role) {
const mapped = roleIdMap.get(overwrite.id);
if (!mapped) {
continue;
}
targetId = mapped;
}
mapped.push({
id: targetId,
type: overwrite.type,
allow: BigInt(overwrite.allow),
deny: BigInt(overwrite.deny)
});
}
return mapped;
}
async function restoreCategories(
guild: Guild,
payload: GuildBackupPayload,
roleIdMap: Map<string, string>,
channelIdMap: Map<string, string>,
summary: RestoreSummary
): Promise<void> {
const categories = payload.channels
.filter((channel) => channel.type === ChannelType.GuildCategory)
.sort((a, b) => a.position - b.position);
for (const categoryData of categories) {
const existing = findChannelByName(guild, categoryData.name, undefined);
if (existing) {
channelIdMap.set(categoryData.id, existing.id);
await applyChannelOverwrites(existing, categoryData, roleIdMap, summary);
summary.channelsUpdated += 1;
continue;
}
try {
const created = await guild.channels.create({
name: categoryData.name,
type: ChannelType.GuildCategory,
permissionOverwrites: mapOverwrites(categoryData.permissionOverwrites, roleIdMap),
reason: RESTORE_REASON
});
channelIdMap.set(categoryData.id, created.id);
summary.channelsCreated += 1;
summary.overwritesApplied += categoryData.permissionOverwrites.length;
} catch (error) {
logger.warn(
{ error, channelName: categoryData.name, guildId: guild.id },
'Failed to create category'
);
}
}
}
async function restoreOtherChannels(
guild: Guild,
payload: GuildBackupPayload,
roleIdMap: Map<string, string>,
channelIdMap: Map<string, string>,
summary: RestoreSummary
): Promise<void> {
const channels = payload.channels
.filter((channel) => channel.type !== ChannelType.GuildCategory)
.sort((a, b) => a.position - b.position);
for (const channelData of channels) {
const parentId = resolveParentId(payload, channelData, channelIdMap, guild);
const existing = findChannelByName(guild, channelData.name, parentId);
if (existing) {
channelIdMap.set(channelData.id, existing.id);
await applyChannelOverwrites(existing, channelData, roleIdMap, summary);
summary.channelsUpdated += 1;
continue;
}
try {
const created = await createChannelFromBackup(guild, channelData, parentId, roleIdMap);
if (created) {
channelIdMap.set(channelData.id, created.id);
summary.channelsCreated += 1;
summary.overwritesApplied += channelData.permissionOverwrites.length;
}
} catch (error) {
logger.warn(
{ error, channelName: channelData.name, guildId: guild.id },
'Failed to create channel'
);
}
}
}
async function createChannelFromBackup(
guild: Guild,
channelData: GuildBackupPayload['channels'][number],
parentId: string | undefined,
roleIdMap: Map<string, string>
): Promise<NonThreadGuildBasedChannel | null> {
const overwrites = mapOverwrites(channelData.permissionOverwrites, roleIdMap);
const base = {
name: channelData.name,
parent: parentId,
permissionOverwrites: overwrites,
reason: RESTORE_REASON
};
switch (channelData.type) {
case ChannelType.GuildText:
return guild.channels.create({
...base,
type: ChannelType.GuildText,
topic: channelData.topic ?? undefined,
nsfw: channelData.nsfw ?? false,
rateLimitPerUser: channelData.rateLimitPerUser ?? 0
});
case ChannelType.GuildAnnouncement:
return guild.channels.create({
...base,
type: ChannelType.GuildAnnouncement,
topic: channelData.topic ?? undefined,
nsfw: channelData.nsfw ?? false,
rateLimitPerUser: channelData.rateLimitPerUser ?? 0
});
case ChannelType.GuildVoice:
return guild.channels.create({
...base,
type: ChannelType.GuildVoice,
bitrate: channelData.bitrate ?? 64_000,
userLimit: channelData.userLimit ?? 0
});
case ChannelType.GuildStageVoice:
return guild.channels.create({
...base,
type: ChannelType.GuildStageVoice,
bitrate: channelData.bitrate ?? 64_000,
userLimit: channelData.userLimit ?? 0
});
case ChannelType.GuildForum:
return guild.channels.create({
...base,
type: ChannelType.GuildForum,
topic: channelData.topic ?? undefined,
nsfw: channelData.nsfw ?? false,
rateLimitPerUser: channelData.rateLimitPerUser ?? 0
});
default:
logger.warn(
{ channelType: channelData.type, channelName: channelData.name },
'Unsupported channel type in backup restore'
);
return null;
}
}
async function applyChannelOverwrites(
channel: GuildChannel,
channelData: GuildBackupPayload['channels'][number],
roleIdMap: Map<string, string>,
summary: RestoreSummary
): Promise<void> {
if (!('permissionOverwrites' in channel)) {
return;
}
const overwrites = mapOverwrites(channelData.permissionOverwrites, roleIdMap);
if (overwrites.length === 0) {
return;
}
try {
await channel.permissionOverwrites.set(overwrites, RESTORE_REASON);
summary.overwritesApplied += overwrites.length;
} catch (error) {
logger.warn(
{ error, channelId: channel.id, guildId: channel.guild.id },
'Failed to apply channel overwrites'
);
}
}
async function applyGuildSettings(
guild: Guild,
payload: GuildBackupPayload,
summary: RestoreSummary
): Promise<void> {
const settings = payload.settings;
if (!settings) {
return;
}
if (settings.verificationLevel !== undefined) {
try {
await guild.setVerificationLevel(settings.verificationLevel, RESTORE_REASON);
summary.settingsApplied.push('verificationLevel');
} catch (error) {
logger.warn({ error, guildId: guild.id }, 'Failed to restore verification level');
}
}
if (settings.explicitContentFilter !== undefined) {
try {
await guild.setExplicitContentFilter(settings.explicitContentFilter, RESTORE_REASON);
summary.settingsApplied.push('explicitContentFilter');
} catch (error) {
logger.warn({ error, guildId: guild.id }, 'Failed to restore explicit content filter');
}
}
if (settings.defaultMessageNotifications !== undefined) {
try {
await guild.setDefaultMessageNotifications(
settings.defaultMessageNotifications,
RESTORE_REASON
);
summary.settingsApplied.push('defaultMessageNotifications');
} catch (error) {
logger.warn({ error, guildId: guild.id }, 'Failed to restore default notifications');
}
}
if (settings.afkTimeout !== undefined) {
try {
await guild.setAFKTimeout(settings.afkTimeout, RESTORE_REASON);
summary.settingsApplied.push('afkTimeout');
} catch (error) {
logger.warn({ error, guildId: guild.id }, 'Failed to restore AFK timeout');
}
}
}
export function ensureBotCanRestore(guild: Guild): boolean {
const me = guild.members.me;
if (!me) {
return false;
}
return me.permissions.has(PermissionFlagsBits.ManageRoles | PermissionFlagsBits.ManageChannels);
}

View File

@@ -0,0 +1,76 @@
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
import { SlashCommandBuilder } from 'discord.js';
export const scheduleCommandData = applyCommandDescription(
new SlashCommandBuilder()
.setName('schedule')
.addSubcommand((sub) =>
applyCommandDescription(sub.setName('create'), 'schedule.create.description')
.addChannelOption((o) =>
applyOptionDescription(o.setName('channel').setRequired(true), 'schedule.common.options.channel')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('content').setRequired(true), 'schedule.common.options.content')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('embed_title'), 'schedule.common.options.embed_title')
)
.addStringOption((o) =>
applyOptionDescription(
o.setName('embed_description'),
'schedule.common.options.embed_description'
)
)
.addIntegerOption((o) =>
applyOptionDescription(
o.setName('embed_color').setMinValue(0).setMaxValue(0xffffff),
'schedule.common.options.embed_color'
)
)
.addRoleOption((o) =>
applyOptionDescription(o.setName('role'), 'schedule.common.options.role')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('when'), 'schedule.create.options.when')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('cron'), 'schedule.create.options.cron')
)
)
.addSubcommand((sub) =>
applyCommandDescription(sub.setName('list'), 'schedule.list.description')
)
.addSubcommand((sub) =>
applyCommandDescription(sub.setName('delete'), 'schedule.delete.description').addStringOption(
(o) => applyOptionDescription(o.setName('id').setRequired(true), 'schedule.common.options.id')
)
),
'schedule.description'
);
export const announceCommandData = applyCommandDescription(
new SlashCommandBuilder()
.setName('announce')
.addChannelOption((o) =>
applyOptionDescription(o.setName('channel').setRequired(true), 'schedule.common.options.channel')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('content').setRequired(true), 'schedule.common.options.content')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('embed_title'), 'schedule.common.options.embed_title')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('embed_description'), 'schedule.common.options.embed_description')
)
.addIntegerOption((o) =>
applyOptionDescription(
o.setName('embed_color').setMinValue(0).setMaxValue(0xffffff),
'schedule.common.options.embed_color'
)
)
.addRoleOption((o) =>
applyOptionDescription(o.setName('role'), 'schedule.common.options.role')
),
'announce.description'
);

View File

@@ -0,0 +1,152 @@
import { ChannelType, PermissionFlagsBits } from 'discord.js';
import { t, tf } from '@nexumi/shared';
import type { SlashCommand } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { requirePermission } from '../../permissions.js';
import { announceCommandData, scheduleCommandData } from './command-definitions.js';
import {
createScheduledMessage,
deleteScheduledMessage,
listScheduledMessages,
SchedulerError,
sendAnnouncement
} from './service.js';
async function ensureManageGuild(
interaction: Parameters<SlashCommand['execute']>[0],
locale: 'de' | 'en'
): Promise<boolean> {
if (!interaction.inGuild()) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return false;
}
return requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale);
}
function readAnnouncementOptions(
interaction: Parameters<SlashCommand['execute']>[0]
): {
channelId: string;
content: string;
embedTitle: string | null;
embedDescription: string | null;
embedColor: number | null;
rolePingId: string | null;
} {
const channel = interaction.options.getChannel('channel', true, [ChannelType.GuildText]);
return {
channelId: channel.id,
content: interaction.options.getString('content', true),
embedTitle: interaction.options.getString('embed_title'),
embedDescription: interaction.options.getString('embed_description'),
embedColor: interaction.options.getInteger('embed_color'),
rolePingId: interaction.options.getRole('role')?.id ?? null
};
}
const scheduleCommand: SlashCommand = {
data: scheduleCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureManageGuild(interaction, locale))) {
return;
}
const sub = interaction.options.getSubcommand();
try {
if (sub === 'create') {
const options = readAnnouncementOptions(interaction);
const schedule = await createScheduledMessage(context, {
guildId: interaction.guildId!,
creatorId: interaction.user.id,
...options,
whenInput: interaction.options.getString('when'),
cron: interaction.options.getString('cron')
});
const timing = schedule.cron
? tf(locale, 'scheduler.created.cron', { cron: schedule.cron })
: schedule.runAt
? tf(locale, 'scheduler.created.at', {
time: `<t:${Math.floor(schedule.runAt.getTime() / 1000)}:F>`
})
: '';
await interaction.reply({
content: tf(locale, 'scheduler.created', { id: schedule.id, timing }),
ephemeral: true
});
return;
}
if (sub === 'list') {
const body = await listScheduledMessages(context, interaction.guildId!, locale);
await interaction.reply({
content: `${t(locale, 'scheduler.list.header')}\n${body}`,
ephemeral: true
});
return;
}
if (sub === 'delete') {
const id = interaction.options.getString('id', true);
const deleted = await deleteScheduledMessage(context, interaction.guildId!, id);
if (!deleted) {
await interaction.reply({
content: t(locale, 'scheduler.error.not_found'),
ephemeral: true
});
return;
}
await interaction.reply({
content: t(locale, 'scheduler.deleted'),
ephemeral: true
});
}
} catch (error) {
if (error instanceof SchedulerError) {
await interaction.reply({
content: t(locale, `scheduler.error.${error.code}`),
ephemeral: true
});
return;
}
throw error;
}
}
};
const announceCommand: SlashCommand = {
data: announceCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureManageGuild(interaction, locale))) {
return;
}
try {
const options = readAnnouncementOptions(interaction);
await sendAnnouncement(context, {
guildId: interaction.guildId!,
creatorId: interaction.user.id,
...options
});
await interaction.reply({
content: t(locale, 'scheduler.announce.sent'),
ephemeral: true
});
} catch (error) {
if (error instanceof SchedulerError) {
await interaction.reply({
content: t(locale, `scheduler.error.${error.code}`),
ephemeral: true
});
return;
}
throw error;
}
}
};
export const scheduleCommands = [scheduleCommand, announceCommand];

View File

@@ -0,0 +1,2 @@
export { scheduleCommands } from './commands.js';
export { sendScheduledMessage } from './service.js';

View File

@@ -0,0 +1,344 @@
import {
EmbedBuilder,
PermissionFlagsBits,
type GuildTextBasedChannel,
type MessageCreateOptions,
type TextChannel
} from 'discord.js';
import { WelcomeEmbedSchema, t, tf } from '@nexumi/shared';
import type { ScheduledMessage } from '@prisma/client';
import { ensureGuild } from '../../guild.js';
import { logger } from '../../logger.js';
import { scheduleQueue } from '../../queues.js';
import type { BotContext } from '../../types.js';
import { parseDuration } from '../moderation/duration.js';
export class SchedulerError extends Error {
constructor(public readonly code: string) {
super(code);
}
}
export type AnnouncementInput = {
guildId: string;
channelId: string;
creatorId: string;
content: string;
embedTitle?: string | null;
embedDescription?: string | null;
embedColor?: number | null;
rolePingId?: string | null;
};
export type ScheduleCreateInput = AnnouncementInput & {
whenInput?: string | null;
cron?: string | null;
};
function parseScheduleWhen(input: string): Date {
const trimmed = input.trim();
if (/^\d+[smhd]$/i.test(trimmed)) {
const ms = parseDuration(trimmed);
if (ms <= 0) {
throw new SchedulerError('invalid_when');
}
return new Date(Date.now() + ms);
}
const parsed = Date.parse(trimmed);
if (Number.isNaN(parsed) || parsed <= Date.now()) {
throw new SchedulerError('invalid_when');
}
return new Date(parsed);
}
function isValidCron(pattern: string): boolean {
const parts = pattern.trim().split(/\s+/);
return parts.length >= 5 && parts.length <= 6;
}
function buildEmbedJson(input: {
embedTitle?: string | null;
embedDescription?: string | null;
embedColor?: number | null;
}): { title?: string; description?: string; color?: number } | null {
const title = input.embedTitle?.trim();
const description = input.embedDescription?.trim();
const color = input.embedColor ?? undefined;
if (!title && !description && color === undefined) {
return null;
}
const embed: { title?: string; description?: string; color?: number } = {};
if (title) {
embed.title = title;
}
if (description) {
embed.description = description;
}
if (color !== undefined) {
embed.color = color;
}
return embed;
}
function parseStoredEmbed(raw: unknown) {
const parsed = WelcomeEmbedSchema.safeParse(raw);
return parsed.success ? parsed.data : null;
}
export function buildAnnouncementPayload(schedule: {
content: string | null;
embed: unknown;
rolePingId: string | null;
}): MessageCreateOptions {
const embedData = parseStoredEmbed(schedule.embed);
const embeds: EmbedBuilder[] = [];
if (embedData) {
const embed = new EmbedBuilder().setColor(embedData.color ?? 0x6366f1);
if (embedData.title) {
embed.setTitle(embedData.title);
}
if (embedData.description) {
embed.setDescription(embedData.description);
}
embeds.push(embed);
}
const baseContent = schedule.content?.trim() ?? '';
const roleMention = schedule.rolePingId ? `<@&${schedule.rolePingId}>` : '';
const content = [roleMention, baseContent].filter(Boolean).join(' ').trim();
const payload: MessageCreateOptions = {};
if (content) {
payload.content = content;
}
if (embeds.length > 0) {
payload.embeds = embeds;
}
if (schedule.rolePingId) {
payload.allowedMentions = { roles: [schedule.rolePingId] };
}
return payload;
}
async function assertSendableChannel(
context: BotContext,
channelId: string
): Promise<GuildTextBasedChannel> {
const channel = await context.client.channels.fetch(channelId);
if (!channel?.isTextBased() || channel.isDMBased()) {
throw new SchedulerError('invalid_channel');
}
const me = channel.guild.members.me;
if (
!me?.permissionsIn(channel).has(PermissionFlagsBits.SendMessages) ||
!me.permissionsIn(channel).has(PermissionFlagsBits.ViewChannel)
) {
throw new SchedulerError('channel_unavailable');
}
return channel;
}
async function enqueueScheduleJob(
schedule: ScheduledMessage,
cron: string | null,
runAt: Date | null
): Promise<string> {
const jobOptions: {
jobId: string;
removeOnComplete: boolean;
removeOnFail: number;
delay?: number;
repeat?: { pattern: string };
} = {
jobId: `schedule-${schedule.id}`,
removeOnComplete: true,
removeOnFail: 50
};
if (cron) {
jobOptions.repeat = { pattern: cron };
} else if (runAt) {
jobOptions.delay = Math.max(0, runAt.getTime() - Date.now());
}
const job = await scheduleQueue.add('scheduleSend', { scheduleId: schedule.id }, jobOptions);
return String(job.id);
}
export async function removeScheduleJob(jobId: string | null): Promise<void> {
if (!jobId) {
return;
}
const job = await scheduleQueue.getJob(jobId);
if (job) {
await job.remove();
}
}
function validateAnnouncementContent(input: AnnouncementInput): void {
const hasContent = input.content.trim().length > 0;
const hasEmbed =
Boolean(input.embedTitle?.trim()) ||
Boolean(input.embedDescription?.trim()) ||
input.embedColor !== null && input.embedColor !== undefined;
if (!hasContent && !hasEmbed) {
throw new SchedulerError('content_required');
}
}
export async function createScheduledMessage(
context: BotContext,
input: ScheduleCreateInput
): Promise<ScheduledMessage> {
await ensureGuild(context.prisma, input.guildId);
validateAnnouncementContent(input);
const whenInput = input.whenInput?.trim();
const cron = input.cron?.trim();
if (!whenInput && !cron) {
throw new SchedulerError('missing_schedule_time');
}
let runAt: Date | null = null;
let cronPattern: string | null = null;
if (cron) {
if (!isValidCron(cron)) {
throw new SchedulerError('invalid_cron');
}
cronPattern = cron;
} else if (whenInput) {
runAt = parseScheduleWhen(whenInput);
}
await assertSendableChannel(context, input.channelId);
const embed = buildEmbedJson(input);
const schedule = await context.prisma.scheduledMessage.create({
data: {
guildId: input.guildId,
channelId: input.channelId,
creatorId: input.creatorId,
content: input.content.trim() || null,
embed: embed ?? undefined,
rolePingId: input.rolePingId ?? null,
cron: cronPattern,
runAt
}
});
const jobId = await enqueueScheduleJob(schedule, cronPattern, runAt);
return context.prisma.scheduledMessage.update({
where: { id: schedule.id },
data: { jobId }
});
}
export async function listScheduledMessages(
context: BotContext,
guildId: string,
locale: 'de' | 'en'
): Promise<string> {
const schedules = await context.prisma.scheduledMessage.findMany({
where: { guildId, enabled: true },
orderBy: { createdAt: 'asc' },
take: 20
});
if (schedules.length === 0) {
return t(locale, 'scheduler.list.empty');
}
return schedules
.map((schedule) => {
const preview =
schedule.content?.slice(0, 60) ??
(schedule.embed ? t(locale, 'scheduler.list.embedPreview') : '—');
const timing = schedule.cron
? tf(locale, 'scheduler.list.cron', { cron: schedule.cron })
: schedule.runAt
? `<t:${Math.floor(schedule.runAt.getTime() / 1000)}:R>`
: '—';
return tf(locale, 'scheduler.list.line', {
id: schedule.id.slice(0, 8),
channel: `<#${schedule.channelId}>`,
timing,
preview
});
})
.join('\n');
}
export async function deleteScheduledMessage(
context: BotContext,
guildId: string,
scheduleIdPrefix: string
): Promise<boolean> {
const schedules = await context.prisma.scheduledMessage.findMany({
where: { guildId, enabled: true }
});
const match = schedules.find((entry) => entry.id.startsWith(scheduleIdPrefix));
if (!match) {
return false;
}
await removeScheduleJob(match.jobId);
await context.prisma.scheduledMessage.delete({ where: { id: match.id } });
return true;
}
export async function sendAnnouncement(
context: BotContext,
input: AnnouncementInput
): Promise<void> {
validateAnnouncementContent(input);
const channel = await assertSendableChannel(context, input.channelId);
const payload = buildAnnouncementPayload({
content: input.content.trim() || null,
embed: buildEmbedJson(input),
rolePingId: input.rolePingId ?? null
});
await (channel as TextChannel).send(payload);
}
export async function sendScheduledMessage(context: BotContext, scheduleId: string): Promise<void> {
const schedule = await context.prisma.scheduledMessage.findUnique({ where: { id: scheduleId } });
if (!schedule || !schedule.enabled) {
return;
}
try {
const channel = await assertSendableChannel(context, schedule.channelId);
const payload = buildAnnouncementPayload(schedule);
if (!payload.content && !payload.embeds?.length) {
throw new SchedulerError('content_required');
}
await (channel as TextChannel).send(payload);
} catch (error) {
logger.error({ scheduleId, error }, 'Failed to send scheduled message');
if (error instanceof SchedulerError && error.code === 'channel_unavailable') {
await context.prisma.scheduledMessage.update({
where: { id: scheduleId },
data: { enabled: false }
});
}
throw error;
}
if (!schedule.cron) {
await removeScheduleJob(schedule.jobId);
await context.prisma.scheduledMessage.delete({ where: { id: scheduleId } });
}
}

View File

@@ -0,0 +1,80 @@
import type { PrismaClient } from '@prisma/client';
import { ensureGuild } from '../../guild.js';
export function utcDayStart(date = new Date()): Date {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
}
export function voiceSessionKey(guildId: string, userId: string): string {
return `stats:voice:${guildId}:${userId}`;
}
export async function incrementMessageActivity(
prisma: PrismaClient,
guildId: string,
userId: string,
channelId: string
): Promise<void> {
await ensureGuild(prisma, guildId);
const day = utcDayStart();
await prisma.activityStat.upsert({
where: {
guildId_userId_channelId_day: {
guildId,
userId,
channelId,
day
}
},
update: {
messageCount: { increment: 1 }
},
create: {
guildId,
userId,
channelId,
day,
messageCount: 1,
voiceMinutes: 0
}
});
}
export async function addVoiceMinutes(
prisma: PrismaClient,
guildId: string,
userId: string,
channelId: string,
joinedAtMs: number
): Promise<void> {
const minutes = Math.floor((Date.now() - joinedAtMs) / 60_000);
if (minutes <= 0) {
return;
}
await ensureGuild(prisma, guildId);
const day = utcDayStart();
await prisma.activityStat.upsert({
where: {
guildId_userId_channelId_day: {
guildId,
userId,
channelId,
day
}
},
update: {
voiceMinutes: { increment: minutes }
},
create: {
guildId,
userId,
channelId,
day,
messageCount: 0,
voiceMinutes: minutes
}
});
}

View File

@@ -0,0 +1,60 @@
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
import { ChannelType, SlashCommandBuilder } from 'discord.js';
export const statsCommandData = applyCommandDescription(
new SlashCommandBuilder()
.setName('stats')
.addSubcommand((sub) =>
applyCommandDescription(sub.setName('setup'), 'stats.setup.description')
.addChannelOption((option) =>
applyOptionDescription(
option
.setName('members_channel')
.setRequired(true)
.addChannelTypes(ChannelType.GuildVoice, ChannelType.GuildText),
'stats.setup.options.members_channel'
)
)
.addChannelOption((option) =>
applyOptionDescription(
option
.setName('online_channel')
.setRequired(true)
.addChannelTypes(ChannelType.GuildVoice, ChannelType.GuildText),
'stats.setup.options.online_channel'
)
)
.addChannelOption((option) =>
applyOptionDescription(
option
.setName('boosts_channel')
.setRequired(true)
.addChannelTypes(ChannelType.GuildVoice, ChannelType.GuildText),
'stats.setup.options.boosts_channel'
)
)
.addStringOption((option) =>
applyOptionDescription(option.setName('members_template'), 'stats.setup.options.members_template')
)
.addStringOption((option) =>
applyOptionDescription(option.setName('online_template'), 'stats.setup.options.online_template')
)
.addStringOption((option) =>
applyOptionDescription(option.setName('boosts_template'), 'stats.setup.options.boosts_template')
)
)
.addSubcommand((sub) => applyCommandDescription(sub.setName('refresh'), 'stats.refresh.description')),
'stats.description'
);
export const invitesCommandData = applyCommandDescription(
new SlashCommandBuilder()
.setName('invites')
.addSubcommand((sub) =>
applyCommandDescription(sub.setName('view'), 'invites.view.description').addUserOption((option) =>
applyOptionDescription(option.setName('user'), 'invites.view.options.user')
)
)
.addSubcommand((sub) => applyCommandDescription(sub.setName('leaderboard'), 'invites.leaderboard.description')),
'invites.description'
);

View File

@@ -0,0 +1,138 @@
import { PermissionFlagsBits } from 'discord.js';
import { t, tf } from '@nexumi/shared';
import type { SlashCommand } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { requirePermission } from '../../permissions.js';
import { invitesCommandData, statsCommandData } from './command-definitions.js';
import { getInviteLeaderboard, getInviteStatsForUser } from './invites.js';
import {
getStatsConfig,
StatsSetupSchema,
updateStatsChannels,
upsertStatsConfig
} from './service.js';
const statsCommand: SlashCommand = {
data: statsCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!interaction.inGuild()) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return;
}
const guildId = interaction.guildId!;
const sub = interaction.options.getSubcommand();
if (sub === 'setup') {
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) {
return;
}
const membersChannel = interaction.options.getChannel('members_channel', true);
const onlineChannel = interaction.options.getChannel('online_channel', true);
const boostsChannel = interaction.options.getChannel('boosts_channel', true);
const parsed = StatsSetupSchema.safeParse({
membersChannelId: membersChannel.id,
onlineChannelId: onlineChannel.id,
boostsChannelId: boostsChannel.id,
membersTemplate: interaction.options.getString('members_template') ?? undefined,
onlineTemplate: interaction.options.getString('online_template') ?? undefined,
boostsTemplate: interaction.options.getString('boosts_template') ?? undefined
});
if (!parsed.success) {
await interaction.reply({ content: t(locale, 'stats.error.invalidSetup'), ephemeral: true });
return;
}
await upsertStatsConfig(context.prisma, guildId, parsed.data);
await updateStatsChannels(context);
await interaction.reply({ content: t(locale, 'stats.setup.done'), ephemeral: true });
return;
}
if (sub === 'refresh') {
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) {
return;
}
const config = await getStatsConfig(context.prisma, guildId);
if (!config?.enabled) {
await interaction.reply({ content: t(locale, 'stats.notConfigured'), ephemeral: true });
return;
}
await updateStatsChannels(context);
await interaction.reply({ content: t(locale, 'stats.refresh.done'), ephemeral: true });
}
}
};
const invitesCommand: SlashCommand = {
data: invitesCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!interaction.inGuild()) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return;
}
const guildId = interaction.guildId!;
const sub = interaction.options.getSubcommand();
if (sub === 'view') {
const targetUser = interaction.options.getUser('user') ?? interaction.user;
const stats = await getInviteStatsForUser(context.prisma, guildId, targetUser.id);
if (!stats || stats.joins === 0) {
await interaction.reply({
content: tf(locale, 'invites.view.empty', { user: `<@${targetUser.id}>` }),
ephemeral: true
});
return;
}
const net = stats.joins - stats.leaves - stats.fakes;
await interaction.reply({
content: tf(locale, 'invites.view.result', {
user: `<@${targetUser.id}>`,
joins: stats.joins,
leaves: stats.leaves,
fakes: stats.fakes,
net
}),
ephemeral: true
});
return;
}
if (sub === 'leaderboard') {
const entries = await getInviteLeaderboard(context.prisma, guildId, 10);
if (entries.length === 0) {
await interaction.reply({ content: t(locale, 'invites.leaderboard.empty'), ephemeral: true });
return;
}
const lines = entries.map((entry, index) => {
const net = entry.joins - entry.leaves - entry.fakes;
return tf(locale, 'invites.leaderboard.line', {
rank: index + 1,
user: `<@${entry.inviterId}>`,
joins: entry.joins,
leaves: entry.leaves,
fakes: entry.fakes,
net
});
});
await interaction.reply({
content: `${t(locale, 'invites.leaderboard.header')}\n${lines.join('\n')}`,
ephemeral: true
});
}
}
};
export const statsCommands: SlashCommand[] = [statsCommand, invitesCommand];

View File

@@ -0,0 +1,120 @@
import { Events } from 'discord.js';
import { logger } from '../../logger.js';
import type { BotContext } from '../../types.js';
import { addVoiceMinutes, incrementMessageActivity, voiceSessionKey } from './activity.js';
import {
isFakeJoin,
recordMemberJoin,
recordMemberLeave,
removeInviteFromCache,
resolveUsedInvite,
syncAllGuildInviteCaches,
syncGuildInviteCache,
upsertInviteInCache
} from './invites.js';
export function registerStatsEvents(context: BotContext): void {
context.client.once(Events.ClientReady, () => {
void syncAllGuildInviteCaches(context.client, context.redis).catch((error) => {
logger.error({ error }, 'Initial invite cache sync failed');
});
});
context.client.on(Events.GuildCreate, (guild) => {
void syncGuildInviteCache(guild, context.redis).catch((error) => {
logger.error({ error, guildId: guild.id }, 'Invite cache sync failed for new guild');
});
});
context.client.on(Events.InviteCreate, (invite) => {
if (!invite.guild) {
return;
}
void upsertInviteInCache(context.redis, invite.guild.id, invite).catch((error) => {
logger.error({ error, inviteCode: invite.code }, 'Failed to update invite cache on create');
});
});
context.client.on(Events.InviteDelete, (invite) => {
if (!invite.guild) {
return;
}
void removeInviteFromCache(context.redis, invite.guild.id, invite.code).catch((error) => {
logger.error({ error, inviteCode: invite.code }, 'Failed to update invite cache on delete');
});
});
context.client.on(Events.GuildMemberAdd, async (member) => {
try {
const usedInvite = await resolveUsedInvite(context.redis, member.guild);
const isFake = isFakeJoin(member);
await recordMemberJoin(
context.prisma,
member.guild.id,
member.id,
usedInvite?.inviterId ?? null,
usedInvite?.code ?? null,
isFake
);
} catch (error) {
logger.error({ error, userId: member.id, guildId: member.guild.id }, 'Invite join tracking failed');
}
});
context.client.on(Events.GuildMemberRemove, async (member) => {
try {
await recordMemberLeave(context.prisma, member.guild.id, member.id);
} catch (error) {
logger.error({ error, userId: member.id, guildId: member.guild.id }, 'Invite leave tracking failed');
}
});
context.client.on(Events.MessageCreate, async (message) => {
try {
if (!message.guild || message.author.bot) {
return;
}
await incrementMessageActivity(
context.prisma,
message.guild.id,
message.author.id,
message.channel.id
);
} catch (error) {
logger.error({ error, messageId: message.id }, 'Activity message tracking failed');
}
});
context.client.on(Events.VoiceStateUpdate, async (oldState, newState) => {
try {
const guildId = newState.guild.id;
const userId = newState.id;
const oldChannelId = oldState.channelId;
const newChannelId = newState.channelId;
if (oldChannelId && oldChannelId !== newChannelId) {
const key = voiceSessionKey(guildId, userId);
const joinedAt = await context.redis.get(key);
await context.redis.del(key);
if (joinedAt) {
await addVoiceMinutes(
context.prisma,
guildId,
userId,
oldChannelId,
Number(joinedAt)
);
}
}
if (newChannelId && newChannelId !== oldChannelId) {
await context.redis.set(voiceSessionKey(guildId, userId), String(Date.now()));
}
} catch (error) {
logger.error({ error, userId: newState.id }, 'Activity voice tracking failed');
}
});
}

View File

@@ -0,0 +1,9 @@
export { statsCommands } from './commands.js';
export { registerStatsEvents } from './events.js';
export {
ensureStatsRecurringJobs,
startStatsWorker,
updateStatsChannels,
STATS_REFRESH_CRON,
STATS_REFRESH_JOB_NAME
} from './service.js';

View File

@@ -0,0 +1,205 @@
import type { Client, Guild, GuildMember, Invite } from 'discord.js';
import type { PrismaClient } from '@prisma/client';
import { ensureGuild } from '../../guild.js';
import type { Redis } from 'ioredis';
const FAKE_ACCOUNT_AGE_MS = 7 * 24 * 60 * 60 * 1000;
export type CachedInvite = {
code: string;
uses: number;
inviterId: string | null;
};
function inviteCacheKey(guildId: string): string {
return `stats:invites:${guildId}`;
}
function parseInviteCache(raw: string | null): Record<string, CachedInvite> {
if (!raw) {
return {};
}
try {
return JSON.parse(raw) as Record<string, CachedInvite>;
} catch {
return {};
}
}
export async function getInviteCache(redis: Redis, guildId: string): Promise<Record<string, CachedInvite>> {
const raw = await redis.get(inviteCacheKey(guildId));
return parseInviteCache(raw);
}
export async function setInviteCache(
redis: Redis,
guildId: string,
invites: Record<string, CachedInvite>
): Promise<void> {
await redis.set(inviteCacheKey(guildId), JSON.stringify(invites));
}
function inviteToCached(invite: Invite): CachedInvite {
return {
code: invite.code,
uses: invite.uses ?? 0,
inviterId: invite.inviter?.id ?? null
};
}
export async function syncGuildInviteCache(guild: Guild, redis: Redis): Promise<void> {
const fetched = await guild.invites.fetch().catch(() => null);
if (!fetched) {
return;
}
const cache: Record<string, CachedInvite> = {};
for (const invite of fetched.values()) {
cache[invite.code] = inviteToCached(invite);
}
await setInviteCache(redis, guild.id, cache);
}
export async function syncAllGuildInviteCaches(client: Client, redis: Redis): Promise<void> {
await Promise.all(
[...client.guilds.cache.values()].map((guild) => syncGuildInviteCache(guild, redis))
);
}
export async function upsertInviteInCache(redis: Redis, guildId: string, invite: Invite): Promise<void> {
const cache = await getInviteCache(redis, guildId);
cache[invite.code] = inviteToCached(invite);
await setInviteCache(redis, guildId, cache);
}
export async function removeInviteFromCache(redis: Redis, guildId: string, code: string): Promise<void> {
const cache = await getInviteCache(redis, guildId);
delete cache[code];
await setInviteCache(redis, guildId, cache);
}
export function isFakeJoin(member: GuildMember): boolean {
return Date.now() - member.user.createdTimestamp < FAKE_ACCOUNT_AGE_MS;
}
export async function resolveUsedInvite(
redis: Redis,
guild: Guild
): Promise<{ code: string; inviterId: string | null } | null> {
const previous = await getInviteCache(redis, guild.id);
const currentInvites = await guild.invites.fetch().catch(() => null);
if (!currentInvites) {
return null;
}
let matched: { code: string; inviterId: string | null } | null = null;
for (const invite of currentInvites.values()) {
const before = previous[invite.code];
const currentUses = invite.uses ?? 0;
const previousUses = before?.uses ?? 0;
if (currentUses > previousUses) {
matched = {
code: invite.code,
inviterId: invite.inviter?.id ?? before?.inviterId ?? null
};
}
previous[invite.code] = inviteToCached(invite);
}
await setInviteCache(redis, guild.id, previous);
return matched;
}
export async function recordMemberJoin(
prisma: PrismaClient,
guildId: string,
userId: string,
inviterId: string | null,
code: string | null,
isFake: boolean
): Promise<void> {
await ensureGuild(prisma, guildId);
await prisma.inviteJoin.upsert({
where: { guildId_userId: { guildId, userId } },
update: {
inviterId,
code,
isFake,
leftAt: null
},
create: {
guildId,
userId,
inviterId,
code,
isFake
}
});
if (!inviterId) {
return;
}
await prisma.inviteStat.upsert({
where: { guildId_inviterId: { guildId, inviterId } },
update: {
joins: { increment: 1 },
...(isFake ? { fakes: { increment: 1 } } : {}),
...(code ? { code } : {})
},
create: {
guildId,
inviterId,
code,
joins: 1,
leaves: 0,
fakes: isFake ? 1 : 0
}
});
}
export async function recordMemberLeave(prisma: PrismaClient, guildId: string, userId: string): Promise<void> {
const join = await prisma.inviteJoin.findUnique({
where: { guildId_userId: { guildId, userId } }
});
if (!join || join.leftAt) {
return;
}
await prisma.inviteJoin.update({
where: { guildId_userId: { guildId, userId } },
data: { leftAt: new Date() }
});
if (!join.inviterId) {
return;
}
await prisma.inviteStat.updateMany({
where: { guildId, inviterId: join.inviterId },
data: { leaves: { increment: 1 } }
});
}
export async function getInviteStatsForUser(
prisma: PrismaClient,
guildId: string,
inviterId: string
) {
return prisma.inviteStat.findUnique({
where: { guildId_inviterId: { guildId, inviterId } }
});
}
export async function getInviteLeaderboard(prisma: PrismaClient, guildId: string, limit = 10) {
return prisma.inviteStat.findMany({
where: { guildId },
orderBy: [{ joins: 'desc' }, { inviterId: 'asc' }],
take: limit
});
}

View File

@@ -0,0 +1,172 @@
import { PermissionFlagsBits, type Guild, type GuildBasedChannel } from 'discord.js';
import { Worker } from 'bullmq';
import { applyCountTemplate } from '@nexumi/shared';
import { z } from 'zod';
import { ensureGuild } from '../../guild.js';
import { logger } from '../../logger.js';
import { redis } from '../../redis.js';
import { statsQueue, statsQueueName } from '../../queues.js';
import type { BotContext } from '../../types.js';
export const StatsSetupSchema = z.object({
membersChannelId: z.string(),
onlineChannelId: z.string(),
boostsChannelId: z.string(),
membersTemplate: z.string().min(1).max(100).optional(),
onlineTemplate: z.string().min(1).max(100).optional(),
boostsTemplate: z.string().min(1).max(100).optional()
});
export type StatsSetupInput = z.infer<typeof StatsSetupSchema>;
export const STATS_REFRESH_CRON = '*/10 * * * *';
export const STATS_REFRESH_JOB_NAME = 'statsRefresh';
export async function getStatsConfig(prisma: BotContext['prisma'], guildId: string) {
await ensureGuild(prisma, guildId);
return prisma.statsConfig.findUnique({ where: { guildId } });
}
export async function upsertStatsConfig(
prisma: BotContext['prisma'],
guildId: string,
input: StatsSetupInput
) {
await ensureGuild(prisma, guildId);
const optionalTemplates = {
...(input.membersTemplate !== undefined ? { membersTemplate: input.membersTemplate } : {}),
...(input.onlineTemplate !== undefined ? { onlineTemplate: input.onlineTemplate } : {}),
...(input.boostsTemplate !== undefined ? { boostsTemplate: input.boostsTemplate } : {})
};
return prisma.statsConfig.upsert({
where: { guildId },
update: {
enabled: true,
membersChannelId: input.membersChannelId,
onlineChannelId: input.onlineChannelId,
boostsChannelId: input.boostsChannelId,
...optionalTemplates
},
create: {
guildId,
enabled: true,
membersChannelId: input.membersChannelId,
onlineChannelId: input.onlineChannelId,
boostsChannelId: input.boostsChannelId,
...optionalTemplates
}
});
}
export function estimateOnlineCount(guild: Guild): number {
if (guild.approximatePresenceCount != null) {
return guild.approximatePresenceCount;
}
return guild.members.cache.filter((member) => {
const status = member.presence?.status;
return status !== undefined && status !== 'offline';
}).size;
}
async function renameStatsChannel(
guild: Guild,
channelId: string | null | undefined,
template: string,
count: number
): Promise<void> {
if (!channelId) {
return;
}
const channel = (await guild.channels.fetch(channelId).catch(() => null)) as GuildBasedChannel | null;
if (!channel || !('setName' in channel) || typeof channel.setName !== 'function') {
logger.warn({ guildId: guild.id, channelId }, 'Stats channel invalid or not renameable');
return;
}
const nextName = applyCountTemplate(template, count).slice(0, 100);
if (channel.name === nextName) {
return;
}
await channel.setName(nextName).catch((error) => {
logger.warn({ error, guildId: guild.id, channelId }, 'Failed to rename stats channel');
});
}
async function refreshGuildStatsChannels(context: BotContext, guildId: string): Promise<void> {
const config = await context.prisma.statsConfig.findUnique({ where: { guildId } });
if (!config?.enabled) {
return;
}
const guild = await context.client.guilds.fetch(guildId).catch(() => null);
if (!guild) {
return;
}
const me = guild.members.me;
if (!me?.permissions.has(PermissionFlagsBits.ManageChannels)) {
logger.warn({ guildId }, 'Missing ManageChannels for stats channel refresh');
return;
}
await guild.members.fetch().catch(() => undefined);
const memberCount = guild.memberCount;
const onlineCount = estimateOnlineCount(guild);
const boostCount = guild.premiumSubscriptionCount ?? 0;
await Promise.all([
renameStatsChannel(guild, config.membersChannelId, config.membersTemplate, memberCount),
renameStatsChannel(guild, config.onlineChannelId, config.onlineTemplate, onlineCount),
renameStatsChannel(guild, config.boostsChannelId, config.boostsTemplate, boostCount)
]);
}
export async function updateStatsChannels(context: BotContext): Promise<void> {
const configs = await context.prisma.statsConfig.findMany({
where: { enabled: true }
});
for (const config of configs) {
try {
await refreshGuildStatsChannels(context, config.guildId);
} catch (error) {
logger.error({ error, guildId: config.guildId }, 'Stats channel refresh failed for guild');
}
}
}
export async function ensureStatsRecurringJobs(): Promise<void> {
await statsQueue.add(
STATS_REFRESH_JOB_NAME,
{},
{
repeat: { pattern: STATS_REFRESH_CRON },
removeOnComplete: 20,
removeOnFail: 20
}
);
}
export function startStatsWorker(context: BotContext): Worker {
const worker = new Worker(
statsQueueName,
async (job) => {
if (job.name === STATS_REFRESH_JOB_NAME) {
await updateStatsChannels(context);
}
},
{ connection: redis }
);
worker.on('failed', (job, error) => {
logger.error({ jobId: job?.id, error }, 'Stats queue job failed');
});
return worker;
}

View File

@@ -5,8 +5,6 @@ import { getGuildLocale } from '../../i18n.js';
import { requirePermission } from '../../permissions.js';
import { voiceCommandData } from './command-definitions.js';
import {
buildControlPanelEmbed,
buildControlPanelRows,
claimTempChannel,
fetchCategory,
getTempVoiceChannelForMember,
@@ -15,6 +13,7 @@ import {
kickFromTempChannel,
lockTempChannel,
renameTempChannel,
sendControlPanelToChannel,
setTempChannelLimit,
transferTempChannelOwnership,
updateTempVoiceConfig
@@ -93,11 +92,8 @@ const voiceCommand: SlashCommand = {
return;
}
await interaction.reply({
embeds: [buildControlPanelEmbed(owned.channel.name, locale)],
components: buildControlPanelRows(owned.channel.id, locale),
ephemeral: true
});
await sendControlPanelToChannel(owned.channel, locale);
await interaction.reply({ content: t(locale, 'tempvoice.panel.sent'), ephemeral: true });
return;
}

View File

@@ -112,16 +112,14 @@ export function buildControlPanelEmbed(channelName: string, locale: Locale): Emb
.setColor(0x6366f1);
}
export async function sendOwnerControlPanelDm(
member: GuildMember,
channelId: string,
channelName: string,
export async function sendControlPanelToChannel(
channel: VoiceChannel,
locale: Locale
): Promise<void> {
const embed = buildControlPanelEmbed(channelName, locale);
const rows = buildControlPanelRows(channelId, locale);
await member.send({ embeds: [embed], components: rows }).catch((error) => {
logger.debug({ error, userId: member.id }, 'Could not DM temp voice control panel');
const embed = buildControlPanelEmbed(channel.name, locale);
const rows = buildControlPanelRows(channel.id, locale);
await channel.send({ embeds: [embed], components: rows }).catch((error) => {
logger.debug({ error, channelId: channel.id }, 'Could not send temp voice control panel');
});
}
@@ -160,7 +158,8 @@ export async function createTempVoiceChannel(
PermissionFlagsBits.Connect,
PermissionFlagsBits.ViewChannel,
PermissionFlagsBits.ManageChannels,
PermissionFlagsBits.MoveMembers
PermissionFlagsBits.MoveMembers,
PermissionFlagsBits.SendMessages
]
},
{
@@ -170,7 +169,8 @@ export async function createTempVoiceChannel(
PermissionFlagsBits.Connect,
PermissionFlagsBits.ViewChannel,
PermissionFlagsBits.ManageChannels,
PermissionFlagsBits.MoveMembers
PermissionFlagsBits.MoveMembers,
PermissionFlagsBits.SendMessages
]
}
],
@@ -197,7 +197,7 @@ export async function createTempVoiceChannel(
const { getGuildLocale } = await import('../../i18n.js');
const locale = await getGuildLocale(context.prisma, member.guild.id);
await sendOwnerControlPanelDm(member, channel.id, channel.name, locale);
await sendControlPanelToChannel(channel, locale);
return channel;
}
@@ -284,10 +284,11 @@ export async function transferTempChannelOwnership(
Connect: true,
ViewChannel: true,
ManageChannels: true,
MoveMembers: true
MoveMembers: true,
SendMessages: true
});
await sendOwnerControlPanelDm(newOwner, channel.id, channel.name, locale);
await sendControlPanelToChannel(channel, locale);
}
export async function claimTempChannel(

View File

@@ -17,3 +17,11 @@ export const ticketQueueName = 'tickets';
export const ticketQueue = new Queue(ticketQueueName, { connection: redis });
export const birthdayQueueName = 'birthdays';
export const birthdayQueue = new Queue(birthdayQueueName, { connection: redis });
export const statsQueueName = 'stats';
export const statsQueue = new Queue(statsQueueName, { connection: redis });
export const feedsQueueName = 'feeds';
export const feedsQueue = new Queue(feedsQueueName, { connection: redis });
export const scheduleQueueName = 'schedules';
export const scheduleQueue = new Queue(scheduleQueueName, { connection: redis });
export const guildBackupQueueName = 'guild-backups';
export const guildBackupQueue = new Queue(guildBackupQueueName, { connection: redis });

View File

@@ -177,35 +177,38 @@ Dieses Dokument hält den aktuellen Implementierungsstand fest. Es wird bei jede
- Manuell auf Test-Server bestätigt (User-Freigabe).
## Phase 4 Giveaways, Tickets, Selfroles, Tags, Starboard, Suggestions, Birthdays, Temp-Voice (Status: implementiert, manuelle Tests ausstehend)
## Phase 4 Giveaways … Temp-Voice (Status: abgeschlossen, manuell bestätigt)
- Manuell auf Test-Server bestätigt (User-Freigabe).
## Phase 5 Stats/Invites, Feeds, Scheduler, Guild-Backup (Status: implementiert + deployed, manuelle Discord-Tests ausstehend)
### Abgeschlossen
- Prisma-Migration `20260722160000_phase4_modules`
- Shared `packages/shared/src/phase4.ts` + Tests
- Alle Module in `commands.ts` / `index.ts` / `jobs.ts` verdrahtet
- Intents: `GuildMessageReactions`; Partials: `Reaction`
- Queues: `giveaways`, `tickets` (inkl. SelfRole-Expire + Auto-Close), `birthdays`
- Prisma-Migration `20260722170000_phase5_modules` (deployed)
- Shared `packages/shared/src/phase5.ts` + Tests
- Module verdrahtet in `commands.ts`, `index.ts`, `jobs.ts`
- Queues/Worker: `stats`, `feeds`, `schedules`, `guild-backups`
- Optional: `TWITCH_CLIENT_ID` / `TWITCH_CLIENT_SECRET`
- E2E: `docker compose up -d --build bot` OK; Bot ready; 74 Guild-Commands registriert
### Module
| Modul | Features |
|--------|----------|
| **Giveaways** | `/giveaway start\|end\|reroll\|list\|delete\|pause`, Join-Button, Auto-End |
| **Tickets** | Panel/Kategorie, Close/Claim/…, HTML-Transkript, Rating, Auto-Close |
| **Selfroles** | Panel create/edit/delete (Buttons/Dropdown/Reactions + Behaviors) |
| **Tags** | create/edit/delete/list/info/run, Auto-Responder |
| **Starboard** | `/starboard setup`, Reaction-Events |
| **Suggestions** | `/suggest`, Staff-Status, Voting-Buttons |
| **Birthdays** | set/remove/next/list/setup, stündlicher Check, 24h-Rolle |
| **Temp-Voice** | setup + Steuerung + Join-to-Create + Control-Panel |
| **Stats/Invites** | `/stats setup\|refresh`, `/invites view\|leaderboard`, Invite-Tracking, ActivityStat, 10-Min-Kanal-Update |
| **Feeds** | `/feeds add\|remove\|list\|test` (Twitch/YouTube/RSS/Reddit), Poll alle 5 Min |
| **Scheduler** | `/schedule create\|list\|delete`, `/announce`, BullMQ once/cron |
| **Guild-Backup** | `/backup create\|list\|info\|delete\|restore` (Owner + Doppelbestätigung) |
### Manuelle Discord-Tests (offen)
- Giveaway, Ticket-Flow, Selfroles, Tags, Starboard, Suggestions, Birthday, Temp-Voice
- `docker compose up -d --build` (Migration + Guild-Command-Sync)
- `/stats setup` + Kanal-Update; Invite-Join → `/invites view|leaderboard`
- `/feeds add` (RSS/Reddit) + `/feeds test`
- `/schedule create` (once) + `/announce`
- `/backup create|list|info` und Delete/Restore-Bestätigung (Owner)
## Nächster geplanter Schritt
- Manuelle Phase-4-Tests, dann nach Freigabe Phase 5 (Statistiken/Invite-Tracking, Social-Feeds, Scheduler, Backup).
- Manuelle Phase-5-Tests, dann nach Freigabe Phase 6 (WebUI-Fundament: OAuth, Layout, Settings-Framework, API).

View File

@@ -963,6 +963,58 @@ export const commandLocales = {
de: 'Nachrichtenvorlage ({user}, {age})',
en: 'Message template ({user}, {age})'
},
'stats.description': {
de: 'Server-Statistik-Kanäle verwalten',
en: 'Manage server statistics channels'
},
'stats.setup.description': {
de: 'Statistik-Kanäle einrichten',
en: 'Configure statistics channels'
},
'stats.setup.options.members_channel': {
de: 'Kanal für Mitgliederzahl',
en: 'Channel for member count'
},
'stats.setup.options.online_channel': {
de: 'Kanal für Online-Anzahl',
en: 'Channel for online count'
},
'stats.setup.options.boosts_channel': {
de: 'Kanal für Boost-Anzahl',
en: 'Channel for boost count'
},
'stats.setup.options.members_template': {
de: 'Vorlage Mitglieder ({count})',
en: 'Members template ({count})'
},
'stats.setup.options.online_template': {
de: 'Vorlage Online ({count})',
en: 'Online template ({count})'
},
'stats.setup.options.boosts_template': {
de: 'Vorlage Boosts ({count})',
en: 'Boosts template ({count})'
},
'stats.refresh.description': {
de: 'Statistik-Kanäle jetzt aktualisieren',
en: 'Refresh statistics channels now'
},
'invites.description': {
de: 'Invite-Statistiken anzeigen',
en: 'Show invite statistics'
},
'invites.view.description': {
de: 'Invite-Statistik eines Nutzers anzeigen',
en: 'Show invite stats for a user'
},
'invites.view.options.user': {
de: 'Nutzer (Standard: du)',
en: 'User (defaults to you)'
},
'invites.leaderboard.description': {
de: 'Top-Einlader anzeigen',
en: 'Show top inviters'
},
'tempvoice.description': {
de: 'Temp-Voice (Join to Create) steuern',
en: 'Control temp voice (join to create)'
@@ -1194,6 +1246,158 @@ export const commandLocales = {
'tag.choice.response_type.embed': {
de: 'Embed',
en: 'Embed'
},
'feeds.description': {
de: 'Social-Feeds verwalten',
en: 'Manage social feeds'
},
'feeds.add.description': {
de: 'Feed hinzufügen',
en: 'Add feed'
},
'feeds.add.options.type': {
de: 'Feed-Typ',
en: 'Feed type'
},
'feeds.add.options.source': {
de: 'Quelle (Kanal, URL oder Subreddit)',
en: 'Source (channel, URL, or subreddit)'
},
'feeds.add.options.channel': {
de: 'Ziel-Kanal',
en: 'Target channel'
},
'feeds.add.options.role': {
de: 'Rolle zum Pingen',
en: 'Role to ping'
},
'feeds.add.options.template': {
de: 'Nachrichtenvorlage ({title}, {url})',
en: 'Message template ({title}, {url})'
},
'feeds.remove.description': {
de: 'Feed entfernen',
en: 'Remove feed'
},
'feeds.remove.options.id': {
de: 'Feed-ID',
en: 'Feed ID'
},
'feeds.list.description': {
de: 'Feeds auflisten',
en: 'List feeds'
},
'feeds.test.description': {
de: 'Feed einmal abrufen und posten',
en: 'Fetch once and post'
},
'feeds.test.options.id': {
de: 'Feed-ID',
en: 'Feed ID'
},
'feeds.choice.type.twitch': {
de: 'Twitch',
en: 'Twitch'
},
'feeds.choice.type.youtube': {
de: 'YouTube',
en: 'YouTube'
},
'feeds.choice.type.rss': {
de: 'RSS',
en: 'RSS'
},
'feeds.choice.type.reddit': {
de: 'Reddit',
en: 'Reddit'
},
'schedule.description': {
de: 'Geplante Nachrichten verwalten',
en: 'Manage scheduled messages'
},
'schedule.create.description': {
de: 'Nachricht planen',
en: 'Schedule a message'
},
'schedule.list.description': {
de: 'Geplante Nachrichten anzeigen',
en: 'List scheduled messages'
},
'schedule.delete.description': {
de: 'Geplante Nachricht löschen',
en: 'Delete a scheduled message'
},
'schedule.create.options.when': {
de: 'Einmalig: Dauer (z. B. 1h) oder ISO-Datum',
en: 'One-shot: duration (e.g. 1h) or ISO date'
},
'schedule.create.options.cron': {
de: 'Wiederkehrend: Cron-Ausdruck',
en: 'Recurring: cron expression'
},
'schedule.common.options.channel': {
de: 'Zielkanal',
en: 'Target channel'
},
'schedule.common.options.content': {
de: 'Nachrichtentext',
en: 'Message text'
},
'schedule.common.options.embed_title': {
de: 'Embed-Titel',
en: 'Embed title'
},
'schedule.common.options.embed_description': {
de: 'Embed-Beschreibung',
en: 'Embed description'
},
'schedule.common.options.embed_color': {
de: 'Embed-Farbe (Dezimal)',
en: 'Embed color (decimal)'
},
'schedule.common.options.role': {
de: 'Zu pingende Rolle',
en: 'Role to ping'
},
'schedule.common.options.id': {
de: 'ID der geplanten Nachricht',
en: 'Scheduled message ID'
},
'announce.description': {
de: 'Sofortige Ankündigung senden',
en: 'Send an immediate announcement'
},
'guildbackup.description': {
de: 'Server-Backups erstellen und wiederherstellen',
en: 'Create and restore server backups'
},
'guildbackup.create.description': {
de: 'Snapshot von Rollen, Kanälen und Einstellungen erstellen',
en: 'Create a snapshot of roles, channels, and settings'
},
'guildbackup.create.options.name': {
de: 'Name des Backups',
en: 'Backup name'
},
'guildbackup.list.description': {
de: 'Backups dieses Servers auflisten',
en: 'List backups for this server'
},
'guildbackup.info.description': {
de: 'Details zu einem Backup anzeigen',
en: 'Show backup details'
},
'guildbackup.delete.description': {
de: 'Backup löschen',
en: 'Delete a backup'
},
'guildbackup.restore.description': {
de: 'Backup wiederherstellen (nur Owner)',
en: 'Restore a backup (owner only)'
},
'guildbackup.common.options.id': {
de: 'Backup-ID',
en: 'Backup ID'
}
} as const;

View File

@@ -6,6 +6,7 @@ export * from './discord-localization.js';
export * from './phase2.js';
export * from './phase3.js';
export * from './phase4.js';
export * from './phase5.js';
export const LocaleSchema = z.enum(['de', 'en']);
export type Locale = z.infer<typeof LocaleSchema>;
@@ -364,6 +365,17 @@ const de: Dictionary = {
'birthdays.list.header': '**Geburtstage (sortiert nach nächstem Datum):**',
'birthdays.next.header': '**Nächste Geburtstage:**',
'birthdays.list.line': '{rank}. {user} — {date} (in {days} T.)',
'stats.notConfigured': 'Server-Statistiken sind noch nicht eingerichtet. Nutze `/stats setup`.',
'stats.setup.done': 'Statistik-Kanäle konfiguriert und aktualisiert.',
'stats.refresh.done': 'Statistik-Kanäle wurden aktualisiert.',
'stats.error.invalidSetup': 'Ungültige Eingabe für die Statistik-Konfiguration.',
'invites.view.empty': '{user} hat noch keine Invite-Statistiken.',
'invites.view.result':
'**Invite-Statistik für {user}**\nJoins: {joins} | Leaves: {leaves} | Fakes: {fakes} | Netto: {net}',
'invites.leaderboard.empty': 'Noch keine Invite-Daten vorhanden.',
'invites.leaderboard.header': '**Top Einlader:**',
'invites.leaderboard.line':
'{rank}. {user} — Joins: {joins}, Leaves: {leaves}, Fakes: {fakes}, Netto: {net}',
'tempvoice.error.disabled': 'Temp-Voice ist auf diesem Server deaktiviert.',
'tempvoice.error.invalidCategory': 'Ungültige Kategorie.',
'tempvoice.error.notOwner': 'Du bist nicht der Besitzer dieses Temp-Voice-Kanals.',
@@ -386,6 +398,7 @@ const de: Dictionary = {
'tempvoice.claim.success': 'Du bist jetzt Besitzer dieses Kanals.',
'tempvoice.panel.title': 'Temp-Voice Steuerung',
'tempvoice.panel.description': 'Verwalte **{channel}** mit den Buttons oder `/voice`-Commands.',
'tempvoice.panel.sent': 'Control-Panel wurde in den Voice-Kanal-Chat gesendet.',
'tempvoice.button.rename': 'Umbenennen',
'tempvoice.button.limit': 'Limit',
'tempvoice.button.lock': 'Sperren',
@@ -436,7 +449,70 @@ const de: Dictionary = {
'tag.error.not_allowed': 'Du darfst diesen Tag hier nicht nutzen.',
'tag.error.invalid_name': 'Ungültiger Tag-Name (132 Zeichen, a-z, 0-9, _, -).',
'tag.error.content_required': 'Text-Tags benötigen Inhalt.',
'tag.error.embed_required': 'Embed-Tags benötigen Titel oder Beschreibung.'
'tag.error.embed_required': 'Embed-Tags benötigen Titel oder Beschreibung.',
'guildbackup.description': 'Server-Backups erstellen und wiederherstellen',
'guildbackup.created': 'Backup **{name}** erstellt (ID: `{id}`).',
'guildbackup.deleted': 'Backup `{id}` gelöscht.',
'guildbackup.list.header': '**Server-Backups**',
'guildbackup.list.empty': 'Keine Backups vorhanden.',
'guildbackup.list.line': '• `{id}` — **{name}** ({date})',
'guildbackup.info.id': 'ID: `{id}`',
'guildbackup.info.name': 'Name: **{name}**',
'guildbackup.info.created': 'Erstellt: {date}',
'guildbackup.info.createdBy': 'Erstellt von: <@{userId}>',
'guildbackup.info.roles': 'Rollen: {count}',
'guildbackup.info.channels': 'Kanäle: {count}',
'guildbackup.info.limitations':
'Hinweis: Backups enthalten keine Nachrichten. Beim Restore werden IDs nicht übernommen; fehlende Rollen/Kanäle werden nach Name ergänzt, bestehende nicht gelöscht.',
'guildbackup.confirm.delete': 'Backup `{id}` wirklich löschen?',
'guildbackup.confirm.restore.step1':
'Backup `{id}` wiederherstellen? Es werden fehlende Rollen/Kanäle ergänzt und Overwrites best-effort angewendet. Bestehende Kanäle werden **nicht** gelöscht.',
'guildbackup.confirm.restore.step2':
'**Letzte Bestätigung:** Backup `{id}` jetzt wiederherstellen? Diese Aktion kann nicht rückgängig gemacht werden.',
'guildbackup.confirm.button.confirm': 'Bestätigen',
'guildbackup.confirm.button.cancel': 'Abbrechen',
'guildbackup.confirm.expired': 'Diese Bestätigung ist abgelaufen.',
'guildbackup.confirm.unauthorized': 'Nur der auslösende Nutzer kann das bestätigen.',
'guildbackup.confirm.cancelled': 'Abgebrochen.',
'guildbackup.restore.done':
'Backup `{id}` wiederhergestellt. Rollen: {rolesCreated} neu, {rolesSkipped} übersprungen. Kanäle: {channelsCreated} neu, {channelsUpdated} aktualisiert. Overwrites: {overwritesApplied}.',
'guildbackup.restore.queued':
'Großes Backup `{id}` — Restore wurde in die Warteschlange gestellt und läuft im Hintergrund.',
'guildbackup.error.not_found': 'Backup nicht gefunden.',
'guildbackup.error.invalid_payload': 'Backup-Daten sind ungültig oder veraltet.',
'guildbackup.error.invalid_name': 'Ungültiger Backup-Name (1100 Zeichen).',
'guildbackup.error.owner_only': 'Nur der Server-Owner kann Backups wiederherstellen.',
'guildbackup.error.bot_missing_permissions':
'Dem Bot fehlen Manage Roles oder Manage Channels für einen Restore.',
'feeds.added': 'Feed `{id}` ({type}) hinzugefügt.',
'feeds.removed': 'Feed `{id}` entfernt.',
'feeds.list.header': '**Social Feeds**',
'feeds.list.empty': 'Keine Feeds konfiguriert.',
'feeds.list.line': '`{id}` — {type}: {source} → {channel}',
'feeds.test.posted': 'Test erfolgreich: {count} Nachricht(en) gesendet.',
'feeds.test.noItems': 'Keine neuen Einträge gefunden.',
'feeds.test.failed': 'Abruf fehlgeschlagen: {reason}',
'feeds.test.skipped': 'Feed-Test übersprungen.',
'feeds.test.twitchSkipped':
'Twitch-Feed übersprungen: TWITCH_CLIENT_ID und TWITCH_CLIENT_SECRET fehlen in der Bot-Konfiguration.',
'feeds.error.not_found': 'Feed nicht gefunden.',
'scheduler.created': 'Geplante Nachricht erstellt (ID: `{id}`). {timing}',
'scheduler.created.at': 'Sendung: {time}',
'scheduler.created.cron': 'Wiederholung: `{cron}`',
'scheduler.deleted': 'Geplante Nachricht gelöscht.',
'scheduler.announce.sent': 'Ankündigung gesendet.',
'scheduler.list.header': '**Geplante Nachrichten**',
'scheduler.list.empty': 'Keine geplanten Nachrichten.',
'scheduler.list.line': '`{id}` — {channel} — {timing} — {preview}',
'scheduler.list.cron': 'Cron `{cron}`',
'scheduler.list.embedPreview': '[Embed]',
'scheduler.error.missing_schedule_time': 'Gib `when` oder `cron` an.',
'scheduler.error.invalid_when': 'Ungültige Zeit. Nutze z. B. 1h, 2d oder ein ISO-Datum.',
'scheduler.error.invalid_cron': 'Ungültiger Cron-Ausdruck (56 Felder).',
'scheduler.error.invalid_channel': 'Ungültiger Kanal.',
'scheduler.error.channel_unavailable': 'Der Bot kann in diesem Kanal nicht senden.',
'scheduler.error.not_found': 'Geplante Nachricht nicht gefunden.',
'scheduler.error.content_required': 'Inhalt oder Embed-Titel/Beschreibung erforderlich.'
};
const en: Dictionary = {
'generic.noPermission': 'You do not have permission for this action.',
@@ -762,6 +838,17 @@ const en: Dictionary = {
'birthdays.list.header': '**Birthdays (sorted by next date):**',
'birthdays.next.header': '**Upcoming birthdays:**',
'birthdays.list.line': '{rank}. {user} — {date} (in {days} d)',
'stats.notConfigured': 'Server statistics are not configured yet. Use `/stats setup`.',
'stats.setup.done': 'Stats channels configured and updated.',
'stats.refresh.done': 'Stats channels have been refreshed.',
'stats.error.invalidSetup': 'Invalid input for stats configuration.',
'invites.view.empty': '{user} has no invite statistics yet.',
'invites.view.result':
'**Invite stats for {user}**\nJoins: {joins} | Leaves: {leaves} | Fakes: {fakes} | Net: {net}',
'invites.leaderboard.empty': 'No invite data available yet.',
'invites.leaderboard.header': '**Top inviters:**',
'invites.leaderboard.line':
'{rank}. {user} — Joins: {joins}, Leaves: {leaves}, Fakes: {fakes}, Net: {net}',
'tempvoice.error.disabled': 'Temp voice is disabled on this server.',
'tempvoice.error.invalidCategory': 'Invalid category.',
'tempvoice.error.notOwner': 'You are not the owner of this temp voice channel.',
@@ -784,6 +871,7 @@ const en: Dictionary = {
'tempvoice.claim.success': 'You are now the owner of this channel.',
'tempvoice.panel.title': 'Temp Voice Control',
'tempvoice.panel.description': 'Manage **{channel}** with the buttons below or `/voice` commands.',
'tempvoice.panel.sent': 'Control panel posted in the voice channel chat.',
'tempvoice.button.rename': 'Rename',
'tempvoice.button.limit': 'Limit',
'tempvoice.button.lock': 'Lock',
@@ -834,7 +922,70 @@ const en: Dictionary = {
'tag.error.not_allowed': 'You are not allowed to use this tag here.',
'tag.error.invalid_name': 'Invalid tag name (132 chars, a-z, 0-9, _, -).',
'tag.error.content_required': 'Text tags require content.',
'tag.error.embed_required': 'Embed tags require a title or description.'
'tag.error.embed_required': 'Embed tags require a title or description.',
'guildbackup.description': 'Create and restore server backups',
'guildbackup.created': 'Created backup **{name}** (ID: `{id}`).',
'guildbackup.deleted': 'Deleted backup `{id}`.',
'guildbackup.list.header': '**Server backups**',
'guildbackup.list.empty': 'No backups found.',
'guildbackup.list.line': '• `{id}` — **{name}** ({date})',
'guildbackup.info.id': 'ID: `{id}`',
'guildbackup.info.name': 'Name: **{name}**',
'guildbackup.info.created': 'Created: {date}',
'guildbackup.info.createdBy': 'Created by: <@{userId}>',
'guildbackup.info.roles': 'Roles: {count}',
'guildbackup.info.channels': 'Channels: {count}',
'guildbackup.info.limitations':
'Note: Backups do not include messages. Restore cannot preserve IDs; missing roles/channels are added by name, existing channels are not deleted.',
'guildbackup.confirm.delete': 'Really delete backup `{id}`?',
'guildbackup.confirm.restore.step1':
'Restore backup `{id}`? Missing roles/channels will be added and overwrites applied best-effort. Existing channels will **not** be deleted.',
'guildbackup.confirm.restore.step2':
'**Final confirmation:** Restore backup `{id}` now? This cannot be undone.',
'guildbackup.confirm.button.confirm': 'Confirm',
'guildbackup.confirm.button.cancel': 'Cancel',
'guildbackup.confirm.expired': 'This confirmation has expired.',
'guildbackup.confirm.unauthorized': 'Only the user who started this action can confirm.',
'guildbackup.confirm.cancelled': 'Cancelled.',
'guildbackup.restore.done':
'Restored backup `{id}`. Roles: {rolesCreated} created, {rolesSkipped} skipped. Channels: {channelsCreated} created, {channelsUpdated} updated. Overwrites: {overwritesApplied}.',
'guildbackup.restore.queued':
'Large backup `{id}` — restore queued and will run in the background.',
'guildbackup.error.not_found': 'Backup not found.',
'guildbackup.error.invalid_payload': 'Backup data is invalid or outdated.',
'guildbackup.error.invalid_name': 'Invalid backup name (1100 characters).',
'guildbackup.error.owner_only': 'Only the server owner can restore backups.',
'guildbackup.error.bot_missing_permissions':
'The bot is missing Manage Roles or Manage Channels for a restore.',
'feeds.added': 'Added feed `{id}` ({type}).',
'feeds.removed': 'Removed feed `{id}`.',
'feeds.list.header': '**Social Feeds**',
'feeds.list.empty': 'No feeds configured.',
'feeds.list.line': '`{id}` — {type}: {source} → {channel}',
'feeds.test.posted': 'Test successful: sent {count} message(s).',
'feeds.test.noItems': 'No new items found.',
'feeds.test.failed': 'Fetch failed: {reason}',
'feeds.test.skipped': 'Feed test skipped.',
'feeds.test.twitchSkipped':
'Twitch feed skipped: TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET are missing in the bot configuration.',
'feeds.error.not_found': 'Feed not found.',
'scheduler.created': 'Scheduled message created (ID: `{id}`). {timing}',
'scheduler.created.at': 'Sends at: {time}',
'scheduler.created.cron': 'Repeats: `{cron}`',
'scheduler.deleted': 'Scheduled message deleted.',
'scheduler.announce.sent': 'Announcement sent.',
'scheduler.list.header': '**Scheduled messages**',
'scheduler.list.empty': 'No scheduled messages.',
'scheduler.list.line': '`{id}` — {channel} — {timing} — {preview}',
'scheduler.list.cron': 'Cron `{cron}`',
'scheduler.list.embedPreview': '[Embed]',
'scheduler.error.missing_schedule_time': 'Provide `when` or `cron`.',
'scheduler.error.invalid_when': 'Invalid time. Use e.g. 1h, 2d, or an ISO date.',
'scheduler.error.invalid_cron': 'Invalid cron expression (56 fields).',
'scheduler.error.invalid_channel': 'Invalid channel.',
'scheduler.error.channel_unavailable': 'The bot cannot send messages in that channel.',
'scheduler.error.not_found': 'Scheduled message not found.',
'scheduler.error.content_required': 'Content or embed title/description is required.'
};
const locales: Record<Locale, Dictionary> = { de, en };

View File

@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest';
import { applyCountTemplate, applyFeedTemplate } from './phase5.js';
describe('phase5 helpers', () => {
it('applies count template', () => {
expect(applyCountTemplate('Members: {count}', 42)).toBe('Members: 42');
});
it('applies feed template', () => {
expect(applyFeedTemplate('{title} - {url}', { title: 'Hi', url: 'https://x.test' })).toBe(
'Hi - https://x.test'
);
});
});

View File

@@ -0,0 +1,66 @@
import { z } from 'zod';
export const SocialFeedTypeSchema = z.enum(['TWITCH', 'YOUTUBE', 'RSS', 'REDDIT']);
export type SocialFeedType = z.infer<typeof SocialFeedTypeSchema>;
export const GuildBackupPayloadSchema = z.object({
version: z.literal(1),
guildName: z.string(),
roles: z.array(
z.object({
id: z.string(),
name: z.string(),
color: z.number(),
hoist: z.boolean(),
mentionable: z.boolean(),
permissions: z.string(),
position: z.number()
})
),
channels: z.array(
z.object({
id: z.string(),
name: z.string(),
type: z.number(),
parentId: z.string().nullable(),
position: z.number(),
topic: z.string().nullable().optional(),
nsfw: z.boolean().optional(),
rateLimitPerUser: z.number().optional(),
bitrate: z.number().optional(),
userLimit: z.number().optional(),
permissionOverwrites: z.array(
z.object({
id: z.string(),
type: z.number(),
allow: z.string(),
deny: z.string()
})
)
})
),
settings: z
.object({
name: z.string().optional(),
verificationLevel: z.number().optional(),
explicitContentFilter: z.number().optional(),
defaultMessageNotifications: z.number().optional(),
afkTimeout: z.number().optional()
})
.optional()
});
export type GuildBackupPayload = z.infer<typeof GuildBackupPayloadSchema>;
export function applyCountTemplate(template: string, count: number): string {
return template.replaceAll('{count}', String(count));
}
export function applyFeedTemplate(
template: string,
vars: Record<string, string>
): string {
return Object.entries(vars).reduce(
(acc, [key, value]) => acc.replaceAll(`{${key}}`, value),
template
);
}