diff --git a/.env.example b/.env.example index 32cb1f2..b5400b4 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/apps/bot/prisma/migrations/20260722170000_phase5_modules/migration.sql b/apps/bot/prisma/migrations/20260722170000_phase5_modules/migration.sql new file mode 100644 index 0000000..6b5fa81 --- /dev/null +++ b/apps/bot/prisma/migrations/20260722170000_phase5_modules/migration.sql @@ -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; diff --git a/apps/bot/prisma/schema.prisma b/apps/bot/prisma/schema.prisma index 6e0d193..59df6aa 100644 --- a/apps/bot/prisma/schema.prisma +++ b/apps/bot/prisma/schema.prisma @@ -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]) +} + diff --git a/apps/bot/src/commands.ts b/apps/bot/src/commands.ts index 1746aa6..351b8db 100644 --- a/apps/bot/src/commands.ts +++ b/apps/bot/src/commands.ts @@ -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])); diff --git a/apps/bot/src/env.ts b/apps/bot/src/env.ts index b5d2e7e..4f41376 100644 --- a/apps/bot/src/env.ts +++ b/apps/bot/src/env.ts @@ -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); diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index d560a4f..c52dbf3 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -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; diff --git a/apps/bot/src/jobs.ts b/apps/bot/src/jobs.ts index 30fb60b..6c8804d 100644 --- a/apps/bot/src/jobs.ts +++ b/apps/bot/src/jobs.ts @@ -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 { removeOnFail: 20 } ); + + await ensureStatsRecurringJobs(); + + await feedsQueue.add( + 'pollFeeds', + {}, + { + repeat: { pattern: '*/5 * * * *' }, + removeOnComplete: 20, + removeOnFail: 20 + } + ); } async function runPgDumpBackup(): Promise { diff --git a/apps/bot/src/modules/feeds/command-definitions.ts b/apps/bot/src/modules/feeds/command-definitions.ts new file mode 100644 index 0000000..8cd6d57 --- /dev/null +++ b/apps/bot/src/modules/feeds/command-definitions.ts @@ -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' +); diff --git a/apps/bot/src/modules/feeds/commands.ts b/apps/bot/src/modules/feeds/commands.ts new file mode 100644 index 0000000..238dee8 --- /dev/null +++ b/apps/bot/src/modules/feeds/commands.ts @@ -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]; diff --git a/apps/bot/src/modules/feeds/fetchers.ts b/apps/bot/src/modules/feeds/fetchers.ts new file mode 100644 index 0000000..8ee1dd2 --- /dev/null +++ b/apps/bot/src/modules/feeds/fetchers.ts @@ -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('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll(''', "'"); +} + +function stripCdata(value: string): string { + return value.replace(/^$/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(/]*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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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' }; + } +} diff --git a/apps/bot/src/modules/feeds/index.ts b/apps/bot/src/modules/feeds/index.ts new file mode 100644 index 0000000..d0e8576 --- /dev/null +++ b/apps/bot/src/modules/feeds/index.ts @@ -0,0 +1,2 @@ +export { feedsCommands } from './commands.js'; +export { pollAllFeeds } from './service.js'; diff --git a/apps/bot/src/modules/feeds/service.ts b/apps/bot/src/modules/feeds/service.ts new file mode 100644 index 0000000..59fdbbb --- /dev/null +++ b/apps/bot/src/modules/feeds/service.ts @@ -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; + +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, 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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'); + } + } +} diff --git a/apps/bot/src/modules/guildbackup/command-definitions.ts b/apps/bot/src/modules/guildbackup/command-definitions.ts new file mode 100644 index 0000000..36edc34 --- /dev/null +++ b/apps/bot/src/modules/guildbackup/command-definitions.ts @@ -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' +); diff --git a/apps/bot/src/modules/guildbackup/commands.ts b/apps/bot/src/modules/guildbackup/commands.ts new file mode 100644 index 0000000..433c930 --- /dev/null +++ b/apps/bot/src/modules/guildbackup/commands.ts @@ -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[0], + locale: 'de' | 'en' +): Promise { + 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]; diff --git a/apps/bot/src/modules/guildbackup/confirmations.ts b/apps/bot/src/modules/guildbackup/confirmations.ts new file mode 100644 index 0000000..1b046e2 --- /dev/null +++ b/apps/bot/src/modules/guildbackup/confirmations.ts @@ -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(); + +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[] { + return [ + new ActionRowBuilder().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 { + 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 { + 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 { + 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 { + 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 { + 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) + ); +} diff --git a/apps/bot/src/modules/guildbackup/index.ts b/apps/bot/src/modules/guildbackup/index.ts new file mode 100644 index 0000000..8653f73 --- /dev/null +++ b/apps/bot/src/modules/guildbackup/index.ts @@ -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'; diff --git a/apps/bot/src/modules/guildbackup/service.ts b/apps/bot/src/modules/guildbackup/service.ts new file mode 100644 index 0000000..ce46f2c --- /dev/null +++ b/apps/bot/src/modules/guildbackup/service.ts @@ -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 { + 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 { + return context.prisma.guildBackup.findMany({ + where: { guildId }, + orderBy: { createdAt: 'desc' }, + take: limit + }); +} + +export async function getGuildBackup( + context: BotContext, + guildId: string, + backupId: string +): Promise { + return context.prisma.guildBackup.findFirst({ + where: { id: backupId, guildId } + }); +} + +export async function deleteGuildBackup( + context: BotContext, + guildId: string, + backupId: string +): Promise { + 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 { + 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 { + 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 { + 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(); + 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 { + const map = new Map(); + 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, + summary: RestoreSummary +): Promise { + 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 +): Promise { + 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, + 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 +): 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, + channelIdMap: Map, + summary: RestoreSummary +): Promise { + 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, + channelIdMap: Map, + summary: RestoreSummary +): Promise { + 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 +): Promise { + 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, + summary: RestoreSummary +): Promise { + 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 { + 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); +} diff --git a/apps/bot/src/modules/scheduler/command-definitions.ts b/apps/bot/src/modules/scheduler/command-definitions.ts new file mode 100644 index 0000000..814d93a --- /dev/null +++ b/apps/bot/src/modules/scheduler/command-definitions.ts @@ -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' +); diff --git a/apps/bot/src/modules/scheduler/commands.ts b/apps/bot/src/modules/scheduler/commands.ts new file mode 100644 index 0000000..0ab88c2 --- /dev/null +++ b/apps/bot/src/modules/scheduler/commands.ts @@ -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[0], + locale: 'de' | 'en' +): Promise { + 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[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: `` + }) + : ''; + + 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]; diff --git a/apps/bot/src/modules/scheduler/index.ts b/apps/bot/src/modules/scheduler/index.ts new file mode 100644 index 0000000..ed21872 --- /dev/null +++ b/apps/bot/src/modules/scheduler/index.ts @@ -0,0 +1,2 @@ +export { scheduleCommands } from './commands.js'; +export { sendScheduledMessage } from './service.js'; diff --git a/apps/bot/src/modules/scheduler/service.ts b/apps/bot/src/modules/scheduler/service.ts new file mode 100644 index 0000000..6c835bc --- /dev/null +++ b/apps/bot/src/modules/scheduler/service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 + ? `` + : '—'; + + 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 { + 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 { + 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 { + 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 } }); + } +} diff --git a/apps/bot/src/modules/stats/activity.ts b/apps/bot/src/modules/stats/activity.ts new file mode 100644 index 0000000..a68aefb --- /dev/null +++ b/apps/bot/src/modules/stats/activity.ts @@ -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 { + 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 { + 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 + } + }); +} diff --git a/apps/bot/src/modules/stats/command-definitions.ts b/apps/bot/src/modules/stats/command-definitions.ts new file mode 100644 index 0000000..596ea62 --- /dev/null +++ b/apps/bot/src/modules/stats/command-definitions.ts @@ -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' +); diff --git a/apps/bot/src/modules/stats/commands.ts b/apps/bot/src/modules/stats/commands.ts new file mode 100644 index 0000000..f3dfa51 --- /dev/null +++ b/apps/bot/src/modules/stats/commands.ts @@ -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]; diff --git a/apps/bot/src/modules/stats/events.ts b/apps/bot/src/modules/stats/events.ts new file mode 100644 index 0000000..630dd97 --- /dev/null +++ b/apps/bot/src/modules/stats/events.ts @@ -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'); + } + }); +} diff --git a/apps/bot/src/modules/stats/index.ts b/apps/bot/src/modules/stats/index.ts new file mode 100644 index 0000000..edb2b4b --- /dev/null +++ b/apps/bot/src/modules/stats/index.ts @@ -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'; diff --git a/apps/bot/src/modules/stats/invites.ts b/apps/bot/src/modules/stats/invites.ts new file mode 100644 index 0000000..730028a --- /dev/null +++ b/apps/bot/src/modules/stats/invites.ts @@ -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 { + if (!raw) { + return {}; + } + try { + return JSON.parse(raw) as Record; + } catch { + return {}; + } +} + +export async function getInviteCache(redis: Redis, guildId: string): Promise> { + const raw = await redis.get(inviteCacheKey(guildId)); + return parseInviteCache(raw); +} + +export async function setInviteCache( + redis: Redis, + guildId: string, + invites: Record +): Promise { + 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 { + const fetched = await guild.invites.fetch().catch(() => null); + if (!fetched) { + return; + } + + const cache: Record = {}; + 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 { + await Promise.all( + [...client.guilds.cache.values()].map((guild) => syncGuildInviteCache(guild, redis)) + ); +} + +export async function upsertInviteInCache(redis: Redis, guildId: string, invite: Invite): Promise { + 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 { + 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 { + 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 { + 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 + }); +} diff --git a/apps/bot/src/modules/stats/service.ts b/apps/bot/src/modules/stats/service.ts new file mode 100644 index 0000000..bad7e13 --- /dev/null +++ b/apps/bot/src/modules/stats/service.ts @@ -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; + +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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/apps/bot/src/modules/tempvoice/commands.ts b/apps/bot/src/modules/tempvoice/commands.ts index ff4f386..4439ac2 100644 --- a/apps/bot/src/modules/tempvoice/commands.ts +++ b/apps/bot/src/modules/tempvoice/commands.ts @@ -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; } diff --git a/apps/bot/src/modules/tempvoice/service.ts b/apps/bot/src/modules/tempvoice/service.ts index 1641425..a65b9f9 100644 --- a/apps/bot/src/modules/tempvoice/service.ts +++ b/apps/bot/src/modules/tempvoice/service.ts @@ -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 { - 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( diff --git a/apps/bot/src/queues.ts b/apps/bot/src/queues.ts index 1eefdd8..da49982 100644 --- a/apps/bot/src/queues.ts +++ b/apps/bot/src/queues.ts @@ -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 }); diff --git a/docs/PHASE-TRACKING.md b/docs/PHASE-TRACKING.md index 9c7923c..9684c10 100644 --- a/docs/PHASE-TRACKING.md +++ b/docs/PHASE-TRACKING.md @@ -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). diff --git a/packages/shared/src/command-locales.ts b/packages/shared/src/command-locales.ts index baf289f..1126fd7 100644 --- a/packages/shared/src/command-locales.ts +++ b/packages/shared/src/command-locales.ts @@ -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; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index cc0ee87..73ac951 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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; @@ -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 (1–32 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 (1–100 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 (5–6 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 (1–32 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 (1–100 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 (5–6 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 = { de, en }; diff --git a/packages/shared/src/phase5.test.ts b/packages/shared/src/phase5.test.ts new file mode 100644 index 0000000..03f5e82 --- /dev/null +++ b/packages/shared/src/phase5.test.ts @@ -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' + ); + }); +}); diff --git a/packages/shared/src/phase5.ts b/packages/shared/src/phase5.ts new file mode 100644 index 0000000..466bf2b --- /dev/null +++ b/packages/shared/src/phase5.ts @@ -0,0 +1,66 @@ +import { z } from 'zod'; + +export const SocialFeedTypeSchema = z.enum(['TWITCH', 'YOUTUBE', 'RSS', 'REDDIT']); +export type SocialFeedType = z.infer; + +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; + +export function applyCountTemplate(template: string, count: number): string { + return template.replaceAll('{count}', String(count)); +} + +export function applyFeedTemplate( + template: string, + vars: Record +): string { + return Object.entries(vars).reduce( + (acc, [key, value]) => acc.replaceAll(`{${key}}`, value), + template + ); +}