This commit is contained in:
Christopher Cookman 2026-01-17 16:05:33 -07:00
commit a52e80c62e
18 changed files with 4912 additions and 0 deletions

142
.gitignore vendored Normal file
View file

@ -0,0 +1,142 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.*
!.env.example
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
.output
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Sveltekit cache directory
.svelte-kit/
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# Firebase cache directory
.firebase/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v3
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# Vite files
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
.vite/
bansync.db

29
banHandler.js Normal file
View file

@ -0,0 +1,29 @@
const colors = require('colors');
module.exports = async (ban, db, client) => {
// Fetch perms this guild has
const guild = await client.guilds.fetch(ban.guild.id);
db.all("SELECT list_id, auto_add FROM perms WHERE entity_id = ? AND entity_type = 'guild'", [ban.guild.id], async (err, lists) => {
;
for (const entry of lists) {
const list = await db.get("SELECT * FROM lists WHERE id = ?", [entry.list_id]);
if (!list) {
console.log(`${colors.red('[ERROR]')} List not found for id ${entry.list_id}. How'd we get here?`);
continue;
}
if (entry.auto_add) {
try {
const existingBan = await db.get("SELECT * FROM bans WHERE list_id = ? AND user_id = ?", [list.id, ban.user.id]);
if (existingBan) {
// Already banned on this list
continue;
}
await db.run("INSERT INTO bans (list_id, user_id, reason, banned_by) VALUES (?, ?, ?, ?)", [list.id, ban.user.id, `${ban.reason || 'No reason provided'} | Sync from ${guild.id}`, `${ban.guild.id} (Auto-add)`]);
} catch (e) {
console.log(`[ERROR] Could not auto-add ban for user ${ban.user.id} in guild ${guild.id}:`, e);
}
}
}
});
};

239
commands.js Normal file
View file

@ -0,0 +1,239 @@
const Discord = require('discord.js');
module.exports = [
{
"name": "list",
"description": "Manage ban lists.",
"options": [
{
"type": Discord.ApplicationCommandOptionType.Subcommand,
"name": "view",
"description": "View your ban lists."
},
{
"type": Discord.ApplicationCommandOptionType.Subcommand,
"name": "create",
"description": "Create a new ban list.",
"options": [
{
"type": Discord.ApplicationCommandOptionType.String,
"name": "name",
"description": "The name of the ban list.",
"required": true
}
]
},
{
"type": Discord.ApplicationCommandOptionType.Subcommand,
"name": "delete",
"description": "Delete an existing ban list.",
"options": [
{
"type": Discord.ApplicationCommandOptionType.String,
"name": "list",
"description": "The ban list to delete.",
"required": true,
"autocomplete": true
}
]
},
{
"type": Discord.ApplicationCommandOptionType.Subcommand,
"name": "transfer",
"description": "Transfer ownership of a ban list.",
"options": [
{
"type": Discord.ApplicationCommandOptionType.String,
"name": "list",
"description": "The ban list to transfer.",
"required": true,
"autocomplete": true
},
{
"type": Discord.ApplicationCommandOptionType.User,
"name": "to",
"description": "The user to transfer ownership to.",
"required": true
}
]
},
{
"type": Discord.ApplicationCommandOptionType.Subcommand,
"name": "import",
"description": "Import bans from the current server to a ban list.",
"options": [
{
"type": Discord.ApplicationCommandOptionType.String,
"name": "list",
"description": "The ban list to import bans into.",
"required": true,
"autocomplete": true
}
]
},
{
"type": Discord.ApplicationCommandOptionType.Subcommand,
"name": "allowguild",
"description": "Allow a guild to use your ban list.",
"options": [
{
"type": Discord.ApplicationCommandOptionType.String,
"name": "list",
"description": "The ban list to allow the guild to use.",
"required": true,
"autocomplete": true
},
{
"type": Discord.ApplicationCommandOptionType.String,
"name": "guild_id",
"description": "The ID of the guild to allow. (Default: Current Guild)",
"required": false
}
]
},
{
"type": Discord.ApplicationCommandOptionType.Subcommand,
"name": "revokeguild",
"description": "Revoke a guild's access to your ban list.",
"options": [
{
"type": Discord.ApplicationCommandOptionType.String,
"name": "list",
"description": "The ban list to revoke the guild's access from.",
"required": true,
"autocomplete": true
},
{
"type": Discord.ApplicationCommandOptionType.String,
"name": "guild_id",
"description": "The ID of the guild to revoke. (Default: Current Guild)",
"required": false
}
]
}
]
},
{
"name": "ban",
"description": "Add a user to the ban list.",
"default_member_permissions": Discord.PermissionFlagsBits.BanMembers.toString(),
"options": [
{
"type": Discord.ApplicationCommandOptionType.User,
"name": "user",
"description": "The user to ban.",
"required": true
},
{
"type": Discord.ApplicationCommandOptionType.String,
"name": "list",
"description": "The ban list to add the user to.",
"required": true,
"autocomplete": true
},
{
"type": Discord.ApplicationCommandOptionType.String,
"name": "reason",
"description": "The reason for the ban.",
"required": false
}
]
},
{
"name": "unban",
"description": "Remove a user from the ban list.",
"default_member_permissions": Discord.PermissionFlagsBits.BanMembers.toString(),
"options": [
{
"type": Discord.ApplicationCommandOptionType.User,
"name": "user",
"description": "The user to unban.",
"required": true
},
{
"type": Discord.ApplicationCommandOptionType.String,
"name": "list",
"description": "The ban list to remove the user from.",
"required": true,
"autocomplete": true
}
]
},
{
"name": "guild",
"description": "Manage your guild's ban list settings.",
"options": [
{
"type": Discord.ApplicationCommandOptionType.Subcommand,
"name": "join",
"description": "Join a ban list.",
"options": [
{
"type": Discord.ApplicationCommandOptionType.String,
"name": "list_id",
"description": "The ID of the ban list to join.",
"required": true
}
]
},
{
"type": Discord.ApplicationCommandOptionType.Subcommand,
"name": "leave",
"description": "Leave a ban list.",
"options": [
{
"type": Discord.ApplicationCommandOptionType.String,
"name": "list_id",
"description": "The ID of the ban list to leave.",
"required": true,
"autocomplete": true
}
]
},
{
"type": Discord.ApplicationCommandOptionType.Subcommand,
"name": "autoadd",
"description": "Toggle automatic adding of bans from the list to this guild.",
"options": [
{
"type": Discord.ApplicationCommandOptionType.Boolean,
"name": "enabled",
"description": "Enable or disable auto-adding bans.",
"required": true
},
{
"type": Discord.ApplicationCommandOptionType.String,
"name": "list_id",
"description": "The ID of the ban list to enable/disable auto-adding from.",
"required": true,
"autocomplete": true
}
]
},
{
"type": Discord.ApplicationCommandOptionType.Subcommand,
"name": "autoremove",
"description": "Toggle automatic removal of bans from the list to this guild.",
"options": [
{
"type": Discord.ApplicationCommandOptionType.Boolean,
"name": "enabled",
"description": "Enable or disable auto-removal of bans.",
"required": true
},
{
"type": Discord.ApplicationCommandOptionType.String,
"name": "list_id",
"description": "The ID of the ban list to enable/disable auto-removal from.",
"required": true,
"autocomplete": true
}
]
},
{
"type": Discord.ApplicationCommandOptionType.Subcommand,
"name": "sync",
"description": "Manually sync bans from all joined lists to this guild."
}
]
}
];

