diff --git a/backend/src/data/Webhooks.ts b/backend/src/data/Webhooks.ts new file mode 100644 index 00000000..d8632274 --- /dev/null +++ b/backend/src/data/Webhooks.ts @@ -0,0 +1,37 @@ +import { getRepository, Repository } from "typeorm"; +import { Webhook } from "./entities/Webhook"; +import { BaseRepository } from "./BaseRepository"; +import { decrypt, encrypt } from "../utils/crypt"; + +export class Webhooks extends BaseRepository { + repository: Repository = getRepository(Webhook); + + protected async _processEntityFromDB(entity) { + entity.token = await decrypt(entity.token); + return entity; + } + + protected async _processEntityToDB(entity) { + entity.token = await encrypt(entity.token); + return entity; + } + + async findByChannelId(channelId: string): Promise { + const result = await this.repository.findOne({ + where: { + channel_id: channelId, + }, + }); + + return result ? this.processEntityFromDB(result) : null; + } + + async create(data: Partial): Promise { + data = await this.processEntityToDB(data); + await this.repository.insert(data); + } + + async delete(id: string): Promise { + await this.repository.delete({ id }); + } +} diff --git a/backend/src/data/entities/Webhook.ts b/backend/src/data/entities/Webhook.ts new file mode 100644 index 00000000..6bb764e5 --- /dev/null +++ b/backend/src/data/entities/Webhook.ts @@ -0,0 +1,14 @@ +import { Column, Entity, PrimaryColumn } from "typeorm"; + +@Entity("webhooks") +export class Webhook { + @Column() + @PrimaryColumn() + id: string; + + @Column() guild_id: string; + + @Column() channel_id: string; + + @Column() token: string; +} diff --git a/backend/src/migrations/1635779678653-CreateWebhooksTable.ts b/backend/src/migrations/1635779678653-CreateWebhooksTable.ts new file mode 100644 index 00000000..e60485df --- /dev/null +++ b/backend/src/migrations/1635779678653-CreateWebhooksTable.ts @@ -0,0 +1,45 @@ +import { MigrationInterface, QueryRunner, Table } from "typeorm"; + +export class CreateWebhooksTable1635779678653 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: "webhooks", + columns: [ + { + name: "id", + type: "bigint", + isPrimary: true, + }, + { + name: "guild_id", + type: "bigint", + }, + { + name: "channel_id", + type: "bigint", + }, + { + name: "token", + type: "text", + }, + { + name: "created_at", + type: "datetime", + default: "(NOW())", + }, + ], + indices: [ + { + columnNames: ["channel_id"], + isUnique: true, + }, + ], + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable("webhooks"); + } +} diff --git a/backend/src/plugins/Cases/CasesPlugin.ts b/backend/src/plugins/Cases/CasesPlugin.ts index 5a8c1b45..4f011982 100644 --- a/backend/src/plugins/Cases/CasesPlugin.ts +++ b/backend/src/plugins/Cases/CasesPlugin.ts @@ -16,6 +16,7 @@ import { getRecentCasesByMod } from "./functions/getRecentCasesByMod"; import { getTotalCasesByMod } from "./functions/getTotalCasesByMod"; import { postCaseToCaseLogChannel } from "./functions/postToCaseLogChannel"; import { CaseArgs, CaseNoteArgs, CasesPluginType, ConfigSchema } from "./types"; +import { InternalPosterPlugin } from "../InternalPoster/InternalPosterPlugin"; const defaultOptions = { config: { @@ -40,6 +41,7 @@ export const CasesPlugin = zeppelinGuildPlugin()({ dependencies: async () => [ TimeAndDatePlugin, + InternalPosterPlugin, // The `as any` cast here is to prevent TypeScript from locking up from the circular dependency ((await import("../Logs/LogsPlugin")) as any).LogsPlugin, ], diff --git a/backend/src/plugins/Cases/functions/postToCaseLogChannel.ts b/backend/src/plugins/Cases/functions/postToCaseLogChannel.ts index cba29ecb..6c1342d7 100644 --- a/backend/src/plugins/Cases/functions/postToCaseLogChannel.ts +++ b/backend/src/plugins/Cases/functions/postToCaseLogChannel.ts @@ -1,4 +1,4 @@ -import { FileOptions, Message, MessageOptions, Snowflake, TextChannel } from "discord.js"; +import { FileOptions, Message, MessageOptions, NewsChannel, Snowflake, TextChannel, ThreadChannel } from "discord.js"; import { GuildPluginData } from "knub"; import { Case } from "../../../data/entities/Case"; import { LogType } from "../../../data/LogType"; @@ -7,24 +7,30 @@ import { CasesPluginType } from "../types"; import { getCaseEmbed } from "./getCaseEmbed"; import { resolveCaseId } from "./resolveCaseId"; import { LogsPlugin } from "../../Logs/LogsPlugin"; +import { APIMessage } from "discord-api-types"; +import { InternalPosterPlugin } from "../../InternalPoster/InternalPosterPlugin"; +import { post } from "../../../../../dashboard/src/api"; +import { InternalPosterMessageResult } from "../../InternalPoster/functions/sendMessage"; export async function postToCaseLogChannel( pluginData: GuildPluginData, content: MessageOptions, file?: FileOptions[], -): Promise { +): Promise { const caseLogChannelId = pluginData.config.get().case_log_channel; if (!caseLogChannelId) return null; const caseLogChannel = pluginData.guild.channels.cache.get(caseLogChannelId as Snowflake); - if (!caseLogChannel || !(caseLogChannel instanceof TextChannel)) return null; + // This doesn't use `!isText() || isThread()` because TypeScript had some issues inferring types from it + if (!caseLogChannel || !(caseLogChannel instanceof TextChannel || caseLogChannel instanceof NewsChannel)) return null; let result; try { if (file != null) { content.files = file; } - result = await caseLogChannel.send({ ...content }); + const poster = pluginData.getPlugin(InternalPosterPlugin); + result = await poster.sendMessage(caseLogChannel, { ...content }); } catch (e) { if (isDiscordAPIError(e) && (e.code === 50013 || e.code === 50001)) { pluginData.getPlugin(LogsPlugin).logBotAlert({ @@ -42,12 +48,12 @@ export async function postToCaseLogChannel( export async function postCaseToCaseLogChannel( pluginData: GuildPluginData, caseOrCaseId: Case | number, -): Promise { +): Promise { const theCase = await pluginData.state.cases.find(resolveCaseId(caseOrCaseId)); - if (!theCase) return null; + if (!theCase) return; const caseEmbed = await getCaseEmbed(pluginData, caseOrCaseId, undefined, true); - if (!caseEmbed) return null; + if (!caseEmbed) return; if (theCase.log_message_id) { const [channelId, messageId] = theCase.log_message_id.split("-"); @@ -55,7 +61,7 @@ export async function postCaseToCaseLogChannel( try { const channel = pluginData.guild.channels.resolve(channelId as Snowflake) as TextChannel; await channel.messages.edit(messageId as Snowflake, caseEmbed); - return null; + return; } catch {} // tslint:disable-line:no-empty } @@ -63,14 +69,13 @@ export async function postCaseToCaseLogChannel( const postedMessage = await postToCaseLogChannel(pluginData, caseEmbed); if (postedMessage) { await pluginData.state.cases.update(theCase.id, { - log_message_id: `${postedMessage.channel.id}-${postedMessage.id}`, + log_message_id: `${postedMessage.channelId}-${postedMessage.id}`, }); } - return postedMessage; } catch { pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Failed to post case #${theCase.case_number} to the case log channel`, }); - return null; + return; } } diff --git a/backend/src/plugins/InternalPoster/InternalPosterPlugin.ts b/backend/src/plugins/InternalPoster/InternalPosterPlugin.ts new file mode 100644 index 00000000..14f074d2 --- /dev/null +++ b/backend/src/plugins/InternalPoster/InternalPosterPlugin.ts @@ -0,0 +1,39 @@ +import { PluginOptions, typedGuildCommand } from "knub"; +import { GuildPingableRoles } from "../../data/GuildPingableRoles"; +import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; +import { ConfigSchema, InternalPosterPluginType } from "./types"; +import { + getPhishermanDomainInfo, + hasPhishermanMasterAPIKey, + phishermanApiKeyIsValid, + reportTrackedDomainsToPhisherman, +} from "../../data/Phisherman"; +import { mapToPublicFn } from "../../pluginUtils"; +import { Webhooks } from "../../data/Webhooks"; +import { Queue } from "../../Queue"; +import { sendMessage } from "./functions/sendMessage"; + +const defaultOptions: PluginOptions = { + config: {}, + overrides: [], +}; + +export const InternalPosterPlugin = zeppelinGuildPlugin()({ + name: "internal_poster", + showInDocs: false, + + configSchema: ConfigSchema, + defaultOptions, + + // prettier-ignore + public: { + sendMessage: mapToPublicFn(sendMessage), + }, + + async beforeLoad(pluginData) { + pluginData.state.webhooks = new Webhooks(); + pluginData.state.queue = new Queue(); + pluginData.state.missingPermissions = false; + pluginData.state.webhookClientCache = new Map(); + }, +}); diff --git a/backend/src/plugins/InternalPoster/functions/getOrCreateWebhookForChannel.ts b/backend/src/plugins/InternalPoster/functions/getOrCreateWebhookForChannel.ts new file mode 100644 index 00000000..ec05f473 --- /dev/null +++ b/backend/src/plugins/InternalPoster/functions/getOrCreateWebhookForChannel.ts @@ -0,0 +1,48 @@ +import { GuildPluginData } from "knub"; +import { InternalPosterPluginType } from "../types"; +import { NewsChannel, Permissions, TextChannel } from "discord.js"; +import { isDiscordAPIError } from "../../../utils"; + +type WebhookInfo = [id: string, token: string]; + +export async function getOrCreateWebhookForChannel( + pluginData: GuildPluginData, + channel: TextChannel | NewsChannel, +): Promise { + // tslint:disable-next-line:no-console FIXME: Here for debugging purposes + console.log(`getOrCreateWebhookForChannel(${channel.id})`); + + // Database cache + const fromDb = await pluginData.state.webhooks.findByChannelId(channel.id); + if (fromDb) { + return [fromDb.id, fromDb.token]; + } + + if (pluginData.state.missingPermissions) { + return null; + } + + // Create new webhook + const member = pluginData.client.user && pluginData.guild.members.cache.get(pluginData.client.user.id); + if (!member || member.permissions.has(Permissions.FLAGS.MANAGE_WEBHOOKS)) { + try { + const webhook = await channel.createWebhook(`Zephook ${channel.id}`); + await pluginData.state.webhooks.create({ + id: webhook.id, + guild_id: pluginData.guild.id, + channel_id: channel.id, + token: webhook.token!, + }); + return [webhook.id, webhook.token!]; + } catch (err) { + if (isDiscordAPIError(err) && err.code === 50013) { + pluginData.state.missingPermissions = true; + console.warn(`Error ${err.code} when trying to create webhook for ${pluginData.guild.id}`); + return null; + } + throw err; + } + } + + return null; +} diff --git a/backend/src/plugins/InternalPoster/functions/sendMessage.ts b/backend/src/plugins/InternalPoster/functions/sendMessage.ts new file mode 100644 index 00000000..a5ce27d9 --- /dev/null +++ b/backend/src/plugins/InternalPoster/functions/sendMessage.ts @@ -0,0 +1,71 @@ +import { Message, MessageOptions, NewsChannel, TextChannel, WebhookClient } from "discord.js"; +import { GuildPluginData } from "knub"; +import { InternalPosterPluginType } from "../types"; +import { getOrCreateWebhookForChannel } from "./getOrCreateWebhookForChannel"; +import { APIMessage } from "discord-api-types"; +import { isDiscordAPIError } from "../../../utils"; + +export type InternalPosterMessageResult = { + id: string; + channelId: string; +}; + +/** + * Sends a message using a webhook or direct API requests, preferring webhooks when possible. + */ +export async function sendMessage( + pluginData: GuildPluginData, + channel: TextChannel | NewsChannel, + content: MessageOptions, +): Promise { + return pluginData.state.queue.add(async () => { + if (!pluginData.state.webhookClientCache.has(channel.id)) { + const webhookInfo = await getOrCreateWebhookForChannel(pluginData, channel); + if (webhookInfo) { + const client = new WebhookClient({ + id: webhookInfo[0], + token: webhookInfo[1], + }); + pluginData.state.webhookClientCache.set(channel.id, client); + } else { + pluginData.state.webhookClientCache.set(channel.id, null); + } + } + + const webhookClient = pluginData.state.webhookClientCache.get(channel.id); + if (webhookClient) { + return webhookClient + .send({ + ...content, + ...(pluginData.client.user && { + username: pluginData.client.user.username, + avatarURL: pluginData.client.user.avatarURL() || pluginData.client.user.defaultAvatarURL, + }), + }) + .then((apiMessage) => ({ + id: apiMessage.id, + channelId: apiMessage.channel_id, + })) + .catch(async (err) => { + // Unknown Webhook + if (isDiscordAPIError(err) && err.code === 10015) { + await pluginData.state.webhooks.delete(webhookClient.id); + pluginData.state.webhookClientCache.delete(channel.id); + + // Fallback to regular message for this log message + return channel.send(content).then((message) => ({ + id: message.id, + channelId: message.channelId, + })); + } + + throw err; + }); + } + + return channel.send(content).then((message) => ({ + id: message.id, + channelId: message.channelId, + })); + }); +} diff --git a/backend/src/plugins/InternalPoster/types.ts b/backend/src/plugins/InternalPoster/types.ts new file mode 100644 index 00000000..54a0ed01 --- /dev/null +++ b/backend/src/plugins/InternalPoster/types.ts @@ -0,0 +1,22 @@ +import * as t from "io-ts"; +import { BasePluginType } from "knub"; +import { Webhooks } from "../../data/Webhooks"; +import { Queue } from "../../Queue"; +import { WebhookClient } from "discord.js"; + +export const ConfigSchema = t.type({}); +export type TConfigSchema = t.TypeOf; + +// +type ChannelWebhookMap = Map; + +export interface InternalPosterPluginType extends BasePluginType { + config: TConfigSchema; + + state: { + queue: Queue; + webhooks: Webhooks; + missingPermissions: boolean; + webhookClientCache: Map; + }; +} diff --git a/backend/src/plugins/Logs/LogsPlugin.ts b/backend/src/plugins/Logs/LogsPlugin.ts index 053afffb..b4661c5a 100644 --- a/backend/src/plugins/Logs/LogsPlugin.ts +++ b/backend/src/plugins/Logs/LogsPlugin.ts @@ -112,6 +112,7 @@ import { logVoiceChannelLeave } from "./logFunctions/logVoiceChannelLeave"; import { logVoiceChannelMove } from "./logFunctions/logVoiceChannelMove"; import { logMemberTimedUnban } from "./logFunctions/logMemberTimedUnban"; import { logDmFailed } from "./logFunctions/logDmFailed"; +import { InternalPosterPlugin } from "../InternalPoster/InternalPosterPlugin"; const defaultOptions: PluginOptions = { config: { @@ -145,6 +146,7 @@ export const LogsPlugin = zeppelinGuildPlugin()({ dependencies: async () => [ TimeAndDatePlugin, + InternalPosterPlugin, // The `as any` cast here is to prevent TypeScript from locking up from the circular dependency ((await import("../Cases/CasesPlugin")) as any).CasesPlugin, ], diff --git a/backend/src/plugins/Logs/util/log.ts b/backend/src/plugins/Logs/util/log.ts index eb350d1d..37fca40a 100644 --- a/backend/src/plugins/Logs/util/log.ts +++ b/backend/src/plugins/Logs/util/log.ts @@ -7,6 +7,7 @@ import { TypedTemplateSafeValueContainer } from "../../../templateFormatter"; import { LogType } from "../../../data/LogType"; import { MessageBuffer } from "../../../utils/MessageBuffer"; import { createChunkedMessage, isDiscordAPIError, MINUTES } from "../../../utils"; +import { InternalPosterPlugin } from "../../InternalPoster/InternalPosterPlugin"; const excludedUserProps = ["user", "member", "mod"]; const excludedRoleProps = ["message.member.roles", "member.roles"]; @@ -98,6 +99,7 @@ export async function log( // Initialize message buffer for this channel if (!pluginData.state.buffers.has(channelId)) { const batchTime = Math.min(Math.max(opts.batch_time ?? DEFAULT_BATCH_TIME, MIN_BATCH_TIME), MAX_BATCH_TIME); + const internalPosterPlugin = pluginData.getPlugin(InternalPosterPlugin); pluginData.state.buffers.set( channelId, new MessageBuffer({ @@ -105,26 +107,26 @@ export async function log( textSeparator: "\n", consume: (part) => { const parse: MessageMentionTypes[] = pluginData.config.get().allow_user_mentions ? ["users"] : []; - const promise = - part.content && !part.embeds?.length - ? createChunkedMessage(channel, part.content, { parse }) - : channel.send({ - ...part, - allowedMentions: { parse }, - }); - promise.catch((err) => { - if (isDiscordAPIError(err)) { - // Missing Access / Missing Permissions - // TODO: Show/log this somewhere - if (err.code === 50001 || err.code === 50013) { - pluginData.state.channelCooldowns.setCooldown(channelId, 2 * MINUTES); - return; + internalPosterPlugin + .sendMessage(channel, { + ...part, + allowedMentions: { parse }, + }) + .catch((err) => { + if (isDiscordAPIError(err)) { + // Missing Access / Missing Permissions + // TODO: Show/log this somewhere + if (err.code === 50001 || err.code === 50013) { + pluginData.state.channelCooldowns.setCooldown(channelId, 2 * MINUTES); + return; + } } - } - // tslint:disable-next-line:no-console - console.warn(`Error while sending ${typeStr} log to ${pluginData.guild.id}/${channelId}: ${err.message}`); - }); + // tslint:disable-next-line:no-console + console.warn( + `Error while sending ${typeStr} log to ${pluginData.guild.id}/${channelId}: ${err.message}`, + ); + }); }, }), ); diff --git a/backend/src/plugins/availablePlugins.ts b/backend/src/plugins/availablePlugins.ts index 99b05f4c..dc8adf25 100644 --- a/backend/src/plugins/availablePlugins.ts +++ b/backend/src/plugins/availablePlugins.ts @@ -35,6 +35,7 @@ import { UtilityPlugin } from "./Utility/UtilityPlugin"; import { WelcomeMessagePlugin } from "./WelcomeMessage/WelcomeMessagePlugin"; import { ZeppelinGlobalPluginBlueprint, ZeppelinGuildPluginBlueprint } from "./ZeppelinPluginBlueprint"; import { PhishermanPlugin } from "./Phisherman/PhishermanPlugin"; +import { InternalPosterPlugin } from "./InternalPoster/InternalPosterPlugin"; // prettier-ignore export const guildPlugins: Array> = [ @@ -71,6 +72,7 @@ export const guildPlugins: Array> = [ CountersPlugin, ContextMenuPlugin, PhishermanPlugin, + InternalPosterPlugin, ]; // prettier-ignore