Added legacy data transfer and ported structures

This commit is contained in:
WatDuhHekBro 2021-12-08 21:56:11 -06:00
parent 16e42be58d
commit 658348d993
No known key found for this signature in database
GPG Key ID: E128514902DF8A05
30 changed files with 724 additions and 565 deletions

5
.gitignore vendored
View File

@ -1,8 +1,7 @@
# Specific to this repository
dist/
data/*
data/public/emote-registry.json
!data/public/
data/
public/emote-registry.json
tmp/
test*
!test/

View File

@ -7,7 +7,7 @@
"start": "node -r dotenv/config .",
"build": "rimraf dist && tsc --project tsconfig.prod.json && npm prune --production",
"dev": "tsc-watch --onSuccess \"npm start\"",
"test": "jest",
"test": "jest --setupFiles dotenv/config",
"format": "prettier --write **/*",
"postinstall": "husky install"
},

View File

@ -1,8 +1,7 @@
import {Command, NamedCommand, confirm, poll} from "onion-lasers";
import {pluralise} from "../../../lib";
import {Storage} from "../../../structures";
import {User, pluralise} from "../../../lib";
import {isAuthorized, getMoneyEmbed} from "./eco-utils";
import {User} from "discord.js";
import {User as DiscordUser} from "discord.js";
export const BetCommand = new NamedCommand({
description: "Bet your Mons with other people.",
@ -27,9 +26,9 @@ export const BetCommand = new NamedCommand({
// handles missing duration argument
async run({send, args, author, channel, guild}) {
if (isAuthorized(guild, channel)) {
const sender = Storage.getUser(author.id);
const target = args[0] as User;
const receiver = Storage.getUser(target.id);
const sender = new User(author.id);
const target = args[0] as DiscordUser;
const receiver = new User(target.id);
const amount = Math.floor(args[1]);
// handle invalid target
@ -54,15 +53,15 @@ export const BetCommand = new NamedCommand({
},
any: new Command({
description: "Duration of the bet.",
async run({send, client, args, author, message, channel, guild}) {
async run({send, args, author, message, channel, guild}) {
if (isAuthorized(guild, channel)) {
// [Pertinence to make configurable on the fly.]
// Lower and upper bounds for bet
const durationBounds = {min: "1m", max: "1d"};
const sender = Storage.getUser(author.id);
const target = args[0] as User;
const receiver = Storage.getUser(target.id);
const sender = new User(author.id);
const target = args[0] as DiscordUser;
const receiver = new User(target.id);
const amount = Math.floor(args[1]);
const duration = parseDuration(args[2].trim());
@ -107,7 +106,6 @@ export const BetCommand = new NamedCommand({
// Very hacky solution for persistence but better than no solution, backup returns runs during the bot's setup code.
sender.ecoBetInsurance += amount;
receiver.ecoBetInsurance += amount;
Storage.save();
// Notify both users.
send(
@ -121,8 +119,8 @@ export const BetCommand = new NamedCommand({
// Wait for the duration of the bet.
return setTimeout(async () => {
// In debug mode, saving the storage will break the references, so you have to redeclare sender and receiver for it to actually save.
const sender = Storage.getUser(author.id);
const receiver = Storage.getUser(target.id);
const sender = new User(author.id);
const receiver = new User(target.id);
// [TODO: when D.JSv13 comes out, inline reply to clean up.]
// When bet is over, give a vote to ask people their thoughts.
// Filter reactions to only collect the pertinent ones.
@ -159,7 +157,6 @@ export const BetCommand = new NamedCommand({
sender.ecoBetInsurance -= amount;
receiver.ecoBetInsurance -= amount;
Storage.save();
}, duration);
} else return;
}

View File

@ -1,6 +1,5 @@
import {Command, getUserByNickname, NamedCommand, confirm, RestCommand} from "onion-lasers";
import {pluralise} from "../../../lib";
import {Storage} from "../../../structures";
import {User, pluralise} from "../../../lib";
import {isAuthorized, getMoneyEmbed, getSendEmbed, ECO_EMBED_COLOR} from "./eco-utils";
export const DailyCommand = new NamedCommand({
@ -8,13 +7,12 @@ export const DailyCommand = new NamedCommand({
aliases: ["get"],
async run({send, author, channel, guild}) {
if (isAuthorized(guild, channel)) {
const user = Storage.getUser(author.id);
const user = new User(author.id);
const now = Date.now();
if (now - user.lastReceived >= 79200000) {
user.money++;
user.lastReceived = now;
Storage.save();
send({
embeds: [
{
@ -50,11 +48,10 @@ export const GuildCommand = new NamedCommand({
description: "Get info on the guild's economy as a whole.",
async run({send, guild, channel}) {
if (isAuthorized(guild, channel)) {
const users = Storage.users;
const users = User.all();
let totalAmount = 0;
for (const ID in users) {
const user = users[ID];
for (const user of users) {
totalAmount += user.money;
}
@ -90,7 +87,7 @@ export const LeaderboardCommand = new NamedCommand({
aliases: ["top"],
async run({send, guild, channel, client}) {
if (isAuthorized(guild, channel)) {
const users = Storage.users;
const users = User.all();
const ids = Object.keys(users);
ids.sort((a, b) => users[b].money - users[a].money);
const fields = [];
@ -132,9 +129,9 @@ export const PayCommand = new NamedCommand({
async run({send, args, author, channel, guild}): Promise<any> {
if (isAuthorized(guild, channel)) {
const amount = Math.floor(args[1]);
const sender = Storage.getUser(author.id);
const sender = new User(author.id);
const target = args[0];
const receiver = Storage.getUser(target.id);
const receiver = new User(target.id);
if (amount <= 0) return send("You must send at least one Mon!");
else if (sender.money < amount)
@ -147,7 +144,6 @@ export const PayCommand = new NamedCommand({
sender.money -= amount;
receiver.money += amount;
Storage.save();
return send(getSendEmbed(author, target, amount));
}
}
@ -164,7 +160,7 @@ export const PayCommand = new NamedCommand({
if (!/\d+/g.test(last) && args.length === 0) return send("You need to enter an amount you're sending!");
const amount = Math.floor(last);
const sender = Storage.getUser(author.id);
const sender = new User(author.id);
if (amount <= 0) return send("You must send at least one Mon!");
else if (sender.money < amount)
@ -201,10 +197,9 @@ export const PayCommand = new NamedCommand({
);
if (confirmed) {
const receiver = Storage.getUser(user.id);
const receiver = new User(user.id);
sender.money -= amount;
receiver.money += amount;
Storage.save();
send(getSendEmbed(author, user, amount));
}
}

View File

@ -1,8 +1,7 @@
import {Command, NamedCommand} from "onion-lasers";
import {Storage} from "../../../structures";
import {isAuthorized, getMoneyEmbed} from "./eco-utils";
import {User} from "discord.js";
import {pluralise} from "../../../lib";
import {User as DiscordUser} from "discord.js";
import {User, pluralise} from "../../../lib";
const WEEKDAY = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
@ -10,7 +9,7 @@ export const MondayCommand = new NamedCommand({
description: "Use this on a UTC Monday to get an extra Mon. Does not affect your 22 hour timer for `eco daily`.",
async run({send, guild, channel, author}) {
if (isAuthorized(guild, channel)) {
const user = Storage.getUser(author.id);
const user = new User(author.id);
const now = new Date();
const weekday = now.getUTCDay();
@ -20,7 +19,6 @@ export const MondayCommand = new NamedCommand({
if (now.getTime() - user.lastMonday >= 86400000) {
user.money++;
user.lastMonday = now.getTime();
Storage.save();
send({content: "It is **Mon**day, my dudes.", embeds: [getMoneyEmbed(author, true)]});
} else send("You've already claimed your **Mon**day reward for this week.");
} else {
@ -43,10 +41,9 @@ export const AwardCommand = new NamedCommand({
user: new Command({
async run({send, author, args}) {
if (author.id === "394808963356688394" || process.env.DEV) {
const target = args[0] as User;
const user = Storage.getUser(target.id);
const target = args[0] as DiscordUser;
const user = new User(target.id);
user.money++;
Storage.save();
send({content: `1 Mon given to ${target.username}.`, embeds: [getMoneyEmbed(target, true)]});
} else {
send("This command is restricted to the bean.");
@ -55,13 +52,12 @@ export const AwardCommand = new NamedCommand({
number: new Command({
async run({send, author, args}) {
if (author.id === "394808963356688394" || process.env.DEV) {
const target = args[0] as User;
const target = args[0] as DiscordUser;
const amount = Math.floor(args[1]);
if (amount > 0) {
const user = Storage.getUser(target.id);
const user = new User(target.id);
user.money += amount;
Storage.save();
send({
content: `${pluralise(amount, "Mon", "s")} given to ${target.username}.`,
embeds: [getMoneyEmbed(target, true)]

View File

@ -1,6 +1,6 @@
import {Command, NamedCommand, paginate, RestCommand} from "onion-lasers";
import {NamedCommand, paginate, RestCommand} from "onion-lasers";
import {pluralise, split} from "../../../lib";
import {Storage, getPrefix} from "../../../structures";
import {User, getPrefix} from "../../../lib";
import {isAuthorized, ECO_EMBED_COLOR} from "./eco-utils";
import {ShopItems, ShopItem} from "./eco-shop-items";
import {EmbedField, MessageEmbedOptions} from "discord.js";
@ -60,16 +60,15 @@ export const BuyCommand = new NamedCommand({
//if (/\d+/g.test(args[args.length - 1]))
//amount = parseInt(args.pop());
for (let item of ShopItems) {
for (const item of ShopItems) {
if (item.usage === combined) {
const user = Storage.getUser(author.id);
const user = new User(author.id);
const cost = item.cost * amount;
if (cost > user.money) {
send("Not enough Mons!");
} else {
user.money -= cost;
Storage.save();
item.run(message, cost, amount);
}

View File

@ -1,11 +1,10 @@
import {pluralise} from "../../../lib";
import {Storage} from "../../../structures";
import {User, Guild, TextChannel, DMChannel, NewsChannel, Channel, TextBasedChannels} from "discord.js";
import {User, pluralise} from "../../../lib";
import {User as DiscordUser, Guild, TextBasedChannels} from "discord.js";
export const ECO_EMBED_COLOR = 0xf1c40f;
export function getMoneyEmbed(user: User, inline: boolean = false): object {
const profile = Storage.getUser(user.id);
export function getMoneyEmbed(user: DiscordUser, inline: boolean = false): object {
const profile = new User(user.id);
console.log(profile);
if (inline) {
@ -49,7 +48,7 @@ export function getMoneyEmbed(user: User, inline: boolean = false): object {
}
}
export function getSendEmbed(sender: User, receiver: User, amount: number): object {
export function getSendEmbed(sender: DiscordUser, receiver: DiscordUser, amount: number): object {
return {
embeds: [
{
@ -70,11 +69,11 @@ export function getSendEmbed(sender: User, receiver: User, amount: number): obje
fields: [
{
name: `Sender: ${sender.tag}`,
value: pluralise(Storage.getUser(sender.id).money, "Mon", "s")
value: pluralise(new User(sender.id).money, "Mon", "s")
},
{
name: `Receiver: ${receiver.tag}`,
value: pluralise(Storage.getUser(receiver.id).money, "Mon", "s")
value: pluralise(new User(receiver.id).money, "Mon", "s")
}
],
footer: {

View File

@ -1,5 +1,5 @@
import {Command, NamedCommand, getPermissionLevel, getPermissionName, CHANNEL_TYPE, RestCommand} from "onion-lasers";
import {Config, Storage, getPrefix} from "../../structures";
import {config, Guild, getPrefix} from "../../lib";
import {Permissions, TextChannel, User, Role, Channel, Util} from "discord.js";
import {logs} from "../../modules/logger";
@ -35,24 +35,21 @@ export default new NamedCommand({
description: "Set a custom prefix for your guild. Removes your custom prefix if none is provided.",
usage: "(<prefix>) (<@bot>)",
async run({send, guild}) {
Storage.getGuild(guild!.id).prefix = null;
Storage.save();
new Guild(guild!.id).prefix = null;
send(
`The custom prefix for this guild has been removed. My prefix is now back to \`${getPrefix()}\`.`
);
},
any: new Command({
async run({send, guild, args}) {
Storage.getGuild(guild!.id).prefix = args[0];
Storage.save();
new Guild(guild!.id).prefix = args[0];
send(`The custom prefix for this guild is now \`${args[0]}\`.`);
},
user: new Command({
description: "Specifies the bot in case of conflicting prefixes.",
async run({send, guild, client, args}) {
if ((args[1] as User).id === client.user!.id) {
Storage.getGuild(guild!.id).prefix = args[0];
Storage.save();
new Guild(guild!.id).prefix = args[0];
send(`The custom prefix for this guild is now \`${args[0]}\`.`);
}
}
@ -67,16 +64,14 @@ export default new NamedCommand({
true: new NamedCommand({
description: "Enable sending of message previews.",
async run({send, guild}) {
Storage.getGuild(guild!.id).messageEmbeds = true;
Storage.save();
new Guild(guild!.id).hasMessageEmbeds = true;
send("Sending of message previews has been enabled.");
}
}),
false: new NamedCommand({
description: "Disable sending of message previews.",
async run({send, guild}) {
Storage.getGuild(guild!.id).messageEmbeds = false;
Storage.save();
new Guild(guild!.id).hasMessageEmbeds = false;
send("Sending of message previews has been disabled.");
}
})
@ -86,15 +81,14 @@ export default new NamedCommand({
description: "Configure your server's autoroles.",
usage: "<roles...>",
async run({send, guild}) {
Storage.getGuild(guild!.id).autoRoles = [];
Storage.save();
new Guild(guild!.id).autoRoles = [];
send("Reset this server's autoroles.");
},
id: "role",
any: new RestCommand({
description: "The roles to set as autoroles.",
async run({send, guild, args}) {
const guildd = Storage.getGuild(guild!.id);
const guildd = new Guild(guild!.id);
for (const role of args) {
if (!role.toString().match(/^<@&(\d{17,})>$/)) {
return send("Not all arguments are a role mention!");
@ -102,7 +96,6 @@ export default new NamedCommand({
const id = role.toString().match(/^<@&(\d{17,})>$/)![1];
guildd.autoRoles!.push(id);
}
Storage.save();
return send("Saved.");
}
})
@ -117,30 +110,26 @@ export default new NamedCommand({
"Sets how welcome messages are displayed for your server. Removes welcome messages if unspecified.",
usage: "`none`/`text`/`graphical`",
async run({send, guild}) {
Storage.getGuild(guild!.id).welcomeType = "none";
Storage.save();
new Guild(guild!.id).welcomeType = "none";
send("Set this server's welcome type to `none`.");
},
// I should probably make this a bit more dynamic... Oh well.
subcommands: {
text: new NamedCommand({
async run({send, guild}) {
Storage.getGuild(guild!.id).welcomeType = "text";
Storage.save();
new Guild(guild!.id).welcomeType = "text";
send("Set this server's welcome type to `text`.");
}
}),
graphical: new NamedCommand({
async run({send, guild}) {
Storage.getGuild(guild!.id).welcomeType = "graphical";
Storage.save();
new Guild(guild!.id).welcomeType = "graphical";
send("Set this server's welcome type to `graphical`.");
}
}),
none: new NamedCommand({
async run({send, guild}) {
Storage.getGuild(guild!.id).welcomeType = "none";
Storage.save();
new Guild(guild!.id).welcomeType = "none";
send("Set this server's welcome type to `none`.");
}
})
@ -150,8 +139,7 @@ export default new NamedCommand({
description: "Sets the welcome channel for your server. Type `#` to reference the channel.",
usage: "(<channel mention>)",
async run({send, channel, guild}) {
Storage.getGuild(guild!.id).welcomeChannel = channel.id;
Storage.save();
new Guild(guild!.id).welcomeChannel = channel.id;
send(`Successfully set ${channel} as the welcome channel for this server.`);
},
id: "channel",
@ -160,8 +148,7 @@ export default new NamedCommand({
const result = args[0] as Channel;
if (result instanceof TextChannel) {
Storage.getGuild(guild!.id).welcomeChannel = result.id;
Storage.save();
new Guild(guild!.id).welcomeChannel = result.id;
send(`Successfully set this server's welcome channel to ${result}.`);
} else {
send(`\`${result.id}\` is not a valid text channel!`);
@ -174,14 +161,12 @@ export default new NamedCommand({
"Sets a custom welcome message for your server. Use `%user%` as the placeholder for the user.",
usage: "(<message>)",
async run({send, guild}) {
Storage.getGuild(guild!.id).welcomeMessage = null;
Storage.save();
new Guild(guild!.id).welcomeMessage = null;
send("Reset your server's welcome message to the default.");
},
any: new RestCommand({
async run({send, guild, combined}) {
Storage.getGuild(guild!.id).welcomeMessage = combined;
Storage.save();
new Guild(guild!.id).welcomeMessage = combined;
send(`Set your server's welcome message to \`${combined}\`.`);
}
})
@ -192,7 +177,7 @@ export default new NamedCommand({
description: "Set a channel to send stream notifications. Type `#` to reference the channel.",
usage: "(<channel mention>)",
async run({send, channel, guild}) {
const targetGuild = Storage.getGuild(guild!.id);
const targetGuild = new Guild(guild!.id);
if (targetGuild.streamingChannel) {
targetGuild.streamingChannel = null;
@ -201,8 +186,6 @@ export default new NamedCommand({
targetGuild.streamingChannel = channel.id;
send(`Set your server's stream notifications channel to ${channel}.`);
}
Storage.save();
},
id: "channel",
channel: new Command({
@ -210,8 +193,7 @@ export default new NamedCommand({
const result = args[0] as Channel;
if (result instanceof TextChannel) {
Storage.getGuild(guild!.id).streamingChannel = result.id;
Storage.save();
new Guild(guild!.id).streamingChannel = result.id;
send(`Successfully set this server's stream notifications channel to ${result}.`);
} else {
send(`\`${result.id}\` is not a valid text channel!`);
@ -232,8 +214,7 @@ export default new NamedCommand({
any: new RestCommand({
async run({send, guild, args, combined}) {
const role = args[0] as Role;
Storage.getGuild(guild!.id).streamingRoles[role.id] = combined;
Storage.save();
new Guild(guild!.id).streamingRoles.set(role.id, combined);
send(
`Successfully set the category \`${combined}\` to notify \`${role.name}\`.`
);
@ -247,10 +228,9 @@ export default new NamedCommand({
role: new Command({
async run({send, guild, args}) {
const role = args[0] as Role;
const guildStorage = Storage.getGuild(guild!.id);
const category = guildStorage.streamingRoles[role.id];
const guildStorage = new Guild(guild!.id);
const category = guildStorage.streamingRoles.get(role.id);
delete guildStorage.streamingRoles[role.id];
Storage.save();
send(
`Successfully removed the category \`${category}\` to notify \`${role.name}\`.`
);
@ -267,24 +247,22 @@ export default new NamedCommand({
async run({send, guild, message}) {
const voiceChannel = message.member?.voice.channel;
if (!voiceChannel) return send("You are not in a voice channel.");
const guildStorage = Storage.getGuild(guild!.id);
delete guildStorage.channelNames[voiceChannel.id];
Storage.save();
const guildStorage = new Guild(guild!.id);
delete guildStorage.defaultChannelNames[voiceChannel.id];
return send(`Successfully removed the default channel name for ${voiceChannel}.`);
},
any: new RestCommand({
async run({send, guild, message, combined}) {
const voiceChannel = message.member?.voice.channel;
const guildID = guild!.id;
const guildStorage = Storage.getGuild(guildID);
const guildStorage = new Guild(guildID);
const newName = combined;
if (!voiceChannel) return send("You are not in a voice channel.");
if (!guild!.me?.permissions.has(Permissions.FLAGS.MANAGE_CHANNELS))
return send("I can't change channel names without the `Manage Channels` permission.");
guildStorage.channelNames[voiceChannel.id] = newName;
Storage.save();
guildStorage.defaultChannelNames.set(voiceChannel.id, newName);
return await send(`Set default channel name to "${newName}".`);
}
})
@ -417,7 +395,7 @@ export default new NamedCommand({
const guildList = Util.splitMessage(
Array.from(client.guilds.cache.map((e) => e.name).values()).join("\n")
);
for (let guildListPart of guildList) {
for (const guildListPart of guildList) {
send(guildListPart);
}
}
@ -456,8 +434,7 @@ export default new NamedCommand({
permission: PERMISSIONS.BOT_ADMIN,
channelType: CHANNEL_TYPE.GUILD,
async run({send, channel}) {
Config.systemLogsChannel = channel.id;
Config.save();
config.systemLogsChannel = channel.id;
send(`Successfully set ${channel} as the system logs channel.`);
},
channel: new Command({
@ -465,8 +442,7 @@ export default new NamedCommand({
const targetChannel = args[0] as Channel;
if (targetChannel instanceof TextChannel) {
Config.systemLogsChannel = targetChannel.id;
Config.save();
config.systemLogsChannel = targetChannel.id;
send(`Successfully set ${targetChannel} as the system logs channel.`);
} else {
send(`\`${targetChannel.id}\` is not a valid text channel!`);

View File

@ -45,7 +45,7 @@ export default new NamedCommand({
// Initialize the emote stats object with every emote in the current guild.
// The goal here is to cut the need to access guild.emojis.get() which'll make it faster and easier to work with.
for (let emote of guild!.emojis.cache.values()) {
for (const emote of guild!.emojis.cache.values()) {
// If you don't include the "a" for animated emotes, it'll not only not show up, but also cause all other emotes in the same message to not show up. The emote name is self-correcting but it's better to keep the right value since it'll be used to calculate message lengths that fit.
stats[emote.id] = {
name: emote.name,
@ -180,7 +180,7 @@ export default new NamedCommand({
}
let emoteList = Util.splitMessage(lines.join("\n"));
for (let emoteListPart of emoteList) {
for (const emoteListPart of emoteList) {
return await send(emoteListPart);
}
},

View File

@ -1,6 +1,6 @@
import {NamedCommand, RestCommand} from "onion-lasers";
import {streamList} from "../../modules/streamNotifications";
import {Storage} from "../../structures";
import {Guild, Member} from "../../lib";
// Alternatively, I could make descriptions last outside of just one stream.
// But then again, users could just copy paste descriptions. :leaSMUG:
@ -96,9 +96,8 @@ export default new NamedCommand({
usage: "(<category>)",
async run({send, guild, author}) {
const userID = author.id;
const memberStorage = Storage.getGuild(guild!.id).getMember(userID);
const memberStorage = new Member(userID, guild!.id);
memberStorage.streamCategory = null;
Storage.save();
send("Successfully removed the category for all your current and future streams.");
// Then modify the current category if the user is streaming
@ -111,8 +110,8 @@ export default new NamedCommand({
any: new RestCommand({
async run({send, guild, author, combined}) {
const userID = author.id;
const guildStorage = Storage.getGuild(guild!.id);
const memberStorage = guildStorage.getMember(userID);
const guildStorage = new Guild(guild!.id);
const memberStorage = new Member(userID, guild!.id);
let found = false;
// Check if it's a valid category
@ -120,7 +119,6 @@ export default new NamedCommand({
if (combined === categoryName) {
found = true;
memberStorage.streamCategory = roleID;
Storage.save();
send(
`Successfully set the category for your current and future streams to: \`${categoryName}\``
);

View File

@ -7,8 +7,8 @@ import {
getUserByNickname,
RestCommand
} from "onion-lasers";
import {Storage} from "../../structures";
import {User} from "discord.js";
import {User} from "../../lib";
import {User as DiscordUser} from "discord.js";
import moment from "moment";
const DATE_FORMAT = "D MMMM YYYY";
@ -106,21 +106,22 @@ function hasDaylightSavings(region: DST) {
return region !== "sh" ? insideBounds : !insideBounds;
}
function getTimeEmbed(user: User) {
const {timezone, daylightSavingsRegion} = Storage.getUser(user.id);
function getTimeEmbed(user: DiscordUser) {
const {timezoneOffset, daylightSavingsRegion} = new User(user.id);
let localDate = "N/A";
let dayOfWeek = "N/A";
let localTime = "N/A";
let timezoneOffset = "N/A";
let timezoneOffsetDisplay = "N/A";
if (timezone !== null) {
const daylightSavingsOffset = daylightSavingsRegion && hasDaylightSavings(daylightSavingsRegion) ? 1 : 0;
const daylightTimezone = timezone + daylightSavingsOffset;
if (timezoneOffset !== null) {
const daylightSavingsOffset =
daylightSavingsRegion !== "none" && hasDaylightSavings(daylightSavingsRegion) ? 1 : 0;
const daylightTimezone = timezoneOffset + daylightSavingsOffset;
const now = moment().utcOffset(daylightTimezone * 60);
localDate = now.format(DATE_FORMAT);
dayOfWeek = now.format(DOW_FORMAT);
localTime = now.format(TIME_FORMAT);
timezoneOffset = daylightTimezone >= 0 ? `+${daylightTimezone}` : daylightTimezone.toString();
timezoneOffsetDisplay = daylightTimezone >= 0 ? `+${daylightTimezone}` : daylightTimezone.toString();
}
const embed = {
@ -147,7 +148,7 @@ function getTimeEmbed(user: User) {
},
{
name: daylightSavingsRegion !== null ? "Current Timezone Offset" : "Timezone Offset",
value: timezoneOffset
value: timezoneOffsetDisplay
},
{
name: "Observes Daylight Savings?",
@ -156,7 +157,7 @@ function getTimeEmbed(user: User) {
]
};
if (daylightSavingsRegion) {
if (daylightSavingsRegion !== "none") {
embed.fields.push(
{
name: "Daylight Savings Active?",
@ -183,9 +184,9 @@ export default new NamedCommand({
setup: new NamedCommand({
description: "Registers your timezone information for the bot.",
async run({send, author}) {
const profile = Storage.getUser(author.id);
profile.timezone = null;
profile.daylightSavingsRegion = null;
const profile = new User(author.id);
profile.timezoneOffset = null;
profile.daylightSavingsRegion = "none";
// Parse and validate reply
const reply = await askForReply(
@ -269,13 +270,13 @@ export default new NamedCommand({
if (isSameDay) {
for (const [hourPoint, dayOffset, timezoneOffset] of timezoneTupleList) {
if (dayOffset === 0 && hour === hourPoint) {
profile.timezone = timezoneOffset;
profile.timezoneOffset = timezoneOffset;
}
}
} else {
for (const [hourPoint, dayOffset, timezoneOffset] of timezoneTupleList) {
if (dayOffset !== 0 && hour === hourPoint) {
profile.timezone = timezoneOffset;
profile.timezoneOffset = timezoneOffset;
}
}
}
@ -283,7 +284,7 @@ export default new NamedCommand({
// If it's a unique hour, just search through the tuple list and find the matching entry.
for (const [hourPoint, _dayOffset, timezoneOffset] of timezoneTupleList) {
if (hour === hourPoint) {
profile.timezone = timezoneOffset;
profile.timezoneOffset = timezoneOffset;
}
}
}
@ -295,7 +296,6 @@ export default new NamedCommand({
);
const finalize = () => {
Storage.save();
send({
content:
"You've finished setting up your timezone! Just check to see if this looks right, and if it doesn't, run this setup again.",
@ -309,7 +309,7 @@ export default new NamedCommand({
// If daylight savings is active, subtract the timezone offset by one to store the standard time.
if (hasDaylightSavings(region)) {
profile.timezone!--;
profile.timezoneOffset!--;
}
finalize();
@ -344,10 +344,9 @@ export default new NamedCommand({
);
if (result) {
const profile = Storage.getUser(author.id);
profile.timezone = null;
profile.daylightSavingsRegion = null;
Storage.save();
const profile = new User(author.id);
profile.timezoneOffset = null;
profile.daylightSavingsRegion = "none";
}
}
}),

View File

@ -1,12 +1,12 @@
import {NamedCommand, RestCommand} from "onion-lasers";
import moment from "moment";
import {Storage} from "../../structures";
import {User} from "../../lib";
import {MessageEmbed} from "discord.js";
export default new NamedCommand({
description: "Keep and edit your personal todo list.",
async run({send, author}) {
const user = Storage.getUser(author.id);
const user = new User(author.id);
const embed = new MessageEmbed().setTitle(`Todo list for ${author.tag}`).setColor("BLUE");
for (const timestamp in user.todoList) {
@ -24,7 +24,7 @@ export default new NamedCommand({
run: "You need to specify a note to add.",
any: new RestCommand({
async run({send, author, combined}) {
const user = Storage.getUser(author.id);
const user = new User(author.id);
user.todoList[Date.now().toString()] = combined;
Storage.save();
send(`Successfully added \`${combined}\` to your todo list.`);
@ -35,7 +35,7 @@ export default new NamedCommand({
run: "You need to specify a note to remove.",
any: new RestCommand({
async run({send, author, combined}) {
const user = Storage.getUser(author.id);
const user = new User(author.id);
let isFound = false;
for (const timestamp in user.todoList) {
@ -55,7 +55,7 @@ export default new NamedCommand({
}),
clear: new NamedCommand({
async run({send, author}) {
const user = Storage.getUser(author.id);
const user = new User(author.id);
user.todoList = {};
Storage.save();
send("Cleared todo list.");

39
src/defs/config.ts Normal file
View File

@ -0,0 +1,39 @@
import {db} from "../modules/database";
import {Collection} from "discord.js";
class Config {
private _systemLogsChannel: string | null;
private _webhooks: Collection<string, string>; // id-token pairs
constructor() {
const {SystemLogsChannel} = db.prepare("SELECT * FROM Settings WHERE Tag = 'Main'").get();
const webhooks = db.prepare("SELECT * FROM Webhooks").all();
this._systemLogsChannel = SystemLogsChannel;
this._webhooks = new Collection();
for (const {ID, Token} of webhooks) {
this._webhooks.set(ID, Token);
}
}
get systemLogsChannel() {
return this._systemLogsChannel;
}
set systemLogsChannel(systemLogsChannel) {
this._systemLogsChannel = systemLogsChannel;
db.prepare("UPDATE Settings SET SystemLogsChannel = ? WHERE Tag = 'Main'").run(systemLogsChannel);
}
get webhooks() {
return this._webhooks;
}
// getWebhook, setWebhook, removeWebhook, hasWebhook, getWebhookEntries
setWebhook(id: string, token: string) {
this._webhooks.set(id, token);
db.prepare(
"INSERT INTO Webhooks VALUES (:id, :token) ON CONFLICT (ID) DO UPDATE SET Token = :token WHERE ID = :id"
).run({id, token});
}
}
// There'll only be one instance of the config.
export const config = new Config();

17
src/defs/emoteRegistry.ts Normal file
View File

@ -0,0 +1,17 @@
import {Snowflake} from "discord.js";
export interface EmoteRegistryDumpEntry {
ref: string | null;
id: Snowflake;
name: string | null;
requires_colons: boolean;
animated: boolean;
url: string;
guild_id: Snowflake;
guild_name: string;
}
export interface EmoteRegistryDump {
version: number;
list: EmoteRegistryDumpEntry[];
}

166
src/defs/guild.ts Normal file
View File

@ -0,0 +1,166 @@
import {db} from "../modules/database";
import {Collection} from "discord.js";
const upsert = (column: string, bindParameter: string) =>
`INSERT INTO Guilds (ID, ${column}) VALUES (:id, :${bindParameter}) ON CONFLICT (ID) DO UPDATE SET ${column} = :${bindParameter} WHERE ID = :id`;
export class Guild {
public readonly id: string;
private _prefix: string | null;
private _welcomeType: "none" | "text" | "graphical";
private _welcomeChannel: string | null;
private _welcomeMessage: string | null;
private _streamingChannel: string | null;
private _hasMessageEmbeds: boolean;
private _streamingRoles: Collection<string, string>; // Role ID: Category Name
private _defaultChannelNames: Collection<string, string>; // Channel ID: Channel Name
private _autoRoles: string[]; // string array of role IDs
constructor(id: string) {
this.id = id;
const data = db.prepare("SELECT * FROM Guilds WHERE ID = ?").get(id);
const streamingRoles =
db.prepare("SELECT RoleID, Category FROM StreamingRoles WHERE GuildID = ?").all(id) ?? [];
const defaultChannelNames =
db.prepare("SELECT ChannelID, Name FROM DefaultChannelNames WHERE GuildID = ?").all(id) ?? [];
const autoRoles = db.prepare("SELECT RoleID FROM AutoRoles WHERE GuildID = ?").all(id) ?? [];
if (data) {
const {Prefix, WelcomeType, WelcomeChannel, WelcomeMessage, StreamingChannel, HasMessageEmbeds} = data;
this._prefix = Prefix;
this._welcomeType = "none";
this._welcomeChannel = WelcomeChannel;
this._welcomeMessage = WelcomeMessage;
this._streamingChannel = StreamingChannel;
this._hasMessageEmbeds = !!HasMessageEmbeds;
switch (WelcomeType) {
case 1:
this._welcomeType = "text";
break;
case 2:
this._welcomeType = "graphical";
break;
}
} else {
this._prefix = null;
this._welcomeType = "none";
this._welcomeChannel = null;
this._welcomeMessage = null;
this._streamingChannel = null;
this._hasMessageEmbeds = true;
}
this._streamingRoles = new Collection();
this._defaultChannelNames = new Collection();
this._autoRoles = [];
for (const {RoleID, Category} of streamingRoles) {
this._streamingRoles.set(RoleID, Category);
}
for (const {ChannelID, Name} of defaultChannelNames) {
this._defaultChannelNames.set(ChannelID, Name);
}
for (const {RoleID} of autoRoles) {
this._autoRoles.push(RoleID);
}
}
static all(): Guild[] {
const IDs = db
.prepare("SELECT ID FROM Guilds")
.all()
.map((guild) => guild.ID);
const guilds = [];
for (const id of IDs) {
guilds.push(new Guild(id));
}
return guilds;
}
get prefix() {
return this._prefix;
}
set prefix(prefix) {
this._prefix = prefix;
db.prepare(upsert("Prefix", "prefix")).run({
id: this.id,
prefix
});
}
get welcomeType() {
return this._welcomeType;
}
set welcomeType(welcomeType) {
this._welcomeType = welcomeType;
let welcomeTypeInt = 0;
switch (welcomeType) {
case "text":
welcomeTypeInt = 1;
break;
case "graphical":
welcomeTypeInt = 2;
break;
}
db.prepare(upsert("WelcomeType", "welcomeType")).run({
id: this.id,
welcomeTypeInt
});
}
get welcomeChannel() {
return this._welcomeChannel;
}
set welcomeChannel(welcomeChannel) {
this._welcomeChannel = welcomeChannel;
db.prepare(upsert("WelcomeChannel", "welcomeChannel")).run({
id: this.id,
welcomeChannel
});
}
get welcomeMessage() {
return this._welcomeMessage;
}
set welcomeMessage(welcomeMessage) {
this._welcomeMessage = welcomeMessage;
db.prepare(upsert("WelcomeMessage", "welcomeMessage")).run({
id: this.id,
welcomeMessage
});
}
get streamingChannel() {
return this._streamingChannel;
}
set streamingChannel(streamingChannel) {
this._streamingChannel = streamingChannel;
db.prepare(upsert("StreamingChannel", "streamingChannel")).run({
id: this.id,
streamingChannel
});
}
get hasMessageEmbeds() {
return this._hasMessageEmbeds;
}
set hasMessageEmbeds(hasMessageEmbeds) {
this._hasMessageEmbeds = hasMessageEmbeds;
db.prepare(upsert("HasMessageEmbeds", "hasMessageEmbeds")).run({
id: this.id,
hasMessageEmbeds: +hasMessageEmbeds
});
}
get streamingRoles() {
return this._streamingRoles; // Role ID: Category Name
}
get defaultChannelNames() {
return this._defaultChannelNames; // Channel ID: Channel Name
}
get autoRoles() {
return this._autoRoles; // string array of role IDs
}
}

39
src/defs/member.ts Normal file
View File

@ -0,0 +1,39 @@
import {db} from "../modules/database";
export class Member {
public readonly userID: string;
public readonly guildID: string;
private _streamCategory: string | null;
constructor(userID: string, guildID: string) {
this.userID = userID;
this.guildID = guildID;
const data = db.prepare("SELECT * FROM Members WHERE UserID = ? AND GuildID = ?").get(userID, guildID);
if (data) {
const {StreamCategory} = data;
this._streamCategory = StreamCategory;
} else {
this._streamCategory = null;
}
}
static all(): Member[] {
const IDs = db.prepare("SELECT UserID, GuildID FROM Members").all();
const members = [];
for (const {UserID, GuildID} of IDs) {
members.push(new Member(UserID, GuildID));
}
return members;
}
get streamCategory() {
return this._streamCategory;
}
set streamCategory(streamCategory) {
this._streamCategory = streamCategory;
db.prepare("INSERT INTO Members VALUES (?, ?, ?)").run(this.userID, this.guildID, streamCategory);
}
}

154
src/defs/user.ts Normal file
View File

@ -0,0 +1,154 @@
import {db} from "../modules/database";
import {Collection} from "discord.js";
const upsert = (column: string, bindParameter: string) =>
`INSERT INTO Users (ID, ${column}) VALUES (:id, :${bindParameter}) ON CONFLICT (ID) DO UPDATE SET ${column} = :${bindParameter} WHERE ID = :id`;
export class User {
public readonly id: string;
private _money: number;
private _lastReceived: number;
private _lastMonday: number;
private _timezoneOffset: number | null; // This is for the standard timezone only, not the daylight savings timezone
private _daylightSavingsRegion: "na" | "eu" | "sh" | "none";
private _ecoBetInsurance: number;
private _todoList: Collection<number, string>;
constructor(id: string) {
this.id = id;
const data = db.prepare("SELECT * FROM Users WHERE ID = ?").get(id);
const todoList = db.prepare("SELECT Timestamp, Entry FROM TodoLists WHERE UserID = ?").all(id) ?? [];
if (data) {
const {Money, LastReceived, LastMonday, TimezoneOffset, DaylightSavingsRegion, EcoBetInsurance} = data;
this._money = Money;
this._lastReceived = LastReceived;
this._lastMonday = LastMonday;
this._timezoneOffset = TimezoneOffset;
this._daylightSavingsRegion = "none";
this._ecoBetInsurance = EcoBetInsurance;
switch (DaylightSavingsRegion) {
case 1:
this._daylightSavingsRegion = "na";
break;
case 2:
this._daylightSavingsRegion = "eu";
break;
case 3:
this._daylightSavingsRegion = "sh";
break;
}
} else {
this._money = 0;
this._lastReceived = -1;
this._lastMonday = -1;
this._timezoneOffset = null;
this._daylightSavingsRegion = "none";
this._ecoBetInsurance = 0;
}
this._todoList = new Collection();
for (const {Timestamp, Entry} of todoList) {
this._todoList.set(Timestamp, Entry);
}
}
static all(): User[] {
const IDs = db
.prepare("SELECT ID FROM Users")
.all()
.map((user) => user.ID);
const users = [];
for (const id of IDs) {
users.push(new User(id));
}
return users;
}
get money() {
return this._money;
}
set money(money) {
this._money = money;
db.prepare(upsert("Money", "money")).run({
id: this.id,
money
});
}
get lastReceived() {
return this._lastReceived;
}
set lastReceived(lastReceived) {
this._lastReceived = lastReceived;
db.prepare(upsert("LastReceived", "lastReceived")).run({
id: this.id,
lastReceived
});
}
get lastMonday() {
return this._lastMonday;
}
set lastMonday(lastMonday) {
this._lastMonday = lastMonday;
db.prepare(upsert("LastMonday", "lastMonday")).run({
id: this.id,
lastMonday
});
}
get timezoneOffset() {
return this._timezoneOffset;
}
set timezoneOffset(timezoneOffset) {
this._timezoneOffset = timezoneOffset;
db.prepare(upsert("TimezoneOffset", "timezoneOffset")).run({
id: this.id,
timezoneOffset
});
}
get daylightSavingsRegion() {
return this._daylightSavingsRegion;
}
set daylightSavingsRegion(daylightSavingsRegion) {
this._daylightSavingsRegion = daylightSavingsRegion;
let dstInfo = 0;
switch (daylightSavingsRegion) {
case "na":
dstInfo = 1;
break;
case "eu":
dstInfo = 2;
break;
case "sh":
dstInfo = 3;
break;
}
db.prepare(upsert("DaylightSavingsRegion", "dstInfo")).run({
id: this.id,
dstInfo
});
}
get ecoBetInsurance() {
return this._ecoBetInsurance;
}
set ecoBetInsurance(ecoBetInsurance) {
this._ecoBetInsurance = ecoBetInsurance;
db.prepare(upsert("EcoBetInsurance", "ecoBetInsurance")).run({
id: this.id,
ecoBetInsurance
});
}
get todoList() {
return this._todoList;
}
// NOTE: Need to figure out an actual ID system
setTodoEntry(timestamp: number, entry: string) {
db.prepare("INSERT INTO TodoLists VALUES (?, ?, ?)").run(this.id, timestamp, entry);
}
}

View File

@ -18,8 +18,7 @@ export const client = new Client({
import {join} from "path";
import {launch} from "onion-lasers";
import {getPrefix} from "./structures";
import {toTitleCase} from "./lib";
import {getPrefix, toTitleCase} from "./lib";
// Send the login request to Discord's API and then load modules while waiting for it.
client.login(process.env.TOKEN).catch(console.error);

View File

@ -1,6 +1,17 @@
// Library for pure functions
import {get} from "https";
import FileManager from "./modules/storage";
import {join} from "path";
import {existsSync, mkdirSync} from "fs";
import {Guild as DiscordGuild} from "discord.js";
import {Guild} from "./defs/guild";
// Instantiating a class has the same effect as a SELECT statement.
// Through getters/setters, it saves basically through INSERT statements.
// new User(<id that doesn't exist>) will create a new entry in the Users table.
export {config} from "./defs/config";
export {User} from "./defs/user";
export {Guild} from "./defs/guild";
export {Member} from "./defs/member";
export {EmoteRegistryDump, EmoteRegistryDumpEntry} from "./defs/emoteRegistry";
/**
* Splits a command by spaces while accounting for quotes which capture string arguments.
@ -13,7 +24,7 @@ export function parseArgs(line: string): string[] {
let inString = false;
let isEscaped = false;
for (let c of line) {
for (const c of line) {
if (isEscaped) {
if (['"', "\\"].includes(c)) selection += c;
else selection += "\\" + c;
@ -92,28 +103,6 @@ export function parseVarsCallback(line: string, callback: (variable: string) =>
return result;
}
export function isType(value: any, type: any): boolean {
if (value === undefined && type === undefined) return true;
else if (value === null && type === null) return true;
else return value !== undefined && value !== null && value.constructor === type;
}
/**
* Checks a value to see if it matches the fallback's type, otherwise returns the fallback.
* For the purposes of the templates system, this function will only check array types, objects should be checked under their own type (as you'd do anyway with something like a User object).
* If at any point the value doesn't match the data structure provided, the fallback is returned.
* Warning: Type checking is based on the fallback's type. Be sure that the "type" parameter is accurate to this!
*/
export function select<T>(value: any, fallback: T, type: Function, isArray = false): T {
if (isArray && isType(value, Array)) {
for (let item of value) if (!isType(item, type)) return fallback;
return value;
} else {
if (isType(value, type)) return value;
else return fallback;
}
}
export function clean(text: unknown) {
if (typeof text === "string")
return text.replace(/`/g, "`" + String.fromCharCode(8203)).replace(/@/g, "@" + String.fromCharCode(8203));
@ -167,29 +156,6 @@ export function getContent(url: string): Promise<{url: string}> {
});
}
export interface GenericJSON {
[key: string]: any;
}
// In order to define a file to write to while also not:
// - Using the delete operator (which doesn't work on properties which cannot be undefined)
// - Assigning it first then using Object.defineProperty (which raises a flag on CodeQL)
// A non-null assertion is used on the class property to say that it'll definitely be assigned.
export abstract class GenericStructure {
private __meta__!: string;
constructor(tag?: string) {
Object.defineProperty(this, "__meta__", {
value: tag || "generic",
enumerable: false
});
}
public save(asynchronous = true) {
FileManager.write(this.__meta__, this, asynchronous);
}
}
// A 50% chance would be "Math.random() < 0.5" because Math.random() can be [0, 1), so to make two equal ranges, you'd need [0, 0.5)U[0.5, 1).
// Similar logic would follow for any other percentage. Math.random() < 1 is always true (100% chance) and Math.random() < 0 is always false (0% chance).
export const Random = {
@ -269,3 +235,35 @@ export function split<T>(array: T[], lengthOfEachSection: number): T[][] {
export function requireAllCasesHandledFor(variable: never): never {
throw new Error(`This function should never be called but got the value: ${variable}`);
}
/**
* Provide a path to create folders for.
* `createPath("data", "public", "tmp")` creates `<root>/data/public/tmp`
*/
export function createPath(...path: string[]): void {
let currentPath = ""; // path.join ignores empty strings
for (const folder of path) {
currentPath = join(currentPath, folder);
if (!existsSync(currentPath)) {
mkdirSync(currentPath);
}
}
}
/**
* Get the current prefix of the guild or the bot's prefix if none is found.
*/
export function getPrefix(guild?: DiscordGuild | null): string {
if (guild) {
const possibleGuildPrefix = new Guild(guild.id).prefix;
// Here, lossy comparison works in our favor because you wouldn't want an empty string to trigger the prefix.
if (possibleGuildPrefix) {
return possibleGuildPrefix;
}
}
return process.env.PREFIX || "$";
}

View File

@ -1,17 +1,17 @@
import {client} from "../index";
import {Storage} from "../structures";
import {Guild} from "../lib";
import {Permissions} from "discord.js";
client.on("voiceStateUpdate", async (before, after) => {
const channel = before.channel;
const {channelNames} = Storage.getGuild(after.guild.id);
const {defaultChannelNames} = new Guild(after.guild.id);
if (
channel &&
channel.members.size === 0 &&
channel.id in channelNames &&
defaultChannelNames.has(channel.id) &&
before.guild.me?.permissions.has(Permissions.FLAGS.MANAGE_CHANNELS)
) {
channel.setName(channelNames[channel.id]);
channel.setName(defaultChannelNames.get(channel.id)!);
}
});

View File

@ -1,5 +1,5 @@
import Database from "better-sqlite3";
import {existsSync} from "fs";
import {existsSync, readFileSync} from "fs";
import {join} from "path";
// This section will serve as the documentation for the database, because in order to guarantee
@ -12,6 +12,7 @@ import {join} from "path";
// -=[ Current Schema ]=-
// System: Version (INT UNIQUE)
// Settings: Group, SystemLogsChannel (TEXT NULLABLE)
// Users: ID, Money (INT), LastReceived (TIME), LastMonday (TIME), TimezoneOffset (INT NULLABLE), DaylightSavingsRegion (INT), EcoBetInsurance (INT)
// Guilds: ID, Prefix (TEXT NULLABLE), WelcomeType (INT), WelcomeChannel (TEXT NULLABLE), WelcomeMessage (TEXT NULLABLE), StreamingChannel (TEXT NULLABLE), HasMessageEmbeds (BOOL)
// Members: UserID, GuildID, StreamCategory (TEXT NULLABLE)
@ -22,18 +23,25 @@ import {join} from "path";
// AutoRoles: GuildID, RoleID
// -=[ Notes ]=-
// - System is a special table and its sole purpose is to store the version number.
// - In theory, you could create different settings groups if you really wanted to.
// - Unless otherwise directed above (NULLABLE), assume the "NOT NULL" constraint.
// - IDs use the "UNIQUE ON CONFLICT REPLACE" constraint to enable implicit UPSERT statements.
// - This way, you don't need to do INSERT INTO ... ON CONFLICT(...) DO UPDATE SET ...
// - IDs use the "ON CONFLICT REPLACE" constraint to enable implicit UPSERT statements.
// - Note that you cannot replace a single column with this.
// - For the sake of simplicity, any Discord ID will be stored and retrieved as a string.
// - Any datetime stuff (marked as TIME) will be stored as a UNIX timestamp in milliseconds (INT).
// - Booleans (marked as BOOL) will be stored as an integer, either 0 or 1 (though it just checks for 0).
const DATA_FOLDER = "data";
const DATABASE_FILE = join(DATA_FOLDER, "main.db");
// Calling migrations[2]() migrates the database from version 2 to version 3.
// NOTE: Once a migration is written, DO NOT change that migration or it'll break all future migrations.
const migrations: (() => void)[] = [
() => {
const hasLegacyData = existsSync(join("data", "config.json")) && existsSync(join("data", "storage.json"));
const CONFIG_FILE = join(DATA_FOLDER, "config.json");
const STORAGE_FILE = join(DATA_FOLDER, "storage.json");
const hasLegacyData = existsSync(CONFIG_FILE) && existsSync(STORAGE_FILE);
// Generate initial state
// Stuff like CREATE TABLE IF NOT EXISTS should be handled by the migration system.
@ -42,8 +50,13 @@ const migrations: (() => void)[] = [
Version INT NOT NULL UNIQUE
)`,
"INSERT INTO System VALUES (1)",
`CREATE TABLE Settings (
Tag TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE,
SystemLogsChannel TEXT
)`,
"INSERT INTO Settings (Tag) VALUES ('Main')",
`CREATE TABLE Users (
ID TEXT NOT NULL UNIQUE ON CONFLICT REPLACE,
ID TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE,
Money INT NOT NULL DEFAULT 0,
LastReceived INT NOT NULL DEFAULT -1,
LastMonday INT NOT NULL DEFAULT -1,
@ -52,7 +65,7 @@ const migrations: (() => void)[] = [
EcoBetInsurance INT NOT NULL DEFAULT 0
)`,
`CREATE TABLE Guilds (
ID TEXT NOT NULL UNIQUE ON CONFLICT REPLACE,
ID TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE,
Prefix TEXT,
WelcomeType INT NOT NULL DEFAULT 0,
WelcomeChannel TEXT,
@ -64,10 +77,10 @@ const migrations: (() => void)[] = [
UserID TEXT NOT NULL,
GuildID TEXT NOT NULL,
StreamCategory TEXT,
UNIQUE (UserID, GuildID) ON CONFLICT REPLACE
PRIMARY KEY (UserID, GuildID) ON CONFLICT REPLACE
)`,
`CREATE TABLE Webhooks (
ID TEXT NOT NULL UNIQUE ON CONFLICT REPLACE,
ID TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE,
Token TEXT NOT NULL
)`,
`CREATE TABLE TodoLists (
@ -77,30 +90,128 @@ const migrations: (() => void)[] = [
)`,
`CREATE TABLE StreamingRoles (
GuildID TEXT NOT NULL,
RoleID TEXT NOT NULL UNIQUE ON CONFLICT REPLACE,
RoleID TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE,
Category TEXT NOT NULL
)`,
`CREATE TABLE DefaultChannelNames (
GuildID TEXT NOT NULL,
ChannelID TEXT NOT NULL UNIQUE ON CONFLICT REPLACE,
ChannelID TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE,
Name TEXT NOT NULL
)`,
`CREATE TABLE AutoRoles (
GuildID TEXT NOT NULL,
RoleID TEXT NOT NULL UNIQUE ON CONFLICT REPLACE
RoleID TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE
)`
])();
// Load initial data if present
if (hasLegacyData) {
generateSQLMigration([])();
const config = JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
const {users, guilds} = JSON.parse(readFileSync(STORAGE_FILE, "utf-8"));
db.prepare("INSERT INTO Settings VALUES ('Main', ?)").run(config.systemLogsChannel);
for (const [id, token] of Object.entries(config.webhooks)) {
db.prepare("INSERT INTO Webhooks VALUES (?, ?)").run(id, token);
}
for (const id in users) {
const {money, lastReceived, lastMonday, timezone, daylightSavingsRegion, todoList, ecoBetInsurance} =
users[id];
let dstInfo = 0;
if (daylightSavingsRegion !== null) {
switch (daylightSavingsRegion) {
case "na":
dstInfo = 1;
break;
case "eu":
dstInfo = 2;
break;
case "sh":
dstInfo = 3;
break;
}
}
db.prepare("INSERT INTO Users VALUES (?, ?, ?, ?, ?, ?, ?)").run(
id,
money,
lastReceived,
lastMonday,
timezone,
dstInfo,
ecoBetInsurance
);
for (const timestamp in todoList) {
const entry = todoList[timestamp];
db.prepare("INSERT INTO TodoLists VALUES (?, ?, ?)").run(id, Number(timestamp), entry);
}
}
for (const id in guilds) {
const {
prefix,
messageEmbeds,
welcomeChannel,
welcomeMessage,
autoRoles,
streamingChannel,
streamingRoles,
channelNames,
members,
welcomeType
} = guilds[id];
let welcomeTypeInt = 0;
switch (welcomeType) {
case "text":
welcomeTypeInt = 1;
break;
case "graphical":
welcomeTypeInt = 2;
break;
}
db.prepare("INSERT INTO Guilds VALUES (?, ?, ?, ?, ?, ?, ?)").run(
id,
prefix,
welcomeTypeInt,
welcomeChannel,
welcomeMessage,
streamingChannel,
+messageEmbeds
);
for (const userID in members) {
const {streamCategory} = members[userID];
db.prepare("INSERT INTO Members VALUES (?, ?, ?)").run(userID, id, streamCategory);
}
for (const roleID in streamingRoles) {
const category = streamingRoles[roleID];
db.prepare("INSERT INTO StreamingRoles VALUES (?, ?, ?)").run(id, roleID, category);
}
for (const channelID in channelNames) {
const channelName = channelNames[channelID];
db.prepare("INSERT INTO DefaultChannelNames VALUES (?, ?, ?)").run(id, channelID, channelName);
}
if (autoRoles) {
for (const roleID of autoRoles) {
db.prepare("INSERT INTO AutoRoles VALUES (?, ?)").run(id, roleID);
}
}
}
}
}
// "UPDATE System SET Version=2" when the time comes
// generateSQLMigration(["UPDATE System SET Version = 2"])
];
const isExistingDatabase = existsSync(join("data", "main.db"));
export const db = new Database(join("data", "main.db"));
const isExistingDatabase = existsSync(DATABASE_FILE);
export const db = new Database(DATABASE_FILE);
let version = -1;
// Get existing version if applicable and throw error if corrupt data.

View File

@ -1,6 +1,7 @@
import {client} from "../index";
import FileManager from "./storage";
import {EmoteRegistryDump} from "../structures";
import {createPath, EmoteRegistryDump} from "../lib";
import {writeFile} from "fs/promises";
import {join} from "path";
function updateGlobalEmoteRegistry(): void {
const data: EmoteRegistryDump = {version: 1, list: []};
@ -20,8 +21,8 @@ function updateGlobalEmoteRegistry(): void {
}
}
FileManager.open("data/public"); // generate folder if it doesn't exist
FileManager.write("public/emote-registry", data, true);
createPath("public"); // generate folder if it doesn't exist
writeFile(join("public", "emote-registry.json"), JSON.stringify(data, null, "\t")).catch(console.error);
}
client.on("emojiCreate", updateGlobalEmoteRegistry);

View File

@ -1,7 +1,6 @@
import {createCanvas, loadImage, Canvas} from "canvas";
import {TextChannel, MessageAttachment} from "discord.js";
import {parseVars} from "../lib";
import {Storage} from "../structures";
import {Guild, parseVars} from "../lib";
import {client} from "../index";
function applyText(canvas: Canvas, text: string) {
@ -16,7 +15,7 @@ function applyText(canvas: Canvas, text: string) {
}
client.on("guildMemberAdd", async (member) => {
const {welcomeType, welcomeChannel, welcomeMessage, autoRoles} = Storage.getGuild(member.guild.id);
const {welcomeType, welcomeChannel, welcomeMessage, autoRoles} = new Guild(member.guild.id);
if (autoRoles) {
member.roles.add(autoRoles);

View File

@ -1,6 +1,6 @@
import {client} from "../index";
import {MessageEmbed} from "discord.js";
import {getPrefix} from "../structures";
import {getPrefix} from "../lib";
import {getMessageByID} from "onion-lasers";
client.on("messageCreate", (message) => {

View File

@ -1,5 +1,5 @@
import {client} from "../index";
import {Storage, getPrefix} from "../structures";
import {User, getPrefix} from "../lib";
client.once("ready", () => {
if (client.user) {
@ -12,12 +12,12 @@ client.once("ready", () => {
});
// Run this setup block once to restore eco bet money in case the bot went down. (And I guess search the client for those users to let them know too.)
for (const id in Storage.users) {
const user = Storage.users[id];
const users = User.all();
for (const user of users) {
if (user.ecoBetInsurance > 0) {
client.users.cache
.get(id)
.get(user.id)
?.send(
`Because my system either crashed or restarted while you had a pending bet, the total amount of money that you bet, which was \`${user.ecoBetInsurance}\`, has been restored.`
);
@ -25,6 +25,5 @@ client.once("ready", () => {
user.ecoBetInsurance = 0;
}
}
Storage.save();
}
});

View File

@ -1,70 +0,0 @@
// Handles most of the file system operations, all of the ones related to `data` at least.
import fs from "fs";
const Storage = {
read(header: string): object {
this.open("data");
const path = `data/${header}.json`;
let data = {};
if (fs.existsSync(path)) {
const file = fs.readFileSync(path, "utf-8");
try {
data = JSON.parse(file);
} catch (error) {
console.error(error, file);
if (!process.env.DEV) {
console.warn("[storage.read]", `Malformed JSON data (header: ${header}), backing it up.`, file);
fs.writeFile(`${path}.backup`, file, (error) => {
if (error) console.error("[storage.read]", error);
console.log("[storage.read]", `Backup file of "${header}" successfully written as ${file}.`);
});
}
}
}
return data;
},
// There is no need to log successfully written operations as it pollutes the log with useless info for debugging.
write(header: string, data: object, asynchronous = true) {
this.open("data");
const path = `data/${header}.json`;
if (process.env.DEV || header === "config") {
const result = JSON.stringify(data, null, "\t");
if (asynchronous)
fs.writeFile(path, result, (error) => {
if (error) console.error("[storage.write]", error);
});
else fs.writeFileSync(path, result);
} else {
const result = JSON.stringify(data);
if (asynchronous)
fs.writeFile(path, result, (error) => {
if (error) console.error("[storage.write]", error);
});
else fs.writeFileSync(path, result);
}
},
open(path: string, filter?: (value: string, index: number, array: string[]) => unknown): string[] {
if (!fs.existsSync(path)) fs.mkdirSync(path);
let directory = fs.readdirSync(path);
if (filter) directory = directory.filter(filter);
return directory;
},
close(path: string) {
if (fs.existsSync(path) && fs.readdirSync(path).length === 0)
fs.rmdir(path, (error) => {
if (error) console.error("[storage.close]", error);
});
}
};
export default Storage;

View File

@ -1,6 +1,6 @@
import {GuildMember, VoiceChannel, MessageEmbed, TextChannel, Message, Collection, StageChannel} from "discord.js";
import {client} from "../index";
import {Storage} from "../structures";
import {Guild, Member} from "../lib";
type Stream = {
streamer: GuildMember;
@ -61,7 +61,7 @@ client.on("voiceStateUpdate", async (before, after) => {
// Note: isStopStreamEvent can be called twice in a row - If Discord crashes/quits while you're streaming, it'll call once with a null channel and a second time with a channel.
if (isStartStreamEvent || isStopStreamEvent) {
const {streamingChannel, streamingRoles, members} = Storage.getGuild(after.guild.id);
const {streamingChannel, streamingRoles} = new Guild(after.guild.id);
if (streamingChannel) {
const member = after.member!;
@ -76,14 +76,12 @@ client.on("voiceStateUpdate", async (before, after) => {
let category = "None";
// Check the category if there's one set then ping that role.
if (member.id in members) {
const roleID = members[member.id].streamCategory;
const roleID = new Member(member.id, after.guild.id).streamCategory;
// Only continue if they set a valid category.
if (roleID && roleID in streamingRoles) {
streamNotificationPing = `<@&${roleID}>`;
category = streamingRoles[roleID];
}
// Only continue if they set a valid category.
if (roleID && streamingRoles.has(roleID)) {
streamNotificationPing = `<@&${roleID}>`;
category = streamingRoles.get(roleID)!;
}
streamList.set(member.id, {

View File

@ -1,24 +1,23 @@
import {client} from "../index";
import {TextChannel} from "discord.js";
import {Config} from "../structures";
import {config} from "../lib";
// Logging which guilds the bot is added to and removed from makes sense.
// However, logging the specific channels that are added/removed is a tad bit privacy-invading.
client.on("guildCreate", async (guild) => {
const owner = await guild.fetchOwner();
console.log(`[GUILD JOIN] ${guild.name} (${guild.id}) added the bot. Owner: ${owner.user.tag} (${owner.user.id}).`);
if (Config.systemLogsChannel) {
const channel = client.channels.cache.get(Config.systemLogsChannel);
if (config.systemLogsChannel) {
const channel = client.channels.cache.get(config.systemLogsChannel);
if (channel instanceof TextChannel) {
channel.send(
`TravBot joined: \`${guild.name}\`. The owner of this guild is: \`${owner.user.tag}\` (\`${owner.user.id}\`)`
);
} else {
console.warn(`${Config.systemLogsChannel} is not a valid text channel for system logs!`);
console.warn(`${config.systemLogsChannel} is not a valid text channel for system logs!`);
}
}
});
@ -26,17 +25,16 @@ client.on("guildCreate", async (guild) => {
client.on("guildDelete", (guild) => {
console.log(`[GUILD LEAVE] ${guild.name} (${guild.id}) removed the bot.`);
if (Config.systemLogsChannel) {
const channel = client.channels.cache.get(Config.systemLogsChannel);
if (config.systemLogsChannel) {
const channel = client.channels.cache.get(config.systemLogsChannel);
if (channel instanceof TextChannel) {
channel.send(`\`${guild.name}\` (\`${guild.id}\`) removed the bot.`);
} else {
console.warn(
`${Config.systemLogsChannel} is not a valid text channel for system logs! Removing it from storage.`
`${config.systemLogsChannel} is not a valid text channel for system logs! Removing it from storage.`
);
Config.systemLogsChannel = null;
Config.save();
config.systemLogsChannel = null;
}
}
});

View File

@ -1,6 +1,6 @@
import {Webhook, TextChannel, NewsChannel, Permissions, Collection} from "discord.js";
import {client} from "..";
import {Config} from "../structures";
import {config} from "../lib";
export const webhookStorage = new Collection<string, Webhook>(); // Channel ID: Webhook
const WEBHOOK_PATTERN = /https:\/\/discord\.com\/api\/webhooks\/(\d{17,})\/(.+)/;
@ -23,8 +23,7 @@ export async function resolveWebhook(channel: TextChannel | NewsChannel): Promis
export function registerWebhook(url: string): boolean {
if (WEBHOOK_PATTERN.test(url)) {
const [_, id, token] = WEBHOOK_PATTERN.exec(url)!;
Config.webhooks[id] = token;
Config.save();
config.setWebhook(id, token);
refreshWebhookCache();
return true;
} else {
@ -39,8 +38,7 @@ export function deleteWebhook(urlOrID: string): boolean {
else if (ID_PATTERN.test(urlOrID)) id = ID_PATTERN.exec(urlOrID)![1];
if (id) {
delete Config.webhooks[id];
Config.save();
delete config.webhooks[id];
refreshWebhookCache();
}

View File

@ -1,245 +0,0 @@
// Contains all the code handling dynamic JSON data. Has a one-to-one connection with each file generated, for example, `Config` which calls `super("config")` meaning it writes to `data/config.json`.
import FileManager from "./modules/storage";
import {select, GenericJSON, GenericStructure} from "./lib";
import {watch} from "fs";
import {Guild as DiscordGuild, Snowflake} from "discord.js";
// Maybe use getters and setters to auto-save on set?
// And maybe use Collections/Maps instead of objects?
class ConfigStructure extends GenericStructure {
public systemLogsChannel: string | null;
public webhooks: {[id: string]: string}; // id-token pairs
constructor(data: GenericJSON) {
super("config");
this.systemLogsChannel = select(data.systemLogsChannel, null, String);
this.webhooks = {};
for (const id in data.webhooks) {
const token = data.webhooks[id];
if (/\d{17,}/g.test(id) && typeof token === "string") {
this.webhooks[id] = token;
}
}
}
}
class User {
public money: number;
public lastReceived: number;
public lastMonday: number;
public timezone: number | null; // This is for the standard timezone only, not the daylight savings timezone
public daylightSavingsRegion: "na" | "eu" | "sh" | null;
public todoList: {[timestamp: string]: string};
public ecoBetInsurance: number;
constructor(data?: GenericJSON) {
this.money = select(data?.money, 0, Number);
this.lastReceived = select(data?.lastReceived, -1, Number);
this.lastMonday = select(data?.lastMonday, -1, Number);
this.timezone = data?.timezone ?? null;
this.daylightSavingsRegion = /^((na)|(eu)|(sh))$/.test(data?.daylightSavingsRegion)
? data?.daylightSavingsRegion
: null;
this.todoList = {};
this.ecoBetInsurance = select(data?.ecoBetInsurance, 0, Number);
if (data) {
for (const timestamp in data.todoList) {
const note = data.todoList[timestamp];
if (typeof note === "string") {
this.todoList[timestamp] = note;
}
}
}
}
}
class Member {
public streamCategory: string | null;
constructor(data?: GenericJSON) {
this.streamCategory = select(data?.streamCategory, null, String);
}
}
class Guild {
public prefix: string | null;
public messageEmbeds: boolean | null;
public welcomeType: "none" | "text" | "graphical";
public welcomeChannel: string | null;
public welcomeMessage: string | null;
public autoRoles: string[] | null; // StringArray of role IDs
public streamingChannel: string | null;
public streamingRoles: {[role: string]: string}; // Role ID: Category Name
public channelNames: {[channel: string]: string};
public members: {[id: string]: Member};
constructor(data?: GenericJSON) {
this.prefix = select(data?.prefix, null, String);
this.messageEmbeds = select(data?.messageLinks, true, Boolean);
this.welcomeChannel = select(data?.welcomeChannel, null, String);
this.welcomeMessage = select(data?.welcomeMessage, null, String);
this.autoRoles = select(data?.autoRoles, null, String, true);
this.streamingChannel = select(data?.streamingChannel, null, String);
this.streamingRoles = {};
this.channelNames = {};
this.members = {};
switch (data?.welcomeType) {
case "text":
this.welcomeType = "text";
break;
case "graphical":
this.welcomeType = "graphical";
break;
default:
this.welcomeType = "none";
break;
}
if (data?.streamingRoles) {
for (const id in data.streamingRoles) {
const category = data.streamingRoles[id];
if (/\d{17,}/g.test(id) && typeof category === "string") {
this.streamingRoles[id] = category;
}
}
}
if (data?.channelNames) {
for (const id in data.channelNames) {
const name = data.channelNames[id];
if (/\d{17,}/g.test(id) && typeof name === "string") {
this.channelNames[id] = name;
}
}
}
if (data?.members) {
for (let id in data.members) {
if (/\d{17,}/g.test(id)) {
this.members[id] = new Member(data.members[id]);
}
}
}
}
/** Gets a member's profile if they exist and generate one if not. */
public getMember(id: string): Member {
if (!/\d{17,}/g.test(id))
console.warn(
"[structures]",
`"${id}" is not a valid user ID! It will be erased when the data loads again.`
);
if (id in this.members) return this.members[id];
else {
const member = new Member();
this.members[id] = member;
return member;
}
}
}
class StorageStructure extends GenericStructure {
public users: {[id: string]: User};
public guilds: {[id: string]: Guild};
constructor(data: GenericJSON) {
super("storage");
this.users = {};
this.guilds = {};
for (let id in data.users) if (/\d{17,}/g.test(id)) this.users[id] = new User(data.users[id]);
for (let id in data.guilds) if (/\d{17,}/g.test(id)) this.guilds[id] = new Guild(data.guilds[id]);
}
/** Gets a user's profile if they exist and generate one if not. */
public getUser(id: string): User {
if (!/\d{17,}/g.test(id))
console.warn(
"[structures]",
`"${id}" is not a valid user ID! It will be erased when the data loads again.`
);
if (id in this.users) return this.users[id];
else {
const user = new User();
this.users[id] = user;
return user;
}
}
/** Gets a guild's settings if they exist and generate one if not. */
public getGuild(id: string): Guild {
if (!/\d{17,}/g.test(id))
console.warn(
"[structures]",
`"${id}" is not a valid guild ID! It will be erased when the data loads again.`
);
if (id in this.guilds) return this.guilds[id];
else {
const guild = new Guild();
this.guilds[id] = guild;
return guild;
}
}
}
// Exports instances. Don't worry, importing it from different files will load the same instance.
export let Config = new ConfigStructure(FileManager.read("config"));
export let Storage = new StorageStructure(FileManager.read("storage"));
// This part will allow the user to manually edit any JSON files they want while the program is running which'll update the program's cache.
// However, fs.watch is a buggy mess that should be avoided in production. While it helps test out stuff for development, it's not a good idea to have it running outside of development as it causes all sorts of issues.
if (process.env.DEV) {
watch("data", (_event, filename) => {
const header = filename.substring(0, filename.indexOf(".json"));
switch (header) {
case "config":
Config = new ConfigStructure(FileManager.read("config"));
break;
case "storage":
Storage = new StorageStructure(FileManager.read("storage"));
break;
}
});
}
/**
* Get the current prefix of the guild or the bot's prefix if none is found.
*/
export function getPrefix(guild?: DiscordGuild | null): string {
if (guild) {
const possibleGuildPrefix = Storage.getGuild(guild.id).prefix;
// Here, lossy comparison works in our favor because you wouldn't want an empty string to trigger the prefix.
if (possibleGuildPrefix) {
return possibleGuildPrefix;
}
}
return process.env.PREFIX || "$";
}
export interface EmoteRegistryDumpEntry {
ref: string | null;
id: Snowflake;
name: string | null;
requires_colons: boolean;
animated: boolean;
url: string;
guild_id: Snowflake;
guild_name: string;
}
export interface EmoteRegistryDump {
version: number;
list: EmoteRegistryDumpEntry[];
}