diff --git a/src/apis/CloudVision.js b/src/apis/CloudVision.js new file mode 100644 index 000000000..c82cc026e --- /dev/null +++ b/src/apis/CloudVision.js @@ -0,0 +1,76 @@ +import config from '../bot/Config.js'; +import vision from '@google-cloud/vision'; +import logger from '../bot/Logger.js'; +import {Collection} from 'discord.js'; +import GuildSettings from '../settings/GuildSettings.js'; + +export class CloudVision { + #imageAnnotatorClient = null; + #imageTexts = new Collection(); + + get isEnabled() { + return config.data.googleCloud.vision?.enabled; + } + + get annotatorClient() { + if (!this.isEnabled) { + return null; + } + + return this.#imageAnnotatorClient ??= new vision.ImageAnnotatorClient({ + credentials: config.data.googleCloud.credentials + }); + } + + /** + * Get all image attachments from a message + * @param {import('discord.js').Message} message + * @returns {import('discord.js').Collection} + */ + getImages(message) { + return message.attachments.filter(attachment => attachment.contentType?.startsWith('image/')); + } + + /** + * Get text from images in a message + * @param {import('discord.js').Message} message + * @returns {Promise} + */ + async getImageText(message) { + if (!this.isEnabled) { + return []; + } + + const guildSettings = await GuildSettings.get(message.guild.id); + if (!guildSettings.isFeatureWhitelisted) { + return []; + } + + if (this.#imageTexts.has(message.id)) { + return this.#imageTexts.get(message.id); + } + + const texts = []; + + for (const image of this.getImages(message).values()) { + try { + const [{textAnnotations}] = await this.annotatorClient.textDetection(image.url); + for (const annotation of textAnnotations) { + texts.push(annotation.description); + } + } + catch (error) { + await logger.error(error); + } + } + + if (texts.length) { + this.#imageTexts.set(message.id, texts); + setTimeout(() => this.#imageTexts.delete(message.id), 5000); + } + + return texts; + } +} + +export default new CloudVision(); diff --git a/src/automod/AutoModManager.js b/src/automod/AutoModManager.js index a7d4a0105..3b282a13d 100644 --- a/src/automod/AutoModManager.js +++ b/src/automod/AutoModManager.js @@ -9,6 +9,7 @@ import {formatTime} from '../util/timeutils.js'; import RepeatedMessage from './RepeatedMessage.js'; import SafeSearch from './SafeSearch.js'; import logger from '../bot/Logger.js'; +import cloudVision from '../apis/CloudVision.js'; export class AutoModManager { #safeSearchCache; @@ -178,24 +179,53 @@ export class AutoModManager { const words = (/** @type {Collection} */ await BadWord.get(channel.id, message.guild.id)) .sort((a, b) => b.priority - a.priority); + for (let word of words.values()) { - if (word.matches(message)) { - const reason = 'Using forbidden words or phrases'; - const comment = `(Filter ID: ${word.id})`; - await bot.delete(message, reason + ' ' + comment); - if (word.response !== 'disabled') { - await this.#sendWarning(message, word.getResponse()); - } - if (word.punishment.action !== 'none') { - const member = new Member(message.author, message.guild); - await member.executePunishment(word.punishment, reason, comment); - } + if (word.matches(message.content)) { + await this.#deleteBadWordMessage(word, message); return true; } } + + if (!cloudVision.isEnabled || !(await GuildSettings.get(message.guild.id)).isFeatureWhitelisted) { + return false; + } + + let texts = null; + for (let word of words.values()) { + if (word.enableVision && word.trigger.supportsImages()) { + texts ??= await cloudVision.getImageText(message); + for (const text of texts) { + if (word.matches(text)) { + await this.#deleteBadWordMessage(word, message); + return true; + } + } + + } + } + return false; } + /** + * @param {BadWord} word + * @param {import('discord.js').Message} message + * @returns {Promise} + */ + async #deleteBadWordMessage(word, message) { + const reason = 'Using forbidden words or phrases'; + const comment = `(Filter ID: ${word.id})`; + await bot.delete(message, reason + ' ' + comment); + if (word.response !== 'disabled') { + await this.#sendWarning(message, word.getResponse()); + } + if (word.punishment.action !== 'none') { + const member = new Member(message.author, message.guild); + await member.executePunishment(word.punishment, reason, comment); + } + } + /** * @param {import('discord.js').Message} message * @return {Promise} has the message been deleted diff --git a/src/automod/SafeSearch.js b/src/automod/SafeSearch.js index 5fff4637d..44a3e5846 100644 --- a/src/automod/SafeSearch.js +++ b/src/automod/SafeSearch.js @@ -1,10 +1,9 @@ -import config from '../bot/Config.js'; import GuildSettings from '../settings/GuildSettings.js'; -import vision from '@google-cloud/vision'; import Cache from '../bot/Cache.js'; import Request from '../bot/Request.js'; import database from '../bot/Database.js'; import logger from '../bot/Logger.js'; +import cloudVision from '../apis/CloudVision.js'; const CACHE_DURATION = 60 * 60 * 1000; @@ -17,25 +16,13 @@ export default class SafeSearch { */ #requesting = new Map(); - constructor() { - if (this.isEnabled) { - this.annotatorClient = new vision.ImageAnnotatorClient({ - credentials: config.data.googleCloud.credentials - }); - } - } - - get isEnabled() { - return config.data.googleCloud.vision?.enabled; - } - /** * is safe search filtering enabled in this guild * @param {import('discord.js').Guild} guild * @return {Promise} */ async isEnabledInGuild(guild) { - if (!this.isEnabled) { + if (!cloudVision.isEnabled) { return false; } @@ -50,7 +37,7 @@ export default class SafeSearch { */ async detect(message) { /** @type {import('discord.js').Collection} */ - const images = message.attachments.filter(attachment => attachment.contentType?.startsWith('image/')); + const images = cloudVision.getImages(message); if (!images.size) { return null; } @@ -96,7 +83,7 @@ export default class SafeSearch { let safeSearchAnnotation = null; try { - [{safeSearchAnnotation}] = await this.annotatorClient.safeSearchDetection(image.url); + [{safeSearchAnnotation}] = await cloudVision.annotatorClient.safeSearchDetection(image.url); if (safeSearchAnnotation) { this.#cache.set(hash, safeSearchAnnotation, CACHE_DURATION); diff --git a/src/bot/Database.js b/src/bot/Database.js index 8a5addeb0..3f5d85f38 100644 --- a/src/bot/Database.js +++ b/src/bot/Database.js @@ -3,6 +3,8 @@ import logger from './Logger.js'; import config from './Config.js'; import CommentFieldMigration from '../database/migrations/CommentFieldMigration.js'; import {asyncFilter} from '../util/util.js'; +import BadWordVisionMigration from '../database/migrations/BadWordVisionMigration.js'; +import AutoResponseVisionMigration from '../database/migrations/AutoResponseVisionMigration.js'; export class Database { /** @@ -100,8 +102,8 @@ export class Database { await this.query('CREATE TABLE IF NOT EXISTS `channels` (`id` VARCHAR(20) NOT NULL, `config` TEXT NOT NULL, PRIMARY KEY (`id`), `guildid` VARCHAR(20))'); await this.query('CREATE TABLE IF NOT EXISTS `guilds` (`id` VARCHAR(20) NOT NULL, `config` TEXT NOT NULL, PRIMARY KEY (`id`))'); await this.query('CREATE TABLE IF NOT EXISTS `users` (`id` VARCHAR(20) NOT NULL, `config` TEXT NOT NULL, PRIMARY KEY (`id`))'); - await this.query('CREATE TABLE IF NOT EXISTS `responses` (`id` int PRIMARY KEY AUTO_INCREMENT, `guildid` VARCHAR(20) NOT NULL, `trigger` TEXT NOT NULL, `response` TEXT NOT NULL, `global` BOOLEAN NOT NULL, `channels` TEXT NULL DEFAULT NULL)'); - await this.query('CREATE TABLE IF NOT EXISTS `badWords` (`id` int PRIMARY KEY AUTO_INCREMENT, `guildid` VARCHAR(20) NOT NULL, `trigger` TEXT NOT NULL, `punishment` TEXT NOT NULL, `response` TEXT NOT NULL, `global` BOOLEAN NOT NULL, `channels` TEXT NULL DEFAULT NULL, `priority` int NULL)'); + await this.query('CREATE TABLE IF NOT EXISTS `responses` (`id` int PRIMARY KEY AUTO_INCREMENT, `guildid` VARCHAR(20) NOT NULL, `trigger` TEXT NOT NULL, `response` TEXT NOT NULL, `global` BOOLEAN NOT NULL, `channels` TEXT NULL DEFAULT NULL, `enableVision` BOOLEAN DEFAULT FALSE)'); + await this.query('CREATE TABLE IF NOT EXISTS `badWords` (`id` int PRIMARY KEY AUTO_INCREMENT, `guildid` VARCHAR(20) NOT NULL, `trigger` TEXT NOT NULL, `punishment` TEXT NOT NULL, `response` TEXT NOT NULL, `global` BOOLEAN NOT NULL, `channels` TEXT NULL DEFAULT NULL, `priority` int NULL, `enableVision` BOOLEAN DEFAULT FALSE)'); await this.query('CREATE TABLE IF NOT EXISTS `moderations` (`id` int PRIMARY KEY AUTO_INCREMENT, `guildid` VARCHAR(20) NOT NULL, `userid` VARCHAR(20) NOT NULL, `action` VARCHAR(10) NOT NULL, `created` bigint NOT NULL, `value` int DEFAULT 0, `expireTime` bigint NULL DEFAULT NULL, `reason` TEXT, `comment` TEXT NULL DEFAULT NULL, `moderator` VARCHAR(20) NULL DEFAULT NULL, `active` BOOLEAN DEFAULT TRUE)'); await this.query('CREATE TABLE IF NOT EXISTS `confirmations` (`id` int PRIMARY KEY AUTO_INCREMENT, `data` TEXT NOT NULL, `expires` bigint NOT NULL)'); await this.query('CREATE TABLE IF NOT EXISTS `safeSearch` (`hash` CHAR(64) PRIMARY KEY, `data` TEXT NOT NULL)'); @@ -109,7 +111,9 @@ export class Database { async getMigrations() { return await asyncFilter([ - new CommentFieldMigration(this) + new CommentFieldMigration(this), + new BadWordVisionMigration(this), + new AutoResponseVisionMigration(this), ], async migration => await migration.check()); } diff --git a/src/commands/settings/auto-response/AddAutoResponseCommand.js b/src/commands/settings/auto-response/AddAutoResponseCommand.js index c8f2251b1..d936c1ffc 100644 --- a/src/commands/settings/auto-response/AddAutoResponseCommand.js +++ b/src/commands/settings/auto-response/AddAutoResponseCommand.js @@ -13,6 +13,7 @@ import AutoResponse from '../../../database/AutoResponse.js'; import ErrorEmbed from '../../../embeds/ErrorEmbed.js'; import colors from '../../../util/colors.js'; import {SELECT_MENU_OPTIONS_LIMIT} from '../../../util/apiLimits.js'; +import config from '../../../bot/Config.js'; export default class AddAutoResponseCommand extends SubCommand { @@ -40,14 +41,23 @@ export default class AddAutoResponseCommand extends SubCommand { .setName('global') .setDescription('Use auto-response in all channels') .setRequired(false)); + + if (config.data.googleCloud.vision.enabled) { + builder.addBooleanOption(option => option + .setName('image-detection') + .setDescription('Respond to images containing text that matches the trigger') + .setRequired(false)); + } + return super.buildOptions(builder); } async execute(interaction) { - const global = interaction.options.getBoolean('global') ?? false; - const type = interaction.options.getString('type') ?? 'include'; + const global = interaction.options.getBoolean('global') ?? false, + type = interaction.options.getString('type') ?? 'include', + vision = interaction.options.getBoolean('image-detection') ?? false; - const confirmation = new Confirmation({global, type}, timeAfter('1 hour')); + const confirmation = new Confirmation({global, type, vision}, timeAfter('1 hour')); await interaction.showModal(new ModalBuilder() .setTitle(`Create new Auto-response of type ${type}`) .setCustomId(`auto-response:add:${await confirmation.save()}`) @@ -109,7 +119,8 @@ export default class AddAutoResponseCommand extends SubCommand { [], confirmation.data.type, trigger, - response + response, + confirmation.data.vision, ); } else { @@ -154,6 +165,7 @@ export default class AddAutoResponseCommand extends SubCommand { confirmation.data.type, confirmation.data.trigger, confirmation.data.response, + confirmation.data.vision, ); } @@ -165,10 +177,27 @@ export default class AddAutoResponseCommand extends SubCommand { * @param {string} type * @param {string} trigger * @param {string} response + * @param {?boolean} enableVision * @return {Promise<*>} */ - async create(interaction, global, channels, type, trigger, response) { - const result = await AutoResponse.new(interaction.guild.id, global, channels, type, trigger, response); + async create( + interaction, + global, + channels, + type, + trigger, + response, + enableVision, + ) { + const result = await AutoResponse.new( + interaction.guild.id, + global, + channels, + type, + trigger, + response, + enableVision, + ); if (!result.success) { return interaction.reply(ErrorEmbed.message(result.message)); } diff --git a/src/commands/settings/auto-response/EditAutoResponseCommand.js b/src/commands/settings/auto-response/EditAutoResponseCommand.js index 52c0fdbfb..9452d9b56 100644 --- a/src/commands/settings/auto-response/EditAutoResponseCommand.js +++ b/src/commands/settings/auto-response/EditAutoResponseCommand.js @@ -14,6 +14,7 @@ import AutoResponse from '../../../database/AutoResponse.js'; import ErrorEmbed from '../../../embeds/ErrorEmbed.js'; import colors from '../../../util/colors.js'; import {SELECT_MENU_OPTIONS_LIMIT} from '../../../util/apiLimits.js'; +import config from '../../../bot/Config.js'; export default class EditAutoResponseCommand extends CompletingAutoResponseCommand { @@ -48,6 +49,14 @@ export default class EditAutoResponseCommand extends CompletingAutoResponseComma .setName('global') .setDescription('Use auto-response in all channels') .setRequired(false)); + + if (config.data.googleCloud.vision.enabled) { + builder.addBooleanOption(option => option + .setName('image-detection') + .setDescription('Respond to images containing text that matches the trigger') + .setRequired(false)); + } + return super.buildOptions(builder); } @@ -60,9 +69,10 @@ export default class EditAutoResponseCommand extends CompletingAutoResponseComma return; } - const global = interaction.options.getBoolean('global'); - const type = interaction.options.getString('type'); - await this.showModal(interaction, autoResponse, global, type); + const global = interaction.options.getBoolean('global'), + type = interaction.options.getString('type'), + vision = interaction.options.getBoolean('image-detection'); + await this.showModal(interaction, autoResponse, global, type, vision); } async executeButton(interaction) { @@ -78,19 +88,29 @@ export default class EditAutoResponseCommand extends CompletingAutoResponseComma return; } - await this.showModal(interaction, autoResponse, null, null); + await this.showModal(interaction, autoResponse, null, null, null); } - async showModal(interaction, autoResponse, global, type) { + /** + * + * @param {import('discord.js').Interaction} interaction + * @param {AutoResponse} autoResponse + * @param {?boolean} global + * @param {?string} type + * @param {?boolean} vision + * @return {Promise} + */ + async showModal(interaction, autoResponse, global, type, vision) { global ??= autoResponse.global; type ??= autoResponse.trigger.type; + vision ??= autoResponse.enableVision; let trigger = autoResponse.trigger; if (type === 'regex') { trigger = trigger.toRegex(); } - const confirmation = new Confirmation({global, type, id: autoResponse.id}, timeAfter('1 hour')); + const confirmation = new Confirmation({global, type, id: autoResponse.id, vision}, timeAfter('1 hour')); await interaction.showModal(new ModalBuilder() .setTitle(`Edit Auto-response #${autoResponse.id}`) .setCustomId(`auto-response:edit:${await confirmation.save()}`) @@ -159,10 +179,10 @@ export default class EditAutoResponseCommand extends CompletingAutoResponseComma [], confirmation.data.type, trigger, - response + response, + confirmation.data.vision, ); - } - else { + } else { confirmation.data.trigger = trigger; confirmation.data.response = response; confirmation.expires = timeAfter('30 min'); @@ -206,6 +226,7 @@ export default class EditAutoResponseCommand extends CompletingAutoResponseComma confirmation.data.type, confirmation.data.trigger, confirmation.data.response, + confirmation.data.vision, ); } @@ -218,9 +239,19 @@ export default class EditAutoResponseCommand extends CompletingAutoResponseComma * @param {string} type * @param {string} trigger * @param {string} response + * @param {?boolean} vision * @return {Promise<*>} */ - async update(interaction, id, global, channels, type, trigger, response) { + async update( + interaction, + id, + global, + channels, + type, + trigger, + response, + vision, + ) { const autoResponse = /** @type {?AutoResponse} */ await AutoResponse.getByID(id, interaction.guildId); @@ -232,6 +263,7 @@ export default class EditAutoResponseCommand extends CompletingAutoResponseComma autoResponse.global = global; autoResponse.channels = channels; + autoResponse.enableVision = vision; const triggerResponse = AutoResponse.getTrigger(type, trigger); if (!triggerResponse.success) { return interaction.reply(ErrorEmbed.message(triggerResponse.message)); diff --git a/src/commands/settings/bad-word/AddBadWordCommand.js b/src/commands/settings/bad-word/AddBadWordCommand.js index 53bab0174..d8a429d74 100644 --- a/src/commands/settings/bad-word/AddBadWordCommand.js +++ b/src/commands/settings/bad-word/AddBadWordCommand.js @@ -52,9 +52,10 @@ export default class AddBadWordCommand extends AddAutoResponseCommand { async execute(interaction) { const global = interaction.options.getBoolean('global') ?? false, type = interaction.options.getString('type') ?? 'include', - punishment = interaction.options.getString('punishment') ?? 'none'; + punishment = interaction.options.getString('punishment') ?? 'none', + vision = interaction.options.getBoolean('image-detection') ?? false; - const confirmation = new Confirmation({global, punishment, type}, timeAfter('1 hour')); + const confirmation = new Confirmation({global, punishment, type, vision}, timeAfter('1 hour')); const modal = new ModalBuilder() .setTitle(`Create new Bad-word of type ${type}`) .setCustomId(`bad-word:add:${await confirmation.save()}`) @@ -159,9 +160,9 @@ export default class AddBadWordCommand extends AddAutoResponseCommand { confirmation.data.punishment, duration, priority, + confirmation.data.vision, ); - } - else { + } else { confirmation.data.trigger = trigger; confirmation.data.response = response; confirmation.data.duration = duration; @@ -208,9 +209,11 @@ export default class AddBadWordCommand extends AddAutoResponseCommand { confirmation.data.punishment, confirmation.data.duration, confirmation.data.priority, + confirmation.data.vision, ); } + // noinspection JSCheckFunctionSignatures /** * create the bad word * @param {import('discord.js').Interaction} interaction @@ -219,14 +222,36 @@ export default class AddBadWordCommand extends AddAutoResponseCommand { * @param {string} type * @param {string} trigger * @param {string} response - * @param {string} punishment + * @param {?string} punishment * @param {?number} duration - * @param {number} priority + * @param {?number} priority + * @param {?boolean} enableVision * @return {Promise<*>} */ - async create(interaction, global, channels, type, trigger, response, punishment, duration, priority) { - const result = await BadWord.new(interaction.guild.id, global, channels, type, - trigger, response, punishment, duration, priority); + async create( + interaction, + global, + channels, + type, + trigger, + response, + punishment, + duration, + priority, + enableVision, + ) { + const result = await BadWord.new( + interaction.guild.id, + global, + channels, + type, + trigger, + response, + punishment, + duration, + priority, + enableVision, + ); if (!result.success) { return interaction.reply(ErrorEmbed.message(result.message)); } diff --git a/src/commands/settings/bad-word/EditBadWordCommand.js b/src/commands/settings/bad-word/EditBadWordCommand.js index 9060f0af4..4c051a99f 100644 --- a/src/commands/settings/bad-word/EditBadWordCommand.js +++ b/src/commands/settings/bad-word/EditBadWordCommand.js @@ -14,6 +14,7 @@ import colors from '../../../util/colors.js'; import BadWord from '../../../database/BadWord.js'; import Punishment from '../../../database/Punishment.js'; import {SELECT_MENU_OPTIONS_LIMIT} from '../../../util/apiLimits.js'; +import config from '../../../bot/Config.js'; export default class EditBadWordCommand extends CompletingBadWordCommand { @@ -76,6 +77,14 @@ export default class EditBadWordCommand extends CompletingBadWordCommand { } ) ); + + if (config.data.googleCloud.vision.enabled) { + builder.addBooleanOption(option => option + .setName('image-detection') + .setDescription('Respond to images containing text that matches the trigger') + .setRequired(false)); + } + return super.buildOptions(builder); } @@ -88,10 +97,11 @@ export default class EditBadWordCommand extends CompletingBadWordCommand { return; } - const global = interaction.options.getBoolean('global'); - const type = interaction.options.getString('type'); - const punishment = interaction.options.getString('punishment'); - await this.showModal(interaction, badWord, global, type, punishment); + const global = interaction.options.getBoolean('global'), + type = interaction.options.getString('type'), + punishment = interaction.options.getString('punishment'), + vision = interaction.options.getBoolean('image-detection'); + await this.showModal(interaction, badWord, global, type, punishment, vision); } async executeButton(interaction) { @@ -107,7 +117,7 @@ export default class EditBadWordCommand extends CompletingBadWordCommand { return; } - await this.showModal(interaction, badWord, null, null, null); + await this.showModal(interaction, badWord, null, null, null, null); } /** @@ -117,19 +127,28 @@ export default class EditBadWordCommand extends CompletingBadWordCommand { * @param {?boolean} global * @param {?string} type * @param {?string} punishment + * @param {?boolean} vision * @return {Promise} */ - async showModal(interaction, badWord, global, type, punishment) { + async showModal( + interaction, + badWord, + global, + type, + punishment, + vision, + ) { global ??= badWord.global; type ??= badWord.trigger.type; punishment ??= badWord.punishment.action; + vision ??= badWord.enableVision; let trigger = badWord.trigger; if (type === 'regex') { trigger = trigger.toRegex(); } - const confirmation = new Confirmation({global, type, id: badWord.id, punishment}, timeAfter('1 hour')); + const confirmation = new Confirmation({global, type, id: badWord.id, punishment, vision}, timeAfter('1 hour')); const modal = new ModalBuilder() .setTitle(`Edit Bad-word #${badWord.id}`) .setCustomId(`bad-word:edit:${await confirmation.save()}`) @@ -245,9 +264,9 @@ export default class EditBadWordCommand extends CompletingBadWordCommand { confirmation.data.punishment, duration, priority, + confirmation.data.vision, ); - } - else { + } else { confirmation.data.trigger = trigger; confirmation.data.response = response; confirmation.data.duration = duration; @@ -296,6 +315,7 @@ export default class EditBadWordCommand extends CompletingBadWordCommand { confirmation.data.punishment, confirmation.data.duration, confirmation.data.priority, + confirmation.data.vision, ); } @@ -311,9 +331,22 @@ export default class EditBadWordCommand extends CompletingBadWordCommand { * @param {PunishmentAction} punishment * @param {?number} duration * @param {number} priority + * @param {?boolean} vision * @return {Promise<*>} */ - async update(interaction, id, global, channels, type, trigger, response, punishment, duration, priority) { + async update( + interaction, + id, + global, + channels, + type, + trigger, + response, + punishment, + duration, + priority, + vision, + ) { const badWord = /** @type {?BadWord} */ await BadWord.getByID(id, interaction.guildId); @@ -324,6 +357,7 @@ export default class EditBadWordCommand extends CompletingBadWordCommand { badWord.global = global; badWord.channels = channels; + badWord.enableVision = vision; const triggerResponse = BadWord.getTrigger(type, trigger); if (!triggerResponse.success) { return interaction.reply(ErrorEmbed.message(triggerResponse.message)); diff --git a/src/database/AutoResponse.js b/src/database/AutoResponse.js index 6c59ea3dd..a1040b82c 100644 --- a/src/database/AutoResponse.js +++ b/src/database/AutoResponse.js @@ -1,10 +1,8 @@ import ChatTriggeredFeature from './ChatTriggeredFeature.js'; import TypeChecker from '../settings/TypeChecker.js'; import {channelMention} from 'discord.js'; -import KeyValueEmbed from '../embeds/KeyValueEmbed.js'; -import {yesNo} from '../util/format.js'; -import {EMBED_FIELD_LIMIT} from '../util/apiLimits.js'; import colors from '../util/colors.js'; +import ChatFeatureEmbed from '../embeds/ChatFeatureEmbed.js'; /** * Class representing an auto response @@ -13,7 +11,7 @@ export default class AutoResponse extends ChatTriggeredFeature { static tableName = 'responses'; - static columns = ['guildid', 'trigger', 'response', 'global', 'channels']; + static columns = ['guildid', 'trigger', 'response', 'global', 'channels', 'enableVision']; /** * constructor - create an auto response @@ -23,6 +21,7 @@ export default class AutoResponse extends ChatTriggeredFeature { * @param {String} json.response message to send to the channel * @param {Boolean} json.global does this apply to all channels in this guild * @param {import('discord.js').Snowflake[]} [json.channels] channels that this applies to + * @param {boolean} [json.enableVision] enable vision api for this auto response * @param {Number} [id] id in DB * @return {AutoResponse} the auto response */ @@ -34,6 +33,7 @@ export default class AutoResponse extends ChatTriggeredFeature { this.response = json.response; this.global = json.global; this.channels = json.channels; + this.enableVision = json.enableVision ?? false; } if (!this.channels) { @@ -60,6 +60,8 @@ export default class AutoResponse extends ChatTriggeredFeature { } TypeChecker.assertString(json.trigger.content, 'Content'); TypeChecker.assertStringUndefinedOrNull(json.trigger.flags, 'Flags'); + + TypeChecker.assertBooleanOrNull(json.enableVision, 'Enable Vision'); } /** @@ -67,7 +69,7 @@ export default class AutoResponse extends ChatTriggeredFeature { * @returns {(*|string)[]} */ serialize() { - return [this.gid, JSON.stringify(this.trigger), this.response, this.global, this.channels.join(',')]; + return [this.gid, JSON.stringify(this.trigger), this.response, this.global, this.channels.join(','), this.enableVision]; } /** @@ -77,19 +79,7 @@ export default class AutoResponse extends ChatTriggeredFeature { * @returns {EmbedWrapper} */ embed(title = 'Auto-response', color = colors.GREEN) { - return new KeyValueEmbed() - .setTitle(title + ` [${this.id}]`) - .setColor(color) - .addPair('Trigger', this.trigger.asString()) - .addPair('Global', yesNo(this.global)) - .addPairIf(!this.global, 'Channels', this.channels.map(channelMention).join(', ')) - .addFields( - /** @type {any} */ - { - name: 'Response', - value: this.response.substring(0, EMBED_FIELD_LIMIT) - }, - ); + return new ChatFeatureEmbed(this, title, color); } /** @@ -100,9 +90,18 @@ export default class AutoResponse extends ChatTriggeredFeature { * @param {String} triggerType * @param {String} triggerContent * @param {String} responseText + * @param {?boolean} enableVision * @returns {Promise<{success:boolean, response: ?AutoResponse, message: ?string}>} */ - static async new(guildID, global, channels, triggerType, triggerContent, responseText) { + static async new( + guildID, + global, + channels, + triggerType, + triggerContent, + responseText, + enableVision + ) { let trigger = this.getTrigger(triggerType, triggerContent); if (!trigger.success) return {success: false, response: null, message: trigger.message}; @@ -111,7 +110,8 @@ export default class AutoResponse extends ChatTriggeredFeature { trigger: trigger.trigger, global, channels, - response: responseText + response: responseText, + enableVision, }); await response.save(); return {success: true, response: response, message: null}; diff --git a/src/database/BadWord.js b/src/database/BadWord.js index 74b8f9a26..66d17e927 100644 --- a/src/database/BadWord.js +++ b/src/database/BadWord.js @@ -2,10 +2,9 @@ import ChatTriggeredFeature from './ChatTriggeredFeature.js'; import TypeChecker from '../settings/TypeChecker.js'; import {channelMention} from 'discord.js'; import Punishment from './Punishment.js'; -import {yesNo} from '../util/format.js'; import {EMBED_FIELD_LIMIT} from '../util/apiLimits.js'; import colors from '../util/colors.js'; -import KeyValueEmbed from '../embeds/KeyValueEmbed.js'; +import ChatFeatureEmbed from '../embeds/ChatFeatureEmbed.js'; /** * Class representing a bad word @@ -18,7 +17,7 @@ export default class BadWord extends ChatTriggeredFeature { static tableName = 'badWords'; - static columns = ['guildid', 'trigger', 'punishment', 'response', 'global', 'channels', 'priority']; + static columns = ['guildid', 'trigger', 'punishment', 'response', 'global', 'channels', 'priority', 'enableVision']; /** * constructor - create a bad word @@ -30,6 +29,7 @@ export default class BadWord extends ChatTriggeredFeature { * @param {Boolean} json.global does this apply to all channels in this guild * @param {import('discord.js').Snowflake[]} [json.channels] channels that this applies to * @param {Number} [json.priority] badword priority (higher -> more important) + * @param {boolean} [json.enableVision] enable vision api for this badword * @param {Number} [id] id in DB * @return {BadWord} */ @@ -50,6 +50,7 @@ export default class BadWord extends ChatTriggeredFeature { this.global = json.global; this.channels = json.channels; this.priority = json.priority || 0; + this.enableVision = json.enableVision ?? false; } if (!this.channels) { @@ -79,6 +80,7 @@ export default class BadWord extends ChatTriggeredFeature { TypeChecker.assertStringUndefinedOrNull(json.trigger.flags, 'Flags'); TypeChecker.assertNumberUndefinedOrNull(json.priority, 'Priority'); + TypeChecker.assertBooleanOrNull(json.enableVision, 'Enable Vision'); } /** @@ -86,7 +88,7 @@ export default class BadWord extends ChatTriggeredFeature { * @returns {(*|string)[]} */ serialize() { - return [this.gid, JSON.stringify(this.trigger), JSON.stringify(this.punishment), this.response, this.global, this.channels.join(','), this.priority]; + return [this.gid, JSON.stringify(this.trigger), JSON.stringify(this.punishment), this.response, this.global, this.channels.join(','), this.priority, this.enableVision]; } /** @@ -97,21 +99,9 @@ export default class BadWord extends ChatTriggeredFeature { */ embed(title = 'Bad-word', color = colors.GREEN) { const duration = this.punishment.duration; - return new KeyValueEmbed() - .setTitle(title + ` [${this.id}]`) - .setColor(color) - .addPair('Trigger', this.trigger.asString()) - .addPair('Global', yesNo(this.global)) - .addPairIf(!this.global, 'Channels', this.channels.map(channelMention).join(', ')) + return new ChatFeatureEmbed(this, title, color) .addPair('Punishment', `${this.punishment.action} ${duration ? `for ${duration}` : ''}`) - .addPair('Priority', this.priority) - .addFields( - /** @type {any} */ - { - name: 'Response', - value: this.response.substring(0, EMBED_FIELD_LIMIT) - }, - ); + .addPair('Priority', this.priority); } /** @@ -125,9 +115,21 @@ export default class BadWord extends ChatTriggeredFeature { * @param {?string} punishment * @param {?number} duration * @param {?number} priority + * @param {?boolean} enableVision * @returns {Promise<{success:boolean, badWord: ?BadWord, message: ?string}>} */ - static async new(guildID, global, channels, triggerType, triggerContent, response, punishment, duration, priority) { + static async new( + guildID, + global, + channels, + triggerType, + triggerContent, + response, + punishment, + duration, + priority, + enableVision, + ) { let trigger = this.getTrigger(triggerType, triggerContent); if (!trigger.success) return {success: false, badWord: null, message: trigger.message}; @@ -139,6 +141,7 @@ export default class BadWord extends ChatTriggeredFeature { channels, response: response || 'disabled', priority, + enableVision, }); await badWord.save(); return {success: true, badWord, message: null}; diff --git a/src/database/ChatTriggeredFeature.js b/src/database/ChatTriggeredFeature.js index b1b00849d..ae32da9bb 100644 --- a/src/database/ChatTriggeredFeature.js +++ b/src/database/ChatTriggeredFeature.js @@ -1,16 +1,15 @@ -import Trigger from './Trigger.js'; import {Collection} from 'discord.js'; -import stringSimilarity from 'string-similarity'; import database from '../bot/Database.js'; import LineEmbed from '../embeds/LineEmbed.js'; import {EMBED_DESCRIPTION_LIMIT} from '../util/apiLimits.js'; import colors from '../util/colors.js'; +import Triggers from './triggers/Triggers.js'; /** * Config cache time (ms) * @type {Number} */ -const cacheDuration = 10*60*1000; +const cacheDuration = 10 * 60 * 1000; /** * @class @@ -63,13 +62,19 @@ export default class ChatTriggeredFeature { */ channels = []; + /** + * Whether cloud vision is enabled for this feature + * @type {boolean} + */ + enableVision = false; + /** * @param {Number} id ID in the database * @param {Trigger} trigger */ constructor(id, trigger) { this.id = id; - this.trigger = new Trigger(trigger); + this.trigger = Triggers.of(trigger); } static getCache() { @@ -120,72 +125,12 @@ export default class ChatTriggeredFeature { } /** - * matches - does this message match this item - * @param {Message} message + * Does the trigger match this content + * @param {string} content * @returns {boolean} */ - matches(message) { - switch (this.trigger.type) { - case 'include': - if (message.content.toLowerCase().includes(this.trigger.content.toLowerCase())) { - return true; - } - break; - - case 'match': - if (message.content.toLowerCase() === this.trigger.content.toLowerCase()) { - return true; - } - break; - - case 'regex': { - let regex = new RegExp(this.trigger.content, this.trigger.flags); - if (regex.test(message.content)) { - return true; - } - break; - } - - case 'phishing': { - // Split domain and min similarity (e.g. discord.com(gg):0.5) - let [domain, similarity] = String(this.trigger.content).split(':'); - similarity = parseFloat(similarity) || 0.5; - domain = domain.toLowerCase(); - // Split domain into "main part", extension and alternative extensions - let parts = domain.match(/^([^/]+)\.([^./(]+)(?:\(([^)]+)\))?$/); - if(!parts || !parts[1] || !parts[2]) { - break; - } - const expectedDomain = parts[1]; - const expectedExtensions = parts[3] ? [parts[2], ...parts[3].toLowerCase().split(/,\s?/g)] : [parts[2]]; - // Check all domains contained in the Discord message (and split them into "main part" and extension) - let regex = /https?:\/\/([^/]+)\.([^./]+)\b/ig, - matches; - while ((matches = regex.exec(message.content)) !== null) { - if(!matches[1] || !matches[2]) { - continue; - } - const foundDomain = matches[1].toLowerCase(), - foundExtension = matches[2].toLowerCase(); - const mainPartMatches = foundDomain === expectedDomain || foundDomain.endsWith(`.${expectedDomain}`); - // Domain is the actual domain or a subdomain of the actual domain -> no phishing - if(mainPartMatches && expectedExtensions.includes(foundExtension)){ - continue; - } - // "main part" matches, but extension doesn't -> probably phishing - if(mainPartMatches && !expectedExtensions.includes(foundExtension)) { - return true; - } - // "main part" is very similar to main part of the actual domain -> probably phishing - if(stringSimilarity.compareTwoStrings(expectedDomain, foundDomain) >= similarity) { - return true; - } - } - break; - } - } - - return false; + matches(content) { + return this.trigger.test(content); } /** @@ -221,7 +166,7 @@ export default class ChatTriggeredFeature { * @param {string} name feature name (e.g. Auto-responses or Bad-words). * @return {Promise} */ - static async getGuildOverview(guild, name ) { + static async getGuildOverview(guild, name) { const objects = await this.getAll(guild.id); if (!objects || !objects.size) { return [new LineEmbed() @@ -269,24 +214,25 @@ export default class ChatTriggeredFeature { } if (data.length !== columns.length) throw 'Unable to update, lengths differ!'; data.push(this.id); - await database.queryAll(`UPDATE ${this.constructor.escapedTableName} SET ${assignments.join(', ')} WHERE id = ?`, ...data); - } - else { + await database.queryAll(`UPDATE ${this.constructor.escapedTableName} + SET ${assignments.join(', ')} + WHERE id = ?`, ...data); + } else { const columns = database.escapeId(this.constructor.columns); const values = ',?'.repeat(this.constructor.columns.length).slice(1); /** @property {Number} insertId*/ const dbEntry = await database.queryAll( - `INSERT INTO ${this.constructor.escapedTableName} (${columns}) VALUES (${values})`, ...this.serialize()); + `INSERT INTO ${this.constructor.escapedTableName} (${columns}) + VALUES (${values})`, ...this.serialize()); this.id = dbEntry.insertId; } if (this.global) { if (!this.constructor.getGuildCache().has(this.gid)) return this.id; this.constructor.getGuildCache().get(this.gid).set(this.id, this); - } - else { + } else { for (const channel of this.channels) { - if(!this.constructor.getChannelCache().has(channel)) continue; + if (!this.constructor.getChannelCache().has(channel)) continue; this.constructor.getChannelCache().get(channel).set(this.id, this); } } @@ -300,16 +246,15 @@ export default class ChatTriggeredFeature { * @returns {Promise} */ async delete() { - await database.query(`DELETE FROM ${this.constructor.escapedTableName} WHERE id = ?`,[this.id]); + await database.query(`DELETE FROM ${this.constructor.escapedTableName} WHERE id = ?`, [this.id]); if (this.global) { if (this.constructor.getGuildCache().has(this.gid)) this.constructor.getGuildCache().get(this.gid).delete(this.id); - } - else { + } else { const channelCache = this.constructor.getChannelCache(); for (const channel of this.channels) { - if(channelCache.has(channel)) { + if (channelCache.has(channel)) { channelCache.get(channel).delete(this.id); } } @@ -328,7 +273,8 @@ export default class ChatTriggeredFeature { response: data.response, global: data.global === 1, channels: data.channels.split(','), - priority: data.priority + priority: data.priority, + enableVision: data.enableVision, }, data.id); } @@ -339,7 +285,10 @@ export default class ChatTriggeredFeature { * @returns {Promise} */ static async getByID(id, guildid) { - const result = await database.query(`SELECT * FROM ${this.escapedTableName} WHERE id = ? AND guildid = ?`, id, guildid); + const result = await database.query(`SELECT * + FROM ${this.escapedTableName} + WHERE id = ? + AND guildid = ?`, id, guildid); if (!result) return null; return this.fromData(result); } @@ -351,23 +300,31 @@ export default class ChatTriggeredFeature { * @returns {{trigger: ?Trigger, success: boolean, message: ?string}} */ static getTrigger(type, value) { - if (!this.triggerTypes.includes(type)) return {success: false, message: `Invalid trigger type ${type}`, trigger: null}; - if (!value) return {success: false, message:'Empty triggers are not allowed', trigger: null}; + if (!this.triggerTypes.includes(type)) return { + success: false, + message: `Invalid trigger type ${type}`, + trigger: null + }; + if (!value) return {success: false, message: 'Empty triggers are not allowed', trigger: null}; let content = value, flags; if (type === 'regex') { /** @type {String[]}*/ let parts = value.split(/(? { this.getGuildCache().delete(guildId); - },cacheDuration); + }, cacheDuration); } /** @@ -434,7 +396,9 @@ export default class ChatTriggeredFeature { */ static async refreshChannel(channelId) { const result = await database.queryAll( - `SELECT * FROM ${this.escapedTableName} WHERE channels LIKE ?`, [`%${channelId}%`]); + `SELECT * + FROM ${this.escapedTableName} + WHERE channels LIKE ?`, [`%${channelId}%`]); const newItems = new Collection(); for (const res of result) { @@ -443,7 +407,7 @@ export default class ChatTriggeredFeature { this.getChannelCache().set(channelId, newItems); setTimeout(() => { this.getChannelCache().delete(channelId); - },cacheDuration); + }, cacheDuration); } } diff --git a/src/database/migrations/AutoResponseVisionMigration.js b/src/database/migrations/AutoResponseVisionMigration.js new file mode 100644 index 000000000..945bc7bd6 --- /dev/null +++ b/src/database/migrations/AutoResponseVisionMigration.js @@ -0,0 +1,11 @@ +import VisionMigration from './VisionMigration.js'; + +export default class AutoResponseVisionMigration extends VisionMigration { + get previousField() { + return 'channels'; + } + + get table() { + return 'responses'; + } +} diff --git a/src/database/migrations/BadWordVisionMigration.js b/src/database/migrations/BadWordVisionMigration.js new file mode 100644 index 000000000..6fc9413fe --- /dev/null +++ b/src/database/migrations/BadWordVisionMigration.js @@ -0,0 +1,11 @@ +import VisionMigration from './VisionMigration.js'; + +export default class BadWordVisionMigration extends VisionMigration { + get previousField() { + return 'priority'; + } + + get table() { + return 'badWords'; + } +} diff --git a/src/database/migrations/VisionMigration.js b/src/database/migrations/VisionMigration.js new file mode 100644 index 000000000..0fa738922 --- /dev/null +++ b/src/database/migrations/VisionMigration.js @@ -0,0 +1,33 @@ +import Migration from './Migration.js'; + +/** + * @abstract + */ +export default class VisionMigration extends Migration { + /** + * @abstract + */ + get table() { + throw new Error('Not implemented'); + } + + /** + * @abstract + */ + get previousField() { + throw new Error('Not implemented'); + } + + async check() { + /** + * @type {{Field: string, Type: string, Key: string, Default, Extra: string}[]} + */ + const columns = await this.database.queryAll('DESCRIBE ' + this.database.escapeId(this.table)); + return !columns.some(column => column.Field === 'enableVision'); + } + + async run() { + await this.database.query(`ALTER TABLE ${this.database.escapeId(this.table)}` + + `ADD COLUMN \`enableVision\` BOOLEAN DEFAULT FALSE AFTER ${this.database.escapeId(this.previousField)}`); + } +} \ No newline at end of file diff --git a/src/database/triggers/IncludeTrigger.js b/src/database/triggers/IncludeTrigger.js new file mode 100644 index 000000000..eeb8dcd65 --- /dev/null +++ b/src/database/triggers/IncludeTrigger.js @@ -0,0 +1,27 @@ +import Trigger from './Trigger.js'; +import {escapeRegExp} from '../../util/util.js'; +import RegexTrigger from './RegexTrigger.js'; + +export default class IncludeTrigger extends Trigger { + constructor(data) { + super({ + type: 'include', + ...data + }); + } + + toRegex() { + return new RegexTrigger({ + content: escapeRegExp(this.content), + flags: this.flags + }); + } + + test(content) { + return content.toLowerCase().includes(this.content.toLowerCase()); + } + + supportsImages() { + return true; + } +} \ No newline at end of file diff --git a/src/database/triggers/MatchTrigger.js b/src/database/triggers/MatchTrigger.js new file mode 100644 index 000000000..f281d35d4 --- /dev/null +++ b/src/database/triggers/MatchTrigger.js @@ -0,0 +1,23 @@ +import Trigger from './Trigger.js'; +import {escapeRegExp} from '../../util/util.js'; +import RegexTrigger from './RegexTrigger.js'; + +export default class MatchTrigger extends Trigger { + constructor(data) { + super({ + type: 'match', + ...data + }); + } + + toRegex() { + return new RegexTrigger({ + content: '^' + escapeRegExp(this.content) + '$', + flags: this.flags + }); + } + + test(content) { + return content.toLowerCase() === this.content.toLowerCase(); + } +} diff --git a/src/database/triggers/PhishingTrigger.js b/src/database/triggers/PhishingTrigger.js new file mode 100644 index 000000000..9e66575f7 --- /dev/null +++ b/src/database/triggers/PhishingTrigger.js @@ -0,0 +1,58 @@ +import Trigger from './Trigger.js'; +import stringSimilarity from 'string-similarity'; + +export default class PhishingTrigger extends Trigger { + constructor(data) { + super({ + type: 'phishing', + ...data + }); + } + + toRegex() { + return this; + } + + test(content) { + // Split domain and min similarity (e.g. discord.com(gg):0.5) + let [domain, similarity] = String(this.trigger.content).split(':'); + similarity = parseFloat(similarity) || 0.5; + domain = domain.toLowerCase(); + // Split domain into "main part", extension and alternative extensions + const parts = domain.match(/^([^/]+)\.([^./(]+)(?:\(([^)]+)\))?$/); + if (!parts || !parts[1] || !parts[2]) { + return false; + } + + const expectedDomain = parts[1]; + const expectedExtensions = parts[3] ? [parts[2], ...parts[3].toLowerCase().split(/,\s?/g)] : [parts[2]]; + // Check all domains contained in the content (and split them into "main part" and extension) + const regex = /https?:\/\/([^/]+)\.([^./]+)\b/ig; + + + let matches; + while ((matches = regex.exec(content)) !== null) { + if (!matches[1] || !matches[2]) { + continue; + } + const foundDomain = matches[1].toLowerCase(), + foundExtension = matches[2].toLowerCase(); + const mainPartMatches = foundDomain === expectedDomain || foundDomain.endsWith(`.${expectedDomain}`); + + // Domain is the actual domain or a subdomain of the actual domain -> no phishing + if (mainPartMatches && expectedExtensions.includes(foundExtension)) { + continue; + } + + // "main part" matches, but extension doesn't -> probably phishing + if (mainPartMatches && !expectedExtensions.includes(foundExtension)) { + return true; + } + + // "main part" is very similar to main part of the actual domain -> probably phishing + if (stringSimilarity.compareTwoStrings(expectedDomain, foundDomain) >= similarity) { + return true; + } + } + } +} \ No newline at end of file diff --git a/src/database/triggers/RegexTrigger.js b/src/database/triggers/RegexTrigger.js new file mode 100644 index 000000000..23f2705e0 --- /dev/null +++ b/src/database/triggers/RegexTrigger.js @@ -0,0 +1,30 @@ +import Trigger from './Trigger.js'; + +export default class RegexTrigger extends Trigger { + constructor(data) { + super({ + type: 'regex', + ...data + }); + } + + /** + * @return {string} + */ + asContentString() { + return `/${this.content}/${this.flags ?? ''}`; + } + + toRegex() { + return this; + } + + test(content) { + let regex = new RegExp(this.content, this.flags); + return regex.test(content); + } + + supportsImages() { + return true; + } +} \ No newline at end of file diff --git a/src/database/Trigger.js b/src/database/triggers/Trigger.js similarity index 59% rename from src/database/Trigger.js rename to src/database/triggers/Trigger.js index 67793426b..6e80b1cc9 100644 --- a/src/database/Trigger.js +++ b/src/database/triggers/Trigger.js @@ -1,6 +1,8 @@ import {inlineCode} from 'discord.js'; -import {escapeRegExp} from '../util/util.js'; +/** + * @abstract + */ export default class Trigger { /** * @type {String} @@ -40,32 +42,32 @@ export default class Trigger { * @return {string} */ asContentString() { - if (this.type === 'regex') { - return `/${this.content}/${this.flags ?? ''}`; - } else { - return this.content; - } + return this.content; } /** * Convert this trigger to a regex trigger. * This is only supported for include and match types. * @return {Trigger} + * @abstract */ toRegex() { - let content; - switch (this.type) { - case 'include': - content = escapeRegExp(this.content); - break; - case 'match': - content = `^${escapeRegExp(this.content)}$`; - break; + throw new Error('Not implemented'); + } - default: - return this; - } + /** + * @param {String} content + * @returns {boolean} + * @abstract + */ + test(content) { + throw new Error('Not implemented'); + } - return new Trigger({type: 'regex', content, flags: 'i'}); + /** + * @returns {boolean} + */ + supportsImages() { + return false; } } \ No newline at end of file diff --git a/src/database/triggers/Triggers.js b/src/database/triggers/Triggers.js new file mode 100644 index 000000000..653c96806 --- /dev/null +++ b/src/database/triggers/Triggers.js @@ -0,0 +1,21 @@ +import IncludeTrigger from './IncludeTrigger.js'; +import MatchTrigger from './MatchTrigger.js'; +import RegexTrigger from './RegexTrigger.js'; +import PhishingTrigger from './PhishingTrigger.js'; + +export default class Triggers { + static of(data) { + switch (data.type) { + case 'include': + return new IncludeTrigger(data); + case 'match': + return new MatchTrigger(data); + case 'regex': + return new RegexTrigger(data); + case 'phishing': + return new PhishingTrigger(data); + default: + throw new Error(`Invalid trigger type: ${data.type}`); + } + } +} \ No newline at end of file diff --git a/src/embeds/ChatFeatureEmbed.js b/src/embeds/ChatFeatureEmbed.js new file mode 100644 index 000000000..c9f29b69f --- /dev/null +++ b/src/embeds/ChatFeatureEmbed.js @@ -0,0 +1,30 @@ +import KeyValueEmbed from './KeyValueEmbed.js'; +import {yesNo} from '../util/format.js'; +import {channelMention} from 'discord.js'; +import cloudVision from '../apis/CloudVision.js'; +import {EMBED_FIELD_LIMIT} from '../util/apiLimits.js'; + +export default class ChatFeatureEmbed extends KeyValueEmbed { + /** + * @param {import('../database/ChatTriggeredFeature.js')} feature + * @param {string} title + * @param {import('discord.js').ColorResolvable} color + */ + constructor(feature, title, color) { + super(); + this.setTitle(title + ` [${feature.id}]`) + .setColor(color) + .addPair('Trigger', feature.trigger.asString()) + .addPair('Global', yesNo(feature.global)) + .addPairIf(!feature.global, 'Channels', feature.channels.map(channelMention).join(', ')) + .addPairIf(cloudVision.isEnabled, 'Detect images', yesNo(feature.enableVision)) + .addFields( + /** @type {any} */ + { + name: 'Response', + value: feature.response.substring(0, EMBED_FIELD_LIMIT) + }, + ); + } + +} \ No newline at end of file diff --git a/src/events/discord/messageCreate/AutoResponseEventListener.js b/src/events/discord/messageCreate/AutoResponseEventListener.js index 8fa1010b3..3c586a9c4 100644 --- a/src/events/discord/messageCreate/AutoResponseEventListener.js +++ b/src/events/discord/messageCreate/AutoResponseEventListener.js @@ -3,6 +3,9 @@ import AutoResponse from '../../../database/AutoResponse.js'; import {RESTJSONErrorCodes, ThreadChannel} from 'discord.js'; import {MESSAGE_LENGTH_LIMIT} from '../../../util/apiLimits.js'; import logger from '../../../bot/Logger.js'; +import {asyncFilter} from '../../../util/util.js'; +import cloudVision from '../../../apis/CloudVision.js'; +import GuildSettings from '../../../settings/GuildSettings.js'; export default class AutoResponseEventListener extends MessageCreateEventListener { @@ -16,9 +19,29 @@ export default class AutoResponseEventListener extends MessageCreateEventListene channel = (/** @type {import('discord.js').ThreadChannel} */ channel).parent; } - /** @type {IterableIterator} */ - const responses = (await AutoResponse.get(channel.id, message.guild.id)).values(); - const triggered = Array.from(responses).filter(response => response.matches(message)); + let texts = null; + const responses = /** @type {AutoResponse[]} */ Array.from(( + await AutoResponse.get(channel.id, message.guild.id) + ).values()); + const triggered = /** @type {AutoResponse[]} */ await asyncFilter(responses, + /** @param {AutoResponse} response */ + async response => { + if (response.matches(message.content)) { + return true; + } + + if (!cloudVision.isEnabled + || !(await GuildSettings.get(message.guild.id)).isFeatureWhitelisted + || !response.enableVision + || !response.trigger.supportsImages() + ) { + return false; + } + + texts ??= await cloudVision.getImageText(message); + return texts.some(t => response.matches(t)); + } + ); if (triggered.length) { const response = triggered[Math.floor(Math.random() * triggered.length)]; diff --git a/src/settings/TypeChecker.js b/src/settings/TypeChecker.js index d58960315..0b8ebcbc8 100644 --- a/src/settings/TypeChecker.js +++ b/src/settings/TypeChecker.js @@ -52,4 +52,8 @@ export default class TypeChecker { static assertString(value, name) { return this.assertOfTypes(value, ['string'], name); } + + static assertBooleanOrNull(value, name) { + return this.assertOfTypes(value, ['boolean'], name, true); + } } \ No newline at end of file