Merge branch 'typescript' into HEAD

This commit is contained in:
Keanu Timmermans 2021-04-12 20:43:26 +02:00
commit dd572e637d
Signed by: keanucode
GPG key ID: A7431C0D513CA93B
9 changed files with 362 additions and 57 deletions

View file

@ -3,7 +3,6 @@ on:
push: push:
branches: branches:
- typescript - typescript
- docker
jobs: jobs:
analyze: analyze:
@ -16,10 +15,13 @@ jobs:
fetch-depth: 2 fetch-depth: 2
- name: Setup Node.JS - name: Setup Node.JS
uses: actions/setup-node@v2-beta uses: actions/setup-node@v2
with: with:
node-version: "12" node-version: "14"
- run: npm ci # 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 - name: Build codebase
run: npm run build run: npm run build

View file

@ -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 . . COPY . .

View file

@ -43,6 +43,9 @@
"tsc-watch": "^4.2.9", "tsc-watch": "^4.2.9",
"typescript": "^3.9.7" "typescript": "^3.9.7"
}, },
"optionalDependencies": {
"fsevents": "^2.1.2"
},
"author": "Keanu Timmermans", "author": "Keanu Timmermans",
"license": "MIT", "license": "MIT",
"keywords": [ "keywords": [

View file

@ -9,7 +9,7 @@ import {
} from "../../core"; } from "../../core";
import {clean} from "../../lib"; import {clean} from "../../lib";
import {Config, Storage} from "../../structures"; 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"; import {logs} from "../../modules/globals";
function getLogBuffer(type: string) { function getLogBuffer(type: string) {
@ -162,6 +162,46 @@ export default new NamedCommand({
send(`Successfully set this server's stream notifications channel to ${result}.`); 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: "<role> <category>",
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: "<role>",
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.", run: "You have to enter some code to execute first.",
any: new RestCommand({ 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. // 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 { try {
let evaled = eval(combined); let evaled = eval(combined);
if (typeof evaled !== "string") evaled = require("util").inspect(evaled); if (typeof evaled !== "string") evaled = require("util").inspect(evaled);

View file

@ -1,5 +1,5 @@
import { import {
Command, RestCommand,
NamedCommand, NamedCommand,
CHANNEL_TYPE, CHANNEL_TYPE,
getPermissionName, getPermissionName,
@ -51,7 +51,7 @@ export default new NamedCommand({
.setColor(EMBED_COLOR); .setColor(EMBED_COLOR);
}); });
}, },
any: new Command({ any: new RestCommand({
async run({send, args}) { async run({send, args}) {
const resultingBlob = await getCommandInfo(args); const resultingBlob = await getCommandInfo(args);
if (typeof resultingBlob === "string") return send(resultingBlob); if (typeof resultingBlob === "string") return send(resultingBlob);

View file

@ -1,44 +1,142 @@
import {NamedCommand, RestCommand} from "../../core"; import {NamedCommand, RestCommand} from "../../core";
import {streamList} from "../../modules/streamNotifications"; 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({ export default new NamedCommand({
description: "Sets the description of your stream. You can embed links by writing `[some name](some link)`", description: "Modifies the current embed for your stream",
async run({send, author, member}) { run: "You need to specify whether to set the description or the image (`desc` and `img` respectively).",
const userID = author.id; 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: "(<description>)",
async run({send, author}) {
const userID = author.id;
if (streamList.has(userID)) { if (streamList.has(userID)) {
const stream = streamList.get(userID)!; const stream = streamList.get(userID)!;
stream.description = "No description set."; stream.description = undefined;
stream.update(); stream.update();
send(`Successfully set the stream description to:`, { send("Successfully removed the stream description.");
embed: { } else {
description: "No description set.", send("You can only use this command when streaming.");
color: member!.displayColor
} }
}); },
} else { any: new RestCommand({
// Alternatively, I could make descriptions last outside of just one stream. async run({send, author, member, combined}) {
send("You can only use this command when streaming."); const userID = author.id;
}
},
any: new RestCommand({
async run({send, author, member, combined}) {
const userID = author.id;
if (streamList.has(userID)) { if (streamList.has(userID)) {
const stream = streamList.get(userID)!; const stream = streamList.get(userID)!;
stream.description = combined; stream.description = combined;
stream.update(); stream.update();
send(`Successfully set the stream description to:`, { send("Successfully set the stream description to:", {
embed: { embed: {
description: stream.description, description: stream.description,
color: member!.displayColor 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: "(<link>)",
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: "(<category>)",
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(", ")}\``
);
}
}
})
})
}
}); });

View file

@ -139,12 +139,16 @@ export interface GenericJSON {
[key: string]: any; [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 { export abstract class GenericStructure {
private __meta__ = "generic"; private __meta__!: string;
constructor(tag?: string) { constructor(tag?: string) {
this.__meta__ = tag || this.__meta__;
Object.defineProperty(this, "__meta__", { Object.defineProperty(this, "__meta__", {
value: tag || "generic",
enumerable: false enumerable: false
}); });
} }

View file

@ -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 {client} from "../index";
import {Storage} from "../structures"; import {Storage} from "../structures";
type Stream = { type Stream = {
streamer: GuildMember; streamer: GuildMember;
channel: VoiceChannel; channel: VoiceChannel;
category: string;
description?: string; description?: string;
thumbnail?: string;
message: Message; message: Message;
streamStart: number;
update: () => void; update: () => void;
}; };
@ -14,10 +17,17 @@ type Stream = {
export const streamList = new Collection<string, Stream>(); export const streamList = new Collection<string, Stream>();
// Probably find a better, DRY way of doing this. // 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 user = streamer.user;
const embed = new MessageEmbed() const embed = new MessageEmbed()
.setTitle(`Stream: \`#${channel.name}\``) .setTitle(channel.name)
.setAuthor( .setAuthor(
streamer.nickname ?? user.username, streamer.nickname ?? user.username,
user.avatarURL({ user.avatarURL({
@ -25,11 +35,22 @@ function getStreamEmbed(streamer: GuildMember, channel: VoiceChannel, descriptio
format: "png" format: "png"
}) ?? user.defaultAvatarURL }) ?? 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) { if (description) embed.setDescription(description);
embed.setDescription(description); if (thumbnail) embed.setThumbnail(thumbnail);
}
return embed; 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. // 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) { if (isStartStreamEvent || isStopStreamEvent) {
const {streamingChannel} = Storage.getGuild(after.guild.id); const {streamingChannel, streamingRoles, members} = Storage.getGuild(after.guild.id);
if (streamingChannel) { if (streamingChannel) {
const member = after.member!; 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. // 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 (textChannel instanceof TextChannel) {
if (isStartStreamEvent) { 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, { streamList.set(member.id, {
streamer: member, streamer: member,
channel: voiceChannel, channel: voiceChannel,
message: await textChannel.send(getStreamEmbed(member, voiceChannel)), category,
message: await textChannel.send(
streamNotificationPing,
getStreamEmbed(member, voiceChannel, streamStart, category)
),
update(this: Stream) { 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) { } else if (isStopStreamEvent) {
if (streamList.has(member.id)) { if (streamList.has(member.id)) {

View file

@ -57,18 +57,30 @@ class User {
} }
} }
class Member {
public streamCategory: string | null;
constructor(data?: GenericJSON) {
this.streamCategory = select(data?.streamCategory, null, String);
}
}
class Guild { class Guild {
public prefix: string | null; public prefix: string | null;
public welcomeType: "none" | "text" | "graphical"; public welcomeType: "none" | "text" | "graphical";
public welcomeChannel: string | null; public welcomeChannel: string | null;
public welcomeMessage: string | null; public welcomeMessage: string | null;
public streamingChannel: string | null; public streamingChannel: string | null;
public streamingRoles: {[role: string]: string}; // Role ID: Category Name
public members: {[id: string]: Member};
constructor(data?: GenericJSON) { constructor(data?: GenericJSON) {
this.prefix = select(data?.prefix, null, String); this.prefix = select(data?.prefix, null, String);
this.welcomeChannel = select(data?.welcomeChannel, null, String); this.welcomeChannel = select(data?.welcomeChannel, null, String);
this.welcomeMessage = select(data?.welcomeMessage, null, String); this.welcomeMessage = select(data?.welcomeMessage, null, String);
this.streamingChannel = select(data?.streamingChannel, null, String); this.streamingChannel = select(data?.streamingChannel, null, String);
this.streamingRoles = {};
this.members = {};
switch (data?.welcomeType) { switch (data?.welcomeType) {
case "text": case "text":
@ -81,6 +93,37 @@ class Guild {
this.welcomeType = "none"; this.welcomeType = "none";
break; 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;
}
} }
} }