118
index.js Normal file
View file

@ -0,0 +1,118 @@
require("dotenv").config({ quiet: true });
const colors = require('colors');
const fs = require('fs').promises;
const path = require('path');
const sqlite3 = require('sqlite3').verbose();
const dbPath = path.join(__dirname, process.env.DB_PATH || 'bansync.db');
const cron = require('node-cron');
const { Client, REST, DiscordAPIError, InteractionType, Routes } = require('discord.js');
// const s3 = require("./s3"); // This *was* for S3 backups and file uploads for images and whatnot. But i figured it was overkill and cut it. s3.js still exists because it's a good base.
// Initialize SQLite database
const db = new sqlite3.Database(dbPath, (sqlErr) => {
if (sqlErr) {
console.error(`${colors.red('[ERROR]')} Failed to connect to the database:`, sqlErr);
process.exit(1); // Can't proceed without DB connection
} else {
console.log(`${colors.green('[INFO]')} Connected to the SQLite database at ${dbPath}`);
}
});
const init = () => {
require('./migrations')(db).then(async () => {
// if (process.env.S3_DB_BACKUP == true) {
// s3.uploadFile('db_backup.db', await fs.readFile(dbPath)).then(() => {
// console.log(`${colors.cyan('[INFO]')} Initial DB backup uploaded to S3.`);
// let schedule = process.env.S3_DB_BACKUP_SCHEDULE || '0 0 * * *'; // Default to every day
// if (!cron.validate(schedule)) {
// console.log(`${colors.red('[ERROR]')} Invalid cron schedule for S3_DB_BACKUP_SCHEDULE. Using default '0 0 * * *'.`);
// schedule = '0 0 * * *';
// }
// cron.schedule(schedule, async () => {
// try {
// const data = await fs.readFile(dbPath);
// await s3.uploadFile('db_backup.db', data);
// console.log(`${colors.cyan('[INFO]')} Backed up DB to S3.`);
// } catch (err) {
// console.log(`${colors.red('[ERROR]')} Failed to back up DB to S3:`, err);
// }
// });
// console.log(`${colors.cyan('[INFO]')} Scheduled DB backups to S3.`);
// });
// } // Cut feature. Overkill lol
discord_setup();
});
};
const discord_setup = () => {
const client = new Client({
intents: ["GuildBans", "Guilds", "GuildModeration"]
});
const banHandler = require('./banHandler');
const unbanHandler = require('./unbanHandler');
client.on('guildBanRemove', (ban) => {
unbanHandler(ban, db, client);
});
client.on('guildBanAdd', (ban) => {
banHandler(ban, db, client);
});
client.on('interactionCreate', async interaction => {
try {
switch (interaction.type) {
case InteractionType.ApplicationCommand:
require(path.join(__dirname, 'interactions', 'commands', interaction.commandName))(interaction, db, client);
break;
case InteractionType.MessageComponent:
require(path.join(__dirname, 'interactions', 'components', interaction.customId))(interaction, db, client);
break;
case InteractionType.ApplicationCommandAutocomplete:
require(path.join(__dirname, 'interactions', 'autocomplete', interaction.commandName))(interaction, db, client);
break;
default:
console.log(`${colors.yellow('[WARN]')} Unknown interaction type: ${interaction.type}`);
interaction.reply({ content: 'Unknown interaction type.', ephemeral: true });
break;
}
} catch (error) {
console.error(`${colors.red('[ERROR]')} Error handling interaction:`, error);
interaction.reply({ content: 'An error occurred while processing your request.', ephemeral: true });
}
});
client.once('clientReady', () => {
console.log(`${colors.green('[DISCORD]')} Bot is online as ${client.user.displayName}`);
// Init application commands (global)
const rest = new REST().setToken(process.env.DISCORD_TOKEN);
(async () => {
try {
console.log(`${colors.cyan('[DISCORD]')} Refreshing application (/) commands.`);
await rest.put(Routes.applicationCommands(client.user.id), {
body: require('./commands')
});
console.log(`${colors.green('[DISCORD]')} Successfully reloaded application (/) commands.`);
} catch (error) {
console.error(`${colors.red('[DISCORD]')} Error reloading application (/) commands:`, error);
}
})();
// Sync bans on startup
const sync = require('./sync');
sync(client, db);
// Sync every 10 minutes
cron.schedule(process.env.SYNC_SCHEDULE, () => {
sync(client, db);
});
});
client.login(process.env.DISCORD_TOKEN).catch(err => {
console.log(`${colors.red('[DISCORD]')} Failed to log in:`, err);
process.exit(1); // Can't proceed without Discord connection
});
};
init();

