├── .eslintrc.json ├── package.json ├── perspective.js ├── .gitignore ├── discord.js └── README.md /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true 5 | }, 6 | "extends": [ 7 | "google" 8 | ], 9 | "globals": { 10 | "Atomics": "readonly", 11 | "SharedArrayBuffer": "readonly" 12 | }, 13 | "parserOptions": { 14 | "ecmaVersion": 2018, 15 | "sourceType": "module" 16 | }, 17 | "rules": { 18 | } 19 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "discord_karma", 3 | "version": "1.0.0", 4 | "description": "Discord bot that keeps track of user sentiment based on the language they use", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "Dale Markowitz", 10 | "license": "Apache-2.0", 11 | "dependencies": { 12 | "discord.js": "^12.1.1", 13 | "dotenv": "^8.2.0", 14 | "googleapis": "^48.0.0" 15 | }, 16 | "devDependencies": { 17 | "eslint": "^6.8.0", 18 | "eslint-config-google": "^0.14.0" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /perspective.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | /* Example usage of some features of the Perspective API */ 18 | const googleapis = require('googleapis'); 19 | 20 | require('dotenv').config(); 21 | 22 | // the values are the thresholds for when to trigger a response 23 | const attributeThresholds = { 24 | 'INSULT': 0.75, 25 | 'IDENTITY_ATTACK': 0.75, 26 | 'TOXICITY': 0.75, 27 | 'SPAM': 0.75, 28 | 'INCOHERENT': 0.75, 29 | 'FLIRTATION': 0.75, 30 | }; 31 | 32 | /** 33 | * Analyze attributes in a block of text 34 | * @param {string} text - text to analyze 35 | * @return {json} res - analyzed atttributes 36 | */ 37 | async function analyzeText(text) { 38 | const analyzer = new googleapis.commentanalyzer_v1alpha1.Commentanalyzer(); 39 | 40 | // this is the format the API expects 41 | const requestedAttributes = {}; 42 | for (const key in attributeThresholds) { 43 | requestedAttributes[key] = {}; 44 | } 45 | 46 | const req = { 47 | comment: {text: text}, 48 | languages: ['en'], 49 | requestedAttributes: requestedAttributes, 50 | }; 51 | 52 | const res = await analyzer.comments.analyze({ 53 | key: process.env.PERSPECTIVE_API_KEY, 54 | resource: req}, 55 | ); 56 | 57 | data = {}; 58 | 59 | for (const key in res['data']['attributeScores']) { 60 | data[key] = 61 | res['data']['attributeScores'][key]['summaryScore']['value'] > 62 | attributeThresholds[key]; 63 | } 64 | return data; 65 | } 66 | 67 | module.exports.analyzeText = analyzeText; 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # Snowpack dependency directory (https://snowpack.dev/) 45 | web_modules/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | .parcel-cache 78 | 79 | # Next.js build output 80 | .next 81 | out 82 | 83 | # Nuxt.js build / generate output 84 | .nuxt 85 | dist 86 | 87 | # Gatsby files 88 | .cache/ 89 | # Comment in the public line in if your project uses Gatsby and not Next.js 90 | # https://nextjs.org/blog/next-9-1#public-directory-support 91 | # public 92 | 93 | # vuepress build output 94 | .vuepress/dist 95 | 96 | # Serverless directories 97 | .serverless/ 98 | 99 | # FuseBox cache 100 | .fusebox/ 101 | 102 | # DynamoDB Local files 103 | .dynamodb/ 104 | 105 | # TernJS port file 106 | .tern-port 107 | 108 | # Stores VSCode versions used for testing VSCode extensions 109 | .vscode-test 110 | 111 | # yarn v2 112 | .yarn/cache 113 | .yarn/unplugged 114 | .yarn/build-state.yml 115 | .yarn/install-state.gz 116 | .pnp.* -------------------------------------------------------------------------------- /discord.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | const Discord = require('discord.js'); 18 | const perspective = require('./perspective.js'); 19 | 20 | require('dotenv').config(); 21 | 22 | const emojiMap = { 23 | 'FLIRTATION': '💕', 24 | 'IDENTITY_ATTACK': '⚠️', 25 | 'TOXICITY': '🧨', 26 | 'INSULT': '👊', 27 | 'INCOHERENT': '🤪', 28 | 'SPAM': '🗑', 29 | }; 30 | 31 | // Store some state about user sentiment 32 | // TODO: Migrate to a DB, like Firebase 33 | const users = {}; 34 | 35 | /** 36 | * Kick bad members out of the guild 37 | * @param {user} user - user to kick 38 | * @param {guild} guild - guild to kick user from 39 | */ 40 | async function kickBaddie(user, guild) { 41 | const member = guild.member(user); 42 | if (!member) return; 43 | try { 44 | await member.kick('was bad :('); 45 | } catch (err) { 46 | console.log(`Could not kick user ${user.username}: ${err}`); 47 | } 48 | } 49 | 50 | /** 51 | * Analyzes a user's message for attribues 52 | * and reacts to it. 53 | * @param {string} message - message the user sent 54 | * @return {bool} shouldKick - whether or not we should 55 | * kick the users 56 | */ 57 | async function evaluateMessage(message) { 58 | let scores; 59 | try { 60 | scores = await perspective.analyzeText(message.content); 61 | } catch (err) { 62 | console.log(err); 63 | return false; 64 | } 65 | 66 | const userid = message.author.id; 67 | 68 | for (const attribute in emojiMap) { 69 | if (scores[attribute]) { 70 | message.react(emojiMap[attribute]); 71 | users[userid][attribute] = 72 | users[userid][attribute] ? 73 | users[userid][attribute] + 1 : 1; 74 | } 75 | } 76 | // Return whether or not we should kick the user 77 | return (users[userid]['TOXICITY'] > process.env.KICK_THRESHOLD); 78 | } 79 | 80 | /** 81 | * Writes current user scores to the channel 82 | * @return {string} sentiment - printable sentiment scores 83 | */ 84 | function getSentiment() { 85 | const scores = []; 86 | for (const user in users) { 87 | if (!Object.keys(users[user]).length) continue; 88 | let score = `<@${user}> - `; 89 | for (const attr in users[user]) { 90 | score += `${emojiMap[attr]} : ${users[user][attr]}\t`; 91 | } 92 | scores.push(score); 93 | } 94 | console.log(scores); 95 | if (!scores.length) { 96 | return ''; 97 | } 98 | return scores.join('\n'); 99 | } 100 | 101 | // Create an instance of a Discord client 102 | const client = new Discord.Client(); 103 | 104 | client.on('ready', () => { 105 | console.log('I am ready!'); 106 | }); 107 | 108 | client.on('message', async (message) => { 109 | // Ignore messages that aren't from a guild 110 | // or are from a bot 111 | if (!message.guild || message.author.bot) return; 112 | 113 | // If we've never seen a user before, add them to memory 114 | const userid = message.author.id; 115 | if (!users[userid]) { 116 | users[userid] = []; 117 | } 118 | 119 | // Evaluate attributes of user's message 120 | let shouldKick = false; 121 | try { 122 | shouldKick = await evaluateMessage(message); 123 | } catch (err) { 124 | console.log(err); 125 | } 126 | if (shouldKick) { 127 | kickBaddie(message.author, message.guild); 128 | delete users[message.author.id]; 129 | message.channel.send(`Kicked user ${message.author.username} from channel`); 130 | return; 131 | } 132 | 133 | 134 | if (message.content.startsWith('!karma')) { 135 | const sentiment = getSentiment(message); 136 | message.channel.send(sentiment ? sentiment : 'No sentiment yet!'); 137 | } 138 | }); 139 | 140 | // Log out bot in using the token from https://discordapp.com/developers/applications/me 141 | client.login(process.env.DISCORD_TOKEN); 142 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Building an AI-Powered Discord Moderator Bot with the Perspective API 2 | Want a deep dive on this project? Read the [blog](https://daleonai.com/build-your-own-ai-moderator-bot-for-discord-with-the-perspective-api). 3 | 4 | This code in this repo lets you build your own moderator bot for 5 | [Discord](https://discordapp.com). Using the [Perspective API](https://perspectiveapi.com), 6 | it analyzes messages sent by users in a Discord channel and checks for 7 | insults, toxicity, spam, incoherence, and fliration. The bot assigns users 8 | emoji flags when they send messages that fall into these different categories: 9 | 10 | ![](https://storage.googleapis.com/blogstuff/discord_emojis.png-04-13-2020_1) 11 | 12 | When a user sends too many toxic messages, the bot kicks them from the Discord channel. 13 | 14 | ![](https://storage.googleapis.com/blogstuff/discord_ban.png-04-13-2020_0) 15 | 16 | To run this bot yourself, you'll need a [Google Cloud account](https://cloud.google.com/) and a Discord developer account (both are free). 17 | 18 | Let's get started. 19 | 20 | 1. Simply clone this repo or download the [Making with ML repo](https://github.com/dalequark/making_with_ml): 21 | 22 | `git clone git@github.com:dalequark/making_with_ml.git` 23 | 24 | then: 25 | 26 | `cd discord_moderator` 27 | 28 | 2. Create a (Google Cloud account)(https://cloud.google.com) if you don't already have one. Create 29 | a new GCP project. 30 | 31 | 2. In your new project, enable the [Perspective Comment Analyzer API](https://console.cloud.google.com/apis/api/commentanalyzer.googleapis.com/overview). 32 | 33 | 3. Next, in the Google Cloud console left hand menu, click API & Services -> Credentials. On that screen, click "+ Create Credentials" -> "API key". Copy that service account key. 34 | 35 | ![](https://storage.googleapis.com/blogstuff/api_credentials.png-04-13-2020_1) 36 | ![](gs://blogstuff/generate_api_key.png-04-13-2020_0) 37 | 38 | 4. On your computer, in the folder `discord_moderator`, you should see a `.env_template` file. 39 | Make a copy of thtat file: 40 | 41 | `cp .env_template .env` 42 | 43 | 5. In the new `.env` file, fill in the `PERSPECTIVE_API_KEY` field with the API key you copied above. 44 | 45 | 6. Now you should be able to use the Perspective API to analyze traits like toxicity, spam, 46 | incoherence, and more. To understand how tot use that API, take a look at 47 | `making_with_ml/discord_moderator/perspective.js`. In that file, you'll see all of the 48 | attributes the API supports: 49 | 50 | ```// Some supported attributes 51 | // attributes = ["TOXICITY", "SEVERE_TOXICITY", "IDENTITY_ATTACK", "INSULT", 52 | // "PROFANITY", "THREAT", "SEXUALLY_EXPLICIT", "FLIRTATION", "SPAM", 53 | // "ATTACK_ON_AUTHOR", "ATTACK_ON_COMMENTER", "INCOHERENT", 54 | // "INFLAMMATORY", "OBSCENE", "SPAM", "UNSUBSTANTIAL"]; 55 | ``` 56 | 57 | You can configure which attributes the API calls for and adjust their thresholds 58 | (how "confident" the model must be in an attribute in order to report it) in the 59 | `attributeThrehsolds` object: 60 | 61 | ``` 62 | // Set your own thresholds for when to trigger a response 63 | const attributeThresholds = { 64 | 'INSULT': 0.75, 65 | 'TOXICITY': 0.75, 66 | 'SPAM': 0.75, 67 | 'INCOHERENT': 0.75, 68 | 'FLIRTATION': 0.75, 69 | }; 70 | ``` 71 | 72 | 7. Now let's set up our Discord bot. First, create [Discord Developer account](https://discordapp.com/developers) and log in to the Developer Portal. On the top right hand corner of the screen, click "New Application." Give your app a name and description. 73 | 74 | ![](https://storage.googleapis.com/blogstuff/discord_new_app.png-04-13-2020_0) 75 | 76 | 8. Click in to your new Discord app and select "Bot" from the left hand menu. Select "Add Bot." Give your new Bot a username and upload a cute or intimidating user icon. On the Bot page, under `Token`, click "Copy" to copy your developer token to your clipboard. 77 | 78 | 9. Paste the Bot developer token you copied in the last step into your `.env` file: 79 | 80 | `DISCORD_TOKEN="YOUR TOKEN HERE"` 81 | 82 | 10. Now you should be able to run your Discord bot from the command line. In the folder, `making_with_ml/discord_moderator`, run: 83 | 84 | `npm install` then `node discord.js` 85 | 86 | It should print `I am ready!` to your terminal. 87 | 88 | 11. Now, open the Discord App and create a new Server which you'll use just for Bot development. To add your Bot to the server, go to the Discord Developer Portal and select `OAuth` from the left side bar: 89 | 90 | ![](https://storage.googleapis.com/blogstuff/oauth.png-04-13-2020_0) 91 | 92 | On the OAuth page, under "Scopes," check the box next to "Bot." Then scroll down and select the permissions "Kick Members," "Send TTS Messages," and "Add Reactions." 93 | 94 | ![](https://storage.googleapis.com/blogstuff/oauth_discord_checklist.png-04-13-2020_1) 95 | 96 | This should generate a https link in the "Scopes" section that you can copy and open in your browser. It should look something like: 97 | 98 | `https://discordapp.com/api/oauth2/authorize?client_id=YOUR_CLIENT_ID&permissions=4164&scope=bot` 99 | 100 | Opening this link in your browser will give you the ability to add your new bot to your test server, which you should do now. 101 | 102 | 12. Voila! Your moderator bot is running. In the Discord server where you added your bot, try typing 103 | phrases that will be recognized as toxic (i.e. "You stink"). The Bot should react with a 🧨 emoji 104 | for toxic phrases and a 👊 emoji for insults. You can configure these reactions in the `discord.js` file: 105 | 106 | ``` 107 | // Set your emoji "awards" here 108 | const emojiMap = { 109 | 'FLIRTATION': '💕', 110 | 'TOXICITY': '🧨', 111 | 'INSULT': '👊', 112 | 'INCOHERENT': '🤪', 113 | 'SPAM': '🗑', 114 | }; 115 | ``` 116 | 117 | If you write more than 4 toxic messages, you'll automatically get kicked from the Discord channel. 118 | To modify this threshold, modify `KICK_THRESHOLD` in your `.env` file. 119 | 120 | 13. Your Discord Bot should run successfully on your local computer now. As a next step, try hosting it with a Cloud service, like [App Engine](https://cloud.google.com/appengine). 121 | 122 | 123 | --------------------------------------------------------------------------------