zappyzep/backend/src/plugins/Starboard.ts

352 lines
13 KiB
TypeScript
Raw Normal View History

2019-11-09 00:48:38 +01:00
import { decorators as d, IPluginOptions } from "knub";
import { ZeppelinPlugin, trimPluginDescription } from "./ZeppelinPlugin";
2018-12-15 23:01:45 +02:00
import { GuildChannel, Message, TextChannel } from "eris";
2019-11-09 00:48:38 +01:00
import { errorMessage, getUrlsInString, noop, successMessage, tNullable } from "../utils";
2018-12-15 23:01:45 +02:00
import path from "path";
import moment from "moment-timezone";
import { GuildSavedMessages } from "../data/GuildSavedMessages";
import { SavedMessage } from "../data/entities/SavedMessage";
import * as t from "io-ts";
2019-11-09 00:48:38 +01:00
import { GuildStarboardMessages } from "../data/GuildStarboardMessages";
import { StarboardMessage } from "../data/entities/StarboardMessage";
import { GuildStarboardReactions } from "../data/GuildStarboardReactions";
const StarboardOpts = t.type({
source_channel_ids: t.array(t.string),
starboard_channel_id: t.string,
positive_emojis: tNullable(t.array(t.string)),
positive_required: tNullable(t.number),
enabled: tNullable(t.boolean),
});
type TStarboardOpts = t.TypeOf<typeof StarboardOpts>;
2018-12-15 23:01:45 +02:00
const ConfigSchema = t.type({
2019-11-09 00:48:38 +01:00
entries: t.record(t.string, StarboardOpts),
can_migrate: t.boolean,
});
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
2019-11-09 00:48:38 +01:00
const defaultStarboardOpts: Partial<TStarboardOpts> = {
positive_emojis: ["⭐"],
positive_required: 5,
enabled: true,
};
export class StarboardPlugin extends ZeppelinPlugin<TConfigSchema> {
public static pluginName = "starboard";
2019-08-22 01:22:26 +03:00
public static showInDocs = false;
2019-08-22 02:58:32 +03:00
public static configSchema = ConfigSchema;
2019-11-09 00:48:38 +01:00
public static pluginInfo = {
prettyName: "Starboards",
description: trimPluginDescription(`
This plugin contains all functionality needed to use discord channels as starboards.
`),
configurationGuide: trimPluginDescription(`
You can customize multiple settings for starboards.
Any emoji that you want available needs to be put into the config in its raw form.
To obtain a raw form of an emoji, please write out the emoji and put a backslash in front of it.
Example with default emoji: "\:star:" => "⭐"
Example with custom emoji: "\:mrvnSmile:" => "<:mrvnSmile:543000534102310933>"
Now, past the result into the config, but make sure to exclude all less-than and greater-than signs like in the second example.
### Starboard with one source channel
All messages in the source channel that get enough positive reactions will be posted into the starboard channel.
The only positive reaction counted here is the default emoji "⭐".
Only users with a role matching the allowed_roles role-id will be counted.
~~~yml
starboard:
config:
entries:
exampleOne:
source_channel_ids: ["604342623569707010"]
starboard_channel_id: "604342689038729226"
positive_emojis: ["⭐"]
positive_required: 5
allowed_roles: ["556110793058287637"]
enabled: true
~~~
### Starboard with two sources and two emoji
All messages in any of the source channels that get enough positive reactions will be posted into the starboard channel.
Both the default emoji "⭐" and the custom emoji ":mrvnSmile:543000534102310933" are counted.
~~~yml
starboard:
config:
entries:
exampleTwo:
source_channel_ids: ["604342623569707010", "604342649251561487"]
starboard_channel_id: "604342689038729226"
positive_emojis: ["⭐", ":mrvnSmile:543000534102310933"]
positive_required: 10
enabled: true
~~~
`),
};
2018-12-15 23:01:45 +02:00
protected savedMessages: GuildSavedMessages;
2019-11-09 00:48:38 +01:00
protected starboardMessages: GuildStarboardMessages;
protected starboardReactions: GuildStarboardReactions;
2018-12-15 23:01:45 +02:00
private onMessageDeleteFn;
2019-08-22 01:22:26 +03:00
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
2018-12-15 23:01:45 +02:00
return {
config: {
2019-11-09 00:48:38 +01:00
can_migrate: false,
entries: {},
2018-12-15 23:01:45 +02:00
},
overrides: [
{
level: ">=100",
config: {
2019-11-09 00:48:38 +01:00
can_migrate: true,
},
},
],
2018-12-15 23:01:45 +02:00
};
}
2019-11-09 00:48:38 +01:00
protected getStarboardOptsForSourceChannel(sourceChannel): TStarboardOpts[] {
const config = this.getConfigForChannel(sourceChannel);
2018-12-15 23:01:45 +02:00
2019-11-09 00:48:38 +01:00
const configs = Object.values(config.entries).filter(opts => opts.source_channel_ids.includes(sourceChannel.id));
configs.forEach(cfg => {
if (cfg.enabled == null) cfg.enabled = defaultStarboardOpts.enabled;
if (cfg.positive_emojis == null) cfg.positive_emojis = defaultStarboardOpts.positive_emojis;
if (cfg.positive_required == null) cfg.positive_required = defaultStarboardOpts.positive_required;
});
2018-12-15 23:01:45 +02:00
2019-11-09 00:48:38 +01:00
return configs;
2018-12-15 23:01:45 +02:00
}
2019-11-09 00:48:38 +01:00
protected getStarboardOptsForStarboardChannel(starboardChannel): TStarboardOpts[] {
const config = this.getConfigForChannel(starboardChannel);
2018-12-15 23:01:45 +02:00
2019-11-09 00:48:38 +01:00
const configs = Object.values(config.entries).filter(opts => opts.starboard_channel_id === starboardChannel.id);
configs.forEach(cfg => {
if (cfg.enabled == null) cfg.enabled = defaultStarboardOpts.enabled;
if (cfg.positive_emojis == null) cfg.positive_emojis = defaultStarboardOpts.positive_emojis;
if (cfg.positive_required == null) cfg.positive_required = defaultStarboardOpts.positive_required;
});
2018-12-15 23:01:45 +02:00
2019-11-09 00:48:38 +01:00
return configs;
2018-12-15 23:01:45 +02:00
}
2019-11-09 00:48:38 +01:00
onLoad() {
this.savedMessages = GuildSavedMessages.getGuildInstance(this.guildId);
this.starboardMessages = GuildStarboardMessages.getGuildInstance(this.guildId);
this.starboardReactions = GuildStarboardReactions.getGuildInstance(this.guildId);
2018-12-15 23:01:45 +02:00
2019-11-09 00:48:38 +01:00
this.onMessageDeleteFn = this.onMessageDelete.bind(this);
this.savedMessages.events.on("delete", this.onMessageDeleteFn);
}
2018-12-15 23:01:45 +02:00
2019-11-09 00:48:38 +01:00
onUnload() {
this.savedMessages.events.off("delete", this.onMessageDeleteFn);
2018-12-15 23:01:45 +02:00
}
/**
* When a reaction is added to a message, check if there are any applicable starboards and if the reactions reach
* the required threshold. If they do, post the message in the starboard channel.
*/
@d.event("messageReactionAdd")
@d.lock("starboardReaction")
2019-11-09 00:48:38 +01:00
async onMessageReactionAdd(msg: Message, emoji: { id: string; name: string }, userId: string) {
2018-12-15 23:01:45 +02:00
if (!msg.author) {
// Message is not cached, fetch it
try {
msg = await msg.channel.getMessage(msg.id);
} catch (e) {
// Sometimes we get this event for messages we can't fetch with getMessage; ignore silently
return;
}
2018-12-15 23:01:45 +02:00
}
2019-11-09 00:48:38 +01:00
const applicableStarboards = await this.getStarboardOptsForSourceChannel(msg.channel);
2018-12-15 23:01:45 +02:00
for (const starboard of applicableStarboards) {
2019-11-09 00:48:38 +01:00
// Instantly continue if the starboard is disabled
if (!starboard.enabled) continue;
2018-12-15 23:01:45 +02:00
// Can't star messages in the starboard channel itself
2019-11-09 00:48:38 +01:00
if (msg.channel.id === starboard.starboard_channel_id) continue;
// Move reaction into DB at this point
await this.starboardReactions.createStarboardReaction(msg.id, userId).catch();
2018-12-15 23:01:45 +02:00
// If the message has already been posted to this starboard, we don't need to do anything else here
2019-11-09 00:48:38 +01:00
const starboardMessages = await this.starboardMessages.getMessagesForStarboardIdAndSourceMessageId(
starboard.starboard_channel_id,
msg.id,
2018-12-15 23:01:45 +02:00
);
2019-11-09 00:48:38 +01:00
if (starboardMessages.length > 0) continue;
2018-12-15 23:01:45 +02:00
2019-11-09 00:48:38 +01:00
const reactions = await this.starboardReactions.getAllReactionsForMessageId(msg.id);
const reactionsCount = reactions.length;
if (reactionsCount >= starboard.positive_required) {
await this.saveMessageToStarboard(msg, starboard.starboard_channel_id);
2018-12-15 23:01:45 +02:00
}
}
}
2019-11-09 00:48:38 +01:00
@d.event("messageReactionRemove")
async onStarboardReactionRemove(msg: Message, emoji: { id: string; name: string }, userId: string) {
await this.starboardReactions.deleteStarboardReaction(msg.id, userId);
}
2018-12-15 23:01:45 +02:00
2019-11-09 00:48:38 +01:00
@d.event("messageReactionRemoveAll")
async onMessageReactionRemoveAll(msg: Message) {
await this.starboardReactions.deleteAllStarboardReactionsForMessageId(msg.id);
2018-12-15 23:01:45 +02:00
}
/**
* Saves/posts a message to the specified starboard. The message is posted as an embed and image attachments are
* included as the embed image.
*/
2019-11-09 00:48:38 +01:00
async saveMessageToStarboard(msg: Message, starboardChannelId: string) {
const channel = this.guild.channels.get(starboardChannelId);
2018-12-15 23:01:45 +02:00
if (!channel) return;
const time = moment(msg.timestamp, "x").format("YYYY-MM-DD [at] HH:mm:ss [UTC]");
const embed: any = {
footer: {
text: `#${(msg.channel as GuildChannel).name} - ${time}`,
2018-12-15 23:01:45 +02:00
},
author: {
name: `${msg.author.username}#${msg.author.discriminator}`,
},
2018-12-15 23:01:45 +02:00
};
if (msg.author.avatarURL) {
embed.author.icon_url = msg.author.avatarURL;
}
if (msg.content) {
embed.description = msg.content;
}
if (msg.attachments.length) {
const attachment = msg.attachments[0];
const ext = path
.extname(attachment.filename)
.slice(1)
.toLowerCase();
if (["jpeg", "jpg", "png", "gif", "webp"].includes(ext)) {
embed.image = { url: attachment.url };
}
} else if (msg.content) {
const links = getUrlsInString(msg.content);
for (const link of links) {
const parts = link
.toString()
.replace(/\/$/, "")
.split(".");
const ext = parts[parts.length - 1].toLowerCase();
if (
(link.hostname === "i.imgur.com" || link.hostname === "cdn.discordapp.com") &&
["jpeg", "jpg", "png", "gif", "webp"].includes(ext)
) {
embed.image = { url: link.toString() };
break;
}
}
2018-12-15 23:01:45 +02:00
}
const starboardMessage = await (channel as TextChannel).createMessage({
content: `https://discordapp.com/channels/${this.guildId}/${msg.channel.id}/${msg.id}`,
embed,
});
2019-11-09 00:48:38 +01:00
await this.starboardMessages.createStarboardMessage(channel.id, msg.id, starboardMessage.id);
2018-12-15 23:01:45 +02:00
}
/**
* Remove a message from the specified starboard
*/
2019-11-09 00:48:38 +01:00
async removeMessageFromStarboard(msg: StarboardMessage) {
await this.bot.deleteMessage(msg.starboard_channel_id, msg.starboard_message_id).catch(noop);
}
2018-12-15 23:01:45 +02:00
2019-11-09 00:48:38 +01:00
async removeMessageFromStarboardMessages(starboard_message_id: string, starboard_channel_id: string) {
await this.starboardMessages.deleteStarboardMessage(starboard_message_id, starboard_channel_id);
2018-12-15 23:01:45 +02:00
}
/**
* When a message is deleted, also delete it from any starboards it's been posted in.
* This function is called in response to GuildSavedMessages events.
* TODO: When a message is removed from the starboard itself, i.e. the bot's embed is removed, also remove that message from the starboard_messages database table
2018-12-15 23:01:45 +02:00
*/
async onMessageDelete(msg: SavedMessage) {
2019-11-09 00:48:38 +01:00
let messages = await this.starboardMessages.getStarboardMessagesForMessageId(msg.id);
if (messages.length > 0) {
for (const starboardMessage of messages) {
if (!starboardMessage.starboard_message_id) continue;
this.removeMessageFromStarboard(starboardMessage).catch(noop);
}
} else {
messages = await this.starboardMessages.getStarboardMessagesForStarboardMessageId(msg.id);
if (messages.length === 0) return;
for (const starboardMessage of messages) {
if (!starboardMessage.starboard_channel_id) continue;
this.removeMessageFromStarboardMessages(
starboardMessage.starboard_message_id,
starboardMessage.starboard_channel_id,
).catch(noop);
}
2018-12-15 23:01:45 +02:00
}
}
2019-11-09 00:48:38 +01:00
@d.command("starboard migrate_pins", "<pinChannelId:channelId> <starboardChannelId:channelId>", {
extra: {
info: {
description:
"Migrates all of a channels pins to starboard messages, posting them in the starboard channel. The old pins are not unpinned.",
},
},
})
@d.permission("can_migrate")
async migratePinsCmd(msg: Message, args: { pinChannelId: string; starboardChannelId }) {
2019-11-09 00:48:38 +01:00
try {
const starboards = await this.getStarboardOptsForStarboardChannel(this.bot.getChannel(args.starboardChannelId));
if (!starboards) {
msg.channel.createMessage(errorMessage("The specified channel doesn't have a starboard!")).catch(noop);
return;
}
2019-11-09 00:48:38 +01:00
const channel = (await this.guild.channels.get(args.pinChannelId)) as GuildChannel & TextChannel;
if (!channel) {
msg.channel
.createMessage(errorMessage("Could not find the specified channel to migrate pins from!"))
.catch(noop);
return;
}
2019-11-09 00:48:38 +01:00
msg.channel.createMessage(`Migrating pins from <#${channel.id}> to <#${args.starboardChannelId}>...`).catch(noop);
2019-11-09 00:48:38 +01:00
const pins = await channel.getPins();
pins.reverse(); // Migrate pins starting from the oldest message
2019-11-09 00:48:38 +01:00
for (const pin of pins) {
const existingStarboardMessage = await this.starboardMessages.getMessagesForStarboardIdAndSourceMessageId(
args.starboardChannelId,
pin.id,
);
if (existingStarboardMessage.length > 0) continue;
await this.saveMessageToStarboard(pin, args.starboardChannelId);
}
2019-11-09 00:48:38 +01:00
msg.channel.createMessage(successMessage("Pins migrated!")).catch(noop);
} catch (error) {
this.sendErrorMessage(
msg.channel,
"Sorry, but something went wrong!\nSyntax: `starboard migrate_pins <sourceChannelId> <starboardChannelid>`",
);
}
}
2018-12-15 23:01:45 +02:00
}