commit d3858722114b0f170faf188c568a1b3efaea3352 Author: Jeeves Date: Sat Jan 11 20:22:47 2025 -0700 initial commit 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]; +}