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

@@ -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(