diff --git a/src/commands/system/admin.ts b/src/commands/system/admin.ts index 1ec78ef..5510ec8 100644 --- a/src/commands/system/admin.ts +++ b/src/commands/system/admin.ts @@ -9,7 +9,7 @@ import { } from "../../core"; import {clean} from "../../lib"; import {Config, Storage} from "../../structures"; -import {Permissions, TextChannel, User} from "discord.js"; +import {Permissions, TextChannel, User, Role} from "discord.js"; import {logs} from "../../modules/globals"; function getLogBuffer(type: string) { @@ -162,6 +162,46 @@ export default new NamedCommand({ send(`Successfully set this server's stream notifications channel to ${result}.`); } }) + }), + streamrole: new NamedCommand({ + description: "Sets/removes a stream notification role (and the corresponding category name)", + usage: "set/remove <...>", + run: "You need to enter in a role.", + subcommands: { + set: new NamedCommand({ + usage: " ", + id: "role", + role: new Command({ + run: "You need to enter a category name.", + 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(); + send( + `Successfully set the category \`${combined}\` to notify \`${role.name}\`.` + ); + } + }) + }) + }), + remove: new NamedCommand({ + usage: "", + id: "role", + 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]; + delete guildStorage.streamingRoles[role.id]; + Storage.save(); + send( + `Successfully removed the category \`${category}\` to notify \`${role.name}\`.` + ); + } + }) + }) + } }) } }), @@ -251,7 +291,7 @@ export default new NamedCommand({ run: "You have to enter some code to execute first.", any: new RestCommand({ // You have to bring everything into scope to use them. AFAIK, there isn't a more maintainable way to do this, but at least TS will let you know if anything gets removed. - async run({send, combined}) { + async run({send, message, channel, guild, author, member, client, args, combined}) { try { let evaled = eval(combined); if (typeof evaled !== "string") evaled = require("util").inspect(evaled); diff --git a/src/commands/utility/streaminfo.ts b/src/commands/utility/streaminfo.ts index b58b1f8..67d6197 100644 --- a/src/commands/utility/streaminfo.ts +++ b/src/commands/utility/streaminfo.ts @@ -1,44 +1,142 @@ import {NamedCommand, RestCommand} from "../../core"; import {streamList} from "../../modules/streamNotifications"; +import {Storage} from "../../structures"; +// Alternatively, I could make descriptions last outside of just one stream. +// But then again, users could just copy paste descriptions. :leaSMUG: +// Stream presets (for permanent parts of the description) might come some time in the future. export default new NamedCommand({ - description: "Sets the description of your stream. You can embed links by writing `[some name](some link)`", - async run({send, author, member}) { - const userID = author.id; + description: "Modifies the current embed for your stream", + run: "You need to specify whether to set the description or the image (`desc` and `img` respectively).", + subcommands: { + description: new NamedCommand({ + aliases: ["desc"], + description: + "Sets the description of your stream. You can embed links by writing `[some name](some link)` or remove it", + usage: "()", + async run({send, author}) { + const userID = author.id; - if (streamList.has(userID)) { - const stream = streamList.get(userID)!; - stream.description = "No description set."; - stream.update(); - send(`Successfully set the stream description to:`, { - embed: { - description: "No description set.", - color: member!.displayColor + if (streamList.has(userID)) { + const stream = streamList.get(userID)!; + stream.description = undefined; + stream.update(); + send("Successfully removed the stream description."); + } else { + send("You can only use this command when streaming."); } - }); - } else { - // Alternatively, I could make descriptions last outside of just one stream. - send("You can only use this command when streaming."); - } - }, - any: new RestCommand({ - async run({send, author, member, combined}) { - const userID = author.id; + }, + any: new RestCommand({ + async run({send, author, member, combined}) { + const userID = author.id; - if (streamList.has(userID)) { - const stream = streamList.get(userID)!; - stream.description = combined; - stream.update(); - send(`Successfully set the stream description to:`, { - embed: { - description: stream.description, - color: member!.displayColor + if (streamList.has(userID)) { + const stream = streamList.get(userID)!; + stream.description = combined; + stream.update(); + send("Successfully set the stream description to:", { + embed: { + description: stream.description, + color: member!.displayColor + } + }); + } else { + send("You can only use this command when streaming."); } - }); - } else { - // Alternatively, I could make descriptions last outside of just one stream. - send("You can only use this command when streaming."); - } - } - }) + } + }) + }), + thumbnail: new NamedCommand({ + aliases: ["img"], + description: "Sets a thumbnail to display alongside the embed or remove it", + usage: "()", + async run({send, author}) { + const userID = author.id; + + if (streamList.has(userID)) { + const stream = streamList.get(userID)!; + stream.thumbnail = undefined; + stream.update(); + send("Successfully removed the stream thumbnail."); + } else { + send("You can only use this command when streaming."); + } + }, + any: new RestCommand({ + async run({send, author, member, combined}) { + const userID = author.id; + + if (streamList.has(userID)) { + const stream = streamList.get(userID)!; + stream.thumbnail = combined; + stream.update(); + send(`Successfully set the stream thumbnail to: ${combined}`, { + embed: { + description: stream.description, + thumbnail: {url: combined}, + color: member!.displayColor + } + }); + } else { + send("You can only use this command when streaming."); + } + } + }) + }), + category: new NamedCommand({ + aliases: ["cat", "group"], + description: + "Sets the stream category any future streams will be in (as well as notification roles if set)", + usage: "()", + async run({send, guild, author}) { + const userID = author.id; + const memberStorage = Storage.getGuild(guild!.id).getMember(userID); + 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 + if (streamList.has(userID)) { + const stream = streamList.get(userID)!; + stream.category = "None"; + stream.update(); + } + }, + any: new RestCommand({ + async run({send, guild, author, combined}) { + const userID = author.id; + const guildStorage = Storage.getGuild(guild!.id); + const memberStorage = guildStorage.getMember(userID); + let found = false; + + // Check if it's a valid category + for (const [roleID, categoryName] of Object.entries(guildStorage.streamingRoles)) { + if (combined === categoryName) { + found = true; + memberStorage.streamCategory = roleID; + Storage.save(); + send( + `Successfully set the category for your current and future streams to: \`${categoryName}\`` + ); + + // Then modify the current category if the user is streaming + if (streamList.has(userID)) { + const stream = streamList.get(userID)!; + stream.category = categoryName; + stream.update(); + } + } + } + + if (!found) { + send( + `No valid category found by \`${combined}\`! The available categories are: \`${Object.values( + guildStorage.streamingRoles + ).join(", ")}\`` + ); + } + } + }) + }) + } }); diff --git a/src/modules/streamNotifications.ts b/src/modules/streamNotifications.ts index b6e9fef..0577b46 100644 --- a/src/modules/streamNotifications.ts +++ b/src/modules/streamNotifications.ts @@ -1,12 +1,15 @@ -import {GuildMember, VoiceChannel, MessageEmbed, TextChannel, Permissions, Message, Collection} from "discord.js"; +import {GuildMember, VoiceChannel, MessageEmbed, TextChannel, Message, Collection} from "discord.js"; import {client} from "../index"; import {Storage} from "../structures"; type Stream = { streamer: GuildMember; channel: VoiceChannel; + category: string; description?: string; + thumbnail?: string; message: Message; + streamStart: number; update: () => void; }; @@ -14,10 +17,17 @@ type Stream = { export const streamList = new Collection(); // Probably find a better, DRY way of doing this. -function getStreamEmbed(streamer: GuildMember, channel: VoiceChannel, description?: string): MessageEmbed { +function getStreamEmbed( + streamer: GuildMember, + channel: VoiceChannel, + streamStart: number, + category: string, + description?: string, + thumbnail?: string +): MessageEmbed { const user = streamer.user; const embed = new MessageEmbed() - .setTitle(`Stream: \`#${channel.name}\``) + .setTitle(channel.name) .setAuthor( streamer.nickname ?? user.username, user.avatarURL({ @@ -25,11 +35,22 @@ function getStreamEmbed(streamer: GuildMember, channel: VoiceChannel, descriptio format: "png" }) ?? user.defaultAvatarURL ) - .setColor(streamer.displayColor); + // I decided to not include certain fields: + // .addField("Activity", "CrossCode", true) - Probably too much presence data involved, increasing memory usage. + // .addField("Viewers", 5, true) - There doesn't seem to currently be a way to track how many viewers there are. Presence data for "WATCHING" doesn't seem to affect it, and listening to raw client events doesn't seem to make it appear either. + .addField("Voice Channel", channel, true) + .addField("Category", category, true) + .setColor(streamer.displayColor) + .setFooter( + "Stream Started", + streamer.guild.iconURL({ + dynamic: true + }) || undefined + ) + .setTimestamp(streamStart); - if (description) { - embed.setDescription(description); - } + if (description) embed.setDescription(description); + if (thumbnail) embed.setThumbnail(thumbnail); return embed; } @@ -40,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} = Storage.getGuild(after.guild.id); + const {streamingChannel, streamingRoles, members} = Storage.getGuild(after.guild.id); if (streamingChannel) { const member = after.member!; @@ -50,13 +71,42 @@ client.on("voiceStateUpdate", async (before, after) => { // Although checking the bot's permission to send might seem like a good idea, having the error be thrown will cause it to show up in the last channel rather than just show up in the console. if (textChannel instanceof TextChannel) { if (isStartStreamEvent) { + const streamStart = Date.now(); + let streamNotificationPing = ""; + 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; + + // Only continue if they set a valid category. + if (roleID && roleID in streamingRoles) { + streamNotificationPing = `<@&${roleID}>`; + category = streamingRoles[roleID]; + } + } + streamList.set(member.id, { streamer: member, channel: voiceChannel, - message: await textChannel.send(getStreamEmbed(member, voiceChannel)), + category, + message: await textChannel.send( + streamNotificationPing, + getStreamEmbed(member, voiceChannel, streamStart, category) + ), update(this: Stream) { - this.message.edit(getStreamEmbed(this.streamer, this.channel, this.description)); - } + this.message.edit( + getStreamEmbed( + this.streamer, + this.channel, + streamStart, + this.category, + this.description, + this.thumbnail + ) + ); + }, + streamStart }); } else if (isStopStreamEvent) { if (streamList.has(member.id)) { diff --git a/src/structures.ts b/src/structures.ts index 8991e3f..6da13a7 100644 --- a/src/structures.ts +++ b/src/structures.ts @@ -57,18 +57,30 @@ class User { } } +class Member { + public streamCategory: string | null; + + constructor(data?: GenericJSON) { + this.streamCategory = select(data?.streamCategory, null, String); + } +} + class Guild { public prefix: string | null; public welcomeType: "none" | "text" | "graphical"; public welcomeChannel: string | null; public welcomeMessage: string | null; public streamingChannel: string | null; + public streamingRoles: {[role: string]: string}; // Role ID: Category Name + public members: {[id: string]: Member}; 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); + this.streamingRoles = {}; + this.members = {}; switch (data?.welcomeType) { case "text": @@ -81,6 +93,37 @@ class Guild { 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?.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(`"${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; + } } }