├── .babelrc ├── .eslintrc ├── .gitignore ├── Gruntfile.js ├── LICENSE ├── README.md ├── demo.gif ├── demo.js ├── esdoc.json ├── hand-glow.jpg ├── package.json └── src ├── functions ├── api.js ├── argument-parser.js ├── fetch.js ├── poll.js └── webhook.js ├── index.js └── types ├── Base.js ├── BulkMessage.js ├── Delete.js ├── File.js ├── Forward.js ├── Keyboard.js ├── Message.js └── Question.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015"], 3 | "plugins": ["babel-plugin-add-module-exports"] 4 | } 5 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": "airbnb/base", 4 | "root": true, 5 | "env": { 6 | "node": true 7 | }, 8 | "rules": { 9 | "comma-dangle": 0, 10 | "no-param-reassign": 0, 11 | "no-console": 0 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | types 3 | !src/types 4 | 5 | # Logs 6 | logs 7 | *.log 8 | 9 | # Runtime data 10 | pids 11 | *.pid 12 | *.seed 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directory 30 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 31 | node_modules 32 | 33 | # Test file I use to test my module 34 | test.js 35 | 36 | .DS_Store 37 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | module.exports = function(grunt) { 2 | grunt.initConfig({ 3 | babel: { 4 | scripts: { 5 | files: [{ 6 | expand: true, 7 | cwd: 'src', 8 | src: '**/*.js', 9 | dest: 'build/' 10 | }] 11 | } 12 | }, 13 | eslint: { 14 | scripts: ['src/**/*.js'] 15 | }, 16 | copy: { 17 | classes: { 18 | files: [{ 19 | expand: true, 20 | cwd: 'build/types', 21 | src: '*', 22 | dest: 'types' 23 | }] 24 | } 25 | }, 26 | watch: { 27 | scripts: { 28 | files: ['src/**/*.js'], 29 | tasks: ['babel'] 30 | } 31 | }, 32 | clean: ['build', 'types'] 33 | }); 34 | 35 | grunt.loadNpmTasks('grunt-babel'); 36 | grunt.loadNpmTasks('grunt-contrib-watch'); 37 | grunt.loadNpmTasks('grunt-eslint'); 38 | grunt.loadNpmTasks('grunt-contrib-copy'); 39 | grunt.loadNpmTasks('grunt-contrib-clean'); 40 | 41 | grunt.registerTask('default', ['clean', 'babel', 'copy', 'eslint']); 42 | }; 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Mahdi Dibaiee 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Telegram Bots 2 | ============= 3 | Create and control [Telegram bots](https://core.telegram.org/bots) easily 4 | using the new [Telegram API](https://core.telegram.org/bots/api). 5 | 6 | ``` 7 | npm install telegram-api 8 | ``` 9 | 10 | telegram-api is in beta, your feedback is appreciated, please [fill an issue](https://github.com/mdibaiee/node-telegram-api/issues) 11 | for any bugs you find or any suggestions you have. 12 | 13 | If you are cloning this repository, remember to run `npm install` to install dependencies. 14 | 15 | If you are looking for a real-life example of a bot written using this module, see [mdibaiee/webdevrobot](https://github.com/mdibaiee/webdevrobot). 16 | 17 | [**Documentation**](https://github.com/mdibaiee/node-telegram-api/wiki) 18 | 19 | Example 20 | ======= 21 | ```javascript 22 | // ES6: 23 | import Bot, { Message, File } from 'telegram-api'; 24 | 25 | // ES5: 26 | var Bot = require('telegram-api').default; 27 | var Message = require('telegram-api/types/Message'); 28 | var File = require('telegram-api/types/File'); 29 | 30 | var bot = new Bot({ 31 | token: 'YOUR_TOKEN' 32 | }); 33 | 34 | bot.start(); 35 | 36 | bot.get(/Hi|Hey|Hello|Yo/, function(message) { 37 | var answer = new Message().text('Hello, Sir').to(message.chat.id); 38 | 39 | bot.send(answer); 40 | }); 41 | 42 | bot.command('start', function(message) { 43 | var welcome = new File().file('./some_photo.png').caption('Welcome').to(message.chat.id); 44 | 45 | bot.send(welcome); 46 | }); 47 | 48 | // Arguments, see: https://github.com/mdibaiee/node-telegram-api/wiki/Commands 49 | bot.command('weather [date]', function(message) { 50 | console.log(message.args.city, message.args.date); 51 | }) 52 | ``` 53 | 54 | Todo 55 | ==== 56 | - [x] Webhook support (not tested, see [#4](https://github.com/mdibaiee/node-telegram-api/issues/4)) 57 | - [x] Forward Type 58 | - [x] BulkMessage Type 59 | - [x] File Type 60 | - [ ] Sticker Type 61 | - [ ] Location Type 62 | - [ ] Contact Type 63 | - [ ] Allow remote control of bots (TCP maybe) 64 | - [ ] YOUR IDEAS! [Fill an issue](https://github.com/mdibaiee/node-telegram-api/issues) 65 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mdibaiee/node-telegram-api/b64b6f7ee84a1e994d5f1d5d856598d72ed921a5/demo.gif -------------------------------------------------------------------------------- /demo.js: -------------------------------------------------------------------------------- 1 | var Bot = require('telegram-api').default; 2 | 3 | // only require the message types you need, more coming soon! 4 | var Message = require('telegram-api/types/Message'); 5 | var Question = require('telegram-api/types/Question'); 6 | 7 | var bot = new Bot({ 8 | token: '114687409:AAEVpfOmGI7xNKiKzVA9LBcOO9INpIwnvDI' 9 | }); 10 | 11 | bot.start().catch(err => { 12 | console.error(err, '\n', err.stack); 13 | }); 14 | 15 | // polling 16 | bot.on('update', update => { 17 | console.log('Polled\n', update); 18 | }); 19 | 20 | const question = new Question({ 21 | text: 'How should I greet you?', 22 | answers: [['Hey'], ['Hello, Sir'], ['Yo bro']] 23 | }); 24 | 25 | bot.get(/Hi\sBot/, message => { 26 | const id = message.chat.id; 27 | 28 | question.to(id).reply(message.message_id); 29 | 30 | bot.send(question).then(answer => { 31 | const msg = new Message().to(id).text('Your answer: ' + answer.text); 32 | bot.send(msg); 33 | }, () => { 34 | const msg = new Message().to(id).text('Invalid answer'); 35 | bot.send(msg); 36 | }); 37 | }); 38 | 39 | const hello = new Message().text('Hello'); 40 | bot.command('start', message => { 41 | bot.send(hello.to(message.chat.id)); 42 | }); 43 | 44 | const test = new Message().text('Test Command'); 45 | 46 | bot.command('test [count|number] ...rest', message => { 47 | bot.send(test.to(message.chat.id).text(message.args.subject)); 48 | }); 49 | 50 | // to stop a bot 51 | // bot.stop(); 52 | -------------------------------------------------------------------------------- /esdoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "src": "./lib", 3 | "destination": "./docs" 4 | } 5 | -------------------------------------------------------------------------------- /hand-glow.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mdibaiee/node-telegram-api/b64b6f7ee84a1e994d5f1d5d856598d72ed921a5/hand-glow.jpg -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "telegram-api", 3 | "version": "4.2.0", 4 | "description": "Control Telegram bots easily using the new Telegram API", 5 | "main": "build/index.js", 6 | "scripts": { 7 | "test": "grunt test" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/mdibaiee/node-telegram-api" 12 | }, 13 | "keywords": [ 14 | "Telegram", 15 | "bot", 16 | "node", 17 | "module" 18 | ], 19 | "author": "Mahdi Dibaiee", 20 | "license": "MIT", 21 | "bugs": { 22 | "url": "https://github.com/mdibaiee/node-telegram-api/issues" 23 | }, 24 | "files": [ 25 | "types", 26 | "build" 27 | ], 28 | "directories": { 29 | "lib": "src" 30 | }, 31 | "engines": { 32 | "node": ">=0.12.0" 33 | }, 34 | "homepage": "https://github.com/mdibaiee/node-telegram-api", 35 | "dependencies": { 36 | "babel-polyfill": "^6.7.4", 37 | "mime": "1.3.4", 38 | "qs": "4.0.0", 39 | "unirest": "0.4.2" 40 | }, 41 | "devDependencies": { 42 | "babel-eslint": "6.0.0", 43 | "babel-plugin-add-module-exports": "^0.1.2", 44 | "babel-preset-es2015": "^6.6.0", 45 | "eslint-config-airbnb": "6.2.0", 46 | "grunt": "^0.4.5", 47 | "grunt-babel": "^6.0.0", 48 | "grunt-contrib-clean": "^1.0.0", 49 | "grunt-contrib-copy": "^1.0.0", 50 | "grunt-contrib-watch": "^1.0.0", 51 | "grunt-copy": "^0.1.0", 52 | "grunt-eslint": "18.0.0" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/functions/api.js: -------------------------------------------------------------------------------- 1 | // API methods 2 | import fetch from './fetch'; 3 | 4 | /** 5 | * Simple replacement for Bluebird's Promise.mapSeries() implementation 6 | * @param {Array} tasks to run serially 7 | * @param {Function} task function to execute 8 | * @return {Promise} 9 | */ 10 | function sequence(tasks, fn) { 11 | return tasks.reduce((promise, task) => promise.then(() => fn(task)), Promise.resolve()); 12 | } 13 | 14 | 15 | /** 16 | * API class, has a function for each method of the Telegram API which take 17 | * an object argument, and send request to the API server 18 | * 19 | * Methods: getMe, sendMessage, forwardMessage, sendPhoto, sendAudio, 20 | * sendDocument, sendSticker, sendVideo, sendLocation, sendChatAction, 21 | * getUserProfilePhotos, getUpdates 22 | * 23 | */ 24 | export default class API { 25 | /** 26 | * Create a new api object with the given token 27 | * @param {string} token 28 | */ 29 | constructor(token) { 30 | this.token = token; 31 | this._queue = []; 32 | this._inUseQueue = []; 33 | } 34 | 35 | /** 36 | * Run Telegram API calls serially using internal queueing mechanism 37 | * @private 38 | */ 39 | _runQueue() { 40 | // implementation taken from https://github.com/yagop/node-telegram-bot-api/issues/192#issuecomment-249488807 41 | if (this._inUseQueue.length || !this._queue.length) return; 42 | 43 | this._inUseQueue = this._queue; 44 | this._queue = []; 45 | 46 | sequence(this._inUseQueue, request => { //eslint-disable-line 47 | return this.request(request.method, request.data) 48 | .then(request.resolve) 49 | .catch(request.reject); 50 | }).then(() => { 51 | this._inUseQueue = []; 52 | this._runQueue(); 53 | }); 54 | } 55 | } 56 | 57 | API.prototype.request = function request(method, data) { 58 | return fetch(`${this.token}/${method}`, data); 59 | }; 60 | 61 | const methods = ['getMe', 'sendMessage', 'forwardMessage', 'sendPhoto', 62 | 'sendAudio', 'sendDocument', 'sendSticker', 'sendVideo', 63 | 'sendLocation', 'sendChatAction', 'getUserProfilePhotos', 64 | 'getUpdates', 'setWebhook', 'deleteMessage']; 65 | 66 | methods.forEach(method => { 67 | API.prototype[method] = function (data) { //eslint-disable-line 68 | if (method === 'getUpdates') { 69 | // don't add 'getUpdates' request to the queue as it's going to 70 | // hinder 'send*' calls performance 71 | return this.request(method, data); 72 | } 73 | 74 | // implementation taken from https://github.com/yagop/node-telegram-bot-api/issues/192#issuecomment-249488807 75 | return new Promise((resolve, reject) => { 76 | this._queue.push({ method, data, resolve, reject }); 77 | process.nextTick(this._runQueue.bind(this)); 78 | }); 79 | }; 80 | }); 81 | -------------------------------------------------------------------------------- /src/functions/argument-parser.js: -------------------------------------------------------------------------------- 1 | const FORMAT_REQUIRED = /<(\W*)(\w+)\|?(\w+)?>/g; 2 | const FORMAT_OPTIONAL = /\[(\W*)(\w+)\|?(\w+)?\]/g; 3 | const FORMAT_REST = /\.{3}(\w+)/g; 4 | 5 | const ESCAPABLE = '.^$*+?()[{\\|}]'.split(''); 6 | 7 | const REQUIRED = 0; 8 | const OPTIONAL = 1; 9 | const REST = 2; 10 | 11 | function escape(symbols, append = '') { 12 | return symbols.split('').map(symbol => 13 | (ESCAPABLE.indexOf(symbol) ? `\\${symbol}` : symbol) + append 14 | ).join(''); 15 | } 16 | 17 | const TYPES = { 18 | number: '\\d', 19 | word: '\\S' 20 | }; 21 | 22 | function getFormat(type = 'word', param = 'required') { 23 | const t = TYPES[type]; 24 | 25 | switch (param) { // eslint-disable-line 26 | case 'required': 27 | return `(${t}+)`; 28 | case 'optional': 29 | return `(${t}+)?`; 30 | case 'rest': 31 | return '(.*)'; 32 | } 33 | 34 | return ''; 35 | } 36 | 37 | /** 38 | * Parses a message for arguments, based on format 39 | * 40 | * The format option may include '' and '[optionalParam]' and 41 | * '...[restParam]' 42 | * indicates a required, single-word argument 43 | * [optionalParam] indicates an optinal, single-word argument 44 | * ...[restParam] indicates a multi-word argument which records until end 45 | * 46 | * You can define a type for your arguments using pipe | sign, like this: 47 | * [count|number] 48 | * Supported Types are: number and word, defaults to word 49 | * 50 | * Example: 51 | * format: ' [count|number] ...text' 52 | * string 1: 'Someone Hey, wassup' 53 | * {name: 'Someone', 54 | * count: undefined, 55 | * text: 'Hey, wassup'} 56 | * 57 | * string 2: 'Someone 5 Hey, wassup' 58 | * {name: 'Someone', 59 | * count: 5, 60 | * text: 'Hey, wassup'} 61 | * @param {string} format Format, as described above 62 | * @param {string} string The message to parse 63 | * @return {object} Parsed arguments 64 | */ 65 | export default function argumentParser(format, string) { 66 | string = string.replace(/[^\s]+/, '').trim(); 67 | format = format.replace(/[^\s]+/, '').trim(); 68 | 69 | if (!format) { 70 | return { args: {}, params: {} }; 71 | } 72 | 73 | let indexes = []; 74 | const params = {}; 75 | 76 | format = format.replace(/\s/g, '\\s*'); 77 | format = format.replace(FORMAT_REQUIRED, 78 | (f, symbols, arg, type = 'word', offset) => { 79 | indexes.push({ arg, offset }); 80 | params[arg] = REQUIRED; 81 | return (escape(symbols) + getFormat(type, 'required')).trim(); 82 | }); 83 | format = format.replace(FORMAT_OPTIONAL, 84 | (f, symbols, arg, type = 'word', offset) => { 85 | indexes.push({ arg, offset }); 86 | params[arg] = OPTIONAL; 87 | return (escape(symbols, '?') + getFormat(type, 'optional')).trim(); 88 | }); 89 | format = format.replace(FORMAT_REST, (full, arg, offset) => { 90 | indexes.push({ offset, arg }); 91 | params[arg] = REST; 92 | return getFormat(null, 'rest'); 93 | }); 94 | 95 | if (!string) { 96 | return { args: {}, params }; 97 | } 98 | 99 | indexes = indexes.sort((a, b) => 100 | (a.offset < b.offset ? -1 : 1) 101 | ); 102 | 103 | const regex = new RegExp(format); 104 | 105 | const matched = regex.exec(string).slice(1); 106 | 107 | const object = {}; 108 | for (const [index, match] of matched.entries()) { 109 | const argument = indexes[index]; 110 | 111 | object[argument.arg] = match; 112 | } 113 | 114 | return { args: object, params }; 115 | } 116 | -------------------------------------------------------------------------------- /src/functions/fetch.js: -------------------------------------------------------------------------------- 1 | import unirest from 'unirest'; 2 | 3 | export default function fetch(path, data = {}) { 4 | return new Promise((resolve, reject) => { 5 | const files = {}; 6 | 7 | for (const key of Object.keys(data)) { 8 | if (data[key].file) { 9 | files[key] = data[key].file; 10 | delete data[key]; 11 | } 12 | } 13 | 14 | unirest.post(`https://api.telegram.org/bot${path}`) 15 | .field(data) 16 | .attach(files) 17 | .end(response => { 18 | if (response.statusType === 4 || response.statusType === 5 || 19 | !response.body || !response.body.ok) { 20 | reject(response); 21 | } else { 22 | resolve(response.body); 23 | } 24 | }); 25 | }); 26 | } 27 | 28 | export function getBody(stream) { 29 | let data = ''; 30 | 31 | return new Promise((resolve, reject) => { 32 | stream.on('data', chunk => { 33 | data += chunk; 34 | }); 35 | 36 | stream.on('end', () => { 37 | resolve(data); 38 | }); 39 | 40 | stream.on('error', reject); 41 | }); 42 | } 43 | -------------------------------------------------------------------------------- /src/functions/poll.js: -------------------------------------------------------------------------------- 1 | export default function poll(bot) { 2 | return bot.api.getUpdates(bot.update) 3 | .then(response => { 4 | if (!response.result.length) { 5 | return poll(bot); 6 | } 7 | bot.emit('update', response.result); 8 | 9 | if (bot._stop) { 10 | return null; 11 | } 12 | return poll(bot); 13 | }) 14 | .catch(e => { 15 | bot.emit('error', e); 16 | return poll(bot); 17 | }); 18 | } 19 | -------------------------------------------------------------------------------- /src/functions/webhook.js: -------------------------------------------------------------------------------- 1 | import https from 'http'; 2 | import qs from 'qs'; 3 | import { getBody } from './fetch'; 4 | 5 | const DEFAULTS = { 6 | server: {}, 7 | port: 443 8 | }; 9 | 10 | export default function webhook(options = {}, bot) { 11 | options = Object.assign(DEFAULTS, options); 12 | 13 | return bot.api.setWebhook(options.url).then(() => { 14 | bot._webhookServer = https.createServer(options.server, (req, res) => 15 | getBody(req).then(data => { 16 | bot.emit('update', qs.parse(data).result); 17 | 18 | res.end('OK'); 19 | }) 20 | ).listen(options.port); 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | if (!global._babelPolyfill) { 2 | require('babel-polyfill'); 3 | } 4 | import API from './functions/api'; 5 | import webhook from './functions/webhook'; 6 | import poll from './functions/poll'; 7 | import argumentParser from './functions/argument-parser'; 8 | import { EventEmitter } from 'events'; 9 | import Message from './types/Message'; 10 | import File from './types/File'; 11 | import Keyboard from './types/Keyboard'; 12 | import BulkMessage from './types/BulkMessage'; 13 | import Question from './types/Question'; 14 | import Forward from './types/Forward'; 15 | 16 | const DEFAULTS = { 17 | update: { 18 | offset: 0, 19 | timeout: 20, 20 | limit: 100 21 | } 22 | }; 23 | 24 | const REQUIRED = 0; 25 | 26 | /** 27 | * Bot class used to connect to a new bot 28 | * Bots have an api property which gives access to all Telegram API methods, 29 | * see API class 30 | */ 31 | export default class Bot extends EventEmitter { 32 | /** 33 | * Create and connect to a new bot 34 | * @param {object} options Bot properties. 35 | */ 36 | constructor(options = { update: {} }) { 37 | super(); 38 | 39 | if (!options.token) { 40 | throw new Error('Token cannot be empty'); 41 | } 42 | 43 | this.token = options.token; 44 | this.update = Object.assign(options.update || {}, DEFAULTS.update); 45 | 46 | this.api = new API(this.token); 47 | 48 | this.msg = {}; 49 | 50 | // EventEmitter 51 | this._events = {}; 52 | this._userEvents = []; 53 | 54 | this.setMaxListeners(100); 55 | } 56 | 57 | /** 58 | * Gets information about the bot and then 59 | * 1) starts polling updates from API 60 | * 2) sets a webhook as defined by the first parameter and listens for updates 61 | * Emits an `update` event after polling with the response from server 62 | * Returns a promise which is resolved after the bot information is received 63 | * and set to it's `info` property i.e. bot.info 64 | * 65 | * @param {object} hook An object containg options passed to webhook 66 | * properties: 67 | * - url: HTTPS url to listen on POST requests coming 68 | * from the Telegram API 69 | * - port: the port to listen to, defaults to 443 70 | * - server: An object passed to https.createServer 71 | * 72 | * @return {promise} A promise which is resolved with the response of getMe 73 | */ 74 | start(hook) { 75 | if (hook) { 76 | return webhook(hook, this); 77 | } 78 | return this.api.getMe() 79 | .then(response => { 80 | this.info = response.result; 81 | 82 | this.on('update', this._update); 83 | 84 | if (hook) { 85 | return webhook(hook, this); 86 | } 87 | 88 | return poll(this); 89 | }) 90 | .catch(e => { 91 | this.emit('error', e); 92 | throw e; 93 | }); 94 | } 95 | 96 | /** 97 | * Listens on specific message matching the pattern which can be an string 98 | * or a regexp. 99 | * @param {string/regex} pattern 100 | * @param {function} listener function to call when a message matching the 101 | * pattern is found, gets the Update 102 | * In case of string, the message should start 103 | * with the string i.e. /^yourString/ 104 | * @return {object} returns the bot object 105 | */ 106 | get(pattern, listener) { 107 | if (typeof pattern === 'string') { 108 | pattern = new RegExp(`^${pattern}`); 109 | } 110 | 111 | this._userEvents.push({ 112 | pattern, listener 113 | }); 114 | 115 | return this; 116 | } 117 | 118 | /** 119 | * Listens on a command 120 | * @param {string} command the command string, should not include slash (/) 121 | * @param {function} listener function to call when the command is received, 122 | * gets the update 123 | * @return {object} returns the bot object 124 | */ 125 | command(command, listener, customMessage = {}) { 126 | const regex = /[^\s]+/; 127 | 128 | const cmd = command.match(regex)[0].trim(); 129 | 130 | this._userEvents.push({ 131 | pattern: new RegExp(`^/${cmd}`), 132 | parse: argumentParser.bind(null, command), 133 | listener, 134 | customMessage 135 | }); 136 | 137 | return this; 138 | } 139 | 140 | /** 141 | * Sends the message provided 142 | * @param {object} message The message to send. Gets it's send method called 143 | * @return {unknown} returns the result of calling message's send method 144 | */ 145 | send(message) { 146 | return message.send(this) 147 | .catch(e => { 148 | this.emit('error', e); 149 | throw e; 150 | }); 151 | } 152 | 153 | /** 154 | * Stops the bot, deattaching all listeners and polling 155 | */ 156 | stop() { 157 | this._stop = true; 158 | 159 | if (this._webhookServer) { 160 | this._webhookServer.close(); 161 | } 162 | 163 | this.removeListener('update', this._update); 164 | this._events = {}; 165 | } 166 | 167 | /** 168 | * The internal update event listener, used to parse messages and fire 169 | * command/get events - YOU SHOULD NOT USE THIS 170 | * 171 | * @param {object} update 172 | */ 173 | _update(update) { 174 | if (!this.update.offset) { 175 | const updateId = update[update.length - 1].update_id; 176 | this.update.offset = updateId; 177 | } 178 | if (this.update) { 179 | this.update.offset += 1; 180 | } 181 | 182 | update.forEach(res => { 183 | const msg = res.message || res.edited_message || res.channel_post || res.edited_channel_post; 184 | let text = msg.text; 185 | if (!text) { 186 | return; 187 | } 188 | 189 | const selfUsername = `@${this.info.username}`; 190 | 191 | if (text.startsWith('/') && text.indexOf(selfUsername) > -1) { 192 | // Commands are sent in /command@thisusername format in groups 193 | const regex = new RegExp(`(/.*)@${this.info.username}`); 194 | text = text.replace(regex, '$1'); 195 | msg.text = text; 196 | } 197 | 198 | const ev = this._userEvents.find(({ pattern }) => pattern.test(text)); 199 | 200 | if (!ev) { 201 | this.emit('command-notfound', msg); 202 | return; 203 | } 204 | 205 | if (!ev.parse) { 206 | ev.listener(msg); 207 | return; 208 | } 209 | 210 | const { params, args } = ev.parse(msg.text); 211 | msg.args = args; 212 | 213 | const requiredParams = Object.keys(params).filter(param => 214 | params[param] === REQUIRED && !args[param] 215 | ); 216 | 217 | if (!requiredParams.length) { 218 | ev.listener(msg); 219 | return; 220 | } 221 | 222 | const bot = this; 223 | function* getAnswer() { 224 | const customMessage = ev.customMessage; 225 | 226 | for (const param of requiredParams) { 227 | const ga = new Message() 228 | .to(msg.chat.id) 229 | .text(customMessage[param] || `Enter value for ${param}`); 230 | yield bot.send(ga).then(answer => { 231 | args[param] = answer.text; 232 | }); 233 | } 234 | } 235 | 236 | const iterator = getAnswer(); 237 | (function loop() { 238 | const next = iterator.next(); 239 | if (next.done) { 240 | ev.listener(msg); 241 | return; 242 | } 243 | 244 | next.value.then(loop); 245 | }()); 246 | }); 247 | } 248 | } 249 | 250 | export { 251 | File, 252 | Message, 253 | BulkMessage, 254 | Forward, 255 | Question, 256 | Keyboard 257 | }; 258 | -------------------------------------------------------------------------------- /src/types/Base.js: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | 3 | const ANSWER_THRESHOLD = 10; 4 | 5 | /** 6 | * Base class of all classes 7 | */ 8 | export default class Base extends EventEmitter { 9 | constructor(method) { 10 | super(); 11 | 12 | this.method = method; 13 | this.properties = {}; 14 | } 15 | 16 | /** 17 | * Sends the message, you should only use this method yourself if 18 | * you are extending this class. Normally you should call bot.send(message) 19 | * 20 | * Events: message:sent => Emitted after sending the message to API, gets the 21 | * API's response 22 | * 23 | * message:answer => Emitted when your message gets an answer from 24 | * the contact (reply in case of groups) 25 | * gets the Update object containing message 26 | * 27 | * @param {object} bot 28 | * @param {boolean} expectAnswer whether a sent message expects an answer from a contact(s) 29 | * @return {Promise} returns a promise, resolved with message:answer 30 | */ 31 | send(bot, expectAnswer = true) { 32 | if (this._keyboard) { 33 | const replyMarkup = JSON.stringify(this._keyboard.getProperties()); 34 | this.properties.reply_markup = replyMarkup; 35 | } 36 | 37 | return new Promise((resolve, reject) => { 38 | this._apiSend(bot) 39 | .then(response => { 40 | this.emit('message:sent', response); 41 | return response.result.message_id; 42 | }) 43 | .then(messageId => { 44 | if (!expectAnswer) { 45 | // no need to add more event callbacks for the messages that don't need expect an answer 46 | resolve(); 47 | return; 48 | } 49 | 50 | const chat = this.properties.chat_id; 51 | let answers = 0; 52 | 53 | bot.on('update', function listener(result) { 54 | answers += result.length; 55 | 56 | const update = result.find(({ message }) => { 57 | // if in a group, there will be a reply to this message 58 | if (chat < 0) { 59 | return message.chat.id === chat 60 | && message.reply_to_message 61 | && message.reply_to_message.message_id === messageId; 62 | } 63 | 64 | return message && message.chat.id === chat; 65 | }); 66 | 67 | if (update) { 68 | resolve(update.message); 69 | this.emit('message:answer', update.message); 70 | bot.removeListener('update', listener); 71 | } 72 | 73 | if (answers >= ANSWER_THRESHOLD) { 74 | bot.removeListener('update', listener); 75 | } 76 | }); 77 | }) 78 | .catch(reject) 79 | .finally(() => { 80 | if (this._keyboard.one_time_keyboard) { 81 | this._keyboard.replyMarkup = ''; 82 | } 83 | }); 84 | }); 85 | } 86 | 87 | _apiSend(bot) { 88 | return bot.api[this.method](this.properties); 89 | } 90 | 91 | /** 92 | * Set disable_notification property to true (send a silent notification) 93 | * @returns {object} returns the message object 94 | */ 95 | disableNotification() { 96 | this.properties.disable_notification = true; 97 | return this; 98 | } 99 | 100 | /** 101 | * Returns properties of the object 102 | * @return {object} properties of object 103 | */ 104 | getProperties() { 105 | return this.properties; 106 | } 107 | 108 | /** 109 | * Set properties of the object 110 | * @param {object} object properties to set 111 | * @param {boolean} extend A boolean indicating if the properties should be 112 | * extended by the object provided (Object.assign) 113 | * or properties should be replaced by the object 114 | * defaults to true 115 | * @return {object} returns the properties (same as getProperties) 116 | */ 117 | setProperties(object, extend = true) { 118 | this.properties = extend ? Object.assign(this.properties, object) 119 | : object; 120 | 121 | return this.getProperties(); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/types/BulkMessage.js: -------------------------------------------------------------------------------- 1 | import Message from './Message'; 2 | 3 | /** 4 | * Message class, used to send a message to multiple chats 5 | */ 6 | export default class BulkMessage extends Message { 7 | /** 8 | * Create a new message 9 | * @param {object} properties Message properties, as defined by Telegram API 10 | */ 11 | constructor(properties = {}) { 12 | super(properties); 13 | 14 | this.chats = []; 15 | } 16 | 17 | /** 18 | * Set multiple chat_id's for the message 19 | * @param {number} chat 20 | * @return {object} returns the message object 21 | */ 22 | to(...args) { 23 | const chats = args.reduce((a, b) => a.concat(b), []); 24 | 25 | this.chats = chats; 26 | return this; 27 | } 28 | 29 | /** 30 | * Send the message to all chats 31 | * @param {Bot} bot 32 | * @return {Promise} Resolved when the message is sent to all chats 33 | */ 34 | send(bot) { 35 | const promises = this.chats.map(chat => { 36 | const clone = Object.assign({}, this.properties); 37 | const message = new Message(clone).to(chat); 38 | return message.send(bot); 39 | }); 40 | 41 | return Promise.all(promises); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/types/Delete.js: -------------------------------------------------------------------------------- 1 | import Base from './Base'; 2 | 3 | export default class Message extends Base { 4 | constructor(properties = {}) { 5 | super('deleteMessage'); 6 | 7 | this.properties = properties; 8 | } 9 | 10 | from(chat) { 11 | this.properties.chat_id = chat; 12 | return this; 13 | } 14 | 15 | id(id) { 16 | this.properties.message_id = id; 17 | return this; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/types/File.js: -------------------------------------------------------------------------------- 1 | import Base from './Base'; 2 | import mime from 'mime'; 3 | 4 | const TYPES = ['photo', 'video', 'document', 'audio']; 5 | 6 | /** 7 | * File class, used to send pictures/movies/audios/documents to chat 8 | */ 9 | export default class File extends Base { 10 | /** 11 | * Create a new file instance 12 | * @param {object} properties File properties, as defined by Telegram API 13 | */ 14 | constructor(properties = {}) { 15 | super('sendDocument'); 16 | 17 | this.properties = properties; 18 | this._keyboard = new Base(); 19 | } 20 | 21 | /** 22 | * Set chat_id of the message 23 | * @param {number} chat 24 | * @return {object} returns the message object 25 | */ 26 | to(chat) { 27 | this.properties.chat_id = chat; 28 | return this; 29 | } 30 | 31 | /** 32 | * Set file of the message 33 | * @param {string} file File path 34 | * @param {string} fileType (optional) if the first argument is a 35 | * file_id string, this option indicates file type 36 | * @return {object} returns the message object 37 | */ 38 | file(file, fileType) { 39 | if (fileType) { 40 | this.properties[fileType] = { file }; 41 | 42 | return this; 43 | } 44 | 45 | let [type, extension] = mime.lookup(file).split('/'); // eslint-disable-line 46 | if (type === 'image') { 47 | type = 'photo'; 48 | } 49 | 50 | if (extension === 'gif') { 51 | type = 'document'; 52 | } 53 | 54 | if (TYPES.indexOf(type) === -1) { 55 | type = 'document'; 56 | } 57 | 58 | this.properties[type] = { file }; 59 | 60 | this.method = `send${type[0].toUpperCase() + type.slice(1)}`; 61 | 62 | return this; 63 | } 64 | 65 | /** 66 | * Set caption for photos 67 | * @param {string} text caption's text 68 | * @return {object} returns the message object 69 | */ 70 | caption(text) { 71 | this.properties.caption = text; 72 | return this; 73 | } 74 | 75 | /** 76 | * Set reply_to_message_id of the message 77 | * @param {number} id message_id of the message to reply to 78 | * @return {object} returns the message object 79 | */ 80 | reply(id) { 81 | this.properties.reply_to_message_id = id; 82 | return this; 83 | } 84 | 85 | /** 86 | * Sets keyboard of the message 87 | * The value of reply_markup is set to the sanitized keyboard properties 88 | * i.e. reply_markup = JSON.stringify(kb.getProperties()) 89 | * @param {object} kb A Keyboard instance 90 | * @return {object} returns the message object 91 | */ 92 | keyboard(kb) { 93 | this._keyboard = kb; 94 | return this; 95 | } 96 | 97 | // This class inherits Base's send method 98 | } 99 | -------------------------------------------------------------------------------- /src/types/Forward.js: -------------------------------------------------------------------------------- 1 | import Base from './Base'; 2 | 3 | /** 4 | * Forward class, used to forward messages from a chat to another 5 | */ 6 | export default class Forward extends Base { 7 | /** 8 | * Create a new forward message 9 | * @param {object} properties Forward Message properties, as defined by 10 | * Telegram API 11 | */ 12 | constructor(properties = {}) { 13 | super('forwardMessage'); 14 | 15 | this.properties = properties; 16 | this._keyboard = new Base(); 17 | } 18 | 19 | /** 20 | * Set chat_id of the message 21 | * @param {number} chat 22 | * @return {object} returns the message object 23 | */ 24 | to(chat) { 25 | this.properties.chat_id = chat; 26 | return this; 27 | } 28 | 29 | /** 30 | * Set from_chat_id, source of message's chat's id 31 | * @param {number} chat Source chat id 32 | * @return {object} returns the message object 33 | */ 34 | from(chat) { 35 | this.properties.from_chat_id = chat; 36 | return this; 37 | } 38 | 39 | /** 40 | * Sets message_id, the message to forward from source to target chat 41 | * @param {number} message ID of the message to forward 42 | * @return {object} returns the message object 43 | */ 44 | message(message) { 45 | this.properties.message_id = message; 46 | return this; 47 | } 48 | 49 | // This class inherits Base's send method 50 | } 51 | -------------------------------------------------------------------------------- /src/types/Keyboard.js: -------------------------------------------------------------------------------- 1 | import Base from './Base'; 2 | 3 | /** 4 | * Keyboard class, used to configure keyboards for messages. 5 | * You should pass your instance of this class to message.keyboard() method 6 | */ 7 | export default class Keyboard extends Base { 8 | /** 9 | * Create a new keyboard 10 | * @param {object} properties Keyboard properties, as defined by Telegram API 11 | * See ReplyKeyboardMarkup, ReplyKeyboardHide, 12 | * ForceReply 13 | */ 14 | constructor(properties = {}) { 15 | super(); 16 | 17 | this.properties = properties; 18 | } 19 | 20 | /** 21 | * Set the keyboard property of reply_markup 22 | * @param {array} keys An array of arrays, with the format of 23 | * Column Column 24 | * Row [['TopLeft', 'TopRight'], 25 | * Row ['BottomLeft', 'BottomRight']] 26 | * @return {object} returns the keyboard object 27 | */ 28 | keys(keys) { 29 | this.properties.keyboard = keys; 30 | this.properties.hide_keyboard = false; 31 | return this; 32 | } 33 | 34 | /** 35 | * Set force_keyboard property of reply_markup 36 | * @param {boolean} enable value of force_keyboard, defaults to true 37 | * @return {object} returns the keyboard object 38 | */ 39 | force(enable = true) { 40 | this.properties.force_keyboard = enable; 41 | return this; 42 | } 43 | 44 | /** 45 | * Set resize_keyboard property of reply_markup 46 | * @param {boolean} enable value of resize_keyboard, defaults to true 47 | * @return {object} returns the keyboard object 48 | */ 49 | resize(enable = true) { 50 | this.properties.resize_keyboard = enable; 51 | return this; 52 | } 53 | 54 | /** 55 | * Set force_keyboard property of reply_markup 56 | * @param {boolean} enable value of force_keyboard, defaults to true 57 | * @return {object} returns the keyboard object 58 | */ 59 | oneTime(enable = true) { 60 | this.properties.one_time_keyboard = enable; 61 | return this; 62 | } 63 | 64 | /** 65 | * Set selective property of reply_markup 66 | * @param {boolean} enable value of force_keyboard, defaults to true 67 | * @return {object} returns the keyboard object 68 | */ 69 | selective(enable = true) { 70 | this.properties.selective = enable; 71 | return this; 72 | } 73 | 74 | /** 75 | * Set hide_keyboard property of reply_markup to true 76 | * @return {object} returns the keyboard object 77 | */ 78 | hide() { 79 | this.properties = { 80 | hide_keyboard: true 81 | }; 82 | 83 | return this; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/types/Message.js: -------------------------------------------------------------------------------- 1 | import Base from './Base'; 2 | 3 | const MSG_MAX_LENGTH = 4096; 4 | 5 | function splitToChunks(str, size) { 6 | const numChunks = Math.ceil(str.length / size); 7 | const chunks = new Array(numChunks); 8 | 9 | for (let i = 0, o = 0; i < numChunks; ++i, o += size) { 10 | chunks[i] = str.substr(o, size); 11 | } 12 | 13 | return chunks; 14 | } 15 | 16 | /** 17 | * Message class, used to send message to a chat 18 | */ 19 | export default class Message extends Base { 20 | /** 21 | * Create a new message 22 | * @param {object} properties Message properties, as defined by Telegram API 23 | */ 24 | constructor(properties = {}) { 25 | super('sendMessage'); 26 | 27 | this.properties = properties; 28 | this._keyboard = new Base(); 29 | } 30 | 31 | /** 32 | * Set chat_id of the message 33 | * @param {number} chat 34 | * @return {object} returns the message object 35 | */ 36 | to(chat) { 37 | this.properties.chat_id = chat; 38 | return this; 39 | } 40 | 41 | /** 42 | * Set text of the message 43 | * @param {string} text Message's content 44 | * @return {object} returns the message object 45 | */ 46 | text(text) { 47 | this.properties.text = text; 48 | return this; 49 | } 50 | 51 | /** 52 | * Set text of the message in HTML format 53 | * @param {string} text Message's content in HTML format 54 | * @return {object} returns the message object 55 | */ 56 | html(text) { 57 | this.properties.parse_mode = 'HTML'; 58 | if (text) { 59 | this.properties.text = text; 60 | } 61 | return this; 62 | } 63 | 64 | /** 65 | * Set text of the message in Markdown format 66 | * @param {string} text Message's content in Markdown format 67 | * @return {object} returns the message object 68 | */ 69 | markdown(text) { 70 | this.properties.parse_mode = 'Markdown'; 71 | if (text) { 72 | this.properties.text = text; 73 | } 74 | return this; 75 | } 76 | 77 | /** 78 | * Set reply_to_message_id of the message 79 | * @param {number} id message_id of the message to reply to 80 | * @return {object} returns the message object 81 | */ 82 | reply(id) { 83 | this.properties.reply_to_message_id = id; 84 | return this; 85 | } 86 | 87 | /** 88 | * Set disable_web_page_preview of the message 89 | * @param {boolean} enable 90 | * @return {object} returns the message object 91 | */ 92 | preview(enable = true) { 93 | this.properties.disable_web_page_preview = !enable; 94 | return this; 95 | } 96 | 97 | /** 98 | * Sets keyboard of the message 99 | * The value of reply_markup is set to the sanitized keyboard properties 100 | * i.e. reply_markup = JSON.stringify(kb.getProperties()) 101 | * @param {object} kb A Keyboard instance 102 | * @return {object} returns the message object 103 | */ 104 | keyboard(kb) { 105 | this._keyboard = kb; 106 | return this; 107 | } 108 | 109 | // override Base.prototype._apiSend() method 110 | _apiSend(bot) { 111 | if (this.properties.text && this.properties.text.length > MSG_MAX_LENGTH) { 112 | let promiseChain = Promise.resolve(); 113 | const textChunks = splitToChunks(this.properties.text, MSG_MAX_LENGTH); 114 | 115 | textChunks.forEach(chunk => { 116 | const properties = Object.assign({}, this.properties, { text: chunk }); 117 | // any unclosed tags, text modifiers will not send out, send as pure text 118 | delete properties.parse_mode; 119 | 120 | promiseChain = promiseChain.then(() => bot.api[this.method](properties)); 121 | }); 122 | 123 | return promiseChain; 124 | } 125 | 126 | return bot.api[this.method](this.properties); 127 | } 128 | 129 | // This class inherits Base's send method 130 | } 131 | -------------------------------------------------------------------------------- /src/types/Question.js: -------------------------------------------------------------------------------- 1 | import Message from './Message'; 2 | import Keyboard from './Keyboard'; 3 | 4 | /** 5 | * Question class, extends Message 6 | * Sends a message, shows a keyboard with the answers provided, and validates 7 | * the answer 8 | */ 9 | export default class Question extends Message { 10 | /** 11 | * Create a new question 12 | * @param {object} options Options, same as Message, plus `answers` which 13 | * is a keyboard layout, see Keyboard#keys 14 | */ 15 | constructor(options = {}) { 16 | super(options); 17 | 18 | const kb = new Keyboard().force().oneTime().selective(); 19 | this.keyboard(kb); 20 | 21 | this.answers(options.answers); 22 | } 23 | 24 | /** 25 | * Sets answers of the question. This is passed to Keyboard#keys, and then 26 | * used to validate the answer given 27 | * @param {array} answers Array of arrays of strings, same as Keyboard#keys 28 | * @return {object} returns the question object 29 | */ 30 | answers(answers) { 31 | this._answers = answers; 32 | this._keyboard.keys(answers); 33 | return this; 34 | } 35 | 36 | /** 37 | * Sends the question (same as Message#send), and validates the answer given 38 | * if the answer is one of the defined answers, resolves, else rejects 39 | * You should not manually use this method unless you're extending this class 40 | * You should instead use bot.send(question); 41 | * @param {object} bot 42 | * @return {promise} A promise which is resolved in case of valid answer, and 43 | * rejected in case of invalid answer 44 | */ 45 | send(bot) { 46 | const answers = this._answers; 47 | 48 | return super.send(bot).then(message => { 49 | let answer; 50 | 51 | answers.forEach(function find(a) { 52 | if (Array.isArray(a)) { 53 | a.forEach(find); 54 | } 55 | if (a === message.text) { 56 | answer = a; 57 | } 58 | }); 59 | 60 | if (answer) { 61 | this.emit('question:answer', answer, message); 62 | return message; 63 | } 64 | 65 | this.emit('question:invalid', message); 66 | throw message; 67 | }); 68 | } 69 | } 70 | --------------------------------------------------------------------------------