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; 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); 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 { 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'); } } }