View file

@ -0,0 +1,26 @@
// Autocomplete for ban.js list option
const colors = require('colors');
module.exports = async (interaction, db, client) => {
if (interaction.commandName !== 'ban') return;
const focusedOption = interaction.options.getFocused(true);
if (focusedOption.name !== 'list') return;
const query = focusedOption.value;
try {
db.all(`SELECT id, name FROM lists WHERE id IN (${Array(listIds.length).fill('?').join(',')}) AND name LIKE ? LIMIT 25`, [...listIds, `%${query}%`], async (err, lists) => {
const choices = lists.map(list => ({
name: list.name,
value: list.id.toString()
}));
await interaction.respond(choices);
});
} catch (error) {
console.error(`${colors.red('[ERROR]')} Error handling autocomplete for ban command:`, error);
await interaction.respond([]);
}
};

View file

@ -0,0 +1,33 @@
// Autocomplete for lists on /guild subcommands
const colors = require('colors');
module.exports = async (interaction, db, client) => {
if (interaction.commandName !== 'guild') return;
const focusedOption = interaction.options.getFocused(true);
if (focusedOption.name !== 'list_id') return;
const query = focusedOption.value || "";
try {
// Fetch lists joined by the guild
db.all(`SELECT l.id, l.name FROM lists l
JOIN guilds gl ON l.id = gl.list_id
WHERE gl.guild_id = ? AND l.name LIKE ? LIMIT 25`,
[interaction.guild.id, `%${query}%`], async (err, lists) => {
if (err) {
console.error(`${colors.red('[ERROR]')} Error fetching lists for autocomplete:`, err);
return await interaction.respond([]);
}
const choices = lists.map(list => ({
name: list.name,
value: list.id.toString()
}));
await interaction.respond(choices);
});
} catch (error) {
console.error(`${colors.red('[ERROR]')} Error handling autocomplete for guild command:`, error);
await interaction.respond([]);
}
};

View file

@ -0,0 +1,27 @@
// Autocomplete handler for /list delete and /list transfer commands
const colors = require('colors');
module.exports = async (interaction, db, client) => {
if (interaction.commandName !== 'list') return;
const focusedOption = interaction.options.getFocused(true);
if (focusedOption.name !== 'list') return;
const query = focusedOption.value || "";
try {
// Fetch lists owned by the user
db.all(`SELECT id, name FROM lists WHERE owner_id = ? AND name LIKE ? LIMIT 25`, [interaction.user.id, `%${query}%`], async (err, lists) => {
const choices = lists.map(list => ({
name: list.name,
value: list.id.toString()
}));
await interaction.respond(choices);
});
} catch (error) {
console.error(`${colors.red('[ERROR]')} Error handling autocomplete for list command:`, error);
await interaction.respond([]);
}
};

View file

@ -0,0 +1,7 @@
const colors = require('colors');
module.exports = async (interaction, db) => {
if (interaction.commandName !== 'ban') return;
await interaction.reply({content: "Not Implimented Yet. (Try just banning people on your guild lol)", ephemeral: true});
}

View file

