Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added spam and troll detection #152

Merged
merged 9 commits into from
Feb 6, 2022
Merged
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@

Set these accordingly within the correct config folder. If you are testing locally, use the `/config/dev` folder for local configurations.

- `TARGET_GUILD_ID`: the guild (server) in which coffee chats are being held.
- `COFFEE_ROLE_ID`: the ID of the role the bot will use to decide who is enrolled into coffeechats.
- `TARGET_GUILD_ID`: the ID of the guild (server) in which coffee chats are being held.
- `COFFEE_ROLE_ID`: the ID of the role the bot will use to decide who is enrolled into coffee chats.
- `NOTIF_CHANNEL_ID`: the ID of the channel the bot will send system notifications to.
- `MOD_CHANNEL_ID` : the ID of the discord mod channel.
Picowchew marked this conversation as resolved.
Show resolved Hide resolved
- `HONEYPOT_CHANNEL_ID`: the ID of the honeypot channel, used to help detect spammers and trolls.
- `PC_CATEGORY_ID`: the ID of the Program Committee category, containing the Program Committee channels.

## Required environment variables

Expand Down
3 changes: 2 additions & 1 deletion config/production/vars.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@
"TARGET_GUILD_ID": "667823274201448469",
"COFFEE_ROLE_ID": "936899047816589354",
"NOTIF_CHANNEL_ID": "872305316522508329",
"MOD_CHANNEL_ID": "841526693135122452"
"HONEYPOT_CHANNEL_ID": "938889977121607750",
"PC_CATEGORY_ID": "813606301108142090"
}
3 changes: 2 additions & 1 deletion config/staging/vars.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@
"TARGET_GUILD_ID": "701335585272627200",
"COFFEE_ROLE_ID": "861744590775779358",
"NOTIF_CHANNEL_ID": "844995465002483752",
"MOD_CHANNEL_ID": "844995465002483752"
"HONEYPOT_CHANNEL_ID": "938846001916166215",
"PC_CATEGORY_ID": "938643353543790624"
}
6 changes: 2 additions & 4 deletions src/bot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ dotenv.config();

import Discord from 'discord.js';
import yaml from 'js-yaml';
import fs from 'fs';
import Commando from 'discord.js-commando';
import path from 'path';

Expand All @@ -13,15 +12,14 @@ import { messageListener } from './components/messageListener';
import { initEmojis } from './components/emojis';
import { createSuggestionCron, createBonusInterviewerListCron } from './components/cron';
import { readFileSync } from 'fs';
import { vars } from './config';

const ENV: string = process.env.NODE_ENV || '.';
const vars = JSON.parse(readFileSync(`./config/${ENV}/vars.json`, 'utf-8'));
const NOTIF_CHANNEL_ID: string = vars.NOTIF_CHANNEL_ID;
const BOT_TOKEN: string = process.env.BOT_TOKEN || '.';
const BOT_PREFIX = '.';

