- Added new models for premium tier configurations and assignments, enabling feature limits for free and premium users. - Introduced GDPR deletion logging and commands for user data management. - Enhanced logging configuration with retention days for audit logs. - Updated bot commands and job processing to include new premium and GDPR functionalities. - Improved localization with new keys for premium features and GDPR commands in both English and German. - Added UI components for managing premium settings and logging retention in the WebUI.
240 lines
6.3 KiB
TypeScript
240 lines
6.3 KiB
TypeScript
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 { assertPremiumLimit, PremiumLimitError } from '../../premium.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);
|
|
|
|
const currentCount = await prisma.socialFeed.count({ where: { guildId } });
|
|
try {
|
|
await assertPremiumLimit(prisma, guildId, 'feeds', currentCount);
|
|
} catch (error) {
|
|
if (error instanceof PremiumLimitError) {
|
|
throw new FeedError(error.code === 'feature_disabled' ? 'premium_feature' : 'premium_limit');
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
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');
|
|
}
|
|
}
|
|
}
|