├── config ├── bot-sites.json ├── debug.json ├── release-notes.json ├── config.json └── config.example.json ├── .gitbook.yaml ├── src ├── reactions │ ├── index.ts │ └── reaction.ts ├── triggers │ ├── index.ts │ └── trigger.ts ├── extensions │ ├── index.ts │ └── custom-client.ts ├── buttons │ ├── index.ts │ └── button.ts ├── commands │ ├── message │ │ ├── index.ts │ │ └── view-date-sent.ts │ ├── user │ │ ├── index.ts │ │ └── view-date-joined.ts │ ├── index.ts │ ├── chat │ │ ├── index.ts │ │ ├── feedback-command.ts │ │ ├── info-command.ts │ │ ├── help-command.ts │ │ └── releasenotes-command.ts │ ├── command.ts │ └── args.ts ├── enums │ ├── dev-command-name.ts │ ├── info-option.ts │ ├── help-option.ts │ └── index.ts ├── models │ ├── cluster-api │ │ ├── guilds.ts │ │ ├── index.ts │ │ └── shards.ts │ ├── enum-helpers │ │ ├── index.ts │ │ ├── permission.ts │ │ └── language.ts │ ├── master-api │ │ ├── index.ts │ │ └── clusters.ts │ ├── config-models.ts │ ├── internal-models.ts │ ├── manager.ts │ └── api.ts ├── constants │ ├── index.ts │ ├── discord-limits.ts │ ├── misc.ts │ └── paywalled-sites.ts ├── events │ ├── event-handler.ts │ ├── index.ts │ ├── guild-leave-handler.ts │ ├── message-handler.ts │ ├── trigger-handler.ts │ ├── reaction-handler.ts │ ├── button-handler.ts │ └── guild-join-handler.ts ├── middleware │ ├── index.ts │ ├── check-auth.ts │ ├── handle-error.ts │ └── map-class.ts ├── jobs │ ├── index.ts │ ├── job.ts │ └── update-server-count-job.ts ├── controllers │ ├── controller.ts │ ├── index.ts │ ├── guilds-controller.ts │ └── shards-controller.ts ├── services │ ├── index.ts │ ├── job-registry.ts │ ├── openai-service.ts │ ├── http-service.ts │ ├── event-data-service.ts │ ├── master-api-service.ts │ ├── job-service.ts │ └── logger.ts ├── utils │ ├── string-utils.ts │ ├── random-utils.ts │ ├── math-utils.ts │ ├── analytics.ts │ ├── index.ts │ ├── env.ts │ ├── rss-parser.ts │ ├── regex-utils.ts │ ├── read-time.ts │ ├── shard-utils.ts │ ├── thread-utils.ts │ ├── format-utils.ts │ ├── partial-utils.ts │ ├── command-utils.ts │ ├── message-utils.ts │ └── permission-utils.ts ├── drizzle.config.ts ├── db │ ├── index.ts │ └── schema.ts ├── scripts │ └── backup-db.ts └── start-manager.ts ├── .eslintignore ├── .prettierignore ├── .vscode ├── extensions.json ├── tasks.json ├── settings.json └── launch.json ├── .dockerignore ├── drizzle └── migrations │ ├── 0001_add_ai_summary.sql │ ├── 0003_fail-logic.sql │ ├── 0004_comments-summary.sql │ ├── 0002_better_dedupe.sql │ ├── 0000_spicy_khan.sql │ ├── 0005_easy_dakota_north.sql │ └── meta │ ├── _journal.json │ └── 0001_snapshot.json ├── tsconfig.test.json ├── .cursor └── rules │ └── general.mdc ├── process.json ├── .prettierrc.json ├── bunfig.toml ├── .env.example ├── tsconfig.json ├── check-memory.sh ├── tests └── utils │ ├── regex-utils.test.ts │ ├── string-utils.test.ts │ ├── math-utils.test.ts │ ├── random-utils.test.ts │ └── format-utils.test.ts ├── LICENSE ├── lang ├── lang.common.json ├── en-US │ └── commands.json └── logs.json ├── docker-compose.yml ├── README.md ├── Dockerfile ├── .gitignore ├── misc └── Discord Bot Cluster API.postman_collection.json ├── CLAUDE.md ├── .eslintrc.json ├── package.json └── LEGAL.md /config/bot-sites.json: -------------------------------------------------------------------------------- 1 | [] 2 | -------------------------------------------------------------------------------- /.gitbook.yaml: -------------------------------------------------------------------------------- 1 | root: ./docs/ 2 | -------------------------------------------------------------------------------- /src/reactions/index.ts: -------------------------------------------------------------------------------- 1 | export { Reaction } from './reaction.js'; 2 | -------------------------------------------------------------------------------- /src/triggers/index.ts: -------------------------------------------------------------------------------- 1 | export { Trigger } from './trigger.js'; 2 | -------------------------------------------------------------------------------- /src/extensions/index.ts: -------------------------------------------------------------------------------- 1 | export { CustomClient } from './custom-client.js'; 2 | -------------------------------------------------------------------------------- /src/buttons/index.ts: -------------------------------------------------------------------------------- 1 | export { Button, ButtonDeferType } from './button.js'; 2 | -------------------------------------------------------------------------------- /src/commands/message/index.ts: -------------------------------------------------------------------------------- 1 | export { ViewDateSent } from './view-date-sent.js'; 2 | -------------------------------------------------------------------------------- /src/commands/user/index.ts: -------------------------------------------------------------------------------- 1 | export { ViewDateJoined } from './view-date-joined.js'; 2 | -------------------------------------------------------------------------------- /src/enums/dev-command-name.ts: -------------------------------------------------------------------------------- 1 | export enum DevCommandName { 2 | INFO = 'INFO', 3 | } 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # Folders 2 | /.cache 3 | /.git 4 | /dist 5 | /docs 6 | /misc 7 | /node_modules 8 | /temp 9 | -------------------------------------------------------------------------------- /src/models/cluster-api/guilds.ts: -------------------------------------------------------------------------------- 1 | export interface GetGuildsResponse { 2 | guilds: string[]; 3 | } 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Folders 2 | /.cache 3 | /.git 4 | /dist 5 | /docs 6 | /misc 7 | /node_modules 8 | /temp 9 | -------------------------------------------------------------------------------- /src/constants/index.ts: -------------------------------------------------------------------------------- 1 | export { DiscordLimits } from './discord-limits.js'; 2 | export * from './misc.js'; 3 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"] 3 | } 4 | -------------------------------------------------------------------------------- /src/enums/info-option.ts: -------------------------------------------------------------------------------- 1 | export enum InfoOption { 2 | ABOUT = 'ABOUT', 3 | TRANSLATE = 'TRANSLATE', 4 | } 5 | -------------------------------------------------------------------------------- /src/events/event-handler.ts: -------------------------------------------------------------------------------- 1 | export interface EventHandler { 2 | process(...args: any[]): Promise; 3 | } 4 | -------------------------------------------------------------------------------- /src/models/enum-helpers/index.ts: -------------------------------------------------------------------------------- 1 | export { Language } from './language.js'; 2 | export { Permission } from './permission.js'; 3 | -------------------------------------------------------------------------------- /src/enums/help-option.ts: -------------------------------------------------------------------------------- 1 | export enum HelpOption { 2 | CONTACT_SUPPORT = 'CONTACT_SUPPORT', 3 | COMMANDS = 'COMMANDS', 4 | } 5 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Folders 2 | /.cache 3 | /.git 4 | /dist 5 | /docs 6 | /misc 7 | /node_modules 8 | /temp 9 | 10 | # Files 11 | /npm-debug.log 12 | -------------------------------------------------------------------------------- /src/models/master-api/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | RegisterClusterRequest, 3 | RegisterClusterResponse, 4 | LoginClusterResponse, 5 | } from './clusters.js'; 6 | -------------------------------------------------------------------------------- /src/middleware/index.ts: -------------------------------------------------------------------------------- 1 | export { checkAuth } from './check-auth.js'; 2 | export { handleError } from './handle-error.js'; 3 | export { mapClass } from './map-class.js'; 4 | -------------------------------------------------------------------------------- /src/enums/index.ts: -------------------------------------------------------------------------------- 1 | export { DevCommandName } from './dev-command-name.js'; 2 | export { HelpOption } from './help-option.js'; 3 | export { InfoOption } from './info-option.js'; 4 | -------------------------------------------------------------------------------- /src/jobs/index.ts: -------------------------------------------------------------------------------- 1 | export { Job } from './job.js'; 2 | export { UpdateServerCountJob } from './update-server-count-job.js'; 3 | export { FeedPollJob } from './feed-poll-job.js'; 4 | -------------------------------------------------------------------------------- /src/models/cluster-api/index.ts: -------------------------------------------------------------------------------- 1 | export { GetGuildsResponse } from './guilds.js'; 2 | export { GetShardsResponse, ShardInfo, ShardStats, SetShardPresencesRequest } from './shards.js'; 3 | -------------------------------------------------------------------------------- /src/models/config-models.ts: -------------------------------------------------------------------------------- 1 | export interface BotSite { 2 | name: string; 3 | enabled: boolean; 4 | url: string; 5 | authorization: string; 6 | body: string; 7 | } 8 | -------------------------------------------------------------------------------- /drizzle/migrations/0001_add_ai_summary.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "feeds" ADD COLUMN "summarize" boolean DEFAULT false NOT NULL;--> statement-breakpoint 2 | ALTER TABLE "feeds" ADD COLUMN "last_summary" text; -------------------------------------------------------------------------------- /drizzle/migrations/0003_fail-logic.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "feeds" ADD COLUMN "last_failure_notification_at" timestamp;--> statement-breakpoint 2 | ALTER TABLE "feeds" ADD COLUMN "backoff_until" timestamp; -------------------------------------------------------------------------------- /drizzle/migrations/0004_comments-summary.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "feeds" RENAME COLUMN "last_summary" TO "last_article_summary";--> statement-breakpoint 2 | ALTER TABLE "feeds" ADD COLUMN "last_comments_summary" text; -------------------------------------------------------------------------------- /src/controllers/controller.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | 3 | export interface Controller { 4 | path: string; 5 | router: Router; 6 | authToken?: string; 7 | register(): void; 8 | } 9 | -------------------------------------------------------------------------------- /src/commands/index.ts: -------------------------------------------------------------------------------- 1 | export { Args } from './args.js'; 2 | export { Command, CommandDeferType } from './command.js'; 3 | export { ChatCommandMetadata, MessageCommandMetadata, UserCommandMetadata } from './metadata.js'; 4 | -------------------------------------------------------------------------------- /src/jobs/job.ts: -------------------------------------------------------------------------------- 1 | export abstract class Job { 2 | abstract name: string; 3 | abstract log: boolean; 4 | abstract schedule: string; 5 | runOnce = false; 6 | initialDelaySecs = 0; 7 | abstract run(): Promise; 8 | } 9 | -------------------------------------------------------------------------------- /src/controllers/index.ts: -------------------------------------------------------------------------------- 1 | export { Controller } from './controller.js'; 2 | export { GuildsController } from './guilds-controller.js'; 3 | export { ShardsController } from './shards-controller.js'; 4 | export { StatsController } from './stats-controller.js'; 5 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": ".", 5 | "noEmit": true 6 | }, 7 | "include": ["src/**/*", "tests/**/*", "vitest.config.ts"], 8 | "exclude": ["node_modules", "dist"] 9 | } -------------------------------------------------------------------------------- /.cursor/rules/general.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | alwaysApply: true 3 | --- 4 | 5 | This is a Discord RSS bot called "Discorss" that automatically polls RSS feeds and posts new items to Discord channels with AI-powered summarization capabilities. 6 | 7 | We're using bun as our package manager. 8 | -------------------------------------------------------------------------------- /config/debug.json: -------------------------------------------------------------------------------- 1 | { 2 | "override": { 3 | "shardMode": { 4 | "enabled": false, 5 | "value": "worker" 6 | } 7 | }, 8 | "dummyMode": { 9 | "enabled": false, 10 | "whitelist": ["641438053059264512"] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /process.json: -------------------------------------------------------------------------------- 1 | { 2 | "apps": [ 3 | { 4 | "name": "my-bot", 5 | "script": "dist/start-manager.js", 6 | "interpreter": "bun", 7 | "interpreter_args": ["--enable-source-maps"], 8 | "restart_delay": 10000 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /src/triggers/trigger.ts: -------------------------------------------------------------------------------- 1 | import { Message } from 'discord.js'; 2 | 3 | import { EventData } from '../models/internal-models.js'; 4 | 5 | export interface Trigger { 6 | requireGuild: boolean; 7 | triggered(msg: Message): boolean; 8 | execute(msg: Message, data: EventData): Promise; 9 | } 10 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "tabWidth": 4, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": true, 7 | "quoteProps": "as-needed", 8 | "trailingComma": "es5", 9 | "bracketSpacing": true, 10 | "arrowParens": "avoid", 11 | "endOfLine": "auto" 12 | } 13 | -------------------------------------------------------------------------------- /bunfig.toml: -------------------------------------------------------------------------------- 1 | [test] 2 | # Test configuration for Bun 3 | root = "." 4 | preload = [] 5 | 6 | [install] 7 | # Install configuration 8 | cache = "disable" 9 | optional = true 10 | dev = true 11 | peer = true 12 | production = true 13 | lockfile = true 14 | dryRun = false 15 | frozenLockfile = false 16 | exact = false 17 | -------------------------------------------------------------------------------- /src/middleware/check-auth.ts: -------------------------------------------------------------------------------- 1 | import { RequestHandler } from 'express'; 2 | 3 | export function checkAuth(token: string): RequestHandler { 4 | return (req, res, next) => { 5 | if (req.headers.authorization !== token) { 6 | res.sendStatus(401); 7 | return; 8 | } 9 | next(); 10 | }; 11 | } 12 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | # Use 'production' for Neon Postgres 3 | DATABASE_URL="" 4 | # Discord Bot Credentials 5 | DISCORD_CLIENT_ID= 6 | DISCORD_BOT_TOKEN= 7 | DEVELOPER_IDS= 8 | 9 | # OpenAI API Key (optional if using OpenRouter) 10 | OPENAI_API_KEY= 11 | # OpenRouter API Key (preferred) 12 | OPENROUTER_API_KEY= 13 | 14 | # Webhook for feedback messages 15 | FEEDBACK_WEBHOOK_URL= 16 | -------------------------------------------------------------------------------- /src/commands/chat/index.ts: -------------------------------------------------------------------------------- 1 | export { CategoryCommand } from './category-command.js'; 2 | export { DevCommand } from './dev-command.js'; 3 | export { FeedCommand } from './feed-command.js'; 4 | export { HelpCommand } from './help-command.js'; 5 | export { InfoCommand } from './info-command.js'; 6 | export { FeedbackCommand } from './feedback-command.js'; 7 | export { ReleaseNotesCommand } from './releasenotes-command.js'; 8 | -------------------------------------------------------------------------------- /src/services/index.ts: -------------------------------------------------------------------------------- 1 | export { CommandRegistrationService } from './command-registration-service.js'; 2 | export { EventDataService } from './event-data-service.js'; 3 | export { HttpService } from './http-service.js'; 4 | export { JobService } from './job-service.js'; 5 | export { Logger } from './logger.js'; 6 | export { MasterApiService } from './master-api-service.js'; 7 | export { FeedStorageService, type FeedPollConfig } from './feed-storage-service.js'; 8 | -------------------------------------------------------------------------------- /src/utils/string-utils.ts: -------------------------------------------------------------------------------- 1 | export class StringUtils { 2 | public static truncate(input: string, length: number, addEllipsis: boolean = false): string { 3 | if (input.length <= length) { 4 | return input; 5 | } 6 | 7 | let output = input.substring(0, addEllipsis ? length - 3 : length); 8 | if (addEllipsis) { 9 | output += '...'; 10 | } 11 | 12 | return output; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/events/index.ts: -------------------------------------------------------------------------------- 1 | export { ButtonHandler } from './button-handler.js'; 2 | export { CommandHandler } from './command-handler.js'; 3 | export { EventHandler } from './event-handler.js'; 4 | export { GuildJoinHandler } from './guild-join-handler.js'; 5 | export { GuildLeaveHandler } from './guild-leave-handler.js'; 6 | export { ReactionHandler } from './reaction-handler.js'; 7 | export { MessageHandler } from './message-handler.js'; 8 | export { TriggerHandler } from './trigger-handler.js'; 9 | -------------------------------------------------------------------------------- /src/reactions/reaction.ts: -------------------------------------------------------------------------------- 1 | import { Message, MessageReaction, User } from 'discord.js'; 2 | 3 | import { EventData } from '../models/internal-models.js'; 4 | 5 | export interface Reaction { 6 | emoji: string; 7 | requireGuild: boolean; 8 | requireSentByClient: boolean; 9 | requireEmbedAuthorTag: boolean; 10 | execute( 11 | msgReaction: MessageReaction, 12 | msg: Message, 13 | reactor: User, 14 | data: EventData 15 | ): Promise; 16 | } 17 | -------------------------------------------------------------------------------- /src/buttons/button.ts: -------------------------------------------------------------------------------- 1 | import { ButtonInteraction } from 'discord.js'; 2 | 3 | import { EventData } from '../models/internal-models.js'; 4 | 5 | export interface Button { 6 | ids: string[]; 7 | deferType: ButtonDeferType; 8 | requireGuild: boolean; 9 | requireEmbedAuthorTag: boolean; 10 | execute(intr: ButtonInteraction, data: EventData): Promise; 11 | } 12 | 13 | export enum ButtonDeferType { 14 | REPLY = 'REPLY', 15 | UPDATE = 'UPDATE', 16 | NONE = 'NONE', 17 | } 18 | -------------------------------------------------------------------------------- /src/utils/random-utils.ts: -------------------------------------------------------------------------------- 1 | export class RandomUtils { 2 | public static intFromInterval(min: number, max: number): number { 3 | return Math.floor(Math.random() * (max - min + 1) + min); 4 | } 5 | 6 | public static shuffle(input: any[]): any[] { 7 | for (let i = input.length - 1; i > 0; i--) { 8 | const j = Math.floor(Math.random() * (i + 1)); 9 | [input[i], input[j]] = [input[j], input[i]]; 10 | } 11 | return input; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "build", 6 | "type": "shell", 7 | "command": "${workspaceFolder}\\node_modules\\.bin\\tsc", 8 | "args": ["--project", "${workspaceFolder}\\tsconfig.json"] 9 | } 10 | ], 11 | "windows": { 12 | "options": { 13 | "shell": { 14 | "executable": "cmd.exe", 15 | "args": ["/d", "/c"] 16 | } 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'drizzle-kit'; 2 | import * as dotenv from 'dotenv'; 3 | 4 | dotenv.config({ path: '.env' }); 5 | 6 | if (!process.env.DATABASE_URL) { 7 | throw new Error('DATABASE_URL environment variable is required.'); 8 | } 9 | 10 | export default defineConfig({ 11 | schema: './src/db/schema.ts', 12 | out: './drizzle/migrations', 13 | dialect: 'postgresql', 14 | dbCredentials: { 15 | url: process.env.DATABASE_URL, 16 | }, 17 | verbose: true, 18 | strict: true, 19 | }); 20 | -------------------------------------------------------------------------------- /src/models/internal-models.ts: -------------------------------------------------------------------------------- 1 | import { Locale } from 'discord.js'; 2 | 3 | // This class is used to store and pass data along in events 4 | export class EventData { 5 | constructor( 6 | // Event language 7 | public lang: Locale, 8 | // Guild language 9 | public langGuild: Locale, 10 | // User permissions in guild context (optional) 11 | public userPermissions?: string[], 12 | // Additional contextual data (optional) 13 | public context: Record = {} 14 | ) {} 15 | } 16 | -------------------------------------------------------------------------------- /config/release-notes.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "version": "1.0.0", 4 | "date": "2025-01-01", 5 | "title": "Initial Release", 6 | "features": [ 7 | "RSS feed polling and Discord integration", 8 | "AI-powered content summarization", 9 | "Archive.is integration for paywalled content", 10 | "Slash command interface", 11 | "Multi-shard architecture with cluster management" 12 | ], 13 | "improvements": [], 14 | "bugfixes": [], 15 | "url": "https://github.com/mergd/discorss" 16 | } 17 | ] 18 | 19 | -------------------------------------------------------------------------------- /src/utils/math-utils.ts: -------------------------------------------------------------------------------- 1 | export class MathUtils { 2 | public static sum(numbers: number[]): number { 3 | return numbers.reduce((a, b) => a + b, 0); 4 | } 5 | 6 | public static clamp(input: number, min: number, max: number): number { 7 | return Math.min(Math.max(input, min), max); 8 | } 9 | 10 | public static range(start: number, size: number): number[] { 11 | return [...Array(size).keys()].map(i => i + start); 12 | } 13 | 14 | public static ceilToMultiple(input: number, multiple: number): number { 15 | return Math.ceil(input / multiple) * multiple; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/events/guild-leave-handler.ts: -------------------------------------------------------------------------------- 1 | import { Guild } from 'discord.js'; 2 | import { createRequire } from 'node:module'; 3 | 4 | import { EventHandler } from './index.js'; 5 | import { Logger } from '../services/index.js'; 6 | 7 | const require = createRequire(import.meta.url); 8 | let Logs = require('../../lang/logs.json'); 9 | 10 | export class GuildLeaveHandler implements EventHandler { 11 | public async process(guild: Guild): Promise { 12 | Logger.info( 13 | Logs.info.guildLeft 14 | .replaceAll('{GUILD_NAME}', guild.name) 15 | .replaceAll('{GUILD_ID}', guild.id) 16 | ); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/utils/analytics.ts: -------------------------------------------------------------------------------- 1 | import { PostHog } from 'posthog-node'; 2 | import { env } from './env.js'; 3 | 4 | // Initialize PostHog if API key is provided 5 | export const posthog = env.POSTHOG_API_KEY 6 | ? new PostHog(env.POSTHOG_API_KEY, { 7 | host: 'https://app.posthog.com', 8 | flushAt: 20, 9 | flushInterval: 10000, 10 | }) 11 | : null; 12 | 13 | // Export shutdown function for graceful cleanup 14 | // DO NOT add process.on handlers here - they conflict with the main shutdown handlers 15 | export async function shutdownPostHog(): Promise { 16 | if (posthog) { 17 | await posthog.shutdown(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/extensions/custom-client.ts: -------------------------------------------------------------------------------- 1 | import { ActivityType, Client, ClientOptions, Presence } from 'discord.js'; 2 | 3 | export class CustomClient extends Client { 4 | constructor(clientOptions: ClientOptions) { 5 | super(clientOptions); 6 | } 7 | 8 | public setPresence( 9 | type: Exclude, 10 | name: string, 11 | url: string 12 | ): Presence { 13 | return this.user?.setPresence({ 14 | activities: [ 15 | { 16 | type, 17 | name, 18 | url, 19 | }, 20 | ], 21 | }); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/middleware/handle-error.ts: -------------------------------------------------------------------------------- 1 | import { ErrorRequestHandler } from 'express'; 2 | import { createRequire } from 'node:module'; 3 | 4 | import { Logger } from '../services/index.js'; 5 | 6 | const require = createRequire(import.meta.url); 7 | let Logs = require('../../lang/logs.json'); 8 | 9 | export function handleError(): ErrorRequestHandler { 10 | return (error, req, res, _next) => { 11 | Logger.error( 12 | Logs.error.apiRequest 13 | .replaceAll('{HTTP_METHOD}', req.method) 14 | .replaceAll('{URL}', req.url), 15 | error 16 | ); 17 | res.status(500).json({ error: true, message: error.message }); 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /drizzle/migrations/0002_better_dedupe.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "feed_failures" ( 2 | "id" serial PRIMARY KEY NOT NULL, 3 | "feed_id" uuid NOT NULL, 4 | "timestamp" timestamp DEFAULT now() NOT NULL, 5 | "error_message" text 6 | ); 7 | --> statement-breakpoint 8 | ALTER TABLE "feeds" 9 | ALTER COLUMN "id" 10 | SET DATA TYPE uuid USING "id"::uuid; 11 | --> statement-breakpoint 12 | ALTER TABLE "feeds" 13 | ADD COLUMN "recent_links" text; 14 | --> statement-breakpoint 15 | DO $$ BEGIN 16 | ALTER TABLE "feed_failures" 17 | ADD CONSTRAINT "feed_failures_feed_id_feeds_id_fk" FOREIGN KEY ("feed_id") REFERENCES "public"."feeds"("id") ON DELETE cascade ON UPDATE no action; 18 | EXCEPTION 19 | WHEN duplicate_object THEN null; 20 | END $$; -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export { ClientUtils } from './client-utils.js'; 2 | export { CommandUtils } from './command-utils.js'; 3 | export { FormatUtils } from './format-utils.js'; 4 | export { InteractionUtils } from './interaction-utils.js'; 5 | export { MathUtils } from './math-utils.js'; 6 | export { MessageUtils } from './message-utils.js'; 7 | export { PartialUtils } from './partial-utils.js'; 8 | export { PermissionUtils } from './permission-utils.js'; 9 | export { RandomUtils } from './random-utils.js'; 10 | export { RegexUtils } from './regex-utils.js'; 11 | export { ShardUtils } from './shard-utils.js'; 12 | export { StringUtils } from './string-utils.js'; 13 | export { ThreadUtils } from './thread-utils.js'; 14 | export { env } from './env.js'; 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2021", 4 | "module": "es2022", 5 | "lib": ["es2021"], 6 | "declaration": true, 7 | "sourceMap": true, 8 | "outDir": "./dist", 9 | "rootDir": "./src", 10 | "strict": false, 11 | "moduleResolution": "node", 12 | "esModuleInterop": true, 13 | "experimentalDecorators": true, 14 | "emitDecoratorMetadata": true, 15 | "skipLibCheck": true, 16 | "forceConsistentCasingInFileNames": true 17 | }, 18 | "exclude": [ 19 | "dist", 20 | "node_modules", 21 | "tests", 22 | "src/drizzle.config.ts", 23 | "disable", 24 | "cron-restart.js" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /src/models/cluster-api/shards.ts: -------------------------------------------------------------------------------- 1 | import { IsDefined, IsEnum, IsString, IsUrl, Length } from 'class-validator'; 2 | import { ActivityType } from 'discord.js'; 3 | 4 | export interface GetShardsResponse { 5 | shards: ShardInfo[]; 6 | stats: ShardStats; 7 | } 8 | 9 | export interface ShardStats { 10 | shardCount: number; 11 | uptimeSecs: number; 12 | } 13 | 14 | export interface ShardInfo { 15 | id: number; 16 | ready: boolean; 17 | error: boolean; 18 | uptimeSecs?: number; 19 | } 20 | 21 | export class SetShardPresencesRequest { 22 | @IsDefined() 23 | @IsEnum(ActivityType) 24 | type: string; 25 | 26 | @IsDefined() 27 | @IsString() 28 | @Length(1, 128) 29 | name: string; 30 | 31 | @IsDefined() 32 | @IsUrl() 33 | url: string; 34 | } 35 | -------------------------------------------------------------------------------- /src/constants/discord-limits.ts: -------------------------------------------------------------------------------- 1 | export class DiscordLimits { 2 | public static readonly GUILDS_PER_SHARD = 2500; 3 | public static readonly CHANNELS_PER_GUILD = 500; 4 | public static readonly ROLES_PER_GUILD = 250; 5 | public static readonly PINS_PER_CHANNEL = 50; 6 | public static readonly ACTIVE_THREADS_PER_GUILD = 1000; 7 | public static readonly EMBEDS_PER_MESSAGE = 10; 8 | public static readonly FIELDS_PER_EMBED = 25; 9 | public static readonly CHOICES_PER_AUTOCOMPLETE = 25; 10 | public static readonly EMBED_COMBINED_LENGTH = 6000; 11 | public static readonly EMBED_TITLE_LENGTH = 256; 12 | public static readonly EMBED_DESCRIPTION_LENGTH = 4096; 13 | public static readonly EMBED_FIELD_NAME_LENGTH = 256; 14 | public static readonly EMBED_FOOTER_LENGTH = 2048; 15 | } 16 | -------------------------------------------------------------------------------- /check-memory.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Script to monitor Docker container memory usage 3 | 4 | CONTAINER_NAME="discordss-public-bot-1" 5 | 6 | echo "Monitoring memory usage for container: $CONTAINER_NAME" 7 | echo "Press Ctrl+C to stop" 8 | echo "" 9 | echo "Time Memory Usage Memory Limit Percentage" 10 | echo "================================================================" 11 | 12 | while true; do 13 | # Get memory stats 14 | STATS=$(docker stats --no-stream --format "{{.MemUsage}}\t{{.MemPerc}}" $CONTAINER_NAME 2>/dev/null) 15 | 16 | if [ $? -eq 0 ]; then 17 | TIMESTAMP=$(date "+%Y-%m-%d %H:%M:%S") 18 | echo "$TIMESTAMP $STATS" 19 | else 20 | echo "Container not running or not found" 21 | break 22 | fi 23 | 24 | sleep 10 25 | done 26 | 27 | -------------------------------------------------------------------------------- /src/services/job-registry.ts: -------------------------------------------------------------------------------- 1 | import { FeedPollJob } from '../jobs/feed-poll-job.js'; 2 | 3 | /** 4 | * Global registry for job instances to allow access from commands and other services 5 | */ 6 | class JobRegistry { 7 | private static instance: JobRegistry; 8 | private feedPollJob: FeedPollJob | null = null; 9 | 10 | private constructor() {} 11 | 12 | public static getInstance(): JobRegistry { 13 | if (!JobRegistry.instance) { 14 | JobRegistry.instance = new JobRegistry(); 15 | } 16 | return JobRegistry.instance; 17 | } 18 | 19 | public setFeedPollJob(job: FeedPollJob): void { 20 | this.feedPollJob = job; 21 | } 22 | 23 | public getFeedPollJob(): FeedPollJob | null { 24 | return this.feedPollJob; 25 | } 26 | } 27 | 28 | export { JobRegistry }; 29 | -------------------------------------------------------------------------------- /src/constants/misc.ts: -------------------------------------------------------------------------------- 1 | export const ITEMS_PER_PAGE = 7; 2 | export const PAGINATION_TIMEOUT = 5 * 60 * 1000; // 5 minutes 3 | export const MAX_RECENT_LINKS = 30; 4 | export const DEFAULT_FREQUENCY_MINUTES = 15; 5 | export const MIN_FREQUENCY_MINUTES = 3; 6 | export const MAX_FREQUENCY_MINUTES = 1440; 7 | export const FAILURE_NOTIFICATION_THRESHOLD = 10; // Increased from 4 to delay notifications 8 | export const FAILURE_QUIET_PERIOD_HOURS = 24; // Quiet period after threshold is reached 9 | export const MAX_ITEM_HOURS = 12; 10 | export const BASE_MINUTES = 15; // Increased from 5 for more aggressive initial backoff 11 | export const MAX_MINUTES = 1440; // Increased from 360 (6 hours) to 24 hours max 12 | export const MODEL_NAME = 'google/gemini-2.0-flash-lite-001'; 13 | export const CATEGORY_BACKOFF_COORDINATION_FACTOR = 0.5; // When one feed in category fails, apply 50% of its backoff to others 14 | -------------------------------------------------------------------------------- /drizzle/migrations/0000_spicy_khan.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "categories" ( 2 | "id" serial PRIMARY KEY NOT NULL, 3 | "guild_id" text NOT NULL, 4 | "name" text NOT NULL, 5 | "name_lower" text NOT NULL, 6 | "frequency_minutes" integer NOT NULL 7 | ); 8 | --> statement-breakpoint 9 | CREATE TABLE IF NOT EXISTS "feeds" ( 10 | "id" text PRIMARY KEY NOT NULL, 11 | "url" text NOT NULL, 12 | "channel_id" text NOT NULL, 13 | "guild_id" text NOT NULL, 14 | "nickname" text, 15 | "category" text, 16 | "added_by" text NOT NULL, 17 | "frequency_override_minutes" integer, 18 | "last_checked" timestamp, 19 | "last_item_guid" text, 20 | "consecutive_failures" integer DEFAULT 0 NOT NULL, 21 | "created_at" timestamp DEFAULT now() NOT NULL 22 | ); 23 | --> statement-breakpoint 24 | CREATE UNIQUE INDEX IF NOT EXISTS "categories_guild_name_lower_idx" ON "categories" USING btree ("guild_id", "name_lower"); -------------------------------------------------------------------------------- /src/commands/command.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApplicationCommandOptionChoiceData, 3 | AutocompleteFocusedOption, 4 | AutocompleteInteraction, 5 | CommandInteraction, 6 | PermissionsString, 7 | } from 'discord.js'; 8 | import { RateLimiter } from 'discord.js-rate-limiter'; 9 | 10 | import { EventData } from '../models/internal-models.js'; 11 | 12 | export interface Command { 13 | names: string[]; 14 | cooldown?: RateLimiter; 15 | deferType: CommandDeferType; 16 | requireClientPerms: PermissionsString[]; 17 | autocomplete?( 18 | intr: AutocompleteInteraction, 19 | option: AutocompleteFocusedOption 20 | ): Promise; 21 | execute(intr: CommandInteraction, data: EventData): Promise; 22 | } 23 | 24 | export enum CommandDeferType { 25 | PUBLIC = 'PUBLIC', 26 | HIDDEN = 'HIDDEN', 27 | NONE = 'NONE', 28 | } 29 | -------------------------------------------------------------------------------- /src/utils/env.ts: -------------------------------------------------------------------------------- 1 | function getEnvVar(key: string, optional = false): string { 2 | const value = process.env[key]; 3 | if (!value || value.trim() === '') { 4 | if (optional) { 5 | return ''; 6 | } 7 | throw new Error(`Missing required environment variable: ${key}`); 8 | } 9 | return value; 10 | } 11 | 12 | export const env = { 13 | NODE_ENV: process.env.NODE_ENV || 'development', 14 | DATABASE_URL: getEnvVar('DATABASE_URL'), 15 | DISCORD_CLIENT_ID: getEnvVar('DISCORD_CLIENT_ID'), 16 | DISCORD_BOT_TOKEN: getEnvVar('DISCORD_BOT_TOKEN'), 17 | DEVELOPER_IDS: getEnvVar('DEVELOPER_IDS'), 18 | OPENAI_API_KEY: getEnvVar('OPENAI_API_KEY', true), 19 | OPENROUTER_API_KEY: getEnvVar('OPENROUTER_API_KEY', true), 20 | FEEDBACK_WEBHOOK_URL: getEnvVar('FEEDBACK_WEBHOOK_URL', true), 21 | POSTHOG_API_KEY: getEnvVar('POSTHOG_API_KEY', true), 22 | }; 23 | -------------------------------------------------------------------------------- /src/models/master-api/clusters.ts: -------------------------------------------------------------------------------- 1 | import { Type } from 'class-transformer'; 2 | import { 3 | IsDefined, 4 | IsInt, 5 | IsPositive, 6 | IsString, 7 | IsUrl, 8 | Length, 9 | ValidateNested, 10 | } from 'class-validator'; 11 | 12 | export class Callback { 13 | @IsDefined() 14 | @IsUrl({ require_tld: false }) 15 | url: string; 16 | 17 | @IsDefined() 18 | @IsString() 19 | @Length(5, 2000) 20 | token: string; 21 | } 22 | 23 | export class RegisterClusterRequest { 24 | @IsDefined() 25 | @IsInt() 26 | @IsPositive() 27 | shardCount: number; 28 | 29 | @IsDefined() 30 | @ValidateNested() 31 | @Type(() => Callback) 32 | callback: Callback; 33 | } 34 | 35 | export interface RegisterClusterResponse { 36 | id: string; 37 | } 38 | 39 | export interface LoginClusterResponse { 40 | shardList: number[]; 41 | totalShards: number; 42 | } 43 | -------------------------------------------------------------------------------- /src/utils/rss-parser.ts: -------------------------------------------------------------------------------- 1 | import Parser from 'rss-parser'; 2 | 3 | // Shared RSS parser instance to reduce memory footprint 4 | let sharedParser: Parser | null = null; 5 | 6 | export function getRSSParser(): Parser { 7 | if (!sharedParser) { 8 | sharedParser = new Parser({ 9 | customFields: { 10 | item: [ 11 | 'guid', 12 | 'isoDate', 13 | 'creator', 14 | 'author', 15 | 'content', 16 | 'contentSnippet', 17 | 'comments', 18 | ], 19 | }, 20 | // Allow feeds with empty titles or other minor issues 21 | maxRedirects: 5, 22 | timeout: 60000, 23 | }); 24 | } 25 | return sharedParser; 26 | } 27 | 28 | export function resetRSSParser(): void { 29 | sharedParser = null; 30 | } 31 | -------------------------------------------------------------------------------- /tests/utils/regex-utils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from 'vitest'; 2 | import { RegexUtils } from '../../src/utils/index.js'; 3 | 4 | // Mock any configs that might be loaded 5 | vi.mock('../../config/config.json', () => ({})); 6 | vi.mock('../../config/debug.json', () => ({})); 7 | vi.mock('../../lang/logs.json', () => ({})); 8 | 9 | describe('RegexUtils', () => { 10 | describe('discordId', () => { 11 | it('should extract a valid Discord ID', () => { 12 | const input = 'User ID: 123456789012345678'; 13 | const result = RegexUtils.discordId(input); 14 | expect(result).toBe('123456789012345678'); 15 | }); 16 | 17 | it('should return undefined for invalid Discord ID', () => { 18 | const input = 'User ID: 12345'; 19 | const result = RegexUtils.discordId(input); 20 | expect(result).toBeUndefined(); 21 | }); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/utils/regex-utils.ts: -------------------------------------------------------------------------------- 1 | export class RegexUtils { 2 | public static regex(input: string): RegExp { 3 | let match = input.match(/^\/(.*)\/([^/]*)$/); 4 | if (!match) { 5 | return; 6 | } 7 | 8 | return new RegExp(match[1], match[2]); 9 | } 10 | 11 | public static escapeRegex(input: string): string { 12 | return input?.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); 13 | } 14 | 15 | public static discordId(input: string): string { 16 | return input?.match(/\b\d{17,20}\b/)?.[0]; 17 | } 18 | 19 | public static tag(input: string): { username: string; tag: string; discriminator: string } { 20 | let match = input.match(/\b(.+)#([\d]{4})\b/); 21 | if (!match) { 22 | return; 23 | } 24 | 25 | return { 26 | tag: match[0], 27 | username: match[1], 28 | discriminator: match[2], 29 | }; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/commands/message/view-date-sent.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationCommandType, MessageContextMenuCommandInteraction } from 'discord.js'; 2 | import { RateLimiter } from 'discord.js-rate-limiter'; 3 | 4 | import { EventData } from '../../models/internal-models.js'; 5 | import { InteractionUtils } from '../../utils/index.js'; 6 | import { Command, CommandDeferType } from '../index.js'; 7 | 8 | export class ViewDateSent implements Command { 9 | public type = ApplicationCommandType.Message; 10 | public names = ['View Date Sent']; 11 | public cooldown = new RateLimiter(1, 5000); 12 | public deferType = CommandDeferType.HIDDEN; 13 | public requireClientPerms = []; 14 | 15 | public async execute( 16 | intr: MessageContextMenuCommandInteraction, 17 | data: EventData 18 | ): Promise { 19 | await InteractionUtils.send( 20 | intr, 21 | `Message sent: ` 22 | ); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /drizzle/migrations/0005_easy_dakota_north.sql: -------------------------------------------------------------------------------- 1 | DO $$ BEGIN 2 | ALTER TABLE "feeds" ADD COLUMN "use_archive_links" boolean DEFAULT false NOT NULL; 3 | EXCEPTION 4 | WHEN duplicate_column THEN null; 5 | END $$; 6 | --> statement-breakpoint 7 | DO $$ BEGIN 8 | ALTER TABLE "feeds" ADD COLUMN "last_error_message_at" timestamp; 9 | EXCEPTION 10 | WHEN duplicate_column THEN null; 11 | END $$; 12 | --> statement-breakpoint 13 | DO $$ BEGIN 14 | ALTER TABLE "feeds" ADD COLUMN "ignore_errors" boolean DEFAULT false NOT NULL; 15 | EXCEPTION 16 | WHEN duplicate_column THEN null; 17 | END $$; 18 | --> statement-breakpoint 19 | DO $$ BEGIN 20 | ALTER TABLE "feeds" ADD COLUMN "disable_failure_notifications" boolean DEFAULT false NOT NULL; 21 | EXCEPTION 22 | WHEN duplicate_column THEN null; 23 | END $$; 24 | --> statement-breakpoint 25 | DO $$ BEGIN 26 | ALTER TABLE "feeds" ADD COLUMN "disabled" boolean DEFAULT false NOT NULL; 27 | EXCEPTION 28 | WHEN duplicate_column THEN null; 29 | END $$; 30 | --> statement-breakpoint 31 | DO $$ BEGIN 32 | ALTER TABLE "feeds" ADD COLUMN "language" text; 33 | EXCEPTION 34 | WHEN duplicate_column THEN null; 35 | END $$; -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Kevin Novak 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /drizzle/migrations/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "7", 3 | "dialect": "postgresql", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "6", 8 | "when": 1744428789785, 9 | "tag": "0000_spicy_khan", 10 | "breakpoints": true 11 | }, 12 | { 13 | "idx": 1, 14 | "version": "7", 15 | "when": 1744830455750, 16 | "tag": "0001_add_ai_summary", 17 | "breakpoints": true 18 | }, 19 | { 20 | "idx": 2, 21 | "version": "7", 22 | "when": 1745272047092, 23 | "tag": "0002_better_dedupe", 24 | "breakpoints": true 25 | }, 26 | { 27 | "idx": 3, 28 | "version": "7", 29 | "when": 1746888059080, 30 | "tag": "0003_fail-logic", 31 | "breakpoints": true 32 | }, 33 | { 34 | "idx": 4, 35 | "version": "7", 36 | "when": 1746889197709, 37 | "tag": "0004_comments-summary", 38 | "breakpoints": true 39 | }, 40 | { 41 | "idx": 5, 42 | "version": "7", 43 | "when": 1762709378891, 44 | "tag": "0005_easy_dakota_north", 45 | "breakpoints": true 46 | } 47 | ] 48 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[json]": { 3 | "editor.defaultFormatter": "esbenp.prettier-vscode", 4 | "editor.formatOnSave": true 5 | }, 6 | "[markdown]": { 7 | "editor.defaultFormatter": "esbenp.prettier-vscode", 8 | "editor.formatOnSave": true 9 | }, 10 | "[typescript]": { 11 | "editor.defaultFormatter": "esbenp.prettier-vscode", 12 | "editor.formatOnSave": true 13 | }, 14 | "cSpell.enabled": true, 15 | "cSpell.words": [ 16 | "autocompletes", 17 | "autocompleting", 18 | "bot's", 19 | "cmds", 20 | "cooldown", 21 | "cooldowns", 22 | "datas", 23 | "descs", 24 | "discordbotlist", 25 | "discordjs", 26 | "discordlabs", 27 | "discordlist", 28 | "disforge", 29 | "filesize", 30 | "luxon", 31 | "millis", 32 | "Novak", 33 | "ondiscord", 34 | "parens", 35 | "pino", 36 | "regexes", 37 | "respawn", 38 | "respawned", 39 | "restjson", 40 | "unescapes", 41 | "varchar" 42 | ], 43 | "typescript.preferences.importModuleSpecifierEnding": "js" 44 | } 45 | -------------------------------------------------------------------------------- /lang/lang.common.json: -------------------------------------------------------------------------------- 1 | { 2 | "bot": { 3 | "name": "My Bot", 4 | "author": "My Name" 5 | }, 6 | "emojis": { 7 | "yes": "✅", 8 | "no": "❌", 9 | "enabled": "🟢", 10 | "disabled": "🔴", 11 | "info": "ℹ️", 12 | "warning": "⚠️", 13 | "previous": "◀️", 14 | "next": "▶️", 15 | "first": "⏪", 16 | "last": "⏩", 17 | "refresh": "🔄" 18 | }, 19 | "colors": { 20 | "default": "#0099ff", 21 | "success": "#00ff83", 22 | "warning": "#ffcc66", 23 | "error": "#ff4a4a" 24 | }, 25 | "links": { 26 | "author": "https://github.com/", 27 | "docs": "https://top.gg/", 28 | "donate": "https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=EW389DYYSS4FC", 29 | "invite": "https://discord.com/", 30 | "source": "https://github.com/", 31 | "stream": "https://www.twitch.tv/novakevin", 32 | "support": "https://support.discord.com/", 33 | "template": "https://github.com/KevinNovak/Discord-Bot-TypeScript-Template", 34 | "terms": "https://github.com/KevinNovak/Discord-Bot-TypeScript-Template/blob/master/LEGAL.md#terms-of-service", 35 | "vote": "https://top.gg/" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/db/index.ts: -------------------------------------------------------------------------------- 1 | import { drizzle as drizzlePg } from 'drizzle-orm/postgres-js'; 2 | import postgres from 'postgres'; 3 | 4 | import * as schema from './schema.js'; 5 | 6 | if (!process.env.DATABASE_URL) { 7 | throw new Error('DATABASE_URL environment variable is required.'); 8 | } 9 | 10 | console.log('[DB] Using PostgreSQL'); 11 | const connectionString = process.env.DATABASE_URL; 12 | const pgClient = postgres(connectionString, { 13 | ssl: connectionString.includes('sslmode=require') ? 'require' : undefined, 14 | max: 3, // Reduce connection pool size to save memory 15 | idle_timeout: 10, // Close idle connections after 10 seconds 16 | max_lifetime: 60 * 15, // Close connections after 15 minutes 17 | connect_timeout: 30, // 30 second connection timeout 18 | prepare: false, // Disable prepared statements to reduce memory 19 | }); 20 | // Disable logger in production to reduce memory usage 21 | // Logger stores query strings which can accumulate 22 | const dbInstance = drizzlePg(pgClient, { 23 | schema, 24 | logger: process.env.NODE_ENV === 'development', 25 | }); 26 | 27 | export const db = dbInstance; 28 | 29 | export async function closeDb(): Promise { 30 | await pgClient.end(); 31 | } 32 | 33 | // Export schema for easy access 34 | export * from './schema.js'; 35 | -------------------------------------------------------------------------------- /src/controllers/guilds-controller.ts: -------------------------------------------------------------------------------- 1 | import { ShardingManager } from 'discord.js'; 2 | import { Request, Response, Router } from 'express'; 3 | import router from 'express-promise-router'; 4 | import { createRequire } from 'node:module'; 5 | 6 | import { Controller } from './index.js'; 7 | import { GetGuildsResponse } from '../models/cluster-api/index.js'; 8 | 9 | const require = createRequire(import.meta.url); 10 | let Config = require('../../config/config.json'); 11 | 12 | export class GuildsController implements Controller { 13 | public path = '/guilds'; 14 | public router: Router = router(); 15 | public authToken: string = Config.api.secret; 16 | 17 | constructor(private shardManager: ShardingManager) {} 18 | 19 | public register(): void { 20 | this.router.get('/', (req, res) => this.getGuilds(req, res)); 21 | } 22 | 23 | private async getGuilds(req: Request, res: Response): Promise { 24 | let guilds: string[] = [ 25 | ...new Set( 26 | ( 27 | await this.shardManager.broadcastEval(client => [...client.guilds.cache.keys()]) 28 | ).flat() 29 | ), 30 | ]; 31 | 32 | let resBody: GetGuildsResponse = { 33 | guilds, 34 | }; 35 | res.status(200).json(resBody); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/commands/args.ts: -------------------------------------------------------------------------------- 1 | import { APIApplicationCommandBasicOption, ApplicationCommandOptionType } from 'discord.js'; 2 | 3 | import { DevCommandName, HelpOption, InfoOption } from '../enums/index.js'; 4 | 5 | export class Args { 6 | public static readonly DEV_COMMAND: APIApplicationCommandBasicOption = { 7 | name: 'command', 8 | description: 'The specific dev command to run', 9 | type: ApplicationCommandOptionType.String, 10 | choices: [ 11 | { 12 | name: 'info', 13 | value: DevCommandName.INFO, 14 | }, 15 | ], 16 | }; 17 | public static readonly HELP_OPTION: APIApplicationCommandBasicOption = { 18 | name: 'option', 19 | description: 'Help topic to display', 20 | type: ApplicationCommandOptionType.String, 21 | choices: [ 22 | { 23 | name: 'Contact Support', 24 | value: HelpOption.CONTACT_SUPPORT, 25 | }, 26 | { 27 | name: 'Commands', 28 | value: HelpOption.COMMANDS, 29 | }, 30 | ], 31 | }; 32 | public static readonly INFO_OPTION: APIApplicationCommandBasicOption = { 33 | name: 'option', 34 | description: 'Info topic to display', 35 | type: ApplicationCommandOptionType.String, 36 | choices: [ 37 | { 38 | name: 'About', 39 | value: InfoOption.ABOUT, 40 | }, 41 | ], 42 | }; 43 | } 44 | -------------------------------------------------------------------------------- /src/commands/user/view-date-joined.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationCommandType, UserContextMenuCommandInteraction } from 'discord.js'; 2 | import { RateLimiter } from 'discord.js-rate-limiter'; 3 | 4 | import { EventData } from '../../models/internal-models.js'; 5 | import { InteractionUtils } from '../../utils/index.js'; 6 | import { Command, CommandDeferType } from '../index.js'; 7 | 8 | export class ViewDateJoined implements Command { 9 | public type = ApplicationCommandType.User; 10 | public names = ['View Date Joined']; 11 | public cooldown = new RateLimiter(1, 5000); 12 | public deferType = CommandDeferType.HIDDEN; 13 | public requireClientPerms = []; 14 | 15 | public async execute(intr: UserContextMenuCommandInteraction, data: EventData): Promise { 16 | let target = intr.targetMember ?? intr.targetUser; 17 | let joinTimestamp: number; 18 | 19 | // Check if targetMember exists and is a full GuildMember instance 20 | if (intr.targetMember && 'joinedTimestamp' in intr.targetMember) { 21 | joinTimestamp = intr.targetMember.joinedTimestamp; 22 | } else { 23 | // Fallback to user creation timestamp if not in guild or not a full member object 24 | joinTimestamp = intr.targetUser.createdTimestamp; 25 | } 26 | 27 | await InteractionUtils.send( 28 | intr, 29 | `${target.toString()} ${intr.targetMember ? 'joined' : 'was created'}: ` 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/middleware/map-class.ts: -------------------------------------------------------------------------------- 1 | import { ClassConstructor, plainToInstance } from 'class-transformer'; 2 | import { validate, ValidationError } from 'class-validator'; 3 | import { NextFunction, Request, RequestHandler, Response } from 'express'; 4 | 5 | export function mapClass(cls: ClassConstructor): RequestHandler { 6 | return async (req: Request, res: Response, next: NextFunction) => { 7 | // Map to class 8 | let obj: object = plainToInstance(cls, req.body); 9 | 10 | // Validate class 11 | let errors = await validate(obj, { 12 | skipMissingProperties: true, 13 | whitelist: true, 14 | forbidNonWhitelisted: false, 15 | forbidUnknownValues: true, 16 | }); 17 | if (errors.length > 0) { 18 | res.status(400).send({ error: true, errors: formatValidationErrors(errors) }); 19 | return; 20 | } 21 | 22 | // Set validated class to locals 23 | res.locals.input = obj; 24 | next(); 25 | }; 26 | } 27 | 28 | interface ValidationErrorLog { 29 | property: string; 30 | constraints?: { [type: string]: string }; 31 | children?: ValidationErrorLog[]; 32 | } 33 | 34 | function formatValidationErrors(errors: ValidationError[]): ValidationErrorLog[] { 35 | return errors.map(error => ({ 36 | property: error.property, 37 | constraints: error.constraints, 38 | children: error.children?.length > 0 ? formatValidationErrors(error.children) : undefined, 39 | })); 40 | } 41 | -------------------------------------------------------------------------------- /tests/utils/string-utils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from 'vitest'; 2 | import { StringUtils } from '../../src/utils/index.js'; 3 | 4 | describe('StringUtils', () => { 5 | describe('truncate', () => { 6 | it('should return the input string when shorter than the specified length', () => { 7 | const input = 'Hello, world!'; 8 | const result = StringUtils.truncate(input, 20); 9 | expect(result).toBe(input); 10 | }); 11 | 12 | it('should truncate the string to the specified length', () => { 13 | const input = 'Hello, world!'; 14 | const result = StringUtils.truncate(input, 5); 15 | expect(result).toBe('Hello'); 16 | expect(result.length).toBe(5); 17 | }); 18 | 19 | it('should add ellipsis when specified', () => { 20 | const input = 'Hello, world!'; 21 | const result = StringUtils.truncate(input, 8, true); 22 | expect(result).toBe('Hello...'); 23 | expect(result.length).toBe(8); 24 | }); 25 | 26 | it('should handle edge case of empty string', () => { 27 | const input = ''; 28 | const result = StringUtils.truncate(input, 5); 29 | expect(result).toBe(''); 30 | }); 31 | 32 | it('should handle exact length input correctly', () => { 33 | const input = 'Hello'; 34 | const result = StringUtils.truncate(input, 5); 35 | expect(result).toBe('Hello'); 36 | expect(result.length).toBe(5); 37 | }); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /src/events/message-handler.ts: -------------------------------------------------------------------------------- 1 | import { Message } from 'discord.js'; 2 | 3 | import { EventHandler, TriggerHandler } from './index.js'; 4 | import { FormatUtils, ClientUtils } from '../utils/index.js'; 5 | 6 | export class MessageHandler implements EventHandler { 7 | constructor(private triggerHandler: TriggerHandler) {} 8 | 9 | public async process(msg: Message): Promise { 10 | // Don't respond to system messages or self 11 | if (msg.system || msg.author.id === msg.client.user?.id) { 12 | return; 13 | } 14 | 15 | // Check if the bot was mentioned 16 | if (msg.mentions.has(msg.client.user.id)) { 17 | // Prevent responding to mentions in replies or complex mention scenarios if desired 18 | // Simple check: respond only if the mention is the first part of the message 19 | const mentionPrefix = `<@${msg.client.user.id}>`; 20 | if (msg.content.trim().startsWith(mentionPrefix)) { 21 | const helpCommand = await ClientUtils.findAppCommand(msg.client, 'help'); 22 | const helpCommandMention = helpCommand 23 | ? FormatUtils.commandMention(helpCommand) 24 | : '/help'; 25 | await msg.reply( 26 | `Hi ${msg.author}! Need help? Use ${helpCommandMention} to see my commands.` 27 | ); 28 | return; // Don't process triggers if responding to a ping 29 | } 30 | } 31 | 32 | // Process trigger 33 | await this.triggerHandler.process(msg); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | db: 5 | image: postgres:16 6 | profiles: ["localdb"] 7 | environment: 8 | POSTGRES_DB: discorss 9 | POSTGRES_USER: discorss 10 | POSTGRES_PASSWORD: discorss 11 | volumes: 12 | - pgdata:/var/lib/postgresql/data 13 | healthcheck: 14 | test: ["CMD", "pg_isready", "-U", "discorss"] 15 | interval: 5s 16 | timeout: 5s 17 | retries: 5 18 | ports: 19 | - "5432:5432" 20 | 21 | bot: 22 | build: . 23 | env_file: .env 24 | restart: unless-stopped 25 | environment: 26 | # Use DATABASE_URL from .env if set, otherwise default to local db 27 | DATABASE_URL: ${DATABASE_URL:-postgres://discorss:discorss@db:5432/discorss} 28 | DISCORD_CLIENT_ID: ${DISCORD_CLIENT_ID} 29 | DISCORD_BOT_TOKEN: ${DISCORD_BOT_TOKEN} 30 | DEVELOPER_IDS: ${DEVELOPER_IDS} 31 | OPENAI_API_KEY: ${OPENAI_API_KEY} 32 | depends_on: 33 | db: 34 | condition: service_healthy 35 | # Tighter memory limits to force OOM and restart on memory leak (temporary mitigation) 36 | deploy: 37 | resources: 38 | limits: 39 | memory: 512M # Lower limit to trigger OOM faster 40 | reservations: 41 | memory: 128M 42 | # Uncomment if you want to expose a port for a web UI/API 43 | # ports: 44 | # - "3000:3000" 45 | 46 | volumes: 47 | pgdata: 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Discorss: RSS feeds for Discord 2 | 3 | Features: 4 | 5 | - Add RSS feeds to any channel 6 | - Automatically poll feeds and send new items to the channel 7 | - Automatically summarize content 8 | - Archive.is links for paywalled content 9 | - Easy self hosting 10 | - Free and open source 11 | - Slash command native – no separate UI like MonitoRSS 12 | 13 | ## Deployment 14 | 15 | Fill out the `.env` file with your configuration. 16 | After installing dependencies, register the slash commands by running `pnpm commands:register`. 17 | 18 | Set up a bot in the Discord developer portal and put the token in for the `DISCORD_BOT_TOKEN` and the client id in for the `DISCORD_CLIENT_ID` variable. 19 | 20 | Get a Postgres-compatible database URL and set it as `DATABASE_URL` in your `.env`. You can use an external provider like [Neon](https://neon.tech/) or add a PostgreSQL service directly within your Railway project dashboard. Railway will automatically provide the `DATABASE_URL` environment variable. 21 | 22 | ### Docker Compose (with optional local Postgres) 23 | 24 | This project uses Docker Compose profiles to make the local Postgres database optional. 25 | 26 | - **To use a local Postgres database:** 27 | 28 | ```sh 29 | docker-compose up --profile localdb 30 | ``` 31 | 32 | This starts both the bot and a local Postgres service. The bot will connect to the local database by default. 33 | 34 | - **To use an external database (e.g., Neon, Railway):** 35 | ```sh 36 | docker-compose up 37 | ``` 38 | This starts only the bot service. Make sure your `.env` contains a valid external `DATABASE_URL`. 39 | 40 | For deployment, you can use [Railway](https://railway.com/) or any other platform that supports Docker or Node.js. 41 | -------------------------------------------------------------------------------- /src/commands/chat/feedback-command.ts: -------------------------------------------------------------------------------- 1 | import { ChatInputCommandInteraction, PermissionsString } from 'discord.js'; 2 | import fetch from 'node-fetch'; 3 | 4 | import { EventData } from '../../models/internal-models.js'; 5 | import { InteractionUtils } from '../../utils/index.js'; 6 | import { Command, CommandDeferType } from '../index.js'; 7 | import { env } from '../../utils/env.js'; 8 | 9 | const GITHUB_REPO_URL = 'https://github.com/mergd/discorss'; 10 | const GITHUB_ISSUES_URL = `${GITHUB_REPO_URL}/issues/new`; 11 | 12 | export class FeedbackCommand implements Command { 13 | public names = ['feedback']; 14 | public deferType = CommandDeferType.HIDDEN; 15 | public requireClientPerms: PermissionsString[] = []; 16 | 17 | public async execute(intr: ChatInputCommandInteraction, _data: EventData): Promise { 18 | const message = intr.options.getString('message', true); 19 | const webhook = env.FEEDBACK_WEBHOOK_URL; 20 | 21 | if (webhook) { 22 | try { 23 | await fetch(webhook, { 24 | method: 'POST', 25 | headers: { 'Content-Type': 'application/json' }, 26 | body: JSON.stringify({ 27 | content: `Feedback from ${intr.user.tag} (${intr.user.id}) in ${intr.guild?.name ?? 'DM'}: ${message}`, 28 | }), 29 | }); 30 | } catch { 31 | // ignore errors sending feedback 32 | } 33 | } 34 | 35 | await InteractionUtils.send( 36 | intr, 37 | `Thank you for your feedback! For bug reports and feature requests, please consider opening an issue on GitHub:\n${GITHUB_ISSUES_URL}`, 38 | true 39 | ); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/utils/read-time.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Calculates the estimated read time for a given text content. 3 | * Uses the standard reading speed of 200-250 words per minute for average readers. 4 | */ 5 | 6 | const WORDS_PER_MINUTE = 225; // Average reading speed for adults 7 | const MIN_READ_TIME = 1; // Minimum read time in minutes 8 | 9 | /** 10 | * Calculates read time in minutes based on word count 11 | * @param text The text content to analyze 12 | * @returns Read time in minutes (minimum 1 minute) 13 | */ 14 | export function calculateReadTime(text: string): number { 15 | if (!text || text.trim().length === 0) { 16 | return MIN_READ_TIME; 17 | } 18 | 19 | // Count words by splitting on whitespace and filtering out empty strings 20 | const wordCount = text 21 | .trim() 22 | .split(/\s+/) 23 | .filter(word => word.length > 0).length; 24 | 25 | // Calculate read time in minutes, rounded up to nearest minute 26 | const readTimeMinutes = Math.ceil(wordCount / WORDS_PER_MINUTE); 27 | 28 | // Ensure minimum read time 29 | return Math.max(readTimeMinutes, MIN_READ_TIME); 30 | } 31 | 32 | /** 33 | * Formats read time as a human-readable string 34 | * @param minutes The read time in minutes 35 | * @returns Formatted read time string (e.g., "3 min read", "1 min read") 36 | */ 37 | export function formatReadTime(minutes: number): string { 38 | return `${minutes} min read`; 39 | } 40 | 41 | /** 42 | * Calculates and formats read time in one function 43 | * @param text The text content to analyze 44 | * @returns Formatted read time string in italics for Discord 45 | */ 46 | export function getFormattedReadTime(text: string): string { 47 | const readTime = calculateReadTime(text); 48 | return `*${formatReadTime(readTime)}*`; 49 | } 50 | -------------------------------------------------------------------------------- /src/utils/shard-utils.ts: -------------------------------------------------------------------------------- 1 | import { fetchRecommendedShardCount, ShardClientUtil, ShardingManager } from 'discord.js'; 2 | 3 | import { MathUtils } from './index.js'; 4 | import { DiscordLimits } from '../constants/index.js'; 5 | 6 | export class ShardUtils { 7 | public static async requiredShardCount(token: string): Promise { 8 | return await this.recommendedShardCount(token, DiscordLimits.GUILDS_PER_SHARD); 9 | } 10 | 11 | public static async recommendedShardCount( 12 | token: string, 13 | serversPerShard: number 14 | ): Promise { 15 | return Math.ceil( 16 | await fetchRecommendedShardCount(token, { guildsPerShard: serversPerShard }) 17 | ); 18 | } 19 | 20 | public static shardIds(shardInterface: ShardingManager | ShardClientUtil): number[] { 21 | if (shardInterface instanceof ShardingManager) { 22 | return shardInterface.shards.map(shard => shard.id); 23 | } else if (shardInterface instanceof ShardClientUtil) { 24 | return shardInterface.ids; 25 | } 26 | } 27 | 28 | public static shardId(guildId: number | string, shardCount: number): number { 29 | // See sharding formula: 30 | // https://discord.com/developers/docs/topics/gateway#sharding-sharding-formula 31 | // tslint:disable-next-line:no-bitwise 32 | return Number((BigInt(guildId) >> 22n) % BigInt(shardCount)); 33 | } 34 | 35 | public static async serverCount( 36 | shardInterface: ShardingManager | ShardClientUtil 37 | ): Promise { 38 | let shardGuildCounts = (await shardInterface.fetchClientValues( 39 | 'guilds.cache.size' 40 | )) as number[]; 41 | return MathUtils.sum(shardGuildCounts); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/utils/thread-utils.ts: -------------------------------------------------------------------------------- 1 | import { DiscordAPIError, RESTJSONErrorCodes as DiscordApiErrors, ThreadChannel } from 'discord.js'; 2 | 3 | const IGNORED_ERRORS = [ 4 | DiscordApiErrors.UnknownMessage, 5 | DiscordApiErrors.UnknownChannel, 6 | DiscordApiErrors.UnknownGuild, 7 | DiscordApiErrors.UnknownUser, 8 | DiscordApiErrors.UnknownInteraction, 9 | DiscordApiErrors.CannotSendMessagesToThisUser, // User blocked bot or DM disabled 10 | DiscordApiErrors.ReactionWasBlocked, // User blocked bot or DM disabled 11 | DiscordApiErrors.MaximumActiveThreads, 12 | ]; 13 | 14 | export class ThreadUtils { 15 | public static async archive( 16 | thread: ThreadChannel, 17 | archived: boolean = true 18 | ): Promise { 19 | try { 20 | return await thread.setArchived(archived); 21 | } catch (error) { 22 | if ( 23 | error instanceof DiscordAPIError && 24 | typeof error.code == 'number' && 25 | IGNORED_ERRORS.includes(error.code) 26 | ) { 27 | return; 28 | } else { 29 | throw error; 30 | } 31 | } 32 | } 33 | 34 | public static async lock( 35 | thread: ThreadChannel, 36 | locked: boolean = true 37 | ): Promise { 38 | try { 39 | return await thread.setLocked(locked); 40 | } catch (error) { 41 | if ( 42 | error instanceof DiscordAPIError && 43 | typeof error.code == 'number' && 44 | IGNORED_ERRORS.includes(error.code) 45 | ) { 46 | return; 47 | } else { 48 | throw error; 49 | } 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/services/openai-service.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore - Package exports not fully supported by TypeScript moduleResolution 2 | import { OpenAI as PostHogOpenAI } from '@posthog/ai/openai'; 3 | import { OpenAI } from 'openai'; 4 | import { posthog } from '../utils/analytics.js'; 5 | import { env } from '../utils/env.js'; 6 | 7 | let openAIClient: OpenAI | PostHogOpenAI | null = null; 8 | 9 | export function resetOpenAIClient(): void { 10 | openAIClient = null; 11 | } 12 | 13 | export function getOpenAIClient(): OpenAI | PostHogOpenAI | null { 14 | const apiKey = env.OPENROUTER_API_KEY || env.OPENAI_API_KEY; 15 | if (!apiKey) { 16 | return null; 17 | } 18 | 19 | if (!openAIClient) { 20 | const useOpenRouter = !!env.OPENROUTER_API_KEY; 21 | const baseURL = useOpenRouter ? 'https://openrouter.ai/api/v1' : undefined; 22 | 23 | if (posthog) { 24 | openAIClient = new PostHogOpenAI({ 25 | apiKey: apiKey, 26 | posthog: posthog, 27 | baseURL: baseURL, 28 | defaultHeaders: useOpenRouter 29 | ? { 30 | 'HTTP-Referer': 'https://github.com/mergd/discorss', 31 | 'X-Title': 'Discorss Bot', 32 | } 33 | : undefined, 34 | }); 35 | } else { 36 | openAIClient = new OpenAI({ 37 | apiKey: apiKey, 38 | baseURL: baseURL, 39 | defaultHeaders: useOpenRouter 40 | ? { 41 | 'HTTP-Referer': 'https://github.com/mergd/discorss', 42 | 'X-Title': 'Discorss Bot', 43 | } 44 | : undefined, 45 | }); 46 | } 47 | } 48 | 49 | return openAIClient; 50 | } 51 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Dockerfile 2 | 3 | # ---- Base Stage ---- 4 | # Use Bun's official image 5 | FROM oven/bun:1 AS base 6 | 7 | WORKDIR /usr/src/app 8 | 9 | # ---- Dependencies Stage ---- 10 | # Install dependencies separately to leverage Docker cache 11 | FROM base AS deps 12 | 13 | # Copy package.json and optionally the bun lockfile 14 | COPY package.json ./ 15 | COPY bun.lockb* ./ 16 | # Install all dependencies (including devDependencies needed for build) 17 | RUN bun install 18 | 19 | # ---- Build Stage ---- 20 | # Build the TypeScript application 21 | FROM base AS build 22 | 23 | # Copy dependencies from the 'deps' stage 24 | COPY --from=deps /usr/src/app/node_modules ./node_modules 25 | # Copy the rest of the application source code 26 | COPY . . 27 | # Run the build script defined in package.json 28 | RUN bun run build 29 | 30 | # ---- Production Stage ---- 31 | # Create the final, smaller production image 32 | FROM base 33 | 34 | WORKDIR /usr/src/app 35 | 36 | # Copy essential files for running the application 37 | COPY package.json ./ 38 | COPY bun.lockb* ./ 39 | COPY --from=deps /usr/src/app/node_modules ./node_modules 40 | COPY --from=build /usr/src/app/dist ./dist 41 | COPY config ./config 42 | COPY lang ./lang 43 | # Copy drizzle config needed for migrations 44 | COPY src/drizzle.config.ts ./src/drizzle.config.ts 45 | COPY drizzle/migrations ./drizzle/migrations 46 | 47 | # Set production environment 48 | ENV NODE_ENV=production 49 | 50 | # Define the command to run migrations, register commands, and then start the app 51 | # --smol: Bun's memory-optimized mode for lower memory usage 52 | # Run migrations first, then register commands, and finally start the app 53 | CMD ["sh", "-c", "bun run db:migrate && bun --enable-source-maps dist/start-bot.js commands register && bun --smol dist/start-manager.js"] 54 | 55 | # Expose the API port for healthchecks 56 | EXPOSE 3001 57 | -------------------------------------------------------------------------------- /src/events/trigger-handler.ts: -------------------------------------------------------------------------------- 1 | import { Message } from 'discord.js'; 2 | import { RateLimiter } from 'discord.js-rate-limiter'; 3 | import { createRequire } from 'node:module'; 4 | 5 | import { EventDataService } from '../services/index.js'; 6 | import { Trigger } from '../triggers/index.js'; 7 | 8 | const require = createRequire(import.meta.url); 9 | let Config = require('../../config/config.json'); 10 | 11 | export class TriggerHandler { 12 | private rateLimiter = new RateLimiter( 13 | Config.rateLimiting.triggers.amount, 14 | Config.rateLimiting.triggers.interval * 1000 15 | ); 16 | 17 | constructor( 18 | private triggers: Trigger[], 19 | private eventDataService: EventDataService 20 | ) {} 21 | 22 | public async process(msg: Message): Promise { 23 | // Check if user is rate limited 24 | let limited = this.rateLimiter.take(msg.author.id); 25 | if (limited) { 26 | return; 27 | } 28 | 29 | // Find triggers caused by this message 30 | let triggers = this.triggers.filter(trigger => { 31 | if (trigger.requireGuild && !msg.guild) { 32 | return false; 33 | } 34 | 35 | if (!trigger.triggered(msg)) { 36 | return false; 37 | } 38 | 39 | return true; 40 | }); 41 | 42 | // If this message causes no triggers then return 43 | if (triggers.length === 0) { 44 | return; 45 | } 46 | 47 | // Get data from database 48 | let data = await this.eventDataService.create({ 49 | user: msg.author, 50 | channel: msg.channel, 51 | guild: msg.guild, 52 | }); 53 | 54 | // Execute triggers 55 | for (let trigger of triggers) { 56 | await trigger.execute(msg, data); 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/services/http-service.ts: -------------------------------------------------------------------------------- 1 | import fetch, { Response } from 'node-fetch'; 2 | import { URL } from 'node:url'; 3 | 4 | export class HttpService { 5 | public async get(url: string | URL, authorization: string): Promise { 6 | return await fetch(url.toString(), { 7 | method: 'get', 8 | headers: { 9 | Authorization: authorization, 10 | Accept: 'application/json', 11 | }, 12 | }); 13 | } 14 | 15 | public async post(url: string | URL, authorization: string, body?: object): Promise { 16 | return await fetch(url.toString(), { 17 | method: 'post', 18 | headers: { 19 | Authorization: authorization, 20 | 'Content-Type': 'application/json', 21 | Accept: 'application/json', 22 | }, 23 | body: body ? JSON.stringify(body) : undefined, 24 | }); 25 | } 26 | 27 | public async put(url: string | URL, authorization: string, body?: object): Promise { 28 | return await fetch(url.toString(), { 29 | method: 'put', 30 | headers: { 31 | Authorization: authorization, 32 | 'Content-Type': 'application/json', 33 | Accept: 'application/json', 34 | }, 35 | body: body ? JSON.stringify(body) : undefined, 36 | }); 37 | } 38 | 39 | public async delete( 40 | url: string | URL, 41 | authorization: string, 42 | body?: object 43 | ): Promise { 44 | return await fetch(url.toString(), { 45 | method: 'delete', 46 | headers: { 47 | Authorization: authorization, 48 | 'Content-Type': 'application/json', 49 | Accept: 'application/json', 50 | }, 51 | body: body ? JSON.stringify(body) : undefined, 52 | }); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/commands/chat/info-command.ts: -------------------------------------------------------------------------------- 1 | import { ChatInputCommandInteraction, EmbedBuilder, PermissionsString } from 'discord.js'; 2 | 3 | import { InfoOption } from '../../enums/index.js'; 4 | import { EventData } from '../../models/internal-models.js'; 5 | import { InteractionUtils } from '../../utils/index.js'; 6 | import { Command, CommandDeferType } from '../index.js'; 7 | 8 | export class InfoCommand implements Command { 9 | public names = ['info']; 10 | public deferType = CommandDeferType.HIDDEN; 11 | public requireClientPerms: PermissionsString[] = []; 12 | 13 | public async execute(intr: ChatInputCommandInteraction, data: EventData): Promise { 14 | let args = { 15 | option: intr.options.getString('option') as InfoOption, 16 | }; 17 | 18 | let embed: EmbedBuilder; 19 | switch (args.option) { 20 | case InfoOption.ABOUT: { 21 | embed = new EmbedBuilder() 22 | .setTitle('About Me') 23 | .setDescription( 24 | `Hi! I'm an RSS Bot.\n\n` + 25 | `I can monitor RSS feeds and post updates to your channels.\n` + 26 | `Use \`/help\` to see available commands.` 27 | ) 28 | .setColor('Blurple'); 29 | break; 30 | } 31 | case InfoOption.TRANSLATE: { 32 | await InteractionUtils.send( 33 | intr, 34 | 'Translation information is not available.', 35 | true 36 | ); 37 | return; 38 | } 39 | default: { 40 | await InteractionUtils.send( 41 | intr, 42 | `Invalid info option specified: ${args.option}`, 43 | true 44 | ); 45 | return; 46 | } 47 | } 48 | 49 | await InteractionUtils.send(intr, { embeds: [embed] }); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /lang/en-US/commands.json: -------------------------------------------------------------------------------- 1 | { 2 | "feed": "feed", 3 | "feedDescription": "Manage RSS feeds for this server.", 4 | "feedAdd": "add", 5 | "feedAddDescription": "Add a new RSS feed to a channel.", 6 | "feedAddUrl": "url", 7 | "feedAddUrlDescription": "The URL of the RSS feed.", 8 | "feedAddChannel": "channel", 9 | "feedAddChannelDescription": "The channel to post updates to.", 10 | "feedAddNickname": "nickname", 11 | "feedAddNicknameDescription": "An optional nickname for the feed.", 12 | "feedAddCategory": "category", 13 | "feedAddCategoryDescription": "An optional category for the feed.", 14 | "feedRemove": "remove", 15 | "feedRemoveDescription": "Remove an RSS feed from a channel.", 16 | "feedRemoveFeedId": "feed_id", 17 | "feedRemoveFeedIdDescription": "The ID or nickname of the feed to remove.", 18 | "feedRemoveChannel": "channel", 19 | "feedRemoveChannelDescription": "The channel the feed is associated with.", 20 | "feedList": "list", 21 | "feedListDescription": "List active RSS feeds.", 22 | "feedListChannel": "channel", 23 | "feedListChannelDescription": "List feeds only for a specific channel.", 24 | "feedTest": "test", 25 | "feedTestDescription": "Test an RSS feed URL and show a preview.", 26 | "feedTestUrl": "url", 27 | "feedTestUrlDescription": "The URL of the RSS feed to test.", 28 | 29 | "category": "category", 30 | "categoryDescription": "Manage feed categories and their polling frequencies.", 31 | "categorySetFrequency": "setfrequency", 32 | "categorySetFrequencyDescription": "Set the polling frequency (in minutes) for a category.", 33 | "categorySetFrequencyCategory": "category", 34 | "categorySetFrequencyCategoryDescription": "The name of the category.", 35 | "categorySetFrequencyMinutes": "minutes", 36 | "categorySetFrequencyMinutesDescription": "Polling frequency in minutes (1-1440). Default: 10.", 37 | "categoryList": "list", 38 | "categoryListDescription": "List configured categories and their frequencies." 39 | } 40 | -------------------------------------------------------------------------------- /config/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "client": { 3 | "intents": ["Guilds", "GuildMessages", "GuildMessageReactions", "DirectMessages"], 4 | "partials": ["Message", "Channel", "Reaction"], 5 | "caches": { 6 | "AutoModerationRuleManager": 0, 7 | "BaseGuildEmojiManager": 0, 8 | "GuildEmojiManager": 0, 9 | "GuildBanManager": 0, 10 | "GuildInviteManager": 0, 11 | "GuildScheduledEventManager": 0, 12 | "GuildStickerManager": 0, 13 | "MessageManager": 0, 14 | "PresenceManager": 0, 15 | "StageInstanceManager": 0, 16 | "ThreadManager": 0, 17 | "ThreadMemberManager": 0, 18 | "VoiceStateManager": 0 19 | } 20 | }, 21 | "api": { 22 | "port": 3001, 23 | "secret": "00000000-0000-0000-0000-000000000000" 24 | }, 25 | "sharding": { 26 | "spawnDelay": 5, 27 | "spawnTimeout": 300, 28 | "serversPerShard": 1000 29 | }, 30 | "clustering": { 31 | "enabled": false, 32 | "shardCount": 16, 33 | "callbackUrl": "http://localhost:3001/", 34 | "masterApi": { 35 | "url": "http://localhost:5000/", 36 | "token": "00000000-0000-0000-0000-000000000000" 37 | } 38 | }, 39 | "jobs": { 40 | "updateServerCount": { 41 | "schedule": "0 */10 * * * *", 42 | "log": false, 43 | "runOnce": false, 44 | "initialDelaySecs": 0 45 | } 46 | }, 47 | "rateLimiting": { 48 | "commands": { 49 | "amount": 10, 50 | "interval": 30 51 | }, 52 | "buttons": { 53 | "amount": 10, 54 | "interval": 30 55 | }, 56 | "triggers": { 57 | "amount": 10, 58 | "interval": 30 59 | }, 60 | "reactions": { 61 | "amount": 10, 62 | "interval": 30 63 | } 64 | }, 65 | "logging": { 66 | "pretty": true, 67 | "rateLimit": { 68 | "minTimeout": 30 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /config/config.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "client": { 3 | "intents": ["Guilds", "GuildMessages", "GuildMessageReactions", "DirectMessages"], 4 | "partials": ["Message", "Channel", "Reaction"], 5 | "caches": { 6 | "AutoModerationRuleManager": 0, 7 | "BaseGuildEmojiManager": 0, 8 | "GuildEmojiManager": 0, 9 | "GuildBanManager": 0, 10 | "GuildInviteManager": 0, 11 | "GuildScheduledEventManager": 0, 12 | "GuildStickerManager": 0, 13 | "MessageManager": 0, 14 | "PresenceManager": 0, 15 | "StageInstanceManager": 0, 16 | "ThreadManager": 0, 17 | "ThreadMemberManager": 0, 18 | "VoiceStateManager": 0 19 | } 20 | }, 21 | "api": { 22 | "port": 3001, 23 | "secret": "00000000-0000-0000-0000-000000000000" 24 | }, 25 | "sharding": { 26 | "spawnDelay": 5, 27 | "spawnTimeout": 300, 28 | "serversPerShard": 1000 29 | }, 30 | "clustering": { 31 | "enabled": false, 32 | "shardCount": 16, 33 | "callbackUrl": "http://localhost:3001/", 34 | "masterApi": { 35 | "url": "http://localhost:5000/", 36 | "token": "00000000-0000-0000-0000-000000000000" 37 | } 38 | }, 39 | "jobs": { 40 | "updateServerCount": { 41 | "schedule": "0 */10 * * * *", 42 | "log": false, 43 | "runOnce": false, 44 | "initialDelaySecs": 0 45 | } 46 | }, 47 | "rateLimiting": { 48 | "commands": { 49 | "amount": 10, 50 | "interval": 30 51 | }, 52 | "buttons": { 53 | "amount": 10, 54 | "interval": 30 55 | }, 56 | "triggers": { 57 | "amount": 10, 58 | "interval": 30 59 | }, 60 | "reactions": { 61 | "amount": 10, 62 | "interval": 30 63 | } 64 | }, 65 | "logging": { 66 | "pretty": true, 67 | "rateLimit": { 68 | "minTimeout": 30 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/services/event-data-service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Channel, 3 | CommandInteractionOptionResolver, 4 | Guild, 5 | PartialDMChannel, 6 | User, 7 | } from 'discord.js'; 8 | 9 | import { Language } from '../models/enum-helpers/language.js'; 10 | import { EventData } from '../models/internal-models.js'; 11 | 12 | export class EventDataService { 13 | public async create( 14 | options: { 15 | user?: User; 16 | channel?: Channel | PartialDMChannel; 17 | guild?: Guild; 18 | args?: Omit; 19 | } = {} 20 | ): Promise { 21 | // Event language 22 | let lang = 23 | options.guild?.preferredLocale && 24 | Language.Enabled.includes(options.guild.preferredLocale) 25 | ? options.guild.preferredLocale 26 | : Language.Default; 27 | 28 | // Guild language 29 | let langGuild = 30 | options.guild?.preferredLocale && 31 | Language.Enabled.includes(options.guild.preferredLocale) 32 | ? options.guild.preferredLocale 33 | : Language.Default; 34 | 35 | // Get user permissions if available 36 | let userPermissions: string[] | undefined = undefined; 37 | if (options.guild && options.user) { 38 | try { 39 | const member = await options.guild.members.fetch(options.user.id); 40 | userPermissions = member.permissions.toArray(); 41 | } catch (error) { 42 | console.warn(`Could not fetch member permissions: ${error}`); 43 | } 44 | } 45 | 46 | // Create context object with additional useful data 47 | const context: Record = {}; 48 | if (options.channel?.id) { 49 | context.channelId = options.channel.id; 50 | } 51 | if (options.guild?.id) { 52 | context.guildId = options.guild.id; 53 | } 54 | if (options.user?.id) { 55 | context.userId = options.user.id; 56 | } 57 | 58 | return new EventData(lang, langGuild, userPermissions, context); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/models/manager.ts: -------------------------------------------------------------------------------- 1 | import { Shard, ShardingManager } from 'discord.js'; 2 | import { createRequire } from 'node:module'; 3 | 4 | import { JobService, Logger } from '../services/index.js'; 5 | 6 | const require = createRequire(import.meta.url); 7 | let Config = require('../../config/config.json'); 8 | let Debug = require('../../config/debug.json'); 9 | let Logs = require('../../lang/logs.json'); 10 | 11 | export class Manager { 12 | constructor( 13 | private shardManager: ShardingManager, 14 | private jobService: JobService 15 | ) {} 16 | 17 | public async start(): Promise { 18 | this.registerListeners(); 19 | 20 | let shardList = this.shardManager.shardList as number[]; 21 | 22 | try { 23 | Logger.info( 24 | Logs.info.managerSpawningShards 25 | .replaceAll('{SHARD_COUNT}', shardList.length.toLocaleString()) 26 | .replaceAll('{SHARD_LIST}', shardList.join(', ')) 27 | ); 28 | await this.shardManager.spawn({ 29 | amount: this.shardManager.totalShards, 30 | delay: Config.sharding.spawnDelay * 1000, 31 | timeout: Config.sharding.spawnTimeout * 1000, 32 | }); 33 | Logger.info(Logs.info.managerAllShardsSpawned); 34 | } catch (error) { 35 | Logger.error(Logs.error.managerSpawningShards, error); 36 | return; 37 | } 38 | 39 | if (Debug.dummyMode.enabled) { 40 | return; 41 | } 42 | 43 | this.jobService.start(); 44 | } 45 | 46 | private registerListeners(): void { 47 | this.shardManager.on('shardCreate', shard => this.onShardCreate(shard)); 48 | } 49 | 50 | private onShardCreate(shard: Shard): void { 51 | Logger.info(Logs.info.managerLaunchedShard.replaceAll('{SHARD_ID}', shard.id.toString())); 52 | } 53 | 54 | public async stop(): Promise { 55 | Logger.info('[Manager] Stopping manager and cleaning up...'); 56 | 57 | if (this.jobService) { 58 | await this.jobService.stop(); 59 | } 60 | 61 | if (this.shardManager) { 62 | this.shardManager.removeAllListeners(); 63 | } 64 | 65 | Logger.info('[Manager] Manager stopped and cleaned up.'); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/utils/format-utils.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationCommand, Guild, Locale } from 'discord.js'; 2 | import { filesize } from 'filesize'; 3 | import { Duration } from 'luxon'; 4 | 5 | export class FormatUtils { 6 | public static roleMention(guild: Guild, discordId: string): string { 7 | if (discordId === '@here') { 8 | return discordId; 9 | } 10 | 11 | if (discordId === guild.id) { 12 | return '@everyone'; 13 | } 14 | 15 | return `<@&${discordId}>`; 16 | } 17 | 18 | public static channelMention(discordId: string): string { 19 | return `<#${discordId}>`; 20 | } 21 | 22 | public static userMention(discordId: string): string { 23 | return `<@!${discordId}>`; 24 | } 25 | 26 | public static commandMention(command: ApplicationCommand, subParts: string[] = []): string { 27 | if ( 28 | command.toString && 29 | typeof command.toString === 'function' && 30 | command.toString !== Object.prototype.toString 31 | ) { 32 | // Use built-in method if available in this version of discord.js 33 | return command.toString(); 34 | } else { 35 | // Fallback to manual formatting 36 | let name = [command.name, ...subParts].join(' '); 37 | return ``; 38 | } 39 | } 40 | 41 | public static duration(milliseconds: number, langCode: Locale): string { 42 | return Duration.fromObject( 43 | Object.fromEntries( 44 | Object.entries( 45 | Duration.fromMillis(milliseconds, { locale: langCode }) 46 | .shiftTo( 47 | 'year', 48 | 'quarter', 49 | 'month', 50 | 'week', 51 | 'day', 52 | 'hour', 53 | 'minute', 54 | 'second' 55 | ) 56 | .toObject() 57 | ).filter(([_, value]) => !!value) // Remove units that are 0 58 | ) 59 | ).toHuman({ maximumFractionDigits: 0 }); 60 | } 61 | 62 | public static fileSize(bytes: number): string { 63 | return filesize(bytes, { output: 'string', pad: true, round: 2 }); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/events/reaction-handler.ts: -------------------------------------------------------------------------------- 1 | import { Message, MessageReaction, User } from 'discord.js'; 2 | import { RateLimiter } from 'discord.js-rate-limiter'; 3 | import { createRequire } from 'node:module'; 4 | 5 | import { EventHandler } from './index.js'; 6 | import { Reaction } from '../reactions/index.js'; 7 | import { EventDataService } from '../services/index.js'; 8 | 9 | const require = createRequire(import.meta.url); 10 | let Config = require('../../config/config.json'); 11 | 12 | export class ReactionHandler implements EventHandler { 13 | private rateLimiter = new RateLimiter( 14 | Config.rateLimiting.reactions.amount, 15 | Config.rateLimiting.reactions.interval * 1000 16 | ); 17 | 18 | constructor( 19 | private reactions: Reaction[], 20 | private eventDataService: EventDataService 21 | ) {} 22 | 23 | public async process(msgReaction: MessageReaction, msg: Message, reactor: User): Promise { 24 | // Don't respond to self, or other bots 25 | if (reactor.id === msgReaction.client.user?.id || reactor.bot) { 26 | return; 27 | } 28 | 29 | // Check if user is rate limited 30 | let limited = this.rateLimiter.take(msg.author.id); 31 | if (limited) { 32 | return; 33 | } 34 | 35 | // Try to find the reaction the user wants 36 | let reaction = this.findReaction(msgReaction.emoji.name); 37 | if (!reaction) { 38 | return; 39 | } 40 | 41 | if (reaction.requireGuild && !msg.guild) { 42 | return; 43 | } 44 | 45 | if (reaction.requireSentByClient && msg.author.id !== msg.client.user?.id) { 46 | return; 47 | } 48 | 49 | // Check if the embeds author equals the reactors tag 50 | if (reaction.requireEmbedAuthorTag && msg.embeds[0]?.author?.name !== reactor.tag) { 51 | return; 52 | } 53 | 54 | // Get data from database 55 | let data = await this.eventDataService.create({ 56 | user: reactor, 57 | channel: msg.channel, 58 | guild: msg.guild, 59 | }); 60 | 61 | // Execute the reaction 62 | await reaction.execute(msgReaction, msg, reactor, data); 63 | } 64 | 65 | private findReaction(emoji: string): Reaction { 66 | return this.reactions.find(reaction => reaction.emoji === emoji); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/constants/paywalled-sites.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A list of domain names known to often have paywalls. 3 | * This is used to provide an archive.is link as an alternative. 4 | * Keep the list in lowercase for case-insensitive matching. 5 | */ 6 | export const PAYWALLED_DOMAINS: Set = new Set([ 7 | 'wsj.com', 8 | 'nytimes.com', 9 | 'ft.com', // Financial Times 10 | 'thetimes.co.uk', 11 | 'bloomberg.com', 12 | 'theathletic.com', 13 | 'hbr.org', // Harvard Business Review 14 | 'economist.com', 15 | 'washingtonpost.com', 16 | 'medium.com', // Often paywalled based on user limits 17 | 'technologyreview.com', // MIT Technology Review 18 | 'newyorker.com', 19 | 'theatlantic.com', 20 | 'wired.com', // Sometimes 21 | 'seekingalpha.com', 22 | 'statista.com', 23 | // Add more domains as needed 24 | ]); 25 | 26 | /** 27 | * Prepends the archive.is prefix to a URL. 28 | * @param url The original URL. 29 | * @returns The archive.is URL. 30 | */ 31 | export const getArchiveUrl = (url: string): string => { 32 | // Ensure no double slashes if url already starts with one 33 | const cleanUrl = url.startsWith('/') ? url.substring(1) : url; 34 | // Ensure the URL starts with http:// or https:// for archive.is 35 | if (!/^https?:\/\//i.test(cleanUrl)) { 36 | // Attempt to prefix with https:// as a default 37 | return `https://archive.is/https://${cleanUrl}`; 38 | } 39 | return `https://archive.is/${cleanUrl}`; 40 | }; 41 | 42 | /** 43 | * Checks if a URL's domain is in the known paywalled list. 44 | * @param url The URL to check. 45 | * @returns True if the domain is considered paywalled, false otherwise. 46 | */ 47 | export const isPaywalled = (url: string | undefined): boolean => { 48 | if (!url) return false; // Handle undefined input 49 | try { 50 | // Ensure the URL has a protocol for correct parsing 51 | let fullUrl = url; 52 | if (!/^https?:\/\//i.test(url)) { 53 | fullUrl = `https://${url}`; // Assume https if missing 54 | } 55 | const parsedUrl = new URL(fullUrl); 56 | const domain = parsedUrl.hostname.startsWith('www.') 57 | ? parsedUrl.hostname.substring(4) 58 | : parsedUrl.hostname; 59 | return PAYWALLED_DOMAINS.has(domain.toLowerCase()); 60 | } catch (e) { 61 | // Invalid URL format, assume not paywalled for safety 62 | console.warn(`[PaywallCheck] Could not parse URL: ${url}`, e); 63 | return false; 64 | } 65 | }; 66 | -------------------------------------------------------------------------------- /src/scripts/backup-db.ts: -------------------------------------------------------------------------------- 1 | import { drizzle } from 'drizzle-orm/postgres-js'; 2 | import postgres from 'postgres'; 3 | import * as fs from 'fs'; 4 | import * as path from 'path'; 5 | import * as schema from '../db/schema.js'; 6 | 7 | // Load environment variables 8 | import * as dotenv from 'dotenv'; 9 | dotenv.config({ path: '.env' }); 10 | 11 | if (!process.env.DATABASE_URL) { 12 | throw new Error('DATABASE_URL environment variable is required.'); 13 | } 14 | 15 | const connectionString = process.env.DATABASE_URL; 16 | const pgClient = postgres(connectionString, { 17 | ssl: connectionString.includes('sslmode=require') ? 'require' : undefined, 18 | max: 1, 19 | }); 20 | const db = drizzle(pgClient, { schema }); 21 | 22 | async function backupData() { 23 | try { 24 | console.log('🔄 Starting database backup...'); 25 | 26 | // Create backup directory 27 | const backupDir = path.join(process.cwd(), 'data/backups'); 28 | if (!fs.existsSync(backupDir)) { 29 | fs.mkdirSync(backupDir, { recursive: true }); 30 | } 31 | 32 | const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); 33 | const backupFile = path.join(backupDir, `database-backup-${timestamp}.json`); 34 | 35 | // Get all table names from schema 36 | const tableNames = Object.keys(schema).filter( 37 | key => schema[key] && typeof schema[key] === 'object' && 'getSQL' in schema[key] 38 | ); 39 | 40 | const backup = {}; 41 | 42 | // Export data from each table 43 | for (const tableName of tableNames) { 44 | try { 45 | console.log(`📦 Backing up table: ${tableName}`); 46 | const table = schema[tableName]; 47 | const data = await db.select().from(table); 48 | backup[tableName] = data; 49 | console.log(`✅ Backed up ${data.length} records from ${tableName}`); 50 | } catch (error) { 51 | console.warn(`⚠️ Could not backup table ${tableName}:`, error.message); 52 | backup[tableName] = []; 53 | } 54 | } 55 | 56 | // Write backup to file 57 | fs.writeFileSync(backupFile, JSON.stringify(backup, null, 2)); 58 | console.log(`✅ Backup completed: ${backupFile}`); 59 | 60 | // Close connection 61 | await pgClient.end(); 62 | } catch (error) { 63 | console.error('❌ Backup failed:', error); 64 | process.exit(1); 65 | } 66 | } 67 | 68 | backupData(); 69 | -------------------------------------------------------------------------------- /src/services/master-api-service.ts: -------------------------------------------------------------------------------- 1 | import { createRequire } from 'node:module'; 2 | import { URL } from 'node:url'; 3 | 4 | import { HttpService } from './index.js'; 5 | import { 6 | LoginClusterResponse, 7 | RegisterClusterRequest, 8 | RegisterClusterResponse, 9 | } from '../models/master-api/index.js'; 10 | 11 | const require = createRequire(import.meta.url); 12 | let Config = require('../../config/config.json'); 13 | 14 | export class MasterApiService { 15 | private clusterId: string; 16 | 17 | constructor(private httpService: HttpService) {} 18 | 19 | public async register(): Promise { 20 | let reqBody: RegisterClusterRequest = { 21 | shardCount: Config.clustering.shardCount, 22 | callback: { 23 | url: Config.clustering.callbackUrl, 24 | token: Config.api.secret, 25 | }, 26 | }; 27 | 28 | let res = await this.httpService.post( 29 | new URL('/clusters', Config.clustering.masterApi.url), 30 | Config.clustering.masterApi.token, 31 | reqBody 32 | ); 33 | 34 | if (!res.ok) { 35 | throw res; 36 | } 37 | 38 | let resBody = (await res.json()) as RegisterClusterResponse; 39 | this.clusterId = resBody.id; 40 | } 41 | 42 | public async login(): Promise { 43 | let res = await this.httpService.put( 44 | new URL(`/clusters/${this.clusterId}/login`, Config.clustering.masterApi.url), 45 | Config.clustering.masterApi.token 46 | ); 47 | 48 | if (!res.ok) { 49 | throw res; 50 | } 51 | 52 | return (await res.json()) as LoginClusterResponse; 53 | } 54 | 55 | public async ready(): Promise { 56 | let res = await this.httpService.put( 57 | new URL(`/clusters/${this.clusterId}/ready`, Config.clustering.masterApi.url), 58 | Config.clustering.masterApi.token 59 | ); 60 | 61 | if (!res.ok) { 62 | throw res; 63 | } 64 | } 65 | 66 | public async unregister(): Promise { 67 | if (!this.clusterId) { 68 | return; 69 | } 70 | 71 | try { 72 | let res = await this.httpService.delete( 73 | new URL(`/clusters/${this.clusterId}`, Config.clustering.masterApi.url), 74 | Config.clustering.masterApi.token 75 | ); 76 | 77 | if (!res.ok) { 78 | throw res; 79 | } 80 | } catch (error) { 81 | // Log but don't throw - best effort cleanup 82 | console.error('[MasterApiService] Error unregistering cluster:', error); 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/utils/partial-utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DiscordAPIError, 3 | RESTJSONErrorCodes as DiscordApiErrors, 4 | Message, 5 | MessageReaction, 6 | PartialMessage, 7 | PartialMessageReaction, 8 | PartialUser, 9 | User, 10 | } from 'discord.js'; 11 | 12 | const IGNORED_ERRORS = [ 13 | DiscordApiErrors.UnknownMessage, 14 | DiscordApiErrors.UnknownChannel, 15 | DiscordApiErrors.UnknownGuild, 16 | DiscordApiErrors.UnknownUser, 17 | DiscordApiErrors.UnknownInteraction, 18 | DiscordApiErrors.MissingAccess, 19 | ]; 20 | 21 | export class PartialUtils { 22 | public static async fillUser(user: User | PartialUser): Promise { 23 | if (user.partial) { 24 | try { 25 | return await user.fetch(); 26 | } catch (error) { 27 | if ( 28 | error instanceof DiscordAPIError && 29 | typeof error.code == 'number' && 30 | IGNORED_ERRORS.includes(error.code) 31 | ) { 32 | return; 33 | } else { 34 | throw error; 35 | } 36 | } 37 | } 38 | 39 | return user as User; 40 | } 41 | 42 | public static async fillMessage(msg: Message | PartialMessage): Promise { 43 | if (msg.partial) { 44 | try { 45 | return await msg.fetch(); 46 | } catch (error) { 47 | if ( 48 | error instanceof DiscordAPIError && 49 | typeof error.code == 'number' && 50 | IGNORED_ERRORS.includes(error.code) 51 | ) { 52 | return; 53 | } else { 54 | throw error; 55 | } 56 | } 57 | } 58 | 59 | return msg as Message; 60 | } 61 | 62 | public static async fillReaction( 63 | msgReaction: MessageReaction | PartialMessageReaction 64 | ): Promise { 65 | if (msgReaction.partial) { 66 | try { 67 | msgReaction = await msgReaction.fetch(); 68 | } catch (error) { 69 | if ( 70 | error instanceof DiscordAPIError && 71 | typeof error.code == 'number' && 72 | IGNORED_ERRORS.includes(error.code) 73 | ) { 74 | return; 75 | } else { 76 | throw error; 77 | } 78 | } 79 | } 80 | 81 | msgReaction.message = await this.fillMessage(msgReaction.message); 82 | if (!msgReaction.message) { 83 | return; 84 | } 85 | 86 | return msgReaction as MessageReaction; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # ===================================================== 2 | # Custom 3 | # ===================================================== 4 | /dist 5 | /temp 6 | !/**/config/**/*.example. 7 | # ignore sqlite 8 | # *.sqlite 9 | # Backups 10 | /data/ 11 | /disable 12 | __*.mdc 13 | 14 | # ===================================================== 15 | # Node.js 16 | # ===================================================== 17 | # Logs 18 | logs 19 | *.log 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | lerna-debug.log* 24 | .pnpm-debug.log* 25 | 26 | # Diagnostic reports (https://nodejs.org/api/report.html) 27 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 28 | 29 | # Runtime data 30 | pids 31 | *.pid 32 | *.seed 33 | *.pid.lock 34 | 35 | # Directory for instrumented libs generated by jscoverage/JSCover 36 | lib-cov 37 | 38 | # Coverage directory used by tools like istanbul 39 | coverage 40 | *.lcov 41 | 42 | # nyc test coverage 43 | .nyc_output 44 | 45 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 46 | .grunt 47 | 48 | # Bower dependency directory (https://bower.io/) 49 | bower_components 50 | 51 | # node-waf configuration 52 | .lock-wscript 53 | 54 | # Compiled binary addons (https://nodejs.org/api/addons.html) 55 | build/Release 56 | 57 | # Dependency directories 58 | node_modules/ 59 | jspm_packages/ 60 | 61 | # Snowpack dependency directory (https://snowpack.dev/) 62 | web_modules/ 63 | 64 | # TypeScript cache 65 | *.tsbuildinfo 66 | 67 | # Optional npm cache directory 68 | .npm 69 | 70 | # Optional eslint cache 71 | .eslintcache 72 | 73 | # Microbundle cache 74 | .rpt2_cache/ 75 | .rts2_cache_cjs/ 76 | .rts2_cache_es/ 77 | .rts2_cache_umd/ 78 | 79 | # Optional REPL history 80 | .node_repl_history 81 | 82 | # Output of 'npm pack' 83 | *.tgz 84 | 85 | # Yarn Integrity file 86 | .yarn-integrity 87 | 88 | # dotenv environment variables file 89 | .env 90 | .env.test 91 | .env.production 92 | !/.env.example 93 | 94 | # parcel-bundler cache (https://parceljs.org/) 95 | .cache 96 | .parcel-cache 97 | 98 | # Next.js build output 99 | .next 100 | out 101 | 102 | # Nuxt.js build / generate output 103 | .nuxt 104 | dist 105 | 106 | # Gatsby files 107 | .cache/ 108 | # Comment in the public line in if your project uses Gatsby and not Next.js 109 | # https://nextjs.org/blog/next-9-1#public-directory-support 110 | # public 111 | 112 | # vuepress build output 113 | .vuepress/dist 114 | 115 | # Serverless directories 116 | .serverless/ 117 | 118 | # FuseBox cache 119 | .fusebox/ 120 | 121 | # DynamoDB Local files 122 | .dynamodb/ 123 | 124 | # TernJS port file 125 | .tern-port 126 | 127 | # Stores VSCode versions used for testing VSCode extensions 128 | .vscode-test 129 | 130 | # yarn v2 131 | .yarn/cache 132 | .yarn/unplugged 133 | .yarn/build-state.yml 134 | .yarn/install-state.gz 135 | .pnp.* -------------------------------------------------------------------------------- /src/jobs/update-server-count-job.ts: -------------------------------------------------------------------------------- 1 | import { ActivityType, ShardingManager } from 'discord.js'; 2 | import { createRequire } from 'node:module'; 3 | 4 | import { Job } from './index.js'; 5 | import { CustomClient } from '../extensions/index.js'; 6 | import { BotSite } from '../models/config-models.js'; 7 | import { HttpService, Logger } from '../services/index.js'; 8 | import { ShardUtils } from '../utils/index.js'; 9 | 10 | const require = createRequire(import.meta.url); 11 | let BotSites: BotSite[] = require('../../config/bot-sites.json'); 12 | let Config = require('../../config/config.json'); 13 | let Logs = require('../../lang/logs.json'); 14 | 15 | export class UpdateServerCountJob extends Job { 16 | public name = 'Update Server Count'; 17 | public schedule: string = Config.jobs.updateServerCount.schedule; 18 | public log: boolean = Config.jobs.updateServerCount.log; 19 | public runOnce: boolean = Config.jobs.updateServerCount.runOnce; 20 | public initialDelaySecs: number = Config.jobs.updateServerCount.initialDelaySecs; 21 | 22 | private botSites: BotSite[]; 23 | 24 | constructor( 25 | private shardManager: ShardingManager, 26 | private httpService: HttpService 27 | ) { 28 | super(); 29 | this.botSites = BotSites.filter(botSite => botSite.enabled); 30 | } 31 | 32 | public async run(): Promise { 33 | let serverCount = await ShardUtils.serverCount(this.shardManager); 34 | 35 | let type = ActivityType.Streaming; 36 | let name = `to ${serverCount.toLocaleString()} servers`; 37 | let url = Config.links?.stream ?? 'https://www.twitch.tv/discord'; 38 | 39 | await this.shardManager.broadcastEval( 40 | (client, context) => { 41 | let customClient = client as CustomClient; 42 | customClient.setPresence(context.type, context.name, context.url); 43 | }, 44 | { context: { type, name, url } } 45 | ); 46 | 47 | Logger.info( 48 | Logs.info.updatedServerCount.replaceAll('{SERVER_COUNT}', serverCount.toLocaleString()) 49 | ); 50 | 51 | for (let botSite of this.botSites) { 52 | try { 53 | let body = JSON.parse( 54 | botSite.body.replaceAll('{{SERVER_COUNT}}', serverCount.toString()) 55 | ); 56 | let res = await this.httpService.post(botSite.url, botSite.authorization, body); 57 | 58 | if (!res.ok) { 59 | throw res; 60 | } 61 | } catch (error) { 62 | Logger.error( 63 | Logs.error.updatedServerCountSite.replaceAll('{BOT_SITE}', botSite.name), 64 | error 65 | ); 66 | continue; 67 | } 68 | 69 | Logger.info(Logs.info.updatedServerCountSite.replaceAll('{BOT_SITE}', botSite.name)); 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/events/button-handler.ts: -------------------------------------------------------------------------------- 1 | import { ButtonInteraction } from 'discord.js'; 2 | import { RateLimiter } from 'discord.js-rate-limiter'; 3 | import { createRequire } from 'node:module'; 4 | 5 | import { EventHandler } from './index.js'; 6 | import { Button, ButtonDeferType } from '../buttons/index.js'; 7 | import { EventDataService } from '../services/index.js'; 8 | import { InteractionUtils } from '../utils/index.js'; 9 | 10 | const require = createRequire(import.meta.url); 11 | let Config = require('../../config/config.json'); 12 | 13 | export class ButtonHandler implements EventHandler { 14 | private rateLimiter = new RateLimiter( 15 | Config.rateLimiting.buttons.amount, 16 | Config.rateLimiting.buttons.interval * 1000 17 | ); 18 | 19 | constructor( 20 | private buttons: Button[], 21 | private eventDataService: EventDataService 22 | ) {} 23 | 24 | public async process(intr: ButtonInteraction): Promise { 25 | // Don't respond to self, or other bots 26 | if (intr.user.id === intr.client.user?.id || intr.user.bot) { 27 | return; 28 | } 29 | 30 | // Check if user is rate limited 31 | let limited = this.rateLimiter.take(intr.user.id); 32 | if (limited) { 33 | return; 34 | } 35 | 36 | // Try to find the button the user wants 37 | let button = this.findButton(intr.customId); 38 | if (!button) { 39 | return; 40 | } 41 | 42 | if (button.requireGuild && !intr.guild) { 43 | return; 44 | } 45 | 46 | // Check if the embeds author equals the users tag 47 | if ( 48 | button.requireEmbedAuthorTag && 49 | intr.message.embeds[0]?.author?.name !== intr.user.tag 50 | ) { 51 | return; 52 | } 53 | 54 | // Defer interaction 55 | // NOTE: Anything after this point we should be responding to the interaction 56 | switch (button.deferType) { 57 | case ButtonDeferType.REPLY: { 58 | await InteractionUtils.deferReply(intr); 59 | break; 60 | } 61 | case ButtonDeferType.UPDATE: { 62 | await InteractionUtils.deferUpdate(intr); 63 | break; 64 | } 65 | } 66 | 67 | // Return if defer was unsuccessful 68 | if (button.deferType !== ButtonDeferType.NONE && !intr.deferred) { 69 | return; 70 | } 71 | 72 | // Get data from database 73 | let data = await this.eventDataService.create({ 74 | user: intr.user, 75 | channel: intr.channel, 76 | guild: intr.guild, 77 | }); 78 | 79 | // Execute the button 80 | await button.execute(intr, data); 81 | } 82 | 83 | private findButton(id: string): Button { 84 | return this.buttons.find(button => button.ids.includes(id)); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /misc/Discord Bot Cluster API.postman_collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "_postman_id": "d37e9bae-0a24-4940-af63-2716ab3bb660", 4 | "name": "Discord Bot Cluster API", 5 | "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" 6 | }, 7 | "item": [ 8 | { 9 | "name": "Shards", 10 | "item": [ 11 | { 12 | "name": "Get Shards", 13 | "request": { 14 | "method": "GET", 15 | "header": [], 16 | "url": { 17 | "raw": "{{BASE_URL}}/shards", 18 | "host": [ 19 | "{{BASE_URL}}" 20 | ], 21 | "path": [ 22 | "shards" 23 | ] 24 | } 25 | }, 26 | "response": [] 27 | }, 28 | { 29 | "name": "Set Shard Presences", 30 | "request": { 31 | "method": "PUT", 32 | "header": [], 33 | "body": { 34 | "mode": "raw", 35 | "raw": "{\r\n \"type\": \"STREAMING\",\r\n \"name\": \"to 1,000,000 servers\",\r\n \"url\": \"https://www.twitch.tv/novakevin\"\r\n}", 36 | "options": { 37 | "raw": { 38 | "language": "json" 39 | } 40 | } 41 | }, 42 | "url": { 43 | "raw": "{{BASE_URL}}/shards/presence", 44 | "host": [ 45 | "{{BASE_URL}}" 46 | ], 47 | "path": [ 48 | "shards", 49 | "presence" 50 | ] 51 | } 52 | }, 53 | "response": [] 54 | } 55 | ] 56 | }, 57 | { 58 | "name": "Guilds", 59 | "item": [ 60 | { 61 | "name": "Get Guilds", 62 | "request": { 63 | "method": "GET", 64 | "header": [], 65 | "url": { 66 | "raw": "{{BASE_URL}}/guilds", 67 | "host": [ 68 | "{{BASE_URL}}" 69 | ], 70 | "path": [ 71 | "guilds" 72 | ] 73 | } 74 | }, 75 | "response": [] 76 | } 77 | ] 78 | }, 79 | { 80 | "name": "Get Root", 81 | "request": { 82 | "method": "GET", 83 | "header": [], 84 | "url": { 85 | "raw": "{{BASE_URL}}", 86 | "host": [ 87 | "{{BASE_URL}}" 88 | ] 89 | } 90 | }, 91 | "response": [] 92 | } 93 | ], 94 | "auth": { 95 | "type": "apikey", 96 | "apikey": [ 97 | { 98 | "key": "key", 99 | "value": "Authorization", 100 | "type": "string" 101 | }, 102 | { 103 | "key": "value", 104 | "value": "00000000-0000-0000-0000-000000000000", 105 | "type": "string" 106 | } 107 | ] 108 | }, 109 | "event": [ 110 | { 111 | "listen": "prerequest", 112 | "script": { 113 | "type": "text/javascript", 114 | "exec": [ 115 | "" 116 | ] 117 | } 118 | }, 119 | { 120 | "listen": "test", 121 | "script": { 122 | "type": "text/javascript", 123 | "exec": [ 124 | "" 125 | ] 126 | } 127 | } 128 | ], 129 | "variable": [ 130 | { 131 | "key": "BASE_URL", 132 | "value": "localhost:3001" 133 | } 134 | ] 135 | } -------------------------------------------------------------------------------- /src/services/job-service.ts: -------------------------------------------------------------------------------- 1 | import parser from 'cron-parser'; 2 | import { DateTime } from 'luxon'; 3 | import schedule, { Job as ScheduledJob } from 'node-schedule'; 4 | import { createRequire } from 'node:module'; 5 | 6 | import { Logger } from './index.js'; 7 | import { Job } from '../jobs/index.js'; 8 | 9 | const require = createRequire(import.meta.url); 10 | let Logs = require('../../lang/logs.json'); 11 | 12 | export class JobService { 13 | private scheduledJobs: ScheduledJob[] = []; 14 | 15 | constructor(private jobs: Job[]) {} 16 | 17 | public start(): void { 18 | for (let job of this.jobs) { 19 | let jobSchedule = job.runOnce 20 | ? parser 21 | .parseExpression(job.schedule, { 22 | currentDate: DateTime.now() 23 | .plus({ seconds: job.initialDelaySecs }) 24 | .toJSDate(), 25 | }) 26 | .next() 27 | .toDate() 28 | : { 29 | start: DateTime.now().plus({ seconds: job.initialDelaySecs }).toJSDate(), 30 | rule: job.schedule, 31 | }; 32 | 33 | const scheduledJob = schedule.scheduleJob(jobSchedule, async () => { 34 | try { 35 | if (job.log) { 36 | Logger.info(Logs.info.jobRun.replaceAll('{JOB}', job.name)); 37 | } 38 | 39 | await job.run(); 40 | 41 | if (job.log) { 42 | Logger.info(Logs.info.jobCompleted.replaceAll('{JOB}', job.name)); 43 | } 44 | } catch (error) { 45 | Logger.error(Logs.error.job.replaceAll('{JOB}', job.name), error); 46 | } 47 | }); 48 | 49 | if (scheduledJob) { 50 | this.scheduledJobs.push(scheduledJob); 51 | } 52 | 53 | Logger.info( 54 | Logs.info.jobScheduled 55 | .replaceAll('{JOB}', job.name) 56 | .replaceAll('{SCHEDULE}', job.schedule) 57 | ); 58 | } 59 | } 60 | 61 | public async stop(): Promise { 62 | Logger.info('[JobService] Stopping all scheduled jobs...'); 63 | 64 | for (const scheduledJob of this.scheduledJobs) { 65 | scheduledJob.cancel(); 66 | } 67 | 68 | this.scheduledJobs = []; 69 | 70 | for (const job of this.jobs) { 71 | if (typeof (job as any).stop === 'function') { 72 | try { 73 | await (job as any).stop(); 74 | } catch (error) { 75 | Logger.error(`[JobService] Error stopping job ${job.name}:`, error); 76 | } 77 | } 78 | } 79 | 80 | Logger.info('[JobService] All scheduled jobs stopped.'); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/controllers/shards-controller.ts: -------------------------------------------------------------------------------- 1 | import { ActivityType, ShardingManager } from 'discord.js'; 2 | import { Request, Response, Router } from 'express'; 3 | import router from 'express-promise-router'; 4 | import { createRequire } from 'node:module'; 5 | 6 | import { Controller } from './index.js'; 7 | import { CustomClient } from '../extensions/index.js'; 8 | import { mapClass } from '../middleware/index.js'; 9 | import { 10 | GetShardsResponse, 11 | SetShardPresencesRequest, 12 | ShardInfo, 13 | ShardStats, 14 | } from '../models/cluster-api/index.js'; 15 | import { Logger } from '../services/index.js'; 16 | 17 | const require = createRequire(import.meta.url); 18 | let Config = require('../../config/config.json'); 19 | let Logs = require('../../lang/logs.json'); 20 | 21 | export class ShardsController implements Controller { 22 | public path = '/shards'; 23 | public router: Router = router(); 24 | public authToken: string = Config.api.secret; 25 | 26 | constructor(private shardManager: ShardingManager) {} 27 | 28 | public register(): void { 29 | this.router.get('/', (req, res) => this.getShards(req, res)); 30 | this.router.put('/presence', mapClass(SetShardPresencesRequest), (req, res) => 31 | this.setShardPresences(req, res) 32 | ); 33 | } 34 | 35 | private async getShards(req: Request, res: Response): Promise { 36 | let shardDatas = await Promise.all( 37 | this.shardManager.shards.map(async shard => { 38 | let shardInfo: ShardInfo = { 39 | id: shard.id, 40 | ready: shard.ready, 41 | error: false, 42 | }; 43 | 44 | try { 45 | let uptime = (await shard.fetchClientValue('uptime')) as number; 46 | shardInfo.uptimeSecs = Math.floor(uptime / 1000); 47 | } catch (error) { 48 | Logger.error(Logs.error.managerShardInfo, error); 49 | shardInfo.error = true; 50 | } 51 | 52 | return shardInfo; 53 | }) 54 | ); 55 | 56 | let stats: ShardStats = { 57 | shardCount: this.shardManager.shards.size, 58 | uptimeSecs: Math.floor(process.uptime()), 59 | }; 60 | 61 | let resBody: GetShardsResponse = { 62 | shards: shardDatas, 63 | stats, 64 | }; 65 | res.status(200).json(resBody); 66 | } 67 | 68 | private async setShardPresences(req: Request, res: Response): Promise { 69 | let reqBody: SetShardPresencesRequest = res.locals.input; 70 | 71 | await this.shardManager.broadcastEval( 72 | (client, context) => { 73 | let customClient = client as CustomClient; 74 | return customClient.setPresence(context.type, context.name, context.url); 75 | }, 76 | { context: { type: ActivityType[reqBody.type], name: reqBody.name, url: reqBody.url } } 77 | ); 78 | 79 | res.sendStatus(200); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /tests/utils/math-utils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from 'vitest'; 2 | import { MathUtils } from '../../src/utils/index.js'; 3 | 4 | describe('MathUtils', () => { 5 | describe('sum', () => { 6 | it('should correctly sum an array of numbers', () => { 7 | const input = [1, 2, 3, 4, 5]; 8 | const result = MathUtils.sum(input); 9 | expect(result).toBe(15); 10 | }); 11 | 12 | it('should return 0 for an empty array', () => { 13 | const result = MathUtils.sum([]); 14 | expect(result).toBe(0); 15 | }); 16 | 17 | it('should handle negative numbers correctly', () => { 18 | const input = [-1, -2, 3, 4]; 19 | const result = MathUtils.sum(input); 20 | expect(result).toBe(4); 21 | }); 22 | }); 23 | 24 | describe('clamp', () => { 25 | it('should return the input value when within range', () => { 26 | const result = MathUtils.clamp(5, 1, 10); 27 | expect(result).toBe(5); 28 | }); 29 | 30 | it('should return the min value when input is too low', () => { 31 | const result = MathUtils.clamp(0, 1, 10); 32 | expect(result).toBe(1); 33 | }); 34 | 35 | it('should return the max value when input is too high', () => { 36 | const result = MathUtils.clamp(15, 1, 10); 37 | expect(result).toBe(10); 38 | }); 39 | 40 | it('should handle negative ranges correctly', () => { 41 | const result = MathUtils.clamp(-5, -10, -2); 42 | expect(result).toBe(-5); 43 | }); 44 | }); 45 | 46 | describe('range', () => { 47 | it('should create an array of sequential numbers from start', () => { 48 | const result = MathUtils.range(5, 3); 49 | expect(result).toEqual([5, 6, 7]); 50 | }); 51 | 52 | it('should create an empty array when size is 0', () => { 53 | const result = MathUtils.range(10, 0); 54 | expect(result).toEqual([]); 55 | }); 56 | 57 | it('should handle negative start values', () => { 58 | const result = MathUtils.range(-3, 4); 59 | expect(result).toEqual([-3, -2, -1, 0]); 60 | }); 61 | }); 62 | 63 | describe('ceilToMultiple', () => { 64 | it('should round up to the nearest multiple', () => { 65 | const result = MathUtils.ceilToMultiple(14, 5); 66 | expect(result).toBe(15); 67 | }); 68 | 69 | it('should not change value already at multiple', () => { 70 | const result = MathUtils.ceilToMultiple(15, 5); 71 | expect(result).toBe(15); 72 | }); 73 | 74 | it('should handle decimal inputs correctly', () => { 75 | const result = MathUtils.ceilToMultiple(10.5, 5); 76 | expect(result).toBe(15); 77 | }); 78 | 79 | it('should handle negative values correctly', () => { 80 | const result = MathUtils.ceilToMultiple(-12, 5); 81 | expect(result).toBe(-10); 82 | }); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # Claude AI Context for Discorss Bot 2 | 3 | ## Project Overview 4 | This is a Discord RSS bot called "Discorss" that automatically polls RSS feeds and posts new items to Discord channels with AI-powered summarization capabilities. 5 | 6 | ## Key Features 7 | - RSS feed polling and Discord integration 8 | - AI-powered content summarization 9 | - Archive.is integration for paywalled content 10 | - Slash command interface 11 | - Multi-shard architecture with cluster management 12 | 13 | ## Technology Stack 14 | - **Runtime**: Node.js 18+ with TypeScript 15 | - **Discord**: discord.js v14 with hybrid sharding 16 | - **Database**: PostgreSQL with Drizzle ORM 17 | - **AI**: OpenAI API for content summarization 18 | - **Package Manager**: pnpm 19 | - **Testing**: Vitest 20 | 21 | ## Project Structure 22 | - `/src/` - Main source code 23 | - `/commands/` - Discord slash commands 24 | - `/events/` - Discord event handlers 25 | - `/jobs/` - Background jobs (feed polling) 26 | - `/services/` - Core business logic 27 | - `/utils/` - Utility functions 28 | - `/db/` - Database schema and connection 29 | - `/config/` - Configuration files 30 | - `/drizzle/` - Database migrations 31 | - `/tests/` - Test files 32 | 33 | ## Important Scripts 34 | ```bash 35 | # Development 36 | pnpm build # Compile TypeScript 37 | pnpm lint # Run ESLint 38 | pnpm test # Run tests 39 | pnpm start # Start the manager (default) 40 | pnpm start:bot # Start single bot instance 41 | pnpm start:manager # Start cluster manager 42 | 43 | # Database 44 | pnpm db:generate # Generate migrations 45 | pnpm db:push # Push schema changes 46 | pnpm db:migrate # Run migrations 47 | pnpm db:studio # Open Drizzle Studio 48 | 49 | # Discord Commands 50 | pnpm commands:register # Register slash commands 51 | pnpm commands:view # View registered commands 52 | pnpm commands:delete # Delete commands 53 | ``` 54 | 55 | ## Key Constants (src/constants/misc.ts) 56 | - `MAX_ITEM_HOURS`: Maximum age for feed items to be processed (replaces MAX_ITEM_AGE_DAYS) 57 | - `DEFAULT_FREQUENCY_MINUTES`: Default polling frequency 58 | - `MAX_FREQUENCY_MINUTES`: Maximum allowed polling frequency 59 | - `MIN_FREQUENCY_MINUTES`: Minimum allowed polling frequency 60 | 61 | ## Development Notes 62 | - Uses ES modules (`"type": "module"` in package.json) 63 | - TypeScript with strict configuration 64 | - Code follows ESLint and Prettier formatting 65 | - Environment variables loaded via dotenv 66 | - Analytics via PostHog 67 | - Error handling with exponential backoff for feed failures 68 | 69 | ## Database Schema 70 | Located in `src/db/schema.ts` - uses Drizzle ORM with PostgreSQL for storing feed configurations, guild settings, and processed items. 71 | 72 | ## Testing 73 | - Unit tests in `/tests/` directory 74 | - Uses Vitest as test runner 75 | - Coverage reports available via `pnpm test:coverage` 76 | 77 | ## Docker Support 78 | - Docker Compose configuration available 79 | - Supports both local and external PostgreSQL databases 80 | - Use `--profile localdb` for local database setup -------------------------------------------------------------------------------- /src/models/api.ts: -------------------------------------------------------------------------------- 1 | import express, { Express } from 'express'; 2 | import { createRequire } from 'node:module'; 3 | import util from 'node:util'; 4 | 5 | import { Controller } from '../controllers/index.js'; 6 | import { checkAuth, handleError } from '../middleware/index.js'; 7 | import { Logger } from '../services/index.js'; 8 | 9 | const require = createRequire(import.meta.url); 10 | let Config = require('../../config/config.json'); 11 | let Logs = require('../../lang/logs.json'); 12 | 13 | export class Api { 14 | private app: Express; 15 | private server: any = null; 16 | 17 | constructor(public controllers: Controller[]) { 18 | this.app = express(); 19 | this.app.use(express.json()); 20 | this.setupHealthcheck(); 21 | this.setupControllers(); 22 | this.app.use(handleError()); 23 | } 24 | 25 | private setupHealthcheck(): void { 26 | // Health endpoint for Railway healthchecks 27 | // Returns unhealthy (503) if memory usage exceeds threshold 28 | this.app.get('/health', (_req, res) => { 29 | const memUsage = process.memoryUsage(); 30 | const heapUsedMB = Math.round(memUsage.heapUsed / 1024 / 1024); 31 | const rssMB = Math.round(memUsage.rss / 1024 / 1024); 32 | 33 | const MEMORY_THRESHOLD_MB = 300; // Return unhealthy above this - leave headroom for 100MB spikes 34 | const isHealthy = rssMB < MEMORY_THRESHOLD_MB; 35 | 36 | const status = { 37 | status: isHealthy ? 'healthy' : 'unhealthy', 38 | memory: { 39 | rss: `${rssMB}MB`, 40 | heap: `${heapUsedMB}MB`, 41 | threshold: `${MEMORY_THRESHOLD_MB}MB`, 42 | }, 43 | uptime: Math.round(process.uptime()), 44 | }; 45 | 46 | res.status(isHealthy ? 200 : 503).json(status); 47 | }); 48 | } 49 | 50 | public async start(): Promise { 51 | let listen = util.promisify(this.app.listen.bind(this.app)); 52 | this.server = await listen(Config.api.port); 53 | Logger.info(Logs.info.apiStarted.replaceAll('{PORT}', Config.api.port)); 54 | } 55 | 56 | public async stop(): Promise { 57 | Logger.info('[Api] Stopping API server...'); 58 | if (this.server) { 59 | return new Promise((resolve, reject) => { 60 | this.server.close((err?: Error) => { 61 | if (err) { 62 | Logger.error('[Api] Error stopping API server:', err); 63 | reject(err); 64 | } else { 65 | Logger.info('[Api] API server stopped.'); 66 | resolve(); 67 | } 68 | }); 69 | }); 70 | } 71 | } 72 | 73 | private setupControllers(): void { 74 | for (let controller of this.controllers) { 75 | if (controller.authToken) { 76 | controller.router.use(checkAuth(controller.authToken)); 77 | } 78 | controller.register(); 79 | this.app.use(controller.path, controller.router); 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/services/logger.ts: -------------------------------------------------------------------------------- 1 | import { DiscordAPIError } from 'discord.js'; 2 | import { Response } from 'node-fetch'; 3 | import { createRequire } from 'node:module'; 4 | import pino from 'pino'; 5 | 6 | const require = createRequire(import.meta.url); 7 | let Config = require('../../config/config.json'); 8 | 9 | // Disable pino-pretty in production to save memory 10 | const usePrettyLogging = Config.logging.pretty && process.env.NODE_ENV !== 'production'; 11 | 12 | let logger = pino( 13 | { 14 | formatters: { 15 | level: label => { 16 | return { level: label }; 17 | }, 18 | }, 19 | }, 20 | usePrettyLogging 21 | ? pino.transport({ 22 | target: 'pino-pretty', 23 | options: { 24 | colorize: true, 25 | ignore: 'pid,hostname', 26 | translateTime: 'yyyy-mm-dd HH:MM:ss.l', 27 | }, 28 | }) 29 | : undefined 30 | ); 31 | 32 | export class Logger { 33 | private static shardId: number; 34 | 35 | public static info(message: string, obj?: any): void { 36 | if (obj) { 37 | logger.info(obj, message); 38 | } else { 39 | logger.info(message); 40 | } 41 | } 42 | 43 | public static warn(message: string, obj?: any): void { 44 | if (obj) { 45 | logger.warn(obj, message); 46 | } else { 47 | logger.warn(message); 48 | } 49 | } 50 | 51 | public static async error(message: string, obj?: any): Promise { 52 | // Log just a message if no error object 53 | if (!obj) { 54 | logger.error(message); 55 | return; 56 | } 57 | 58 | // Otherwise log details about the error 59 | if (typeof obj === 'string') { 60 | logger 61 | .child({ 62 | message: obj, 63 | }) 64 | .error(message); 65 | } else if (obj instanceof Response) { 66 | let resText: string; 67 | try { 68 | resText = await obj.text(); 69 | } catch { 70 | // Ignore 71 | } 72 | logger 73 | .child({ 74 | path: obj.url, 75 | statusCode: obj.status, 76 | statusName: obj.statusText, 77 | headers: obj.headers.raw(), 78 | body: resText, 79 | }) 80 | .error(message); 81 | } else if (obj instanceof DiscordAPIError) { 82 | logger 83 | .child({ 84 | message: obj.message, 85 | code: obj.code, 86 | statusCode: obj.status, 87 | method: obj.method, 88 | url: obj.url, 89 | stack: obj.stack, 90 | }) 91 | .error(message); 92 | } else { 93 | logger.error(obj, message); 94 | } 95 | } 96 | 97 | public static setShardId(shardId: number): void { 98 | if (this.shardId !== shardId) { 99 | this.shardId = shardId; 100 | logger = logger.child({ shardId }); 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "parserOptions": { 5 | "project": "./tsconfig.json" 6 | }, 7 | "plugins": ["@typescript-eslint", "import", "unicorn"], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/recommended", 11 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 12 | "plugin:import/recommended", 13 | "plugin:import/typescript" 14 | ], 15 | "rules": { 16 | "@typescript-eslint/explicit-function-return-type": [ 17 | "error", 18 | { 19 | "allowExpressions": true 20 | } 21 | ], 22 | "@typescript-eslint/no-explicit-any": "off", 23 | "@typescript-eslint/no-floating-promises": "off", 24 | "@typescript-eslint/no-inferrable-types": [ 25 | "error", 26 | { 27 | "ignoreParameters": true 28 | } 29 | ], 30 | "@typescript-eslint/no-misused-promises": "off", 31 | "@typescript-eslint/no-unsafe-argument": "off", 32 | "@typescript-eslint/no-unsafe-assignment": "off", 33 | "@typescript-eslint/no-unsafe-call": "off", 34 | "@typescript-eslint/no-unsafe-enum-comparison": "off", 35 | "@typescript-eslint/no-unsafe-member-access": "off", 36 | "@typescript-eslint/no-unsafe-return": "off", 37 | "@typescript-eslint/no-unused-vars": [ 38 | "error", 39 | { 40 | "argsIgnorePattern": "^_", 41 | "caughtErrorsIgnorePattern": "^_", 42 | "varsIgnorePattern": "^_" 43 | } 44 | ], 45 | "@typescript-eslint/no-var-requires": "off", 46 | "@typescript-eslint/only-throw-error": "off", 47 | "@typescript-eslint/require-await": "off", 48 | "@typescript-eslint/restrict-template-expressions": "off", 49 | "@typescript-eslint/return-await": ["error", "always"], 50 | "@typescript-eslint/typedef": [ 51 | "error", 52 | { 53 | "parameter": true, 54 | "propertyDeclaration": true 55 | } 56 | ], 57 | "import/extensions": ["error", "ignorePackages"], 58 | "import/no-extraneous-dependencies": "error", 59 | "import/no-unresolved": "off", 60 | "import/no-useless-path-segments": "error", 61 | "import/order": [ 62 | "error", 63 | { 64 | "alphabetize": { 65 | "caseInsensitive": true, 66 | "order": "asc" 67 | }, 68 | "groups": [ 69 | ["builtin", "external", "object", "type"], 70 | ["internal", "parent", "sibling", "index"] 71 | ], 72 | "newlines-between": "always" 73 | } 74 | ], 75 | "no-return-await": "off", 76 | "no-unused-vars": "off", 77 | "prefer-const": "off", 78 | "quotes": [ 79 | "error", 80 | "single", 81 | { 82 | "allowTemplateLiterals": true 83 | } 84 | ], 85 | "sort-imports": [ 86 | "error", 87 | { 88 | "allowSeparatedGroups": true, 89 | "ignoreCase": true, 90 | "ignoreDeclarationSort": true, 91 | "ignoreMemberSort": false, 92 | "memberSyntaxSortOrder": ["none", "all", "multiple", "single"] 93 | } 94 | ], 95 | "unicorn/prefer-node-protocol": "error" 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/models/enum-helpers/permission.ts: -------------------------------------------------------------------------------- 1 | import { PermissionFlagsBits } from 'discord.js'; 2 | 3 | // import { Language } from './language.js'; // Remove Language import as well 4 | 5 | // Simplified permission data - just mapping names to flags 6 | export abstract class Permission { 7 | // Using Discord.js PermissionFlagsBits directly is often simpler 8 | // Keeping this structure if the template relies on it elsewhere, but simplified 9 | public static Data = { 10 | AddReactions: PermissionFlagsBits.AddReactions, 11 | Administrator: PermissionFlagsBits.Administrator, 12 | AttachFiles: PermissionFlagsBits.AttachFiles, 13 | BanMembers: PermissionFlagsBits.BanMembers, 14 | ChangeNickname: PermissionFlagsBits.ChangeNickname, 15 | Connect: PermissionFlagsBits.Connect, 16 | CreateEvents: PermissionFlagsBits.CreateEvents, 17 | CreateGuildExpressions: PermissionFlagsBits.CreateGuildExpressions, 18 | CreateInstantInvite: PermissionFlagsBits.CreateInstantInvite, 19 | CreatePrivateThreads: PermissionFlagsBits.CreatePrivateThreads, 20 | CreatePublicThreads: PermissionFlagsBits.CreatePublicThreads, 21 | DeafenMembers: PermissionFlagsBits.DeafenMembers, 22 | EmbedLinks: PermissionFlagsBits.EmbedLinks, 23 | KickMembers: PermissionFlagsBits.KickMembers, 24 | ManageChannels: PermissionFlagsBits.ManageChannels, 25 | ManageEmojisAndStickers: PermissionFlagsBits.ManageEmojisAndStickers, 26 | ManageEvents: PermissionFlagsBits.ManageEvents, 27 | ManageGuild: PermissionFlagsBits.ManageGuild, 28 | ManageGuildExpressions: PermissionFlagsBits.ManageGuildExpressions, 29 | ManageMessages: PermissionFlagsBits.ManageMessages, 30 | ManageNicknames: PermissionFlagsBits.ManageNicknames, 31 | ManageRoles: PermissionFlagsBits.ManageRoles, 32 | ManageThreads: PermissionFlagsBits.ManageThreads, 33 | ManageWebhooks: PermissionFlagsBits.ManageWebhooks, 34 | MentionEveryone: PermissionFlagsBits.MentionEveryone, 35 | ModerateMembers: PermissionFlagsBits.ModerateMembers, 36 | MoveMembers: PermissionFlagsBits.MoveMembers, 37 | MuteMembers: PermissionFlagsBits.MuteMembers, 38 | PrioritySpeaker: PermissionFlagsBits.PrioritySpeaker, 39 | ReadMessageHistory: PermissionFlagsBits.ReadMessageHistory, 40 | RequestToSpeak: PermissionFlagsBits.RequestToSpeak, 41 | SendMessages: PermissionFlagsBits.SendMessages, 42 | SendMessagesInThreads: PermissionFlagsBits.SendMessagesInThreads, 43 | SendPolls: PermissionFlagsBits.SendPolls, 44 | SendTTSMessages: PermissionFlagsBits.SendTTSMessages, 45 | SendVoiceMessages: PermissionFlagsBits.SendVoiceMessages, 46 | Speak: PermissionFlagsBits.Speak, 47 | Stream: PermissionFlagsBits.Stream, 48 | UseApplicationCommands: PermissionFlagsBits.UseApplicationCommands, 49 | UseEmbeddedActivities: PermissionFlagsBits.UseEmbeddedActivities, 50 | UseExternalApps: PermissionFlagsBits.UseExternalApps, 51 | UseExternalEmojis: PermissionFlagsBits.UseExternalEmojis, 52 | UseExternalSounds: PermissionFlagsBits.UseExternalSounds, 53 | UseExternalStickers: PermissionFlagsBits.UseExternalStickers, 54 | UseSoundboard: PermissionFlagsBits.UseSoundboard, 55 | UseVAD: PermissionFlagsBits.UseVAD, 56 | ViewAuditLog: PermissionFlagsBits.ViewAuditLog, 57 | ViewChannel: PermissionFlagsBits.ViewChannel, 58 | ViewCreatorMonetizationAnalytics: PermissionFlagsBits.ViewCreatorMonetizationAnalytics, 59 | ViewGuildInsights: PermissionFlagsBits.ViewGuildInsights, 60 | }; 61 | } 62 | -------------------------------------------------------------------------------- /src/commands/chat/help-command.ts: -------------------------------------------------------------------------------- 1 | import { ChatInputCommandInteraction, EmbedBuilder, PermissionsString } from 'discord.js'; 2 | 3 | import { HelpOption } from '../../enums/index.js'; 4 | import { EventData } from '../../models/internal-models.js'; 5 | import { ClientUtils, FormatUtils, InteractionUtils } from '../../utils/index.js'; 6 | import { Command, CommandDeferType } from '../index.js'; 7 | import { ChatCommandMetadata } from '../metadata.js'; 8 | 9 | export class HelpCommand implements Command { 10 | public names = ['help']; 11 | public deferType = CommandDeferType.HIDDEN; 12 | public requireClientPerms: PermissionsString[] = []; 13 | 14 | public async execute(intr: ChatInputCommandInteraction, data: EventData): Promise { 15 | let args = { 16 | option: (intr.options.getString('option') as HelpOption) ?? HelpOption.COMMANDS, 17 | }; 18 | 19 | let embed: EmbedBuilder; 20 | switch (args.option) { 21 | case HelpOption.CONTACT_SUPPORT: { 22 | const feedbackCmd = await ClientUtils.findAppCommand( 23 | intr.client, 24 | ChatCommandMetadata.FEEDBACK.name 25 | ); 26 | const feedbackMention = feedbackCmd 27 | ? FormatUtils.commandMention(feedbackCmd) 28 | : `\`/${ChatCommandMetadata.FEEDBACK.name}\``; 29 | 30 | embed = new EmbedBuilder() 31 | .setTitle('Contact Support') 32 | .setDescription( 33 | `If you're experiencing issues or have feedback, please use ${feedbackMention} to reach out to us.` 34 | ) 35 | .setColor('Blue'); 36 | break; 37 | } 38 | case HelpOption.COMMANDS: { 39 | const commandList = Object.entries(ChatCommandMetadata) 40 | .map(([key, metadata]) => { 41 | return ClientUtils.findAppCommand(intr.client, metadata.name).then( 42 | appCmd => { 43 | if (!appCmd) 44 | return `• **/${metadata.name}**: ${metadata.description}`; 45 | const mention = FormatUtils.commandMention(appCmd); 46 | return `• ${mention}: ${metadata.description}`; 47 | } 48 | ); 49 | }) 50 | .filter(item => item !== null); 51 | 52 | const commandDescriptions = await Promise.all(commandList); 53 | 54 | const feedbackCmd = await ClientUtils.findAppCommand( 55 | intr.client, 56 | ChatCommandMetadata.FEEDBACK.name 57 | ); 58 | const feedbackMention = feedbackCmd 59 | ? FormatUtils.commandMention(feedbackCmd) 60 | : `\`/${ChatCommandMetadata.FEEDBACK.name}\``; 61 | 62 | embed = new EmbedBuilder() 63 | .setTitle('Command List') 64 | .setDescription( 65 | `Here are the available commands:\n\n${commandDescriptions.join( 66 | '\n' 67 | )}\n\nNeed help? Use ${feedbackMention} to report issues or provide feedback.` 68 | ) 69 | .setColor('Green'); 70 | break; 71 | } 72 | default: { 73 | await InteractionUtils.send( 74 | intr, 75 | `Invalid help option specified: ${args.option}`, 76 | true 77 | ); 78 | return; 79 | } 80 | } 81 | 82 | await InteractionUtils.send(intr, { embeds: [embed] }); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "discorss-bot", 3 | "version": "1.0.0", 4 | "author": "mergd", 5 | "description": "A discord.js bot for RSS feeds", 6 | "license": "MIT", 7 | "private": true, 8 | "engines": { 9 | "bun": ">=1.0.0" 10 | }, 11 | "type": "module", 12 | "exports": [ 13 | "./dist/start-bot.js", 14 | "./dist/start-manager.js" 15 | ], 16 | "scripts": { 17 | "lint": "bunx eslint . --cache --ext .js,.jsx,.ts,.tsx", 18 | "lint:fix": "bunx eslint . --fix --cache --ext .js,.jsx,.ts,.tsx", 19 | "format": "bunx prettier --check .", 20 | "format:fix": "bunx prettier --write .", 21 | "clean": "git clean -xdf --exclude=\"./config/**/*\"", 22 | "clean:dry": "git clean -xdf --exclude=\"./config/**/*\" --dry-run", 23 | "build": "bun run tsc --project tsconfig.json", 24 | "commands:view": "bun run build && bun --enable-source-maps dist/start-bot.js commands view", 25 | "commands:register": "bun run build && bun --enable-source-maps dist/start-bot.js commands register", 26 | "commands:rename": "bun run build && bun --enable-source-maps dist/start-bot.js commands rename", 27 | "commands:delete": "bun run build && bun --enable-source-maps dist/start-bot.js commands delete", 28 | "commands:clear": "bun run build && bun --enable-source-maps dist/start-bot.js commands clear", 29 | "start": "bun run start:manager", 30 | "start:bot": "bun run build && bun --enable-source-maps dist/start-bot.js", 31 | "start:manager": "bun run build && bun --enable-source-maps dist/start-manager.js", 32 | "test": "bun test", 33 | "test:watch": "bun test --watch", 34 | "test:coverage": "bun test --coverage", 35 | "db:generate": "bunx drizzle-kit generate --config=src/drizzle.config.ts", 36 | "db:push": "bunx drizzle-kit push --config=src/drizzle.config.ts", 37 | "db:studio": "bunx drizzle-kit studio", 38 | "db:migrate": "bunx drizzle-kit migrate --config=src/drizzle.config.ts", 39 | "db:backup": "bun run build && bun --enable-source-maps dist/scripts/backup-db.js", 40 | "db:restore": "bun run build && bun --enable-source-maps dist/scripts/restore-db.js", 41 | "docker:build": "docker build -t discord-bot-rss . && docker run -d --name discord-bot-rss discord-bot-rss", 42 | "docker:run": "docker start discord-bot-rss" 43 | }, 44 | "dependencies": { 45 | "@discordjs/rest": "^2.2.0", 46 | "@libsql/client": "^0.15.3", 47 | "@posthog/ai": "^7.0.0", 48 | "@types/pg": "^8.11.11", 49 | "ai": "^4.3.7", 50 | "class-transformer": "0.5.1", 51 | "class-validator": "0.14.1", 52 | "cron-parser": "^4.9.0", 53 | "date-fns": "^4.1.0", 54 | "discord-hybrid-sharding": "^2.2.6", 55 | "discord.js": "^14.15.2", 56 | "discord.js-rate-limiter": "1.3.2", 57 | "dotenv": "^16.4.5", 58 | "drizzle-orm": "^0.44.5", 59 | "express": "4.21.2", 60 | "express-promise-router": "4.1.1", 61 | "filesize": "10.1.6", 62 | "jsdom": "^26.1.0", 63 | "linguini": "1.3.1", 64 | "luxon": "3.5.0", 65 | "node-fetch": "3.3.2", 66 | "node-schedule": "2.1.1", 67 | "openai": "^6.8.0", 68 | "pg": "^8.14.1", 69 | "pino": "9.6.0", 70 | "pino-pretty": "13.0.0", 71 | "postgres": "^3.4.5", 72 | "posthog-node": "^5.11.0", 73 | "reflect-metadata": "^0.2.2", 74 | "remove-markdown": "0.6.0", 75 | "rss-parser": "^3.13.0", 76 | "tslib": "^2.6.2", 77 | "typescript": "^5.8.3", 78 | "uuid": "^10.0.0" 79 | }, 80 | "devDependencies": { 81 | "@types/express": "4.17.21", 82 | "@types/jsdom": "^21.1.7", 83 | "@types/luxon": "3.4.2", 84 | "@types/node": "^20.12.12", 85 | "@types/node-schedule": "2.1.7", 86 | "@types/remove-markdown": "0.3.4", 87 | "@types/uuid": "^9.0.8", 88 | "@typescript-eslint/eslint-plugin": "^8.24.1", 89 | "@typescript-eslint/parser": "^8.24.1", 90 | "drizzle-kit": "^0.31.4", 91 | "eslint": "^9.20.1", 92 | "eslint-plugin-import": "^2.31.0", 93 | "eslint-plugin-unicorn": "^57.0.0", 94 | "prettier": "^3.5.1" 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /LEGAL.md: -------------------------------------------------------------------------------- 1 | # Terms of Service 2 | 3 | ## Usage Agreement 4 | 5 | By inviting the bot or using its features, you are agreeing to the below mentioned Terms of Service and Privacy Policy. 6 | 7 | You acknowledge that you have the privilege to use the bot freely on any Discord server you share with it, that you can invite it to any server that you have "Manage Server" rights for and that this privilege might get revoked for you, if you're subject of breaking the terms and/or policy of this bot, or the Terms of Service, Privacy Policy and/or Community Guidelines of Discord Inc. 8 | 9 | Through inviting or interacting with the bot it may collect specific data as described in its [Privacy Policy](#privacy-policy). The intended usage of this data is for core functionalities of the bot such as command handling, server settings, and user settings. 10 | 11 | ## Intended Age 12 | 13 | The bot may not be used by individuals under the minimal age described in Discord's Terms of Service. 14 | 15 | Do not provide any age-restricted content (as defined in Discord's safety policies) to the bot. Age-restricted content includes but is not limited to content and discussion related to: 16 | 17 | - Sexually explicit material such as pornography or sexually explicit text 18 | - Violent content 19 | - Illegal, dangerous, and regulated goods such as firearms, tactical gear, alcohol, or drug use 20 | - Gambling-adjacent or addictive behavior 21 | 22 | Content submitted to the bot through the use of commands arguments, text inputs, image inputs, or otherwise must adhere to the above conditions. Violating these conditions may result in your account being reported to Discord Inc for further action. 23 | 24 | ## Affiliation 25 | 26 | The bot is not affiliated with, supported by, or made by Discord Inc. 27 | 28 | Any direct connection to Discord or any of its trademark objects is purely coincidental. We do not claim to have the copyright ownership of any of Discord's assets, trademarks or other intellectual property. 29 | 30 | ## Liability 31 | 32 | The owner(s) of the bot may not be made liable for individuals breaking these Terms at any given time. We have faith in the end users being truthful about their information and not misusing this bot or the services of Discord Inc in a malicious way. 33 | 34 | We reserve the right to update these terms at our own discretion, giving you a 1-week (7 days) period to opt out of these terms if you're not agreeing with the new changes. 35 | 36 | You may opt out by removing the bot from any server you have the rights for. 37 | 38 | ## Contact 39 | 40 | People may get in contact through the official support server of the bot. 41 | 42 | Other ways of support may be provided but aren't guaranteed. 43 | 44 | # Privacy Policy 45 | 46 | ## Usage of Data 47 | 48 | The bot may use stored data, as defined below, for different features including but not limited to: 49 | 50 | - Command handling 51 | - Providing server and user preferences 52 | 53 | The bot may share non-sensitive data with 3rd party sites or services, including but not limited to: 54 | 55 | - Aggregate/statistical data (ex: total number of server or users) 56 | - Discord generated IDs needed to tie 3rd party data to Discord or user-provided data 57 | 58 | Personally identifiable (other than IDs) or sensitive information will not be shared with 3rd party sites or services. 59 | 60 | ## Updating Data 61 | 62 | The bot's data may be updated when using specific commands. 63 | 64 | Updating data can require the input of an end user, and data that can be seen as sensitive, such as content of a message, may need to be stored when using certain commands. 65 | 66 | ## Temporarily Stored Data 67 | 68 | The bot may keep stored data in an internal caching mechanic for a certain amount of time. After this time period, the cached information will be dropped and only be re-added when required. 69 | 70 | Data may be dropped from cache pre-maturely through actions such as removing the bot from the server. 71 | 72 | ## Removal of Data 73 | 74 | Manual removal of the data can be requested through the official support server. Discord IDs such as user, guild, role, etc. may be stored even after the removal of other data in order to properly identify bot specific statistics since those IDs are public and non-sensitive. 75 | 76 | For security reasons we will ask you to provide us with proof of ownership to the data you wish to be removed. Only a server owner may request manual removal of server data. 77 | -------------------------------------------------------------------------------- /tests/utils/random-utils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; 2 | import { RandomUtils } from '../../src/utils/index.js'; 3 | 4 | // Mock any configs that might be loaded 5 | vi.mock('../../config/config.json', () => ({})); 6 | vi.mock('../../config/debug.json', () => ({})); 7 | vi.mock('../../lang/logs.json', () => ({})); 8 | 9 | describe('RandomUtils', () => { 10 | // Store the original Math.random function 11 | const originalRandom = Math.random; 12 | 13 | // After each test, restore the original Math.random 14 | afterEach(() => { 15 | Math.random = originalRandom; 16 | }); 17 | 18 | describe('intFromInterval', () => { 19 | it('should return a number within the specified range', () => { 20 | // Test with a range of values 21 | for (let i = 0; i < 100; i++) { 22 | const min = 5; 23 | const max = 10; 24 | const result = RandomUtils.intFromInterval(min, max); 25 | 26 | expect(result).toBeGreaterThanOrEqual(min); 27 | expect(result).toBeLessThanOrEqual(max); 28 | expect(Number.isInteger(result)).toBe(true); 29 | } 30 | }); 31 | 32 | it('should use Math.random correctly', () => { 33 | // Mock Math.random to return a specific value 34 | Math.random = vi.fn().mockReturnValue(0.5); 35 | 36 | const result = RandomUtils.intFromInterval(1, 10); 37 | 38 | // With Math.random() = 0.5, we expect it to return the middle value 39 | // 1 + Math.floor(0.5 * (10 - 1 + 1)) = 1 + Math.floor(5) = 1 + 5 = 6 40 | expect(result).toBe(6); 41 | expect(Math.random).toHaveBeenCalled(); 42 | }); 43 | 44 | it('should handle min equal to max', () => { 45 | const result = RandomUtils.intFromInterval(5, 5); 46 | expect(result).toBe(5); 47 | }); 48 | 49 | it('should handle negative ranges', () => { 50 | Math.random = vi.fn().mockReturnValue(0.5); 51 | 52 | const result = RandomUtils.intFromInterval(-10, -5); 53 | 54 | // With Math.random() = 0.5, and range of -10 to -5 (6 numbers) 55 | // -10 + Math.floor(0.5 * (-5 - -10 + 1)) = -10 + Math.floor(0.5 * 6) = -10 + 3 = -7 56 | expect(result).toBe(-7); 57 | }); 58 | }); 59 | 60 | describe('shuffle', () => { 61 | it('should maintain the same elements after shuffling', () => { 62 | const original = [1, 2, 3, 4, 5]; 63 | const shuffled = RandomUtils.shuffle([...original]); 64 | 65 | // Check that no elements were added or removed 66 | expect(shuffled.length).toBe(original.length); 67 | original.forEach(item => { 68 | expect(shuffled).toContain(item); 69 | }); 70 | }); 71 | 72 | it('should shuffle elements based on Math.random', () => { 73 | // Create a predictable sequence of random values 74 | const randomValues = [0.5, 0.1, 0.9, 0.3]; 75 | let callCount = 0; 76 | Math.random = vi.fn().mockImplementation(() => { 77 | return randomValues[callCount++ % randomValues.length]; 78 | }); 79 | 80 | const original = [1, 2, 3, 4]; 81 | const shuffled = RandomUtils.shuffle([...original]); 82 | 83 | // With our mocked random sequence, we can predict the shuffle outcome 84 | // This relies on the specific Fisher-Yates implementation 85 | expect(shuffled).not.toEqual(original); 86 | expect(Math.random).toHaveBeenCalled(); 87 | }); 88 | 89 | it('should handle empty arrays', () => { 90 | const result = RandomUtils.shuffle([]); 91 | expect(result).toEqual([]); 92 | }); 93 | 94 | it('should handle single-element arrays', () => { 95 | const result = RandomUtils.shuffle([1]); 96 | expect(result).toEqual([1]); 97 | }); 98 | 99 | it('should return the input array reference', () => { 100 | const input = [1, 2, 3]; 101 | const result = RandomUtils.shuffle(input); 102 | expect(result).toBe(input); // Same reference 103 | }); 104 | }); 105 | }); 106 | -------------------------------------------------------------------------------- /src/utils/command-utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Interaction, 3 | CommandInteraction, 4 | GuildChannel, 5 | GuildMember, 6 | MessageComponentInteraction, 7 | ModalSubmitInteraction, 8 | NewsChannel, 9 | PermissionsBitField, 10 | TextChannel, 11 | ThreadChannel, 12 | } from 'discord.js'; 13 | import { createRequire } from 'node:module'; 14 | 15 | import { Command } from '../commands/index.js'; 16 | import { EventData } from '../models/internal-models.js'; 17 | import { Logger } from '../services/logger.js'; 18 | import { InteractionUtils } from './index.js'; 19 | 20 | const require = createRequire(import.meta.url); 21 | let Config = require('../../config/config.json'); 22 | 23 | export class CommandUtils { 24 | public static findCommand(commands: Command[], commandParts: string[]): Command { 25 | let found = [...commands]; 26 | let closestMatch: Command; 27 | for (let [index, commandPart] of commandParts.entries()) { 28 | found = found.filter(command => command.names[index] === commandPart); 29 | if (found.length === 0) { 30 | return closestMatch; 31 | } 32 | 33 | if (found.length === 1) { 34 | return found[0]; 35 | } 36 | 37 | let exactMatch = found.find(command => command.names.length === index + 1); 38 | if (exactMatch) { 39 | closestMatch = exactMatch; 40 | } 41 | } 42 | return closestMatch; 43 | } 44 | 45 | public static async runChecks( 46 | command: Command, 47 | intr: CommandInteraction | MessageComponentInteraction | ModalSubmitInteraction, 48 | data: EventData 49 | ): Promise { 50 | if (intr.inGuild()) { 51 | if (command.requireClientPerms?.length > 0) { 52 | let me = intr.guild.members.me; 53 | if (!me) { 54 | try { 55 | me = await intr.guild.members.fetchMe(); 56 | } catch (err) { 57 | Logger.error('Failed to fetch self member:', err); 58 | } 59 | } 60 | if (!me?.permissions) { 61 | await InteractionUtils.send(intr, 'Could not determine my permissions.', true); 62 | return false; 63 | } 64 | 65 | if (!intr.channel || !intr.channelId) { 66 | await InteractionUtils.send( 67 | intr, 68 | 'Could not determine the channel context.', 69 | true 70 | ); 71 | return false; 72 | } 73 | 74 | let channelPerms = intr.channel?.permissionsFor 75 | ? intr.channel.permissionsFor(me) 76 | : me.permissionsIn(intr.channelId); 77 | if (!channelPerms) { 78 | await InteractionUtils.send( 79 | intr, 80 | 'Could not determine my permissions in this channel.', 81 | true 82 | ); 83 | return false; 84 | } 85 | 86 | let missingClientPerms = command.requireClientPerms.filter( 87 | perm => !channelPerms.has(perm) 88 | ); 89 | if (missingClientPerms.length > 0) { 90 | await InteractionUtils.send( 91 | intr, 92 | `I am missing the following permissions in this channel: ${missingClientPerms.join(', ')}`, 93 | true 94 | ); 95 | return false; 96 | } 97 | } 98 | } 99 | 100 | if (command.cooldown) { 101 | let limited = command.cooldown.take(intr.user.id); 102 | if (limited) { 103 | const intervalSeconds = command.cooldown.interval / 1000; 104 | await InteractionUtils.send( 105 | intr, 106 | `This command is on cooldown. Please wait ${intervalSeconds} seconds.`, 107 | true 108 | ); 109 | return false; 110 | } 111 | } 112 | 113 | return true; 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/commands/chat/releasenotes-command.ts: -------------------------------------------------------------------------------- 1 | import { ChatInputCommandInteraction, EmbedBuilder, PermissionsString } from 'discord.js'; 2 | import { createRequire } from 'node:module'; 3 | 4 | import { EventData } from '../../models/internal-models.js'; 5 | import { InteractionUtils } from '../../utils/index.js'; 6 | import { Command, CommandDeferType } from '../index.js'; 7 | 8 | const require = createRequire(import.meta.url); 9 | 10 | interface ReleaseNote { 11 | version: string; 12 | date: string; 13 | title: string; 14 | features?: string[]; 15 | improvements?: string[]; 16 | bugfixes?: string[]; 17 | url?: string; 18 | } 19 | 20 | export class ReleaseNotesCommand implements Command { 21 | public names = ['releasenotes']; 22 | public deferType = CommandDeferType.PUBLIC; 23 | public requireClientPerms: PermissionsString[] = ['SendMessages', 'EmbedLinks']; 24 | 25 | public async execute(intr: ChatInputCommandInteraction, _data: EventData): Promise { 26 | try { 27 | const releaseNotes: ReleaseNote[] = require('../../../config/release-notes.json'); 28 | 29 | if (!releaseNotes || releaseNotes.length === 0) { 30 | await InteractionUtils.send( 31 | intr, 32 | 'No release notes available yet. Check back later!', 33 | true 34 | ); 35 | return; 36 | } 37 | 38 | const latestRelease = releaseNotes[0]; 39 | const description = this.buildReleaseDescription(latestRelease); 40 | 41 | const embed = new EmbedBuilder() 42 | .setTitle(`Release Notes: ${latestRelease.title || latestRelease.version}`) 43 | .setDescription(description) 44 | .setColor('Blurple') 45 | .setTimestamp(new Date(latestRelease.date)); 46 | 47 | if (latestRelease.url) { 48 | embed.setURL(latestRelease.url); 49 | } 50 | 51 | if (releaseNotes.length > 1) { 52 | const previousReleases = releaseNotes 53 | .slice(1, 6) 54 | .map(release => { 55 | const displayName = release.title || release.version; 56 | return release.url 57 | ? `[${displayName}](${release.url})` 58 | : `**${displayName}**`; 59 | }) 60 | .join('\n'); 61 | 62 | if (previousReleases) { 63 | embed.addFields({ 64 | name: 'Previous Releases', 65 | value: previousReleases, 66 | }); 67 | } 68 | } 69 | 70 | await InteractionUtils.send(intr, { embeds: [embed] }); 71 | } catch (error) { 72 | await InteractionUtils.send( 73 | intr, 74 | 'Unable to load release notes at this time.', 75 | true 76 | ); 77 | } 78 | } 79 | 80 | private buildReleaseDescription(release: ReleaseNote): string { 81 | const parts: string[] = []; 82 | 83 | if (release.version) { 84 | parts.push(`**Version:** ${release.version}`); 85 | } 86 | 87 | if (release.features && release.features.length > 0) { 88 | parts.push('\n**New Features:**'); 89 | release.features.forEach(feature => { 90 | parts.push(`• ${feature}`); 91 | }); 92 | } 93 | 94 | if (release.improvements && release.improvements.length > 0) { 95 | parts.push('\n**Improvements:**'); 96 | release.improvements.forEach(improvement => { 97 | parts.push(`• ${improvement}`); 98 | }); 99 | } 100 | 101 | if (release.bugfixes && release.bugfixes.length > 0) { 102 | parts.push('\n**Bug Fixes:**'); 103 | release.bugfixes.forEach(fix => { 104 | parts.push(`• ${fix}`); 105 | }); 106 | } 107 | 108 | let description = parts.join('\n'); 109 | 110 | if (description.length > 4096) { 111 | description = description.substring(0, 4093) + '...'; 112 | } 113 | 114 | return description || 'No release notes provided.'; 115 | } 116 | } 117 | 118 | 119 | -------------------------------------------------------------------------------- /lang/logs.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "appStarted": "Application started.", 4 | "apiStarted": "API started on port {PORT}.", 5 | "commandActionView": "\nLocal and remote:\n {LOCAL_AND_REMOTE_LIST}\nLocal only:\n {LOCAL_ONLY_LIST}\nRemote only:\n {REMOTE_ONLY_LIST}", 6 | "commandActionCreating": "Creating commands: {COMMAND_LIST}", 7 | "commandActionCreated": "Commands created.", 8 | "commandActionUpdating": "Updating commands: {COMMAND_LIST}", 9 | "commandActionUpdated": "Commands updated.", 10 | "commandActionRenaming": "Renaming command: '{OLD_COMMAND_NAME}' --> '{NEW_COMMAND_NAME}'", 11 | "commandActionRenamed": "Command renamed.", 12 | "commandActionDeleting": "Deleting command: '{COMMAND_NAME}'", 13 | "commandActionDeleted": "Command deleted.", 14 | "commandActionClearing": "Deleting all commands: {COMMAND_LIST}", 15 | "commandActionCleared": "Commands deleted.", 16 | "managerSpawningShards": "Spawning {SHARD_COUNT} shards: [{SHARD_LIST}].", 17 | "managerLaunchedShard": "Launched Shard {SHARD_ID}.", 18 | "managerAllShardsSpawned": "All shards have been spawned.", 19 | "clientLogin": "Client logged in as '{USER_TAG}'.", 20 | "clientReady": "Client is ready!", 21 | "jobScheduled": "Scheduled job '{JOB}' for '{SCHEDULE}'.", 22 | "jobRun": "Running job '{JOB}'.", 23 | "jobCompleted": "Job '{JOB}' completed.", 24 | "updatedServerCount": "Updated server count. Connected to {SERVER_COUNT} total servers.", 25 | "updatedServerCountSite": "Updated server count on '{BOT_SITE}'.", 26 | "guildJoined": "Guild '{GUILD_NAME}' ({GUILD_ID}) joined.", 27 | "guildLeft": "Guild '{GUILD_NAME}' ({GUILD_ID}) left." 28 | }, 29 | "warn": { 30 | "managerNoShards": "No shards to spawn." 31 | }, 32 | "error": { 33 | "unspecified": "An unspecified error occurred.", 34 | "unhandledRejection": "An unhandled promise rejection occurred.", 35 | "retrieveShards": "An error occurred while retrieving which shards to spawn.", 36 | "managerSpawningShards": "An error occurred while spawning shards.", 37 | "managerShardInfo": "An error occurred while retrieving shard info.", 38 | "commandAction": "An error occurred while running a command action.", 39 | "commandActionNotFound": "Could not find a command with the name '{COMMAND_NAME}'.", 40 | "commandActionRenameMissingArg": "Please supply the current command name and new command name.", 41 | "commandActionDeleteMissingArg": "Please supply a command name to delete.", 42 | "clientLogin": "An error occurred while the client attempted to login.", 43 | "job": "An error occurred while running the '{JOB}' job.", 44 | "updatedServerCountSite": "An error occurred while updating the server count on '{BOT_SITE}'.", 45 | "guildJoin": "An error occurred while processing a guild join.", 46 | "guildLeave": "An error occurred while processing a guild leave.", 47 | "message": "An error occurred while processing a message.", 48 | "reaction": "An error occurred while processing a reaction.", 49 | "command": "An error occurred while processing a command interaction.", 50 | "button": "An error occurred while processing a button interaction.", 51 | "commandNotFound": "[{INTERACTION_ID}] A command with the name '{COMMAND_NAME}' could not be found.", 52 | "autocompleteNotFound": "[{INTERACTION_ID}] An autocomplete method for the '{COMMAND_NAME}' command could not be found.", 53 | "commandGuild": "[{INTERACTION_ID}] An error occurred while executing the '{COMMAND_NAME}' command for user '{USER_TAG}' ({USER_ID}) in channel '{CHANNEL_NAME}' ({CHANNEL_ID}) in guild '{GUILD_NAME}' ({GUILD_ID}).", 54 | "autocompleteGuild": "[{INTERACTION_ID}] An error occurred while autocompleting the '{OPTION_NAME}' option for the '{COMMAND_NAME}' command for user '{USER_TAG}' ({USER_ID}) in channel '{CHANNEL_NAME}' ({CHANNEL_ID}) in guild '{GUILD_NAME}' ({GUILD_ID}).", 55 | "commandOther": "[{INTERACTION_ID}] An error occurred while executing the '{COMMAND_NAME}' command for user '{USER_TAG}' ({USER_ID}).", 56 | "autocompleteOther": "[{INTERACTION_ID}] An error occurred while autocompleting the '{OPTION_NAME}' option for the '{COMMAND_NAME}' command for user '{USER_TAG}' ({USER_ID}).", 57 | "apiRequest": "An error occurred while processing a '{HTTP_METHOD}' request to '{URL}'.", 58 | "apiRateLimit": "A rate limit was hit while making a request." 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /drizzle/migrations/meta/0001_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "9f6e9476-b4ad-4e11-a1f7-e88600b514c0", 3 | "prevId": "b61b8473-7289-4c8d-877f-f910d066c7bb", 4 | "version": "7", 5 | "dialect": "postgresql", 6 | "tables": { 7 | "public.categories": { 8 | "name": "categories", 9 | "schema": "", 10 | "columns": { 11 | "id": { 12 | "name": "id", 13 | "type": "serial", 14 | "primaryKey": true, 15 | "notNull": true 16 | }, 17 | "guild_id": { 18 | "name": "guild_id", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true 22 | }, 23 | "name": { 24 | "name": "name", 25 | "type": "text", 26 | "primaryKey": false, 27 | "notNull": true 28 | }, 29 | "name_lower": { 30 | "name": "name_lower", 31 | "type": "text", 32 | "primaryKey": false, 33 | "notNull": true 34 | }, 35 | "frequency_minutes": { 36 | "name": "frequency_minutes", 37 | "type": "integer", 38 | "primaryKey": false, 39 | "notNull": true 40 | } 41 | }, 42 | "indexes": { 43 | "categories_guild_name_lower_idx": { 44 | "name": "categories_guild_name_lower_idx", 45 | "columns": [ 46 | { 47 | "expression": "guild_id", 48 | "isExpression": false, 49 | "asc": true, 50 | "nulls": "last" 51 | }, 52 | { 53 | "expression": "name_lower", 54 | "isExpression": false, 55 | "asc": true, 56 | "nulls": "last" 57 | } 58 | ], 59 | "isUnique": true, 60 | "concurrently": false, 61 | "method": "btree", 62 | "with": {} 63 | } 64 | }, 65 | "foreignKeys": {}, 66 | "compositePrimaryKeys": {}, 67 | "uniqueConstraints": {} 68 | }, 69 | "public.feeds": { 70 | "name": "feeds", 71 | "schema": "", 72 | "columns": { 73 | "id": { 74 | "name": "id", 75 | "type": "text", 76 | "primaryKey": true, 77 | "notNull": true 78 | }, 79 | "url": { 80 | "name": "url", 81 | "type": "text", 82 | "primaryKey": false, 83 | "notNull": true 84 | }, 85 | "channel_id": { 86 | "name": "channel_id", 87 | "type": "text", 88 | "primaryKey": false, 89 | "notNull": true 90 | }, 91 | "guild_id": { 92 | "name": "guild_id", 93 | "type": "text", 94 | "primaryKey": false, 95 | "notNull": true 96 | }, 97 | "nickname": { 98 | "name": "nickname", 99 | "type": "text", 100 | "primaryKey": false, 101 | "notNull": false 102 | }, 103 | "category": { 104 | "name": "category", 105 | "type": "text", 106 | "primaryKey": false, 107 | "notNull": false 108 | }, 109 | "added_by": { 110 | "name": "added_by", 111 | "type": "text", 112 | "primaryKey": false, 113 | "notNull": true 114 | }, 115 | "frequency_override_minutes": { 116 | "name": "frequency_override_minutes", 117 | "type": "integer", 118 | "primaryKey": false, 119 | "notNull": false 120 | }, 121 | "last_checked": { 122 | "name": "last_checked", 123 | "type": "timestamp", 124 | "primaryKey": false, 125 | "notNull": false 126 | }, 127 | "last_item_guid": { 128 | "name": "last_item_guid", 129 | "type": "text", 130 | "primaryKey": false, 131 | "notNull": false 132 | }, 133 | "consecutive_failures": { 134 | "name": "consecutive_failures", 135 | "type": "integer", 136 | "primaryKey": false, 137 | "notNull": true, 138 | "default": 0 139 | }, 140 | "created_at": { 141 | "name": "created_at", 142 | "type": "timestamp", 143 | "primaryKey": false, 144 | "notNull": true, 145 | "default": "now()" 146 | }, 147 | "summarize": { 148 | "name": "summarize", 149 | "type": "boolean", 150 | "primaryKey": false, 151 | "notNull": true, 152 | "default": false 153 | }, 154 | "last_summary": { 155 | "name": "last_summary", 156 | "type": "text", 157 | "primaryKey": false, 158 | "notNull": false 159 | } 160 | }, 161 | "indexes": {}, 162 | "foreignKeys": {}, 163 | "compositePrimaryKeys": {}, 164 | "uniqueConstraints": {} 165 | } 166 | }, 167 | "enums": {}, 168 | "schemas": {}, 169 | "_meta": { 170 | "columns": {}, 171 | "schemas": {}, 172 | "tables": {} 173 | } 174 | } -------------------------------------------------------------------------------- /tests/utils/format-utils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from 'vitest'; 2 | import { ApplicationCommand, Guild, Locale } from 'discord.js'; 3 | import { FormatUtils } from '../../src/utils/index.js'; 4 | 5 | // Mock any configs that might be loaded 6 | vi.mock('../../config/config.json', () => ({})); 7 | vi.mock('../../config/debug.json', () => ({})); 8 | vi.mock('../../lang/logs.json', () => ({})); 9 | 10 | // Mock the external dependencies 11 | vi.mock('filesize', () => ({ 12 | filesize: vi.fn().mockImplementation((bytes, options) => { 13 | if (bytes === 1024) return '1.00 KB'; 14 | if (bytes === 1048576) return '1.00 MB'; 15 | return `${bytes} B`; 16 | }), 17 | })); 18 | 19 | vi.mock('luxon', () => ({ 20 | Duration: { 21 | fromMillis: vi.fn().mockImplementation((ms, options) => ({ 22 | shiftTo: vi.fn().mockReturnValue({ 23 | toObject: vi.fn().mockReturnValue({ 24 | hours: ms === 3600000 ? 1 : 0, 25 | minutes: ms === 60000 ? 1 : 0, 26 | seconds: ms === 5000 ? 5 : 0, 27 | }), 28 | }), 29 | })), 30 | fromObject: vi.fn().mockImplementation(obj => ({ 31 | toHuman: vi.fn().mockImplementation(({ maximumFractionDigits }) => { 32 | if (obj.hours === 1) return '1 hour'; 33 | if (obj.minutes === 1) return '1 minute'; 34 | if (obj.seconds === 5) return '5 seconds'; 35 | return 'unknown duration'; 36 | }), 37 | })), 38 | }, 39 | })); 40 | 41 | describe('FormatUtils', () => { 42 | describe('roleMention', () => { 43 | it('should return @here for @here mentions', () => { 44 | const mockGuild = { id: '123456789012345678' } as Guild; 45 | const result = FormatUtils.roleMention(mockGuild, '@here'); 46 | expect(result).toBe('@here'); 47 | }); 48 | 49 | it('should return @everyone for guild id mentions', () => { 50 | const mockGuild = { id: '123456789012345678' } as Guild; 51 | const result = FormatUtils.roleMention(mockGuild, '123456789012345678'); 52 | expect(result).toBe('@everyone'); 53 | }); 54 | 55 | it('should format regular role mentions', () => { 56 | const mockGuild = { id: '123456789012345678' } as Guild; 57 | const result = FormatUtils.roleMention(mockGuild, '987654321098765432'); 58 | expect(result).toBe('<@&987654321098765432>'); 59 | }); 60 | }); 61 | 62 | describe('channelMention', () => { 63 | it('should format channel mentions', () => { 64 | const result = FormatUtils.channelMention('123456789012345678'); 65 | expect(result).toBe('<#123456789012345678>'); 66 | }); 67 | }); 68 | 69 | describe('userMention', () => { 70 | it('should format user mentions', () => { 71 | const result = FormatUtils.userMention('123456789012345678'); 72 | expect(result).toBe('<@!123456789012345678>'); 73 | }); 74 | }); 75 | 76 | describe('commandMention', () => { 77 | it('should format simple command mentions', () => { 78 | const mockCommand = { 79 | name: 'test', 80 | id: '123456789012345678', 81 | } as ApplicationCommand; 82 | 83 | const result = FormatUtils.commandMention(mockCommand); 84 | expect(result).toBe(''); 85 | }); 86 | 87 | it('should format command mentions with subcommands', () => { 88 | const mockCommand = { 89 | name: 'user', 90 | id: '123456789012345678', 91 | } as ApplicationCommand; 92 | 93 | const result = FormatUtils.commandMention(mockCommand, ['info']); 94 | expect(result).toBe(''); 95 | }); 96 | }); 97 | 98 | describe('duration', () => { 99 | it('should format hours correctly', () => { 100 | const result = FormatUtils.duration(3600000, Locale.EnglishUS); 101 | expect(result).toBe('1 hour'); 102 | }); 103 | 104 | it('should format minutes correctly', () => { 105 | const result = FormatUtils.duration(60000, Locale.EnglishUS); 106 | expect(result).toBe('1 minute'); 107 | }); 108 | 109 | it('should format seconds correctly', () => { 110 | const result = FormatUtils.duration(5000, Locale.EnglishUS); 111 | expect(result).toBe('5 seconds'); 112 | }); 113 | }); 114 | 115 | describe('fileSize', () => { 116 | it('should format bytes to KB correctly', () => { 117 | const result = FormatUtils.fileSize(1024); 118 | expect(result).toBe('1.00 KB'); 119 | }); 120 | 121 | it('should format bytes to MB correctly', () => { 122 | const result = FormatUtils.fileSize(1048576); 123 | expect(result).toBe('1.00 MB'); 124 | }); 125 | 126 | it('should handle small byte values', () => { 127 | const result = FormatUtils.fileSize(100); 128 | expect(result).toBe('100 B'); 129 | }); 130 | }); 131 | }); 132 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "start:bot", 6 | "type": "node", 7 | "request": "launch", 8 | "protocol": "inspector", 9 | "preLaunchTask": "build", 10 | "cwd": "${workspaceFolder}", 11 | "runtimeExecutable": "node", 12 | "args": ["--enable-source-maps", "${workspaceFolder}/dist/start-bot.js"], 13 | "resolveSourceMapLocations": ["${workspaceFolder}/**", "!**/node_modules/**"], 14 | "outputCapture": "std", 15 | "internalConsoleOptions": "openOnSessionStart", 16 | "skipFiles": ["/**"], 17 | "restart": false 18 | }, 19 | { 20 | "name": "start:manager", 21 | "type": "node", 22 | "request": "launch", 23 | "protocol": "inspector", 24 | "preLaunchTask": "build", 25 | "cwd": "${workspaceFolder}", 26 | "runtimeExecutable": "node", 27 | "args": ["--enable-source-maps", "${workspaceFolder}/dist/start-manager.js"], 28 | "resolveSourceMapLocations": ["${workspaceFolder}/**", "!**/node_modules/**"], 29 | "outputCapture": "std", 30 | "internalConsoleOptions": "openOnSessionStart", 31 | "skipFiles": ["/**"], 32 | "restart": false 33 | }, 34 | { 35 | "name": "commands:view", 36 | "type": "node", 37 | "request": "launch", 38 | "protocol": "inspector", 39 | "preLaunchTask": "build", 40 | "cwd": "${workspaceFolder}", 41 | "runtimeExecutable": "node", 42 | "args": [ 43 | "--enable-source-maps", 44 | "${workspaceFolder}/dist/start-bot.js", 45 | "commands", 46 | "view" 47 | ], 48 | "resolveSourceMapLocations": ["${workspaceFolder}/**", "!**/node_modules/**"], 49 | "outputCapture": "std", 50 | "internalConsoleOptions": "openOnSessionStart", 51 | "skipFiles": ["/**"], 52 | "restart": false 53 | }, 54 | { 55 | "name": "commands:register", 56 | "type": "node", 57 | "request": "launch", 58 | "protocol": "inspector", 59 | "preLaunchTask": "build", 60 | "cwd": "${workspaceFolder}", 61 | "runtimeExecutable": "node", 62 | "args": [ 63 | "--enable-source-maps", 64 | "${workspaceFolder}/dist/start-bot.js", 65 | "commands", 66 | "register" 67 | ], 68 | "resolveSourceMapLocations": ["${workspaceFolder}/**", "!**/node_modules/**"], 69 | "outputCapture": "std", 70 | "internalConsoleOptions": "openOnSessionStart", 71 | "skipFiles": ["/**"], 72 | "restart": false 73 | }, 74 | { 75 | "name": "commands:rename", 76 | "type": "node", 77 | "request": "launch", 78 | "protocol": "inspector", 79 | "preLaunchTask": "build", 80 | "cwd": "${workspaceFolder}", 81 | "runtimeExecutable": "node", 82 | "args": [ 83 | "--enable-source-maps", 84 | "${workspaceFolder}/dist/start-bot.js", 85 | "commands", 86 | "rename", 87 | "old_name", 88 | "new_name" 89 | ], 90 | "resolveSourceMapLocations": ["${workspaceFolder}/**", "!**/node_modules/**"], 91 | "outputCapture": "std", 92 | "internalConsoleOptions": "openOnSessionStart", 93 | "skipFiles": ["/**"], 94 | "restart": false 95 | }, 96 | { 97 | "name": "commands:delete", 98 | "type": "node", 99 | "request": "launch", 100 | "protocol": "inspector", 101 | "preLaunchTask": "build", 102 | "cwd": "${workspaceFolder}", 103 | "runtimeExecutable": "node", 104 | "args": [ 105 | "--enable-source-maps", 106 | "${workspaceFolder}/dist/start-bot.js", 107 | "commands", 108 | "delete", 109 | "command_name" 110 | ], 111 | "resolveSourceMapLocations": ["${workspaceFolder}/**", "!**/node_modules/**"], 112 | "outputCapture": "std", 113 | "internalConsoleOptions": "openOnSessionStart", 114 | "skipFiles": ["/**"], 115 | "restart": false 116 | }, 117 | { 118 | "name": "commands:clear", 119 | "type": "node", 120 | "request": "launch", 121 | "protocol": "inspector", 122 | "preLaunchTask": "build", 123 | "cwd": "${workspaceFolder}", 124 | "runtimeExecutable": "node", 125 | "args": [ 126 | "--enable-source-maps", 127 | "${workspaceFolder}/dist/start-bot.js", 128 | "commands", 129 | "clear" 130 | ], 131 | "resolveSourceMapLocations": ["${workspaceFolder}/**", "!**/node_modules/**"], 132 | "outputCapture": "std", 133 | "internalConsoleOptions": "openOnSessionStart", 134 | "skipFiles": ["/**"], 135 | "restart": false 136 | } 137 | ] 138 | } 139 | -------------------------------------------------------------------------------- /src/events/guild-join-handler.ts: -------------------------------------------------------------------------------- 1 | import { EmbedBuilder, Guild, TextChannel } from 'discord.js'; 2 | import { createRequire } from 'node:module'; 3 | 4 | import { ChatCommandMetadata } from '../commands/metadata.js'; 5 | import { EventHandler } from './index.js'; 6 | import { EventDataService, Logger } from '../services/index.js'; 7 | import { ClientUtils, FormatUtils, MessageUtils } from '../utils/index.js'; 8 | import { posthog } from '../utils/analytics.js'; 9 | 10 | const require = createRequire(import.meta.url); 11 | let Logs = require('../../lang/logs.json'); 12 | 13 | export class GuildJoinHandler implements EventHandler { 14 | constructor(private eventDataService: EventDataService) {} 15 | 16 | public async process(guild: Guild): Promise { 17 | Logger.info( 18 | Logs.info.guildJoined 19 | .replaceAll('{GUILD_NAME}', guild.name) 20 | .replaceAll('{GUILD_ID}', guild.id) 21 | ); 22 | 23 | // --- PostHog Tracking --- START 24 | if (posthog) { 25 | posthog.capture({ 26 | distinctId: guild.ownerId, 27 | event: 'guild_joined', 28 | properties: { 29 | guildId: guild.id, 30 | guildName: guild.name, 31 | guildMemberCount: guild.memberCount, 32 | guildOwnerId: guild.ownerId, 33 | shardId: guild.shardId, 34 | }, 35 | groups: { guild: guild.id }, 36 | }); 37 | posthog.groupIdentify({ 38 | groupType: 'guild', 39 | groupKey: guild.id, 40 | properties: { 41 | name: guild.name, 42 | member_count: guild.memberCount, 43 | joined_at: new Date().toISOString(), 44 | }, 45 | }); 46 | } 47 | // --- PostHog Tracking --- END 48 | 49 | let owner = await guild.fetchOwner(); 50 | 51 | // Get data from database 52 | let data = await this.eventDataService.create({ 53 | user: owner?.user, 54 | guild, 55 | }); 56 | 57 | // Build welcome embed 58 | const feedCmd = await ClientUtils.findAppCommand(guild.client, ChatCommandMetadata.FEED.name); 59 | const feedMention = feedCmd 60 | ? FormatUtils.commandMention(feedCmd) 61 | : `\`/${ChatCommandMetadata.FEED.name}\``; 62 | 63 | const youtubeCmd = await ClientUtils.findAppCommand( 64 | guild.client, 65 | ChatCommandMetadata.YOUTUBE.name 66 | ); 67 | const youtubeMention = youtubeCmd 68 | ? FormatUtils.commandMention(youtubeCmd) 69 | : `\`/${ChatCommandMetadata.YOUTUBE.name}\``; 70 | 71 | const helpCmd = await ClientUtils.findAppCommand( 72 | guild.client, 73 | ChatCommandMetadata.HELP.name 74 | ); 75 | const helpMention = helpCmd 76 | ? FormatUtils.commandMention(helpCmd) 77 | : `\`/${ChatCommandMetadata.HELP.name}\``; 78 | 79 | const welcomeEmbed = new EmbedBuilder() 80 | .setTitle('👋 Welcome to Discorss!') 81 | .setDescription( 82 | `Thanks for adding me to **${guild.name}**! I'm here to help you stay updated by automatically bringing RSS feed and YouTube channel updates directly into your Discord server.` 83 | ) 84 | .setColor('Aqua') 85 | .addFields( 86 | { 87 | name: '🚀 Quick Start', 88 | value: `**Add an RSS feed:** 89 | ${feedMention} \`add\` \`url:https://example.com/feed.xml\` 90 | 91 | **Add a YouTube channel:** 92 | ${youtubeMention} \`add\` \`channel_id:UC...\` 93 | 94 | **List your feeds:** 95 | ${feedMention} \`list\` 96 | 97 | **Get help:** 98 | ${helpMention}`, 99 | inline: false, 100 | }, 101 | { 102 | name: '✨ Key Features', 103 | value: `• **RSS Feed Monitoring** - Track any RSS or Atom feed 104 | • **YouTube Integration** - Follow YouTube channels automatically 105 | • **AI Summaries** - Get AI-powered summaries of articles (optional) 106 | • **Categories** - Organize feeds with custom categories 107 | • **Custom Frequencies** - Control how often feeds are checked`, 108 | inline: false, 109 | }, 110 | { 111 | name: '📚 Need Help?', 112 | value: `Use ${helpMention} to see all available commands and get detailed information about how to use them.`, 113 | inline: false, 114 | } 115 | ) 116 | .setTimestamp() 117 | .setFooter({ 118 | text: 'Happy feed monitoring! 🎉', 119 | }); 120 | 121 | // Send welcome message to the server's notify channel 122 | let notifyChannel = await ClientUtils.findNotifyChannel(guild, data.langGuild); 123 | if (notifyChannel) { 124 | try { 125 | await MessageUtils.send(notifyChannel, welcomeEmbed); 126 | } catch (error) { 127 | Logger.error(Logs.error.messageSend, error); 128 | } 129 | } 130 | 131 | // Send welcome message to owner 132 | if (owner) { 133 | try { 134 | await MessageUtils.send(owner.user, welcomeEmbed); 135 | } catch (error) { 136 | // Ignore DMs not sending 137 | } 138 | } 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/models/enum-helpers/language.ts: -------------------------------------------------------------------------------- 1 | import { Locale } from 'discord.js'; 2 | 3 | interface LanguageData { 4 | englishName: string; 5 | nativeName: string; 6 | } 7 | 8 | export class Language { 9 | public static Default = Locale.EnglishUS; 10 | public static Enabled: Locale[] = [Locale.EnglishUS, Locale.EnglishGB]; 11 | 12 | // See https://discord.com/developers/docs/reference#locales 13 | public static Data: { 14 | [key in Locale]: LanguageData; 15 | } = { 16 | bg: { englishName: 'Bulgarian', nativeName: 'български' }, 17 | cs: { englishName: 'Czech', nativeName: 'Čeština' }, 18 | da: { englishName: 'Danish', nativeName: 'Dansk' }, 19 | de: { englishName: 'German', nativeName: 'Deutsch' }, 20 | el: { englishName: 'Greek', nativeName: 'Ελληνικά' }, 21 | 'en-GB': { englishName: 'English, UK', nativeName: 'English, UK' }, 22 | 'en-US': { englishName: 'English, US', nativeName: 'English, US' }, 23 | 'es-419': { englishName: 'Spanish, LATAM', nativeName: 'Español, LATAM' }, 24 | 'es-ES': { englishName: 'Spanish', nativeName: 'Español' }, 25 | fi: { englishName: 'Finnish', nativeName: 'Suomi' }, 26 | fr: { englishName: 'French', nativeName: 'Français' }, 27 | hi: { englishName: 'Hindi', nativeName: 'हिन्दी' }, 28 | hr: { englishName: 'Croatian', nativeName: 'Hrvatski' }, 29 | hu: { englishName: 'Hungarian', nativeName: 'Magyar' }, 30 | id: { englishName: 'Indonesian', nativeName: 'Bahasa Indonesia' }, 31 | it: { englishName: 'Italian', nativeName: 'Italiano' }, 32 | ja: { englishName: 'Japanese', nativeName: '日本語' }, 33 | ko: { englishName: 'Korean', nativeName: '한국어' }, 34 | lt: { englishName: 'Lithuanian', nativeName: 'Lietuviškai' }, 35 | nl: { englishName: 'Dutch', nativeName: 'Nederlands' }, 36 | no: { englishName: 'Norwegian', nativeName: 'Norsk' }, 37 | pl: { englishName: 'Polish', nativeName: 'Polski' }, 38 | 'pt-BR': { englishName: 'Portuguese, Brazilian', nativeName: 'Português do Brasil' }, 39 | ro: { englishName: 'Romanian, Romania', nativeName: 'Română' }, 40 | ru: { englishName: 'Russian', nativeName: 'Pусский' }, 41 | 'sv-SE': { englishName: 'Swedish', nativeName: 'Svenska' }, 42 | th: { englishName: 'Thai', nativeName: 'ไทย' }, 43 | tr: { englishName: 'Turkish', nativeName: 'Türkçe' }, 44 | uk: { englishName: 'Ukrainian', nativeName: 'Українська' }, 45 | vi: { englishName: 'Vietnamese', nativeName: 'Tiếng Việt' }, 46 | 'zh-CN': { englishName: 'Chinese, China', nativeName: '中文' }, 47 | 'zh-TW': { englishName: 'Chinese, Taiwan', nativeName: '繁體中文' }, 48 | }; 49 | 50 | public static find(input: string, enabled: boolean): Locale { 51 | return this.findMultiple(input, enabled, 1)[0]; 52 | } 53 | 54 | public static findMultiple( 55 | input: string, 56 | enabled: boolean, 57 | limit: number = Number.MAX_VALUE 58 | ): Locale[] { 59 | let langCodes = enabled ? this.Enabled : Object.values(Locale).sort(); 60 | let search = input.toLowerCase(); 61 | let found = new Set(); 62 | // Exact match 63 | if (found.size < limit) 64 | langCodes 65 | .filter(langCode => langCode.toLowerCase() === search) 66 | .forEach(langCode => found.add(langCode)); 67 | if (found.size < limit) 68 | langCodes 69 | .filter(langCode => this.Data[langCode].nativeName.toLowerCase() === search) 70 | .forEach(langCode => found.add(langCode)); 71 | if (found.size < limit) 72 | langCodes 73 | .filter(langCode => this.Data[langCode].nativeName.toLowerCase() === search) 74 | .forEach(langCode => found.add(langCode)); 75 | if (found.size < limit) 76 | langCodes 77 | .filter(langCode => this.Data[langCode].englishName.toLowerCase() === search) 78 | .forEach(langCode => found.add(langCode)); 79 | // Starts with search term 80 | if (found.size < limit) 81 | langCodes 82 | .filter(langCode => langCode.toLowerCase().startsWith(search)) 83 | .forEach(langCode => found.add(langCode)); 84 | if (found.size < limit) 85 | langCodes 86 | .filter(langCode => this.Data[langCode].nativeName.toLowerCase().startsWith(search)) 87 | .forEach(langCode => found.add(langCode)); 88 | if (found.size < limit) 89 | langCodes 90 | .filter(langCode => 91 | this.Data[langCode].englishName.toLowerCase().startsWith(search) 92 | ) 93 | .forEach(langCode => found.add(langCode)); 94 | // Includes search term 95 | if (found.size < limit) 96 | langCodes 97 | .filter(langCode => langCode.toLowerCase().startsWith(search)) 98 | .forEach(langCode => found.add(langCode)); 99 | if (found.size < limit) 100 | langCodes 101 | .filter(langCode => this.Data[langCode].nativeName.toLowerCase().startsWith(search)) 102 | .forEach(langCode => found.add(langCode)); 103 | if (found.size < limit) 104 | langCodes 105 | .filter(langCode => 106 | this.Data[langCode].englishName.toLowerCase().startsWith(search) 107 | ) 108 | .forEach(langCode => found.add(langCode)); 109 | return [...found]; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/utils/message-utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseMessageOptions, 3 | DiscordAPIError, 4 | RESTJSONErrorCodes as DiscordApiErrors, 5 | EmbedBuilder, 6 | EmojiResolvable, 7 | Message, 8 | MessageEditOptions, 9 | MessageReaction, 10 | PartialGroupDMChannel, 11 | StartThreadOptions, 12 | TextBasedChannel, 13 | ThreadChannel, 14 | User, 15 | } from 'discord.js'; 16 | 17 | const IGNORED_ERRORS = [ 18 | DiscordApiErrors.UnknownMessage, 19 | DiscordApiErrors.UnknownChannel, 20 | DiscordApiErrors.UnknownGuild, 21 | DiscordApiErrors.UnknownUser, 22 | DiscordApiErrors.UnknownInteraction, 23 | DiscordApiErrors.CannotSendMessagesToThisUser, // User blocked bot or DM disabled 24 | DiscordApiErrors.ReactionWasBlocked, // User blocked bot or DM disabled 25 | DiscordApiErrors.MaximumActiveThreads, 26 | ]; 27 | 28 | export class MessageUtils { 29 | public static async send( 30 | target: User | TextBasedChannel, 31 | content: string | EmbedBuilder | BaseMessageOptions 32 | ): Promise { 33 | if (target instanceof PartialGroupDMChannel) return; 34 | try { 35 | let options: BaseMessageOptions = 36 | typeof content === 'string' 37 | ? { content } 38 | : content instanceof EmbedBuilder 39 | ? { embeds: [content] } 40 | : content; 41 | return await target.send(options); 42 | } catch (error) { 43 | if ( 44 | error instanceof DiscordAPIError && 45 | typeof error.code == 'number' && 46 | IGNORED_ERRORS.includes(error.code) 47 | ) { 48 | return; 49 | } else { 50 | throw error; 51 | } 52 | } 53 | } 54 | 55 | public static async reply( 56 | msg: Message, 57 | content: string | EmbedBuilder | BaseMessageOptions 58 | ): Promise { 59 | try { 60 | let options: BaseMessageOptions = 61 | typeof content === 'string' 62 | ? { content } 63 | : content instanceof EmbedBuilder 64 | ? { embeds: [content] } 65 | : content; 66 | return await msg.reply(options); 67 | } catch (error) { 68 | if ( 69 | error instanceof DiscordAPIError && 70 | typeof error.code == 'number' && 71 | IGNORED_ERRORS.includes(error.code) 72 | ) { 73 | return; 74 | } else { 75 | throw error; 76 | } 77 | } 78 | } 79 | 80 | public static async edit( 81 | msg: Message, 82 | content: string | EmbedBuilder | MessageEditOptions 83 | ): Promise { 84 | try { 85 | let options: MessageEditOptions = 86 | typeof content === 'string' 87 | ? { content } 88 | : content instanceof EmbedBuilder 89 | ? { embeds: [content] } 90 | : content; 91 | return await msg.edit(options); 92 | } catch (error) { 93 | if ( 94 | error instanceof DiscordAPIError && 95 | typeof error.code == 'number' && 96 | IGNORED_ERRORS.includes(error.code) 97 | ) { 98 | return; 99 | } else { 100 | throw error; 101 | } 102 | } 103 | } 104 | 105 | public static async react(msg: Message, emoji: EmojiResolvable): Promise { 106 | try { 107 | return await msg.react(emoji); 108 | } catch (error) { 109 | if ( 110 | error instanceof DiscordAPIError && 111 | typeof error.code == 'number' && 112 | IGNORED_ERRORS.includes(error.code) 113 | ) { 114 | return; 115 | } else { 116 | throw error; 117 | } 118 | } 119 | } 120 | 121 | public static async pin(msg: Message, pinned: boolean = true): Promise { 122 | try { 123 | return pinned ? await msg.pin() : await msg.unpin(); 124 | } catch (error) { 125 | if ( 126 | error instanceof DiscordAPIError && 127 | typeof error.code == 'number' && 128 | IGNORED_ERRORS.includes(error.code) 129 | ) { 130 | return; 131 | } else { 132 | throw error; 133 | } 134 | } 135 | } 136 | 137 | public static async startThread( 138 | msg: Message, 139 | options: StartThreadOptions 140 | ): Promise { 141 | try { 142 | return await msg.startThread(options); 143 | } catch (error) { 144 | if ( 145 | error instanceof DiscordAPIError && 146 | typeof error.code == 'number' && 147 | IGNORED_ERRORS.includes(error.code) 148 | ) { 149 | return; 150 | } else { 151 | throw error; 152 | } 153 | } 154 | } 155 | 156 | public static async delete(msg: Message): Promise { 157 | try { 158 | return await msg.delete(); 159 | } catch (error) { 160 | if ( 161 | error instanceof DiscordAPIError && 162 | typeof error.code == 'number' && 163 | IGNORED_ERRORS.includes(error.code) 164 | ) { 165 | return; 166 | } else { 167 | throw error; 168 | } 169 | } 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/db/schema.ts: -------------------------------------------------------------------------------- 1 | import { relations } from 'drizzle-orm'; 2 | import { 3 | boolean, 4 | integer, 5 | pgTable, 6 | serial, 7 | text, 8 | timestamp, 9 | uniqueIndex, 10 | uuid, 11 | } from 'drizzle-orm/pg-core'; 12 | import { v4 as uuidv4 } from 'uuid'; 13 | 14 | // Table for storing feed configurations 15 | 16 | export const feeds = pgTable('feeds', { 17 | id: uuid('id') 18 | .primaryKey() 19 | .$defaultFn(() => uuidv4()), // Generate UUID automatically 20 | url: text('url').notNull(), 21 | channelId: text('channel_id').notNull(), 22 | guildId: text('guild_id').notNull(), 23 | nickname: text('nickname'), 24 | category: text('category'), 25 | addedBy: text('added_by').notNull(), 26 | frequencyOverrideMinutes: integer('frequency_override_minutes'), 27 | // Change integer timestamp to native pg timestamp 28 | // lastChecked: integer('last_checked', { mode: 'timestamp' }), // Store as Unix timestamp (integer) 29 | lastChecked: timestamp('last_checked', { mode: 'date' }), 30 | lastItemGuid: text('last_item_guid'), // GUID of the last successfully sent item 31 | consecutiveFailures: integer('consecutive_failures').notNull().default(0), 32 | // Change integer timestamp to native pg timestamp 33 | // createdAt: integer('created_at', { mode: 'timestamp' }) 34 | // .notNull() 35 | // .$defaultFn(() => new Date()), // Set creation timestamp automatically 36 | createdAt: timestamp('created_at', { mode: 'date' }).notNull().defaultNow(), 37 | summarize: boolean('summarize').notNull().default(false), // AI summarization enabled 38 | useArchiveLinks: boolean('use_archive_links').notNull().default(false), // Enable archive.is links for paywalled content 39 | lastArticleSummary: text('last_article_summary'), // Last article summary (nullable) 40 | lastCommentsSummary: text('last_comments_summary'), // Last comments summary (nullable) 41 | // Add the new column for recent links (store as JSON string) 42 | recentLinks: text('recent_links'), 43 | lastFailureNotificationAt: timestamp('last_failure_notification_at', { mode: 'date' }), 44 | lastErrorMessageAt: timestamp('last_error_message_at', { mode: 'date' }), 45 | backoffUntil: timestamp('backoff_until', { mode: 'date' }), 46 | ignoreErrors: boolean('ignore_errors').notNull().default(false), // Skip error notifications for this feed 47 | disableFailureNotifications: boolean('disable_failure_notifications').notNull().default(false), // Skip failure threshold notifications 48 | disabled: boolean('disabled').notNull().default(false), // Completely disable feed polling (auto-set for dead feeds) 49 | language: text('language'), // Language code for summaries (e.g., 'en', 'es', 'fr', 'de', etc.) - overrides guild language 50 | }); 51 | 52 | // Table for storing individual feed failure events (for rolling 24hr checks) 53 | export const feedFailures = pgTable('feed_failures', { 54 | id: serial('id').primaryKey(), // Auto-incrementing primary key 55 | feedId: uuid('feed_id') 56 | .notNull() 57 | .references(() => feeds.id, { onDelete: 'cascade' }), // Foreign key to feeds table 58 | timestamp: timestamp('timestamp', { mode: 'date' }).notNull().defaultNow(), 59 | errorMessage: text('error_message'), // Optional: Store the error message 60 | }); 61 | 62 | // Table for storing category configurations 63 | export const categories = pgTable( 64 | 'categories', 65 | { 66 | // Add a serial primary key for easier relations, keep guildId/nameLower for uniqueness 67 | id: serial('id').primaryKey(), 68 | guildId: text('guild_id').notNull(), 69 | name: text('name').notNull(), 70 | nameLower: text('name_lower').notNull(), // Keep for case-insensitive lookups/constraints 71 | frequencyMinutes: integer('frequency_minutes').notNull(), 72 | }, 73 | table => ({ 74 | // Composite primary key on guildId and nameLower to ensure unique category names per guild (case-insensitive) 75 | // pk: primaryKey({ columns: [table.guildId, table.nameLower] }), 76 | // Use a unique index for PostgreSQL instead of composite PK for upsert logic 77 | guildNameLowerUnique: uniqueIndex('categories_guild_name_lower_idx').on( 78 | table.guildId, 79 | table.nameLower 80 | ), 81 | }) 82 | ); 83 | 84 | // Define relations using the new primary keys 85 | export const feedRelations = relations(feeds, ({ one }) => ({ 86 | categoryRelation: one(categories, { 87 | fields: [feeds.category], // Assuming feeds.category links to categories.name 88 | references: [categories.name], // Link to categories table name (need to ensure uniqueness) 89 | // Alternatively, add a categoryId to feeds and link to categories.id 90 | }), 91 | })); 92 | 93 | // Example relation for categories (a category can have many feeds) 94 | export const categoryRelations = relations(categories, ({ many }) => ({ 95 | feeds: many(feeds), 96 | })); 97 | 98 | // Define relationships (optional but good practice) 99 | export const feedsRelations = relations(feeds, ({ many }) => ({ 100 | failures: many(feedFailures), 101 | })); 102 | 103 | export const feedFailuresRelations = relations(feedFailures, ({ one }) => ({ 104 | feed: one(feeds, { 105 | fields: [feedFailures.feedId], 106 | references: [feeds.id], 107 | }), 108 | })); 109 | 110 | // Table for storing guild-level settings 111 | export const guilds = pgTable('guilds', { 112 | guildId: text('guild_id').primaryKey(), 113 | language: text('language'), // Language code (e.g., 'en', 'es', 'fr', 'de', etc.) 114 | createdAt: timestamp('created_at', { mode: 'date' }).notNull().defaultNow(), 115 | updatedAt: timestamp('updated_at', { mode: 'date' }).notNull().defaultNow(), 116 | }); 117 | -------------------------------------------------------------------------------- /src/utils/permission-utils.ts: -------------------------------------------------------------------------------- 1 | import { Channel, DMChannel, GuildChannel, PermissionFlagsBits, ThreadChannel } from 'discord.js'; 2 | 3 | export class PermissionUtils { 4 | public static canSend(channel: Channel, embedLinks: boolean = false): boolean { 5 | if (channel instanceof DMChannel) { 6 | return true; 7 | } else if (channel instanceof GuildChannel || channel instanceof ThreadChannel) { 8 | let channelPerms = channel.permissionsFor(channel.client.user); 9 | if (!channelPerms) { 10 | // This can happen if the guild disconnected while a collector is running 11 | return false; 12 | } 13 | 14 | // VIEW_CHANNEL - Needed to view the channel 15 | // SEND_MESSAGES - Needed to send messages 16 | // EMBED_LINKS - Needed to send embedded links 17 | return channelPerms.has([ 18 | PermissionFlagsBits.ViewChannel, 19 | PermissionFlagsBits.SendMessages, 20 | ...(embedLinks ? [PermissionFlagsBits.EmbedLinks] : []), 21 | ]); 22 | } else { 23 | return false; 24 | } 25 | } 26 | 27 | public static canMention(channel: Channel): boolean { 28 | if (channel instanceof DMChannel) { 29 | return true; 30 | } else if (channel instanceof GuildChannel || channel instanceof ThreadChannel) { 31 | let channelPerms = channel.permissionsFor(channel.client.user); 32 | if (!channelPerms) { 33 | // This can happen if the guild disconnected while a collector is running 34 | return false; 35 | } 36 | 37 | // VIEW_CHANNEL - Needed to view the channel 38 | // MENTION_EVERYONE - Needed to mention @everyone, @here, and all roles 39 | return channelPerms.has([ 40 | PermissionFlagsBits.ViewChannel, 41 | PermissionFlagsBits.MentionEveryone, 42 | ]); 43 | } else { 44 | return false; 45 | } 46 | } 47 | 48 | public static canReact(channel: Channel, removeOthers: boolean = false): boolean { 49 | if (channel instanceof DMChannel) { 50 | return true; 51 | } else if (channel instanceof GuildChannel || channel instanceof ThreadChannel) { 52 | let channelPerms = channel.permissionsFor(channel.client.user); 53 | if (!channelPerms) { 54 | // This can happen if the guild disconnected while a collector is running 55 | return false; 56 | } 57 | 58 | // VIEW_CHANNEL - Needed to view the channel 59 | // ADD_REACTIONS - Needed to add new reactions to messages 60 | // READ_MESSAGE_HISTORY - Needed to add new reactions to messages 61 | // https://discordjs.guide/popular-topics/permissions-extended.html#implicit-permissions 62 | // MANAGE_MESSAGES - Needed to remove others reactions 63 | return channelPerms.has([ 64 | PermissionFlagsBits.ViewChannel, 65 | PermissionFlagsBits.AddReactions, 66 | PermissionFlagsBits.ReadMessageHistory, 67 | ...(removeOthers ? [PermissionFlagsBits.ManageMessages] : []), 68 | ]); 69 | } else { 70 | return false; 71 | } 72 | } 73 | 74 | public static canPin(channel: Channel, findOld: boolean = false): boolean { 75 | if (channel instanceof DMChannel) { 76 | return true; 77 | } else if (channel instanceof GuildChannel || channel instanceof ThreadChannel) { 78 | let channelPerms = channel.permissionsFor(channel.client.user); 79 | if (!channelPerms) { 80 | // This can happen if the guild disconnected while a collector is running 81 | return false; 82 | } 83 | 84 | // VIEW_CHANNEL - Needed to view the channel 85 | // MANAGE_MESSAGES - Needed to pin messages 86 | // READ_MESSAGE_HISTORY - Needed to find old pins 87 | return channelPerms.has([ 88 | PermissionFlagsBits.ViewChannel, 89 | PermissionFlagsBits.ManageMessages, 90 | ...(findOld ? [PermissionFlagsBits.ReadMessageHistory] : []), 91 | ]); 92 | } else { 93 | return false; 94 | } 95 | } 96 | 97 | public static canCreateThreads( 98 | channel: Channel, 99 | manageThreads: boolean = false, 100 | findOld: boolean = false 101 | ): boolean { 102 | if (channel instanceof DMChannel) { 103 | return false; 104 | } else if (channel instanceof GuildChannel || channel instanceof ThreadChannel) { 105 | let channelPerms = channel.permissionsFor(channel.client.user); 106 | if (!channelPerms) { 107 | // This can happen if the guild disconnected while a collector is running 108 | return false; 109 | } 110 | 111 | // VIEW_CHANNEL - Needed to view the channel 112 | // SEND_MESSAGES_IN_THREADS - Needed to send messages in threads 113 | // CREATE_PUBLIC_THREADS - Needed to create public threads 114 | // MANAGE_THREADS - Needed to rename, delete, archive, unarchive, slow mode threads 115 | // READ_MESSAGE_HISTORY - Needed to find old threads 116 | return channelPerms.has([ 117 | PermissionFlagsBits.ViewChannel, 118 | PermissionFlagsBits.SendMessagesInThreads, 119 | PermissionFlagsBits.CreatePublicThreads, 120 | ...(manageThreads ? [PermissionFlagsBits.ManageThreads] : []), 121 | ...(findOld ? [PermissionFlagsBits.ReadMessageHistory] : []), 122 | ]); 123 | } else { 124 | return false; 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/start-manager.ts: -------------------------------------------------------------------------------- 1 | import { ShardingManager } from 'discord.js'; 2 | import { createRequire } from 'node:module'; 3 | import 'reflect-metadata'; 4 | 5 | import { GuildsController, ShardsController, StatsController } from './controllers/index.js'; 6 | import { FeedPollJob, Job, UpdateServerCountJob } from './jobs/index.js'; 7 | import { Api } from './models/api.js'; 8 | import { Manager } from './models/manager.js'; 9 | import { HttpService, JobService, Logger, MasterApiService } from './services/index.js'; 10 | import { JobRegistry } from './services/job-registry.js'; 11 | import { shutdownPostHog } from './utils/analytics.js'; 12 | import { env } from './utils/env.js'; 13 | import { MathUtils, ShardUtils } from './utils/index.js'; 14 | 15 | const require = createRequire(import.meta.url); 16 | let Config = require('../config/config.json'); 17 | let Debug = require('../config/debug.json'); 18 | let Logs = require('../lang/logs.json'); 19 | 20 | // Load sensitive values from env 21 | Config.client.id = env.DISCORD_CLIENT_ID; 22 | Config.client.token = env.DISCORD_BOT_TOKEN; 23 | Config.developers = env.DEVELOPER_IDS.split(','); 24 | 25 | async function start(): Promise { 26 | Logger.info(Logs.info.appStarted); 27 | 28 | // Dependencies 29 | let httpService = new HttpService(); 30 | let masterApiService = new MasterApiService(httpService); 31 | if (Config.clustering.enabled) { 32 | await masterApiService.register(); 33 | } 34 | 35 | // Sharding 36 | let shardList: number[]; 37 | let totalShards: number; 38 | try { 39 | if (Config.clustering.enabled) { 40 | let resBody = await masterApiService.login(); 41 | shardList = resBody.shardList; 42 | let requiredShards = await ShardUtils.requiredShardCount(Config.client.token); 43 | totalShards = Math.max(requiredShards, resBody.totalShards); 44 | } else { 45 | let recommendedShards = await ShardUtils.recommendedShardCount( 46 | Config.client.token, 47 | Config.sharding.serversPerShard 48 | ); 49 | shardList = MathUtils.range(0, recommendedShards); 50 | totalShards = recommendedShards; 51 | } 52 | } catch (error) { 53 | Logger.error(Logs.error.retrieveShards, error); 54 | return; 55 | } 56 | 57 | if (shardList.length === 0) { 58 | Logger.warn(Logs.warn.managerNoShards); 59 | return; 60 | } 61 | 62 | let shardManager = new ShardingManager('dist/start-bot.js', { 63 | token: Config.client.token, 64 | mode: Debug.override.shardMode.enabled ? Debug.override.shardMode.value : 'process', 65 | respawn: true, 66 | totalShards, 67 | shardList, 68 | }); 69 | 70 | // Jobs 71 | let feedPollJob = new FeedPollJob(shardManager); 72 | let jobs: Job[] = [ 73 | Config.clustering.enabled ? undefined : new UpdateServerCountJob(shardManager, httpService), 74 | feedPollJob, 75 | ].filter(Boolean); 76 | 77 | // Register the FeedPollJob in the global registry for access from commands 78 | JobRegistry.getInstance().setFeedPollJob(feedPollJob); 79 | 80 | let manager = new Manager(shardManager, new JobService(jobs)); 81 | 82 | // API 83 | let guildsController = new GuildsController(shardManager); 84 | let shardsController = new ShardsController(shardManager); 85 | let statsController = new StatsController(shardManager); 86 | let api = new Api([guildsController, shardsController, statsController]); 87 | 88 | // Start 89 | await manager.start(); 90 | await api.start(); 91 | if (Config.clustering.enabled) { 92 | await masterApiService.ready(); 93 | } 94 | 95 | // Store instances for graceful shutdown 96 | let managerInstance = manager; 97 | let apiInstance = api; 98 | let masterApiServiceInstance = masterApiService; 99 | 100 | // Graceful shutdown handlers 101 | const shutdown = async (signal: string) => { 102 | Logger.info(`[StartManager] Received ${signal}, starting graceful shutdown...`); 103 | try { 104 | if (Config.clustering.enabled && masterApiServiceInstance) { 105 | try { 106 | await masterApiServiceInstance.unregister(); 107 | } catch (error) { 108 | Logger.error('[StartManager] Error unregistering from master API:', error); 109 | } 110 | } 111 | 112 | if (apiInstance) { 113 | try { 114 | await apiInstance.stop(); 115 | } catch (error) { 116 | Logger.error('[StartManager] Error stopping API:', error); 117 | } 118 | } 119 | 120 | if (managerInstance) { 121 | await managerInstance.stop(); 122 | } 123 | 124 | // Shutdown PostHog analytics 125 | await shutdownPostHog(); 126 | 127 | // Close database connection 128 | const { closeDb } = await import('./db/index.js'); 129 | await closeDb(); 130 | 131 | // Reset RSS parser 132 | const { resetRSSParser } = await import('./utils/rss-parser.js'); 133 | resetRSSParser(); 134 | 135 | await new Promise(resolve => setTimeout(resolve, 1000)); 136 | Logger.info('[StartManager] Graceful shutdown complete.'); 137 | process.exit(0); 138 | } catch (error) { 139 | Logger.error('[StartManager] Error during shutdown:', error); 140 | process.exit(1); 141 | } 142 | }; 143 | 144 | process.on('SIGINT', () => shutdown('SIGINT')); 145 | process.on('SIGTERM', () => shutdown('SIGTERM')); 146 | } 147 | 148 | process.on('unhandledRejection', (reason, _promise) => { 149 | Logger.error(Logs.error.unhandledRejection, reason); 150 | }); 151 | 152 | start().catch(error => { 153 | Logger.error(Logs.error.unspecified, error); 154 | }); 155 | --------------------------------------------------------------------------------