@ -0,0 +1,143 @@
// Guild subcommands
const colors = require('colors');
module.exports = async (interaction, db, client) => {
if (interaction.commandName !== 'guild') return;
const subcommand = interaction.options.getSubcommand();
switch (subcommand) {
case 'autoadd':
{
const listId = interaction.options.getString('list_id');
const enabled = interaction.options.getBoolean('enabled');
// Check if the guild is joined to the list
db.get(`SELECT * FROM guilds WHERE guild_id = ? AND list_id = ?`, [interaction.guild.id, listId], (err, row) => {
if (err) {
console.error(err);
return interaction.reply({ content: '❌ An error occurred while accessing the database.', ephemeral: true });
}
if (!row) {
return interaction.reply({ content: '❌ This guild is not joined to the specified ban list.', ephemeral: true });
}
// Update auto_add setting
db.run(`UPDATE guilds SET auto_add = ? WHERE guild_id = ? AND list_id = ?`, [enabled ? 1 : 0, interaction.guild.id, listId], (err) => {
if (err) {
console.error(err);
return interaction.reply({ content: '❌ An error occurred while updating the database.', ephemeral: true });
}
interaction.reply({ content: `✅ Auto-add from the ban list has been ${enabled ? 'enabled' : 'disabled'}.`, ephemeral: true });
});
});
}
break;
case 'autoremove':
{
const listId = interaction.options.getString('list_id');
const enabled = interaction.options.getBoolean('enabled');
// Check if the guild is joined to the list
db.get(`SELECT * FROM guilds WHERE guild_id = ? AND list_id = ?`, [interaction.guild.id, listId], (err, row) => {
if (err) {
console.error(err);
return interaction.reply({ content: '❌ An error occurred while accessing the database.', ephemeral: true });
}
if (!row) {
return interaction.reply({ content: '❌ This guild is not joined to the specified ban list.', ephemeral: true });
}
// Update auto_remove setting
db.run(`UPDATE guilds SET auto_remove = ? WHERE guild_id = ? AND list_id = ?`, [enabled ? 1 : 0, interaction.guild.id, listId], (err) => {
if (err) {
console.error(err);
return interaction.reply({ content: '❌ An error occurred while updating the database.', ephemeral: true });
}
interaction.reply({ content: `✅ Auto-remove from the ban list has been ${enabled ? 'enabled' : 'disabled'}.`, ephemeral: true });
});
});
}
break;
case "join":
{
// then fucking impliment it
const listId = interaction.options.getString('list_id');
// Check if the list exists
db.get(`SELECT * FROM lists WHERE id = ?`, [listId], (err, list) => {
if (err) {
console.error(err);
return interaction.reply({ content: '❌ An error occurred while accessing the database.', ephemeral: true });
}
if (!list) {
return interaction.reply({ content: '❌ The specified ban list does not exist.', ephemeral: true });
}
// Check if the guild is already joined to the list
db.get(`SELECT * FROM guilds WHERE guild_id = ? AND list_id = ?`, [interaction.guild.id, listId], (err, row) => {
if (err) {
console.error(err);
return interaction.reply({ content: '❌ An error occurred while accessing the database.', ephemeral: true });
}
if (row) {
return interaction.reply({ content: '❌ This guild is already joined to the specified ban list.', ephemeral: true });
}
// Join the guild to the list
db.run(`INSERT INTO guilds (guild_id, list_id, auto_add, auto_remove) VALUES (?, ?, 0, 0)`, [interaction.guild.id, listId], (err) => {
if (err) {
console.error(err);
return interaction.reply({ content: '❌ An error occurred while updating the database.', ephemeral: true });
}
interaction.reply({ content: `✅ This guild has successfully joined the ban list "${list.name}".`, ephemeral: true });
});
});
});
}
break;
case "leave":
{
const listId = interaction.options.getString('list_id');
// Check if the guild is joined to the list
db.get(`SELECT * FROM guilds WHERE guild_id = ? AND list_id = ?`, [interaction.guild.id, listId], (err, row) => {
if (err) {
console.error(err);
return interaction.reply({ content: '❌ An error occurred while accessing the database.', ephemeral: true });
}
if (!row) {
return interaction.reply({ content: '❌ This guild is not joined to the specified ban list.', ephemeral: true });
}
// Leave the guild from the list
db.run(`DELETE FROM guilds WHERE guild_id = ? AND list_id = ?`, [interaction.guild.id, listId], (err) => {
if (err) {
console.error(err);
return interaction.reply({ content: '❌ An error occurred while updating the database.', ephemeral: true });
}
interaction.reply({ content: `✅ This guild has successfully left the ban list.`, ephemeral: true });
});
});
}
break;
case 'sync':
{
// Manual sync logic to be implemented
await interaction.reply({ content: '🔄 Manual sync initiated. (Not yet implemented)', ephemeral: true });
}
break;
default:
await interaction.reply({ content: '❌ Unknown subcommand.', ephemeral: true });
}
};

View file

