Skip to content

Commit 6855347

Browse files
committed
Add image support for bad-words/auto-responses
1 parent f359732 commit 6855347

23 files changed

+675
-218
lines changed

src/apis/CloudVision.js

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import config from '../bot/Config.js';
2+
import vision from '@google-cloud/vision';
3+
import logger from '../bot/Logger.js';
4+
import {Collection} from 'discord.js';
5+
import GuildSettings from '../settings/GuildSettings.js';
6+
7+
export class CloudVision {
8+
#imageAnnotatorClient = null;
9+
#imageTexts = new Collection();
10+
11+
get isEnabled() {
12+
return config.data.googleCloud.vision?.enabled;
13+
}
14+
15+
get annotatorClient() {
16+
if (!this.isEnabled) {
17+
return null;
18+
}
19+
20+
return this.#imageAnnotatorClient ??= new vision.ImageAnnotatorClient({
21+
credentials: config.data.googleCloud.credentials
22+
});
23+
}
24+
25+
/**
26+
* Get all image attachments from a message
27+
* @param {import('discord.js').Message} message
28+
* @returns {import('discord.js').Collection<import('discord.js').Snowflake, import('discord.js').Attachment>}
29+
*/
30+
getImages(message) {
31+
return message.attachments.filter(attachment => attachment.contentType?.startsWith('image/'));
32+
}
33+
34+
/**
35+
* Get text from images in a message
36+
* @param {import('discord.js').Message} message
37+
* @returns {Promise<string[]>}
38+
*/
39+
async getImageText(message) {
40+
if (!this.isEnabled) {
41+
return [];
42+
}
43+
44+
const guildSettings = await GuildSettings.get(message.guild.id);
45+
if (!guildSettings.isFeatureWhitelisted) {
46+
return [];
47+
}
48+
49+
if (this.#imageTexts.has(message.id)) {
50+
return this.#imageTexts.get(message.id);
51+
}
52+
53+
const texts = [];
54+
55+
for (const image of this.getImages(message).values()) {
56+
try {
57+
const [{textAnnotations}] = await this.annotatorClient.textDetection(image.url);
58+
for (const annotation of textAnnotations) {
59+
texts.push(annotation.description);
60+
}
61+
}
62+
catch (error) {
63+
await logger.error(error);
64+
}
65+
}
66+
67+
if (texts.length) {
68+
this.#imageTexts.set(message.id, texts);
69+
setTimeout(() => this.#imageTexts.delete(message.id), 5000);
70+
}
71+
72+
return texts;
73+
}
74+
}
75+
76+
export default new CloudVision();

src/automod/AutoModManager.js

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {formatTime} from '../util/timeutils.js';
99
import RepeatedMessage from './RepeatedMessage.js';
1010
import SafeSearch from './SafeSearch.js';
1111
import logger from '../bot/Logger.js';
12+
import cloudVision from '../apis/CloudVision.js';
1213

