diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml index c575066..a43d289 100644 --- a/.github/workflows/image.yml +++ b/.github/workflows/image.yml @@ -3,7 +3,6 @@ on: push: branches: - typescript - - docker jobs: analyze: @@ -16,10 +15,13 @@ jobs: fetch-depth: 2 - name: Setup Node.JS - uses: actions/setup-node@v2-beta + uses: actions/setup-node@v2 with: - node-version: "12" - - run: npm ci + node-version: "14" + # https://github.com/npm/cli/issues/558#issuecomment-580018468 + # Error: "npm ERR! fsevents not accessible from jest-haste-map" + # (supposed to just be a warning b/c optional dependency, but CI environment causes it to fail) + - run: npm i - name: Build codebase run: npm run build diff --git a/Dockerfile b/Dockerfile index ab33a22..cd595ba 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,69 @@ -FROM node:current-alpine +############### +# Solution #1 # +############### +# https://github.com/geekduck/docker-node-canvas +# Took 20m 55s + +#FROM node:12 +# +#RUN apt-get update \ +# && apt-get install -qq build-essential libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev +# +#RUN mkdir -p /opt/node/js \ +# && cd /opt/node \ +# && npm i canvas +# +#WORKDIR /opt/node/js +# +#ENTRYPOINT ["node"] + +############### +# Solution #2 # +############### +# https://github.com/Automattic/node-canvas/issues/729#issuecomment-352991456 +# Took 22m 50s + +#FROM ubuntu:xenial +# +#RUN apt-get update && apt-get install -y \ +# curl \ +# git +# +#RUN curl -sL https://deb.nodesource.com/setup_8.x | bash - \ +# && curl -sL https://deb.nodesource.com/setup_8.x | bash - \ +# && curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \ +# && echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list +# +#RUN apt-get update && apt-get install -y \ +# nodejs \ +# yarn \ +# libcairo2-dev \ +# libjpeg-dev \ +# libpango1.0-dev \ +# libgif-dev \ +# libpng-dev \ +# build-essential \ +# g++ + +############### +# Solution #3 # +############### +# https://github.com/Automattic/node-canvas/issues/866#issuecomment-330001221 +# Took 7m 29s + +FROM node:10.16.0-alpine +FROM mhart/alpine-node:8.5.0 + +RUN apk add --no-cache \ + build-base \ + g++ \ + cairo-dev \ + jpeg-dev \ + pango-dev \ + bash \ + imagemagick + +# The rest of the commands to execute COPY . . diff --git a/package.json b/package.json index 74c35eb..3da03e0 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,9 @@ "tsc-watch": "^4.2.9", "typescript": "^3.9.7" }, + "optionalDependencies": { + "fsevents": "^2.1.2" + }, "author": "Keanu Timmermans", "license": "MIT", "keywords": [ 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/system/help.ts b/src/commands/system/help.ts index 588aa92..a9c4df8 100644 --- a/src/commands/system/help.ts +++ b/src/commands/system/help.ts @@ -1,5 +1,5 @@ import { - Command, + RestCommand, NamedCommand, CHANNEL_TYPE, getPermissionName, @@ -51,7 +51,7 @@ export default new NamedCommand({ .setColor(EMBED_COLOR); }); }, - any: new Command({ + any: new RestCommand({ async run({send, args}) { const resultingBlob = await getCommandInfo(args); if (typeof resultingBlob === "string") return send(resultingBlob); 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/lib.ts b/src/lib.ts index 490dd13..06ae42e 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -139,12 +139,16 @@ 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__ = "generic"; + private __meta__!: string; constructor(tag?: string) { - this.__meta__ = tag || this.__meta__; Object.defineProperty(this, "__meta__", { + value: tag || "generic", enumerable: false }); } 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; + } } }