@ -0,0 +1,263 @@
// List management (create, delete, and transfer)
const colors = require("colors")
const crypto = require("crypto");
module.exports = async (interaction, db) => {
if (interaction.commandName !== 'list') return;
try {
switch (interaction.options.getSubcommand()) {
case 'create':
{
const id = crypto.randomBytes(16).toString("hex");
const name = interaction.options.getString("name");
db.run(
"INSERT INTO lists (id, name, owner_id) VALUES (?, ?, ?)",
[id, name, interaction.user.id],
async function (err) {
if (err) {
console.error(err);
return interaction.reply({
content: "❌ Failed to create ban list.",
ephemeral: true
});
}
await interaction.reply({
content: `✅ Ban list **${name}** created with ID \`${id}\`.`,
ephemeral: true
});
}
);
}
break;
case 'delete':
{
const listId = interaction.options.getString('list');
// Verify ownership
db.get("SELECT * FROM lists WHERE id = ?", [listId], async (err, list) => {
if (err) {
console.error(err);
await interaction.reply({ content: `❌ Database error occurred.`, ephemeral: true });
return;
}
if (!list) {
await interaction.reply({ content: `❌ Ban list with ID \`${listId}\` does not exist.`, ephemeral: true });
return;
}
if (list.owner_id !== interaction.user.id) {
await interaction.reply({ content: `❌ You do not own ban list **${list.name}**.`, ephemeral: true });
return;
}
// Delete the list
db.run("DELETE FROM lists WHERE id = ?", [listId], async (err) => {
if (err) {
console.error(err);
await interaction.reply({ content: `❌ Failed to delete ban list.`, ephemeral: true });
return;
}
await interaction.reply({ content: `✅ Ban list **${list.name}** has been deleted.`, ephemeral: true });
});
});
}
break;
case 'transfer':
{
const listId = interaction.options.getString('list');
const newOwner = interaction.options.getUser('to');
if (!newOwner) {
await interaction.reply({ content: '❌ Target user not found.', ephemeral: true });
return;
}
// Verify ownership
db.get("SELECT * FROM lists WHERE id = ?", [listId], async (err, list) => {
if (err) {
console.error(err);
await interaction.reply({ content: `❌ Database error occurred.`, ephemeral: true });
return;
}
if (!list) {
await interaction.reply({ content: `❌ Ban list with ID \`${listId}\` does not exist.`, ephemeral: true });
return;
}
if (list.owner_id !== interaction.user.id) {
await interaction.reply({ content: `❌ You do not own ban list **${list.name}**.`, ephemeral: true });
return;
}
// Transfer ownership
db.run("UPDATE lists SET owner_id = ? WHERE id = ?", [newOwner.id, listId], async (err) => {
if (err) {
console.error(err);
await interaction.reply({ content: `❌ Failed to transfer ownership.`, ephemeral: true });
return;
}
await interaction.reply({ content: `✅ Ban list **${list.name}** ownership has been transferred to ${newOwner.tag}.`, ephemeral: true });
});
});
}
break;
case "import":
{
const importBans = (list) => {
// Fetch bans from the guild
interaction.guild.bans.fetch().then(bans => {
let importedCount = 0;
bans.forEach(async ban => {
console.log(`Importing ban for user ${ban.user.id} into list ${list.id}`);
await db.run("INSERT OR IGNORE INTO bans (user_id, list_id, reason, banned_by) VALUES (?, ?, ?, ?)", [ban.user.id, list.id, ban.reason || '', "System"], (err) => {
if (err) {
console.error(err);
} else {
importedCount++;
}
});
});
interaction.reply({ content: `✅ Imported ${importedCount} bans from this server into ban list **${list.name}**.`, ephemeral: true });
}).catch(err => {
console.error(err);
interaction.reply({ content: '❌ Failed to fetch bans from this server.', ephemeral: true });
});
};
const listId = interaction.options.getString('list');
// Verify ownership or guild has access (either works)
db.get("SELECT * FROM lists WHERE id = ?", [listId], async (err, list) => {
if (err) {
console.error(err);
await interaction.reply({ content: `❌ Database error occurred.`, ephemeral: true });
return;
}
if (!list) {
await interaction.reply({ content: `❌ Ban list with ID \`${listId}\` does not exist.`, ephemeral: true });
return;
}
// Check ownership
if (list.owner_id !== interaction.user.id) {
// Check if guild has access in perms table (guild will exist here if it has perms to write)
db.get("SELECT * FROM list_permissions WHERE list_id = ? AND guild_id = ?", [listId, interaction.guild.id], async (err, perm) => {
if (err) {
console.error(err);
await interaction.reply({ content: `❌ Database error occurred.`, ephemeral: true });
return;
}
if (!perm) {
await interaction.reply({ content: `❌ You do not own ban list **${list.name}** and this server does not have permission to modify it.`, ephemeral: true });
return;
}
// Guild has permission, proceed
importBans(list);
});
} else {
// User owns the list, proceed
importBans(list);
}
});
}
break;
case "allowguild":
{
const listId = interaction.options.getString('list');
const guildId = interaction.options.getString('guild_id') || interaction.guild.id;
// Verify ownership
db.get("SELECT * FROM lists WHERE id = ?", [listId], async (err, list) => {
if (err) {
console.error(err);
await interaction.reply({ content: `❌ Database error occurred.`, ephemeral: true });
return;
}
if (!list) {
await interaction.reply({ content: `❌ Ban list with ID \`${listId}\` does not exist.`, ephemeral: true });
return;
}
if (list.owner_id !== interaction.user.id) {
await interaction.reply({ content: `❌ You do not own ban list **${list.name}**.`, ephemeral: true });
return;
}
// Grant
db.run("INSERT OR IGNORE INTO perms (list_id, entity_id, entity_type) VALUES (?, ?, 'guild')", [listId, guildId], async (err) => {
if (err) {
console.error(err);
await interaction.reply({ content: `❌ Failed to allow guild.`, ephemeral: true });
return;
}
await interaction.reply({ content: `✅ Guild \`${guildId}\` has been allowed to use ban list **${list.name}**.`, ephemeral: true });
});
});
}
break;
case "revokeguild":
{
const listId = interaction.options.getString('list');
const guildId = interaction.options.getString('guild_id') || interaction.guild.id;
// Verify ownership
db.get("SELECT * FROM lists WHERE id = ?", [listId], async (err, list) => {
if (err) {
console.error(err);
await interaction.reply({ content: `❌ Database error occurred.`, ephemeral: true });
return;
}
if (!list) {
await interaction.reply({ content: `❌ Ban list with ID \`${listId}\` does not exist.`, ephemeral: true });
return;
}
if (list.owner_id !== interaction.user.id) {
await interaction.reply({ content: `❌ You do not own ban list **${list.name}**.`, ephemeral: true });
return;
}
// Revoke
db.run("DELETE FROM perms WHERE list_id = ? AND guild_id = ?", [listId, guildId], async (err) => {
if (err) {
console.error(err);
await interaction.reply({ content: `❌ Failed to revoke guild.`, ephemeral: true });
return;
}
await interaction.reply({ content: `✅ Guild \`${guildId}\` has been revoked access to ban list **${list.name}**.`, ephemeral: true });
});
});
}
break;
case "view":
{
// Fetch all lists owned by the user
db.all("SELECT * FROM lists WHERE owner_id = ?", [interaction.user.id], async (err, lists) => {
if (err) {
console.error(err);
await interaction.reply({ content: `❌ Database error occurred.`, ephemeral: true });
return;
}
if (lists.length === 0) {
await interaction.reply({ content: '❌ You do not own any ban lists.', ephemeral: true });
return;
}
let response = '📋 **Your Ban Lists:**\n';
lists.forEach(list => {
response += `• **${list.name}** (ID: \`${list.id}\`)\n`;
});
await interaction.reply({ content: response, ephemeral: true });
});
}
break;
default:
await interaction.reply({ content: '❌ Unknown subcommand.', ephemeral: true });
}
} catch (error) {
console.error(`${colors.red('[ERROR]')} Error handling list command:`, error);
await interaction.reply({ content: '❌ An error occurred while processing your request.', ephemeral: true });
}
};