// initialize Commando client
const botOwners = yaml.load(fs.readFileSync('config/owners.yml', 'utf8')) as string[];
const botOwners = yaml.load(readFileSync('config/owners.yml', 'utf8')) as string[];
export const client = new Commando.Client({
owner: botOwners,
commandPrefix: BOT_PREFIX,
Expand Down
3 changes: 1 addition & 2 deletions src/commands/coffeechats/coffeeMatch.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { Message } from 'discord.js';
import { Message, User } from 'discord.js';
import { CommandoClient, CommandoMessage } from 'discord.js-commando';
import { writeHistoricMatches, getMatch } from '../../components/coffeechat';
import { AdminCommand } from '../../utils/commands';
import { User } from 'discord.js';
import _ from 'lodash';
import { logError } from '../../components/logger';

Expand Down
18 changes: 1 addition & 17 deletions src/components/coffeechat.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
import { Client } from 'discord.js-commando';
import { Database } from 'sqlite';
import { openDB } from './db';
import _ from 'lodash';
import { Person, stableMarriage } from 'stable-marriage';
import { readFileSync } from 'fs';
import { vars } from '../config';

const ENV: string = process.env.NODE_ENV || '.';
const vars = JSON.parse(readFileSync(`./config/${ENV}/vars.json`, 'utf-8'));
const COFFEE_ROLE_ID: string = vars.COFFEE_ROLE_ID;
const TARGET_GUILD_ID: string = vars.TARGET_GUILD_ID;
//since we might fully hit hundreds of people if we release this into the wider server, set iterations at around 100-200 to keep time at a reasonable number
Expand All @@ -19,19 +16,6 @@ interface historic_match {
match_date: string;
}

export const initCoffeeChatTables = async (db: Database): Promise<void> => {
//Database to store past matches, with TIMESTAMP being the time matches were written into DB
await db.run(
`
CREATE TABLE IF NOT EXISTS coffee_historic_matches (
first_user_id TEXT NOT NULL,
second_user_id TEXT NOT NULL,
match_date TIMESTAMP NOT NULL
)
`
);
};

/*
* Generates a single match round based on historical match records
* Does NOT save this match to history; must call writeHistoricMatches to "confirm" this matching happened
Expand Down
44 changes: 0 additions & 44 deletions src/components/coin.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import { Database } from 'sqlite';
import _ from 'lodash';

import { openDB } from './db';

export enum BonusType {
Expand Down Expand Up @@ -70,48 +68,6 @@ export interface UserCoinBonus {
last_granted: Date;
}

export const initUserCoinTable = async (db: Database): Promise<void> => {
await db.run(
`
CREATE TABLE IF NOT EXISTS user_coin (
user_id VARCHAR(255) PRIMARY KEY NOT NULL,
balance INTEGER NOT NULL CHECK(balance>=0)
);
`
);
};

export const initUserCoinBonusTable = async (db: Database): Promise<void> => {
await db.run(
`
CREATE TABLE IF NOT EXISTS user_coin_bonus (
user_id VARCHAR(255) NOT NULL,
bonus_type INTEGER NOT NULL,
last_granted TIMESTAMP NOT NULL,
PRIMARY KEY (user_id, bonus_type)
);
`
);
};

export const initUserCoinLedgerTable = async (db: Database): Promise<void> => {
await db.run(
`
CREATE TABLE IF NOT EXISTS user_coin_ledger (
id INTEGER PRIMARY KEY NOT NULL,
user_id VARCHAR(255) NOT NULL,
amount INTEGER NOT NULL,
new_balance INTEGER NOT NULL,
event INTEGER NOT NULL,
reason VARCHAR(255),
admin_id VARCHAR(255),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`
);
await db.run('CREATE INDEX IF NOT EXISTS ix_user_coin_ledger_user_id ON user_coin_ledger (user_id)');
};

export const getCoinBalanceByUserId = async (userId: string): Promise<number> => {
const db = await openDB();
// Query user coin balance from DB.
Expand Down
8 changes: 3 additions & 5 deletions src/components/cron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,17 @@ import { EMBED_COLOUR } from '../utils/embeds';
import { getInterviewers } from './interview';
import { coinBonusMap, BonusType, adjustCoinBalanceByUserId } from './coin';
import _ from 'lodash';
import { readFileSync } from 'fs';
import { vars } from '../config';

const ENV: string = process.env.NODE_ENV || '.';
const vars = JSON.parse(readFileSync(`./config/${ENV}/vars.json`, 'utf-8'));
const MOD_CHANNEL_ID: string = vars.MOD_CHANNEL_ID;
const NOTIF_CHANNEL_ID: string = vars.NOTIF_CHANNEL_ID;

// Checks for new suggestions every min
export const createSuggestionCron = (client: CommandoClient): CronJob =>
new CronJob('0 */1 * * * *', async function () {
const createdSuggestions = await getSuggestions(SuggestionState.Created);
const createdSuggestionIds = createdSuggestions.map((a) => Number(a.id));
if (!_.isEmpty(createdSuggestionIds)) {
const messageChannel = client.channels.cache.get(MOD_CHANNEL_ID);
const messageChannel = client.channels.cache.get(NOTIF_CHANNEL_ID);

if (!messageChannel) {
throw 'Bad channel ID';
Expand Down
102 changes: 94 additions & 8 deletions src/components/db.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,111 @@
import sqlite3 from 'sqlite3';
import { open, Database } from 'sqlite';

import { initSuggestionsTable } from './suggestions';
import { initInterviewTables } from './interview';
import { initCoffeeChatTables } from './coffeechat';
import { initUserCoinBonusTable, initUserCoinTable, initUserCoinLedgerTable } from './coin';
import logger from './logger';

let db: Database | null = null;

export const openCommandoDB = async (): Promise<Database> =>
await open({ filename: 'db/commando.db', driver: sqlite3.Database });

const initCoffeeChatTables = async (db: Database): Promise<void> => {
//Database to store past matches, with TIMESTAMP being the time matches were written into DB
await db.run(
`
CREATE TABLE IF NOT EXISTS coffee_historic_matches (
first_user_id TEXT NOT NULL,
second_user_id TEXT NOT NULL,
match_date TIMESTAMP NOT NULL
)
`
);
};

const initInterviewTables = async (db: Database): Promise<void> => {
await db.run(
`
CREATE TABLE IF NOT EXISTS interviewers (
user_id TEXT PRIMARY KEY,
link TEXT NOT NULL,
status INTEGER NOT NULL DEFAULT 0
)
`
);
await db.run(
`
CREATE TABLE IF NOT EXISTS domains (
user_id TEXT NOT NULL,
domain TEXT NOT NULL
)
`
);
await db.run('CREATE INDEX IF NOT EXISTS ix_domains_domain ON domains (domain)');
};

const initSuggestionsTable = async (db: Database): Promise<void> => {
await db.run(
`
CREATE TABLE IF NOT EXISTS suggestions (
id INTEGER PRIMARY KEY NOT NULL,
author_id VARCHAR(255) NOT NULL,
author_username TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
suggestion TEXT NOT NULL,
state VARCHAR(255) NOT NULL
)
`
);
};

const initUserCoinBonusTable = async (db: Database): Promise<void> => {
await db.run(
`
CREATE TABLE IF NOT EXISTS user_coin_bonus (
user_id VARCHAR(255) NOT NULL,
bonus_type INTEGER NOT NULL,
last_granted TIMESTAMP NOT NULL,
PRIMARY KEY (user_id, bonus_type)
)
`
);
};

const initUserCoinLedgerTable = async (db: Database): Promise<void> => {
await db.run(
`
CREATE TABLE IF NOT EXISTS user_coin_ledger (
id INTEGER PRIMARY KEY NOT NULL,
user_id VARCHAR(255) NOT NULL,
amount INTEGER NOT NULL,
new_balance INTEGER NOT NULL,
event INTEGER NOT NULL,
reason VARCHAR(255),
admin_id VARCHAR(255),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`
);
await db.run('CREATE INDEX IF NOT EXISTS ix_user_coin_ledger_user_id ON user_coin_ledger (user_id)');
};

const initUserCoinTable = async (db: Database): Promise<void> => {
await db.run(
`
CREATE TABLE IF NOT EXISTS user_coin (
user_id VARCHAR(255) PRIMARY KEY NOT NULL,
balance INTEGER NOT NULL CHECK(balance>=0)
)
`
);
};

const initTables = async (db: Database): Promise<void> => {
//initialize all relevant tables
await initSuggestionsTable(db);
await initInterviewTables(db);
await initCoffeeChatTables(db);
await initUserCoinTable(db);
await initInterviewTables(db);
await initSuggestionsTable(db);
await initUserCoinBonusTable(db);
await initUserCoinLedgerTable(db);
await initUserCoinTable(db);
};

export const openDB = async (): Promise<Database> => {
Expand Down
9 changes: 0 additions & 9 deletions src/components/interview.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import _ from 'lodash';
import { Database } from 'sqlite';

import { openDB } from './db';

//maps from key to readable string
Expand Down Expand Up @@ -28,13 +26,6 @@ export enum Status {
Paused
}

export const initInterviewTables = async (db: Database): Promise<void> => {
await db.run(`CREATE TABLE IF NOT EXISTS interviewers
(user_id TEXT PRIMARY KEY, link TEXT NOT NULL, status INTEGER NOT NULL DEFAULT 0)`);
await db.run('CREATE TABLE IF NOT EXISTS domains (user_id TEXT NOT NULL, domain TEXT NOT NULL)');
await db.run('CREATE INDEX IF NOT EXISTS ix_domains_domain ON domains (domain)');
};

export const getInterviewer = async (id: string): Promise<Interviewer | undefined> => {
const db = await openDB();
return await db.get('SELECT * FROM interviewers WHERE user_id = ?', id);
Expand Down
56 changes: 54 additions & 2 deletions src/components/messageListener.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,61 @@
import { Message } from 'discord.js';
import { Message, TextChannel } from 'discord.js';
import { applyBonusByUserId } from './coin';
import { client } from '../bot';
import { TextChannel } from 'discord.js';
import { logError } from './logger';
import { sendKickEmbed } from '../utils/embeds';
import { vars } from '../config';

const PC_CATEGORY_ID: string = vars.PC_CATEGORY_ID;
const HONEYPOT_CHANNEL_ID: string = vars.HONEYPOT_CHANNEL_ID;

/*
* Detect spammers/trolls/people who got hacked, not by the honeypot method, by
* detecting that the message contains a ping and punishable word, and is sent
* in the Discord server, not in the PC category
*/
const detectSpammersAndTrollsNotByHoneypot = (message: Message): boolean => {
// Pings that would mention many people in the Discord server
const pingWords = ['@everyone', '@here'];
// Keywords that point towards the user being a spammer/troll/someone who got hacked
const punishableWords = ['http', 'nitro'];
return (
pingWords.some((word) => message.content.includes(word)) &&
punishableWords.some((word) => message.content.toLowerCase().includes(word)) &&
message.channel instanceof TextChannel &&
message.channel.parentID !== PC_CATEGORY_ID
);
};

/*
* Punish spammers/trolls/people who got hacked
Picowchew marked this conversation as resolved.
Show resolved Hide resolved
* Return true if someone of this kind is detected, false otherwise
*/
const punishSpammersAndTrolls = async (message: Message): Promise<boolean> => {
if (detectSpammersAndTrollsNotByHoneypot(message) || message.channel.id === HONEYPOT_CHANNEL_ID) {
// Delete the message, and if the user is still in the server, then kick them and log it
await message.delete();
if (message.member) {
const user = message.member.user;
const reason = 'Spammer/troll/got hacked';
let isSuccessful = true;
try {
await message.member.kick(reason);
} catch (err) {
isSuccessful = false;
logError(err as Error);
}
await sendKickEmbed(message, user, reason, isSuccessful);
}
return true;
}
return false;
};

export const messageListener = async (message: Message): Promise<void> => {
if (await punishSpammersAndTrolls(message)) {
return;
}

if (!client.user) {
return;
}
Expand Down
Loading