├── .editorconfig ├── .env.example ├── .eslintrc.json ├── .gitattributes ├── .gitignore ├── LICENSE ├── README.md ├── package-lock.json ├── package.json └── src ├── api.js ├── character ├── index.js └── query.js ├── deleteViaReaction.js ├── discordMessage.js ├── index.js ├── media ├── index.js └── query.js ├── staff ├── index.js └── query.js ├── studio ├── index.js └── query.js └── user ├── index.js └── query.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 4 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | TOKEN=DISCORD_TOKEN_HERE 2 | PREFIX=! 3 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint:recommended", 3 | "parserOptions": { 4 | "ecmaVersion": "2019" 5 | }, 6 | "env": { 7 | "node": true 8 | }, 9 | "rules": { 10 | "no-console": "off" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 joshstar 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

AniList Discord Bot

2 |

A simple Discord bot for searching AniList.co

3 | 4 | --- 5 | 6 | ## Usage 7 | 8 | ![AniList Discord Bot !help command](https://user-images.githubusercontent.com/4658208/51229242-9ffca900-1929-11e9-8e5f-b7603bdff35a.png) 9 | 10 | ## Installation 11 | 12 | ### Requirements 13 | 14 | - Node v12.0.0 or higher 15 | - A Discord developer account 16 | 17 | ### Getting Started 18 | 19 | 1. Clone this repo and run `npm install` 20 | 1. Create a copy of `.env.example` named `.env` 21 | 1. Go to the [Discord Developer Portal](https://discordapp.com/developers/applications/) and create an application 22 | 1. Go to the "Bot" page and click "Add a bot" 23 | 1. Copy the token created for your bot and paste it into the `TOKEN` value in your `.env` file 24 | 1. Run `npm start` 25 | 26 | ## Config 27 | 28 | ### Prefix 29 | 30 | Type: `string`
31 | Default: `!` 32 | 33 | This determines what should prefix the commands that are recognized by the bot. 34 | 35 | ## License 36 | 37 | MIT © [Josh Star](./LICENSE) 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "anilist-discord-bot", 3 | "version": "1.0.0", 4 | "description": "Search for anime, manga, and more from AniList", 5 | "author": "Josh Star", 6 | "license": "MIT", 7 | "main": "src/index.js", 8 | "scripts": { 9 | "start": "node src/index.js", 10 | "dev": "nodemon --watch src src/index.js", 11 | "lint": "eslint --ext .js src/" 12 | }, 13 | "husky": { 14 | "hooks": { 15 | "pre-commit": "lint-staged" 16 | } 17 | }, 18 | "lint-staged": { 19 | "*.js": [ 20 | "eslint --fix", 21 | "git add" 22 | ], 23 | "*.{js,md}": [ 24 | "prettier --write", 25 | "git add" 26 | ] 27 | }, 28 | "dependencies": { 29 | "discord.js": "^12.5.3", 30 | "dotenv": "^6.2.0", 31 | "graphql-request": "^1.8.2", 32 | "striptags": "^3.1.1", 33 | "turndown": "^5.0.3" 34 | }, 35 | "devDependencies": { 36 | "eslint": "^5.16.0", 37 | "husky": "^1.3.1", 38 | "lint-staged": "^8.2.1", 39 | "nodemon": "^1.19.4", 40 | "prettier": "^1.19.1" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/api.js: -------------------------------------------------------------------------------- 1 | const { GraphQLClient } = require("graphql-request"); 2 | 3 | const client = new GraphQLClient("https://graphql.anilist.co", { 4 | redirect: "follow" 5 | }); 6 | 7 | const fetch = (query, variables) => 8 | client 9 | .request(query, variables) 10 | .then(data => data) 11 | .catch(error => ({ 12 | error: error.response.errors[0] || "Unknown Error" 13 | })); 14 | 15 | module.exports = fetch; 16 | -------------------------------------------------------------------------------- /src/character/index.js: -------------------------------------------------------------------------------- 1 | const api = require("../api"); 2 | const query = require("./query"); 3 | const discordMessage = require("../discordMessage"); 4 | 5 | const search = async searchArg => { 6 | const response = await api(query, { 7 | search: searchArg 8 | }); 9 | 10 | if (response.error) { 11 | return response; 12 | } 13 | 14 | const data = response.Character; 15 | 16 | let name = data.name.first; 17 | if (data.name.last != null) { 18 | name += ` ${data.name.last}`; 19 | } 20 | 21 | return discordMessage({ 22 | name: name, 23 | url: data.siteUrl, 24 | imageUrl: data.image.large, 25 | description: data.description 26 | }); 27 | }; 28 | 29 | module.exports = { 30 | search 31 | }; 32 | -------------------------------------------------------------------------------- /src/character/query.js: -------------------------------------------------------------------------------- 1 | module.exports = ` 2 | query ($search: String) { 3 | Character(search: $search) { 4 | id 5 | siteUrl 6 | name { 7 | first 8 | last 9 | } 10 | image { 11 | large 12 | } 13 | description(asHtml: true) 14 | } 15 | } 16 | `; 17 | -------------------------------------------------------------------------------- /src/deleteViaReaction.js: -------------------------------------------------------------------------------- 1 | async function deleteViaReaction( 2 | commandMessage, 3 | responseEmbed, 4 | responseUrlMessage, 5 | client 6 | ) { 7 | // Create the initial reaction 8 | const reaction = await responseEmbed.react("❌"); 9 | 10 | // Wait 20 seconds before giving up 11 | const reactionFilter = (reaction, user) => 12 | reaction.emoji.name === "❌" && user.id === commandMessage.author.id; 13 | const reactionCollector = responseEmbed.createReactionCollector( 14 | reactionFilter, 15 | { time: 20000 } 16 | ); 17 | 18 | // Delete the message if we 'collect' (Filter is successful) 19 | reactionCollector.once("collect", reaction => { 20 | reaction.message.delete(); 21 | if (responseUrlMessage) responseUrlMessage.delete(); 22 | }); 23 | 24 | // When the time is up, remove the bots reaction to the post 25 | reactionCollector.once("end", () => { 26 | if (!reaction.message.deleted) { 27 | try { 28 | reaction.remove(client.user); 29 | } catch (e) { 30 | // Manage Messages permissions not enabled 31 | } 32 | } 33 | }); 34 | } 35 | 36 | module.exports = deleteViaReaction; 37 | -------------------------------------------------------------------------------- /src/discordMessage.js: -------------------------------------------------------------------------------- 1 | const TurndownService = require("turndown"); 2 | const turndownService = new TurndownService(); 3 | const anilistLogo = "https://anilist.co/img/logo_al.png"; 4 | 5 | turndownService.remove("span"); 6 | 7 | const shorten = str => { 8 | const markdown = turndownService.turndown(str); 9 | if (markdown.length > 400) { 10 | return markdown.substring(0, 400) + "..."; 11 | } else { 12 | return markdown; 13 | } 14 | }; 15 | 16 | const discordMessage = ({ 17 | name, 18 | url, 19 | imageUrl, 20 | description, 21 | footer, 22 | title 23 | } = {}) => { 24 | return { 25 | title: title, 26 | author: { 27 | name: name, 28 | url: url, 29 | icon_url: anilistLogo 30 | }, 31 | thumbnail: { 32 | url: imageUrl 33 | }, 34 | description: shorten(description), 35 | footer: { 36 | text: footer 37 | } 38 | }; 39 | }; 40 | 41 | module.exports = discordMessage; 42 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | require("dotenv").config(); 2 | const Discord = require("discord.js"); 3 | 4 | const character = require("./character"); 5 | const media = require("./media"); 6 | const staff = require("./staff"); 7 | const user = require("./user"); 8 | const studio = require("./studio"); 9 | 10 | const client = new Discord.Client({ disableMentions: "everyone" }); 11 | 12 | const deleteViaReaction = require("./deleteViaReaction"); 13 | 14 | // Use exclamation mark as the default prefix 15 | const prefix = process.env.PREFIX || "!"; 16 | 17 | client.on("ready", () => { 18 | // This event will run if the bot starts, and logs in, successfully. 19 | console.log( 20 | `Bot has started, with ${client.users.cache.size} users, in ${client.channels.cache.size} channels of ${client.guilds.cache.size} guilds.` 21 | ); 22 | console.log( 23 | `\nAdd the bot to your server here:\nhttps://discordapp.com/oauth2/authorize?client_id=${client.user.id}&scope=bot&permissions=1024` 24 | ); 25 | }); 26 | 27 | client.on("message", async message => { 28 | const messageContent = message.content; 29 | 30 | // Ensure the message starts with our prefix 31 | if (messageContent.indexOf(prefix) !== 0) { 32 | return; 33 | } 34 | 35 | let args = messageContent 36 | .slice(prefix.length) 37 | .trim() 38 | .split(/ +/g); 39 | const command = args.shift().toLowerCase(); 40 | args = args.join(" "); 41 | 42 | let response = null; 43 | 44 | switch (command) { 45 | case "help": 46 | response = help; 47 | break; 48 | 49 | case "a": 50 | case "anime": 51 | response = await media.search(args, "ANIME"); 52 | break; 53 | 54 | case "m": 55 | case "manga": 56 | response = await media.search(args, "MANGA"); 57 | break; 58 | 59 | case "c": 60 | case "character": 61 | response = await character.search(args); 62 | break; 63 | 64 | case "p": 65 | case "person": 66 | case "staff": 67 | response = await staff.search(args); 68 | break; 69 | 70 | case "s": 71 | case "studio": 72 | response = await studio.search(args); 73 | break; 74 | 75 | case "u": 76 | case "user": 77 | response = await user.search(args); 78 | break; 79 | } 80 | 81 | if (response === null) return; 82 | 83 | if (response.error) { 84 | message.channel.send(response.error.message); 85 | return; 86 | } 87 | 88 | let replyUrl; 89 | if (response.author && response.author.url) { 90 | replyUrl = message.channel.send(`<${response.author.url}>`); 91 | } 92 | 93 | const replyEmbed = message.channel.send({ 94 | embed: { 95 | ...response, 96 | color: 3447003 97 | } 98 | }); 99 | 100 | if (command !== "help") { 101 | deleteViaReaction( 102 | message, 103 | await replyEmbed, 104 | replyUrl ? await replyUrl : replyUrl, 105 | client 106 | ); 107 | } 108 | }); 109 | 110 | const help = { 111 | title: "Commands", 112 | description: ` 113 | Search anime: !a or !anime 114 | Search manga: !m or !manga 115 | Search character: !c or !character 116 | Search staff: !p or !person or !staff 117 | Search studio: !s or !studio 118 | Search user: !u or !user 119 | 120 | GitHub: https://github.com/AniList/AniList-Discord-Bot` 121 | }; 122 | 123 | client.login(process.env.TOKEN); 124 | -------------------------------------------------------------------------------- /src/media/index.js: -------------------------------------------------------------------------------- 1 | const api = require("../api"); 2 | const query = require("./query"); 3 | const discordMessage = require("../discordMessage"); 4 | 5 | const capitalize = str => 6 | str 7 | .split("_") 8 | .map( 9 | word => 10 | word.charAt(0).toUpperCase() + word.substring(1).toLowerCase() 11 | ) 12 | .join(" "); 13 | 14 | const search = async (searchArg, type) => { 15 | const response = await api(query, { 16 | search: searchArg, 17 | type 18 | }); 19 | 20 | if (response.error) { 21 | return response; 22 | } 23 | 24 | const data = response.Media; 25 | const { averageScore: score, status } = data; 26 | 27 | const scoreString = score != null ? `Score: ${score}%` : ""; 28 | const statusString = status != null ? `Status: ${capitalize(status)}` : ""; 29 | 30 | let footer = ""; 31 | // Use the en quad space after score to not get stripped by Discord 32 | if (score) footer += scoreString + "  "; 33 | if (status) footer += statusString; 34 | 35 | return discordMessage({ 36 | name: data.title.romaji, 37 | url: data.siteUrl, 38 | imageUrl: data.coverImage.large, 39 | description: data.description, 40 | footer: footer 41 | }); 42 | }; 43 | 44 | module.exports = { 45 | search 46 | }; 47 | -------------------------------------------------------------------------------- /src/media/query.js: -------------------------------------------------------------------------------- 1 | module.exports = ` 2 | query ($search: String, $type: MediaType) { 3 | Media(search: $search, type: $type, isAdult:false) { 4 | id 5 | siteUrl 6 | title { 7 | romaji 8 | } 9 | coverImage { 10 | large 11 | } 12 | status(version:2) 13 | description(asHtml: true) 14 | averageScore 15 | } 16 | } 17 | `; 18 | -------------------------------------------------------------------------------- /src/staff/index.js: -------------------------------------------------------------------------------- 1 | const api = require("../api"); 2 | const query = require("./query"); 3 | const discordMessage = require("../discordMessage"); 4 | 5 | const search = async searchArg => { 6 | const response = await api(query, { 7 | search: searchArg 8 | }); 9 | 10 | if (response.error) { 11 | return response; 12 | } 13 | 14 | const data = response.Staff; 15 | 16 | let name = data.name.first; 17 | if (data.name.last != null) { 18 | name += ` ${data.name.last}`; 19 | } 20 | 21 | return discordMessage({ 22 | name: name, 23 | url: data.siteUrl, 24 | imageUrl: data.image.large, 25 | description: data.description 26 | }); 27 | }; 28 | 29 | module.exports = { 30 | search 31 | }; 32 | -------------------------------------------------------------------------------- /src/staff/query.js: -------------------------------------------------------------------------------- 1 | module.exports = ` 2 | query ($search: String) { 3 | Staff(search: $search) { 4 | id 5 | siteUrl 6 | name { 7 | first 8 | last 9 | } 10 | image { 11 | large 12 | } 13 | description(asHtml: true) 14 | } 15 | } 16 | `; 17 | -------------------------------------------------------------------------------- /src/studio/index.js: -------------------------------------------------------------------------------- 1 | const api = require("../api"); 2 | const query = require("./query"); 3 | const discordMessage = require("../discordMessage"); 4 | 5 | const search = async searchArg => { 6 | const response = await api(query, { 7 | search: searchArg 8 | }); 9 | 10 | if (response.error) { 11 | return response; 12 | } 13 | 14 | const data = response.Studio; 15 | let anime = ""; 16 | data.media.nodes.map(media => { 17 | anime += ` 18 | ${media.title.romaji}
19 | `; 20 | }); 21 | 22 | return discordMessage({ 23 | title: "Popular Anime", 24 | name: data.name, 25 | url: data.siteUrl, 26 | description: anime 27 | }); 28 | }; 29 | 30 | module.exports = { 31 | search 32 | }; 33 | -------------------------------------------------------------------------------- /src/studio/query.js: -------------------------------------------------------------------------------- 1 | module.exports = ` 2 | query ($search: String) { 3 | Studio(search: $search) { 4 | id 5 | name 6 | siteUrl 7 | media (isMain: true, sort: POPULARITY_DESC, perPage: 5) { 8 | nodes { 9 | siteUrl 10 | title { 11 | romaji 12 | } 13 | } 14 | } 15 | } 16 | } 17 | `; 18 | -------------------------------------------------------------------------------- /src/user/index.js: -------------------------------------------------------------------------------- 1 | const api = require("../api"); 2 | const query = require("./query"); 3 | const discordMessage = require("../discordMessage"); 4 | const striptags = require("striptags"); 5 | 6 | const search = async searchArg => { 7 | const response = await api(query, { 8 | search: searchArg 9 | }); 10 | 11 | if (response.error) { 12 | return response; 13 | } 14 | 15 | const data = response.User; 16 | const watchedTime = data.statistics.anime.minutesWatched; 17 | const chaptersRead = data.statistics.manga.chaptersRead; 18 | 19 | const chaptersString = 20 | chaptersRead != 0 ? `Chapters read: ${chaptersRead} ` : ""; 21 | 22 | let daysWatched = ""; 23 | if (watchedTime != 0) { 24 | daysWatched = (watchedTime / (60 * 24)).toFixed(1); 25 | daysWatched = `Days watched: ${daysWatched}`; 26 | } 27 | 28 | let footer = ""; 29 | // Use the en quad space after score to not get stripped by Discord 30 | if (watchedTime) footer += daysWatched + "  "; 31 | if (chaptersRead) footer += chaptersString; 32 | 33 | return discordMessage({ 34 | name: data.name, 35 | url: data.siteUrl, 36 | imageUrl: data.avatar.large, 37 | description: striptags(data.about), 38 | footer: footer 39 | }); 40 | }; 41 | 42 | module.exports = { 43 | search 44 | }; 45 | -------------------------------------------------------------------------------- /src/user/query.js: -------------------------------------------------------------------------------- 1 | module.exports = ` 2 | query ($search: String) { 3 | User(name: $search) { 4 | id 5 | name 6 | siteUrl 7 | avatar { 8 | large 9 | } 10 | about (asHtml: true), 11 | statistics { 12 | anime { 13 | minutesWatched 14 | } 15 | manga { 16 | chaptersRead 17 | } 18 | } 19 | } 20 | } 21 | `; 22 | --------------------------------------------------------------------------------