56
migrations.js Normal file
View file

@ -0,0 +1,56 @@
const fs = require('fs').promises;
const path = require('path');
const util = require('util');
const colors = require('colors');
module.exports = (db) => {
const run = util.promisify(db.run.bind(db));
const get = util.promisify(db.get.bind(db));
const exec = util.promisify(db.exec.bind(db));
return new Promise((resolve, reject) => {
(async () => {
try {
await run(`CREATE TABLE IF NOT EXISTS migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
);`);
const migrationsDir = path.join(__dirname, 'migrations');
let files;
try {
files = await fs.readdir(migrationsDir);
} catch (e) {
if (e.code === 'ENOENT') return resolve(); // no migrations directory
throw e;
}
files = files.filter(f => path.extname(f).toLowerCase() === '.sql').sort();
for (const file of files) {
const name = file;
const already = await get('SELECT 1 FROM migrations WHERE name = ?', [name]);
if (already) continue;
const sql = await fs.readFile(path.join(migrationsDir, file), 'utf8');
await exec('BEGIN');
try {
await exec(sql);
console.log(`${colors.yellow('[MIGRATION]')} Applied migration: ${name}`);
await run('INSERT INTO migrations (name) VALUES (?)', [name]);
await exec('COMMIT');
} catch (e) {
console.error(`${colors.red('[MIGRATION]')} Failed migration: ${name}`);
await exec('ROLLBACK');
throw e;
}
}
console.log(`${colors.green('[MIGRATION]')} All migrations applied.`);
resolve();
} catch (err) {
reject(err);
}
})();
});
}

View file

