From 9655615e4d454b918c589bab016c50f9278eeb2b Mon Sep 17 00:00:00 2001 From: V0W4N Date: Wed, 23 Sep 2026 14:29:23 +0300 Subject: [PATCH 1/2] mod linking --- .../migrations/1790160967_bot_mods.cjs | 209 ++++++ src/models/index.ts | 4 + src/models/misc/BotMod.ts | 110 +++ src/models/misc/BotModLink.ts | 90 +++ src/models/misc/associations.ts | 27 + src/server/bootstrap/runtimeServices.ts | 5 + src/server/routes/v2/admin/botMods.ts | 153 ++++ src/server/routes/v2/admin/index.ts | 2 + .../services/mods/BotModsSyncCronService.ts | 30 + src/server/services/mods/botModDiff.test.ts | 105 +++ src/server/services/mods/botModDiff.ts | 61 ++ src/server/services/mods/botModSync.ts | 688 ++++++++++++++++++ 12 files changed, 1484 insertions(+) create mode 100644 src/database/migrations/1790160967_bot_mods.cjs create mode 100644 src/models/misc/BotMod.ts create mode 100644 src/models/misc/BotModLink.ts create mode 100644 src/server/routes/v2/admin/botMods.ts create mode 100644 src/server/services/mods/BotModsSyncCronService.ts create mode 100644 src/server/services/mods/botModDiff.test.ts create mode 100644 src/server/services/mods/botModDiff.ts create mode 100644 src/server/services/mods/botModSync.ts diff --git a/src/database/migrations/1790160967_bot_mods.cjs b/src/database/migrations/1790160967_bot_mods.cjs new file mode 100644 index 00000000..15801926 --- /dev/null +++ b/src/database/migrations/1790160967_bot_mods.cjs @@ -0,0 +1,209 @@ +'use strict'; + +const MIGRATION = '1790160967_bot_mods'; + +function isIgnorableSchemaError(error) { + const code = error?.original?.code || error?.parent?.code || error?.code || ''; + const errno = error?.original?.errno || error?.parent?.errno || error?.errno; + return ( + code === 'ER_TABLE_EXISTS_ERROR' || + code === 'ER_DUP_FIELDNAME' || + code === 'ER_DUP_KEYNAME' || + code === 'ER_CANT_DROP_FIELD_OR_KEY' || + errno === 1050 || + errno === 1060 || + errno === 1061 || + errno === 1091 + ); +} + +async function tryStep(label, fn) { + try { + await fn(); + } catch (error) { + if (isIgnorableSchemaError(error)) { + console.log(`[${MIGRATION}] skip ${label}: ${error.message}`); + return; + } + console.error(`[${MIGRATION}] failed ${label}:`, error.message); + throw error; + } +} + +async function tableExists(queryInterface, tableName) { + const tables = await queryInterface.showAllTables(); + const names = tables.map((t) => (typeof t === 'string' ? t : t.tableName || t.name)); + return names.includes(tableName); +} + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await tryStep('createTable(bot_mods)', async () => { + if (await tableExists(queryInterface, 'bot_mods')) return; + await queryInterface.createTable('bot_mods', { + id: { + type: Sequelize.STRING(64), + primaryKey: true, + allowNull: false, + }, + sourceMongoId: { + type: Sequelize.STRING(32), + allowNull: true, + }, + name: { + type: Sequelize.STRING(512), + allowNull: false, + }, + version: { + type: Sequelize.STRING(64), + allowNull: true, + }, + parsedDownload: { + type: Sequelize.TEXT, + allowNull: true, + }, + download: { + type: Sequelize.TEXT, + allowNull: true, + }, + description: { + type: Sequelize.TEXT, + allowNull: true, + }, + cachedUsername: { + type: Sequelize.STRING(64), + allowNull: false, + }, + creatorDiscordId: { + type: Sequelize.STRING(32), + allowNull: false, + }, + uploadedAt: { + type: Sequelize.DATE, + allowNull: true, + }, + ignoreUpdate: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + hideFromSearch: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + lastSeenAt: { + type: Sequelize.DATE, + allowNull: false, + }, + missingSince: { + type: Sequelize.DATE, + allowNull: true, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'), + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'), + }, + }); + }); + + await tryStep('addIndex(bot_mods.missingSince)', async () => { + await queryInterface.addIndex('bot_mods', ['missingSince'], { + name: 'idx_bot_mods_missing_since', + }); + }); + + await tryStep('createTable(bot_mod_links)', async () => { + if (await tableExists(queryInterface, 'bot_mod_links')) return; + await queryInterface.createTable('bot_mod_links', { + id: { + type: Sequelize.INTEGER, + autoIncrement: true, + primaryKey: true, + allowNull: false, + }, + botId: { + type: Sequelize.STRING(64), + allowNull: false, + references: {model: 'bot_mods', key: 'id'}, + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + }, + modId: { + type: Sequelize.INTEGER, + allowNull: false, + references: {model: 'mods', key: 'id'}, + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + }, + enabled: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: true, + }, + lastAppliedVersion: { + type: Sequelize.STRING(64), + allowNull: true, + }, + lastAppliedDownloadUrl: { + type: Sequelize.TEXT, + allowNull: true, + }, + lastSyncAt: { + type: Sequelize.DATE, + allowNull: true, + }, + lastSyncStatus: { + type: Sequelize.STRING(32), + allowNull: true, + }, + lastSyncMessage: { + type: Sequelize.TEXT, + allowNull: true, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'), + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'), + }, + }); + }); + + await tryStep('addIndex(bot_mod_links.botId unique)', async () => { + await queryInterface.addIndex('bot_mod_links', ['botId'], { + unique: true, + name: 'bot_mod_links_bot_id_unique', + }); + }); + + await tryStep('addIndex(bot_mod_links.modId unique)', async () => { + await queryInterface.addIndex('bot_mod_links', ['modId'], { + unique: true, + name: 'bot_mod_links_mod_id_unique', + }); + }); + }, + + async down(queryInterface) { + await tryStep('dropTable(bot_mod_links)', async () => { + if (!(await tableExists(queryInterface, 'bot_mod_links'))) return; + await queryInterface.dropTable('bot_mod_links'); + }); + await tryStep('dropTable(bot_mods)', async () => { + if (!(await tableExists(queryInterface, 'bot_mods'))) return; + await queryInterface.dropTable('bot_mods'); + }); + }, +}; diff --git a/src/models/index.ts b/src/models/index.ts index 97925062..e6520c6c 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -99,6 +99,8 @@ import ModTagAssignment from './misc/ModTagAssignment.js'; import ModLike from './misc/ModLike.js'; import ModDownloadUnique from './misc/ModDownloadUnique.js'; import ModSlugRedirect from './misc/ModSlugRedirect.js'; +import BotMod from './misc/BotMod.js'; +import BotModLink from './misc/BotModLink.js'; import TranslationContributor from './misc/TranslationContributor.js'; // Create db object with models first export const db = { @@ -205,6 +207,8 @@ export const db = { ModLike, ModDownloadUnique, ModSlugRedirect, + BotMod, + BotModLink, TranslationContributor, }, }; diff --git a/src/models/misc/BotMod.ts b/src/models/misc/BotMod.ts new file mode 100644 index 00000000..615f711b --- /dev/null +++ b/src/models/misc/BotMod.ts @@ -0,0 +1,110 @@ +import { + Model, + DataTypes, + InferAttributes, + InferCreationAttributes, + CreationOptional, +} from 'sequelize'; +import {getSequelizeForModelGroup} from '@/config/db.js'; +import type BotModLink from './BotModLink.js'; + +const sequelize = getSequelizeForModelGroup('admin'); + +class BotMod extends Model, InferCreationAttributes> { + declare id: string; + declare sourceMongoId: CreationOptional; + declare name: string; + declare version: CreationOptional; + declare parsedDownload: CreationOptional; + declare download: CreationOptional; + declare description: CreationOptional; + declare cachedUsername: string; + declare creatorDiscordId: string; + declare uploadedAt: CreationOptional; + declare ignoreUpdate: CreationOptional; + declare hideFromSearch: CreationOptional; + declare lastSeenAt: Date; + declare missingSince: CreationOptional; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; + declare link?: BotModLink | null; +} + +BotMod.init( + { + id: { + type: DataTypes.STRING(64), + primaryKey: true, + allowNull: false, + }, + sourceMongoId: { + type: DataTypes.STRING(32), + allowNull: true, + }, + name: { + type: DataTypes.STRING(512), + allowNull: false, + }, + version: { + type: DataTypes.STRING(64), + allowNull: true, + }, + parsedDownload: { + type: DataTypes.TEXT, + allowNull: true, + }, + download: { + type: DataTypes.TEXT, + allowNull: true, + }, + description: { + type: DataTypes.TEXT, + allowNull: true, + }, + cachedUsername: { + type: DataTypes.STRING(64), + allowNull: false, + }, + creatorDiscordId: { + type: DataTypes.STRING(32), + allowNull: false, + }, + uploadedAt: { + type: DataTypes.DATE, + allowNull: true, + }, + ignoreUpdate: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + hideFromSearch: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + lastSeenAt: { + type: DataTypes.DATE, + allowNull: false, + }, + missingSince: { + type: DataTypes.DATE, + allowNull: true, + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false, + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false, + }, + }, + { + sequelize, + tableName: 'bot_mods', + indexes: [{fields: ['missingSince'], name: 'idx_bot_mods_missing_since'}], + }, +); + +export default BotMod; diff --git a/src/models/misc/BotModLink.ts b/src/models/misc/BotModLink.ts new file mode 100644 index 00000000..19a3cb27 --- /dev/null +++ b/src/models/misc/BotModLink.ts @@ -0,0 +1,90 @@ +import { + Model, + DataTypes, + InferAttributes, + InferCreationAttributes, + CreationOptional, + ForeignKey, +} from 'sequelize'; +import {getSequelizeForModelGroup} from '@/config/db.js'; +import type Mod from './Mod.js'; +import type BotMod from './BotMod.js'; + +const sequelize = getSequelizeForModelGroup('admin'); + +class BotModLink extends Model, InferCreationAttributes> { + declare id: CreationOptional; + declare botId: ForeignKey; + declare modId: ForeignKey; + declare enabled: CreationOptional; + declare lastAppliedVersion: CreationOptional; + declare lastAppliedDownloadUrl: CreationOptional; + declare lastSyncAt: CreationOptional; + declare lastSyncStatus: CreationOptional; + declare lastSyncMessage: CreationOptional; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; + declare botMod?: BotMod; + declare mod?: Mod; +} + +BotModLink.init( + { + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + botId: { + type: DataTypes.STRING(64), + allowNull: false, + }, + modId: { + type: DataTypes.INTEGER, + allowNull: false, + }, + enabled: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true, + }, + lastAppliedVersion: { + type: DataTypes.STRING(64), + allowNull: true, + }, + lastAppliedDownloadUrl: { + type: DataTypes.TEXT, + allowNull: true, + }, + lastSyncAt: { + type: DataTypes.DATE, + allowNull: true, + }, + lastSyncStatus: { + type: DataTypes.STRING(32), + allowNull: true, + }, + lastSyncMessage: { + type: DataTypes.TEXT, + allowNull: true, + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false, + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false, + }, + }, + { + sequelize, + tableName: 'bot_mod_links', + indexes: [ + {unique: true, fields: ['botId'], name: 'bot_mod_links_bot_id_unique'}, + {unique: true, fields: ['modId'], name: 'bot_mod_links_mod_id_unique'}, + ], + }, +); + +export default BotModLink; diff --git a/src/models/misc/associations.ts b/src/models/misc/associations.ts index acd1fd12..7735f06b 100644 --- a/src/models/misc/associations.ts +++ b/src/models/misc/associations.ts @@ -10,6 +10,8 @@ import ModTag from './ModTag.js'; import ModTagAssignment from './ModTagAssignment.js'; import ModLike from './ModLike.js'; import ModSlugRedirect from './ModSlugRedirect.js'; +import BotMod from './BotMod.js'; +import BotModLink from './BotModLink.js'; export function initializeMiscAssociations() { Mod.hasMany(ModAssignee, { @@ -105,6 +107,31 @@ export function initializeMiscAssociations() { onUpdate: 'CASCADE', }); + BotMod.hasOne(BotModLink, { + foreignKey: 'botId', + as: 'link', + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }); + BotModLink.belongsTo(BotMod, { + foreignKey: 'botId', + as: 'botMod', + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }); + Mod.hasOne(BotModLink, { + foreignKey: 'modId', + as: 'botLink', + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }); + BotModLink.belongsTo(Mod, { + foreignKey: 'modId', + as: 'mod', + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }); + UsefulLink.hasMany(UsefulLinkLocale, { foreignKey: 'linkId', as: 'locales', diff --git a/src/server/bootstrap/runtimeServices.ts b/src/server/bootstrap/runtimeServices.ts index 68d0b9d0..8d27f899 100644 --- a/src/server/bootstrap/runtimeServices.ts +++ b/src/server/bootstrap/runtimeServices.ts @@ -115,6 +115,11 @@ export async function initializeRuntimeServices(): Promise { ); WeeklyScheduleFillCronService.startScheduledFill(); + const { BotModsSyncCronService } = await import( + '@/server/services/mods/BotModsSyncCronService.js' + ); + BotModsSyncCronService.startScheduledSync(); + const { initUploadKinds } = await import('@/server/services/upload/registerKinds.js'); initUploadKinds(); } diff --git a/src/server/routes/v2/admin/botMods.ts b/src/server/routes/v2/admin/botMods.ts new file mode 100644 index 00000000..7b918e70 --- /dev/null +++ b/src/server/routes/v2/admin/botMods.ts @@ -0,0 +1,153 @@ +import {Router, Request, Response} from 'express'; +import {Auth} from '@/server/middleware/auth.js'; +import {ApiDoc} from '@/server/middleware/apiDoc.js'; +import {respondMysqlClientError} from '@/misc/utils/db/mysqlClientError.js'; +import { + linkBotModToCatalog, + listBotMods, + parseBotModId, + parseBotModListFilter, + parseBotModListLimit, + parseBotModListOffset, + parseCatalogModId, + runBotModsSync, + setBotModLinkEnabled, + unlinkBotMod, +} from '@/server/services/mods/botModSync.js'; + +const router: Router = Router(); + +function respondSyncError(res: Response, error: unknown, fallback: string, logLabel: string) { + const status = (error as Error & {status?: number}).status; + if (status && status >= 400 && status < 500) { + return res.status(status).json({error: (error as Error).message}); + } + return respondMysqlClientError(res, error, fallback, {logLabel}); +} + +router.get( + '/', + Auth.superAdmin(), + ApiDoc({ + operationId: 'adminListBotMods', + summary: 'List scraped bot mods and their catalog links', + tags: ['Admin', 'Mods'], + security: ['bearerAuth'], + responses: {200: {description: 'Scraped bot mods'}}, + }), + async (req: Request, res: Response) => { + try { + const q = typeof req.query.q === 'string' ? req.query.q.trim() : ''; + const hasModId = req.query.modId !== undefined && String(req.query.modId).trim() !== ''; + const modId = hasModId ? parseCatalogModId(req.query.modId) : undefined; + if (hasModId && modId === null) { + return res.status(400).json({error: 'Invalid modId'}); + } + const result = await listBotMods({ + q: q || undefined, + filter: parseBotModListFilter(req.query.filter), + modId, + offset: parseBotModListOffset(req.query.offset), + limit: parseBotModListLimit(req.query.limit), + }); + return res.json(result); + } catch (error) { + return respondSyncError(res, error, 'Failed to list bot mods', 'Admin list bot mods failed:'); + } + }, +); + +router.post( + '/sync', + Auth.superAdmin(), + ApiDoc({ + operationId: 'adminSyncBotMods', + summary: 'Fetch the bot mods feed and apply linked release updates', + tags: ['Admin', 'Mods'], + security: ['bearerAuth'], + responses: {200: {description: 'Sync result'}}, + }), + async (_req: Request, res: Response) => { + try { + const result = await runBotModsSync(); + return res.json(result); + } catch (error) { + return respondSyncError(res, error, 'Failed to sync bot mods', 'Admin sync bot mods failed:'); + } + }, +); + +router.put( + '/:botId/link', + Auth.superAdmin(), + ApiDoc({ + operationId: 'adminLinkBotMod', + summary: 'Link a scraped bot mod to a catalog mod', + tags: ['Admin', 'Mods'], + security: ['bearerAuth'], + responses: {200: {description: 'Linked bot mod'}}, + }), + async (req: Request, res: Response) => { + try { + const botId = parseBotModId(req.params.botId); + if (!botId) return res.status(400).json({error: 'Invalid bot mod id'}); + const body = req.body && typeof req.body === 'object' ? (req.body as Record) : {}; + const modId = parseCatalogModId(body.modId); + if (!modId) return res.status(400).json({error: 'modId is required'}); + const botMod = await linkBotModToCatalog(botId, modId); + return res.json({botMod}); + } catch (error) { + return respondSyncError(res, error, 'Failed to link bot mod', 'Admin link bot mod failed:'); + } + }, +); + +router.patch( + '/:botId/link', + Auth.superAdmin(), + ApiDoc({ + operationId: 'adminPatchBotModLink', + summary: 'Enable or pause a bot mod catalog link', + tags: ['Admin', 'Mods'], + security: ['bearerAuth'], + responses: {200: {description: 'Updated link'}}, + }), + async (req: Request, res: Response) => { + try { + const botId = parseBotModId(req.params.botId); + if (!botId) return res.status(400).json({error: 'Invalid bot mod id'}); + const body = req.body && typeof req.body === 'object' ? (req.body as Record) : {}; + if (typeof body.enabled !== 'boolean') { + return res.status(400).json({error: 'enabled must be a boolean'}); + } + const botMod = await setBotModLinkEnabled(botId, body.enabled); + return res.json({botMod}); + } catch (error) { + return respondSyncError(res, error, 'Failed to update bot mod link', 'Admin patch bot mod link failed:'); + } + }, +); + +router.delete( + '/:botId/link', + Auth.superAdmin(), + ApiDoc({ + operationId: 'adminUnlinkBotMod', + summary: 'Remove a bot mod catalog link', + tags: ['Admin', 'Mods'], + security: ['bearerAuth'], + responses: {200: {description: 'Unlinked'}}, + }), + async (req: Request, res: Response) => { + try { + const botId = parseBotModId(req.params.botId); + if (!botId) return res.status(400).json({error: 'Invalid bot mod id'}); + await unlinkBotMod(botId); + return res.json({ok: true}); + } catch (error) { + return respondSyncError(res, error, 'Failed to unlink bot mod', 'Admin unlink bot mod failed:'); + } + }, +); + +export default router; diff --git a/src/server/routes/v2/admin/index.ts b/src/server/routes/v2/admin/index.ts index 782fa916..41f1139e 100644 --- a/src/server/routes/v2/admin/index.ts +++ b/src/server/routes/v2/admin/index.ts @@ -14,6 +14,7 @@ import tournamentsRoutes from './tournaments.js'; import oauthClientsRoutes from './oauthClients.js'; import usefulLinksRoutes from './usefulLinks.js'; import modsRoutes from './mods.js'; +import botModsRoutes from './botMods.js'; import translationContributorsRoutes from './translationContributors.js'; // Import other admin routes here @@ -36,6 +37,7 @@ router.use('/tournaments', tournamentsRoutes); router.use('/oauth-clients', oauthClientsRoutes); router.use('/useful-links', usefulLinksRoutes); router.use('/mods', modsRoutes); +router.use('/bot-mods', botModsRoutes); router.use('/translation-contributors', translationContributorsRoutes); router.head('/verify-password', Auth.superAdminPassword(), async (req, res) => { diff --git a/src/server/services/mods/BotModsSyncCronService.ts b/src/server/services/mods/BotModsSyncCronService.ts new file mode 100644 index 00000000..7ad9290a --- /dev/null +++ b/src/server/services/mods/BotModsSyncCronService.ts @@ -0,0 +1,30 @@ +import {CronJob} from 'cron'; +import {logger} from '@/server/services/core/LoggerService.js'; +import {runBotModsSync} from './botModSync.js'; + +/** Every 15 minutes. */ +const CRON_SCHEDULE = '*/15 * * * *'; + +export class BotModsSyncCronService { + private static cron: CronJob | null = null; + + static startScheduledSync(): void { + if (process.env.BOT_MODS_SYNC_DISABLED === '1') { + logger.info('Bot mods sync cron disabled (BOT_MODS_SYNC_DISABLED=1)'); + return; + } + if (BotModsSyncCronService.cron) return; + + BotModsSyncCronService.cron = new CronJob(CRON_SCHEDULE, async () => { + try { + const result = await runBotModsSync(); + logger.info('Bot mods scheduled sync finished', result); + } catch (error) { + logger.error('Bot mods scheduled sync failed', error); + } + }); + + BotModsSyncCronService.cron.start(); + logger.info('Bot mods sync cron started'); + } +} diff --git a/src/server/services/mods/botModDiff.test.ts b/src/server/services/mods/botModDiff.test.ts new file mode 100644 index 00000000..a2dde789 --- /dev/null +++ b/src/server/services/mods/botModDiff.test.ts @@ -0,0 +1,105 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import {BOT_MOD_DIFF, decideBotModLinkAction, snapshotVersion} from './botModDiff.js'; + +const base = { + enabled: true, + ignoreUpdate: false, + missing: false, + version: '1.2.0', + parsedDownload: 'https://github.com/org/mod/releases/download/1.2.0/mod.zip', + lastAppliedVersion: '1.2.0', + lastAppliedDownloadUrl: 'https://github.com/org/mod/releases/download/1.2.0/mod.zip', + catalogHasVersion: true, +}; + +void test('snapshotVersion trims and caps length', () => { + assert.equal(snapshotVersion(' 1.0.0 '), '1.0.0'); + assert.equal(snapshotVersion(null), ''); + assert.equal(snapshotVersion('x'.repeat(80)).length, 64); +}); + +void test('same version and url is a no-op', () => { + assert.equal(decideBotModLinkAction(base).kind, BOT_MOD_DIFF.NOOP); +}); + +void test('same version with a new download url advances the cursor only', () => { + assert.equal( + decideBotModLinkAction({ + ...base, + parsedDownload: 'https://github.com/org/mod/releases/download/1.2.0/mod-rebuild.zip', + }).kind, + BOT_MOD_DIFF.ADVANCE_URL, + ); +}); + +void test('new version creates a release when the catalog does not already have it', () => { + assert.equal( + decideBotModLinkAction({ + ...base, + version: '1.3.0', + parsedDownload: 'https://github.com/org/mod/releases/download/1.3.0/mod.zip', + catalogHasVersion: false, + }).kind, + BOT_MOD_DIFF.CREATE_RELEASE, + ); +}); + +void test('new version that already exists on the catalog is skipped', () => { + assert.equal( + decideBotModLinkAction({ + ...base, + version: '1.3.0', + catalogHasVersion: true, + }).kind, + BOT_MOD_DIFF.SKIP_EXISTING, + ); +}); + +void test('empty version is an error', () => { + assert.equal(decideBotModLinkAction({...base, version: ' '}).kind, BOT_MOD_DIFF.EMPTY_VERSION); + assert.equal(decideBotModLinkAction({...base, version: null}).kind, BOT_MOD_DIFF.EMPTY_VERSION); +}); + +void test('missing download url is an error', () => { + assert.equal( + decideBotModLinkAction({...base, parsedDownload: ' '}).kind, + BOT_MOD_DIFF.MISSING_DOWNLOAD, + ); +}); + +void test('ignoreUpdate skips applying', () => { + assert.equal( + decideBotModLinkAction({...base, ignoreUpdate: true, version: '9.9.9'}).kind, + BOT_MOD_DIFF.IGNORE_UPDATE, + ); +}); + +void test('disabled and missing rows are not applied', () => { + assert.equal(decideBotModLinkAction({...base, enabled: false}).kind, BOT_MOD_DIFF.DISABLED); + assert.equal(decideBotModLinkAction({...base, missing: true, version: '9.9.9'}).kind, BOT_MOD_DIFF.MISSING); +}); + +void test('cursor matching a version the catalog does not have creates a release', () => { + assert.equal( + decideBotModLinkAction({ + ...base, + catalogHasVersion: false, + }).kind, + BOT_MOD_DIFF.CREATE_RELEASE, + ); +}); + +void test('first version after linking an empty snapshot creates a release', () => { + assert.equal( + decideBotModLinkAction({ + ...base, + lastAppliedVersion: null, + lastAppliedDownloadUrl: null, + version: '1.0.0', + catalogHasVersion: false, + }).kind, + BOT_MOD_DIFF.CREATE_RELEASE, + ); +}); diff --git a/src/server/services/mods/botModDiff.ts b/src/server/services/mods/botModDiff.ts new file mode 100644 index 00000000..3db2f009 --- /dev/null +++ b/src/server/services/mods/botModDiff.ts @@ -0,0 +1,61 @@ +export const BOT_MOD_DIFF = { + NOOP: 'noop', + ADVANCE_URL: 'advance_url', + CREATE_RELEASE: 'create_release', + SKIP_EXISTING: 'skip_existing', + EMPTY_VERSION: 'empty_version', + MISSING_DOWNLOAD: 'missing_download', + IGNORE_UPDATE: 'ignore_update', + DISABLED: 'disabled', + MISSING: 'missing', +} as const; + +export type BotModDiffKind = (typeof BOT_MOD_DIFF)[keyof typeof BOT_MOD_DIFF]; + +export const BOT_MOD_VERSION_MAX = 64; + +export function snapshotVersion(raw: unknown): string { + return String(raw ?? '') + .trim() + .slice(0, BOT_MOD_VERSION_MAX); +} + +export function snapshotDownloadUrl(raw: unknown): string { + return String(raw ?? '').trim(); +} + +export type BotModLinkDiffInput = { + enabled: boolean; + ignoreUpdate: boolean; + missing: boolean; + version: string | null | undefined; + parsedDownload: string | null | undefined; + lastAppliedVersion: string | null | undefined; + lastAppliedDownloadUrl: string | null | undefined; + catalogHasVersion: boolean; +}; + +export function decideBotModLinkAction(input: BotModLinkDiffInput): {kind: BotModDiffKind} { + if (input.missing) return {kind: BOT_MOD_DIFF.MISSING}; + if (!input.enabled) return {kind: BOT_MOD_DIFF.DISABLED}; + if (input.ignoreUpdate) return {kind: BOT_MOD_DIFF.IGNORE_UPDATE}; + + const version = snapshotVersion(input.version); + if (!version) return {kind: BOT_MOD_DIFF.EMPTY_VERSION}; + + const downloadUrl = snapshotDownloadUrl(input.parsedDownload); + if (!downloadUrl) return {kind: BOT_MOD_DIFF.MISSING_DOWNLOAD}; + + const cursorVersion = snapshotVersion(input.lastAppliedVersion); + const cursorUrl = snapshotDownloadUrl(input.lastAppliedDownloadUrl); + + if (input.catalogHasVersion) { + if (version === cursorVersion) { + if (downloadUrl === cursorUrl) return {kind: BOT_MOD_DIFF.NOOP}; + return {kind: BOT_MOD_DIFF.ADVANCE_URL}; + } + return {kind: BOT_MOD_DIFF.SKIP_EXISTING}; + } + + return {kind: BOT_MOD_DIFF.CREATE_RELEASE}; +} diff --git a/src/server/services/mods/botModSync.ts b/src/server/services/mods/botModSync.ts new file mode 100644 index 00000000..c44665c2 --- /dev/null +++ b/src/server/services/mods/botModSync.ts @@ -0,0 +1,688 @@ +import axios from 'axios'; +import {Op, type WhereOptions} from 'sequelize'; +import {getSequelizeForModelGroup} from '@/config/db.js'; +import BotMod from '@/models/misc/BotMod.js'; +import BotModLink from '@/models/misc/BotModLink.js'; +import Mod from '@/models/misc/Mod.js'; +import ModVersion from '@/models/misc/ModVersion.js'; +import {logger} from '@/server/services/core/LoggerService.js'; +import {invalidatePublicModsCache} from './modCache.js'; +import {createModVersion} from './modCreate.js'; +import {indexCatalogMod} from './modSearchIndex.js'; +import {normalizeVersionLabel} from './modSlug.js'; +import { + BOT_MOD_DIFF, + decideBotModLinkAction, + snapshotDownloadUrl, + snapshotVersion, +} from './botModDiff.js'; + +export const DEFAULT_BOT_MODS_URL = 'https://bot.adofai.gg/api/mods/'; +const FETCH_TIMEOUT_MS = 30_000; +const NAME_MAX = 512; +const USERNAME_MAX = 64; +const DISCORD_ID_MAX = 32; +const MONGO_ID_MAX = 32; +const BOT_ID_MAX = 64; +const LIST_DEFAULT_LIMIT = 200; +const LIST_MAX_LIMIT = 500; + +const sequelize = getSequelizeForModelGroup('admin'); + +export const BOT_MOD_LIST_FILTERS = ['all', 'unlinked', 'linked', 'problems'] as const; +export type BotModListFilter = (typeof BOT_MOD_LIST_FILTERS)[number]; + +export type SerializedBotModLink = { + modId: number; + modName: string | null; + modSlug: string | null; + enabled: boolean; + lastAppliedVersion: string | null; + lastAppliedDownloadUrl: string | null; + lastSyncAt: string | null; + lastSyncStatus: string | null; + lastSyncMessage: string | null; +}; + +export type SerializedBotMod = { + id: string; + name: string; + version: string | null; + parsedDownload: string | null; + download: string | null; + description: string | null; + cachedUsername: string; + creatorDiscordId: string; + uploadedAt: string | null; + ignoreUpdate: boolean; + hideFromSearch: boolean; + lastSeenAt: string | null; + missingSince: string | null; + link: SerializedBotModLink | null; +}; + +export type BotModsSyncResult = { + fetched: number; + upserted: number; + missing: number; + created: number; + skipped: number; + advanced: number; + unchanged: number; + ignored: number; + errors: number; + alreadyRunning: boolean; +}; + +type BotModsSyncCounts = Omit; + +function clip(raw: unknown, max: number): string { + return String(raw ?? '').trim().slice(0, max); +} + +function iso(value: Date | string | null | undefined): string | null { + if (!value) return null; + const date = value instanceof Date ? value : new Date(value); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); +} + +function parseUploadedAt(raw: unknown): Date | null { + const n = typeof raw === 'number' ? raw : Number(raw); + if (!Number.isFinite(n) || n <= 0) return null; + const ms = n < 1e12 ? n * 1000 : n; + const date = new Date(ms); + return Number.isNaN(date.getTime()) ? null : date; +} + +function asBoolean(raw: unknown): boolean { + return raw === true || raw === 'true' || raw === 1 || raw === '1'; +} + +export function parseBotModId(raw: unknown): string | null { + const id = String(raw ?? '').trim(); + if (!id || id.length > BOT_ID_MAX) return null; + return id; +} + +export function parseBotModListFilter(raw: unknown): BotModListFilter { + if (typeof raw === 'string' && (BOT_MOD_LIST_FILTERS as readonly string[]).includes(raw)) { + return raw as BotModListFilter; + } + return 'all'; +} + +export function parseBotModListOffset(raw: unknown): number { + const n = parseInt(String(raw ?? ''), 10); + if (!Number.isFinite(n) || n < 0) return 0; + return n; +} + +export function parseBotModListLimit(raw: unknown): number { + const n = parseInt(String(raw ?? ''), 10); + if (!Number.isFinite(n)) return LIST_DEFAULT_LIMIT; + return Math.min(LIST_MAX_LIMIT, Math.max(1, n)); +} + +export function parseCatalogModId(raw: unknown): number | null { + const n = typeof raw === 'number' ? raw : parseInt(String(raw ?? ''), 10); + if (!Number.isInteger(n) || n <= 0) return null; + return n; +} + +function escapeLike(value: string): string { + return value.replace(/[\\%_]/g, '\\$&'); +} + +function isUniqueConstraintError(error: unknown): boolean { + const err = error as {name?: string; parent?: {errno?: number}; original?: {errno?: number}}; + if (err?.name === 'SequelizeUniqueConstraintError') return true; + const errno = err?.parent?.errno ?? err?.original?.errno; + return errno === 1062; +} + +function clientError(message: string, status: number): Error & {status: number} { + const error = new Error(message) as Error & {status: number}; + error.status = status; + return error; +} + +function errorMessage(error: unknown): string { + if (error instanceof Error && error.message) return error.message.slice(0, 500); + return 'Unknown error'; +} + +export function serializeBotMod(row: BotMod): SerializedBotMod { + const link = row.link ?? null; + const mod = link?.mod ?? null; + return { + id: row.id, + name: row.name, + version: row.version, + parsedDownload: row.parsedDownload, + download: row.download, + description: row.description, + cachedUsername: row.cachedUsername, + creatorDiscordId: row.creatorDiscordId, + uploadedAt: iso(row.uploadedAt), + ignoreUpdate: Boolean(row.ignoreUpdate), + hideFromSearch: Boolean(row.hideFromSearch), + lastSeenAt: iso(row.lastSeenAt), + missingSince: iso(row.missingSince), + link: link + ? { + modId: link.modId, + modName: mod?.name ?? null, + modSlug: mod?.slug ?? null, + enabled: Boolean(link.enabled), + lastAppliedVersion: link.lastAppliedVersion, + lastAppliedDownloadUrl: link.lastAppliedDownloadUrl, + lastSyncAt: iso(link.lastSyncAt), + lastSyncStatus: link.lastSyncStatus, + lastSyncMessage: link.lastSyncMessage, + } + : null, + }; +} + +function isProblemRow(row: SerializedBotMod): boolean { + if (row.missingSince) return true; + if (row.ignoreUpdate) return true; + if (!snapshotVersion(row.version)) return true; + if (row.link?.lastSyncStatus === 'error') return true; + return false; +} + +type ParsedFeedItem = { + id: string; + sourceMongoId: string | null; + name: string; + version: string | null; + parsedDownload: string | null; + download: string | null; + description: string | null; + cachedUsername: string; + creatorDiscordId: string; + uploadedAt: Date | null; + ignoreUpdate: boolean; + hideFromSearch: boolean; + lastSeenAt: Date; + missingSince: null; + updatedAt: Date; +}; + +function parseFeedItem(raw: unknown, seenAt: Date): ParsedFeedItem | null { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null; + const src = raw as Record; + const id = parseBotModId(src.id); + if (!id) return null; + const parsedDownload = snapshotDownloadUrl(src.parsedDownload) || snapshotDownloadUrl(src.download) || null; + const download = snapshotDownloadUrl(src.download) || null; + return { + id, + sourceMongoId: clip(src._id, MONGO_ID_MAX) || null, + name: clip(src.name, NAME_MAX) || id, + version: snapshotVersion(src.version) || null, + parsedDownload, + download, + description: + typeof src.description === 'string' + ? src.description + : src.description === null || src.description === undefined + ? null + : String(src.description), + cachedUsername: clip(src.cachedUsername, USERNAME_MAX) || 'unknown', + creatorDiscordId: clip(src.user, DISCORD_ID_MAX), + uploadedAt: parseUploadedAt(src.uploadedTimestamp), + ignoreUpdate: asBoolean(src.ignoreUpdate), + hideFromSearch: asBoolean(src.hideFromSearch), + lastSeenAt: seenAt, + missingSince: null, + updatedAt: seenAt, + }; +} + +export function botModsFeedUrl(): string { + const raw = process.env.BOT_MODS_URL?.trim(); + return raw || DEFAULT_BOT_MODS_URL; +} + +async function fetchBotModsFeed(url: string): Promise { + const response = await axios.get(url, { + timeout: FETCH_TIMEOUT_MS, + headers: { + Accept: 'application/json', + 'User-Agent': 'TUF-Website/bot-mods-sync', + }, + validateStatus: (status) => status >= 200 && status < 300, + }); + if (!Array.isArray(response.data)) { + throw new Error('Bot mods feed was not a JSON array'); + } + return response.data; +} + +async function stampLink( + link: BotModLink, + patch: { + status: string; + message: string | null; + at: Date; + version?: string | null; + url?: string | null; + keepCursor?: boolean; + }, +): Promise { + const next: Partial<{ + lastSyncAt: Date; + lastSyncStatus: string; + lastSyncMessage: string | null; + lastAppliedVersion: string | null; + lastAppliedDownloadUrl: string | null; + }> = { + lastSyncAt: patch.at, + lastSyncStatus: patch.status, + lastSyncMessage: patch.message, + }; + if (!patch.keepCursor) { + if (patch.version !== undefined) next.lastAppliedVersion = patch.version || null; + if (patch.url !== undefined) next.lastAppliedDownloadUrl = patch.url || null; + } + await link.update(next); +} + +async function applyLinkedSnapshot(link: BotModLink, seenAt: Date, counts: BotModsSyncCounts, createdModIds: Set): Promise { + const bot = link.botMod; + if (!bot) { + counts.errors += 1; + await stampLink(link, { + status: 'error', + message: 'Bot snapshot missing', + at: seenAt, + keepCursor: true, + }); + return; + } + + const version = snapshotVersion(bot.version); + const downloadUrl = snapshotDownloadUrl(bot.parsedDownload); + let catalogHasVersion = false; + if (version) { + const existing = await ModVersion.findOne({ + where: {modId: link.modId, version: normalizeVersionLabel(version)}, + }); + catalogHasVersion = Boolean(existing); + } + + const action = decideBotModLinkAction({ + enabled: Boolean(link.enabled), + ignoreUpdate: Boolean(bot.ignoreUpdate), + missing: Boolean(bot.missingSince), + version: bot.version, + parsedDownload: bot.parsedDownload, + lastAppliedVersion: link.lastAppliedVersion, + lastAppliedDownloadUrl: link.lastAppliedDownloadUrl, + catalogHasVersion, + }); + + switch (action.kind) { + case BOT_MOD_DIFF.NOOP: + counts.unchanged += 1; + await stampLink(link, {status: 'ok', message: null, at: seenAt, keepCursor: true}); + return; + case BOT_MOD_DIFF.ADVANCE_URL: + counts.advanced += 1; + await stampLink(link, { + status: 'ok', + message: 'Download URL changed for the same version', + at: seenAt, + version, + url: downloadUrl, + }); + return; + case BOT_MOD_DIFF.SKIP_EXISTING: + counts.skipped += 1; + await stampLink(link, { + status: 'skipped', + message: `Version ${version} already exists`, + at: seenAt, + version, + url: downloadUrl, + }); + return; + case BOT_MOD_DIFF.EMPTY_VERSION: + counts.errors += 1; + await stampLink(link, { + status: 'error', + message: 'Empty version', + at: seenAt, + keepCursor: true, + }); + return; + case BOT_MOD_DIFF.MISSING_DOWNLOAD: + counts.errors += 1; + await stampLink(link, { + status: 'error', + message: 'Missing download URL', + at: seenAt, + keepCursor: true, + }); + return; + case BOT_MOD_DIFF.IGNORE_UPDATE: + counts.ignored += 1; + await stampLink(link, { + status: 'ignored', + message: 'Bot marked ignoreUpdate', + at: seenAt, + keepCursor: true, + }); + return; + case BOT_MOD_DIFF.MISSING: + await stampLink(link, { + status: 'missing', + message: 'Not present in the latest feed', + at: seenAt, + keepCursor: true, + }); + return; + case BOT_MOD_DIFF.DISABLED: + return; + case BOT_MOD_DIFF.CREATE_RELEASE: { + try { + await createModVersion({ + modId: link.modId, + version, + downloadUrl, + notes: null, + releasedAt: bot.uploadedAt || seenAt, + }); + createdModIds.add(link.modId); + counts.created += 1; + await stampLink(link, { + status: 'created', + message: `Created release ${version}`, + at: seenAt, + version, + url: downloadUrl, + }); + } catch (error) { + if (isUniqueConstraintError(error)) { + counts.skipped += 1; + await stampLink(link, { + status: 'skipped', + message: `Version ${version} already exists`, + at: seenAt, + version, + url: downloadUrl, + }); + return; + } + counts.errors += 1; + logger.error('Bot mod auto-release failed', {botId: bot.id, modId: link.modId, error}); + await stampLink(link, { + status: 'error', + message: errorMessage(error), + at: seenAt, + keepCursor: true, + }); + } + return; + } + default: + return; + } +} + +async function reindexCreatedMods(createdModIds: Set): Promise { + if (createdModIds.size === 0) return; + for (const modId of createdModIds) { + try { + await indexCatalogMod(modId); + } catch (error) { + logger.error('Bot mod search reindex failed', {modId, error}); + } + } + try { + await invalidatePublicModsCache(); + } catch (error) { + logger.error('Bot mod cache invalidate failed', error); + } +} + +async function applyLinkNow(link: BotModLink): Promise { + const counts: BotModsSyncCounts = { + fetched: 0, + upserted: 0, + missing: 0, + created: 0, + skipped: 0, + advanced: 0, + unchanged: 0, + ignored: 0, + errors: 0, + }; + const createdModIds = new Set(); + await applyLinkedSnapshot(link, new Date(), counts, createdModIds); + await reindexCreatedMods(createdModIds); +} + +async function runBotModsSyncOnce(): Promise { + const seenAt = new Date(); + const url = botModsFeedUrl(); + const feed = await fetchBotModsFeed(url); + if (feed.length === 0) { + throw new Error('Bot mods feed was empty'); + } + + const parsed: ParsedFeedItem[] = []; + const seenIds: string[] = []; + const seen = new Set(); + for (const item of feed) { + const row = parseFeedItem(item, seenAt); + if (!row || seen.has(row.id)) continue; + seen.add(row.id); + seenIds.push(row.id); + parsed.push(row); + } + if (parsed.length === 0) { + throw new Error('Bot mods feed had no usable rows'); + } + + await BotMod.bulkCreate(parsed, { + updateOnDuplicate: [ + 'sourceMongoId', + 'name', + 'version', + 'parsedDownload', + 'download', + 'description', + 'cachedUsername', + 'creatorDiscordId', + 'uploadedAt', + 'ignoreUpdate', + 'hideFromSearch', + 'lastSeenAt', + 'missingSince', + 'updatedAt', + ], + }); + + const [missingCount] = await BotMod.update( + {missingSince: seenAt}, + { + where: { + id: {[Op.notIn]: seenIds}, + missingSince: {[Op.is]: null}, + }, + }, + ); + + const counts: BotModsSyncCounts = { + fetched: feed.length, + upserted: parsed.length, + missing: missingCount, + created: 0, + skipped: 0, + advanced: 0, + unchanged: 0, + ignored: 0, + errors: 0, + }; + + const links = await BotModLink.findAll({ + where: {enabled: true}, + include: [{model: BotMod, as: 'botMod', required: true}], + }); + const createdModIds = new Set(); + for (const link of links) { + await applyLinkedSnapshot(link, seenAt, counts, createdModIds); + } + + await reindexCreatedMods(createdModIds); + + return {...counts, alreadyRunning: false}; +} + +let syncInFlight: Promise | null = null; + +export async function runBotModsSync(): Promise { + if (syncInFlight) { + const result = await syncInFlight; + return {...result, alreadyRunning: true}; + } + const pending = runBotModsSyncOnce().finally(() => { + if (syncInFlight === pending) syncInFlight = null; + }); + syncInFlight = pending; + return pending; +} + +export async function listBotMods(options: { + q?: string; + filter: BotModListFilter; + modId?: number; + offset: number; + limit: number; +}): Promise<{botMods: SerializedBotMod[]; total: number}> { + const clauses: WhereOptions[] = []; + if (typeof options.modId === 'number') { + clauses.push({'$link.modId$': options.modId}); + } + if (options.q) { + const like = `%${escapeLike(options.q)}%`; + clauses.push({ + [Op.or]: [ + {name: {[Op.like]: like}}, + {cachedUsername: {[Op.like]: like}}, + {id: {[Op.like]: like}}, + {creatorDiscordId: {[Op.like]: like}}, + ], + }); + } + const where: WhereOptions = + clauses.length === 0 ? {} : clauses.length === 1 ? clauses[0] : {[Op.and]: clauses}; + + const rows = await BotMod.findAll({ + where, + include: [ + { + model: BotModLink, + as: 'link', + required: Boolean(options.modId), + include: [{model: Mod, as: 'mod', attributes: ['id', 'name', 'slug'], required: false}], + }, + ], + order: [ + ['name', 'ASC'], + ['id', 'ASC'], + ], + }); + + let serialized = rows.map(serializeBotMod); + if (options.filter === 'unlinked') { + serialized = serialized.filter((row) => !row.link); + } else if (options.filter === 'linked') { + serialized = serialized.filter((row) => Boolean(row.link)); + } else if (options.filter === 'problems') { + serialized = serialized.filter(isProblemRow); + } + + const total = serialized.length; + return { + botMods: serialized.slice(options.offset, options.offset + options.limit), + total, + }; +} + +export async function linkBotModToCatalog(botId: string, modId: number): Promise { + const bot = await BotMod.findByPk(botId); + if (!bot) throw clientError('Unknown bot mod id. Run a sync first.', 404); + const mod = await Mod.findByPk(modId); + if (!mod) throw clientError('Mod not found', 404); + + const transaction = await sequelize.transaction(); + try { + await BotModLink.destroy({ + where: {[Op.or]: [{botId}, {modId}]}, + transaction, + }); + await BotModLink.create( + { + botId, + modId, + enabled: true, + lastAppliedVersion: null, + lastAppliedDownloadUrl: null, + lastSyncAt: new Date(), + lastSyncStatus: 'linked', + lastSyncMessage: null, + }, + {transaction}, + ); + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + + const link = await BotModLink.findOne({ + where: {botId}, + include: [{model: BotMod, as: 'botMod', required: true}], + }); + if (link) { + await applyLinkNow(link); + } + + const reloaded = await BotMod.findByPk(botId, { + include: [ + { + model: BotModLink, + as: 'link', + include: [{model: Mod, as: 'mod', attributes: ['id', 'name', 'slug']}], + }, + ], + }); + if (!reloaded) throw clientError('Unknown bot mod id. Run a sync first.', 404); + return serializeBotMod(reloaded); +} + +export async function unlinkBotMod(botId: string): Promise { + const deleted = await BotModLink.destroy({where: {botId}}); + if (deleted === 0) throw clientError('Bot mod is not linked', 404); +} + +export async function setBotModLinkEnabled(botId: string, enabled: boolean): Promise { + const link = await BotModLink.findOne({where: {botId}}); + if (!link) throw clientError('Bot mod is not linked', 404); + await link.update({enabled}); + const reloaded = await BotMod.findByPk(botId, { + include: [ + { + model: BotModLink, + as: 'link', + include: [{model: Mod, as: 'mod', attributes: ['id', 'name', 'slug']}], + }, + ], + }); + if (!reloaded) throw clientError('Unknown bot mod id', 404); + return serializeBotMod(reloaded); +} From ecff733d47109d1b07747a0f60867d77a3c6b784 Mon Sep 17 00:00:00 2001 From: V0W4N Date: Wed, 23 Sep 2026 14:33:45 +0300 Subject: [PATCH 2/2] fix lint --- src/server/routes/v2/admin/botMods.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/routes/v2/admin/botMods.ts b/src/server/routes/v2/admin/botMods.ts index 7b918e70..28119b77 100644 --- a/src/server/routes/v2/admin/botMods.ts +++ b/src/server/routes/v2/admin/botMods.ts @@ -46,7 +46,7 @@ router.get( const result = await listBotMods({ q: q || undefined, filter: parseBotModListFilter(req.query.filter), - modId, + modId: modId ?? undefined, offset: parseBotModListOffset(req.query.offset), limit: parseBotModListLimit(req.query.limit), });