1314
export class AutoModManager {
1415
#safeSearchCache;
@@ -178,24 +179,53 @@ export class AutoModManager {
178179

179180
const words = (/** @type {Collection<number, BadWord>} */ await BadWord.get(channel.id, message.guild.id))
180181
.sort((a, b) => b.priority - a.priority);
182+
181183
for (let word of words.values()) {
182-
if (word.matches(message)) {
183-
const reason = 'Using forbidden words or phrases';
184-
const comment = `(Filter ID: ${word.id})`;
185-
await bot.delete(message, reason + ' ' + comment);
186-
if (word.response !== 'disabled') {
187-
await this.#sendWarning(message, word.getResponse());
188-
}
189-
if (word.punishment.action !== 'none') {
190-
const member = new Member(message.author, message.guild);
191-
await member.executePunishment(word.punishment, reason, comment);
192-
}
184+
if (word.matches(message.content)) {
185+
await this.#deleteBadWordMessage(word, message);
193186
return true;
194187
}
195188
}
189+
190+
if (!cloudVision.isEnabled || !(await GuildSettings.get(message.guild.id)).isFeatureWhitelisted) {
191+
return false;
192+
}
193+
194+
let texts = null;
195+
for (let word of words.values()) {
196+
if (word.enableVision && word.trigger.supportsImages()) {
197+
texts ??= await cloudVision.getImageText(message);
198+
for (const text of texts) {
199+
if (word.matches(text)) {
200+
await this.#deleteBadWordMessage(word, message);
201+
return true;
202+
}
203+
}
204+
205+
}
206+
}
207+
196208
return false;
197209
}
198210

211+
/**
212+
* @param {BadWord} word
213+
* @param {import('discord.js').Message} message
214+
* @returns {Promise<void>}
215+
*/
216+
async #deleteBadWordMessage(word, message) {
217+
const reason = 'Using forbidden words or phrases';
218+
const comment = `(Filter ID: ${word.id})`;
219+
await bot.delete(message, reason + ' ' + comment);
220+
if (word.response !== 'disabled') {
221+
await this.#sendWarning(message, word.getResponse());
222+
}
223+
if (word.punishment.action !== 'none') {
224+
const member = new Member(message.author, message.guild);
225+
await member.executePunishment(word.punishment, reason, comment);
226+
}
227+
}
228+
199229
/**
200230
* @param {import('discord.js').Message} message
201231
* @return {Promise<boolean>} has the message been deleted

src/automod/SafeSearch.js

Lines changed: 4 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1-
import config from '../bot/Config.js';
21
import GuildSettings from '../settings/GuildSettings.js';
3-
import vision from '@google-cloud/vision';
42
import Cache from '../bot/Cache.js';
53
import Request from '../bot/Request.js';
64
import database from '../bot/Database.js';
75
import logger from '../bot/Logger.js';
6+
import cloudVision from '../apis/CloudVision.js';
87

98
const CACHE_DURATION = 60 * 60 * 1000;
109

@@ -17,25 +16,13 @@ export default class SafeSearch {
1716
*/
1817
#requesting = new Map();
1918

20-
constructor() {
21-
if (this.isEnabled) {
22-
this.annotatorClient = new vision.ImageAnnotatorClient({
23-
credentials: config.data.googleCloud.credentials
24-
});
25-
}
26-
}
27-
28-
get isEnabled() {
29-
return config.data.googleCloud.vision?.enabled;
30-
}
31-
3219
/**
3320
* is safe search filtering enabled in this guild
3421
* @param {import('discord.js').Guild} guild
3522
* @return {Promise<boolean>}
3623
*/
3724
async isEnabledInGuild(guild) {
38-
if (!this.isEnabled) {
25+
if (!cloudVision.isEnabled) {
3926
return false;
4027
}
4128

@@ -50,7 +37,7 @@ export default class SafeSearch {
5037
*/
5138
async detect(message) {
5239
/** @type {import('discord.js').Collection<string, import('discord.js').Attachment>} */
53-
const images = message.attachments.filter(attachment => attachment.contentType?.startsWith('image/'));
40+
const images = cloudVision.getImages(message);
5441
if (!images.size) {
5542
return null;
5643
}
@@ -96,7 +83,7 @@ export default class SafeSearch {
9683

9784
let safeSearchAnnotation = null;
9885
try {
99-
[{safeSearchAnnotation}] = await this.annotatorClient.safeSearchDetection(image.url);
86+
[{safeSearchAnnotation}] = await cloudVision.annotatorClient.safeSearchDetection(image.url);
10087

10188
if (safeSearchAnnotation) {
10289
this.#cache.set(hash, safeSearchAnnotation, CACHE_DURATION);

src/bot/Database.js

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import logger from './Logger.js';
33
import config from './Config.js';
44
import CommentFieldMigration from '../database/migrations/CommentFieldMigration.js';
55
import {asyncFilter} from '../util/util.js';
6+
import BadWordVisionMigration from '../database/migrations/BadWordVisionMigration.js';
7+
import AutoResponseVisionMigration from '../database/migrations/AutoResponseVisionMigration.js';
68

79
export class Database {
810
/**
@@ -100,16 +102,18 @@ export class Database {
100102
await this.query('CREATE TABLE IF NOT EXISTS `channels` (`id` VARCHAR(20) NOT NULL, `config` TEXT NOT NULL, PRIMARY KEY (`id`), `guildid` VARCHAR(20))');
101103
await this.query('CREATE TABLE IF NOT EXISTS `guilds` (`id` VARCHAR(20) NOT NULL, `config` TEXT NOT NULL, PRIMARY KEY (`id`))');
102104
await this.query('CREATE TABLE IF NOT EXISTS `users` (`id` VARCHAR(20) NOT NULL, `config` TEXT NOT NULL, PRIMARY KEY (`id`))');
103-
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)');
104-
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)');
105+
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)');
106+
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)');
105107
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)');
106108
await this.query('CREATE TABLE IF NOT EXISTS `confirmations` (`id` int PRIMARY KEY AUTO_INCREMENT, `data` TEXT NOT NULL, `expires` bigint NOT NULL)');
107109
await this.query('CREATE TABLE IF NOT EXISTS `safeSearch` (`hash` CHAR(64) PRIMARY KEY, `data` TEXT NOT NULL)');
108110
}
109111

110112
async getMigrations() {
111113
return await asyncFilter([
112-
new CommentFieldMigration(this)
114+
new CommentFieldMigration(this),
115+
new BadWordVisionMigration(this),
116+
new AutoResponseVisionMigration(this),
113117
], async migration => await migration.check());
114118
}
115119

src/commands/settings/auto-response/AddAutoResponseCommand.js

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import AutoResponse from '../../../database/AutoResponse.js';
1313
import ErrorEmbed from '../../../embeds/ErrorEmbed.js';
1414
import colors from '../../../util/colors.js';
1515
import {SELECT_MENU_OPTIONS_LIMIT} from '../../../util/apiLimits.js';
16+
import config from '../../../bot/Config.js';
1617

1718
export default class AddAutoResponseCommand extends SubCommand {
1819

@@ -40,14 +41,23 @@ export default class AddAutoResponseCommand extends SubCommand {
4041
.setName('global')
4142
.setDescription('Use auto-response in all channels')
4243
.setRequired(false));
44+
45+
if (config.data.googleCloud.vision.enabled) {
46+
builder.addBooleanOption(option => option
47+
.setName('image-detection')
48+
.setDescription('Respond to images containing text that matches the trigger')
49+
.setRequired(false));
50+
}
51+
4352
return super.buildOptions(builder);
4453
}
4554

4655
async execute(interaction) {
47-
const global = interaction.options.getBoolean('global') ?? false;
48-
const type = interaction.options.getString('type') ?? 'include';
56+
const global = interaction.options.getBoolean('global') ?? false,
57+
type = interaction.options.getString('type') ?? 'include',
58+
vision = interaction.options.getBoolean('image-detection') ?? false;
4959

50-
const confirmation = new Confirmation({global, type}, timeAfter('1 hour'));
60+
const confirmation = new Confirmation({global, type, vision}, timeAfter('1 hour'));
5161
await interaction.showModal(new ModalBuilder()
5262
.setTitle(`Create new Auto-response of type ${type}`)
5363
.setCustomId(`auto-response:add:${await confirmation.save()}`)
@@ -109,7 +119,8 @@ export default class AddAutoResponseCommand extends SubCommand {
109119
[],
110120
confirmation.data.type,
111121
trigger,
112-
response
122+
response,
123+
confirmation.data.vision,
113124
);
114125
}
115126
else {
@@ -154,6 +165,7 @@ export default class AddAutoResponseCommand extends SubCommand {
154165
confirmation.data.type,
155166
confirmation.data.trigger,
156167
confirmation.data.response,
168+
confirmation.data.vision,
157169
);
158170
}
159171

@@ -165,10 +177,27 @@ export default class AddAutoResponseCommand extends SubCommand {
165177
* @param {string} type
166178
* @param {string} trigger
167179
* @param {string} response
180+
* @param {?boolean} enableVision
168181
* @return {Promise<*>}
169182
*/
170-
async create(interaction, global, channels, type, trigger, response) {
171-
const result = await AutoResponse.new(interaction.guild.id, global, channels, type, trigger, response);
183+
async create(
184+
interaction,
185+
global,
186+
channels,
187+
type,
188+
trigger,
189+
response,
190+
enableVision,
191+
) {
192+
const result = await AutoResponse.new(
193+
interaction.guild.id,
194+
global,
195+
channels,
196+
type,
197+
trigger,
198+
response,
199+
enableVision,
200+
);
172201
if (!result.success) {
173202
return interaction.reply(ErrorEmbed.message(result.message));
174203
}

0 commit comments

Comments
 (0)