From d3858722114b0f170faf188c568a1b3efaea3352 Mon Sep 17 00:00:00 2001 From: Jeeves Date: Sat, 11 Jan 2025 20:22:47 -0700 Subject: [PATCH] initial commit --- .gitignore | 2 + default.nix | 9 +++ deno.json | 9 +++ deno.lock | 154 ++++++++++++++++++++++++++++++++++++++++++++ flake.lock | 61 ++++++++++++++++++ flake.nix | 14 ++++ main.ts | 180 ++++++++++++++++++++++++++++++++++++++++++++++++++++ shell.nix | 4 ++ 8 files changed, 433 insertions(+) create mode 100644 .gitignore create mode 100644 default.nix create mode 100644 deno.json create mode 100644 deno.lock create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 main.ts create mode 100644 shell.nix diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..60c069f --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +config.json +db.sqlite diff --git a/default.nix b/default.nix new file mode 100644 index 0000000..bc88d6a --- /dev/null +++ b/default.nix @@ -0,0 +1,9 @@ +{ pkgs ? import {}, + bash, + deno, +}: let + src = ./.; +in pkgs.writeScriptBin "blogbot" '' + #!${bash}/bin/bash + ${deno}/bin/deno --allow-net --allow-read --allow-write --lock ${src}/deno.lock ${src}/mod.ts +'' diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..b9481c5 --- /dev/null +++ b/deno.json @@ -0,0 +1,9 @@ +{ + "imports": { + "discord.js": "npm:discord.js@^14.17.2", + "sqlite": "https://deno.land/x/sqlite@v3.9.1/mod.ts" + }, + "tasks": { + "start": "deno run --allow-env --allow-read --allow-write=db.sqlite main.ts" + } +} diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..c9155ae --- /dev/null +++ b/deno.lock @@ -0,0 +1,154 @@ +{ + "version": "4", + "specifiers": { + "npm:discord.js@^14.17.2": "14.17.2" + }, + "npm": { + "@discordjs/builders@1.10.0": { + "integrity": "sha512-ikVZsZP+3shmVJ5S1oM+7SveUCK3L9fTyfA8aJ7uD9cNQlTqF+3Irbk2Y22KXTb3C3RNUahRkSInClJMkHrINg==", + "dependencies": [ + "@discordjs/formatters", + "@discordjs/util", + "@sapphire/shapeshift", + "discord-api-types", + "fast-deep-equal", + "ts-mixer", + "tslib" + ] + }, + "@discordjs/collection@1.5.3": { + "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==" + }, + "@discordjs/collection@2.1.1": { + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==" + }, + "@discordjs/formatters@0.6.0": { + "integrity": "sha512-YIruKw4UILt/ivO4uISmrGq2GdMY6EkoTtD0oS0GvkJFRZbTSdPhzYiUILbJ/QslsvC9H9nTgGgnarnIl4jMfw==", + "dependencies": [ + "discord-api-types" + ] + }, + "@discordjs/rest@2.4.2": { + "integrity": "sha512-9bOvXYLQd5IBg/kKGuEFq3cstVxAMJ6wMxO2U3wjrgO+lHv8oNCT+BBRpuzVQh7BoXKvk/gpajceGvQUiRoJ8g==", + "dependencies": [ + "@discordjs/collection@2.1.1", + "@discordjs/util", + "@sapphire/async-queue", + "@sapphire/snowflake", + "@vladfrangu/async_event_emitter", + "discord-api-types", + "magic-bytes.js", + "tslib", + "undici" + ] + }, + "@discordjs/util@1.1.1": { + "integrity": "sha512-eddz6UnOBEB1oITPinyrB2Pttej49M9FZQY8NxgEvc3tq6ZICZ19m70RsmzRdDHk80O9NoYN/25AqJl8vPVf/g==" + }, + "@discordjs/ws@1.2.0": { + "integrity": "sha512-QH5CAFe3wHDiedbO+EI3OOiyipwWd+Q6BdoFZUw/Wf2fw5Cv2fgU/9UEtJRmJa9RecI+TAhdGPadMaEIur5yJg==", + "dependencies": [ + "@discordjs/collection@2.1.1", + "@discordjs/rest", + "@discordjs/util", + "@sapphire/async-queue", + "@types/ws", + "@vladfrangu/async_event_emitter", + "discord-api-types", + "tslib", + "ws" + ] + }, + "@sapphire/async-queue@1.5.5": { + "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==" + }, + "@sapphire/shapeshift@4.0.0": { + "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", + "dependencies": [ + "fast-deep-equal", + "lodash" + ] + }, + "@sapphire/snowflake@3.5.3": { + "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==" + }, + "@types/node@22.5.4": { + "integrity": "sha512-FDuKUJQm/ju9fT/SeX/6+gBzoPzlVCzfzmGkwKvRHQVxi4BntVbyIwf6a4Xn62mrvndLiml6z/UBXIdEVjQLXg==", + "dependencies": [ + "undici-types" + ] + }, + "@types/ws@8.5.13": { + "integrity": "sha512-osM/gWBTPKgHV8XkTunnegTRIsvF6owmf5w+JtAfOw472dptdm0dlGv4xCt6GwQRcC2XVOvvRE/0bAoQcL2QkA==", + "dependencies": [ + "@types/node" + ] + }, + "@vladfrangu/async_event_emitter@2.4.6": { + "integrity": "sha512-RaI5qZo6D2CVS6sTHFKg1v5Ohq/+Bo2LZ5gzUEwZ/WkHhwtGTCB/sVLw8ijOkAUxasZ+WshN/Rzj4ywsABJ5ZA==" + }, + "discord-api-types@0.37.115": { + "integrity": "sha512-ivPnJotSMrXW8HLjFu+0iCVs8zP6KSliMelhr7HgcB2ki1QzpORkb26m71l1pzSnnGfm7gb5n/VtRTtpw8kXFA==" + }, + "discord.js@14.17.2": { + "integrity": "sha512-mrH6ziLVtNtId4bV4bsaUt5jE6NUaiHMPqO5VsSw1VVhFnjFi9duD8ctlo90/6cUH+8uyKBkoq9mSJ35SuuZ7Q==", + "dependencies": [ + "@discordjs/builders", + "@discordjs/collection@1.5.3", + "@discordjs/formatters", + "@discordjs/rest", + "@discordjs/util", + "@discordjs/ws", + "@sapphire/snowflake", + "discord-api-types", + "fast-deep-equal", + "lodash.snakecase", + "tslib", + "undici" + ] + }, + "fast-deep-equal@3.1.3": { + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "lodash.snakecase@4.1.1": { + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==" + }, + "lodash@4.17.21": { + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, + "magic-bytes.js@1.10.0": { + "integrity": "sha512-/k20Lg2q8LE5xiaaSkMXk4sfvI+9EGEykFS4b0CHHGWqDYU0bGUFSwchNOMA56D7TCs9GwVTkqe9als1/ns8UQ==" + }, + "ts-mixer@6.0.4": { + "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==" + }, + "tslib@2.8.1": { + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, + "undici-types@6.19.8": { + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==" + }, + "undici@6.19.8": { + "integrity": "sha512-U8uCCl2x9TK3WANvmBavymRzxbfFYG+tAu+fgx3zxQy3qdagQqBLwJVrdyO1TBfUXvfKveMKJZhpvUYoOjM+4g==" + }, + "ws@8.18.0": { + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==" + } + }, + "remote": { + "https://deno.land/x/sqlite@v3.9.1/build/sqlite.js": "2afc7875c7b9c85d89730c4a311ab3a304e5d1bf761fbadd8c07bbdf130f5f9b", + "https://deno.land/x/sqlite@v3.9.1/build/vfs.js": "7f7778a9fe499cd10738d6e43867340b50b67d3e39142b0065acd51a84cd2e03", + "https://deno.land/x/sqlite@v3.9.1/mod.ts": "e09fc79d8065fe222578114b109b1fd60077bff1bb75448532077f784f4d6a83", + "https://deno.land/x/sqlite@v3.9.1/src/constants.ts": "90f3be047ec0a89bcb5d6fc30db121685fc82cb00b1c476124ff47a4b0472aa9", + "https://deno.land/x/sqlite@v3.9.1/src/db.ts": "03d0c860957496eadedd86e51a6e650670764630e64f56df0092e86c90752401", + "https://deno.land/x/sqlite@v3.9.1/src/error.ts": "f7a15cb00d7c3797da1aefee3cf86d23e0ae92e73f0ba3165496c3816ab9503a", + "https://deno.land/x/sqlite@v3.9.1/src/function.ts": "bc778cab7a6d771f690afa27264c524d22fcb96f1bb61959ade7922c15a4ab8d", + "https://deno.land/x/sqlite@v3.9.1/src/query.ts": "d58abda928f6582d77bad685ecf551b1be8a15e8e38403e293ec38522e030cad", + "https://deno.land/x/sqlite@v3.9.1/src/wasm.ts": "e79d0baa6e42423257fb3c7cc98091c54399254867e0f34a09b5bdef37bd9487" + }, + "workspace": { + "dependencies": [ + "npm:discord.js@^14.17.2" + ] + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..02324b7 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1736012469, + "narHash": "sha256-/qlNWm/IEVVH7GfgAIyP6EsVZI6zjAx1cV5zNyrs+rI=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "8f3e1f807051e32d8c95cd12b9b421623850a34d", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..305aba6 --- /dev/null +++ b/flake.nix @@ -0,0 +1,14 @@ +{ + inputs = { + nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem(system: let + pkgs = nixpkgs.legacyPackages.${system}; + in { + packages.default = pkgs.callPackage ./default.nix {}; + devShells.default = import ./shell.nix { inherit pkgs; }; + }); +} diff --git a/main.ts b/main.ts new file mode 100644 index 0000000..13c33ee --- /dev/null +++ b/main.ts @@ -0,0 +1,180 @@ +import { + Client, + Events, + GatewayIntentBits, + GuildBasedChannel, + Message, +} from "discord.js"; +import { DB } from "sqlite"; + +import config from "./config.json" with { type: "json" }; + +const db = new DB("db.sqlite"); +db.execute(` + CREATE TABLE IF NOT EXISTS bag( + userid INTEGER NOT NULL PRIMARY KEY, + idx INTEGER + ); + CREATE TABLE IF NOT EXISTS time( + id INTEGER NOT NULL PRIMARY KEY, + time INTEGER NOT NULL + ) +`); + +const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + // allow enumeration of all members with the opt-in role + GatewayIntentBits.GuildMembers, + GatewayIntentBits.GuildPresences, + ], +}); + +client.once(Events.ClientReady, (readyClient) => { + console.log(`Ready! Logged in as ${readyClient.user.tag}`); + const [timestamp] = db.query("SELECT time FROM time"); + if (!timestamp) { + const date = getNextDate(); + console.log(`Scheduled next blogger for ${date}`); + setTimeout(nextBloggerAndRepeat, date.getTime() - Date.now()); + } else { + const date = new Date((timestamp[0] as number) * 1000); + console.log(`Scheduled next blogger for ${date}`); + setTimeout(nextBloggerAndRepeat, date.getTime() - Date.now()); + } +}); + +client.login(config.token); + +function getNextDate(): Date { + const date = new Date(); + // date.setUTCSeconds(date.getUTCSeconds() + 10); + date.setUTCSeconds(0); + date.setUTCMinutes(0); + date.setUTCHours(date.getUTCHours() + 1); + // date.setUTCHours(0); + // date.setUTCDate(date.getUTCDate() + 1); + return date; +} + +async function nextBloggerAndRepeat() { + await nextBlogger(); + const date = getNextDate(); + setTimeout(nextBloggerAndRepeat, date.getTime() - Date.now()); + console.log(`Scheduled next blogger for ${date}`); + db.query( + "INSERT INTO time(id,time) VALUES(0,?) ON CONFLICT(id) DO UPDATE SET time = ?", + [ + Math.floor(date.getTime() / 1000), + Math.floor(date.getTime() / 1000), + ], + ); +} + +// this uses a bag randomizer algorithm to keep things fair. + +// it uses sqlite to provide robustness against crashes and bot restarts. +// it associates a userid with an idx, which is either an integer or null. +// when it's time to select the next blogger, +// it selects all users with non-null idxs, and sorts by idx, +// then it pops the top one off that array, and marks that idx as null. + +// this system allows it to handle someone opting in or out between bag shuffles. +// see below comments for the reasoning behind this. +async function nextBlogger() { + const guild = await client.guilds.fetch(config.guildID); + await guild.members.fetch(); // refresh the cache + const optinRole = await guild.roles.fetch(config.optinRoleID); + const selectedRole = await guild.roles.fetch(config.selectedRoleID); + const blogChannel = await guild.channels.fetch(config.blogChannelID); + + const bagOfUsers: Array = []; + optinRole?.members.each((member) => bagOfUsers.push(member.id)); + + for (const user of bagOfUsers) { + const dbUser = db.query("SELECT userid FROM bag WHERE userid = ?", [user]); + // check if this user opted-in before bag was empty. + // if so we add them to the bag at a random idx. + if (dbUser.length == 0) { + const [[numUsersInDB]] = db.query<[number]>("SELECT COUNT(*) FROM bag"); + const idx = Math.floor(Math.random() * numUsersInDB); + db.query( + "INSERT INTO bag(userid,idx) VALUES(?,?) ON CONFLICT(userid) DO UPDATE SET idx = ?", + [user, idx, idx], + ); + } + } + + // if bag is empty, refill and shuffle it + const [[numUsersInDB]] = db.query( + "SELECT COUNT(*) FROM bag WHERE idx = NULL", + ); + if (numUsersInDB == 0) { + shuffleUserBag(bagOfUsers); + + db.execute("DELETE FROM bag"); + for (const idx in bagOfUsers) { + db.query("INSERT INTO bag(userid,idx) VALUES(?,?)", [ + bagOfUsers[idx], + idx, + ]); + } + } + + const dbBagOfUsers = db.query<[bigint]>( + "SELECT userid FROM bag WHERE idx IS NOT NULL ORDER BY idx ASC", + ); + + const selectedUserRow = dbBagOfUsers.pop(); + console.log("selected user row " + selectedUserRow); + if (!selectedUserRow) return; // nobody has opted-in + const selectedUserID = (selectedUserRow[0] as bigint).toString(); + + // check if this user opted-out before bag was empty. + // if so we delete their db row and rerun this function. + // this prevents someone from accidentally getting picked after they opted-out. + if (!bagOfUsers.includes(selectedUserID)) { + db.query("DELETE FROM bag WHERE userid = ?", [selectedUserID]); + return nextBlogger(); + } + + db.query("UPDATE bag SET idx = NULL WHERE userid = ?", [selectedUserID]); + selectedRole?.members.each(async (member) => { + if (member.id != selectedUserID) { + await member.roles.remove(config.selectedRoleID); + } + }); + const selectedUser = await guild.members.fetch(selectedUserID); + selectedUser.roles.add(config.selectedRoleID); + if (blogChannel) await sillyPing(blogChannel, selectedUserID); + + console.log(db.query("SELECT userid,idx FROM bag")); +} + +// a lazy way to do this. +// i can use an array of template strings so long as whatever variable we use inside it is in scope. +// this probably evaluates ALL the strings, even though we discard all but one. +// but it's good enough. +function sillyPing( + channel: GuildBasedChannel, + userID: string, +): Promise> { + if (!channel.isTextBased()) throw new Error("Channel is not text-based"); + const messages = [ + `<@${userID}>, it's your turn!`, + `Go go go <@${userID}>!`, + `<@${userID}>: ready, set, RAMBLE!!`, + `Here's your 15 minutes of fame, <@${userID}>! No take-backsies!`, + ]; + const message = messages[Math.floor(Math.random() * messages.length)]; + return channel.send(message); +} + +// in-place shuffle +// https://stackoverflow.com/a/12646864 +function shuffleUserBag(array: Array) { + for (let i = array.length - 1; i >= 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [array[i], array[j]] = [array[j], array[i]]; + } +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..97d4ad4 --- /dev/null +++ b/shell.nix @@ -0,0 +1,4 @@ +{ pkgs ? import {} }: +pkgs.mkShell { + packages = with pkgs; [deno]; +}