@ -0,0 +1,69 @@
-- migrations/001_init_tables.sqlite.sql
-- SQLite-compatible rewrite of migrations/001_init_tables.sql
PRAGMA foreign_keys = ON;
-- Table: lists
CREATE TABLE IF NOT EXISTS lists (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL CHECK (length(trim(name)) > 0 AND length(name) <= 24),
owner_id TEXT NOT NULL,
private INTEGER NOT NULL DEFAULT 1, -- 0 = false, 1 = true
created_at DATETIME NOT NULL DEFAULT (CURRENT_TIMESTAMP),
updated_at DATETIME NOT NULL DEFAULT (CURRENT_TIMESTAMP)
);
-- Trigger to keep updated_at current.
-- Uses an AFTER UPDATE that only performs the extra UPDATE when the client
-- didn't explicitly change updated_at; the inner UPDATE won't recurse
-- because the WHEN clause will be false on the second invocation.
CREATE TRIGGER IF NOT EXISTS trg_lists_updated_at
AFTER UPDATE ON lists
FOR EACH ROW
WHEN NEW.updated_at = OLD.updated_at
BEGIN
UPDATE lists SET updated_at = CURRENT_TIMESTAMP WHERE id = OLD.id;
END;
-- Index to quickly find lists by owner
CREATE INDEX IF NOT EXISTS idx_lists_owner_id ON lists(owner_id);
-- Table: guilds
CREATE TABLE IF NOT EXISTS guilds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guild_id TEXT NOT NULL,
list_id TEXT NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
auto_add INTEGER NOT NULL DEFAULT 1, -- 0 = false, 1 = true
auto_remove INTEGER NOT NULL DEFAULT 0, -- 0 = false, 1 = true
created_at DATETIME NOT NULL DEFAULT (CURRENT_TIMESTAMP)
);
-- Table: Bans
CREATE TABLE IF NOT EXISTS bans (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
user_id TEXT NOT NULL,
reason TEXT,
list_id TEXT NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
banned_by TEXT NOT NULL,
banned_at DATETIME NOT NULL DEFAULT (CURRENT_TIMESTAMP)
);
-- Table: Perms (Users/Guilds with write access to a list that aren't the owner)
CREATE TABLE IF NOT EXISTS perms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
list_id TEXT NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
entity_id TEXT NOT NULL, -- user id or guild id
entity_type TEXT NOT NULL CHECK (entity_type IN ('user', 'guild')),
auto_add INTEGER NOT NULL DEFAULT 0, -- 0 = false, 1 = true; Whether this entitys bans should be auto-added to the list (only for guilds)
auto_remove INTEGER NOT NULL DEFAULT 0, -- 0 = false, 1 = true; Whether this entitys unbans should be auto-removed from the list (only for guilds)
created_at DATETIME NOT NULL DEFAULT (CURRENT_TIMESTAMP)
);
-- Ensure a guild can't be attached to the same list twice
CREATE UNIQUE INDEX IF NOT EXISTS ux_guilds_guildid_listid ON guilds(guild_id, list_id);
-- Helpful index to look up guild rows by guild_id
CREATE INDEX IF NOT EXISTS idx_guilds_guild_id ON guilds(guild_id);
-- Example inserts
-- INSERT INTO lists (name, owner_id, private) VALUES ('Example List', 'owner123', 1);
-- INSERT INTO guilds (guild_id, list_id, auto_add) VALUES ('guild123', (SELECT id FROM lists WHERE name = 'Example List'), 1);

3512
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

22
package.json Normal file
View file

@ -0,0 +1,22 @@
{
"name": "bansync",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"@aws-sdk/client-s3": "^3.970.0",
"colors": "^1.4.0",
"discord.js": "^14.25.1",
"dotenv": "^17.2.3",
"mariadb": "^3.4.5",
"node-cron": "^4.2.1",
"sqlite3": "^5.1.7"
}
}

88
s3.js Normal file
View file

@ -0,0 +1,88 @@
const s3 = require("@aws-sdk/client-s3");
const { S3Client, PutObjectCommand, GetObjectCommand } = s3;
const s3Client = new S3Client({
region: process.env.AWS_REGION,
endpoint: process.env.S3_ENDPOINT,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
},
forcePathStyle: process.env.S3_FORCE_PATH_STYLE === 'true',
});
module.exports = {};
module.exports.uploadFile = async (key, body) => {
return new Promise(async (resolve, reject) => {
const command = new PutObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: key,
Body: body,
});
try {
await s3Client.send(command);
resolve();
} catch (err) {
reject(err);
}
});
};
module.exports.downloadFile = async (key) => {
return new Promise(async (resolve, reject) => {
const command = new GetObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: key,
});
try {
const response = await s3Client.send(command);
const stream = response.Body;
const chunks = [];
try {
for await (const chunk of stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
resolve(Buffer.concat(chunks));
} catch (streamErr) {
reject(streamErr);
}
} catch (err) {
reject(err);
}
});
};
// Function to see if file/folder exists
module.exports.fileExists = async (key) => {
return new Promise(async (resolve, reject) => {
try {
const command = new GetObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: key,
});
await s3Client.send(command);
resolve(true);
} catch (err) {
resolve(false);
}
});
};
// List files in a folder
module.exports.listFiles = async (prefix) => {
const { ListObjectsV2Command } = require("@aws-sdk/client-s3");
return new Promise(async (resolve, reject) => {
const command = new ListObjectsV2Command({
Bucket: process.env.S3_BUCKET,
Prefix: prefix,
});
try {
const response = await s3Client.send(command);
const files = response.Contents ? response.Contents.map(item => item.Key) : [];
resolve(files);
} catch (err) {
reject(err);
}
});
};

91
sync.js Normal file
View file

