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

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

View File

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