initial commit
This commit is contained in:
commit
d385872211
8 changed files with 433 additions and 0 deletions
180
main.ts
Normal file
180
main.ts
Normal file
|
@ -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<string> = [];
|
||||
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<Message<true>> {
|
||||
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<unknown>) {
|
||||
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]];
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue