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

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

View File

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

View File

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

View File

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

View File

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

View File

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