├── .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 | 
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 |
--------------------------------------------------------------------------------