├── .eslintrc.js ├── .gitignore ├── .travis.yml ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── bin ├── expert-install.sh └── guided-install.sh ├── db └── .gitkeep ├── package-lock.json ├── package.json ├── setupBot.js ├── src ├── Bot.js ├── Installer.js ├── Log.js ├── Plugin.js ├── PluginManager.js ├── Util.js ├── helpers │ ├── Auth.js │ └── Scheduler.js └── plugins │ ├── 8ball.js │ ├── Antiflood.js │ ├── Auth.js │ ├── BoobsButts.js │ ├── Config.js │ ├── Echo.js │ ├── Fap.js │ ├── Forward.js │ ├── Google.js │ ├── GoogleImages.js │ ├── Ignore.js │ ├── Imgur.js │ ├── Karma.js │ ├── Kick.js │ ├── Markov.js │ ├── Math.js │ ├── MediaSet.js │ ├── ModTools.js │ ├── Ping.js │ ├── Quote.js │ ├── RSS.js │ ├── Reddit.js │ ├── RegexSet.js │ ├── Remind.js │ ├── Reverse.js │ ├── Roll.js │ ├── Rule34.js │ ├── Set.js │ ├── SetPicture.js │ ├── SetTitle.js │ ├── Spoiler.js │ ├── Text.js │ ├── UrbanDictionary.js │ ├── UserInfo.js │ ├── UserStats.js │ ├── Version.js │ ├── Vote.js │ ├── Welcome.js │ ├── Wikipedia.js │ ├── Wordgame.js │ ├── YouTube.js │ └── xkcd.js └── tests ├── cli.js ├── integration ├── helpers │ └── TelegramBot.js ├── indexTest.js └── sample-config.json └── unit ├── fixtures └── QuoteMessage.json └── plugins └── QuoteTest.js /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "parserOptions": { 3 | "ecmaVersion": 8 4 | }, 5 | "extends": "eslint:recommended", 6 | "env": { 7 | "node": true, 8 | "es6": true 9 | }, 10 | "rules": { 11 | "getter-return": "error", 12 | "no-await-in-loop": "warn", 13 | "array-callback-return": "error", 14 | "class-methods-use-this": ["warn", {exceptMethods: ["onText", "onCommand"]}], 15 | "guard-for-in": "warn", 16 | "no-caller": "error", 17 | "no-else-return": "warn", 18 | "no-eval": "error", 19 | "no-extra-label": "error", 20 | "no-eq-null": "error", 21 | "no-implied-eval": "error", 22 | "no-invalid-this": "error", 23 | "no-iterator": "error", 24 | "no-labels": "warn", 25 | "no-lone-blocks": "error", 26 | "no-loop-func": "warn", 27 | "no-multi-spaces": "error", 28 | "no-octal-escape": "error", 29 | "no-proto": "error", 30 | "no-return-assign": "error", 31 | "no-return-await": "warn", 32 | "no-self-compare": "error", 33 | "no-sequences": "error", 34 | "no-throw-literal": "error", 35 | "no-unused-expressions": "error", 36 | "no-useless-call": "error", 37 | "no-useless-concat": "error", 38 | "no-void": "error", 39 | "no-with": "error", 40 | "require-await": "warn", 41 | "yoda": "error", 42 | "no-undef-init": "error", 43 | "no-use-before-define": "error", 44 | "no-new-require": "error", 45 | "no-path-concat": "error", 46 | "no-sync": "warn", 47 | "array-bracket-spacing": ["error", "never"], 48 | "comma-dangle": ["error", "never"], 49 | "no-multiple-empty-lines": "error", 50 | "indent": ["error", 4, { 51 | "SwitchCase": 1 52 | }], 53 | "arrow-parens": ["warn", "as-needed"], 54 | "no-useless-computed-key": "error", 55 | "no-useless-constructor": "error", 56 | "no-var": "error", 57 | "object-shorthand": "warn", 58 | "prefer-const": "warn", 59 | "prefer-rest-params": "warn", 60 | "no-useless-escape": 0 // Disabled waiting for issue https://github.com/eslint/eslint/issues/9759 61 | } 62 | }; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | db/ 4 | config.json -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 'node' # Latest stable version 4 | # - '6' # Latest "current" version 5 | # ^ Disabled because tests now require async/await 6 | cache: 7 | directories: 8 | - node_modules 9 | script: 10 | - npm run lint 11 | - npm test -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | **Refer to the [developer's guide](http://telegram-bot-node.github.io/Nikoro/dist/developer.html).** 4 | 5 | ## Coding standards 6 | 7 | Please make sure `eslint src/` runs without errors before submitting your code. 8 | 9 | We use the [Google coding standards](https://google.github.io/styleguide/javascriptguide.xml), with a few modifications. Here are the most important ones: 10 | 11 | * Curly braces after `if` aren't mandatory; 12 | * **4 spaces indentation**; 13 | * JSDoc comments aren't required nor suggested. 14 | 15 | When possible, use promises rather than callbacks, ES6 objects (eg. `let` and `const` rather than `var`), and early returns. Prefer clarity over conciseness. 16 | 17 | ## Git commit messages 18 | 19 | * Separate subject from body with a blank line 20 | * Limit the subject line to 50 characters 21 | * Capitalize the subject line 22 | * Do not end the subject line with a period 23 | * Use the imperative mood in the subject line 24 | * Wrap the body at 72 characters 25 | * Use the body to explain what and why vs. how 26 | 27 | (taken from http://chris.beams.io/posts/git-commit/#seven-rules) 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright © 2015 Cristian Baldi, bld.cris.96@gmail.com 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nikoro 2 | 3 | [![Build Status](https://travis-ci.org/Telegram-Bot-Node/Nikoro.svg?branch=es6)](https://travis-ci.org/Telegram-Bot-Node/Nikoro) 4 | 5 | An all-in-one, plugin-based, Telegram bot written in Node.js. 6 | 7 | See it in action on [@nikorobot](https://telegram.me/nikorobot)! [TODO] 8 | 9 | Based on [node-telegram-bot-api](https://github.com/yagop/node-telegram-bot-api). 10 | 11 | ## Quick install 12 | 13 | ``` 14 | git clone https://github.com/Telegram-Bot-Node/Nikoro 15 | cd Nikoro 16 | npm run setup:guided 17 | ``` 18 | 19 | After running `npm run bot`, you can simply add the bot to a group chat and it will work. You can also message it directly. 20 | 21 | To see a list of available plugins, use the command `/help`; if you need help about a specific plugin, do `/help PluginName`. 22 | 23 | See the [usage guide](http://telegram-bot-node.github.io/Nikoro/dist/user.html) for more information. 24 | 25 | ## Table of Contents 26 | 27 | - [Features](#features) 28 | - [Contributing](#contributing) 29 | - [Contributors](#contributors) 30 | - [Need help?](#need-help) 31 | 32 | ## Features 33 | 34 | * **Plugin-based**: run many bots in one, have a plugin for everything. 35 | * Support for inline commands, buttons, stickers, etc. 36 | * Completely **customizable**: see something you don't like? Just change it! 37 | * Written in **Node.js**: one of the most powerful and easiest programming languages available. 38 | * Easy to install: just a few simple commands and your bot is up and running! 39 | * Easy-to-write plugins: with many helper functions write powerful plugins in a few minutes. Connect to a database, download files, parse commands without writing any code: everything is already here for you. 40 | * Open source :D 41 | 42 | ## Contributing 43 | 44 | Did you make a plugin you want to share with everyone? Did you improve the code in any way? Did you fix an issue? 45 | 46 | Submit a pull request! This project will only grow if *YOU* help! 47 | 48 | Basic guidelines for contributing are outlined in `CONTRIBUTING.md`. We also have a complete [developer's guide](http://telegram-bot-node.github.io/Nikoro/developer.html)! Finally, you can look at existing plugins (`src/plugins/Ping.js` can be a simple starting point) for a quick start. 49 | 50 | ## Contributors 51 | In alphabetical order 52 | 53 | * [CapacitorSet](https://github.com/CapacitorSet/) 54 | * Developed some plugins 55 | * Worked on a bit of everything, mostly internals 56 | * Messed up the code some more 57 | 58 | * [Cristian Baldi](https://github.com/crisbal/) 59 | * Crated the project 60 | * Developed a bunch of plugins 61 | * Added redis support 62 | * Improved the plugin system 63 | * Messed up the code 64 | 65 | * [Phill Farrugia](https://github.com/phillfarrugia/) 66 | * Improved & documented the code 67 | * Added Heroku Integration 68 | * Created Unit Tests 69 | * Added automatic testing via Travis CI 70 | 71 | ## Need help? 72 | [Send me a mail](bld.cris.96@gmail.com) or [create an issue](https://github.com/Telegram-Bot-Node/Nikoro/issues/new), I will answer ASAP. :+1: 73 | -------------------------------------------------------------------------------- /bin/expert-install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "Checking for Node.js..." 4 | if ! command -v node > /dev/null; then 5 | echo "\"node\" not found." 6 | echo "Please install Node.js: http://nodejs.org/" 7 | exit 1 8 | fi 9 | echo "Node.js found." 10 | 11 | echo "Checking for npm..." 12 | if ! command -v npm > /dev/null ; then 13 | echo "Please install npm." 14 | exit 1 15 | fi 16 | echo "npm found." 17 | 18 | echo "Installing dependencies..." 19 | npm install 20 | if [ $? -ne 0 ] ; then 21 | exit 1 22 | fi 23 | echo "Dependencies installed." 24 | 25 | npm run configure:expert 26 | -------------------------------------------------------------------------------- /bin/guided-install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "Checking for Node.js..." 4 | if ! command -v node > /dev/null; then 5 | echo "\"node\" not found." 6 | echo "Please install Node.js: http://nodejs.org/" 7 | exit 1 8 | fi 9 | echo "Node.js found." 10 | 11 | echo "Checking for npm..." 12 | if ! command -v npm > /dev/null ; then 13 | echo "Please install npm." 14 | exit 1 15 | fi 16 | echo "npm found." 17 | 18 | echo "Installing dependencies..." 19 | npm install --production 20 | if [ $? -ne 0 ] ; then 21 | exit 1 22 | fi 23 | echo "Dependencies installed." 24 | 25 | npm run configure:guided 26 | -------------------------------------------------------------------------------- /db/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Telegram-Bot-Node/Nikoro/461ab5983844aae6ad1b49ea3f11fa80023fcecb/db/.gitkeep -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "telegram-bot-node", 3 | "version": "1.0.0", 4 | "description": "An all-in-one, plugin-based, Telegram Bot.", 5 | "dependencies": { 6 | "cron": "^1.3.0", 7 | "google-search": "0.0.5", 8 | "inquirer": "^1.2.2", 9 | "leaky-bucket": "^2.1.1", 10 | "node-telegram-bot-api": "^0.29.0", 11 | "request": "^2.67.0", 12 | "request-promise-native": "^1.0.5", 13 | "rss-parser": "^3.1.1", 14 | "safe-regex": "^1.1.0", 15 | "walk-sync": "^0.3.1", 16 | "wikijs": "^4.5.0", 17 | "winston": "^2.2.0" 18 | }, 19 | "devDependencies": { 20 | "eslint": "^4.13.1", 21 | "mocha": "^4.0.1" 22 | }, 23 | "scripts": { 24 | "setup:guided": "bash bin/guided-install.sh", 25 | "configure:guided": "node setupBot.js", 26 | "setup:expert": "bash bin/expert-install.sh", 27 | "configure:expert": "node src/Installer.js", 28 | "bot": "node src/Bot.js", 29 | "lint": "eslint src/", 30 | "test": "mocha tests/ --name '*Test.js' --recursive --exit", 31 | "test:watch": "npm run test -- --watch", 32 | "install-hooks": "cp hooks/* .git/hooks/" 33 | }, 34 | "engines": { 35 | "node": ">=6.0.0" 36 | }, 37 | "author": "Cristian Baldi", 38 | "license": "MIT" 39 | } 40 | -------------------------------------------------------------------------------- /setupBot.js: -------------------------------------------------------------------------------- 1 | /* eslint no-console: 0 */ 2 | 3 | const fs = require("fs"); 4 | const inquirer = require("inquirer"); 5 | const Log = require("./src/Log"); 6 | const log = Log("Bot", null, "debug"); 7 | const path = require("path"); 8 | const TelegramBot = require("node-telegram-bot-api"); 9 | const walk = require("walk-sync"); 10 | 11 | // Version reporting, useful for bug reports 12 | let commit = ""; 13 | if (fs.existsSync(path.join(__dirname, "./.git"))) 14 | commit = fs.readFileSync(path.join(__dirname, "./.git/refs/heads/es6"), "utf8").substr(0, 7); 15 | log.info(`Nikoro version ${require('./package.json').version}` + (commit ? `, commit ${commit}` : "")); 16 | 17 | let token; 18 | let bot; 19 | let admin; 20 | let adminChatId; 21 | const enabledPlugins = new Set(); 22 | 23 | const configPath = `${__dirname}/config.json`; 24 | 25 | inquirer 26 | .prompt({ 27 | type: "confirm", 28 | name: "force", 29 | message: "A configuration file already exists. Would you like to overwrite it?", 30 | default: false, 31 | when: function() { 32 | try { 33 | JSON.parse(fs.readFileSync(configPath, "utf8")); 34 | return true; 35 | } catch (e) { 36 | return false; 37 | } 38 | } 39 | }) 40 | .then(({force = true}) => { 41 | if (!force) process.exit(0); 42 | return inquirer.prompt({ 43 | type: "input", 44 | name: "TELEGRAM_TOKEN", 45 | message: "What's the bot token? (You can get one from @BotFather.)", 46 | validate: token => /\d+:\w+/.test(token) ? true : "Please insert a valid token." 47 | }); 48 | }) 49 | .then(({TELEGRAM_TOKEN}) => { 50 | token = TELEGRAM_TOKEN; 51 | bot = new TelegramBot(TELEGRAM_TOKEN, {polling: true}); 52 | 53 | log.info("The bot is online!"); 54 | 55 | log.info("Send a message to your bot to get started."); 56 | 57 | return new Promise(resolve => bot.once("message", resolve)); 58 | }) 59 | .then(msg => { 60 | admin = msg.from; 61 | adminChatId = msg.chat.id; 62 | log.info("Done! Check your Telegram chat."); 63 | return bot.sendMessage(adminChatId, `Hello, ${admin.first_name}!`); 64 | }) 65 | .then(() => { 66 | let msg = "Now, we'll choose the plugins. Here is the list.\n\n"; 67 | const plugins = walk(`${__dirname}/src/plugins`) 68 | // Use only .js files. 69 | .filter(item => /\.js$/i.test(item)) 70 | .map(path => { 71 | // Remove the extension. 72 | path = path.replace(/\.js$/i, ""); 73 | let plugin; 74 | 75 | try { 76 | plugin = require(`./src/plugins/${path}`, false).plugin; 77 | } catch (e) { 78 | return { 79 | name: `${path}.js`, 80 | disabled: true, 81 | message: e.message 82 | }; 83 | } 84 | 85 | return { 86 | name: plugin.name, 87 | disabled: false, 88 | message: plugin.description, 89 | path 90 | }; 91 | }); 92 | msg += plugins 93 | .map(plugin => { 94 | if (plugin.disabled) 95 | return `_${plugin.name}_ - Disabled because: ${plugin.message}`; 96 | return `*${plugin.name}*: ${plugin.message}`; 97 | }) 98 | .join("\n"); 99 | const buttons = plugins 100 | .filter(p => !p.disabled) 101 | .map(plugin => ({ 102 | text: plugin.name, 103 | callback_data: plugin.path 104 | })); 105 | // We want to put the buttons in two columns. 106 | const rows = buttons.reduce((rows, button) => { 107 | const lastRow = rows[rows.length === 0 ? 0 : (rows.length - 1)]; 108 | // If there is space, add a new button 109 | if (lastRow.length < 2) 110 | lastRow.push(button); 111 | // Otherwise, push a new row 112 | else 113 | rows.push([button]); 114 | return rows; 115 | }, [[]]); 116 | return bot.sendMessage(adminChatId, msg, { 117 | parse_mode: "markdown", 118 | reply_markup: { 119 | inline_keyboard: rows 120 | } 121 | }); 122 | }) 123 | .then(() => bot.sendMessage(adminChatId, "Toggle plugins by clicking on them. When you're done, send /done to proceed.")) 124 | .then(() => { 125 | bot.on("callback_query", msg => { 126 | const plugin = msg.data; 127 | let text; 128 | if (enabledPlugins.has(plugin)) { 129 | text = `${plugin} disabled.`; 130 | enabledPlugins.delete(plugin); 131 | } else { 132 | text = `${plugin} enabled.`; 133 | enabledPlugins.add(plugin); 134 | } 135 | bot.answerCallbackQuery({ 136 | callback_query_id: msg.id, 137 | text 138 | }); 139 | }); 140 | return new Promise(resolve => bot.on("text", message => { 141 | if (message.text === "/done") 142 | resolve(); 143 | })); 144 | }) 145 | .then(() => { 146 | const config = { 147 | TELEGRAM_TOKEN: token, 148 | owners: [admin.id], 149 | activePlugins: Array.from(enabledPlugins) 150 | }; 151 | fs.writeFileSync(configPath, JSON.stringify(config, null, 4)); 152 | const message = "You configured the bot successfully! The setup procedure will now end."; 153 | log.info(message); 154 | log.info("You can now run 'npm run bot' to launch the bot."); 155 | return bot.sendMessage(adminChatId, message); 156 | }) 157 | .then(() => new Promise(resolve => setTimeout(resolve, 1000))) 158 | .then(() => process.exit(0)) 159 | .catch(err => { 160 | if (err) { 161 | log.error(err); 162 | log.error("A fatal error occurred. Terminating."); 163 | } 164 | process.exit(1); 165 | }); 166 | 167 | process.on('unhandledRejection', (reason, p) => { 168 | log.error("Unhandled rejection at Promise ", p, " with reason ", reason); 169 | }); 170 | -------------------------------------------------------------------------------- /src/Bot.js: -------------------------------------------------------------------------------- 1 | /* eslint no-console: 0 no-sync: 0 */ 2 | 3 | // Automatic cancellation in node-telegram-bot-api is deprecated, disable it 4 | process.env.NTBA_FIX_319 = 1; 5 | 6 | const fs = require("fs"); 7 | const path = require("path"); 8 | const TelegramBot = require(process.env.IS_TEST_ENVIRONMENT ? process.env.MOCK_NTBA : "node-telegram-bot-api"); 9 | const PluginManager = require("./PluginManager"); 10 | const Config = JSON.parse(fs.readFileSync("./config.json", "utf8")); 11 | const Logger = require("./Log"); 12 | const log = new Logger("Bot", Config); 13 | if (process.env.IS_TEST_ENVIRONMENT) 14 | log.warn("Running in test mode (mocking Telegram)"); 15 | const Auth = require("./helpers/Auth"); 16 | const auth = new Auth(Config, log); 17 | 18 | if (typeof Config.TELEGRAM_TOKEN !== "string" || Config.TELEGRAM_TOKEN === "") { 19 | log.error("You must provide a Telegram bot token in config.json. Try running \"npm run configure:guided\" or \"npm run configure:expert\"."); 20 | process.exit(1); 21 | } 22 | 23 | // Version reporting, useful for bug reports 24 | let commit = ""; 25 | if (fs.existsSync(path.join(__dirname, "../.git"))) { 26 | const branchRef = fs.readFileSync(path.join(__dirname, "../.git/HEAD"), "utf8").replace(/^ref: /, "").replace(/\n$/, ""); 27 | commit = fs.readFileSync(path.join(__dirname, "../.git", branchRef), "utf8").substr(0, 7); 28 | } 29 | log.info(`Nikoro version ${require("../package.json").version}` + (commit ? `, commit ${commit}` : "")); 30 | 31 | if (Config.globalAdmins) { 32 | log.warn("Config contains deprecated key 'globalAdmins', replacing with 'owners'"); 33 | Config.owners = Config.globalAdmins; 34 | delete Config.globalAdmins; 35 | fs.writeFileSync("./config.json", JSON.stringify(Config, null, 4)); 36 | } 37 | 38 | log.verbose("Creating a TelegramBot instance..."); 39 | const bot = new TelegramBot(Config.TELEGRAM_TOKEN, {polling: true}); 40 | log.info("Instance created."); 41 | 42 | log.verbose("Loading plugins..."); 43 | const pluginManager = new PluginManager(bot, Config, auth); 44 | pluginManager.loadPlugins(Config.activePlugins, false); 45 | pluginManager.startSynchronization(); 46 | log.info("Plugins loaded."); 47 | 48 | log.info("The bot is online!"); 49 | 50 | function handleShutdown(reason) { 51 | return err => { 52 | if (err && (err != "SIGINT")) log.error(err); 53 | log.warn("Shutting down, reason: " + reason); 54 | log.info("Stopping safely all the plugins..."); 55 | pluginManager.stopSynchronization(); 56 | pluginManager.stopPlugins().then(function() { 57 | log.info("All plugins stopped correctly."); 58 | process.exit(); 59 | }); 60 | }; 61 | } 62 | 63 | // If `CTRL+C` is pressed we stop the bot safely. 64 | process.on("SIGINT", handleShutdown("Terminated by user")); 65 | 66 | // Stop safely in case of `uncaughtException`. 67 | process.on("uncaughtException", handleShutdown("Uncaught exception")); 68 | 69 | process.on("unhandledRejection", (reason, p) => { 70 | log.error("Unhandled rejection at Promise ", p, " with reason ", reason); 71 | }); 72 | 73 | if (process.env.IS_TEST_ENVIRONMENT) 74 | module.exports = bot; // The test instance will reuse this to push messages -------------------------------------------------------------------------------- /src/Installer.js: -------------------------------------------------------------------------------- 1 | /* eslint no-sync: 0 */ 2 | const fs = require("fs"); 3 | const walk = require("walk-sync"); 4 | const inquirer = require("inquirer"); 5 | 6 | const descriptionsToPathsMap = {}; 7 | const pluginQuestions = []; 8 | 9 | const questions = [ 10 | { 11 | type: "input", 12 | name: "TELEGRAM_TOKEN", 13 | message: "What's the bot token?", 14 | validate: token => /\d+:\w+/.test(token) ? true : "Please insert a valid token. You can get one from @BotFather." 15 | }, 16 | { 17 | type: "checkbox", 18 | name: "activePlugins", 19 | message: "What plugins would you like to enable?", 20 | // Find every file in the plugins folder. 21 | choices: walk(`${__dirname}/plugins`) 22 | // Use only .js files. 23 | .filter(item => /\.js$/i.test(item)) 24 | .map(path => { 25 | // Remove the extension. 26 | path = path.replace(/\.js$/i, ""); 27 | let plugin; 28 | 29 | try { 30 | plugin = require(`${__dirname}/plugins/${path}`, false).plugin; 31 | } catch (e) { 32 | let message = e.message; 33 | message = message.replace(/^Cannot find module '([^']+)'$/, "Must install \"$1\" first"); 34 | return { 35 | name: ` ${path}.js`, 36 | disabled: message 37 | }; 38 | } 39 | 40 | const string = " " + plugin.name + (plugin.description ? ` - ${plugin.description}` : ""); 41 | descriptionsToPathsMap[string] = path; 42 | 43 | return {name: string}; 44 | }) 45 | }, 46 | { 47 | type: "input", 48 | name: "owners", 49 | message: "Enter the list of owners as an array of user IDs (eg. [1111, 1234])", 50 | filter: JSON.parse, 51 | validate: array => { 52 | try { 53 | if (array.every(ID => /\d+/.test(ID))) 54 | return true; 55 | return "Please insert an array of user IDs."; 56 | } catch (e) { 57 | return "Please insert an array of user IDs."; 58 | } 59 | } 60 | }, 61 | ...pluginQuestions, 62 | { 63 | type: "list", 64 | name: "loggingLevel", 65 | message: "What logging level is to be used?", 66 | choices: [ 67 | "error", 68 | "warn", 69 | "info", 70 | "verbose", 71 | "debug" 72 | ], 73 | default: "info" 74 | } 75 | ]; 76 | 77 | const configPath = `${__dirname}/../config.json`; 78 | 79 | inquirer.prompt({ 80 | type: "confirm", 81 | name: "force", 82 | message: "A configuration file already exists. Would you like to overwrite it?", 83 | default: false, 84 | when() { 85 | try { 86 | JSON.parse(fs.readFileSync(configPath, "utf8")); 87 | return true; 88 | } catch (e) { 89 | return false; 90 | } 91 | } 92 | }).then(({force = true}) => { 93 | if (!force) process.exit(0); 94 | return inquirer.prompt(questions); 95 | }).then(answers => { 96 | answers.activePlugins = answers.activePlugins.map(description => descriptionsToPathsMap[description]); 97 | fs.writeFileSync(configPath, JSON.stringify(answers, null, 4)); 98 | process.exit(0); 99 | }); -------------------------------------------------------------------------------- /src/Log.js: -------------------------------------------------------------------------------- 1 | const winston = require("winston"); 2 | 3 | module.exports = function Logger(loggername, config, level) { 4 | if (!level && config) level = config.loggingLevel; 5 | if (!level) level = "info"; 6 | if (loggername in winston.loggers) 7 | return winston.loggers.get(loggername); 8 | 9 | return winston.loggers.add(loggername, { 10 | console: { 11 | level, 12 | colorize: true, 13 | label: loggername 14 | } 15 | }); 16 | }; 17 | -------------------------------------------------------------------------------- /src/Plugin.js: -------------------------------------------------------------------------------- 1 | const Logger = require("./Log"); 2 | 3 | module.exports = class Plugin { 4 | static get plugin() { 5 | return { 6 | name: "Plugin", 7 | description: "Base Plugin", 8 | help: "There is no need to ask for help", 9 | 10 | isHidden: true 11 | }; 12 | } 13 | 14 | static get handlerNames() { 15 | // This is the list of supported events, mapped to their handlers. 16 | return { 17 | message: "onMessage", 18 | 19 | _command: "onCommand", 20 | _inline_command: "onInlineCommand", 21 | 22 | audio: "onAudio", 23 | callback_query: "onCallbackQuery", 24 | contact: "onContact", 25 | document: "onDocument", 26 | inline_query: "onInline", 27 | left_chat_member: "onLeftChatMember", 28 | location: "onLocation", 29 | new_chat_members: "onNewChatMembers", 30 | photo: "onPhoto", 31 | sticker: "onSticker", 32 | text: "onText", 33 | video: "onVideo", 34 | video_note: "onVideoNote", 35 | voice: "onVoice" 36 | }; 37 | } 38 | 39 | get plugin() { 40 | return this.constructor.plugin; 41 | } 42 | 43 | constructor({db, blacklist, config /* , bot, auth */}) { 44 | if (new.target === Plugin) { 45 | throw new TypeError("Cannot construct Plugin instances directly!"); 46 | } 47 | 48 | this.log = new Logger(this.plugin.name, config); 49 | 50 | this.db = db; 51 | this.blacklist = new Set(blacklist); // Chats where the plugin is disabled 52 | } 53 | 54 | smartReply(ret, message) { 55 | if (typeof ret === "string" || typeof ret === "number") { 56 | this.sendMessage(message.chat.id, ret); 57 | return; 58 | } 59 | if (typeof ret === "undefined") 60 | return; 61 | switch (ret.type) { 62 | case "text": 63 | return this.sendMessage(message.chat.id, ret.text, ret.options); 64 | 65 | case "audio": 66 | return this.sendAudio(message.chat.id, ret.audio, ret.options); 67 | 68 | case "document": 69 | return this.sendDocument(message.chat.id, ret.document, ret.options); 70 | 71 | case "photo": 72 | return this.sendPhoto(message.chat.id, ret.photo, ret.options); 73 | 74 | case "sticker": 75 | return this.sendSticker(message.chat.id, ret.sticker, ret.options); 76 | 77 | case "video": 78 | return this.sendVideo(message.chat.id, ret.video, ret.options); 79 | 80 | case "voice": 81 | return this.sendVoice(message.chat.id, ret.voice, ret.options); 82 | 83 | case "status": case "chatAction": 84 | return this.sendChatAction(message.chat.id, ret.status, ret.options); 85 | 86 | default: 87 | this.log.error(`Unrecognized reply type ${ret.type}`); 88 | return Promise.reject(new Error(`Unrecognized reply type ${ret.type}`)); 89 | } 90 | } 91 | 92 | stop() { 93 | } 94 | }; 95 | -------------------------------------------------------------------------------- /src/PluginManager.js: -------------------------------------------------------------------------------- 1 | /* eslint no-sync: 0 */ 2 | const fs = require("fs"); 3 | const path = require("path"); 4 | const Logger = require("./Log"); 5 | const Plugin = require("./Plugin"); 6 | 7 | // A small utility functor to find a plugin with a given name 8 | const nameMatches = targetName => pl => pl.plugin.name.toLowerCase() === targetName.toLowerCase(); 9 | 10 | const SYNC_INTERVAL = 5000; 11 | 12 | function messageIsCommand(message) { 13 | if (!message.entities) return; 14 | const entity = message.entities[0]; 15 | return entity.offset === 0 && entity.type === "bot_command"; 16 | } 17 | 18 | // Note: we only parse commands at the start of the message, 19 | // therefore we suppose entity.offset = 0 20 | function parseCommand(message) { 21 | const entity = message.entities[0]; 22 | 23 | const rawCommand = message.text.substring(1, entity.length); 24 | let command; 25 | if (rawCommand.search("@") === -1) 26 | command = rawCommand; 27 | else 28 | command = rawCommand.substring(0, rawCommand.search("@")); 29 | 30 | let args = []; 31 | if (message.text.length > entity.length) { 32 | args = message.text.slice(entity.length + 1).split(" "); 33 | } 34 | 35 | return {args, command}; 36 | } 37 | 38 | module.exports = class PluginManager { 39 | constructor(bot, config, auth) { 40 | this.bot = bot; 41 | this.log = new Logger("PluginManager", config); 42 | this.auth = auth; 43 | this.plugins = []; 44 | 45 | this.config = config; 46 | 47 | const events = Object.keys(Plugin.handlerNames) 48 | // We handle the message event by ourselves. 49 | .filter(prop => prop !== "message") 50 | // Events beginning with an underscore (eg. _command) are internal. 51 | .filter(prop => prop[0] !== "_"); 52 | 53 | // Registers a handler for every Telegram event. 54 | // It runs the message through the proxy and forwards it to the plugin manager. 55 | for (const eventName of events) { 56 | bot.on( 57 | eventName, 58 | async message => { 59 | // const messageID = message.message_id + '@' + message.chat.id; 60 | // console.time(messageID); 61 | this.parseHardcoded(message); 62 | try { 63 | await Promise.all(this.plugins 64 | .filter(plugin => plugin.plugin.isProxy) 65 | .map(plugin => plugin.proxy(eventName, message)) 66 | ); 67 | } catch (err) { 68 | if (err) 69 | this.log.error("Message rejected with error", err); 70 | } 71 | try { 72 | await this.emit("message", message); 73 | await this.emit(eventName, message); 74 | this.log.debug("Message chain completed."); 75 | } catch (e) { 76 | if (!e) { 77 | this.log.error("Message chain failed with no error message."); 78 | this.bot.sendMessage(message.chat.id, "An error occurred."); 79 | return; 80 | } 81 | this.log.error(e); 82 | this.bot.sendMessage(message.chat.id, e.stack.split("\n").slice(0, 2).join("\n")); 83 | } 84 | // console.timeEnd(messageID); 85 | } 86 | ); 87 | } 88 | } 89 | 90 | parseHardcoded(message) { 91 | // Hardcoded commands 92 | if (!messageIsCommand(message)) return; 93 | const {command, args: [pluginName, targetChat]} = parseCommand(message); 94 | // Skip everything if we're not interested in this command 95 | if (command !== "help" 96 | && command !== "start" 97 | && command !== "plugins" 98 | && command !== "enable" 99 | && command !== "disable") return; 100 | 101 | const response = this.processHardcoded(command, pluginName, targetChat, message); 102 | this.bot.sendMessage(message.chat.id, response, { 103 | parse_mode: "markdown", 104 | disable_web_page_preview: true 105 | }); 106 | } 107 | 108 | processHardcoded(command, pluginName, targetChat, message) { 109 | if (command === "start") { 110 | let text = `*Nikoro* v${require("../package.json").version} 111 | 112 | Nikoro is a plugin-based Telegram bot. To get started, use /help to find out how to use the currently active plugins.`; 113 | if (this.auth.isOwner(message.from.id)) 114 | text += `\n\nYou can also use /plugins for a list of available plugins, or browse the [user guide](https://telegram-bot-node.github.io/Nikoro/user.html) for more information on this bot's features.`; 115 | return text; 116 | } 117 | if (command === "help") { 118 | const availablePlugins = this.plugins 119 | .map(pl => pl.plugin) 120 | .filter(pl => !pl.isHidden); 121 | 122 | if (!pluginName) 123 | return "The following plugins are enabled:\n\n" + availablePlugins 124 | .map(pl => `*${pl.name}*`) 125 | .join("\n") + "\n\nFor help about a specific plugin, use /help PluginName."; 126 | 127 | const plugin = availablePlugins 128 | .find(pl => pl.name.toLowerCase() === pluginName.toLowerCase()); 129 | 130 | if (!plugin) 131 | return "No such plugin."; 132 | return `*${plugin.name}* - ${plugin.description}\n\n${plugin.help}`; 133 | } 134 | 135 | if (command === "plugins") { 136 | const pluginPath = path.join(__dirname, "plugins"); 137 | const files = fs.readdirSync(pluginPath).map(filename => filename.replace(/\.js$/, "")); 138 | const plugins = files.map(filename => { 139 | try { 140 | const plugin = require(path.join(pluginPath, filename), false).plugin; 141 | return { 142 | name: filename, 143 | description: plugin.description 144 | }; 145 | } catch (e) { 146 | let message = e.message; 147 | message = message.replace(/^Cannot find module '([^']+)'$/, "Must install `$1` first"); 148 | return { 149 | name: `${filename}`, 150 | disabled: message 151 | }; 152 | } 153 | }); 154 | // Does the list of plugins contain the given name? 155 | const isEnabled = pl => this.plugins.some(nameMatches(pl.name)) 156 | const enabled = plugins 157 | .filter(pl => isEnabled(pl)) 158 | .map(pl => `*${pl.name}*: ${pl.description}`) 159 | .join("\n"); 160 | const available = plugins 161 | .filter(pl => !isEnabled(pl)) 162 | .map(pl => `*${pl.name}*` + (pl.disabled ? ` (${pl.disabled})` : `: ${pl.description}`)) 163 | .join("\n"); 164 | return "Enabled:\n" + enabled + "\n\nAvailable:\n" + available + "\n\nUse \"/enable PluginName\" to load a plugin."; 165 | } 166 | 167 | if (!this.auth.isOwner(message.from.id, message.chat.id)) 168 | return "Insufficient privileges (owner required)."; 169 | // Syntax: /("enable"|"disable") pluginName [targetChat|"chat"] 170 | // The string "chat" will enable the plugin in the current chat. 171 | if (targetChat === "chat") targetChat = message.chat.id; 172 | targetChat = Number(targetChat); 173 | // Checks if it is already in this.plugins 174 | const isGloballyEnabled = this.plugins.some(nameMatches(pluginName)); 175 | switch (command) { 176 | case "enable": 177 | if (targetChat) { 178 | try { 179 | this.loadAndAdd(pluginName); 180 | const plugin = this.plugins.find(nameMatches(pluginName)); 181 | plugin.blacklist.delete(targetChat); 182 | return `Plugin enabled successfully for chat ${targetChat}.`; 183 | } catch (e) { 184 | this.log.warn(e); 185 | if (e.message === "No such file.") 186 | return "No such plugin.\n\nIf you can't find the plugin you want, try running /plugins."; 187 | return "Couldn't load plugin: " + e.message; 188 | } 189 | } 190 | 191 | if (isGloballyEnabled) 192 | return "Plugin already enabled."; 193 | 194 | this.log.info(`Enabling ${pluginName} from message interface`); 195 | try { 196 | this.loadAndAdd(pluginName); 197 | return "Plugin enabled successfully."; 198 | } catch (e) { 199 | this.log.warn(e); 200 | if (e.message === "No such file.") 201 | return "No such plugin.\n\nIf you can't find the plugin you want, try running /plugins."; 202 | if (!/^Cannot find module/.test(e.message)) 203 | return "Couldn't load plugin, check console for errors."; 204 | return e.message.replace(/Cannot find module '([^']+)'/, "The plugin has a missing dependency: `$1`"); 205 | } 206 | 207 | case "disable": 208 | if (targetChat) { 209 | if (!isGloballyEnabled) 210 | return "Plugin isn't enabled."; 211 | const plugin = this.plugins.find(nameMatches(pluginName)); 212 | plugin.blacklist.add(targetChat); 213 | return `Plugin disabled successfully for chat ${targetChat}.`; 214 | } 215 | if (isGloballyEnabled) { 216 | const outcome = this.removePlugin(pluginName); 217 | return outcome ? "Plugin disabled successfully." : "An error occurred."; 218 | } 219 | return "Plugin already disabled."; 220 | } 221 | } 222 | 223 | // Instantiates the plugin. 224 | // Case-insensitive. 225 | // Returns the plugin itself. 226 | loadPlugin(_pluginName) { 227 | // Find a matching filename, case-insensitively 228 | const files = fs.readdirSync(path.join(__dirname, "plugins")).map(filename => filename.replace(/\.js$/, "")); 229 | const pluginName = files.find(filename => filename.toLowerCase() === _pluginName.toLowerCase()); 230 | if (!pluginName) 231 | throw new Error("No such file."); 232 | 233 | const pluginPath = path.join(__dirname, "plugins", pluginName); 234 | /* Invalidates the require() cache. 235 | * This allows for "hot fixes" to plugins: just /disable it, make the 236 | * required changes, and /enable it again. 237 | * If the cache wasn't invalidated, the plugin would be loaded from 238 | * cache rather than from disk, meaning that your changes wouldn't apply. 239 | * Method: https://stackoverflow.com/a/16060619 240 | */ 241 | delete require.cache[require.resolve(pluginPath)]; 242 | const ThisPlugin = require(pluginPath); 243 | 244 | this.log.debug(`Required ${pluginName}`); 245 | 246 | // Load the blacklist and database from disk 247 | const databasePath = PluginManager.getDatabasePath(pluginName); 248 | let db = {}; 249 | let blacklist = []; 250 | 251 | if (fs.existsSync(databasePath)) { 252 | const data = JSON.parse(fs.readFileSync(databasePath, "utf8")); 253 | db = data.db; 254 | blacklist = data.blacklist; 255 | } 256 | 257 | const loadedPlugin = new ThisPlugin({ 258 | db, 259 | blacklist, 260 | bot: this.bot, 261 | config: this.config, 262 | auth: this.auth 263 | }); 264 | 265 | // Bind all the methods from the bot API 266 | for (const method of Object.getOwnPropertyNames(Object.getPrototypeOf(this.bot))) { 267 | if (typeof this.bot[method] !== "function") continue; 268 | if (method === "constructor" || method === "on" || method === "onText") continue; 269 | if (/^_/.test(method)) continue; // Do not expose internal methods 270 | this.log.debug(`Binding ${method}`); 271 | loadedPlugin[method] = this.bot[method].bind(this.bot); 272 | } 273 | 274 | this.log.debug(`Created ${pluginName}.`); 275 | 276 | return loadedPlugin; 277 | } 278 | 279 | // Adds the plugin to the list of active plugins 280 | addPlugin(loadedPlugin) { 281 | this.plugins.push(loadedPlugin); 282 | this.log.verbose(`Added ${loadedPlugin.plugin.name}.`); 283 | } 284 | 285 | // Returns true if the plugin was added successfully, false otherwise. 286 | loadAndAdd(pluginName, persist = true) { 287 | try { 288 | const plugin = this.loadPlugin(pluginName); 289 | this.log.debug(pluginName + " loaded correctly."); 290 | this.addPlugin(plugin); 291 | if (persist) { 292 | this.config.activePlugins.push(pluginName); 293 | fs.writeFileSync("config.json", JSON.stringify(this.config, null, 4)); 294 | } 295 | } catch (e) { 296 | this.log.warn(`Failed to initialize plugin ${pluginName}.`); 297 | throw e; 298 | } 299 | } 300 | 301 | // Load and add every plugin in the list. 302 | loadPlugins(pluginNames, persist = true) { 303 | this.log.verbose(`Loading and adding ${pluginNames.length} plugins...`); 304 | Error.stackTraceLimit = 5; // Avoid printing useless data in stack traces 305 | 306 | const log = pluginNames.map(name => { 307 | try { 308 | this.loadAndAdd(name, persist); 309 | return true; 310 | } catch (e) { 311 | this.log.warn(e); 312 | return false; 313 | } 314 | }); 315 | if (log.some(result => result !== true)) { 316 | this.log.warn("Some plugins couldn't be loaded."); 317 | } 318 | 319 | Error.stackTraceLimit = 10; // Reset to default value 320 | } 321 | 322 | // Returns true if at least one plugin was removed 323 | removePlugin(pluginName, persist = true) { 324 | this.log.verbose(`Removing plugin ${pluginName}`); 325 | if (persist) { 326 | this.config.activePlugins = this.config.activePlugins.filter(name => !nameMatches(name)); 327 | fs.writeFileSync("config.json", JSON.stringify(this.config, null, 4)); 328 | } 329 | const prevPluginNum = this.plugins.length; 330 | const isCurrentPlugin = nameMatches(pluginName); 331 | this.plugins.filter(isCurrentPlugin).forEach(pl => pl.stop()); 332 | this.plugins = this.plugins.filter(pl => !isCurrentPlugin(pl)); 333 | const curPluginNum = this.plugins.length; 334 | return (prevPluginNum - curPluginNum) > 0; 335 | } 336 | 337 | stopPlugins() { 338 | return Promise.all(this.plugins.map(pl => pl.stop())); 339 | } 340 | 341 | static getDatabasePath(pluginName) { 342 | return path.join(__dirname, "..", "db", "plugin_" + pluginName + ".json"); 343 | } 344 | 345 | startSynchronization() { 346 | this.synchronizationInterval = setInterval(() => { 347 | this.log.debug("Starting synchronization"); 348 | this.auth.synchronize(); 349 | this.plugins.forEach(plugin => { 350 | fs.writeFile( 351 | PluginManager.getDatabasePath(plugin.plugin.name), 352 | JSON.stringify({ 353 | db: plugin.db, 354 | blacklist: Array.from(plugin.blacklist) 355 | }), 356 | err => { 357 | if (err) { 358 | this.log.error("Error synchronizing the database", err); 359 | } 360 | } 361 | ); 362 | }); 363 | }, SYNC_INTERVAL); 364 | } 365 | 366 | stopSynchronization() { 367 | if (this.synchronizationInterval) { 368 | clearInterval(this.synchronizationInterval); 369 | } 370 | } 371 | 372 | emit(event, message) { 373 | this.log.debug(`Triggered event ${event}`); 374 | 375 | let cmdPromise; 376 | if (event !== "message") { 377 | // Command emitter 378 | if (messageIsCommand(message)) { 379 | const {command, args} = parseCommand(message); 380 | cmdPromise = this._emit("_command", {message, command, args}); 381 | } else if (message.query !== undefined) { 382 | const parts = message.query.split(" "); 383 | const command = parts[0].toLowerCase(); 384 | const args = parts.length > 1 ? parts.slice(1) : []; 385 | cmdPromise = this._emit("_inline_command", {message, command, args}); 386 | } 387 | } 388 | 389 | const msgPromise = this._emit(event, {message}); 390 | return Promise.all([cmdPromise, msgPromise]); 391 | } 392 | 393 | _emit(event, data) { 394 | const handlerName = Plugin.handlerNames[event]; 395 | return Promise.all(this.plugins 396 | // If the plugin exposes a listener 397 | .filter(pl => handlerName in pl) 398 | // If the plugin is disabled in this chat 399 | .filter(pl => ("chat" in data.message) && !pl.blacklist.has(data.message.chat.id)) 400 | .map(pl => { 401 | try { 402 | const ret = pl[handlerName](data) 403 | const smartReply = pl.smartReply.bind(pl); 404 | if (ret && ret.then) 405 | return ret.then(x => smartReply(x, data.message)); 406 | return smartReply(ret, data.message); 407 | } catch (e) { 408 | return Promise.reject(e); 409 | } 410 | }) 411 | ); 412 | } 413 | }; 414 | -------------------------------------------------------------------------------- /src/Util.js: -------------------------------------------------------------------------------- 1 | /* eslint no-warning-comments:0 */ 2 | 3 | /* 4 | Util 5 | A module which collects a few useful functions 6 | which could be used when developing plugins 7 | */ 8 | 9 | const request = require("request"); 10 | const fs = require("fs"); 11 | const Logger = require("./Log"); 12 | const log = new Logger("Util", {loggingLevel: "info"}); // todo: figure out how to manage this 13 | 14 | /* Because of architectural reasons (i.e. being able to synchronize the db), this 15 | * is a thin wrapper around UserInfo. 16 | */ 17 | class NameResolver { 18 | // This function is called ONLY by the UserInfo plugin to pass its db 19 | setDb(db) { 20 | log.verbose("Name resolution database initialized."); 21 | this.db = db; 22 | } 23 | 24 | // For a given username, retrieve the user ID. 25 | getUserIDFromUsername(username) { 26 | username = username.replace(/^@/, ""); 27 | if (!this.db) { 28 | log.error("NameResolver: database is uninitialized"); 29 | throw new Error("Database uninitialized"); 30 | } 31 | return Object.keys(this.db).find(userID => this.db[userID] === username); 32 | } 33 | 34 | // For a given user ID, get the latest username. 35 | getUsernameFromUserID(userID) { 36 | if (!this.db) { 37 | log.error("NameResolver: database is uninitialized"); 38 | throw new Error("Database uninitialized"); 39 | } 40 | return this.db[userID]; 41 | } 42 | } 43 | 44 | const nameResolver = new NameResolver(); 45 | 46 | /* Makes it possible to run commands (eg. /ignore) both as "/ignore username", "/ignore ID", 47 | * or replying "/ignore" to a message sent by @username. 48 | */ 49 | function getTargetID(message, args, commandName = "command") { 50 | if (args.length > 0) { 51 | if (/^\d+$/.test(args[0])) { 52 | return Number(args[0]); 53 | } 54 | if (/^@[a-z0-9_]+$/i.test(args[0])) { // Attempt to resolve username 55 | try { 56 | const target = Number(nameResolver.getUserIDFromUsername(args[0])); 57 | if (!target) 58 | return "I've never seen that username."; 59 | return target; 60 | } catch (e) { 61 | return "Couldn't resolve username. Did you /enable UserInfo?"; 62 | } 63 | } 64 | return "Syntax: `/" + commandName + " `"; 65 | } 66 | if (message.reply_to_message) { 67 | if (message.reply_to_message.new_chat_participant) 68 | return message.reply_to_message.new_chat_participant.id; 69 | if (message.reply_to_message.left_chat_participant) 70 | return message.reply_to_message.left_chat_participant.id; 71 | return message.reply_to_message.from.id; 72 | } 73 | return "Syntax: `/" + commandName + " `"; 74 | } 75 | 76 | function escapeRegExp(str) { 77 | log.debug(`Escaping RegExp: ${str}`); 78 | return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&"); 79 | } 80 | 81 | // http://stackoverflow.com/a/2117523 82 | function makeUUID() { 83 | return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace( 84 | /[xy]/g, 85 | c => { 86 | const r = Math.random() * 16 | 0; 87 | const v = c === "x" ? r : r & 0x3 | 0x8; 88 | return v.toString(16); 89 | } 90 | ); 91 | } 92 | 93 | // `callback` receives the temporary path (eg. /tmp/notavirus.exe). 94 | function downloadAndSaveTempResource(url, extension, callback) { 95 | log.warn("Using deprecated function Util.downloadAndSaveTempResource; can you use this.sendPhoto directly?"); 96 | log.info(`Downloading and saving resource from ${url}`); 97 | 98 | const fn = `/tmp/${makeUUID()}.${extension}`; 99 | 100 | request({ 101 | url, 102 | headers: { 103 | "User-Agent": "stagefright/1.2 (Linux;Android 5.0)" 104 | } 105 | }) 106 | .pipe(fs.createWriteStream(fn)) 107 | .on("close", () => callback(fn)); 108 | } 109 | 110 | function buildPrettyUserName(user) { 111 | let name = ""; 112 | 113 | if (user.first_name) name += user.first_name + " "; 114 | 115 | if (user.last_name) name += user.last_name + " "; 116 | 117 | if (user.username) name += `@${user.username} `; 118 | 119 | if (user.id) name += `[${user.id}] `; 120 | 121 | return name.trim(); 122 | } 123 | 124 | function buildPrettyChatName(chat) { 125 | let name = ""; 126 | 127 | if (chat.title) name += chat.title + " "; 128 | 129 | if (chat.username) name += `@${chat.username} `; 130 | 131 | if (chat.first_name) name += chat.first_name + " "; 132 | 133 | if (chat.last_name) name += chat.last_name + " "; 134 | 135 | if (chat.type) name += `(${chat.type}) `; 136 | 137 | if (chat.id) name += `[${chat.id}] `; 138 | 139 | return name.trim(); 140 | } 141 | 142 | const escapeHTML = str => str.replace(/&/g, "&").replace(//g, ">"); 143 | 144 | function makeHTMLLink(title, url) { 145 | // We don't really care about other characters, but '"' may close the href string. 146 | if (url.includes("\"")) 147 | throw new Error("Invalid link"); 148 | return `${escapeHTML(title)}`; 149 | } 150 | 151 | module.exports = { 152 | nameResolver, 153 | getTargetID, 154 | escapeRegExp, 155 | escapeHTML, 156 | makeUUID, 157 | downloadAndSaveTempResource, 158 | buildPrettyUserName, 159 | buildPrettyChatName, 160 | makeHTMLLink 161 | }; -------------------------------------------------------------------------------- /src/helpers/Auth.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const assert = require("assert"); 3 | 4 | // https://stackoverflow.com/a/1584377 5 | Array.prototype.unique = function() { 6 | const a = this.concat(); 7 | for (let i = 0; i < a.length; ++i) { 8 | for (let j = i + 1; j < a.length; ++j) { 9 | if (a[i] === a[j]) 10 | a.splice(j--, 1); 11 | } 12 | } 13 | 14 | return a; 15 | }; 16 | 17 | module.exports = class Auth { 18 | constructor(config, logger) { 19 | try { 20 | const data = fs.readFileSync("./db/helper_Auth.json", "utf-8"); 21 | this.db = JSON.parse(data); 22 | this.db._owners = this.db._owners.concat(config.owners).unique(); 23 | } catch (err) { 24 | logger.warn(err); 25 | logger.warn("No auth db found, or an error occurred while reading it. Generating an empty one."); 26 | this.db = { 27 | auth: {}, 28 | _owners: config.owners 29 | }; 30 | } 31 | } 32 | 33 | synchronize() { 34 | fs.writeFile( 35 | "./db/helper_Auth.json", 36 | JSON.stringify(this.db), 37 | err => { 38 | if (err) throw err; 39 | } 40 | ); 41 | } 42 | 43 | isChatAdmin(_userId, _chatId) { 44 | const userId = Number(_userId); 45 | assert(isFinite(userId)); 46 | assert(!isNaN(userId)); 47 | const chatId = Number(_chatId); 48 | assert(isFinite(chatId)); 49 | assert(!isNaN(chatId)); 50 | 51 | return this.isOwner(userId) || this.getChatAdmins(chatId).includes(userId); 52 | } 53 | 54 | isOwner(_userId) { 55 | const userId = Number(_userId); 56 | assert(isFinite(userId)); 57 | assert(!isNaN(userId)); 58 | return this.getOwners().includes(userId); 59 | } 60 | 61 | addChatAdmin(_userId, _chatId) { 62 | const userId = Number(_userId); 63 | assert(isFinite(userId)); 64 | assert(!isNaN(userId)); 65 | const chatId = Number(_chatId); 66 | assert(isFinite(chatId)); 67 | assert(!isNaN(chatId)); 68 | 69 | if (this.isOwner(_userId)) return; // Do not add duplicates 70 | if (!this.db.auth[chatId]) 71 | this.db.auth[chatId] = {}; 72 | 73 | if (!this.db.auth[chatId].admins) 74 | this.db.auth[chatId].admins = []; 75 | 76 | this.db.auth[chatId].admins.push(userId); 77 | this.synchronize(); 78 | } 79 | 80 | removeChatAdmin(_userId, _chatId) { 81 | const userId = Number(_userId); 82 | assert(isFinite(userId)); 83 | assert(!isNaN(userId)); 84 | const chatId = Number(_chatId); 85 | assert(isFinite(chatId)); 86 | assert(!isNaN(chatId)); 87 | if (!this.db.auth[chatId]) 88 | this.db.auth[chatId] = {}; 89 | 90 | if (!this.db.auth[chatId].admins) 91 | this.db.auth[chatId].admins = []; 92 | 93 | this.db.auth[chatId].admins = this.db.auth[chatId].admins.filter(admin => admin !== userId); 94 | this.synchronize(); 95 | } 96 | 97 | addOwner(_userId) { 98 | const userId = Number(_userId); 99 | assert(isFinite(userId)); 100 | assert(!isNaN(userId)); 101 | if (!this.db._owners) 102 | this.db._owners = []; 103 | 104 | this.db._owners.push(userId); 105 | this.synchronize(); 106 | } 107 | 108 | getChatAdmins(_chatId) { 109 | const chatId = Number(_chatId); 110 | assert(isFinite(chatId)); 111 | assert(!isNaN(chatId)); 112 | if (this.db.auth[chatId] && this.db.auth[chatId].admins) { 113 | return this.db.auth[chatId].admins; 114 | } 115 | return []; 116 | } 117 | 118 | getOwners() { 119 | if (this.db._owners) { 120 | return this.db._owners; 121 | } 122 | return []; 123 | } 124 | }; -------------------------------------------------------------------------------- /src/helpers/Scheduler.js: -------------------------------------------------------------------------------- 1 | // This is intended to act as a singleton. Because it remains in the require() cache, the 2 | // class will be initialized only once. 3 | 4 | const EventEmitter = require("events"); 5 | const fs = require("fs"); 6 | const path = require("path"); 7 | const assert = require("assert"); 8 | const cron = require("cron"); 9 | 10 | const dbPath = path.join(__dirname, "../../db/helper_Scheduler.json"); 11 | 12 | /* Schedules events and emits them at a specified date. 13 | * Events persist across restarts/reboots. 14 | * When scheduling, take care to add some metadata that uniquely identifies 15 | * your plugin, so that you don't mistake unrelated events for your own! 16 | */ 17 | class Scheduler extends EventEmitter { 18 | constructor() { 19 | super(); 20 | this.events = []; 21 | this.crons = []; 22 | if (fs.existsSync(dbPath)) { 23 | const entries = JSON.parse(fs.readFileSync(dbPath)); 24 | entries 25 | .filter(it => "date" in it) 26 | .map(it => {it.date = new Date(it.date); return it}) 27 | .forEach(({name, metadata, date}) => this.scheduleOneoff(name, metadata, date)); 28 | entries 29 | .filter(it => "cronString" in it) 30 | .forEach(({name, metadata, cronString}) => this.scheduleCron(name, metadata, cronString)); 31 | } 32 | } 33 | scheduleOneoff(name, metadata, _date) { 34 | assert.deepEqual(typeof metadata, "object", "Metadata must be an object!"); 35 | assert((typeof _date === "object") || (typeof _date === "number"), "Must pass a valid date!"); 36 | const date = Number(_date); // Cast to Unix timestamp 37 | this.events.push({name, metadata, date}); 38 | const now = new Date(); 39 | if ((date - now) < Math.pow(2, 32)) // setTimeout can only schedule 2^32 ms in the future 40 | setTimeout(() => this.emit(name, metadata), date - now); 41 | this.synchronize(); 42 | } 43 | scheduleCron(name, metadata, cronString) { 44 | assert.deepEqual(typeof metadata, "object", "Metadata must be an object!"); 45 | assert.deepEqual(typeof cronString, "string", "Must pass a valid cron string!"); 46 | 47 | const job = new cron.CronJob(cronString, () => this.emit(name, metadata), undefined, true); 48 | this.crons.push({name, metadata, cronString, job}); 49 | this.synchronize(); 50 | } 51 | /* Cancels all events that match a specific function. 52 | * Take care to check for your plugin's metadata, so that you don't 53 | * accidentally delete other plugins' events! 54 | */ 55 | cancel(fn) { 56 | this.events = this.events.filter(it => !fn(it)); 57 | this.crons.filter(it => { 58 | if (!fn(it)) 59 | return true; 60 | it.job.stop(); 61 | return false; 62 | }); 63 | } 64 | 65 | // Private method 66 | synchronize() { 67 | // Remove old events 68 | const now = new Date(); 69 | this.events = this.events.filter(evt => evt.date >= now); 70 | const serializableCrons = this.crons.map(({name, metadata, cronString}) => ({name, metadata, cronString})); 71 | const serializableData = this.events.concat(serializableCrons); 72 | fs.writeFileSync(dbPath, JSON.stringify(serializableData)); 73 | } 74 | } 75 | 76 | module.exports = new Scheduler(); -------------------------------------------------------------------------------- /src/plugins/8ball.js: -------------------------------------------------------------------------------- 1 | const Plugin = require("../Plugin"); 2 | 3 | const choices = [ 4 | "It is certain", 5 | "It is decidedly so", 6 | "Without a doubt", 7 | "Yes definitely", 8 | "You may rely on it", 9 | "As I see it, yes", 10 | "Most likely", 11 | "Outlook good", 12 | "Yes", 13 | "Signs point to yes", 14 | "Reply hazy try again", 15 | "Ask again later", 16 | "Better not tell you now", 17 | "Cannot predict now", 18 | "Concentrate and ask again", 19 | "Don't count on it", 20 | "My reply is no", 21 | "My sources say no", 22 | "Outlook not so good", 23 | "Very doubtful" 24 | ]; 25 | 26 | module.exports = class The8Ball extends Plugin { 27 | static get plugin() { 28 | return { 29 | name: "The8Ball", 30 | description: "Magic 8-Ball!", 31 | help: "The Magic 8-Ball will give an answer to all your yes-no questions.\n/8ball question" 32 | }; 33 | } 34 | 35 | onCommand({command}) { 36 | if (command !== "8ball") return; 37 | return choices[Math.floor(Math.random() * choices.length)]; 38 | } 39 | }; -------------------------------------------------------------------------------- /src/plugins/Antiflood.js: -------------------------------------------------------------------------------- 1 | const Plugin = require("./../Plugin"); 2 | const Util = require("./../Util"); 3 | const LeakyBucket = require("leaky-bucket"); 4 | 5 | const RATE_TIMEOUT = 5000; 6 | 7 | module.exports = class Antiflood extends Plugin { 8 | constructor(obj) { 9 | super(obj); 10 | 11 | this.auth = obj.auth; 12 | 13 | this.lastMessages = {}; 14 | this.ignoreLimiters = {}; 15 | this.warnLimiters = {}; 16 | this.warnMessageLimiters = {}; 17 | this.kickLimiters = {}; 18 | } 19 | 20 | static get plugin() { 21 | return { 22 | name: "Antiflood", 23 | description: "Automatically ignore or kick spamming users", 24 | help: `Use /floodignore if you want the bot to ignore spamming users (i.e. not respond to their commands) after N messages in 5 seconds. 25 | Use /floodwarn to warn the user after N messages every 5 seconds. 26 | Use /floodkick to kick users automatically after N messages every 5 seconds. 27 | 28 | A value of 0 disables the feature (eg. "/floodkick 0" will disable automatic kicking).`, 29 | 30 | isProxy: true 31 | }; 32 | } 33 | 34 | processIgnore(message) { 35 | // Approve messages if no ignoreLimiter is present 36 | const ignoreLimiter = this.ignoreLimiters[message.chat.id]; 37 | if (!ignoreLimiter) return Promise.resolve(); 38 | // Approve messages if the ignoreLimiter says it's fine 39 | return ignoreLimiter.throttle().catch(() => { 40 | this.log.verbose("Rejecting message from " + Util.buildPrettyUserName(message.from)); 41 | // Re-reject, so the rejection bubbles up 42 | return Promise.reject(); 43 | }); 44 | } 45 | 46 | processWarn(message) { 47 | const warnLimiter = this.warnLimiters[message.chat.id]; 48 | if (!warnLimiter) return; 49 | warnLimiter.throttle().catch(() => { 50 | const warnMessageLimiter = this.warnMessageLimiters[message.chat.id]; 51 | // Do not help the user spam: apply another limiter to our messages. 52 | return warnMessageLimiter.throttle(); 53 | }).catch(() => { 54 | const username = Util.buildPrettyUserName(message.from); 55 | this.log.verbose(`Warning ${username} for flooding`); 56 | this.sendMessage(message.chat.id, `User ${username} is flooding!`); 57 | }); 58 | } 59 | 60 | processKick(message) { 61 | const kickLimiter = this.kickLimiters[message.chat.id]; 62 | if (!kickLimiter) return; 63 | kickLimiter.throttle().catch(() => { 64 | const username = Util.buildPrettyUserName(message.from); 65 | this.log.verbose(`Kicking ${username} for flooding`); 66 | this.sendMessage(message.chat.id, `Kicking ${username} for flooding.`); 67 | return this.kickChatMember(message.chat.id, message.from.id); 68 | }).catch(err => this.sendMessage(message.chat.id, "An error occurred while kicking the user: " + err)); 69 | } 70 | 71 | proxy(eventName, message) { 72 | // Don't even process inline messages 73 | if (!message.chat) return Promise.resolve(); 74 | 75 | // Skip old messages when "catching up" 76 | const oldThreshold = 30; // todo: move to config 77 | const now = new Date().getTime(); 78 | if ((Math.round(now / 1000) - message.date) > oldThreshold) 79 | return Promise.reject(); 80 | 81 | this.processWarn(message); 82 | this.processKick(message); 83 | return this.processIgnore(message); 84 | } 85 | 86 | onCommand({message, command, args}) { 87 | switch (command) { 88 | case "floodignore": { 89 | if (args.length !== 1) 90 | return "Syntax: /floodignore "; 91 | if (!this.auth.isChatAdmin(message.from.id, message.chat.id)) 92 | return "Insufficient privileges (chat admin required)."; 93 | const chatId = message.chat.id; 94 | const N = Number(args[0]); 95 | if (N === 0) { 96 | delete this.ignoreLimiters[chatId]; 97 | return "Antiflood ignore disabled for this chat."; 98 | } 99 | this.ignoreLimiters[chatId] = new LeakyBucket(N, RATE_TIMEOUT, 0); 100 | return `New rate limit: ${N} messages per 5 seconds`; 101 | } 102 | case "floodwarn": { 103 | if (args.length !== 1) 104 | return "Syntax: /floodwarn "; 105 | if (!this.auth.isChatAdmin(message.from.id, message.chat.id)) 106 | return "Insufficient privileges (chat admin required)."; 107 | const chatId = message.chat.id; 108 | const N = Number(args[0]); 109 | if (N === 0) { 110 | delete this.warnLimiters[chatId]; 111 | return "Antiflood warn disabled for this chat."; 112 | } 113 | this.warnLimiters[chatId] = new LeakyBucket(N, RATE_TIMEOUT, 0); 114 | // Issue at most one warning every five seconds, so as not to help the user spam 115 | this.warnMessageLimiters[chatId] = new LeakyBucket(1, RATE_TIMEOUT, 0); 116 | return `New rate limit: ${N} messages per 5 seconds`; 117 | } 118 | case "floodkick": { 119 | if (args.length !== 1) 120 | return "Syntax: /floodkick "; 121 | if (!this.auth.isChatAdmin(message.from.id, message.chat.id)) 122 | return "Insufficient privileges (chat admin required)."; 123 | const chatId = message.chat.id; 124 | const N = Number(args[0]); 125 | if (N === 0) { 126 | delete this.kickLimiters[chatId]; 127 | return "Antiflood kick disabled for this chat."; 128 | } 129 | this.kickLimiters[chatId] = new LeakyBucket(N, RATE_TIMEOUT, 0); 130 | return `New rate limit: ${N} messages per 5 seconds`; 131 | } 132 | } 133 | } 134 | }; -------------------------------------------------------------------------------- /src/plugins/Auth.js: -------------------------------------------------------------------------------- 1 | const Plugin = require("./../Plugin"); 2 | const Util = require("./../Util"); 3 | 4 | module.exports = class AuthPlugin extends Plugin { 5 | static get plugin() { 6 | return { 7 | name: "Auth", 8 | description: "Plugin to handle authentication", 9 | help: `Commands: 10 | 11 | /adminlist to list admins, /ownerlist to list the owner(s) 12 | /addadmin, /deladmin to add or remove admins 13 | /importadmins to import the chat's admins as this bot's "chat admins" 14 | 15 | The owner(s) can add other owners by manually editing the bot's configuration and restarting the bot.` 16 | }; 17 | } 18 | 19 | constructor(obj) { 20 | super(obj); 21 | 22 | this.auth = obj.auth; 23 | } 24 | 25 | async onCommand({message, command, args}) { 26 | const chatID = message.chat.id; 27 | switch (command) { 28 | case "adminlist": 29 | return this.auth.getChatAdmins(chatID).map(id => Util.nameResolver.getUsernameFromUserID(id) || id).map(str => "- " + str).join("\n") || "None."; 30 | case "addadmin": { 31 | if (!this.auth.isChatAdmin(message.from.id, chatID)) 32 | return "Insufficient privileges (chat admin required)."; 33 | const target = Util.getTargetID(message, args, "addadmin"); 34 | if (typeof target === "string") return target; 35 | this.auth.addChatAdmin(target, chatID); 36 | return "Added."; 37 | } 38 | case "deladmin": { 39 | if (!this.auth.isChatAdmin(message.from.id, chatID)) 40 | return "Insufficient privileges (chat admin required)."; 41 | const target = Util.getTargetID(message, args, "deladmin"); 42 | if (typeof target === "string") return target; 43 | this.auth.removeChatAdmin(target, chatID); 44 | return "Removed."; 45 | } 46 | case "importadmins": { 47 | if (!this.auth.isChatAdmin(message.from.id, chatID)) 48 | return "Insufficient privileges (chat admin required)."; 49 | const users = await this.getChatAdministrators(chatID); 50 | const myID = (await this.getMe()).id; 51 | let i = 0; 52 | for (const user of users) { 53 | if (user.user.id === myID) continue; // Do not add self 54 | if (this.auth.isOwner(user.user.id)) continue; // Do not add owners 55 | this.auth.addChatAdmin(user.user.id, message.chat.id); 56 | i++; 57 | } 58 | return `Imported ${i} users successfully!` 59 | } 60 | } 61 | } 62 | }; -------------------------------------------------------------------------------- /src/plugins/BoobsButts.js: -------------------------------------------------------------------------------- 1 | const Plugin = require("../Plugin"); 2 | const request = require("request-promise-native"); 3 | 4 | module.exports = class BoobsButts extends Plugin { 5 | static get plugin() { 6 | return { 7 | name: "BoobsButts", 8 | description: "Get boobs and butts.", 9 | help: "Just type /boobs or /butts." 10 | }; 11 | } 12 | 13 | async onCommand({command}) { 14 | switch (command) { 15 | case "boobs": 16 | case "butts": { 17 | const data = await request(`http://api.o${command}.ru/noise/1`); 18 | const item = JSON.parse(data)[0]; 19 | return { 20 | type: "photo", 21 | photo: `http://media.o${command}.ru/${item.preview}` 22 | }; 23 | } 24 | } 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /src/plugins/Config.js: -------------------------------------------------------------------------------- 1 | const Plugin = require("./../Plugin"); 2 | 3 | function editTree(tree, path, newValue) { 4 | if (path.length === 0) return newValue; 5 | const key = path.shift(); 6 | if (tree[key]) 7 | tree[key] = editTree(tree[key], path, newValue); 8 | else 9 | tree[key] = newValue; 10 | return tree; 11 | } 12 | 13 | module.exports = class Config extends Plugin { 14 | static get plugin() { 15 | return { 16 | name: "Config", 17 | description: "Configuration manager", 18 | help: "Syntax: /config (get|set) Plugin foo.bar [JSON value]" 19 | }; 20 | } 21 | 22 | onCommand({command, args}) { 23 | if (command !== "config") return; 24 | const [type, pluginName, property, ...jsonValue] = args; 25 | if (!type) return "Syntax: /config (get|set) Plugin foo.bar [JSON value]"; 26 | 27 | let jsonValueString; 28 | if (jsonValue) 29 | jsonValueString = jsonValue.join(" "); 30 | 31 | switch (type) { 32 | case "get": { 33 | let config = JSON.parse(JSON.stringify(this.db["plugin_" + pluginName].config)); 34 | if (jsonValueString) 35 | config = property.split(".").reduce((x, d) => x[d], config); 36 | return JSON.stringify(config); 37 | } 38 | case "set": { 39 | let value; 40 | try { 41 | value = JSON.parse(jsonValueString); 42 | } catch (e) { 43 | return "Couldn't parse the JSON value."; 44 | } 45 | const config = JSON.parse(JSON.stringify(this.db["plugin_" + pluginName].config)); 46 | editTree(config, property.split("."), value); 47 | this.db["plugin_" + pluginName].config = config; 48 | return "Done."; 49 | } 50 | default: 51 | return "Unknown command"; 52 | } 53 | } 54 | }; -------------------------------------------------------------------------------- /src/plugins/Echo.js: -------------------------------------------------------------------------------- 1 | // Author: Cristian Achille 2 | // Date: 25-10-2016 3 | 4 | const Plugin = require("./../Plugin"); 5 | 6 | module.exports = class Echo extends Plugin { 7 | static get plugin() { 8 | return { 9 | name: "Echo", 10 | description: "Totally not a bot with an echo", 11 | help: "`/echo Lorem Ipsum`" 12 | }; 13 | } 14 | 15 | onCommand({command, args}) { 16 | if (command !== "echo") return; 17 | return args.join(" "); 18 | } 19 | }; -------------------------------------------------------------------------------- /src/plugins/Fap.js: -------------------------------------------------------------------------------- 1 | const Plugin = require("../Plugin"); 2 | const Util = require("./../Util"); 3 | const request = require("request-promise-native"); 4 | 5 | module.exports = class Porn extends Plugin { 6 | static get plugin() { 7 | return { 8 | name: "Porn", 9 | description: "Searches porn.com.", 10 | help: "`/porn `" 11 | }; 12 | } 13 | 14 | async onCommand({command, args}) { 15 | if (command !== "porn") return; 16 | if (args.length === 0) 17 | return "Please enter a search query."; 18 | 19 | const query = args.join(" "); 20 | const data = JSON.parse(await request(`http://api.porn.com/videos/find.json?search=${encodeURIComponent(query)}`)); 21 | if (data.success !== true) 22 | throw new Error("An error occurred."); 23 | 24 | const item = data.result[0]; 25 | const minutes = String(Math.floor(item.duration / 60)); 26 | let seconds = String(item.duration % 60); 27 | if (String(seconds).length === 1) 28 | seconds = "0" + seconds; 29 | 30 | return { 31 | type: "text", 32 | text: `${Util.makeHTMLLink(item.title, item.url)} - ${minutes}:${seconds}`, 33 | options: { 34 | parse_mode: "HTML" 35 | } 36 | }; 37 | } 38 | }; 39 | -------------------------------------------------------------------------------- /src/plugins/Forward.js: -------------------------------------------------------------------------------- 1 | const Plugin = require("./../Plugin"); 2 | 3 | module.exports = class Forward extends Plugin { 4 | static get plugin() { 5 | return { 6 | name: "Forward", 7 | description: "Forward messages to a channel or group on command.", 8 | help: "Reply /fwd to a message. Use `/fwdset ` to set the destination." 9 | }; 10 | } 11 | 12 | onCommand({message, command, args}) { 13 | const chatID = message.chat.id; 14 | switch (command) { 15 | case "fwdset": 16 | if (args.length !== 1) 17 | return "Syntax: /fwdset "; 18 | if (!this.db[chatID]) 19 | this.db[chatID] = {}; 20 | this.db[chatID].target = args[0]; 21 | return "Done."; 22 | case "fwd": 23 | if (!this.db[chatID]) 24 | return "Use /fwdset to set the target channel/group."; 25 | if (!message.reply_to_message) 26 | return "Reply to a message with /fwd to forward it."; 27 | this.forwardMessage( 28 | this.db[chatID].target, 29 | message.reply_to_message.chat.id, 30 | message.reply_to_message.message_id 31 | ); 32 | } 33 | } 34 | }; -------------------------------------------------------------------------------- /src/plugins/Google.js: -------------------------------------------------------------------------------- 1 | const Plugin = require("../Plugin"); 2 | const Util = require("./../Util"); 3 | const assert = require("assert"); 4 | const GoogleSearch = require("google-search"); 5 | 6 | module.exports = class Google extends Plugin { 7 | constructor(obj) { 8 | super(obj); 9 | 10 | assert(typeof obj.config.GOOGLE_API_KEY === typeof "", "You must supply a Google API key."); 11 | assert(obj.config.GOOGLE_API_KEY !== "", "Please supply a valid Google API key."); 12 | assert(typeof obj.config.GOOGLE_CSE_ID === typeof "", "You must supply a Google CX key."); 13 | assert(obj.config.GOOGLE_CSE_ID !== "", "Please supply a valid Google CX key."); 14 | this.client = new GoogleSearch({ 15 | key: obj.config.GOOGLE_API_KEY, 16 | cx: obj.config.GOOGLE_CSE_ID 17 | }); 18 | } 19 | 20 | static get plugin() { 21 | return { 22 | name: "Google", 23 | description: "Search on Google.", 24 | help: "/google query", 25 | needs: { 26 | config: { 27 | GOOGLE_API_KEY: "Google API key", 28 | GOOGLE_CSE_ID: "Google CSE ID" 29 | } 30 | } 31 | }; 32 | } 33 | 34 | async onCommand({command, args}) { 35 | if (command !== "google") return; 36 | const query = args.join(" "); 37 | const response = await new Promise((resolve, reject) => this.client.build({q: query, num: 5}, (error, response) => error ? reject(error) : resolve(response))); 38 | 39 | return { 40 | type: "text", 41 | text: response.items 42 | .map(({title, link, snippet}) => `${Util.makeHTMLLink(title, link)}\n${snippet.replace(/\n/g, "")}`) 43 | .join("\n\n"), 44 | options: { 45 | disable_web_page_preview: true, 46 | parse_mode: "HTML" 47 | } 48 | }; 49 | } 50 | }; -------------------------------------------------------------------------------- /src/plugins/GoogleImages.js: -------------------------------------------------------------------------------- 1 | const Plugin = require("../Plugin"); 2 | const assert = require("assert"); 3 | const GoogleSearch = require("google-search"); 4 | 5 | module.exports = class GoogleImages extends Plugin { 6 | constructor(obj) { 7 | super(obj); 8 | 9 | assert("GOOGLE_API_KEY" in obj.config, "string", "You must supply a Google API key."); 10 | assert.notStrictEqual(obj.config.GOOGLE_API_KEY, "", "Please supply a valid Google API key."); 11 | assert("GOOGLE_CSE_ID" in obj.config, "string", "You must supply a Google CX key."); 12 | assert.notStrictEqual(obj.config.GOOGLE_CSE_ID, "", "Please supply a valid Google CX key."); 13 | 14 | this.client = new GoogleSearch({ 15 | key: obj.config.GOOGLE_API_KEY, 16 | cx: obj.config.GOOGLE_CSE_ID 17 | }); 18 | } 19 | 20 | static get plugin() { 21 | return { 22 | name: "GoogleImages", 23 | description: "Search for images on Google.", 24 | help: "/images query", 25 | needs: { 26 | config: { 27 | GOOGLE_API_KEY: "Google API key", 28 | GOOGLE_CSE_ID: "Google CSE ID" 29 | } 30 | } 31 | }; 32 | } 33 | 34 | async onCommand({command, args}) { 35 | if (command !== "images") return; 36 | const query = args.join(" "); 37 | const response = await new Promise((resolve, reject) => this.client.build({q: query, searchtype: "image", num: 1}, (error, response) => error ? reject(error) : resolve(response))); 38 | if ((!response.items) || (response.items.length === 0)) 39 | return "No results found."; 40 | const item = response.items[0]; 41 | let caption = item.snippet.replace(/\n/g, ""); 42 | if (caption.length > 200) { 43 | caption = caption.substring(0, 197) + "..." 44 | } 45 | return { 46 | type: "photo", 47 | photo: item.pagemap.cse_image[0].src, 48 | options: {caption} 49 | }; 50 | } 51 | }; 52 | -------------------------------------------------------------------------------- /src/plugins/Ignore.js: -------------------------------------------------------------------------------- 1 | const Plugin = require("../Plugin"); 2 | const Util = require("../Util.js"); 3 | 4 | module.exports = class Ignore extends Plugin { 5 | constructor(obj) { 6 | super(obj); 7 | 8 | this.auth = obj.auth; 9 | if (!this.db.ignored) { 10 | this.db.ignored = []; 11 | } 12 | } 13 | 14 | static get plugin() { 15 | return { 16 | name: "Ignore", 17 | description: "Ignore users", 18 | help: "Syntax: /ignore ", 19 | 20 | isProxy: true 21 | }; 22 | } 23 | 24 | proxy(eventName, message) { 25 | if (this.db.ignored.indexOf(message.from.id) !== -1) 26 | return Promise.reject(); 27 | return Promise.resolve(); 28 | } 29 | 30 | onCommand({message, command, args}) { 31 | switch (command) { 32 | case "ignorelist": 33 | return this.db.ignored.map(id => Util.nameResolver.getUsernameFromUserID(id) || id).map(str => "- " + str).join("\n") || "None."; 34 | case "ignore": { 35 | if (!this.auth.isChatAdmin(message.from.id, message.chat.id)) 36 | return "Insufficient privileges (chat admin required)."; 37 | const target = Util.getTargetID(message, args, "ignore"); 38 | if (typeof target === "string") // Error messages 39 | return target; 40 | if (this.auth.isChatAdmin(target, message.chat.id)) 41 | return "Can't ignore chat admins."; 42 | 43 | this.db.ignored.push(target); 44 | return "Ignored."; 45 | } 46 | case "unignore": { 47 | if (!this.auth.isChatAdmin(message.from.id, message.chat.id)) 48 | return "Insufficient privileges (chat admin required)."; 49 | const target = Util.getTargetID(message, args, "unignore"); 50 | if (typeof target === "string") // Error messages 51 | return target; 52 | 53 | this.db.ignored = this.db.ignored.filter(id => id !== target); 54 | return "Unignored."; 55 | } 56 | } 57 | } 58 | }; 59 | -------------------------------------------------------------------------------- /src/plugins/Imgur.js: -------------------------------------------------------------------------------- 1 | const Plugin = require("../Plugin"); 2 | const request = require("request-promise-native"); 3 | 4 | module.exports = class Imgur extends Plugin { 5 | static get plugin() { 6 | return { 7 | name: "Imgur", 8 | description: "Get a random image from imgur", 9 | help: "`/imgur` will get you a random image from imgur, the popular image hosting website.\nBeware, you could randomly find adult content." 10 | }; 11 | } 12 | 13 | onCommand({message, command}) { 14 | if (command !== "imgur") return; 15 | return this.findValidPic(0, message); 16 | } 17 | 18 | async findValidPic(s, message) { 19 | if (s > 50) 20 | return; 21 | 22 | this.sendChatAction(message.chat.id, "upload_photo"); 23 | 24 | const url = `http://i.imgur.com/${Imgur.generateUrl(6)}.png`; 25 | 26 | try { 27 | const response = await request({ 28 | method: "HEAD", 29 | uri: url 30 | }); 31 | if (response["content-length"] === "12022") 32 | return this.findValidPic(s + 1, message); 33 | return { 34 | type: "photo", 35 | photo: url 36 | }; 37 | } catch (e) { 38 | if (e.statusCode === 404) 39 | return this.findValidPic(s + 1, message); 40 | return "An error occurred."; 41 | } 42 | } 43 | 44 | static generateUrl(len) { 45 | let url = ""; 46 | const letters = "qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM1234567890"; 47 | for (let i = 0; i < len; i++) 48 | url += letters[Math.floor(Math.random() * letters.length)]; 49 | return url; 50 | } 51 | }; 52 | -------------------------------------------------------------------------------- /src/plugins/Karma.js: -------------------------------------------------------------------------------- 1 | const Plugin = require("./../Plugin"); 2 | 3 | module.exports = class Karma extends Plugin { 4 | static get plugin() { 5 | return { 6 | name: "Karma", 7 | description: "Keeps scores about users.", 8 | help: "@username++, @username--. Use /karmachart to view scores." 9 | }; 10 | } 11 | 12 | onCommand({message, command}) { 13 | if (command !== "karmachart") return; 14 | if (!this.db[message.chat.id]) return "No scores yet."; 15 | const users = Object.keys(this.db[message.chat.id]); 16 | if (users.length === 0) return "No scores yet."; 17 | return users.map(user => `${user}: ${this.db[message.chat.id][user]} points`).join("\n"); 18 | } 19 | 20 | onText({message}) { 21 | // Telegram usernames are 5 or more characters long 22 | // and contain [A-Z], [a-z], [0-9]. 23 | // Match that, plus either "++" or "--" 24 | const regex = /@([a-z0-9_]{5,})(\+\+|--)/i; 25 | if (!regex.test(message.text)) return; 26 | const chatId = message.chat.id; 27 | 28 | const parts = message.text.match(regex); 29 | const target = parts[1]; 30 | const operator = parts[2]; 31 | 32 | if (target.toLowerCase() === message.from.username.toLowerCase()) 33 | return "You can't karma yourself!"; 34 | 35 | if (!this.db[chatId]) 36 | this.db[chatId] = {}; 37 | if (!this.db[chatId][target]) 38 | this.db[chatId][target] = 0; 39 | 40 | this.db[chatId][target] += (operator === "++") ? +1 : -1; 41 | 42 | return `${target} now has ${this.db[chatId][target]} karma points`; 43 | } 44 | }; -------------------------------------------------------------------------------- /src/plugins/Kick.js: -------------------------------------------------------------------------------- 1 | const Plugin = require("./../Plugin"); 2 | const Util = require("../Util"); 3 | 4 | module.exports = class Kick extends Plugin { 5 | static get plugin() { 6 | return { 7 | name: "Kick", 8 | description: "Kicks users", 9 | help: "Reply with /kick or /ban, or send /[kick|ban] ID." 10 | }; 11 | } 12 | 13 | constructor(obj) { 14 | super(obj); 15 | 16 | this.auth = obj.auth; 17 | } 18 | 19 | async onCommand({message, command, args}) { 20 | const chatID = message.chat.id; 21 | switch (command) { 22 | case "banlist": { 23 | if (!this.db[chatID]) 24 | return "Empty."; 25 | return this.db[chatID].map(id => Util.nameResolver.getUsernameFromUserID(id) || id).map(str => "- " + str).join("\n") || "None."; 26 | } 27 | case "kick": { 28 | if (!this.auth.isChatAdmin(message.from.id, chatID)) 29 | return "Insufficient privileges (chat admin required)."; 30 | const target = Util.getTargetID(message, args, "kick"); 31 | if (typeof target === "string") return target; 32 | if (this.auth.isChatAdmin(target, chatID)) 33 | return "Can't kick chat admins!"; 34 | return this.kick(chatID, target).catch(e => { 35 | if (/USER_NOT_PARTICIPANT/.test(e.message)) 36 | return "The user is no longer in the chat!"; 37 | throw e; 38 | }); 39 | } 40 | case "ban": { 41 | if (!this.auth.isChatAdmin(message.from.id, chatID)) 42 | return "Insufficient privileges (chat admin required)."; 43 | const target = Util.getTargetID(message, args, "ban"); 44 | if (typeof target === "string") return target; 45 | if (this.auth.isChatAdmin(target, chatID)) 46 | return "Can't ban chat admins!"; 47 | this.ban(chatID, target); 48 | return this.kick(chatID, target).catch(e => { 49 | /* We don't care if the user is no longer in the chat, so 50 | * we should swallow the error. However, in that case, the 51 | * admin wouldn't receive any feedback! So, return a 52 | * confirmation message. 53 | */ 54 | if (/USER_NOT_PARTICIPANT/.test(e.message)) 55 | return "Banned."; 56 | throw e; 57 | }); 58 | } 59 | case "unban": { 60 | if (!this.auth.isChatAdmin(message.from.id, chatID)) 61 | return "Insufficient privileges (chat admin required)."; 62 | const target = Util.getTargetID(message, args, "unban"); 63 | if (typeof target === "string") return target; 64 | if (!this.db[chatID]) 65 | return "It seems that there are no banned users."; 66 | this.db[chatID] = this.db[chatID].filter(id => id !== target); 67 | const chat = await this.getChat(chatID); 68 | if (chat.type === "supergroup") 69 | await this.unbanChatMember(chatID, target); 70 | return "Unbanned."; 71 | } 72 | } 73 | } 74 | 75 | kick(chatID, target) { 76 | return this.kickChatMember(chatID, target).then(() => {}).catch(e => { 77 | if (/CHAT_ADMIN_REQUIRED/.test(e.message)) 78 | return "I'm not a chat administrator, I can't kick users!"; 79 | throw e; 80 | }); 81 | } 82 | 83 | // Note that banning does not imply kicking. 84 | ban(chatID, target) { 85 | if (!this.db[chatID]) 86 | this.db[chatID] = []; 87 | this.db[chatID].push(target); 88 | } 89 | 90 | onNewChatMembers({message}) { 91 | const chatID = message.chat.id; 92 | // If there is no database, nobody was ever banned so far. Return early. 93 | if (!this.db[chatID]) return; 94 | 95 | // Return a promise, so that we can print exceptions. 96 | return Promise.all(message.new_chat_members 97 | .map(member => member.id) 98 | .filter(target => this.db[chatID].includes(target)) 99 | .filter(target => !this.auth.isChatAdmin(target, chatID)) 100 | .map(target => this.kick(chatID, target).then(msg => { 101 | // Yeah, not super clean. 102 | if (msg) 103 | return this.sendMessage(chatID, msg); 104 | })) 105 | ).then(() => {}); 106 | } 107 | }; 108 | -------------------------------------------------------------------------------- /src/plugins/Markov.js: -------------------------------------------------------------------------------- 1 | const Plugin = require("./../Plugin"); 2 | 3 | function arraySample(arr) { 4 | return arr[Math.floor(Math.random() * arr.length)]; 5 | } 6 | 7 | class Blather { 8 | constructor({ 9 | isStart = (key, index) => index === 0, 10 | clean = textArray => textArray.join(" "), 11 | split = text => text.split(/\s+/), 12 | depth = 2, 13 | joiner = "<|>", 14 | dictionary = {} 15 | }) { 16 | this.isStart = isStart; 17 | this.clean = clean; 18 | this.split = split; 19 | this.depth = depth; 20 | this.joiner = joiner; 21 | this.dictionary = dictionary; 22 | } 23 | 24 | addFragment(text, chat) { 25 | const tokens = this.split(text); 26 | const limit = tokens.length - 1 - this.depth; 27 | for (let i = 0; i < tokens.length; i++) { 28 | if (i > limit) return; 29 | 30 | const key = tokens.slice(i, i + this.depth).join(this.joiner); 31 | 32 | if (this.isStart(key, i)) 33 | this.dictionary[chat].starts.push(key); 34 | 35 | this.dictionary[chat].chains[key] = this.dictionary[chat].chains[key] || []; 36 | this.dictionary[chat].chains[key].push(tokens[i + this.depth]); 37 | } 38 | } 39 | 40 | // start is an array of words with which to start 41 | generateFragment(chat, start = arraySample(this.dictionary[chat].starts).split(this.joiner)) { 42 | return this.fill(start, chat, Blather.shouldStopFragment); 43 | } 44 | 45 | fill(chain, chat, stopCondition) { 46 | let key = chain.slice(chain.length - this.depth).join(this.joiner); 47 | 48 | while (this.dictionary[chat].chains[key] && !stopCondition(chain)) { 49 | chain.push(arraySample(this.dictionary[chat].chains[key])); 50 | key = chain.slice(chain.length - this.depth).join(this.joiner); 51 | } 52 | 53 | return this.clean(chain); 54 | } 55 | 56 | stringify() { 57 | return JSON.stringify({ 58 | depth: this.depth, 59 | joiner: this.joiner, 60 | dictionary: this.dictionary 61 | }); 62 | } 63 | 64 | static shouldStopFragment(chain) { 65 | return chain.length >= 1000; 66 | } 67 | 68 | static destringify(stringified) { 69 | return new Blather(JSON.parse(stringified)); 70 | } 71 | } 72 | 73 | module.exports = class Markov extends Plugin { 74 | constructor(obj) { 75 | super(obj); 76 | 77 | if (this.db) { 78 | this.m = Blather.destringify(JSON.stringify(this.db)); 79 | } else { 80 | this.m = new Blather(); 81 | } 82 | 83 | this.rate = 0.02; 84 | } 85 | 86 | static get plugin() { 87 | return { 88 | name: "Markov", 89 | description: "Generates random text.", 90 | help: "/markov, or `/markov `" 91 | }; 92 | } 93 | 94 | onText({message}) { 95 | const chat = message.chat.id; 96 | if (!this.m.dictionary[chat]) this.m.dictionary[chat] = {starts: [], chains: {}}; 97 | // Take advantage of this to sync the db to memory 98 | this.db = { 99 | depth: this.m.depth, 100 | joiner: this.m.joiner, 101 | dictionary: this.m.dictionary 102 | }; 103 | 104 | this.m.addFragment(message.text, chat); 105 | if (Math.random() > this.rate) return; 106 | this.sendMessage(message.chat.id, this.m.generateFragment(chat)); 107 | } 108 | 109 | onCommand({message, command, args}) { 110 | if (command !== "markov") return; 111 | const chat = message.chat.id; 112 | if (!this.m.dictionary[chat]) return; 113 | return this.m.generateFragment(message.chat.id, (args.length > 0) ? args : undefined); 114 | } 115 | }; -------------------------------------------------------------------------------- /src/plugins/Math.js: -------------------------------------------------------------------------------- 1 | const Plugin = require("./../Plugin"); 2 | const mathjs = require("mathjs"); 3 | 4 | module.exports = class Math extends Plugin { 5 | static get plugin() { 6 | return { 7 | name: "Math", 8 | description: "Amazing calculator based on [math.js](http://mathjs.org/)", 9 | help: "Use `/calc expression` or `/math expression` to get a quick answer for your math expression. Supports imaginary numbers, sin/cos/tan, and much more!" 10 | }; 11 | } 12 | 13 | onCommand({command, args}) { 14 | if (command !== "calc" && command !== "math") return; 15 | return mathjs.eval(args.join(" ")); 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /src/plugins/MediaSet.js: -------------------------------------------------------------------------------- 1 | const Plugin = require("./../Plugin"); 2 | const Util = require("./../Util"); 3 | 4 | module.exports = class MediaSet extends Plugin { 5 | constructor(obj) { 6 | super(obj); 7 | 8 | if (!this.db.triggers) { 9 | this.db.triggers = {}; 10 | } 11 | 12 | if (!this.db.pendingRequests) { 13 | this.db.pendingRequests = {}; 14 | } 15 | } 16 | 17 | static get plugin() { 18 | return { 19 | name: "MediaSet", 20 | description: "Media-capable set command", 21 | help: "/mset `trigger`, /munset `trigger`" 22 | }; 23 | } 24 | 25 | onText({message}) { 26 | if (!this.db.triggers[message.chat.id]) return; 27 | 28 | const text = message.text; 29 | const triggers = this.db.triggers[message.chat.id]; 30 | 31 | for (const trigger in triggers) { 32 | if (text.indexOf(trigger) === -1) continue; 33 | const re = new RegExp("(?:\\b|^)(" + Util.escapeRegExp(trigger) + ")(?:\\b|$)", "g"); 34 | const match = re.exec(text); 35 | if (!match) continue; 36 | const media = triggers[trigger]; 37 | 38 | this.log.verbose("Match on " + Util.buildPrettyChatName(message.chat)); 39 | switch (media.type) { 40 | case "audio": 41 | this.sendAudio(message.chat.id, media.fileId); 42 | break; 43 | case "document": 44 | this.sendDocument(message.chat.id, media.fileId); 45 | break; 46 | case "photo": 47 | this.sendPhoto(message.chat.id, media.fileId); 48 | break; 49 | case "sticker": 50 | this.sendSticker(message.chat.id, media.fileId); 51 | break; 52 | case "video": 53 | this.sendVideo(message.chat.id, media.fileId); 54 | break; 55 | case "video_note": 56 | this.sendVideoNote(message.chat.id, media.fileId); 57 | break; 58 | case "voice": 59 | this.sendVoice(message.chat.id, media.fileId); 60 | break; 61 | default: 62 | this.log.error(`Unrecognized media type: ${media.type}`); 63 | } 64 | } 65 | } 66 | 67 | onAudio({message}) { 68 | this.setStepTwo(message, "audio"); 69 | } 70 | onDocument({message}) { 71 | this.setStepTwo(message, "document"); 72 | } 73 | onPhoto({message}) { 74 | this.setStepTwo(message, "photo"); 75 | } 76 | onSticker({message}) { 77 | this.setStepTwo(message, "sticker"); 78 | } 79 | onVideo({message}) { 80 | this.setStepTwo(message, "video"); 81 | } 82 | onVideoNote({message}) { 83 | this.setStepTwo(message, "video_note"); 84 | } 85 | onVoice({message}) { 86 | this.setStepTwo(message, "voice"); 87 | } 88 | 89 | async onCommand({message, command, args}) { 90 | const chatID = message.chat.id; 91 | const trigger = args[0]; 92 | switch (command) { 93 | case "mset": { 94 | if (args.length !== 1) 95 | return "Syntax: /mset trigger"; 96 | this.log.verbose("Triggered stepOne on " + Util.buildPrettyChatName(message.chat)); 97 | if (!this.db.pendingRequests[chatID]) 98 | this.db.pendingRequests[chatID] = {}; 99 | const {message_id} = await this.sendMessage(chatID, "Perfect! Now send me the media as a reply to this message!"); 100 | this.db.pendingRequests[chatID][message_id] = trigger; 101 | break; 102 | } 103 | case "munset": 104 | case "moonset": // Easter egg! 105 | if (args.length !== 1) 106 | return "Syntax: `/munset trigger`"; 107 | delete this.db.triggers[chatID][trigger]; 108 | this.log.verbose("Removed trigger " + trigger + " on " + Util.buildPrettyChatName(message.chat)); 109 | return "Done!"; 110 | } 111 | } 112 | 113 | setStepTwo(message, mediaType) { 114 | // is this a reply for a "now send media" message? 115 | if (!message.hasOwnProperty("reply_to_message")) return; 116 | // are there pending requests for this chat? 117 | if (!this.db.pendingRequests[message.chat.id]) return; 118 | 119 | // This is because keys are stored as strings, but message.message_id is a number. 120 | const messageId = String(message.reply_to_message.message_id); 121 | 122 | // foreach request (identified by the "now send media" message id) 123 | for (const request in this.db.pendingRequests[message.chat.id]) { 124 | // if the message is not replying just continue 125 | if (messageId !== request) continue; 126 | 127 | const trigger = this.db.pendingRequests[message.chat.id][request]; 128 | 129 | // do we have triggers for this chat? 130 | if (!this.db.triggers[message.chat.id]) 131 | this.db.triggers[message.chat.id] = {}; 132 | 133 | // build the trigger 134 | let fileId; 135 | if (mediaType === "photo") 136 | fileId = message.photo[0].file_id; 137 | else 138 | fileId = message[mediaType].file_id; 139 | 140 | this.log.verbose("Added trigger on " + Util.buildPrettyChatName(message.chat)); 141 | // set the trigger 142 | this.db.triggers[message.chat.id][trigger] = { 143 | type: mediaType, 144 | fileId 145 | }; 146 | 147 | // delete pending request 148 | delete this.db.pendingRequests[message.chat.id][request]; 149 | 150 | this.sendMessage(message.chat.id, "Done! Enjoy!"); 151 | return; 152 | } 153 | } 154 | }; -------------------------------------------------------------------------------- /src/plugins/ModTools.js: -------------------------------------------------------------------------------- 1 | const Plugin = require("./../Plugin"); 2 | const Util = require("../Util"); 3 | 4 | module.exports = class ModTools extends Plugin { 5 | static get plugin() { 6 | return { 7 | name: "ModTools", 8 | description: "Moderation tools", 9 | help: `- Warnings: use /warn to warn a user and delete the message (gets kicked after 3 warnings) 10 | - Blacklist: words that will get you kicked and your message removed. /blacklist shows the blacklist, \`/blacklist add \` adds a word, \`/blacklist delete \` removes it. 11 | - #admin: use #admin to notify all chat admins.` 12 | }; 13 | } 14 | 15 | constructor(obj) { 16 | super(obj); 17 | 18 | this.auth = obj.auth; 19 | if (!this.db.warnings) 20 | this.db.warnings = {}; 21 | if (!this.db.blacklist) 22 | this.db.blacklist = {}; 23 | } 24 | 25 | onCommand({message, command, args}) { 26 | const chatID = message.chat.id; 27 | switch (command) { 28 | case "warn": { 29 | if (!this.auth.isChatAdmin(message.from.id, chatID)) 30 | return "Insufficient privileges (chat admin required)."; 31 | if (!message.reply_to_message) 32 | return "Reply to a message with /warn to issue a warning and delete a message."; 33 | if (!message.from) 34 | return "Internal error."; 35 | const target = message.reply_to_message.from.id; 36 | if (!this.db.warnings[chatID]) 37 | this.db.warnings[chatID] = {[target]: 0}; 38 | if (!this.db.warnings[chatID][target]) 39 | this.db.warnings[chatID][target] = 0; 40 | this.db.warnings[chatID][target]++; 41 | if (this.db.warnings[chatID][target] < 3) { 42 | this.db.warnings[chatID][target] = 0; 43 | this.kick(message, target); 44 | this.deleteMessage(message.chat.id, message.message_id); 45 | return "User warned. Kicked after 3 warnings."; 46 | } 47 | return `User warned. Number of warnings: ${this.db.warnings[chatID][target]}/3.`; 48 | } 49 | case "blacklist": { 50 | if (args.length === 0) { 51 | if (!this.db.blacklist[chatID]) 52 | return "The blacklist is empty."; 53 | return this.db.blacklist[chatID].map(word => `- ${word}`).join("\n"); 54 | } 55 | if (!this.auth.isChatAdmin(message.from.id, chatID)) 56 | return "Insufficient privileges (chat admin required)."; 57 | const word = args.slice(1).join(" "); 58 | if (!this.db.blacklist[chatID]) 59 | this.db.blacklist[chatID] = []; 60 | switch (args[0]) { 61 | case "add": 62 | if (args.length === 1) 63 | return "Syntax: `/blacklist add "; 64 | this.db.blacklist[chatID].push(word); 65 | return "Done!"; 66 | case "delete": 67 | if (args.length === 1) 68 | return "Syntax: `/blacklist delete "; 69 | this.db.blacklist[chatID] = this.db.blacklist[chatID].filter(val => val !== word); 70 | return "Done!"; 71 | default: 72 | return "Syntax: `/blacklist `"; 73 | } 74 | } 75 | } 76 | } 77 | 78 | onText({message}) { 79 | const chatID = message.chat.id; 80 | if (message.text.includes("#admin")) { 81 | for (const admin of this.auth.getAdmins(chatID)) { 82 | this.sendMessage(admin, `Message from ${Util.buildPrettyChatName(message.chat)}:\n\n${message.text}`) 83 | .catch(() => this.sendMessage(chatID, `Couldn't send message to admin ${admin} (${Util.nameResolver.getUsernameFromUserID(admin)}). Perhaps they need to initiate a conversation with the bot?`)); 84 | } 85 | } 86 | 87 | if (this.auth.isChatAdmin(message.from.id, message.chat.id)) 88 | return; 89 | for (const word of this.db.blacklist[chatID]) { 90 | if (!message.text.includes(word)) 91 | continue; 92 | this.deleteMessage(message.chat.id, message.message_id); 93 | this.kick(message, message.from.id); 94 | break; 95 | } 96 | } 97 | 98 | kick(message, target) { 99 | this.kickChatMember(message.chat.id, target) 100 | .catch(err => this.sendMessage(message.chat.id, "An error occurred while kicking the user: " + err)); 101 | } 102 | }; 103 | -------------------------------------------------------------------------------- /src/plugins/Ping.js: -------------------------------------------------------------------------------- 1 | const Plugin = require("./../Plugin"); 2 | 3 | module.exports = class Ping extends Plugin { 4 | static get plugin() { 5 | return { 6 | name: "Ping", 7 | description: "Ping - Pong", 8 | help: "Send `ping`, get `pong`\nIf only life was _this_ easy." 9 | }; 10 | } 11 | 12 | onCommand({command}) { 13 | if (command !== "ping") return; 14 | this.log.debug("Got a ping"); 15 | return "Pong!"; 16 | } 17 | 18 | onText({message}) { 19 | if (message.text !== "ping") return; 20 | this.log.debug("Got a ping"); 21 | return "Pong!"; 22 | } 23 | }; -------------------------------------------------------------------------------- /src/plugins/Quote.js: -------------------------------------------------------------------------------- 1 | // Author: Cristian Achille 2 | // Date: 19-10-2016 3 | 4 | const Plugin = require("../Plugin"); 5 | 6 | module.exports = class Quote extends Plugin { 7 | constructor(obj) { 8 | super(obj); 9 | 10 | if (!this.db.quotes) { 11 | this.db.quotes = []; 12 | } 13 | } 14 | 15 | static get plugin() { 16 | return { 17 | name: "Quote", 18 | description: "A classic quote system", 19 | help: `Commands: 20 | /addquote adds the message you replied to 21 | /quote returns a random quote 22 | \`/quote \` returns the quote with the given ID` 23 | }; 24 | } 25 | 26 | onCommand({command, message, args}) { 27 | switch (command) { 28 | case "addquote": 29 | return this.addQuote(message); 30 | case "quote": 31 | if (args[0]) 32 | return this.findQuote(args[0] - 1); 33 | return this.randomQuote(); 34 | } 35 | } 36 | 37 | addQuote(message) { 38 | if (!message.reply_to_message || !message.reply_to_message.text) 39 | return "Reply to a text message to add it to the quotes database."; 40 | 41 | const author = Quote.getAuthor(message.reply_to_message); 42 | const text = message.reply_to_message.text; 43 | 44 | this.db.quotes.push({ 45 | author, 46 | text 47 | }); 48 | 49 | return `Quote added with ID ${this.db.quotes.length - 1}.`; 50 | } 51 | 52 | findQuote(id) { 53 | const quote = this.db.quotes[id]; 54 | if (!quote) 55 | return "Quote not found!"; 56 | 57 | return `<${quote.author}>: ${quote.text}`; 58 | } 59 | 60 | randomQuote() { 61 | const id = Math.floor(Math.random() * this.db.quotes.length); 62 | return this.findQuote(id); 63 | } 64 | 65 | static getAuthor(obj) { 66 | let author = obj.from.username; 67 | const forward = obj.forward_from; 68 | if (author) 69 | author = "@" + author; 70 | else 71 | author = obj.from.first_name; 72 | 73 | if (forward) { 74 | if (forward.username) 75 | author = forward.first_name; 76 | author = "@" + forward.username; 77 | } 78 | 79 | return author; 80 | } 81 | }; 82 | -------------------------------------------------------------------------------- /src/plugins/RSS.js: -------------------------------------------------------------------------------- 1 | const Plugin = require("./../Plugin"); 2 | const Util = require("./../Util"); 3 | const Scheduler = require("./../helpers/Scheduler.js"); 4 | const Parser = require("rss-parser"); 5 | 6 | async function getFeedItems(URL) { 7 | const parser = new Parser(); 8 | const feed = await parser.parseURL(URL); 9 | return feed.items; 10 | } 11 | 12 | module.exports = class RSS extends Plugin { 13 | static get plugin() { 14 | return { 15 | name: "RSS", 16 | description: "Read RSS feeds", 17 | help: `/addrss to add an RSS feed 18 | /deleterss to delete it 19 | /rsslist to list RSS feeds 20 | /rss to fetch and print news from the feeds 21 | /rsstime to set when to automatically print news (eg. /rsstime 10:00, must use 24-hour format)` 22 | }; 23 | } 24 | 25 | constructor(obj) { 26 | super(obj); 27 | 28 | this.auth = obj.auth; 29 | Scheduler.on("RSSTrigger", ({chatID}) => this.printRSS(chatID)); 30 | } 31 | 32 | onCommand({message, command, args}) { 33 | const chatID = message.chat.id; 34 | switch (command) { 35 | case "rsslist": { 36 | const entry = this.db[chatID]; 37 | if (!entry) 38 | return "None."; 39 | return `At ${entry.time}:\n\n` + (entry.feeds.join("\n") || "None."); 40 | } 41 | case "addrss": { 42 | if (!this.auth.isChatAdmin(message.from.id, chatID)) 43 | return "Insufficient privileges (chat admin required)."; 44 | if (args.length != 1) 45 | return "Syntax: /addrss http://example.com/feed.xml" 46 | const URL = args[0]; 47 | if (!this.db[chatID]) 48 | this.db[chatID] = { 49 | feeds: [], 50 | time: "12:00" 51 | }; 52 | this.db[chatID].feeds.push(URL); 53 | return `Added ${URL}.`; 54 | } 55 | case "deleterss": { 56 | if (!this.auth.isChatAdmin(message.from.id, chatID)) 57 | return "Insufficient privileges (chat admin required)."; 58 | if (args.length != 1) 59 | return "Syntax: /deleterss http://example.com/feed.xml" 60 | const URL = args[0]; 61 | if (!this.db[chatID]) 62 | this.db[chatID] = { 63 | feeds: [], 64 | time: "12:00" 65 | }; 66 | this.db[chatID].feeds.filter(it => it !== URL); 67 | return `Deleted ${URL}.`; 68 | } 69 | case "rss": { 70 | if (!this.auth.isChatAdmin(message.from.id, chatID)) 71 | return "Insufficient privileges (chat admin required)."; 72 | if (!this.db[chatID].feeds) 73 | return "No RSS feeds set for this chat."; 74 | this.printRSS(chatID); 75 | break; 76 | } 77 | case "rsstime": { 78 | if (!this.auth.isChatAdmin(message.from.id, chatID)) 79 | return "Insufficient privileges (chat admin required)."; 80 | if (args.length != 1) 81 | return "Syntax: /rsstime time (eg. /rsstime 12:00). Must use the 24-hour format." 82 | const time = args[0]; 83 | if (!/^[012]\d:[0-5]\d$/.test(time)) 84 | return "Syntax: /rsstime time (eg. /rsstime 12:00). Must use the 24-hour format." 85 | const [hh, mm] = time.match(/^([012]\d):([0-5]\d)$/).slice(1); 86 | if (!this.db[chatID]) 87 | this.db[chatID] = { 88 | feeds: [], 89 | time: hh + ":" + mm 90 | }; 91 | else 92 | this.db[chatID].time = hh + ":" + mm; 93 | Scheduler.cancel(it => it.plugin === "RSS" && it.chatID === chatID); 94 | Scheduler.scheduleCron("RSSTrigger", {plugin: "RSS", chatID}, `00 ${mm} ${hh} * * *`); 95 | return `I will print RSS feeds every day at ${hh}:${mm}.`; 96 | } 97 | } 98 | } 99 | 100 | async printRSS(chatID) { 101 | console.log("Printing!"); 102 | console.log(this.db); 103 | const itemArrays = await Promise.all(this.db[chatID].feeds.map(getFeedItems)); 104 | console.log(itemArrays); 105 | await itemArrays.forEach(itemArray => { 106 | if (itemArray) { 107 | let i = 1; 108 | const text = itemArray.map(item => `${i++}. ` + Util.makeHTMLLink(item.title, item.link)).join("\n"); 109 | console.log(text); 110 | this.sendMessage( 111 | chatID, 112 | text, 113 | { 114 | parse_mode: "HTML" 115 | }); 116 | } 117 | }); 118 | } 119 | }; -------------------------------------------------------------------------------- /src/plugins/Reddit.js: -------------------------------------------------------------------------------- 1 | const Plugin = require("../Plugin"); 2 | const Util = require("./../Util"); 3 | const request = require("request-promise-native"); 4 | 5 | module.exports = class Reddit extends Plugin { 6 | static get plugin() { 7 | return { 8 | name: "Reddit", 9 | description: "Get a random post from the Reddit frontpage or a subreddit", 10 | help: `\`/reddit\` gets a random post; \`/reddit sub\` gets a random post from the subreddit. 11 | \`/redimg _subreddit_\` : gets a random image from _subreddit_ 12 | ` 13 | }; 14 | } 15 | 16 | async sendRequest(url) { 17 | return request(url); 18 | } 19 | 20 | subCommand(command,body) { 21 | const results = JSON.parse(body).data.children; 22 | switch (command) { 23 | case "reddit": 24 | return this.reddit(results); 25 | case "redimg": 26 | return this.redimg(results); 27 | } 28 | } 29 | 30 | 31 | async onCommand({message, command, args}) { 32 | if (command !== "reddit" && command !== "redimg") 33 | return; 34 | 35 | const sub = args[0]; 36 | return this.sendRequest("https://reddit.com/" + (sub ? `r/${sub}` : "") + ".json") 37 | .then(body => this.subCommand(command, body)) 38 | .catch(() => "Reddit down or subreddit banned.") 39 | 40 | } 41 | 42 | reddit(results) { 43 | const item = results[Math.floor(Math.random() * results.length)].data; 44 | return { 45 | type: "text", 46 | text: `${Util.makeHTMLLink(item.title, "https://reddit.com" + item.permalink)} - r/${item.subreddit}`, 47 | options: { 48 | parse_mode: "HTML" 49 | } 50 | }; 51 | } 52 | 53 | redimg(results) { 54 | results = results 55 | .map(c => c.data) 56 | .filter(c => c.post_hint === "image"); 57 | 58 | if (results.length == 0) return "Subreddit not found!" 59 | 60 | const item = results[Math.floor(Math.random() * results.length)]; 61 | return { 62 | type: "photo", 63 | photo: item.url, 64 | options: { 65 | caption: `${item.title}\n\n${item.url}` 66 | } 67 | }; 68 | } 69 | }; 70 | 71 | -------------------------------------------------------------------------------- /src/plugins/RegexSet.js: -------------------------------------------------------------------------------- 1 | const Plugin = require("./../Plugin"); 2 | const safe = require("safe-regex"); 3 | 4 | module.exports = class RegexSet extends Plugin { 5 | constructor(obj) { 6 | super(obj); 7 | 8 | this.auth = obj.auth; 9 | if (!this.db.replacements) { 10 | this.db.replacements = []; 11 | } 12 | } 13 | 14 | static get plugin() { 15 | return { 16 | name: "RegexSet", 17 | description: "Regex-capable set command", 18 | help: "Commands: `/regexset /regex/flags replacement`, /regexlist\n\nFor example:\n/regexset /fo+/i - bar\n\nDon't forget to escape literal slashes with \"\\/\"." 19 | }; 20 | } 21 | 22 | onText({message}) { 23 | const chatID = message.chat.id; 24 | 25 | for (const item of this.db.replacements) { 26 | if (chatID !== item.chatID) continue; 27 | const matches = message.text.match(new RegExp(item.regex, item.flags)); 28 | if (!matches) continue; 29 | 30 | let replacement = item.text; 31 | 32 | for (let i = 0; i < matches.length; i++) 33 | replacement = replacement.replace( 34 | new RegExp("\\$" + i, "g"), 35 | matches[i] 36 | ); 37 | replacement = replacement.replace(/\$name/g, message.from.first_name); 38 | replacement = replacement.replace(/\$username/g, message.from.username); 39 | replacement = replacement.replace(/\$text/g, message.text); 40 | 41 | this.sendMessage(message.chat.id, replacement); 42 | } 43 | } 44 | 45 | onCommand({message, command, args}) { 46 | switch (command) { 47 | case "regexdelete": 48 | if (!this.auth.isChatAdmin(message.from.id, message.chat.id)) 49 | return "Insufficient privileges (chat admin required)."; 50 | return this.regexdelete(args, message.chat.id); 51 | case "regexlist": 52 | return this.regexlist(message.chat.id); 53 | case "regexset": { 54 | if (!this.auth.isChatAdmin(message.from.id, message.chat.id)) 55 | return "Insufficient privileges (chat admin required)."; 56 | return this.regexset(args, message.chat.id); 57 | } 58 | } 59 | } 60 | 61 | regexset(args, chatID) { 62 | const helpText = "Syntax: `/regexset /regex/flags replacement` (see `/help RegexSet` for more information)"; 63 | if (args.length < 2) 64 | return helpText; 65 | const replacement = args.pop(); 66 | const literalRegex = args.join(" "); 67 | 68 | const metaRegex = /^\/(.+)\/([a-z]*)$/i; // Regex for a valid regex 69 | if (!metaRegex.test(literalRegex)) 70 | return helpText; 71 | 72 | const [regexBody, flags] = literalRegex.match(metaRegex).slice(1); 73 | 74 | try { 75 | RegExp(regexBody, flags); 76 | } catch (e) { 77 | return "Cannot compile regular expression: " + e; 78 | } 79 | 80 | if (!safe(regexBody)) 81 | return "That regular expression seems to be inefficient."; 82 | 83 | this.db.replacements.push({regex: regexBody, text: replacement, flags, chatID}); 84 | return "Done."; 85 | } 86 | 87 | regexlist(chatID) { 88 | const string = this.db.replacements 89 | .filter(item => item.chatID === chatID) 90 | .map(item => ` - /${item.regex}/${item.flags} -> ${item.text}`) 91 | .join("\n") || "No items set for this chat."; 92 | return string + "\n\nTo delete a regular expression, use /regexdelete /regex/flags."; 93 | } 94 | 95 | regexdelete(args, chatID) { 96 | if (args.length === 0) 97 | return "Syntax: /regexdelete /regex/flags"; 98 | 99 | const literalRegex = args.join(" "); 100 | 101 | const metaRegex = /^\/(.+)\/([a-z]*)$/i; // Regex for a valid regex 102 | if (!metaRegex.test(literalRegex)) 103 | return "Syntax: /regexdelete /regex/flags"; 104 | 105 | const [regexBody, flags] = literalRegex.match(metaRegex).slice(1); 106 | 107 | const find = obj => (obj.regex === regexBody) && (obj.flags === flags) && (obj.chatID === chatID); 108 | if (!this.db.replacements.some(find)) 109 | return "No such expression."; 110 | const i = this.db.replacements.findIndex(obj => find(obj)); 111 | 112 | this.db.replacements.splice(i, 1); 113 | return "Deleted."; 114 | } 115 | }; -------------------------------------------------------------------------------- /src/plugins/Remind.js: -------------------------------------------------------------------------------- 1 | const Plugin = require("./../Plugin"); 2 | const Scheduler = require("./../helpers/Scheduler.js"); 3 | 4 | module.exports = class Remind extends Plugin { 5 | static get plugin() { 6 | return { 7 | name: "Remind", 8 | description: "Reminds you of upcoming events.", 9 | help: ` - '/remind date event' (eg. '/remind 16/01/18 Call Bob', day comes before month) 10 | - '/remind time event' (eg. '/remind 18:00 Call Bob'. Please use a 24-hour format, not am/pm!) 11 | - '/remind date time event' (eg. '/remind 16/01/18 18:00 Call Bob'. Please use a 24-hour format, not am/pm!) 12 | ${"[TODO: enable in the future] - '/remind delay event' (eg. '/remind 2h30m Call Bob')"} 13 | 14 | Use /remindlist for a list of upcoming events.` 15 | }; 16 | } 17 | 18 | constructor(obj) { 19 | super(obj); 20 | Scheduler.on("reminder", evt => { 21 | if (evt.plugin !== "Remind") return; 22 | this.sendMessage(evt.chat, `Time's up: ${evt.text}`); 23 | }); 24 | } 25 | 26 | onCommand({message, command, args}) { 27 | switch (command) { 28 | case "remindlist": 29 | return Scheduler.events 30 | .filter(it => it.metadata.plugin === "Remind") 31 | .filter(it => it.metadata.chat === message.chat.id) 32 | .map(it => `${new Date(it.date).toLocaleString("it-IT")}: ${it.metadata.text}`) 33 | .join("\n") || "None."; 34 | case "remind": 35 | // To reduce indentation 36 | return this.remindHandler({message, command, args}); 37 | } 38 | } 39 | 40 | remindHandler({message, args}) { 41 | if (args.length < 2) 42 | return "Invalid command (see /help Remind for usage)." 43 | const delayString = args.shift(); 44 | const now = new Date(); 45 | let date; 46 | const hourRegex = /^([012]?\d):([012345]\d)$/; 47 | const dateRegex = /^([0123]\d)[-/]([01]\d)[-/](?:20)?(\d\d)$/; 48 | if (hourRegex.test(delayString)) { 49 | const [hours, minutes] = delayString.match(hourRegex).slice(1); 50 | date = new Date(); 51 | date.setHours(hours, minutes, 0); 52 | } else if (dateRegex.test(delayString)) { 53 | const [day, month, year] = delayString.match(dateRegex).slice(1); 54 | date = new Date(); 55 | date.setFullYear(2000 + Number(year), Number(month) - 1, day); 56 | if (hourRegex.test(args[0])) { 57 | const hourString = args.shift(); 58 | const [hours, minutes] = hourString.match(hourRegex).slice(1); 59 | date = new Date(); 60 | date.setHours(hours, minutes, 0); 61 | } 62 | } 63 | if (date < now) 64 | return "Can't set events in the past!"; 65 | Scheduler.scheduleOneoff("reminder", { 66 | plugin: "Remind", 67 | chat: message.chat.id, 68 | text: args.join(" ") 69 | }, date); 70 | // Use your own language here! 71 | // This was made for Italian chat groups, so it uses the Italian localization. 72 | return `Scheduled for ${date.toLocaleString("it-IT")}.`; 73 | } 74 | }; -------------------------------------------------------------------------------- /src/plugins/Reverse.js: -------------------------------------------------------------------------------- 1 | const Plugin = require("./../Plugin"); 2 | 3 | module.exports = class Reverse extends Plugin { 4 | static get plugin() { 5 | return { 6 | name: "Reverse", 7 | description: "Reverses inline messages." 8 | }; 9 | } 10 | 11 | onInlineCommand({message, command, args}) { 12 | if (command !== "reverse") return; 13 | const text = args.join(" ").split("").reverse().join(""); 14 | this.answerInlineQuery(message.id, [{ 15 | type: "article", 16 | id: "1", 17 | title: text, 18 | input_message_content: { 19 | message_text: text 20 | } 21 | }]); 22 | } 23 | }; -------------------------------------------------------------------------------- /src/plugins/Roll.js: -------------------------------------------------------------------------------- 1 | // Author: Cristian Achille 2 | // Date: 22-10-2016 3 | 4 | const Plugin = require("../Plugin"); 5 | 6 | module.exports = class Roll extends Plugin { 7 | static get plugin() { 8 | return { 9 | name: "Roll", 10 | description: "Test your luck with this fancy plugin", 11 | help: ` command: 12 | \`/roll NdM\` rolls \`N\` dices of \`M\` faces 13 | example: ^/roll 1d6^` 14 | }; 15 | } 16 | 17 | onCommand({command, args}) { 18 | if (command !== "settitle") return; 19 | if (args.length === 0) return "You can't roll the air, give me something! (example: /roll 1d6)"; 20 | const parts = args[0].split("d"); 21 | const n = Number(parts[0]); 22 | const m = Number(parts[1]); 23 | 24 | let result = ""; 25 | for (let i = 0; i < n; i++) 26 | result += (Math.floor(Math.random() * m) + 1) + " "; 27 | 28 | return result; 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /src/plugins/Rule34.js: -------------------------------------------------------------------------------- 1 | const Plugin = require("../Plugin"); 2 | const request = require("request-promise-native"); 3 | 4 | module.exports = class Rule34 extends Plugin { 5 | static get plugin() { 6 | return { 7 | name: "Rule34", 8 | description: "If it exists, there's porn of it.", 9 | help: "/rule34 " 10 | }; 11 | } 12 | 13 | async onCommand({command, args}) { 14 | if (command !== "rule34") return; 15 | if (args.length === 0) 16 | return "Please enter a search query."; 17 | const query = args.join("+"); 18 | this.log.debug(`Search query: ${query}`); 19 | 20 | this.log.debug(`URL: https://rule34.xxx/index.php?page=dapi&s=post&q=index&limit=1&tags=${encodeURIComponent(query)}`); 21 | 22 | const data = await request(`https://rule34.xxx/index.php?page=dapi&s=post&q=index&limit=1&tags=${encodeURIComponent(query).replace("%2B", "+")}`); 23 | this.log.debug(`Received: ${data}`); 24 | 25 | const regexp = /file_url="(?:https?:)?(\/\/img\.rule34\.xxx\/images\/\d+\/[0-9a-f]+\.\w+)"/i; 26 | 27 | const imgurlarr = data.match(regexp); 28 | 29 | this.log.debug("Matches: " + JSON.stringify(imgurlarr)); 30 | 31 | if (imgurlarr === null || imgurlarr.length === 0) 32 | return "No results found."; 33 | 34 | const target = "https:" + imgurlarr[1]; 35 | 36 | this.log.debug(`Target URL: ${target}`); 37 | 38 | return { 39 | type: "photo", 40 | photo: target 41 | }; 42 | } 43 | }; -------------------------------------------------------------------------------- /src/plugins/Set.js: -------------------------------------------------------------------------------- 1 | const Plugin = require("./../Plugin"); 2 | 3 | module.exports = class Set extends Plugin { 4 | constructor(obj) { 5 | super(obj); 6 | 7 | if (!this.db.replacements) { 8 | this.db.replacements = []; 9 | } 10 | } 11 | 12 | static get plugin() { 13 | return { 14 | name: "Set", 15 | description: "Trigger bot responses whenever someone says a specific sentence.", 16 | help: "`/set ` to set a trigger, `/unset ` to delete it." 17 | }; 18 | } 19 | 20 | onText({message}) { 21 | const chatID = message.chat.id; 22 | 23 | for (const item of this.db.replacements) { 24 | if (chatID !== item.chatID) continue; 25 | if (!message.text.startsWith(item.trigger)) continue; 26 | this.sendMessage(message.chat.id, item.replacement); 27 | } 28 | } 29 | 30 | onCommand({message, command, args}) { 31 | const chatID = message.chat.id; 32 | switch (command) { 33 | case "set": { 34 | if (args.length < 2) return "Syntax: `/set `"; 35 | 36 | const trigger = args.shift(); 37 | const replacement = args.join(" "); 38 | this.db.replacements.push({trigger, replacement, chatID}); 39 | return "Done."; 40 | } 41 | case "unset": { 42 | const trigger = args[0]; 43 | // Take only replacements with either a different chat id or a different trigger 44 | this.db.replacements = this.db.replacements.filter(item => (item.chatID !== chatID) || (item.trigger !== trigger)); 45 | return "Done."; 46 | } 47 | case "get": { 48 | let text = ""; 49 | for (const item of this.db.replacements) { 50 | if (item.chatID !== chatID) continue; 51 | text += `${item.trigger} => ${item.replacement}\n`; 52 | } 53 | return (text === "") ? "No triggers set." : text; 54 | } 55 | } 56 | } 57 | }; -------------------------------------------------------------------------------- /src/plugins/SetPicture.js: -------------------------------------------------------------------------------- 1 | const os = require("os"); 2 | const Plugin = require("../Plugin"); 3 | 4 | module.exports = class SetPicture extends Plugin { 5 | constructor(obj) { 6 | super(obj); 7 | 8 | this.auth = obj.auth; 9 | } 10 | 11 | static get plugin() { 12 | return { 13 | name: "SetPicture", 14 | description: "Sets the chat's picture.", 15 | help: "Send a picture with the caption /setpicture to set the chat's picture." 16 | }; 17 | } 18 | 19 | onCommand({message, command}) { 20 | if (command !== "setpicture") return; 21 | if (!message.reply_to_message || !message.reply_to_message.photo) 22 | return "Reply to a picture with the caption /setpicture to set the chat's picture."; 23 | if (!this.auth.isChatAdmin(message.from.id, message.chat.id)) 24 | return "Insufficient privileges (chat admin required)."; 25 | return this.setPhoto(message.reply_to_message); 26 | } 27 | 28 | onPhoto({message}) { 29 | if (!message.caption) 30 | return; 31 | if (!message.caption.startsWith("/setpicture")) 32 | return; 33 | if (!this.auth.isChatAdmin(message.from.id, message.chat.id)) 34 | return "Insufficient privileges (chat admin required)."; 35 | return this.setPhoto(message); 36 | } 37 | 38 | async setPhoto(message) { 39 | const fileId = message.photo[message.photo.length - 1].file_id; 40 | const path = await this.downloadFile(fileId, os.tmpdir()) 41 | try { 42 | await this.setChatPhoto(message.chat.id, path); 43 | } catch (e) { 44 | return "Couldn't set the chat picture."; 45 | } 46 | } 47 | }; 48 | -------------------------------------------------------------------------------- /src/plugins/SetTitle.js: -------------------------------------------------------------------------------- 1 | const Plugin = require("../Plugin"); 2 | 3 | module.exports = class SetTitle extends Plugin { 4 | constructor(obj) { 5 | super(obj); 6 | 7 | this.auth = obj.auth; 8 | } 9 | 10 | static get plugin() { 11 | return { 12 | name: "SetTitle", 13 | description: "Sets the chat's title.", 14 | help: "Syntax: /settitle " 15 | }; 16 | } 17 | 18 | async onCommand({message, command, args}) { 19 | if (command !== "settitle") return; 20 | if (!this.auth.isChatAdmin(message.from.id, message.chat.id)) 21 | return "Insufficient privileges (chat admin required)."; 22 | if (args.length === 0) 23 | return "Syntax: /settitle <title>"; 24 | const title = args.join(" "); 25 | try { 26 | await this.setChatTitle(message.chat.id, title); 27 | } catch (e) { 28 | return "Couldn't set the chat title!"; 29 | } 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /src/plugins/Spoiler.js: -------------------------------------------------------------------------------- 1 | const Plugin = require("./../Plugin"); 2 | const prefixRegex = /^spoiler /; 3 | 4 | module.exports = class Spoiler extends Plugin { 5 | static get plugin() { 6 | return { 7 | name: "Spoiler", 8 | description: "Use this plugin to send spoilers!", 9 | help: "This is an inline plugin. Type the bot's username, followed by \"spoiler <your text>\". You can hide only *parts of the message* enclosing them in asterisks.\n\nFor instance, \"@bot spoiler The protagonist *dies in the fourth season*.\"" 10 | }; 11 | } 12 | 13 | onInlineCommand({message, command, args}) { 14 | if (command !== "spoiler") return; 15 | if (args.length === 0) { 16 | const usage = "@bot spoiler The protagonist *dies in the fourth season*."; 17 | this.answerInlineQuery(message.id, [ 18 | {id: "0", type: "article", title: "Usage", description: usage, message_text: "Usage: " + usage} 19 | ]); 20 | return; 21 | } 22 | const text = args.join(" "); 23 | const spoilerRegex = /\*[^\*]+\*/g; 24 | 25 | this.answerInlineQuery(message.id, [ 26 | { 27 | id: "0", 28 | type: "article", 29 | title: "Send spoiler", 30 | message_text: spoilerRegex.test(text) ? text.replace(/\*[^\*]+\*/g, "SPOILER") : "SPOILER", 31 | reply_markup: {inline_keyboard: [[{ 32 | text: "Reveal spoiler", 33 | callback_data: "spoiler " + text.replace(/\*/g, "") 34 | }]]} 35 | } 36 | ]); 37 | } 38 | 39 | onCallbackQuery({message}) { 40 | if (!prefixRegex.test(message.data)) 41 | return; 42 | this.answerCallbackQuery({ 43 | callback_query_id: message.id, 44 | text: message.data.replace(prefixRegex, ""), 45 | show_alert: true, 46 | cache_time: 600 47 | }); 48 | } 49 | }; -------------------------------------------------------------------------------- /src/plugins/Text.js: -------------------------------------------------------------------------------- 1 | const Plugin = require("./../Plugin"); 2 | 3 | const smallCaps = {a: "\u1d00", A: "\u1d00", b: "\u0299", B: "\u0299", c: "\u1d04", C: "\u1d04", d: "\u1d05", D: "\u1d05", e: "\u1d07", E: "\u1d07", f: "\ua730", F: "\ua730", g: "\u0262", G: "\u0262", h: "\u029c", H: "\u029c", i: "\u026a", I: "\u026a", j: "\u1d0a", J: "\u1d0a", k: "\u1d0b", K: "\u1d0b", l: "\u029f", L: "\u029f", m: "\u1d0d", M: "\u1d0d", n: "\u0274", N: "\u0274", o: "\u1d0f", O: "\u1d0f", p: "\u1d29", P: "\u1d29", q: "q", Q: "Q", r: "\u0280", R: "\u0280", s: "\ua731", S: "\ua731", t: "\u1d1b", T: "\u1d1b", u: "\u1d1c", U: "\u1d1c", v: "\u1d20", V: "\u1d20", w: "\u1d21", W: "\u1d21", x: "x", X: "x", y: "y", Y: "Y", z: "\u1d22", Z: "\u1d22"}; 4 | const circled = {a: "\u24d0", A: "\u24b6", b: "\u24d1", B: "\u24b7", c: "\u24d2", C: "\u24b8", d: "\u24d3", D: "\u24b9", e: "\u24d4", E: "\u24ba", f: "\u24d5", F: "\u24bb", g: "\u24d6", G: "\u24bc", h: "\u24d7", H: "\u24bd", i: "\u24d8", I: "\u24be", j: "\u24d9", J: "\u24bf", k: "\u24da", K: "\u24c0", l: "\u24db", L: "\u24c1", m: "\u24dc", M: "\u24c2", n: "\u24dd", N: "\u24c3", o: "\u24de", O: "\u24c4", p: "\u24df", P: "\u24c5", q: "\u24e0", Q: "\u24c6", r: "\u24e1", R: "\u24c7", s: "\u24e2", S: "\u24c8", t: "\u24e3", T: "\u24c9", u: "\u24e4", U: "\u24ca", v: "\u24e5", V: "\u24cb", w: "\u24e6", W: "\u24cc", x: "\u24e7", X: "\u24cd", y: "\u24e8", Y: "\u24ce", z: "\u24e9", Z: "\u24cf"}; 5 | const fullWidth = {a: "\uff41", A: "\uff21", b: "\uff42", B: "\uff22", c: "\uff43", C: "\uff23", d: "\uff44", D: "\uff24", e: "\uff45", E: "\uff25", f: "\uff46", F: "\uff26", g: "\uff47", G: "\uff27", h: "\uff48", H: "\uff28", i: "\uff49", I: "\uff29", j: "\uff4a", J: "\uff2a", k: "\uff4b", K: "\uff2b", l: "\uff4c", L: "\uff2c", m: "\uff4d", M: "\uff2d", n: "\uff4e", N: "\uff2e", o: "\uff4f", O: "\uff2f", p: "\uff50", P: "\uff30", q: "\uff51", Q: "\uff31", r: "\uff52", R: "\uff32", s: "\uff53", S: "\uff33", t: "\uff54", T: "\uff34", u: "\uff55", U: "\uff35", v: "\uff56", V: "\uff36", w: "\uff57", W: "\uff37", x: "\uff58", X: "\uff38", y: "\uff59", Y: "\uff39", z: "\uff5a", Z: "\uff3a"}; 6 | const letterSmall = {a: "\u1d43", A: "\u1d2c", b: "\u1d47", B: "\u1d2e", c: "\u1d9c", C: "C", d: "\u1d48", D: "\u1d30", e: "\u1d49", E: "\u1d31", f: "\u1da0", F: "F", g: "\u1d4d", G: "\u1d33", h: "\u02b0", H: "\u1d34", i: "\u1da4", I: "\u1d35", j: "\u02b2", J: "\u1d36", k: "\u1d4f", K: "\u1d37", l: "\u02e1", L: "\u1dab", m: "\u1d50", M: "\u1d39", n: "\u1daf", N: "\u1db0", o: "\u1d52", O: "\u1d3c", p: "\u1d56", P: "\u1d3e", q: "q", Q: "Q", r: "\u02b3", R: "\u1d3f", s: "\u02e2", S: "S", t: "\u1d57", T: "\u1d40", u: "\u1d58", U: "\u1d41", v: "\u1d5b", V: "\u2c7d", w: "\u02b7", W: "X", x: "\u02e3", X: "\u1d42", y: "\u02b8", Y: "Y", z: "\u1dbb", Z: "Z"}; 7 | const upsideDown = {a: "\u0250", A: "\u0250", b: "q", B: "q", c: "\u0254", C: "\u0254", d: "p", D: "p", e: "\u01dd", E: "\u01dd", f: "\u025f", F: "\u025f", g: "\u0183", G: "\u0183", h: "\u0265", H: "\u0265", i: "\u0131", I: "\u0131", j: "\u027e", J: "\u027e", k: "\u029e", K: "\u029e", l: "\u05df", L: "\u05df", m: "\u026f", M: "\u026f", n: "u", N: "u", o: "o", O: "o", p: "d", P: "d", q: "b", Q: "b", r: "\u0279", R: "\u0279", s: "s", S: "s", t: "\u0287", T: "\u0287", u: "n", U: "n", v: "\u028c", V: "\u028c", w: "\u028d", W: "\u028d", x: "x", X: "\u028d", y: "\u028e", Y: "x", z: "z", Z: "z"}; 8 | 9 | function translateText(text, dict) { 10 | let string = ""; 11 | for (let i = 0; i < text.length; i++) 12 | string += dict[text[i]] || text[i]; 13 | return string; 14 | } 15 | 16 | module.exports = class Text extends Plugin { 17 | static get plugin() { 18 | return { 19 | name: "Text", 20 | description: "Unicode magic on any text." 21 | }; 22 | } 23 | 24 | onInlineCommand({message, command, args}) { 25 | if (command !== "text") return; 26 | const text = args.join(" "); 27 | 28 | const textSmallCaps = translateText(text, smallCaps); 29 | const textCircled = translateText(text, circled); 30 | const textFullWidth = translateText(text, fullWidth); 31 | const textLetterSmall = translateText(text, letterSmall); 32 | const textUpsideDown = translateText(text, upsideDown); 33 | 34 | this.answerInlineQuery(message.id, [ 35 | {id: "0", type: "article", message_text: textSmallCaps, title: textSmallCaps}, 36 | {id: "1", type: "article", message_text: textCircled, title: textCircled}, 37 | {id: "2", type: "article", message_text: textFullWidth, title: textFullWidth}, 38 | {id: "3", type: "article", message_text: textLetterSmall, title: textLetterSmall}, 39 | {id: "4", type: "article", message_text: textUpsideDown, title: textUpsideDown} 40 | ]); 41 | } 42 | }; -------------------------------------------------------------------------------- /src/plugins/UrbanDictionary.js: -------------------------------------------------------------------------------- 1 | const Plugin = require("../Plugin"); 2 | const Util = require("./../Util"); 3 | const request = require("request-promise-native"); 4 | 5 | module.exports = class UrbanDictionary extends Plugin { 6 | static get plugin() { 7 | return { 8 | name: "UrbanDictionary", 9 | description: "Fetches the Urban Dictionary definition for a supplied word or phrase.", 10 | help: "`/ud <query>` returns the definition for a given word or phrase" 11 | }; 12 | } 13 | 14 | async onCommand({command, args}) { 15 | if (command !== "ud") return; 16 | if (args.length === 0) 17 | return "Please provide a search term after /ud."; 18 | 19 | const query = args.join(" "); 20 | 21 | const response = await request(`http://api.urbandictionary.com/v0/define?term=${encodeURIComponent(query)}`); 22 | const data = JSON.parse(response); 23 | if (data.result_type === "no_results") 24 | return `Sorry, I was unable to find results for "${args.join(" ")}".`; 25 | /* data.list will be an array, it seems like typically 26 | the most popular defintion is at the zero index though. 27 | */ 28 | const def = data.list[0]; 29 | return { 30 | type: "text", 31 | text: `<b>${Util.escapeHTML(def.word)}</b>: ` 32 | + `${Util.escapeHTML(def.definition)}\n\n` 33 | + `<i>${Util.escapeHTML(def.example)}</i>`, 34 | options: { 35 | parse_mode: "HTML" 36 | } 37 | }; 38 | } 39 | }; -------------------------------------------------------------------------------- /src/plugins/UserInfo.js: -------------------------------------------------------------------------------- 1 | const Plugin = require("./../Plugin"); 2 | const Util = require("./../Util"); 3 | 4 | module.exports = class UserInfo extends Plugin { 5 | static get plugin() { 6 | return { 7 | name: "UserInfo", 8 | description: "Log usernames and user IDs", 9 | help: "Syntax: `/id user`", 10 | 11 | isProxy: true 12 | }; 13 | } 14 | 15 | constructor(obj) { 16 | super(obj); 17 | 18 | if (!this.db) 19 | this.db = {}; 20 | Util.nameResolver.setDb(this.db); 21 | this.auth = obj.auth; 22 | } 23 | 24 | proxy(eventName, message) { 25 | // Discard inline messages 26 | if (!message.chat) return; 27 | 28 | if (message.from.username) { 29 | const username = message.from.username; 30 | const userID = message.from.id; 31 | this.log.debug(`ID ${userID} mapped to username ${username}`); 32 | this.db[userID] = username; 33 | } 34 | 35 | // Register people who join or leave, too. 36 | if (message.new_chat_participant || message.left_chat_participant) { 37 | const source = message.new_chat_participant ? 38 | message.new_chat_participant : 39 | message.left_chat_participant; 40 | this.log.debug(`ID ${source.id} mapped to username ${source.username}`); 41 | this.db[source.id] = source.username; 42 | } 43 | 44 | // Util.nameResolver.setDb(this.db); 45 | return Promise.resolve(); 46 | } 47 | 48 | onCommand({message, command, args}) { 49 | if (command !== "id") return; 50 | 51 | if (args.length === 1) { 52 | const input = args[0]; 53 | if (/^@/.test(input)) { 54 | const username = input.replace("@", ""); 55 | return this.printFromUsername(username, message.chat.id); 56 | } 57 | const userID = input; 58 | return this.printFromID(userID, message.chat.id); 59 | } 60 | 61 | if (message.reply_to_message) { 62 | let userID; 63 | if (message.reply_to_message.new_chat_participant) 64 | userID = message.reply_to_message.new_chat_participant.id; 65 | else if (message.reply_to_message.left_chat_participant) 66 | userID = message.reply_to_message.left_chat_participant.id; 67 | else 68 | userID = message.reply_to_message.from.id; 69 | 70 | return this.printFromID(userID, message.chat.id); 71 | } 72 | 73 | return "Syntax: /id @username or /id userID"; 74 | } 75 | 76 | printFromUsername(username, chatID) { 77 | const userID = Util.nameResolver.getUserIDFromUsername(username); 78 | if (!userID) 79 | return "No such user."; 80 | const isOwner = this.auth.isOwner(userID, chatID); 81 | const isChatAdmin = this.auth.isChatAdmin(userID, chatID); 82 | 83 | return UserInfo.print(username, userID, isChatAdmin, isOwner); 84 | } 85 | 86 | printFromID(userID, chatID) { 87 | const username = Util.nameResolver.getUsernameFromUserID(userID); 88 | const isOwner = this.auth.isOwner(userID, chatID); 89 | const isChatAdmin = this.auth.isChatAdmin(userID, chatID); 90 | 91 | return UserInfo.print(username, userID, isChatAdmin, isOwner); 92 | } 93 | 94 | static print(username, userID, isChatAdmin, isOwner) { 95 | return { 96 | type: "text", 97 | text: `*Username*: ${username ? ("\`@" + username + "\`") : "none"} 98 | *User ID*: \`${userID}\` 99 | *Chat admin*? ${isChatAdmin ? "yes" : "no"} 100 | *Owner*? ${isOwner ? "yes" : "no"}`, 101 | options: { 102 | parse_mode: "Markdown" 103 | } 104 | }; 105 | } 106 | }; -------------------------------------------------------------------------------- /src/plugins/UserStats.js: -------------------------------------------------------------------------------- 1 | const Plugin = require("./../Plugin"); 2 | 3 | function wordCount(text) { 4 | const words = text.trim().replace(/\s+/gi, " ").split(" "); 5 | // This checks if the first element of the `words` array is there, and 6 | // if it is truthy (an empty string, which is the case when `text` is 7 | // empty or contains only spaces, will not trigger this, returning 0). 8 | if (words[0]) 9 | return words.length; 10 | return 0; 11 | } 12 | 13 | module.exports = class UserStats extends Plugin { 14 | static get plugin() { 15 | return { 16 | name: "UserStats", 17 | description: "Get user stats on message count.", 18 | help: "Enter /userstats to get statistics." 19 | }; 20 | } 21 | 22 | onText({message}) { 23 | // Reject inline messages 24 | if (!message.chat) return; 25 | 26 | if (!message.from.username) return; 27 | 28 | const chatId = message.chat.id; 29 | const userId = message.from.id; 30 | if (!this.db["stat" + chatId]) { 31 | this.db["stat" + chatId] = {}; 32 | this.db["stat" + chatId].totalMessageCount = 0; 33 | this.db["stat" + chatId].totalWordCount = 0; 34 | } 35 | if (!this.db["stat" + chatId][userId]) { 36 | this.db["stat" + chatId][userId] = { 37 | userId, 38 | username: message.from.username, 39 | messageCount: 0, 40 | wordCount: 0 41 | }; 42 | } 43 | this.db["stat" + chatId][userId].messageCount++; 44 | this.db["stat" + chatId].totalMessageCount++; 45 | 46 | if (message.text) { 47 | const wc = wordCount(message.text); 48 | if (!this.db["stat" + chatId].totalWordCount) { 49 | this.db["stat" + chatId].totalWordCount = 0; 50 | } 51 | this.db["stat" + chatId].totalWordCount += wc; 52 | if (!this.db["stat" + chatId][userId].wordCount) { 53 | this.db["stat" + chatId][userId].wordCount = 0; 54 | } 55 | this.db["stat" + chatId][userId].wordCount += wc; 56 | } 57 | } 58 | 59 | onCommand({message, command}) { 60 | switch (command) { 61 | case "userstats": { 62 | const statsObject = this.db["stat" + message.chat.id]; 63 | const totalCount = statsObject.totalMessageCount; 64 | const userList = Object.keys(statsObject) 65 | .map(item => statsObject[item]) 66 | .filter(item => typeof item === "object") 67 | .sort((a, b) => b.messageCount - a.messageCount); 68 | 69 | return "Total messages:\n\n" + userList.map(user => { 70 | const percentage = (user.messageCount / totalCount * 100).toFixed(4); 71 | return `${user.username}: ${user.messageCount} (${percentage}%)`; 72 | }).join("\n"); 73 | } 74 | case "wordstats": { 75 | const statsObject = this.db["stat" + message.chat.id]; 76 | const userList = Object.keys(statsObject) 77 | .map(item => statsObject[item]) 78 | .filter(item => typeof item === "object") 79 | .sort((a, b) => b.wordCount - a.wordCount); 80 | 81 | return "Total messages:\n\n" + userList.map(user => { 82 | const averageWords = (user.wordCount / user.messageCount).toFixed(4); 83 | return `${user.username}: ${user.wordCount} words (${averageWords} words/message)`; 84 | }).join("\n"); 85 | } 86 | } 87 | } 88 | }; 89 | -------------------------------------------------------------------------------- /src/plugins/Version.js: -------------------------------------------------------------------------------- 1 | /* eslint no-sync: 0 */ 2 | const fs = require("fs"); 3 | const path = require("path"); 4 | const Plugin = require("./../Plugin"); 5 | 6 | const githubURL = "https://github.com/Telegram-Bot-Node/Nikoro"; 7 | let commit = ""; 8 | if (fs.existsSync(path.join(__dirname, "../../.git"))) { 9 | const branchRef = fs.readFileSync(path.join(__dirname, "../../.git/HEAD"), "utf8").replace(/^ref: /, "").replace(/\n$/, ""); 10 | commit = fs.readFileSync(path.join(__dirname, "../../.git", branchRef), "utf8").substr(0, 7); 11 | } 12 | 13 | module.exports = class Ping extends Plugin { 14 | static get plugin() { 15 | return { 16 | name: "Version", 17 | description: "Displays useful informations about the bot.", 18 | help: "Use /version." 19 | }; 20 | } 21 | 22 | onCommand({command}) { 23 | if (command !== "version") return; 24 | return { 25 | type: "text", 26 | text: `*Nikoro* v${require("../../package.json").version} ${commit} 27 | An open source, plugin-based Telegram bot written in Node.js. MIT licensed. 28 | [Fork me on GitHub!](${githubURL})`, 29 | options: { 30 | disable_web_page_preview: true, 31 | parse_mode: "Markdown" 32 | } 33 | }; 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /src/plugins/Vote.js: -------------------------------------------------------------------------------- 1 | const Plugin = require("../Plugin"); 2 | 3 | module.exports = class Vote extends Plugin { 4 | constructor(obj) { 5 | super(obj); 6 | 7 | if (!this.db.votes) { 8 | this.db.votes = {}; 9 | } 10 | } 11 | 12 | static get plugin() { 13 | return { 14 | name: "Vote", 15 | description: "A simple vote plugin.", 16 | help: `\`/vote <option>\` to vote for an option 17 | \`/clearvote\` to clear the current vote 18 | \`/setvote <topic>\` to set the current topic for the vote 19 | \`/getvote\` or \`/voteresults\` to get info and results about the current vote.` 20 | }; 21 | } 22 | 23 | onCommand({message, command, args}) { 24 | const chatID = message.chat.id; 25 | switch (command) { 26 | case "vote": 27 | if (args.length === 0) return "Syntax: `/vote <option>`"; 28 | if (!this.db.votes[chatID]) return "There is no vote at this time."; 29 | 30 | this.db.votes[chatID].results[message.from.username] = args.join(" "); 31 | return "Your vote has been registered."; 32 | case "clearvote": 33 | delete this.db.votes[message.chat.id]; 34 | return "Question cleared."; 35 | case "setvote": { 36 | if (args.length === 0) return "Please specify a question."; 37 | const question = args.join(" "); 38 | 39 | // Note that previous results are automatically removed 40 | this.db.votes[message.chat.id] = { 41 | text: question, 42 | results: {} 43 | }; 44 | 45 | return "Question set."; 46 | } 47 | case "getvote": { 48 | if (!this.db.votes[chatID]) return "There is no vote at this time."; 49 | 50 | const poll = this.db.votes[chatID]; 51 | const totalVotes = Object.keys(poll.results).length; 52 | let response = `Question: ${poll.text}\n` + 53 | `Vote count: ${totalVotes}\n\n`; 54 | 55 | const uniqueAnswers = new Set(); 56 | for (const user in poll.results) { 57 | if (!poll.results.hasOwnProperty(user)) continue; 58 | uniqueAnswers.add(poll.results[user]); 59 | } 60 | 61 | // Map<Answer, Array<Users>> 62 | const answerToUsersMap = new Map(); 63 | for (const answer of uniqueAnswers) { 64 | const users = []; 65 | // Which users voted for this answer? 66 | for (const user in poll.results) { 67 | if (!poll.results.hasOwnProperty(user)) continue; 68 | if (poll.results[user] !== answer) continue; 69 | users.push(user); 70 | } 71 | answerToUsersMap.set(answer, users); 72 | } 73 | 74 | // Sort the map (https://stackoverflow.com/a/31159284) by user array length 75 | const sortedMap = new Map([...answerToUsersMap.entries()] 76 | // eslint-disable-next-line no-unused-vars 77 | .sort(([[answerA, usersA], [answerB, usersB]]) => usersA.length - usersB.length)); 78 | 79 | sortedMap.forEach((users, answer) => { 80 | const votes = users.length; 81 | const percentage = (100 * votes / totalVotes).toFixed(2); 82 | response += `${answer}: ${votes} votes (${percentage}%)\n` + users.join(", ") + "\n"; 83 | }); 84 | 85 | return response; 86 | } 87 | } 88 | } 89 | }; -------------------------------------------------------------------------------- /src/plugins/Welcome.js: -------------------------------------------------------------------------------- 1 | const Plugin = require("./../Plugin"); 2 | 3 | module.exports = class Welcome extends Plugin { 4 | static get plugin() { 5 | return { 6 | name: "Welcome", 7 | description: "Says welcome and goodbye to users when they join or leave a group." 8 | }; 9 | } 10 | 11 | onNewChatMembers({message}) { 12 | if (!this.db[message.chat.id]) this.db[message.chat.id] = []; 13 | return "Welcome " + 14 | message.new_chat_members 15 | .map(m => m.username ? `@${m.username}` : `${m.first_name}`) 16 | .join(", ") + 17 | "!"; 18 | } 19 | 20 | onLeftChatMember({message}) { 21 | if (!this.db[message.chat.id]) this.db[message.chat.id] = []; 22 | return "Goodbye " + 23 | (message.left_chat_member.username ? 24 | `@${message.left_chat_member.username}` : 25 | `${message.left_chat_member.first_name}`) + 26 | "!"; 27 | } 28 | }; -------------------------------------------------------------------------------- /src/plugins/Wikipedia.js: -------------------------------------------------------------------------------- 1 | const Plugin = require("../Plugin"); 2 | const wiki = require("wikijs").default(); 3 | const Util = require("./../Util"); 4 | 5 | module.exports = class Wikipedia extends Plugin { 6 | static get plugin() { 7 | return { 8 | name: "Wikipedia", 9 | description: "Search articles on Wikipedia.", 10 | help: "/wiki query" 11 | }; 12 | } 13 | 14 | async onCommand({command, args}) { 15 | if (command !== "wiki") return; 16 | const query = args.join(" "); 17 | const page = await wiki.page(query); 18 | const title = page.raw.title; 19 | const summary = await page.summary(); 20 | return { 21 | type: "text", 22 | text: `<b>${Util.escapeHTML(title)}</b>\n\n${Util.escapeHTML(summary)}`, 23 | options: { 24 | parse_mode: "HTML" 25 | } 26 | }; 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /src/plugins/Wordgame.js: -------------------------------------------------------------------------------- 1 | const Plugin = require("./../Plugin"); 2 | 3 | const wordlist = Object.values(require("diceware-wordlist-en-eff")); 4 | 5 | function getRandomWord() { 6 | return wordlist[Math.floor(Math.random() * wordlist.length)]; 7 | } 8 | 9 | // https://stackoverflow.com/a/12646864 10 | function shuffleWord(word) { 11 | const array = word.split(""); 12 | for (let i = array.length - 1; i > 0; i--) { 13 | const j = Math.floor(Math.random() * (i + 1)); 14 | [array[i], array[j]] = [array[j], array[i]]; 15 | } 16 | return array.join(""); 17 | } 18 | 19 | module.exports = class Wordgame extends Plugin { 20 | static get plugin() { 21 | return { 22 | name: "Wordgame", 23 | description: "Some word games.", 24 | help: "Try /type or /anagram." 25 | }; 26 | } 27 | 28 | onCommand({command}) { 29 | switch (command) { 30 | case "cancel": 31 | this.state = ""; 32 | return "Done."; 33 | case "anagram": 34 | if (this.state) return "Another game is already running, use /cancel to stop it"; 35 | this.state = "type"; 36 | this.word = getRandomWord(); 37 | return `What's the anagram of "${shuffleWord(this.word)}"?`; 38 | case "type": 39 | if (this.state) return "Another game is already running, use /cancel to stop it"; 40 | this.state = "type"; 41 | this.word = getRandomWord(); 42 | return `Type "${this.word}"!`; 43 | } 44 | } 45 | 46 | onText({message}) { 47 | if (this.state === "") return; 48 | if (message.text !== this.word) return; 49 | this.state = ""; 50 | return `Well done, @${message.from.username}!`; 51 | } 52 | }; 53 | -------------------------------------------------------------------------------- /src/plugins/YouTube.js: -------------------------------------------------------------------------------- 1 | const Plugin = require("../Plugin"); 2 | const YouTube = require("youtube-api"); 3 | const assert = require("assert"); 4 | 5 | function sanitize(str) { 6 | return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); 7 | } 8 | 9 | module.exports = class YouTubePlugin extends Plugin { 10 | static get plugin() { 11 | return { 12 | name: "YouTube", 13 | description: "Search for videos on YouTube.", 14 | help: "/yt query", 15 | needs: { 16 | config: { 17 | YOUTUBE_API_KEY: "API key for Youtube." 18 | } 19 | } 20 | }; 21 | } 22 | 23 | constructor(obj) { 24 | super(obj); 25 | 26 | assert(typeof obj.config.YOUTUBE_API_KEY === typeof "", "You must supply a YouTube API key."); 27 | assert(obj.config.YOUTUBE_API_KEY !== "", "Please supply a valid YouTube API key."); 28 | 29 | YouTube.authenticate({ 30 | type: "key", 31 | key: obj.config.YOUTUBE_API_KEY 32 | }); 33 | } 34 | 35 | async onCommand({command, args}) { 36 | if (command !== "yt") return; 37 | const query = args.join(" "); 38 | 39 | const data = await new Promise((resolve, reject) => YouTube.search.list({ 40 | part: "snippet", // required by YT API 41 | q: query 42 | }, (err, data) => err ? reject(err) : resolve(data))); 43 | 44 | if (data.items.length === 0) 45 | return "No videos found!"; 46 | 47 | const result = data.items[0]; 48 | const title = sanitize(result.snippet.title); 49 | const description = sanitize(result.snippet.description); 50 | 51 | return { 52 | type: "text", 53 | text: `<a href="https://youtube.com/watch?v=${result.id.videoId}">${title}</a>\n\n${description}`, 54 | options: { 55 | parse_mode: "HTML" 56 | } 57 | }; 58 | } 59 | }; 60 | -------------------------------------------------------------------------------- /src/plugins/xkcd.js: -------------------------------------------------------------------------------- 1 | const Plugin = require("./../Plugin"); 2 | const request = require("request-promise-native"); 3 | 4 | module.exports = class xkcd extends Plugin { 5 | static get plugin() { 6 | return { 7 | name: "xkcd", 8 | description: "Returns xkcd comics!", 9 | help: "/xkcd to get the latest xkcd, `/xkcd <comic ID>` to get the xkcd w/ that ID." 10 | }; 11 | } 12 | 13 | async onCommand({command, args}) { 14 | if (command !== "xkcd") return; 15 | let xkcdid = ""; 16 | if (args.length !== 0) { 17 | if (isNaN(args[0])) 18 | return "Please write a number as the ID."; 19 | xkcdid = args[0] + "/"; 20 | } 21 | const requrl = `https://xkcd.com/${xkcdid}info.0.json`; 22 | 23 | this.log.debug(`Requesting XKCD at ${requrl}`); 24 | 25 | try { 26 | const data = await request(requrl); 27 | const jsondata = JSON.parse(data); 28 | return { 29 | type: "photo", 30 | photo: jsondata.img 31 | }; 32 | } catch (e) { 33 | if (e.statusCode === 404) 34 | return "Comic strip not found!"; 35 | return "An error occurred."; 36 | } 37 | } 38 | }; -------------------------------------------------------------------------------- /tests/cli.js: -------------------------------------------------------------------------------- 1 | /* eslint no-console: 0 no-sync: 0 */ 2 | /* Run this tool and send "#help" for usage details. 3 | */ 4 | 5 | const readline = require("readline"); 6 | const fs = require("fs"); 7 | 8 | const rl = readline.createInterface({ 9 | input: process.stdin, 10 | output: process.stdout 11 | }); 12 | 13 | process.env.IS_TEST_ENVIRONMENT = 1; 14 | process.env.MOCK_NTBA = "../tests/integration/helpers/TelegramBot.js"; 15 | const API = require("../src/Bot.js"); 16 | 17 | API.on("_debug_message", ({text}) => { 18 | const [first, ...rest] = text.split("\n"); 19 | console.log("< " + first); 20 | for (const line of rest) 21 | console.log("| " + line); 22 | }); 23 | 24 | let chatID = 1; 25 | let myUID = 1; 26 | let myUname = "dev"; 27 | let myName = "Dev"; 28 | 29 | rl.on("line", text => { 30 | if (text.startsWith("#")) { 31 | if (text === "#quit" || text === "#exit") 32 | process.exit(0); 33 | if (text === "#help") 34 | console.log( 35 | `This is a tool for testing Nikoro. It emulates the Telegram API: any message 36 | you type below will appear to the bot as if it came from Telegram. 37 | 38 | Commands prefixed with "#" are for internal usage, and won't be seen by the bot. 39 | 40 | Commands: 41 | #help to show this help text. 42 | #chatid <ID> to change the chat ID to <ID> (current value: ${chatID}, default value 1). 43 | #myuid <ID> to change your user ID to <ID> (current value: ${myUID}, default value 1). 44 | #myuname <username> to change your username to <username> (current value: ${myUname}, default value "dev"). 45 | #myname <name> to change your name to <name> (current value: ${myName}, default value "Dev"). 46 | #quit to quit the tool. 47 | `); 48 | if (text.startsWith("#myuid ")) 49 | myUID = Number(text.replace(/#myuid /, "")); 50 | if (text.startsWith("#myuname ")) 51 | myUname = Number(text.replace(/#myuname /, "")); 52 | if (text.startsWith("#myname ")) 53 | myName = Number(text.replace(/#myname /, "")); 54 | } else { 55 | API.pushMessage({ 56 | text, 57 | from: { 58 | id: myUID, 59 | first_name: myName, 60 | username: myUname 61 | }, 62 | chat: { 63 | id: chatID, 64 | title: "Dev group", 65 | type: "group", 66 | all_members_are_administrators: false 67 | } 68 | }); 69 | } 70 | }); -------------------------------------------------------------------------------- /tests/integration/helpers/TelegramBot.js: -------------------------------------------------------------------------------- 1 | const EventEmitter = require("events"); 2 | 3 | module.exports = class TelegramBot extends EventEmitter { 4 | constructor() { 5 | super(); 6 | this.i = 0; 7 | this.date = Math.floor(new Date() / 1000); 8 | } 9 | 10 | pushMessage(message, type = "text") { 11 | if (!message.id) 12 | message.message_id = this.i++; 13 | if (!message.from) 14 | message.from = { 15 | id: 12345678, 16 | first_name: "Foobar", 17 | username: "foo_bar" 18 | }; 19 | if (!message.chat) 20 | message.chat = { 21 | id: -123456789, 22 | title: "Test group", 23 | type: "group", 24 | all_members_are_administrators: false 25 | }; 26 | if (!message.date) 27 | message.date = this.date++; 28 | const cmdRegex = /\/[\w_]+/i; 29 | if (cmdRegex.test(message.text)) 30 | message.entities = [{ 31 | type: "bot_command", 32 | offset: 0, 33 | length: message.text.match(cmdRegex)[0].length 34 | }]; 35 | this.emit(type, message); 36 | } 37 | 38 | pushRootMessage(message, type = "text") { 39 | message.from = { 40 | id: 1, 41 | first_name: "Root", 42 | username: "root" 43 | }; 44 | this.pushMessage(message, type); 45 | } 46 | 47 | pushEvilMessage(message, type = "text") { 48 | message.from = { 49 | id: 1000, 50 | first_name: "Evil Eve", 51 | username: "eve" 52 | }; 53 | this.pushMessage(message, type); 54 | } 55 | 56 | sendMessage(chatId, text, options) { 57 | this.emit("_debug_message", { 58 | chatId, 59 | text, 60 | options 61 | }); 62 | } 63 | }; -------------------------------------------------------------------------------- /tests/integration/indexTest.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node, es6, mocha */ 2 | const PluginManager = require("../../src/PluginManager"); 3 | const Auth = require("../../src/helpers/Auth"); 4 | const TelegramBot = require("./helpers/TelegramBot"); 5 | const config = require("./sample-config.json"); 6 | const auth = new Auth(config); 7 | 8 | let i = 0; 9 | function makeSentinel() { 10 | return `Sentinel<${i++}>`; 11 | } 12 | 13 | function expectsAnyMessage(bot) { 14 | return new Promise(resolve => bot.on("_debug_message", resolve)); 15 | } 16 | 17 | function expectsMessage(bot, target) { 18 | // Todo: cleanup listener 19 | return new Promise(resolve => bot.on("_debug_message", ({text}) => { 20 | if (text === target) resolve(); 21 | })); 22 | } 23 | 24 | function notExpectsMessage(bot, target, errorText = "Should not have received message", delay = 500) { 25 | // Todo: cleanup listener 26 | return new Promise((resolve, reject) => { 27 | const timeout = setTimeout(resolve, delay); 28 | bot.on("_debug_message", ({text}) => { 29 | if (text !== target) 30 | return; 31 | clearTimeout(timeout); 32 | reject(new Error(errorText)); 33 | }); 34 | }); 35 | } 36 | 37 | describe("Bot", function() { 38 | let bot; 39 | let pluginManager; 40 | it("should start correctly with the Ping plugin", function() { 41 | bot = new TelegramBot(); 42 | pluginManager = new PluginManager(bot, config, auth); 43 | pluginManager.loadPlugins(["Ping"]); // [] = Active plugins 44 | }); 45 | it("should reply to /help", function() { 46 | const p = expectsAnyMessage(bot); 47 | bot.pushMessage({text: "/help"}); 48 | return p; 49 | }); 50 | it("should reply to /help Ping", function() { 51 | const p = expectsAnyMessage(bot); 52 | bot.pushMessage({text: "/help Ping"}); 53 | return p; 54 | }); 55 | it("should enable plugins", function() { 56 | const sentinel = makeSentinel(); 57 | const p = expectsMessage(bot, sentinel); 58 | bot.pushRootMessage({text: "/enable Echo"}); 59 | bot.pushMessage({text: `/echo ${sentinel}`}); 60 | return p; 61 | }); 62 | it("should disable plugins", function() { 63 | this.slow(1100); 64 | const sentinel = makeSentinel(); 65 | const p = notExpectsMessage(bot, sentinel, "Echo wasn't disabled"); 66 | 67 | bot.pushRootMessage({text: "/disable Echo"}); 68 | bot.pushMessage({text: `/echo ${sentinel}`}); 69 | return p; 70 | }); 71 | it("shouldn't let unauthorized users enable plugins", function() { 72 | this.slow(200); 73 | const sentinel = makeSentinel(); 74 | const p = notExpectsMessage(bot, sentinel, "Echo was enabled"); 75 | 76 | bot.pushEvilMessage({text: "/enable Echo"}); 77 | bot.pushMessage({text: `/echo ${sentinel}`}); 78 | return p; 79 | }); 80 | it("shouldn't let unauthorized users disable plugins", function() { 81 | pluginManager.loadPlugins(["Echo"]); 82 | const sentinel = makeSentinel(); 83 | const p = expectsMessage(bot, sentinel); 84 | 85 | bot.pushEvilMessage({text: "/disable Echo"}); 86 | bot.pushMessage({text: `/echo ${sentinel}`}); 87 | return p; 88 | }); 89 | it("should support multiline inputs", function() { 90 | pluginManager.loadPlugins(["Echo"]); 91 | const string = makeSentinel() + "\n" + makeSentinel(); 92 | const p = expectsMessage(bot, string); 93 | bot.pushMessage({text: `/echo ${string}`}); 94 | return p; 95 | }); 96 | }); 97 | 98 | describe("Ignore", function() { 99 | const bot = new TelegramBot(); 100 | const pluginManager = new PluginManager(bot, config, auth); 101 | pluginManager.loadPlugins(["Echo", "Ignore"]); 102 | it("should ignore", function() { 103 | const sentinel = makeSentinel(); 104 | const p = notExpectsMessage(bot, sentinel, "The bot replied to an echo"); 105 | 106 | bot.pushRootMessage({text: "/ignore 123"}); 107 | // Give Ignore some time to react 108 | setTimeout(() => bot.pushMessage({ 109 | text: `/echo ${sentinel}`, 110 | from: { 111 | id: 123, 112 | first_name: "Foo Bar", 113 | username: "foobar" 114 | } 115 | }), 50); 116 | return p; 117 | }); 118 | }); 119 | 120 | describe("Ping", function() { 121 | const bot = new TelegramBot(); 122 | const pluginManager = new PluginManager(bot, config, auth); 123 | pluginManager.loadPlugins(["Ping"]); 124 | it("should reply to /ping", function() { 125 | const p = expectsAnyMessage(bot); 126 | bot.pushMessage({text: "ping"}); 127 | return p; 128 | }); 129 | }); 130 | 131 | describe("Antiflood", function() { 132 | const bot = new TelegramBot(); 133 | const pluginManager = new PluginManager(bot, config, auth); 134 | pluginManager.loadPlugins(["Antiflood", "Echo"]); 135 | it("should reject spam", async function() { 136 | this.timeout(4000); 137 | this.slow(3000); 138 | const sentinel = makeSentinel(); 139 | const spamAmount = 50; 140 | const spamLimit = 5; 141 | let replies = 0; 142 | 143 | const callback = ({text}) => { 144 | if (text === sentinel) 145 | replies++; 146 | }; 147 | bot.on("_debug_message", callback); 148 | 149 | bot.pushRootMessage({text: `/floodignore ${spamLimit}`}); 150 | 151 | // Leave the plugin some time to set up the thing 152 | await (new Promise(resolve => setTimeout(() => resolve(), 100))); 153 | 154 | for (let i = 0; i < spamAmount; i++) 155 | bot.pushMessage({text: `/echo ${sentinel}`}); 156 | 157 | return new Promise((resolve, reject) => setTimeout(function() { 158 | // bot.removeListener("_debug_message", callback); 159 | if (replies === spamLimit) 160 | resolve(); 161 | else 162 | reject(new Error(`The bot replied ${replies} times.`)); 163 | }, 50 * spamAmount)); // The bot shouldn't take longer than 50 ms avg per message 164 | }); 165 | }); 166 | 167 | describe("Scheduler", function() { 168 | it("should initialize correctly", function() { 169 | require("../../src/helpers/Scheduler"); 170 | }); 171 | it.skip("should schedule events", function(done) { 172 | this.slow(1500); 173 | const scheduler = require("../../src/helpers/Scheduler"); 174 | const sentinel = makeSentinel(); 175 | const delay = 1000; 176 | const start = new Date(); 177 | scheduler.on(sentinel, () => { 178 | const end = new Date(); 179 | // Margin of error: +/- 100 ms 180 | if ((start - end) > (delay + 100)) 181 | done(new Error(`Takes too long: ${start - end} ms`)); 182 | else if ((start - end) < (delay - 100)) 183 | done(new Error(`Takes too little: ${start - end} ms`)); 184 | else 185 | done(); 186 | }); 187 | scheduler.schedule(sentinel, {}, new Date(start + delay)); 188 | }); 189 | it.skip("should cancel events", function(done) { 190 | this.slow(1500); 191 | const scheduler = require("../../src/helpers/Scheduler"); 192 | const sentinel = makeSentinel(); 193 | const doneTimeout = setTimeout(() => done(), 1000); 194 | sentinel.on(sentinel, () => { 195 | clearTimeout(doneTimeout); 196 | done(new Error("Event was not cancelled")); 197 | }, {}, new Date(new Date() + 500)); 198 | const errorEvent = scheduler.schedule(sentinel, {}); 199 | scheduler.cancel(errorEvent); 200 | }); 201 | it.skip("should look up events by metadata", function(done) { 202 | this.slow(1500); 203 | const scheduler = require("../../src/helpers/Scheduler"); 204 | const sentinel = makeSentinel(); 205 | let isFinished = false; 206 | const doneTimeout = setTimeout(() => done(), 1000); 207 | scheduler.on(sentinel, () => { 208 | if (isFinished) 209 | return; // Prevents "done called twice" errors 210 | clearTimeout(doneTimeout); 211 | done(new Error("Event was not cancelled")); 212 | }); 213 | scheduler.schedule(sentinel, { 214 | name: "My test event", 215 | color: "red" 216 | }, new Date(new Date() + 500)); 217 | const errorEvent = scheduler.search(event => event.name === "My test event" && event.color === "red"); 218 | if (!errorEvent) { 219 | isFinished = true; 220 | clearTimeout(doneTimeout); 221 | done(new Error("Event not found")); 222 | return; 223 | } 224 | scheduler.cancel(errorEvent); 225 | }); 226 | }); -------------------------------------------------------------------------------- /tests/integration/sample-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "comment": [ 3 | "This is a default file required for running tests.", 4 | "Change this with `npm run configure`." 5 | ], 6 | 7 | "activePlugins": ["Ping"], 8 | "owners": [1], 9 | "loggingLevel": "warn" 10 | } 11 | -------------------------------------------------------------------------------- /tests/unit/fixtures/QuoteMessage.json: -------------------------------------------------------------------------------- 1 | { 2 | "message_id": 22, 3 | "from":{ 4 | "id": 108833532, 5 | "is_bot": false, 6 | "first_name":" Sergey", 7 | "last_name":" Ufocoder", 8 | "username":" ufocoder", 9 | "language_code":" ru-RU" 10 | }, 11 | "chat":{ 12 | "id": 108833532, 13 | "first_name": "Sergey", 14 | "last_name": "Ufocoder", 15 | "username": "ufocoder", 16 | "type": "private" 17 | }, 18 | "date": 1507029332, 19 | "reply_to_message": { 20 | "message_id": 21, 21 | "from":{ 22 | "id": 108833532, 23 | "is_bot": false, 24 | "first_name": "Sergey", 25 | "last_name": "Ufocoder", 26 | "username": "ufocoder", 27 | "language_code": "ru-RU" 28 | }, 29 | "chat": { 30 | "id": 108833532, 31 | "first_name": "Sergey", 32 | "last_name": "Ufocoder", 33 | "username": "ufocoder", 34 | "type": "private" 35 | }, 36 | "date": 1507029326, 37 | "text": "Lorem ipsum" 38 | }, 39 | "text": "/addquote", 40 | "entities": [ 41 | { 42 | "offset": 0, 43 | "length": 9, 44 | "type": "bot_command" 45 | } 46 | ] 47 | } -------------------------------------------------------------------------------- /tests/unit/plugins/QuoteTest.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha*/ 2 | const assert = require("assert"); 3 | const Plugin = require("../../../src/plugins/Quote"); 4 | const message = require("../fixtures/QuoteMessage"); 5 | 6 | describe("Plugins", () => { 7 | describe("Quote", () => { 8 | it("has help description", () => { 9 | assert(Plugin.plugin.name); 10 | assert(Plugin.plugin.description); 11 | assert(Plugin.plugin.help); 12 | }); 13 | 14 | it("has commands", () => { 15 | const plugin = new Plugin({db: {}}); 16 | 17 | assert.strictEqual(typeof plugin.commands, "object"); 18 | assert(plugin.commands.addquote); 19 | assert(plugin.commands.quote); 20 | }); 21 | 22 | it("add and find saved quote", () => { 23 | const plugin = new Plugin({db: {}}); 24 | const quoteId = 0; 25 | 26 | assert.strictEqual(plugin.findQuote(quoteId), "Quote not found!"); 27 | assert.strictEqual(plugin.addQuote(message), "Quote added with ID " + quoteId); 28 | assert.strictEqual(plugin.findQuote(quoteId), "<@ufocoder>: Lorem ipsum"); 29 | }); 30 | 31 | it("get random quote", () => { 32 | const plugin = new Plugin({db: {}}); 33 | 34 | assert.strictEqual(plugin.randomQuote(), "Quote not found!"); 35 | assert.strictEqual(plugin.addQuote(message), "Quote added with ID 0"); 36 | assert.strictEqual(plugin.randomQuote(), "<@ufocoder>: Lorem ipsum"); 37 | }); 38 | }); 39 | }); --------------------------------------------------------------------------------