diff --git a/src/commands/fun/eco.ts b/src/commands/fun/eco.ts index da7d97a..022383a 100644 --- a/src/commands/fun/eco.ts +++ b/src/commands/fun/eco.ts @@ -3,6 +3,7 @@ import {isAuthorized, getMoneyEmbed} from "./modules/eco-utils"; import {DailyCommand, PayCommand, GuildCommand, LeaderboardCommand} from "./modules/eco-core"; import {BuyCommand, ShopCommand} from "./modules/eco-shop"; import {MondayCommand} from "./modules/eco-extras"; +import {BetCommand} from "./modules/eco-bet"; export default new NamedCommand({ description: "Economy command for Monika.", @@ -16,7 +17,8 @@ export default new NamedCommand({ leaderboard: LeaderboardCommand, buy: BuyCommand, shop: ShopCommand, - monday: MondayCommand + monday: MondayCommand, + bet: BetCommand }, id: "user", user: new Command({ diff --git a/src/commands/fun/modules/eco-bet.ts b/src/commands/fun/modules/eco-bet.ts new file mode 100644 index 0000000..f3ea452 --- /dev/null +++ b/src/commands/fun/modules/eco-bet.ts @@ -0,0 +1,198 @@ +import {Command, NamedCommand, askYesOrNo} from "../../../core"; +import {pluralise} from "../../../lib"; +import {Storage} from "../../../structures"; +import {isAuthorized, getMoneyEmbed, getSendEmbed, ECO_EMBED_COLOR} from "./eco-utils"; +import {User} from "discord.js"; + +export const BetCommand = new NamedCommand({ + description: "Bet your Mons with other people.", + usage: " ", + run: "Who are you betting with?", + user: new Command({ + description: "User to bet with.", + // handles missing amount argument + async run({args, author, channel, guild}) { + if (isAuthorized(guild, channel)) { + const target = args[0]; + + // handle invalid target + if (target.id == author.id) return channel.send("You can't bet Mons with yourself!"); + else if (target.bot && process.argv[2] !== "dev") return channel.send("You can't bet Mons with a bot!"); + + return channel.send("How much are you betting?"); + } else return; + }, + number: new Command({ + description: "Amount of Mons to bet.", + // handles missing duration argument + async run({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 amount = Math.floor(args[1]); + + // handle invalid target + if (target.id == author.id) return channel.send("You can't bet Mons with yourself!"); + else if (target.bot && process.argv[2] !== "dev") + return channel.send("You can't bet Mons with a bot!"); + + // handle invalid amount + if (amount <= 0) return channel.send("You must bet at least one Mon!"); + else if (sender.money < amount) + return channel.send("You don't have enough Mons for that.", getMoneyEmbed(author)); + else if (receiver.money < amount) + return channel.send("They don't have enough Mons for that.", getMoneyEmbed(target)); + + return channel.send("How long until the bet ends?"); + } else return; + }, + any: new Command({ + description: "Duration of the bet.", + async run({client, 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 amount = Math.floor(args[1]); + const duration = parseDuration(args[2].trim()); + + // handle invalid target + if (target.id == author.id) return channel.send("You can't bet Mons with yourself!"); + else if (target.bot && process.argv[2] !== "dev") + return channel.send("You can't bet Mons with a bot!"); + + // handle invalid amount + if (amount <= 0) return channel.send("You must bet at least one Mon!"); + else if (sender.money < amount) + return channel.send("You don't have enough Mons for that.", getMoneyEmbed(author)); + else if (receiver.money < amount) + return channel.send("They don't have enough Mons for that.", getMoneyEmbed(target)); + + // handle invalid duration + if (duration <= 0) return channel.send("Invalid bet duration"); + else if (duration <= parseDuration(durationBounds.min)) + return channel.send(`Bet duration is too short, maximum duration is ${durationBounds.min}`); + else if (duration >= parseDuration(durationBounds.max)) + return channel.send(`Bet duration is too long, maximum duration is ${durationBounds.max}`); + + // Ask target whether or not they want to take the bet. + const takeBet = await askYesOrNo( + await channel.send( + `<@${target.id}>, do you want to take this bet of ${pluralise(amount, "Mon", "s")}` + ), + target.id + ); + + if (takeBet) { + // [MEDIUM PRIORITY: bet persistence to prevent losses in case of shutdown.] + // Remove amount money from both parts at the start to avoid duplication of money. + sender.money -= amount; + receiver.money -= amount; + // 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. + await channel.send( + `<@${target.id}> has taken <@${author.id}>'s bet, the bet amount of ${pluralise( + amount, + "Mon", + "s" + )} has been deducted from each of them.` + ); + + // Wait for the duration of the bet. + return client.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); + // [TODO: when D.JSv13 comes out, inline reply to clean up.] + // When bet is over, give a vote to ask people their thoughts. + const voteMsg = await channel.send( + `VOTE: do you think that <@${ + target.id + }> has won the bet?\nhttps://discord.com/channels/${guild!.id}/${channel.id}/${ + message.id + }` + ); + await voteMsg.react("✅"); + await voteMsg.react("❌"); + + // Filter reactions to only collect the pertinent ones. + voteMsg + .awaitReactions( + (reaction, user) => { + return ["✅", "❌"].includes(reaction.emoji.name); + }, + // [Pertinence to make configurable on the fly.] + {time: parseDuration("2m")} + ) + .then((reactions) => { + // Count votes + const okReaction = reactions.get("✅"); + const noReaction = reactions.get("❌"); + const ok = okReaction ? (okReaction.count ?? 1) - 1 : 0; + const no = noReaction ? (noReaction.count ?? 1) - 1 : 0; + + if (ok > no) { + receiver.money += amount * 2; + channel.send( + `By the people's votes, <@${target.id}> has won the bet that <@${author.id}> had sent them.` + ); + } else if (ok < no) { + sender.money += amount * 2; + channel.send( + `By the people's votes, <@${target.id}> has lost the bet that <@${author.id}> had sent them.` + ); + } else { + sender.money += amount; + receiver.money += amount; + channel.send( + `By the people's votes, <@${target.id}> couldn't be determined to have won or lost the bet that <@${author.id}> had sent them.` + ); + } + sender.ecoBetInsurance -= amount; + receiver.ecoBetInsurance -= amount; + Storage.save(); + }); + }, duration); + } else return await channel.send(`<@${target.id}> has rejected your bet, <@${author.id}>`); + } else return; + } + }) + }) + }) +}); + +/** + * Parses a duration string into milliseconds + * Examples: + * - 3d -> 3 days -> 259200000ms + * - 2h -> 2 hours -> 7200000ms + * - 7m -> 7 minutes -> 420000ms + * - 3s -> 3 seconds -> 3000ms + */ +function parseDuration(duration: string): number { + // extract last char as unit + const unit = duration[duration.length - 1].toLowerCase(); + // get the rest as value + let value: number = +duration.substring(0, duration.length - 1); + + if (!["d", "h", "m", "s"].includes(unit) || isNaN(value)) return 0; + + if (unit === "d") value *= 86400000; + // 1000ms * 60s * 60m * 24h + else if (unit === "h") value *= 3600000; + // 1000ms * 60s * 60m + else if (unit === "m") value *= 60000; + // 1000ms * 60s + else if (unit === "s") value *= 1000; // 1000ms + + return value; +} diff --git a/src/commands/fun/whois.ts b/src/commands/fun/whois.ts index cee90c0..d7285f6 100644 --- a/src/commands/fun/whois.ts +++ b/src/commands/fun/whois.ts @@ -32,6 +32,8 @@ const registry: {[id: string]: string} = { "You are, uhh, Stay Put, Soft Puppy, Es-Pee, Swift Pacemaker, Smug Poyo, and many more.\n...Seriously, this woman has too many names.", "243061915281129472": "Some random conlanger, worldbuilder and programmer doofus. ~~May also secretly be a nyan. :3~~", + "792751612904603668": + "Some random nyan. :3 ~~May also secretly be a conlanger, worldbuilder and programmer doofus.~~", "367439475153829892": "A weeb.", "760375501775700038": "˙qǝǝʍ ∀", "389178357302034442": "In his dreams, he is the star. its him. <:itsMe:808174425253871657>", diff --git a/src/commands/system/admin.ts b/src/commands/system/admin.ts index ac0e9e0..5e0c225 100644 --- a/src/commands/system/admin.ts +++ b/src/commands/system/admin.ts @@ -119,6 +119,32 @@ export default new NamedCommand({ }) }) } + }), + stream: new NamedCommand({ + description: "Set a channel to send stream notifications. Type `#` to reference the channel.", + usage: "()", + async run({message, channel, guild, author, member, client, args}) { + const targetGuild = Storage.getGuild(guild!.id); + + if (targetGuild.streamingChannel) { + targetGuild.streamingChannel = null; + channel.send("Removed your server's stream notifications channel."); + } else { + targetGuild.streamingChannel = channel.id; + channel.send(`Set your server's stream notifications channel to ${channel}.`); + } + + Storage.save(); + }, + id: "channel", + channel: new Command({ + async run({message, channel, guild, author, member, client, args}) { + const result = args[0] as TextChannel; + Storage.getGuild(guild!.id).streamingChannel = result.id; + Storage.save(); + channel.send(`Successfully set this server's stream notifications channel to ${result}.`); + } + }) }) } }), diff --git a/src/commands/utility/react.ts b/src/commands/utility/react.ts index 43f3394..2e018c9 100644 --- a/src/commands/utility/react.ts +++ b/src/commands/utility/react.ts @@ -10,7 +10,18 @@ export default new NamedCommand({ let target: Message | undefined; let distance = 1; - if (args.length >= 2) { + // allows reactions by using an in-line reply + if (message.reference) { + const messageID = message.reference.messageID; + try { + target = await channel.messages.fetch(messageID!); + } catch { + return channel.send("Unknown error occurred!"); + } + } + + // handles reacts by message id/distance + else if (args.length >= 2) { const last = args[args.length - 1]; // Because this is optional, do not .pop() unless you're sure it's a message link indicator. const URLPattern = /^(?:https:\/\/discord.com\/channels\/(\d{17,19})\/(\d{17,19})\/(\d{17,19}))$/; const copyIDPattern = /^(?:(\d{17,19})-(\d{17,19}))$/; diff --git a/src/commands/utility/streaminfo.ts b/src/commands/utility/streaminfo.ts new file mode 100644 index 0000000..969e668 --- /dev/null +++ b/src/commands/utility/streaminfo.ts @@ -0,0 +1,18 @@ +import {Command, NamedCommand} from "../../core"; +import {streamList} from "../../modules/streamNotifications"; + +export default new NamedCommand({ + description: "Sets the description of your stream. You can embed links by writing `[some name](some link)`", + async run({message, channel, guild, author, member, client, args}) { + const userID = author.id; + + if (streamList.has(userID)) { + const stream = streamList.get(userID)!; + stream.description = args.join(" ") || "No description set."; + stream.update(); + } else { + // Alternatively, I could make descriptions last outside of just one stream. + channel.send("You can only use this command when streaming."); + } + } +}); diff --git a/src/index.ts b/src/index.ts index 67cacb5..6c917d7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -70,3 +70,4 @@ import "./modules/channelListener"; import "./modules/intercept"; import "./modules/messageEmbed"; import "./modules/guildMemberAdd"; +import "./modules/streamNotifications"; diff --git a/src/modules/ready.ts b/src/modules/ready.ts index eac1fa0..3b83964 100644 --- a/src/modules/ready.ts +++ b/src/modules/ready.ts @@ -1,5 +1,5 @@ import {client} from "../index"; -import {Config} from "../structures"; +import {Config, Storage} from "../structures"; client.once("ready", () => { if (client.user) { @@ -10,5 +10,21 @@ client.once("ready", () => { type: "LISTENING", name: `${Config.prefix}help` }); + + // 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]; + + if (user.ecoBetInsurance > 0) { + client.users.cache + .get(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.` + ); + user.money += user.ecoBetInsurance; + user.ecoBetInsurance = 0; + } + } + Storage.save(); } }); diff --git a/src/modules/streamNotifications.ts b/src/modules/streamNotifications.ts new file mode 100644 index 0000000..f6e2843 --- /dev/null +++ b/src/modules/streamNotifications.ts @@ -0,0 +1,84 @@ +import {GuildMember, VoiceChannel, MessageEmbed, TextChannel, Permissions, Message, Collection} from "discord.js"; +import {client} from "../index"; +import {Storage} from "../structures"; + +type Stream = { + streamer: GuildMember; + channel: VoiceChannel; + description?: string; + message: Message; + update: () => void; +}; + +// A list of user IDs and message embeds. +export const streamList = new Collection(); + +// Probably find a better, DRY way of doing this. +function getStreamEmbed(streamer: GuildMember, channel: VoiceChannel, description?: string): MessageEmbed { + const user = streamer.user; + const embed = new MessageEmbed() + .setTitle(`Stream: \`#${channel.name}\``) + .setAuthor( + streamer.nickname ?? user.username, + user.avatarURL({ + dynamic: true, + format: "png" + }) ?? user.defaultAvatarURL + ) + .setColor(streamer.displayColor); + + if (description) { + embed.setDescription(description); + } + + return embed; +} + +client.on("voiceStateUpdate", async (before, after) => { + const isStartStreamEvent = !before.streaming && after.streaming; + const isStopStreamEvent = before.streaming && (!after.streaming || !after.channel); // If you were streaming before but now are either not streaming or have left the channel. + // 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} = Storage.getGuild(after.guild.id); + + if (streamingChannel) { + const member = after.member!; + const voiceChannel = after.channel!; + const textChannel = client.channels.cache.get(streamingChannel); + + if (textChannel instanceof TextChannel) { + if (isStartStreamEvent) { + streamList.set(member.id, { + streamer: member, + channel: voiceChannel, + message: await textChannel.send(getStreamEmbed(member, voiceChannel)), + update(this: Stream) { + this.message.edit(getStreamEmbed(this.streamer, this.channel, this.description)); + } + }); + } else if (isStopStreamEvent) { + if (streamList.has(member.id)) { + const {message} = streamList.get(member.id)!; + message.delete(); + streamList.delete(member.id); + } + } + } else { + console.error( + `The streaming notifications channel ${streamingChannel} for guild ${after.guild.id} either doesn't exist or isn't a text channel.` + ); + } + } + } +}); + +client.on("channelUpdate", (before, after) => { + if (before.type === "voice" && after.type === "voice") { + for (const stream of streamList.values()) { + if (after.id === stream.channel.id) { + stream.update(); + } + } + } +}); diff --git a/src/structures.ts b/src/structures.ts index aac1958..f764d5a 100644 --- a/src/structures.ts +++ b/src/structures.ts @@ -31,6 +31,7 @@ class User { 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); @@ -41,6 +42,7 @@ class User { ? data?.daylightSavingsRegion : null; this.todoList = {}; + this.ecoBetInsurance = select(data?.ecoBetInsurance, 0, Number); if (data) { for (const timestamp in data.todoList) { @@ -58,11 +60,13 @@ class Guild { public welcomeType: "none" | "text" | "graphical"; public welcomeChannel: string | null; public welcomeMessage: string | null; + public streamingChannel: string | null; constructor(data?: GenericJSON) { this.prefix = select(data?.prefix, null, String); this.welcomeChannel = select(data?.welcomeChannel, null, String); this.welcomeMessage = select(data?.welcomeMessage, null, String); + this.streamingChannel = select(data?.streamingChannel, null, String); switch (data?.welcomeType) { case "text":