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:
54
apps/bot/src/modules/feeds/command-definitions.ts
Normal file
54
apps/bot/src/modules/feeds/command-definitions.ts
Normal 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'
|
||||
);
|
||||
141
apps/bot/src/modules/feeds/commands.ts
Normal file
141
apps/bot/src/modules/feeds/commands.ts
Normal 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];
|
||||
277
apps/bot/src/modules/feeds/fetchers.ts
Normal file
277
apps/bot/src/modules/feeds/fetchers.ts
Normal 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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll(''', "'");
|
||||
}
|
||||
|
||||
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' };
|
||||
}
|
||||
}
|
||||
2
apps/bot/src/modules/feeds/index.ts
Normal file
2
apps/bot/src/modules/feeds/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { feedsCommands } from './commands.js';
|
||||
export { pollAllFeeds } from './service.js';
|
||||
228
apps/bot/src/modules/feeds/service.ts
Normal file
228
apps/bot/src/modules/feeds/service.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user