├── .eslintrc.yml ├── .gitignore ├── .npmignore ├── .travis.yml ├── CHANGELOG.md ├── Gruntfile.js ├── LICENSE.txt ├── README.md ├── doc ├── api.hbs └── api.md ├── example ├── emojified.js ├── init.js ├── paging-text.js ├── queued-up.js ├── ratelimited.js └── web-socket.js ├── lib ├── client.js └── index.js ├── package.json └── test ├── .eslintrc.yml ├── README.md └── test.index.js /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | env: 2 | es6: true 3 | node: true 4 | extends: 'eslint:recommended' 5 | rules: 6 | indent: 7 | - error 8 | - 4 9 | linebreak-style: 10 | - error 11 | - unix 12 | quotes: 13 | - error 14 | - double 15 | semi: 16 | - error 17 | - always 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | coverage/ 3 | package-lock.json 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # from .gitignore 2 | node_modules/ 3 | coverage/ 4 | 5 | # additions 6 | example/ 7 | Gruntfile.js 8 | test/ 9 | .eslintrc.yml 10 | .gitignore 11 | .travis.yml 12 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '6' 4 | - '7' 5 | - '8' 6 | - '9' 7 | after_script: npm run test-coverage && cat ./coverage/lcov.info | coveralls 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | This project adheres to [Semantic Versioning](http://semver.org/). 5 | 6 | 7 | ## Unreleased 8 | 9 | Removed: 10 | 11 | * Removed fanciness: Openshift WebHook 12 | 13 | 14 | ## 0.13.0 - 2017-12-21 15 | 16 | Added: 17 | 18 | * Add option `ratelimiting.maxBackoff` 19 | * Add support for **node-telegram-bot-api@v0.30.0** 20 | * Dependencies updated 21 | 22 | 23 | ## 0.12.0 - 2017-10-22 24 | 25 | Added: 26 | 27 | * Added method `Tgfancy#resolveChatId()` 28 | * Add support for **node-telegram-bot-api@v0.29.0** 29 | * Dependencies updated 30 | 31 | 32 | ## 0.11.0 - 2017-08-15 33 | 34 | Added: 35 | 36 | * Support NTBA 0.28.0 37 | * Dependencies updated 38 | 39 | 40 | ## 0.10.0 - 2017-05-06 41 | 42 | Changed: 43 | 44 | * Rate-limiting logic updated to use update NTBA API, i.e. `error.response` 45 | object. 46 | * Dependencies updated 47 | 48 | 49 | ## 0.9.0 - 2017-02-16 50 | 51 | Added: 52 | 53 | * Added method `Tgfancy#hasOpenWebSocket()` 54 | 55 | 56 | ## 0.8.0 - 2017-01-21 57 | 58 | Added: 59 | 60 | * New fanciness: websocket updates 61 | 62 | Changed: 63 | 64 | * Redesign feature options 65 | 66 | 67 | ## 0.7.0 - 2016-12-30 68 | 69 | Added: 70 | 71 | * Add and modify functions requiring chat ID resolution 72 | * New fanciness: emojification, ratelimiting 73 | 74 | 75 | ## 0.6.0 - 2016-11-15 76 | 77 | Added: 78 | 79 | * Automatically disable polling if setting webhook on Openshift 80 | 81 | 82 | ## 0.5.0 - 2016-11-15 83 | 84 | Added: 85 | 86 | * New fanciness: Openshift WebHook 87 | 88 | 89 | ## 0.4.0 - 2016-11-10 90 | 91 | Added: 92 | 93 | * Toggling features 94 | 95 | Changed: 96 | 97 | * All options to **tgfancy** **MUST** be placed under the `tgfancy` key. 98 | 99 | 100 | ## 0.3.0 - 2016-11-05 101 | 102 | Added: 103 | 104 | * Resolve all chat IDs in method's arguments 105 | * Make package smaller by adding more rules to `.npmignore` 106 | 107 | 108 | ## 0.2.1 - 2016-11-04 109 | 110 | Changed: 111 | 112 | * Tgfancy is a proper sub-class of TelegramBot 113 | * Dependencies updated 114 | 115 | 116 | ## 0.2.0 - 2016-11-03 117 | 118 | Added: 119 | 120 | * Tests and CI have been added 121 | 122 | 123 | Changed: 124 | 125 | * Drop support for Node.js v4 126 | 127 | 128 | ## 0.1.1 - 2016-10-30 129 | 130 | Fixed: 131 | 132 | * Add missing dependency, 'tg-resolve' 133 | 134 | 135 | ## 0.1.0 - 2016-10-30 136 | 137 | Added: 138 | 139 | * Text paging, in `Tgfancy#sendMessage()` 140 | * Chat ID resolution 141 | * Kick-without-Ban 142 | 143 | 144 | ## 0.0.0 - 2016-10-26 145 | 146 | **Out in the Wild** 147 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | /** 2 | * The MIT License (MIT) 3 | * Copyright (c) 2016 GochoMugo 4 | * 5 | * Task runner. 6 | */ 7 | 8 | 9 | // npm-installed modules 10 | const loadTasks = require("load-grunt-tasks"); 11 | 12 | 13 | exports = module.exports = function(grunt) { 14 | loadTasks(grunt); 15 | 16 | grunt.initConfig({ 17 | eslint: { 18 | target: [ 19 | "lib/**/*.js", 20 | "test/**/*.js", 21 | "example/**/*.js", 22 | "Gruntfile.js", 23 | ], 24 | }, 25 | mochaTest: { 26 | src: ["test/*.js"], 27 | }, 28 | }); 29 | 30 | grunt.registerTask("testenv", "mark env as testing", function() { 31 | process.env.NODE_ENV = "testing"; 32 | }); 33 | grunt.registerTask("lint", ["eslint"]); 34 | grunt.registerTask("test", ["testenv", "lint", "mochaTest"]); 35 | }; 36 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 GochoMugo 4 | 5 | Permission is hereby granted, free of charge, to any person 6 | obtaining a copy of this software and associated 7 | documentation files (the "Software"), to deal in the Software 8 | without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, 10 | and/or sell copies of the Software, and to permit persons to 11 | whom the Software is furnished to do so, subject to the 12 | following conditions: 13 | 14 | The above copyright notice and this permission notice shall 15 | be included in all copies or substantial portions of the 16 | Software. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY 19 | KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 20 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR 21 | PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 22 | OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR 23 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 24 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 25 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tgfancy 2 | 3 | > A Fancy, Higher-Level Wrapper for Telegram Bot API 4 | > 5 | > Built on top of [node-telegram-bot-api][api]. 6 | 7 | [![Version](https://img.shields.io/npm/v/tgfancy.svg)](https://www.npmjs.com/package/tgfancy) 8 | [![Supported Node.js Versions](https://img.shields.io/node/v/tgfancy.svg)](https://www.npmjs.com/package/tgfancy) 9 | 10 | 11 | ## installation: 12 | 13 | ```bash 14 | $ npm install tgfancy --save 15 | ``` 16 | 17 | 18 | ## sample usage: 19 | 20 | ```js 21 | const Tgfancy = require("tgfancy"); 22 | const bot = new Tgfancy(token, { 23 | // all options to 'tgfancy' MUST be placed under the 24 | // 'tgfancy' key, as shown below 25 | tgfancy: { 26 | option: "value", 27 | }, 28 | }); 29 | 30 | bot.sendMessage(chatId, "text message"); 31 | ``` 32 | 33 | 34 | ## introduction: 35 | 36 | **tgfancy is basically [node-telegram-bot-api][api] on steroids.** 37 | Therefore, you **MUST** know how to work with [node-telegram-bot-api][api] 38 | before using this wrapper. **tgfancy** is a **drop-in replacement**! 39 | 40 | **tgfancy** provides **ALL** the methods exposed by [**TelegramBot**][api-bot] 41 | from [node-telegram-bot-api][api]. This means that all the methods from 42 | **TelegramBot** are available on **Tgfancy**. This also includes the 43 | constructor. 44 | 45 | 46 | ## fanciness: 47 | 48 | > Here comes the fanciness 49 | 50 | **tgfancy** adds the following fanciness: 51 | 52 | * [Ordered Sending](#ordered-sending) 53 | * [Text Paging](#text-paging) 54 | * [Rate-Limiting](#ratelimiting) 55 | * [Emojification](#emojification) 56 | * [Fetching Updates via WebSocket](#websocket-updates) 57 | 58 | Have a look at the [API Reference][doc-api]. 59 | 60 | [doc-api]:https://github.com/GochoMugo/tgfancy/tree/master/doc/api.md 61 | 62 | 63 | 64 | ### feature options: 65 | 66 | Most of the features are **enabled by default**. Such a feature (enabled by 67 | default) is similar to doing something like: 68 | 69 | ```js 70 | const bot = new Tgfancy(token, { 71 | tgfancy: { 72 | feature: true, // 'true' to enable! 73 | }, 74 | }); 75 | ``` 76 | 77 | Such a feature can be **disabled** like so: 78 | 79 | ```js 80 | const bot = new Tgfancy(token, { 81 | tgfancy: { 82 | feature: false, // 'false' to disable! 83 | }, 84 | }); 85 | ``` 86 | 87 | If a feature allows more options, you may pass an object, instead of `true`, 88 | like: 89 | 90 | ```js 91 | const bot = new Tgfancy(token, { 92 | tgfancy: { 93 | feature: { // feature will be enabled! 94 | key: "value", // feature option 95 | }, 96 | }, 97 | }); 98 | ``` 99 | 100 | See example at `example/feature-toggled.js`. 101 | 102 | 103 | --- 104 | 105 | 106 | 107 | ### Ordered sending: 108 | 109 | Using an internal queue, we can ensure messages are sent, *to a specific 110 | chat*, in order without having to implement the 111 | wait-for-response-to-send-next-message logic. 112 | 113 | **Feature option:** `orderedSending` (see [above](#feature-options)) 114 | 115 | For example, 116 | 117 | ```js 118 | bot.sendMessage(chatId, "first message"); 119 | bot.sendMessage(chatId, "second message"); 120 | ``` 121 | 122 | With **tgfancy**, you are guaranteed that `"first message"` will be sent 123 | **before** `"second message"`. 124 | 125 | Fancied functions: `[ 126 | "sendAudio", 127 | "sendDocument", 128 | "sendGame", 129 | "sendInvoice", 130 | "sendLocation", 131 | "sendMessage", 132 | "sendPhoto", 133 | "sendSticker", 134 | "sendVenue", 135 | "sendVideo", 136 | "sendVideoNote", 137 | "sendVoice", 138 | ]` 139 | 140 | An earlier discussion on this feature can be found [here][docs-queue-1]. 141 | See example at `example/queued-up.js`. 142 | 143 | [docs-queue-1]:https://github.com/yagop/node-telegram-bot-api/issues/192 144 | 145 | 146 | --- 147 | 148 | 149 | 150 | ### Text paging: 151 | 152 | The `Tgfancy#sendMessage(chatId, message)` automatically pages messages, 153 | that is, if `message` is longer than the maximum limit of 4096 characters, 154 | the `message` is split into multiple parts. These parts are sent serially, 155 | one after the other. 156 | 157 | The page number, for example `[01/10]`, is prefixed to the text. 158 | 159 | **Feature option:** `textPaging` (see [above](#feature-options)) 160 | 161 | For example, 162 | 163 | ```js 164 | // 'veryLongText' is a message that contains more than 4096 characters 165 | // Usually, trying to send this message would result in the API returning 166 | // an error. 167 | bot.sendMessage(chatId, veryLongText) 168 | .then(function(messages) { 169 | // 'messages' is an Array containing Message objects from 170 | // the Telegram API, for each of the parts 171 | console.log("message has been sent in multiple pages"); 172 | }).catch(function(error) { 173 | console.error(error); 174 | }); 175 | ``` 176 | 177 | **Note:** We do **not** support sending messages that'd result into more 178 | than 99 parts. 179 | 180 | See example at `example/paging-text.js`. 181 | 182 | --- 183 | 184 | 185 | 186 | ### Rate-Limiting: 187 | 188 | Any request that encounters a `429` error i.e. rate-limiting error 189 | will be retried after some time (as advised by the Telegram API or 190 | 1 minute by default). 191 | The request will be retried for a number of times, until it succeeds or 192 | the maximum number of retries has been reached 193 | 194 | **Feature option:** `ratelimiting` (see [above](#feature-options)) 195 | 196 | For example, 197 | 198 | ```js 199 | const bot = new Tgfancy(token, { 200 | tgfancy: { 201 | // options for this fanciness 202 | ratelimiting: { 203 | // number of times to retry a request before giving up 204 | maxRetries: 10, // default: 10 205 | // number of milliseconds to wait before retrying the 206 | // request (if API does not advise us otherwise!) 207 | timeout: 1000 * 60, // default: 60000 (1 minute) 208 | // (optional) function invoked whenever this fanciness handles 209 | // any ratelimiting error. 210 | // this is useful for debugging and analysing your bot 211 | // behavior 212 | notify(methodName, ...args) { // default: undefined 213 | // 'methodName' is the name of the invoked method 214 | // 'args' is an array of the arguments passed to the method 215 | // do something useful here 216 | // ...snip... 217 | }, 218 | // maximum number of milliseconds to allow for waiting 219 | // in backoff-mode before retrying the request. 220 | // This is important to avoid situations where the server 221 | // can cause lengthy timeouts e.g. too long of a wait-time 222 | // that is causes adverse effects on efficiency and performance. 223 | maxBackoff: 1000 * 60 * 5, // default: 5 minutes 224 | }, 225 | }, 226 | }); 227 | ``` 228 | 229 | Fancied functions: `[ 230 | "addStickerToSet", 231 | "answerCallbackQuery", 232 | "answerInlineQuery", 233 | "answerPreCheckoutQuery", 234 | "answerShippingQuery", 235 | "createNewStickerSet", 236 | "deleteChatPhoto", 237 | "deleteChatStickerSet", 238 | "deleteMessage", 239 | "deleteStickerFromSet", 240 | "downloadFile", 241 | "editMessageCaption", 242 | "editMessageLiveLocation", 243 | "editMessageReplyMarkup", 244 | "editMessageText", 245 | "exportChatInviteLink", 246 | "forwardMessage", 247 | "getChat", 248 | "getChatAdministrators", 249 | "getChatMember", 250 | "getChatMembersCount", 251 | "getFile", 252 | "getFileLink", 253 | "getGameHighScores", 254 | "getStickerSet", 255 | "getUpdates", 256 | "getUserProfilePhotos", 257 | "kickChatMember", 258 | "leaveChat", 259 | "pinChatMessage", 260 | "promoteChatMember", 261 | "restrictChatMember", 262 | "sendAudio", 263 | "sendChatAction", 264 | "sendContact", 265 | "sendDocument", 266 | "sendGame", 267 | "sendInvoice", 268 | "sendLocation", 269 | "sendMediaGroup", 270 | "sendMessage", 271 | "sendPhoto", 272 | "sendSticker", 273 | "sendVenue", 274 | "sendVideo", 275 | "sendVideoNote", 276 | "sendVoice", 277 | "setChatDescription", 278 | "setChatPhoto", 279 | "setChatStickerSet", 280 | "setChatTitle", 281 | "setGameScore", 282 | "setStickerPositionInSet", 283 | "setWebHook", 284 | "stopMessageLiveLocation", 285 | "unbanChatMember", 286 | "unpinChatMessage", 287 | "uploadStickerFile", 288 | ]` 289 | 290 | An earlier discussion on this feature can be found [here][docs-ratelimiting-1]. 291 | See example at `example/ratelimited.js`. 292 | 293 | [docs-ratelimiting-1]:https://github.com/GochoMugo/tgfancy/issues/4 294 | 295 | 296 | --- 297 | 298 | 299 | 300 | ### Emojification: 301 | 302 | Any Github-flavoured Markdown emoji, such as `:heart:` can be replaced 303 | automatically with their corresponding Unicode values. By default, 304 | uses the [node-emoji][emoji] library (Go give a star!). 305 | **Disabled by default**. 306 | 307 | **Feature option:** `emojification` (see [above](#feature-options)) 308 | 309 | For example, 310 | 311 | ```js 312 | const bot = new Tgfancy(token, { 313 | tgfancy: { 314 | emojification: true, 315 | }, 316 | }); 317 | bot.sendMessage(chatId, "Message text with :heart: emoji") 318 | .then(function(msg) { 319 | // 'msg' is the Message sent to the chat 320 | console.log(msg.text); // => "Message text with ❤️ emoji" 321 | }); 322 | ``` 323 | 324 | However, it is possible to define a custom function used to perform 325 | emojification. The function **must** have the signature, 326 | `emojify(text)` and return the emojified text. 327 | 328 | ```js 329 | const bot = new Tgfancy(token, { 330 | tgfancy: { 331 | emojification: { 332 | emojify(text) { 333 | // emojify here 334 | // ... snip ... 335 | return emojifiedText; 336 | }, 337 | }, 338 | }, 339 | }); 340 | ``` 341 | 342 | Fancied functions: `["sendMessage", "editMessageText"]` 343 | 344 | See example at `example/emojified.js`. 345 | 346 | [emoji]:https://github.com/omnidan/node-emoji#readme 347 | 348 | 349 | --- 350 | 351 | 352 | 353 | ### Fetching Updates via WebSocket: 354 | 355 | In addition to polling and web-hooks, this introduces another mechanism 356 | for fetching your updates: **WebSocket**. While currently it is **not** 357 | officially supported by Telegram, we have a *bridge* up and running 358 | that you can connect to for this purpose. **Disabled by default**. 359 | 360 | **Feature option:** `webSocket` (see [above](#feature-options)) 361 | 362 | For example, 363 | 364 | ```js 365 | const bot = new Tgfancy(token, { 366 | tgfancy: { 367 | webSocket: true, 368 | }, 369 | }); 370 | ``` 371 | 372 | The current default bridge is at 373 | *wss://telegram-websocket-bridge-qalwkrjzzs.now.sh* and is being run by 374 | [@GingerPlusPlus][gingerplusplus]. 375 | 376 | You can specify more options as so: 377 | 378 | ```js 379 | const bot = new Tgfancy(token, { 380 | tgfancy: { 381 | webSocket: { 382 | // specify a custom URL for a different bridge 383 | url: "wss://telegram-websocket-bridge-qalwkrjzzs.now.sh", 384 | // immediately open the websocket 385 | autoOpen: true, 386 | }, 387 | }, 388 | }); 389 | ``` 390 | 391 | See example at `example/web-socket.js`. 392 | 393 | [gingerplusplus]:https://github.com/GingerPlusPlus 394 | 395 | 396 | --- 397 | 398 | 399 | ## license: 400 | 401 | **The MIT License (MIT)** 402 | 403 | Copyright (c) 2016 GochoMugo 404 | 405 | 406 | [api]: https://github.com/yagop/node-telegram-bot-api 407 | [api-bot]: https://github.com/yagop/node-telegram-bot-api#telegrambot 408 | -------------------------------------------------------------------------------- /doc/api.hbs: -------------------------------------------------------------------------------- 1 | # API Reference 2 | 3 | {{#class name="Tgfancy"~}} 4 | {{>header~}} 5 | {{>body~}} 6 | {{>member-index~}} 7 | {{>members~}} 8 | {{/class}} 9 | -------------------------------------------------------------------------------- /doc/api.md: -------------------------------------------------------------------------------- 1 | # API Reference 2 | 3 | 4 | 5 | ## Tgfancy 6 | Tgfancy 7 | 8 | **Kind**: global class 9 | 10 | * [Tgfancy](#Tgfancy) 11 | * [new Tgfancy(token, [options])](#new_Tgfancy_new) 12 | * [.openWebSocket()](#Tgfancy+openWebSocket) ⇒ Promise 13 | * [.closeWebSocket()](#Tgfancy+closeWebSocket) ⇒ Promise 14 | * [.hasOpenWebSocket()](#Tgfancy+hasOpenWebSocket) ⇒ Boolean 15 | 16 | 17 | 18 | ### new Tgfancy(token, [options]) 19 | Construct a new client. 20 | 'token' and 'options' are passed to TelegramBot. 21 | 22 | 23 | | Param | Type | Default | Description | 24 | | --- | --- | --- | --- | 25 | | token | String | | | 26 | | [options] | Options | | | 27 | | [options.emojification] | Boolean \| Object | | | 28 | | [options.emojify] | function | | | 29 | | [options.orderedSending] | Boolean | true | | 30 | | [options.ratelimiting] | Boolean \| Object | true | | 31 | | [options.ratelimiting.maxRetries] | Number | | | 32 | | [options.ratelimiting.timeout] | Number | | | 33 | | [options.ratelimiting.notify] | function | | | 34 | | [options.ratelimiting.maxBackoff] | Number | | Maximum number of ms to be in back-off mode | 35 | | [options.textPaging] | Boolean | true | | 36 | | [options.webSocket] | Boolean \| Object | | | 37 | | [options.webSocket.url] | String | | | 38 | | [options.webSocket.autoOpen] | Boolean | true | | 39 | 40 | 41 | 42 | ### tgfancy.openWebSocket() ⇒ Promise 43 | Open a WebSocket for fetching updates from the bridge. 44 | Multiple invocations do nothing if websocket is already open. 45 | 46 | **Kind**: instance method of [Tgfancy](#Tgfancy) 47 | 48 | 49 | ### tgfancy.closeWebSocket() ⇒ Promise 50 | Close the websocket. 51 | Multiple invocations do nothing if websocket is already closed. 52 | 53 | **Kind**: instance method of [Tgfancy](#Tgfancy) 54 | 55 | 56 | ### tgfancy.hasOpenWebSocket() ⇒ Boolean 57 | Return `true` if we have an open websocket. Otherwise, `false`. 58 | 59 | **Kind**: instance method of [Tgfancy](#Tgfancy) 60 | -------------------------------------------------------------------------------- /example/emojified.js: -------------------------------------------------------------------------------- 1 | /** 2 | * The MIT License (MIT) 3 | * Copyright (c) 2016 GochoMugo 4 | * 5 | * An example demonstrating emojification of text 6 | */ 7 | /* eslint-disable no-console */ 8 | 9 | 10 | 11 | // built-in modules 12 | const assert = require("assert"); 13 | 14 | 15 | // own modules 16 | const Tgfancy = require(".."); 17 | const state = require("./init"); 18 | 19 | 20 | // module variables 21 | const bot = new Tgfancy(state.token, { 22 | tgfancy: { 23 | emojification: true, 24 | }, 25 | }); 26 | const emoji = ":heart:"; 27 | 28 | 29 | bot.sendMessage(state.userId, "emojify " + emoji) 30 | .then(function(msg) { 31 | assert.ok(msg.text.indexOf(emoji) === -1); 32 | console.log("Message sent to chat, after emojification"); 33 | }).catch(function(error) { 34 | console.error(error); 35 | process.exit(1); 36 | }); 37 | -------------------------------------------------------------------------------- /example/init.js: -------------------------------------------------------------------------------- 1 | /** 2 | * The MIT License (MIT) 3 | * Copyright (c) 2016 GochoMugo 4 | * 5 | * Handles loading necessary configurations and other relevant configuration 6 | * details. 7 | */ 8 | /* eslint-disable no-console */ 9 | 10 | 11 | // module variables 12 | const token = process.env.TELEGRAM_TOKEN; 13 | const userId = process.env.TELEGRAM_USERID; 14 | const username = process.env.TELEGRAM_USERNAME; 15 | 16 | 17 | if (!token) { 18 | console.error("Error: Telegram token is missing"); 19 | process.exit(1); 20 | } 21 | if (!userId) { 22 | console.error("Error: Telegram User ID is missing"); 23 | process.exit(1); 24 | } 25 | if (!username) { 26 | console.error("Error: Telegram username is missing"); 27 | process.exit(1); 28 | } 29 | 30 | 31 | // defining and exporting the state that will be available to all 32 | // examples, when they are being run 33 | exports = module.exports = { token, userId, username }; 34 | -------------------------------------------------------------------------------- /example/paging-text.js: -------------------------------------------------------------------------------- 1 | /** 2 | * The MIT License (MIT) 3 | * Copyright (c) 2016 GochoMugo 4 | * 5 | * An example demonstrating paging of text in sendMessage(). 6 | */ 7 | /* eslint-disable no-console */ 8 | 9 | 10 | // own modules 11 | const Tgfancy = require(".."); 12 | const state = require("./init"); 13 | 14 | 15 | // module variables 16 | const bot = new Tgfancy(state.token); 17 | const longText = Array(4096 * 3 + 1).join("#"); 18 | 19 | 20 | bot.sendMessage(state.userId, longText) 21 | .then(function(messages) { 22 | console.log("Long message has been set in %d 'pages'", messages.length); 23 | }).catch(function(error) { 24 | console.error(error); 25 | process.exit(1); 26 | }); 27 | -------------------------------------------------------------------------------- /example/queued-up.js: -------------------------------------------------------------------------------- 1 | /** 2 | * The MIT License (MIT) 3 | * Copyright (c) 2016 GochoMugo 4 | * 5 | * An example demonstrating queueing of sending functions. 6 | */ 7 | /* eslint-disable no-console */ 8 | 9 | 10 | // own modules 11 | const Tgfancy = require(".."); 12 | const state = require("./init"); 13 | 14 | 15 | // module variables 16 | const bot = new Tgfancy(state.token); 17 | 18 | 19 | function toLog(message) { 20 | return function() { console.log(message); }; 21 | } 22 | 23 | 24 | bot.sendMessage(state.userId, "first message").then(toLog("first sent")); 25 | bot.sendMessage(state.userId, "second message").then(toLog("second sent")); 26 | setTimeout(function() { 27 | bot.sendMessage(state.userId, "third message").then(toLog("third sent")); 28 | }, 2000); 29 | -------------------------------------------------------------------------------- /example/ratelimited.js: -------------------------------------------------------------------------------- 1 | /** 2 | * The MIT License (MIT) 3 | * Copyright (c) 2016 GochoMugo 4 | * 5 | * An example demonstrating rate-limiting. 6 | */ 7 | /* eslint-disable no-console */ 8 | 9 | 10 | // own modules 11 | const Tgfancy = require(".."); 12 | const state = require("./init"); 13 | 14 | 15 | // module variables 16 | const bot = new Tgfancy(state.token, { 17 | tgfancy: { 18 | ratelimiting: { 19 | notify(methodName) { 20 | console.log("Handling rate-limiting error from %s", methodName); 21 | console.log("Exiting without waiting..."); 22 | process.exit(); 23 | }, 24 | }, 25 | }, 26 | }); 27 | const longText = Array(4096 * 9 + 1).join("#"); 28 | 29 | 30 | setInterval(function() { 31 | bot.sendMessage(state.userId, longText) 32 | .catch(function(error) { 33 | console.error(error); 34 | process.exit(1); 35 | }); 36 | }, 5); 37 | -------------------------------------------------------------------------------- /example/web-socket.js: -------------------------------------------------------------------------------- 1 | /** 2 | * The MIT License (MIT) 3 | * Copyright (c) 2016 GochoMugo 4 | * 5 | * An example demonstrating use of websocket to fetch telegram 6 | * updates. 7 | */ 8 | /* eslint-disable no-console */ 9 | 10 | 11 | // own modules 12 | const Tgfancy = require(".."); 13 | const state = require("./init"); 14 | 15 | 16 | // module variables 17 | const bot = new Tgfancy(state.token, { 18 | tgfancy: { 19 | webSocket: true, 20 | }, 21 | }); 22 | 23 | 24 | bot.on("text", function(msg) { 25 | console.log(`>>> ${msg.from.first_name}: ${msg.text}`); 26 | const text = msg.text.split("").reverse().join(""); 27 | bot.sendMessage(msg.chat.id, text).then(() => { 28 | console.log(`<<< bot: ${text}`); 29 | }); 30 | }); 31 | console.log("<<< bot: Send a text message to your bot"); 32 | -------------------------------------------------------------------------------- /lib/client.js: -------------------------------------------------------------------------------- 1 | /** 2 | * The MIT License (MIT) 3 | * Copyright (c) 2016 GochoMugo 4 | * 5 | * The client with the fancy boobs and ass! 6 | * 7 | * Notes: 8 | * ----- 9 | * 1. Use of queue to send messages was first proposed at 10 | * https://github.com/yagop/node-telegram-bot-api/issues/192#issuecomment-249488807 11 | * 2. The Telegram WebSocket Updates bridge is being run by @GingerPlusPlus 12 | */ 13 | 14 | 15 | // npm-installed modules 16 | const _ = require("lodash"); 17 | const Debug = require("debug"); 18 | const emoji = require("node-emoji"); 19 | const Promise = require("bluebird"); 20 | const TelegramBot = require("node-telegram-bot-api"); 21 | const WebSocket = require("ws"); 22 | 23 | 24 | // module variables 25 | const debug = Debug("tgfancy:client"); 26 | // Maximum length of a Message's text 27 | const MAX_MSG_TXT_LEN = 4096; 28 | // URL to our default Telegram WebSocket Updates bridge 29 | const WEBSOCKET_URL = "wss://telegram-websocket-bridge-qalwkrjzzs.now.sh"; 30 | const emojifiedFns = [ 31 | ["editMessageText", { position: 0 }], 32 | ["sendMessage", { position: 1 }], 33 | ]; 34 | const ratelimitedFns = [ 35 | "addStickerToSet", 36 | "answerCallbackQuery", 37 | "answerInlineQuery", 38 | "answerPreCheckoutQuery", 39 | "answerShippingQuery", 40 | "createNewStickerSet", 41 | "deleteChatPhoto", 42 | "deleteChatStickerSet", 43 | "deleteMessage", 44 | "deleteStickerFromSet", 45 | "downloadFile", 46 | "editMessageCaption", 47 | "editMessageLiveLocation", 48 | "editMessageReplyMarkup", 49 | "editMessageText", 50 | "exportChatInviteLink", 51 | "forwardMessage", 52 | "getChat", 53 | "getChatAdministrators", 54 | "getChatMember", 55 | "getChatMembersCount", 56 | "getFile", 57 | "getFileLink", 58 | "getGameHighScores", 59 | "getStickerSet", 60 | "getUpdates", 61 | "getUserProfilePhotos", 62 | "kickChatMember", 63 | "leaveChat", 64 | "pinChatMessage", 65 | "promoteChatMember", 66 | "restrictChatMember", 67 | "sendAudio", 68 | "sendChatAction", 69 | "sendContact", 70 | "sendDocument", 71 | "sendGame", 72 | "sendInvoice", 73 | "sendLocation", 74 | "sendMediaGroup", 75 | "sendMessage", 76 | "sendPhoto", 77 | "sendSticker", 78 | "sendVenue", 79 | "sendVideo", 80 | "sendVideoNote", 81 | "sendVoice", 82 | "setChatDescription", 83 | "setChatPhoto", 84 | "setChatStickerSet", 85 | "setChatTitle", 86 | "setGameScore", 87 | "setStickerPositionInSet", 88 | "setWebHook", 89 | "stopMessageLiveLocation", 90 | "unbanChatMember", 91 | "unpinChatMessage", 92 | "uploadStickerFile", 93 | ]; 94 | // NOTE: we are assuming that a valid chat ID is passed as 95 | // the first argument 96 | const queuedSendFns = [ 97 | "sendAudio", 98 | "sendDocument", 99 | "sendGame", 100 | "sendInvoice", 101 | "sendLocation", 102 | "sendMessage", 103 | "sendPhoto", 104 | "sendSticker", 105 | "sendVenue", 106 | "sendVideo", 107 | "sendVideoNote", 108 | "sendVoice", 109 | ]; 110 | const defaults = { 111 | emojification: { 112 | emojify: emoji.emojify, 113 | }, 114 | orderedSending: true, 115 | ratelimiting: { 116 | _default: true, 117 | maxRetries: 10, 118 | timeout: 1000 * 60, // 1 minute 119 | notify: undefined, 120 | maxBackoff: 1000 * 60 * 5, // 5 minutes 121 | }, 122 | textPaging: true, 123 | webSocket: { 124 | url: WEBSOCKET_URL, 125 | autoOpen: true, 126 | }, 127 | }; 128 | 129 | 130 | class Tgfancy extends TelegramBot { 131 | /** 132 | * Construct a new client. 133 | * 'token' and 'options' are passed to TelegramBot. 134 | * 135 | * @class Tgfancy 136 | * @constructor 137 | * @param {String} token 138 | * @param {Options} [options] 139 | * @param {Boolean|Object} [options.emojification] 140 | * @param {Function} [options.emojify] 141 | * @param {Boolean} [options.orderedSending=true] 142 | * @param {Boolean|Object} [options.ratelimiting=true] 143 | * @param {Number} [options.ratelimiting.maxRetries] 144 | * @param {Number} [options.ratelimiting.timeout] 145 | * @param {Function} [options.ratelimiting.notify] 146 | * @param {Number} [options.ratelimiting.maxBackoff] Maximum number of ms to be in back-off mode 147 | * @param {Boolean} [options.textPaging=true] 148 | * @param {Boolean|Object} [options.webSocket] 149 | * @param {String} [options.webSocket.url] 150 | * @param {Boolean} [options.webSocket.autoOpen=true] 151 | */ 152 | constructor(token, options={}) { 153 | options.tgfancy = options.tgfancy || {}; // NOTE: mutation to original options object 154 | function boolOrObject(key) { 155 | const value = options.tgfancy[key]; 156 | if (value) return { _active: true }; // 'true' or '{...}' 157 | if (typeof value === "undefined" && defaults[key]._default) return { _active: true }; 158 | return { _active: false }; 159 | } 160 | const opts = _.defaultsDeep({ 161 | token, 162 | // options that allow either a 'boolean' or an 'object' 163 | emojification: boolOrObject("emojification"), 164 | ratelimiting: boolOrObject("ratelimiting"), 165 | webSocket: boolOrObject("webSocket"), 166 | }, options.tgfancy, defaults); 167 | 168 | super(token, options); 169 | /* 170 | * Because JS has NO true classes, we need to rename our options 171 | * from 'options', since it would override the instance variable 172 | * in the super class (from within it! Yeah! Ikr! Fuck that!) 173 | */ 174 | this.tgfancy = opts; 175 | const self = this; 176 | 177 | // Working around rate-limits 178 | if (this.tgfancy.ratelimiting._active) { 179 | ratelimitedFns.forEach(function(methodName) { 180 | self[methodName] = self._ratelimit(self[methodName]); 181 | }); 182 | } 183 | 184 | // The TelegramBot#sendMessage() performs paging of 185 | // the text across 4096th-char boundaries 186 | if (this.tgfancy.textPaging) { 187 | this.sendMessage = this._pageText(this.sendMessage); 188 | } 189 | 190 | // Some functions require their text arguments be emojified. 191 | // This should be done BEFORE paging the text to ensure paging 192 | // boundaries are NOT disturbed! 193 | if (this.tgfancy.emojification._active) { 194 | emojifiedFns.forEach(function(methodDesc) { 195 | const methodName = methodDesc[0]; 196 | self[methodName] = self._emojify(self[methodName], methodDesc[1]); 197 | }); 198 | } 199 | 200 | // Some functions are wrapped around to provide queueing of 201 | // multiple messages in a bid to ensure order 202 | if (this.tgfancy.orderedSending) { 203 | // Multiple internal queues are used to ensure *this* client 204 | // sends the messages, to a specific chat, in order 205 | this._sendQueues = {}; 206 | this._sending = {}; 207 | 208 | // some patching to ensure stuff works out of the box ;-) 209 | this._sendQueueTrigger = this._sendQueueTrigger.bind(this); 210 | 211 | queuedSendFns.forEach(function(methodName) { 212 | self[methodName] = self._sendQueueWrap(self[methodName]); 213 | }); 214 | } 215 | 216 | // setting up listening to updates via websocket 217 | if (this.tgfancy.webSocket._active) { 218 | this._ws = null; 219 | if (this.tgfancy.webSocket.autoOpen) this.openWebSocket(); 220 | } 221 | } 222 | 223 | /** 224 | * Return a function that works around rate-limits enforced 225 | * by Telegram servers. 226 | * 227 | * @private 228 | * @param {Function} method 229 | * @return {Function} 230 | * @see https://core.telegram.org/bots/faq#my-bot-is-hitting-limits-how-do-i-avoid-this 231 | */ 232 | _ratelimit(method) { 233 | const self = this; 234 | 235 | return function(...args) { 236 | let retry = 0; 237 | function exec(resolve, reject) { 238 | method.call(self, ...args) 239 | .then(resolve) 240 | .catch(function(error) { 241 | if (!error.response || error.response.statusCode !== 429) { 242 | return reject(error); 243 | } 244 | retry++; 245 | const opts = self.tgfancy.ratelimiting; 246 | if (retry > opts.maxRetries) { 247 | return reject(error); 248 | } 249 | const body = error.response.body; 250 | const params = body ? body.parameters : undefined; 251 | const timeout = params && params["retry_after"] ? (1000 * params["retry_after"]) : opts.timeout; 252 | if (timeout > opts.maxBackoff) { 253 | error = new Error("timeout above maxBackoff"); 254 | error.timeout = timeout; 255 | return reject(error); 256 | } 257 | if (opts.notify) { 258 | opts.notify(method.name, ...args); 259 | } 260 | setTimeout(function() { 261 | exec(resolve, reject); 262 | }, timeout); 263 | }); 264 | } 265 | return new Promise(function(resolve, reject) { 266 | exec(resolve, reject); 267 | }); 268 | }; 269 | } 270 | 271 | /** 272 | * Return a function wrapping around the supplied 'method' that 273 | * uses queueing to send the message. 274 | * 275 | * @private 276 | * @param {Function} method Context-bound function 277 | * @return {Function} The function maintains the same signature as 'method' 278 | */ 279 | _sendQueueWrap(method) { 280 | const self = this; 281 | 282 | return function(...args) { 283 | let resolve, reject; 284 | const promise = new Promise(function(promiseResolve, promiseReject) { 285 | resolve = promiseResolve; 286 | reject = promiseReject; 287 | }); 288 | const chatId = args[0]; 289 | let queue = self._sendQueues[chatId]; 290 | 291 | if (!queue) { 292 | queue = self._sendQueues[chatId] = []; 293 | } 294 | 295 | debug("queueing message to chat %s", chatId); 296 | queue.push({ method, args, resolve, reject }); 297 | process.nextTick(function() { 298 | return self._sendQueueTrigger(chatId); 299 | }); 300 | return promise; 301 | }; 302 | } 303 | 304 | /** 305 | * Trigger processing of the send-queue for a particular chat. 306 | * This is invoked internally to handle queue processing. 307 | * 308 | * @private 309 | * @param {String} chatId 310 | */ 311 | _sendQueueTrigger(chatId) { 312 | const self = this; 313 | const queue = this._sendQueues[chatId]; 314 | const sending = this._sending[chatId]; 315 | 316 | // if we are already processing the queue, or 317 | // there is no queue, bolt! 318 | if (sending || !queue) return; 319 | 320 | this._sending[chatId] = true; 321 | delete this._sendQueues[chatId]; 322 | 323 | debug("processing %d requests in send-queue for chat %s", queue.length, chatId); 324 | Promise.mapSeries(queue, function(request) { 325 | return request.method.apply(self, request.args) 326 | .then(request.resolve) 327 | .catch(request.reject); 328 | }).then(function() { 329 | debug("processing queue complete"); 330 | delete self._sending[chatId]; 331 | // trigger queue processing, as more requests might have been 332 | // queued up while we were busy above 333 | self._sendQueueTrigger(chatId); 334 | }); 335 | } 336 | 337 | /** 338 | * Return a function that wraps around 'sendMessage', to 339 | * add paging fanciness. 340 | * 341 | * @private 342 | * @param {Function} sendMessage 343 | * @return {Function} sendMessage(chatId, message, form) 344 | */ 345 | _pageText(sendMessage) { 346 | const self = this; 347 | 348 | return function(chatId, message, form={}) { 349 | if (message.length < MAX_MSG_TXT_LEN) { 350 | return sendMessage.call(self, chatId, message, form); 351 | } 352 | 353 | let index = 0; 354 | let parts = []; 355 | // we are reserving 8 characters for adding the page number in 356 | // the following format: [01/10] 357 | let reserveSpace = 8; 358 | let shortTextLength = MAX_MSG_TXT_LEN - reserveSpace; 359 | let shortText; 360 | 361 | while ((shortText = message.substr(index, shortTextLength))) { 362 | parts.push(shortText); 363 | index += shortTextLength; 364 | } 365 | 366 | // The reserve space limits us to accommodate for not more 367 | // than 99 pages. We signal an error to the user. 368 | if (parts.length > 99) { 369 | debug("Tgfancy#sendMessage: Paging resulted into more than 99 pages"); 370 | return new Promise(function(resolve, reject) { 371 | const error = new Error("Paging resulted into more than the maximum number of parts allowed"); 372 | error.parts = parts; 373 | return reject(error); 374 | }); 375 | } 376 | 377 | parts = parts.map(function(part, i) { 378 | return `[${i+1}/${parts.length}] ${part}`; 379 | }); 380 | 381 | debug("sending message in %d pages", parts.length); 382 | return Promise.mapSeries(parts, function(part) { 383 | return sendMessage.call(self, chatId, part, form); 384 | }); 385 | }; 386 | } 387 | 388 | /** 389 | * Emojify text to be sent. 390 | * 391 | * @private 392 | * @param {Function} method 393 | * @param {Object} methodDesc 394 | * @param {Number} methodDesc.position Position of 'text' argument 395 | * @return {Function} 396 | */ 397 | _emojify(method, methodDesc) { 398 | const self = this; 399 | 400 | return function() { 401 | const args = arguments; 402 | const text = args[methodDesc.position]; 403 | const emojifiedText = self.tgfancy.emojification.emojify(text); 404 | args[methodDesc.position] = emojifiedText; 405 | return method.call(self, ...args); 406 | }; 407 | } 408 | 409 | /** 410 | * Open a WebSocket for fetching updates from the bridge. 411 | * Multiple invocations do nothing if websocket is already open. 412 | * 413 | * @return {Promise} 414 | */ 415 | openWebSocket() { 416 | if (this._ws) { 417 | return Promise.resolve(); 418 | } 419 | if (this.isPolling() || this.hasOpenWebHook()) { 420 | return Promise.reject(new Error("WebSocket, Polling and WebHook are all mutually exclusive")); 421 | } 422 | debug("setting up websocket updates: %s", this.tgfancy.webSocket.url); 423 | this._ws = new WebSocket(`${this.tgfancy.webSocket.url}/${this.tgfancy.token}`); 424 | this._ws.on("message", (data) => { 425 | try { 426 | this.processUpdate(JSON.parse(data)); 427 | } catch (ex) { 428 | // TODO: handle exception properly 429 | } 430 | }); 431 | return new Promise((resolve) => { 432 | this._ws.on("open", () => { 433 | resolve(); 434 | }); 435 | }); 436 | } 437 | 438 | /** 439 | * Close the websocket. 440 | * Multiple invocations do nothing if websocket is already closed. 441 | * 442 | * @return {Promise} 443 | */ 444 | closeWebSocket() { 445 | if (!this._ws) { 446 | return Promise.resolve(); 447 | } 448 | debug("closing websocket"); 449 | return new Promise((resolve) => { 450 | this._ws.once("close", () => { 451 | this._ws = null; 452 | resolve(); 453 | }); 454 | this._ws.close(); 455 | }); 456 | } 457 | 458 | /** 459 | * Return `true` if we have an open websocket. Otherwise, `false`. 460 | * 461 | * @return {Boolean} 462 | */ 463 | hasOpenWebSocket() { 464 | return !!this._ws; 465 | } 466 | } 467 | 468 | 469 | // export the class 470 | exports = module.exports = Tgfancy; 471 | 472 | 473 | // If we are running tests, we expose some of the internals 474 | // to allow sanity checks 475 | if (process.env.NODE_ENV === "testing") { 476 | exports.internals = { 477 | emojifiedFns, 478 | queuedSendFns, 479 | ratelimitedFns, 480 | }; 481 | } 482 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * The MIT License (MIT) 3 | * Copyright (c) 2016 GochoMugo 4 | * 5 | * tgfancy: A Fancy, Higher-Level Wrapper for Telegram Bot API 6 | */ 7 | 8 | 9 | // own modules 10 | const client = require("./client"); 11 | const pkg = require("../package.json"); 12 | 13 | 14 | function exportConst(key, value) { 15 | Object.defineProperty(exports, key, { 16 | value, 17 | }); 18 | } 19 | 20 | 21 | exports = module.exports = client; 22 | exportConst("NAME", pkg.name); 23 | exportConst("VERSION", pkg.version); 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tgfancy", 3 | "version": "0.13.0", 4 | "description": "A Fancy, Higher-Level Wrapper for Telegram Bot API", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "doc": "jsdoc2md --files lib/client.js --template doc/api.hbs > doc/api.md", 8 | "test": "grunt test", 9 | "test-coverage": "NODE_ENV=testing istanbul cover _mocha --report lcovonly -- --exit -R spec test/test.*.js" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/GochoMugo/tgfancy.git" 14 | }, 15 | "keywords": [ 16 | "telegram", 17 | "bot", 18 | "api" 19 | ], 20 | "author": "GochoMugo ", 21 | "license": "MIT", 22 | "bugs": { 23 | "url": "https://github.com/GochoMugo/tgfancy/issues" 24 | }, 25 | "homepage": "https://github.com/GochoMugo/tgfancy#readme", 26 | "engines": { 27 | "node": ">=6" 28 | }, 29 | "dependencies": { 30 | "bluebird": "^3.5.1", 31 | "debug": "^3.1.0", 32 | "lodash": "^4.17.4", 33 | "node-emoji": "^1.8.1", 34 | "node-telegram-bot-api": "^0.30.0", 35 | "ws": "^3.3.3" 36 | }, 37 | "devDependencies": { 38 | "coveralls": "^3.0.0", 39 | "grunt": "^1.0.1", 40 | "grunt-cli": "^1.2.0", 41 | "grunt-eslint": "^20.1.0", 42 | "grunt-mocha-test": "^0.13.3", 43 | "istanbul": "^0.4.5", 44 | "jsdoc-to-markdown": "^3.0.3", 45 | "load-grunt-tasks": "^3.5.2", 46 | "mocha": "^4.0.1", 47 | "mocha-lcov-reporter": "^1.3.0", 48 | "should": "^13.1.3" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /test/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | env: 2 | es6: true 3 | node: true 4 | mocha: true 5 | extends: 'eslint:recommended' 6 | rules: 7 | indent: 8 | - error 9 | - 4 10 | linebreak-style: 11 | - error 12 | - unix 13 | quotes: 14 | - error 15 | - double 16 | semi: 17 | - error 18 | - always 19 | -------------------------------------------------------------------------------- /test/README.md: -------------------------------------------------------------------------------- 1 | ## tests 2 | 3 | ### setup: 4 | 5 | The following environment variables are required: 6 | 7 | 1. `TELEGRAM_TOKEN`: your bot token 8 | 1. `TELEGRAM_USERNAME`: your username, in the format `@username` 9 | 1. `TELEGRAM_USERID`: your user ID 10 | 11 | 12 | ### running: 13 | 14 | In your terminal: 15 | 16 | ```bash 17 | $ npm test 18 | ``` 19 | -------------------------------------------------------------------------------- /test/test.index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * The MIT License (MIT) 3 | * Copyright (c) 2016 GochoMugo 4 | * 5 | * Tests. 6 | */ 7 | /* eslint-disable no-console */ 8 | 9 | 10 | // npm-installed modules 11 | const _ = require("lodash"); 12 | const emoji = require("node-emoji"); 13 | const TelegramBot = require("node-telegram-bot-api"); 14 | const should = require("should"); 15 | const ws = require("ws"); 16 | 17 | 18 | // own modules 19 | const Tgfancy = require(".."); 20 | 21 | 22 | // module variables 23 | const pkg = require("../package.json"); 24 | const token = process.env.TELEGRAM_TOKEN; 25 | if (!token) { 26 | console.error("Error: Telegram token is required"); 27 | process.exit(1); 28 | } 29 | const username = process.env.TELEGRAM_USERNAME; 30 | if (!username) { 31 | console.error("Error: Telegram username is required"); 32 | process.exit(1); 33 | } 34 | const userid = parseInt(process.env.TELEGRAM_USERID, 10); 35 | if (!userid) { 36 | console.error("Error: Telegram user ID is required"); 37 | process.exit(1); 38 | } 39 | const timeout = 15 * 1000; // 15 secs 40 | let client = createClient(); 41 | const update = { 42 | "update_id": 666, 43 | message: { 44 | "message_id": 666, 45 | date: Date.now(), 46 | chat: { 47 | id: 666, 48 | type: "private", 49 | }, 50 | text: "Trigger!", 51 | }, 52 | }; 53 | let portindex = 9678; 54 | 55 | 56 | // construct the client. This is useful when we need 57 | // to re-create the client. 58 | // This allows us to re-use one token for all of our tests. 59 | function createClient(options) { 60 | const opts = _.defaultsDeep({}, options, { 61 | tgfancy: { 62 | emojification: true, 63 | }, 64 | }); 65 | return new Tgfancy(token, opts); 66 | } 67 | 68 | 69 | describe("module.exports", function() { 70 | it("exposes a function", function() { 71 | should(Tgfancy).be.a.Function(); 72 | }); 73 | it("exposes the NAME and VERSION", function() { 74 | should(Tgfancy.NAME).eql(pkg.name); 75 | should(Tgfancy.VERSION).eql(pkg.version); 76 | }); 77 | }); 78 | 79 | 80 | describe("sanity check: alphabetically-ordered, methods/functions", function() { 81 | function areMethods(fns) { 82 | fns.forEach(function(fn) { 83 | should(Tgfancy.prototype[fn]).be.a.Function(); 84 | }); 85 | } 86 | function checkOrder(fns) { 87 | const sorted = fns.slice().sort(); 88 | for (let i = 0; i < sorted.length; i++) { 89 | should(sorted[i]).eql(fns[i]); 90 | } 91 | } 92 | it("emojifiedFns", function() { 93 | const fns = Tgfancy.internals.emojifiedFns.map(function(fn) { 94 | return fn[0]; 95 | }); 96 | areMethods(fns); 97 | checkOrder(fns); 98 | }); 99 | it("queuedSendFns", function() { 100 | const fns = Tgfancy.internals.queuedSendFns; 101 | areMethods(fns); 102 | checkOrder(fns); 103 | }); 104 | it("ratelimitedFns", function() { 105 | const fns = Tgfancy.internals.ratelimitedFns; 106 | areMethods(fns); 107 | checkOrder(fns); 108 | }); 109 | }); 110 | 111 | 112 | describe("tgfancy", function() { 113 | it("is an instance of Tgfancy", function() { 114 | should(client).be.an.instanceof(Tgfancy); 115 | }); 116 | it("is a sub-class of TelegramBot", function() { 117 | should(client).be.an.instanceof(TelegramBot); 118 | }); 119 | it(".token is the token registered during construction", function() { 120 | should(client.token).eql(token); 121 | }); 122 | it(".options is an object with the options being used", function() { 123 | should(client.options).be.an.Object(); 124 | }); 125 | }); 126 | 127 | 128 | describe("Text Paging (using Tgfancy#sendMessage())", function() { 129 | this.timeout(timeout * 2); 130 | it("pages long message", function() { 131 | const length = 5500; 132 | const longText = Array(length + 1).join("#"); 133 | return client.sendMessage(userid, longText) 134 | .then(function(messages) { 135 | should(messages).be.an.Array(); 136 | should(messages.length).eql(2); 137 | should(messages[0].text).containEql("[1/2]"); 138 | should(messages[1].text).containEql("[2/2]"); 139 | let mergedText = messages[0].text + messages[1].text; 140 | mergedText = mergedText.replace(/[^#]/g, ""); 141 | should(mergedText.length).eql(length); 142 | }); 143 | }); 144 | }); 145 | 146 | 147 | describe("Queued-methods (using Tgfancy#sendMessage())", function() { 148 | this.timeout(timeout * 10); 149 | it("queues requests", function(done) { 150 | const noiseLevel = 5; 151 | const replies = []; 152 | for (let index = 1; index <= noiseLevel; index++) { 153 | client.sendMessage(userid, index.toString()) 154 | .then(function(message) { 155 | replies.push(message.text); 156 | if (message.text === noiseLevel.toString()) { 157 | checkOrder(); 158 | } 159 | }).catch(done); 160 | } 161 | function checkOrder() { 162 | let reply = 1; 163 | replies.forEach(function(r) { 164 | should(parseInt(r, 10)).eql(reply); 165 | reply++; 166 | }); 167 | return done(); 168 | } 169 | }); 170 | }); 171 | 172 | 173 | describe("Emojification", function() { 174 | this.timeout(timeout); 175 | it("replaces GFM emoji in text", function() { 176 | const emojistring = ":heart:"; 177 | const emojicode = emoji.get("heart"); 178 | return client.sendMessage(userid, "emoji " + emojistring) 179 | .then(function(msg) { 180 | should(msg.text).containEql(emojicode); 181 | should(msg.text).not.containEql(emojistring); 182 | }); 183 | }); 184 | }); 185 | 186 | 187 | describe("WebSocket", function() { 188 | let wss; 189 | const port = portindex++; 190 | const url = `ws://127.0.0.1:${port}`; 191 | before(function(done) { 192 | wss = new ws.Server({ port }); 193 | wss.on("connection", function connection(ws, upgradeReq) { 194 | should(upgradeReq.url).containEql(token); 195 | let interval = setInterval(function() { 196 | ws.send(JSON.stringify(update)); 197 | }, 1000); 198 | ws.on("close", function() { 199 | clearInterval(interval); 200 | }); 201 | }); 202 | wss.on("listening", done); 203 | }); 204 | after(function(done) { 205 | wss.close(done); 206 | }); 207 | it("receives updates", function(done) { 208 | const bot = new Tgfancy(token, { 209 | tgfancy: { 210 | webSocket: { url }, 211 | }, 212 | }); 213 | bot.once("message", function(msg) { 214 | should(msg).be.an.Object(); 215 | return done(); 216 | }); 217 | }); 218 | 219 | describe("#openWebSocket", function() { 220 | it("opens the websocket", function(done) { 221 | const bot = new Tgfancy(token, { 222 | tgfancy: { 223 | webSocket: { url, autoOpen: false }, 224 | }, 225 | }); 226 | bot.once("message", function() { 227 | bot.closeWebSocket(); 228 | return done(); 229 | }); 230 | bot.openWebSocket(); 231 | }); 232 | it("returns error if polling is being used already", function() { 233 | this.timeout(10 * 1000); 234 | const bot = new Tgfancy(token, { polling: { timeout: 0, autoStart: false } }); 235 | return bot.startPolling().then(function() { 236 | return bot.openWebSocket().catch(function(err) { 237 | should(err.message).containEql("mutually exclusive"); 238 | return bot.stopPolling(); 239 | }); 240 | }); 241 | }); 242 | it("returns error if webhook is being used already", function() { 243 | const bot = new Tgfancy(token); 244 | return bot.openWebHook().then(function() { 245 | return bot.openWebSocket().catch(function(err) { 246 | should(err.message).containEql("mutually exclusive"); 247 | return bot.closeWebHook(); 248 | }); 249 | }); 250 | }); 251 | }); 252 | describe("#closeWebSocket", function() { 253 | it("closes websocket", function(done) { 254 | this.timeout(10 * 1000); 255 | const bot = new Tgfancy(token, { 256 | tgfancy: { webSocket: { url } }, 257 | }); 258 | let messages = 0; 259 | bot.on("message", function() { messages++; }); 260 | bot.once("message", function() { 261 | return bot.closeWebSocket() 262 | .then(function() { 263 | messages = 0; 264 | setTimeout(function() { 265 | should.equal(messages, 0); 266 | return done(); 267 | }, 5000); 268 | }) 269 | .catch(function(err) { should(err).not.be.ok(); }); 270 | }); 271 | }); 272 | }); 273 | describe("#hasOpenWebsocket", function() { 274 | const bot = new Tgfancy(token, { 275 | tgfancy: { webSocket: { url, autoOpen: false } }, 276 | }); 277 | before(function() { 278 | return bot.openWebSocket(); 279 | }); 280 | it("returns 'true' if websocket is open", function() { 281 | should(bot.hasOpenWebSocket()).eql(true); 282 | }); 283 | it("returns 'false' if websocket is closed", function() { 284 | return bot.closeWebSocket().then(() => { 285 | should(bot.hasOpenWebSocket()).eql(false); 286 | }); 287 | }); 288 | }); 289 | }); 290 | 291 | 292 | /** 293 | * NOTE: 294 | * we are NOT running tests for rate-limiting currently, 295 | * as it is quite intensive. 296 | * We should look for a better way to test rate-limiting. 297 | * Until then, we should be switching between 298 | * 'describe.skip' and 'describe.only' on our local machines 299 | * when necessary. Live by faith! ;-) 300 | */ 301 | describe.skip("Ratelimiting", function() { 302 | this.timeout(timeout * 10); 303 | it("handles rate-limiting", function(done) { 304 | const longText = Array(4096 * 9 + 1).join("#"); 305 | let interval = null; 306 | const client = createClient({ 307 | tgfancy: { 308 | orderedSending: false, 309 | ratelimiting: { 310 | maxRetries: 1, 311 | timeout: 100, 312 | notify() { 313 | clearInterval(interval); 314 | interval = null; 315 | return done(); 316 | }, 317 | }, 318 | }, 319 | }); 320 | interval = setInterval(function() { 321 | client.sendMessage(userid, longText) 322 | .catch(function(error) { 323 | if (!interval) return; 324 | should(error.message).not.containEql("429"); 325 | }); 326 | }, 40); 327 | }); 328 | it("respects the maxBackoff"); 329 | }); 330 | --------------------------------------------------------------------------------