@ -0,0 +1,91 @@
const colors = require('colors');
const { PermissionFlagsBits } = require('discord.js');
module.exports = (client, db) => {
// Handle auto_add guilds
db.all("SELECT guild_id, list_id, auto_add FROM guilds WHERE auto_add = 1", async (err, rows) => {
if (err) {
console.log(`${colors.red('[ERROR]')} Could not query guilds for auto_add:`, err);
return;
}
if (rows) {
for (const row of rows) {
// Check we can access guild and have ban perms
const guild = await client.guilds.fetch(row.guild_id);
if (!guild) {
console.log(`[ERROR] Could not find guild: ${row.guild_id}`); // We don't remove if they re-add the bot
continue;
}
const member = await guild.members.fetch(client.user.id);
if (!member || !member.permissions.has(PermissionFlagsBits.BanMembers)) {
console.log(`${colors.red('[ERROR]')} Bot does not have ban permissions in guild: ${row.guild_id}`);
continue;
}
try {
const listId = row.list_id;
const guildId = row.guild_id;
const guild = await client.guilds.fetch(guildId);
const member = await guild.members.fetch(client.user.id);
const guildBans = await guild.bans.fetch();
db.all("SELECT user_id, reason FROM bans WHERE list_id = ?", [listId], async (err, bans) => {
for (const ban of bans) {
if (!guildBans.has(ban.user_id)) {
await guild.members.ban(ban.user_id, { reason: `Auto-synced ban from list ${listId}: ${ban.reason}` });
if (process.env.ENVIRONMENT !== 'production') {
console.log(`[AUTO-ADD] Banned user ${ban.user_id} in guild ${guildId} from list ${listId}`);
};
}
}
});
} catch (e) {
console.log(`[ERROR] Could not sync bans for guild ${row.guild_id}:`, e);
}
}
}
});
// Handle auto_remove guilds
db.all("SELECT guild_id, list_id, auto_remove FROM guilds WHERE auto_remove = 1", async (err, rows) => {
if (err) {
console.log(`${colors.red('[ERROR]')} Could not query guilds for auto_remove:`, err);
return;
}
if (rows) {
for (const row of rows) {
// Check we can access guild and have ban perms
const guild = await client.guilds.fetch(row.guild_id);
if (!guild) {
console.log(`[ERROR] Could not find guild: ${row.guild_id}`); // We don't remove if they re-add the bot
continue;
}
const member = await guild.members.fetch(client.user.id);
if (!member || !member.permissions.has(PermissionFlagsBits.BanMembers)) {
console.log(`${colors.red('[ERROR]')} Bot does not have ban permissions in guild: ${row.guild_id}`);
continue;
}
try {
const listId = row.list_id;
const guildId = row.guild_id;
db.all("SELECT user_id FROM bans WHERE list_id = ?", [listId], async (err, bans) => {
if (err) {
console.error(err);
return;
}
const guildBans = await guild.bans.fetch();
// Anybody not in the bans list should be unbanned
for (const [bannedUserId, banInfo] of guildBans) {
if (!bans.find(ban => ban.user_id === bannedUserId)) {
await guild.members.unban(bannedUserId, `Auto-removed ban from list ${listId}`);
if (process.env.ENVIRONMENT !== 'production') {
console.log(`[AUTO-REMOVE] Unbanned user ${bannedUserId} in guild ${guildId} from list ${listId}`);
};
}
}
});
} catch (e) {
console.log(`[ERROR] Could not sync unbans for guild ${row.guild_id}:`, e);
}
}
}
});
}

18
theory.md Normal file
View file

@ -0,0 +1,18 @@
# Discord Ban Sync Bot
Here's the plan, Ban Lists
- Allow anybody to install the bot to their user or guild.
- Anybody can create a "ban-list" with a unique ID, name, and list of users/guilds that can modify it.
- Allow user to make list public/private? (tl;dr private mode only users allowed to modify can link the list to a guild and use it. Public ones all you'd need is the ID)
## DB Struct
- lists
- id: default uuid(); primary
- name: varchar(24); do sanitizing, this will be user defined
- owner_id: varchar(24); User Snowflake of person who currently owns list. Default is creator, might add transfer feature?
- private: bool, default true
- guilds
- id: int, autoinc, primary
- guild_id: guild id
- list_id: list id (ref to lists.id)
- auto_add: Automatically sync new bans that are added to the db, otherwise require manual sync

29
unbanHandler.js Normal file
View file

@ -0,0 +1,29 @@
const colors = require('colors');
module.exports = async (ban, db, client) => {
// Fetch perms this guild has
const guild = await client.guilds.fetch(ban.guild.id);
db.all("SELECT list_id, auto_remove FROM perms WHERE entity_id = ? AND entity_type = 'guild'", [ban.guild.id], async (err, lists) => {
;
for (const entry of lists) {
const list = await db.get("SELECT * FROM lists WHERE id = ?", [entry.list_id]);
if (!list) {
console.log(`${colors.red('[ERROR]')} List not found for id ${entry.list_id}. How'd we get here?`);
continue;
}
if (entry.auto_remove) {
try {
const existingBan = await db.get("SELECT * FROM bans WHERE list_id = ? AND user_id = ?", [list.id, ban.user.id]);
if (!existingBan) {
// Not banned on this list
continue;
}
await db.run("DELETE FROM bans WHERE list_id = ? AND user_id = ?", [list.id, ban.user.id]);
} catch (e) {
console.log(`[ERROR] Could not auto-remove ban for user ${ban.user.id} in guild ${guild.id}:`, e);
}
}
}
});
};