diff --git a/src/commands/admin.ts b/src/commands/admin.ts index 2622087..40c2242 100644 --- a/src/commands/admin.ts +++ b/src/commands/admin.ts @@ -164,6 +164,47 @@ export default new Command({ }) }) } + }), + stream: new Command({ + description: "Set a channel to send stream notifications. Type `#` to reference the channel.", + usage: "()", + async run($) { + if ($.guild) { + const guild = Storage.getGuild($.guild.id); + + if (guild.streamingChannel) { + guild.streamingChannel = null; + $.channel.send("Removed your server's stream notifications channel."); + } else { + guild.streamingChannel = $.channel.id; + $.channel.send(`Set your server's stream notifications channel to ${$.channel}.`); + } + + Storage.save(); + } else { + $.channel.send("You must use this command in a server."); + } + }, + // If/when channel types come out, this will be the perfect candidate to test it. + any: new Command({ + async run($) { + if ($.guild) { + const match = $.args[0].match(/^<#(\d{17,19})>$/); + + if (match) { + Storage.getGuild($.guild.id).streamingChannel = match[1]; + Storage.save(); + $.channel.send(`Successfully set this server's welcome channel to ${match[0]}.`); + } else { + $.channel.send( + "You must provide a reference channel. You can do this by typing `#` then searching for the proper channel." + ); + } + } else { + $.channel.send("You must use this command in a server."); + } + } + }) }) } }), diff --git a/src/commands/utilities/streaminfo.ts b/src/commands/utilities/streaminfo.ts new file mode 100644 index 0000000..7d90785 --- /dev/null +++ b/src/commands/utilities/streaminfo.ts @@ -0,0 +1,18 @@ +import Command from "../../core/command"; +import {streamList} from "../../events/voiceStateUpdate"; + +export default new Command({ + description: "Sets the description of your stream. You can embed links by writing `[some name](some link)`", + async run($) { + 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/core/structures.ts b/src/core/structures.ts index 13bdc9f..0e77494 100644 --- a/src/core/structures.ts +++ b/src/core/structures.ts @@ -58,11 +58,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": diff --git a/src/events/channelUpdate.ts b/src/events/channelUpdate.ts new file mode 100644 index 0000000..7d49789 --- /dev/null +++ b/src/events/channelUpdate.ts @@ -0,0 +1,14 @@ +import Event from "../core/event"; +import {streamList} from "./voiceStateUpdate"; + +export default new Event<"channelUpdate">({ + async on(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/events/voiceStateUpdate.ts b/src/events/voiceStateUpdate.ts new file mode 100644 index 0000000..54b90d4 --- /dev/null +++ b/src/events/voiceStateUpdate.ts @@ -0,0 +1,78 @@ +import {GuildMember, VoiceChannel, MessageEmbed, TextChannel, Permissions, Message, Collection} from "discord.js"; +import Event from "../core/event"; +import $ from "../core/lib"; +import {Storage} from "../core/structures"; +import {client} from "../index"; + +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; +} + +export default new Event<"voiceStateUpdate">({ + async on(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 { + $.error( + `The streaming notifications channel ${streamingChannel} for guild ${after.guild.id} either doesn't exist or isn't a text channel.` + ); + } + } + } + } +});