├── .gitignore ├── .npmignore ├── Gruntfile.coffee ├── README.md ├── TODO.md ├── changelog.md ├── coffeelint.json ├── index.js ├── inline.md ├── package.json └── src ├── bot.coffee ├── command-handler.coffee ├── command.coffee ├── constants.coffee ├── context.coffee ├── index.coffee ├── keyboard.coffee ├── middlewares.coffee ├── mixins.coffee ├── session-manager ├── index.coffee ├── memory.coffee └── redis.coffee └── utils.coffee /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /lib/ 3 | *.log 4 | /__storage/ 5 | .DS_Store 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /src/ 3 | *.log 4 | /__storage/ 5 | .DS_Store 6 | -------------------------------------------------------------------------------- /Gruntfile.coffee: -------------------------------------------------------------------------------- 1 | module.exports = (grunt) -> 2 | grunt.loadNpmTasks('grunt-coffeelint') 3 | grunt.loadNpmTasks('grunt-contrib-coffee') 4 | 5 | grunt.initConfig { 6 | coffeelint: { 7 | all: ['src/**/*.coffee'] 8 | options: { 9 | configFile: 'coffeelint.json' 10 | } 11 | } 12 | coffee: { 13 | compile: { 14 | expand: true 15 | cwd: './src/' 16 | src: ['**/*.coffee'] 17 | dest: './lib/' 18 | ext: '.js' 19 | } 20 | } 21 | } 22 | 23 | grunt.registerTask 'prepublish', ['coffeelint', 'coffee:compile'] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bot-brother 2 | Node.js library to help you easy create telegram bots. Works on top of [node-telegram-bot-api](https://github.com/yagop/node-telegram-bot-api) 3 | *Supports telegram-api 2.0 inline keyboards* 4 | 5 | Main features: 6 | - sessions 7 | - middlewares 8 | - localization 9 | - templated keyboards and messages 10 | - navigation between commands 11 | - inline keyboards 12 | 13 | This bots work on top of **bot-brother**: 14 | [@weatherman_bot](https://telegram.me/weatherman_bot) 15 | [@zodiac_bot](https://telegram.me/zodiac_bot) 16 | [@my_ali_bot](https://telegram.me/my_ali_bot) 17 | [@delorean_bot](https://telegram.me/delorean_bot) 18 | [@matchmaker_bot](https://telegram.me/matchmaker_bot) 19 | 20 | ## Table of contents 21 | 22 | 23 | 24 | 25 | - [Install](#install) 26 | - [Simple usage](#simple-usage) 27 | - [Examples of usage](#examples-of-usage) 28 | - [Commands](#commands) 29 | - [Middlewares](#middlewares) 30 | - [Predefined middlewares](#predefined-middlewares) 31 | - [Sessions](#sessions) 32 | - [Redis storage](#redis-storage) 33 | - [With custom Redis-client](#with-custom-redis-client) 34 | - [Memory storage](#memory-storage) 35 | - [Your custom storage](#your-custom-storage) 36 | - [Localization and texts](#localization-and-texts) 37 | - [Keyboards](#keyboards) 38 | - [Going to command](#going-to-command) 39 | - [isShown flag](#isshown-flag) 40 | - [Localization in keyboards](#localization-in-keyboards) 41 | - [Keyboard templates](#keyboard-templates) 42 | - [Keyboard answers](#keyboard-answers) 43 | - [Inline 2.0 keyboards](#inline-20-keyboards) 44 | - [Api](#api) 45 | - [Bot](#bot) 46 | - [bot.api](#botapi) 47 | - [bot.command](#botcommand) 48 | - [bot.keyboard](#botkeyboard) 49 | - [bot.texts](#bottexts) 50 | - [Using webHook](#using-webhook) 51 | - [Command](#command) 52 | - [Context](#context) 53 | - [Context properties](#context-properties) 54 | - [context.session](#contextsession) 55 | - [context.data](#contextdata) 56 | - [context.meta](#contextmeta) 57 | - [context.command](#contextcommand) 58 | - [context.answer](#contextanswer) 59 | - [context.message](#contextmessage) 60 | - [context.bot](#contextbot) 61 | - [context.isRedirected](#contextisredirected) 62 | - [context.isSynthetic](#contextissynthetic) 63 | - [Context methods](#context-methods) 64 | - [context.keyboard(keyboardDefinition)](#contextkeyboardkeyboarddefinition) 65 | - [context.hideKeyboard()](#contexthidekeyboard) 66 | - [context.inlineKeyboard(keyboardDefinition)](#contextinlinekeyboardkeyboarddefinition) 67 | - [context.render(key, data)](#contextrenderkey-data) 68 | - [context.go()](#contextgo) 69 | - [context.goParent()](#contextgoparent) 70 | - [context.goBack()](#contextgoback) 71 | - [context.repeat()](#contextrepeat) 72 | - [context.end()](#contextend) 73 | - [context.setLocale(locale)](#contextsetlocalelocale) 74 | - [context.getLocale()](#contextgetlocale) 75 | - [context.sendMessage(text, [options])](#contextsendmessagetext-options) 76 | - [context.forwardMessage(fromChatId, messageId)](#contextforwardmessagefromchatid-messageid) 77 | - [context.sendPhoto(photo, [options])](#contextsendphotophoto-options) 78 | - [context.sendAudio(audio, [options])](#contextsendaudioaudio-options) 79 | - [context.sendDocument(A, [options])](#contextsenddocumenta-options) 80 | - [context.sendSticker(A, [options])](#contextsendstickera-options) 81 | - [context.sendVideo(A, [options])](#contextsendvideoa-options) 82 | - [context.sendVoice(voice, [options])](#contextsendvoicevoice-options) 83 | - [context.sendChatAction(action)](#contextsendchatactionaction) 84 | - [context.getUserProfilePhotos([offset], [limit])](#contextgetuserprofilephotosoffset-limit) 85 | - [context.sendLocation(latitude, longitude, [options])](#contextsendlocationlatitude-longitude-options) 86 | 87 | 88 | 89 | ## Install 90 | ```sh 91 | npm install bot-brother 92 | ``` 93 | 94 | ## Simple usage 95 | ```js 96 | var bb = require('bot-brother'); 97 | var bot = bb({ 98 | key: '<_TELEGRAM_BOT_TOKEN>', 99 | sessionManager: bb.sessionManager.memory(), 100 | polling: { interval: 0, timeout: 1 } 101 | }); 102 | 103 | // Let's create command '/start'. 104 | bot.command('start') 105 | .invoke(function (ctx) { 106 | // Setting data, data is used in text message templates. 107 | ctx.data.user = ctx.meta.user; 108 | // Invoke callback must return promise. 109 | return ctx.sendMessage('Hello <%=user.first_name%>. How are you?'); 110 | }) 111 | .answer(function (ctx) { 112 | ctx.data.answer = ctx.answer; 113 | // Returns promise. 114 | return ctx.sendMessage('OK. I understood. You feel <%=answer%>'); 115 | }); 116 | 117 | // Creating command '/upload_photo'. 118 | bot.command('upload_photo') 119 | .invoke(function (ctx) { 120 | return ctx.sendMessage('Drop me a photo, please'); 121 | }) 122 | .answer(function (ctx) { 123 | // ctx.message is an object that represents Message. 124 | // See https://core.telegram.org/bots/api#message 125 | return ctx.sendPhoto(ctx.message.photo[0].file_id, {caption: 'I got your photo!'}); 126 | }); 127 | ``` 128 | 129 | ## Examples of usage 130 | We've written simple notification bot with `bot-brother`, so you can inspect code here: https://github.com/SerjoPepper/delorean_bot 131 |
132 | You can try bot in action here: 133 | https://telegram.me/delorean_bot 134 | 135 | ## Commands 136 | Commands can be set with strings or regexps. 137 | ```js 138 | bot.command(/^page[0-9]+/).invoke(function (ctx) { 139 | return ctx.sendMessage('Invoked on any page') 140 | }); 141 | 142 | bot.command('page1').invoke(function (ctx) { 143 | return ctx.sendMessage('Invoked only on page1'); 144 | }); 145 | 146 | bot.command('page2').invoke(function (ctx) { 147 | return ctx.sendMessage('Invoked only on page2'); 148 | }); 149 | ``` 150 | 151 | 152 | ## Middlewares 153 | Middlewares are useful for multistage command handling. 154 | ```js 155 | var bb = require('bot-brother'); 156 | var bot = bb({ 157 | key: '<_TELEGRAM_BOT_TOKEN>' 158 | }) 159 | 160 | bot.use('before', function (ctx) { 161 | return findUserFromDbPromise(ctx.meta.user.id).then(function (user) { 162 | user.vehicle = user.vehicle || 'Car' 163 | // You can set any fieldname except following: 164 | // 1. You can't create fields starting with '_', like ctx._variable; 165 | // 2. 'bot', 'session', 'message', 'isRedirected', 'isSynthetic', 'command', 'isEnded', 'meta' are reserved names. 166 | ctx.user = user; 167 | }); 168 | }); 169 | 170 | bot.command('my_command') 171 | .use('before', function (ctx) { 172 | ctx.user.age = ctx.user.age || '25'; 173 | }) 174 | .invoke(function (ctx) { 175 | ctx.data.user = ctx.user; 176 | return ctx.sendMessage('Your vehicle is <%=user.vehicle%>. Your age is <%=user.age%>.'); 177 | }); 178 | ``` 179 | There are following stages, sorted in order of appearance. 180 | 181 | | Name | Description | 182 | | ------------ | ------------------------------ | 183 | | before | applied before all stages | 184 | | beforeInvoke | applied before invoke stage | 185 | | beforeAnswer | applied before answer stage | 186 | | invoke | same as `command.invoke(...)` | 187 | | answer | same as `command.answer(...)` | 188 | 189 | Let's look at following example, and try to understand how and in what order they will be invoked. 190 | ```js 191 | bot.use('before', function (ctx) { 192 | return ctx.sendMessage('bot before'); 193 | }) 194 | .use('beforeInvoke', function (ctx) { 195 | return ctx.sendMessage('bot beforeInvoke'); 196 | }) 197 | .use('beforeAnswer', function (ctx) { 198 | return ctx.sendMessage('bot beforeAnswer'); 199 | }); 200 | 201 | // This callback cathes all commands. 202 | bot.command(/.*/).use('before', function (ctx) { 203 | return ctx.sendMessage('rgx before'); 204 | }) 205 | .use('beforeInvoke', function (ctx) { 206 | return ctx.sendMessage('rgx beforeInvoke'); 207 | }) 208 | .use('beforeAnswer', function (ctx) { 209 | return ctx.sendMessage('rgx beforeAnswer'); 210 | }); 211 | 212 | bot.command('hello') 213 | .use('before', function (ctx) { 214 | return ctx.sendMessage('hello before'); 215 | }) 216 | .use('beforeInvoke', function (ctx) { 217 | return ctx.sendMessage('hello beforeInvoke'); 218 | }) 219 | .use('beforeAnswer', function (ctx) { 220 | return ctx.sendMessage('hello beforeAnswer'); 221 | }) 222 | .invoke(function (ctx) { 223 | return ctx.sendMessage('hello invoke'); 224 | }) 225 | .answer(function (ctx) { 226 | return ctx.go('world'); 227 | }); 228 | 229 | bot.command('world') 230 | .use('before', function (ctx) { 231 | return ctx.sendMessage('world before'); 232 | }) 233 | .invoke(function (ctx) { 234 | return ctx.sendMessage('world invoke'); 235 | }); 236 | ``` 237 | 238 | Bot dialog 239 | ``` 240 | me > /hello 241 | bot > bot before 242 | bot > bot beforeInvoke 243 | bot > rgx before 244 | bot > rgx beforeInvoke 245 | bot > hello before 246 | bot > hello beforeInvoke 247 | bot > hello invoke 248 | me > I type something 249 | bot > bot before 250 | bot > bot beforeAnswer 251 | bot > rgx before 252 | bot > rgx beforeAnswer 253 | bot > hello beforeAnswer 254 | bot > bot before // We've jumped to "world" command with "ctx.go('world')"" 255 | bot > bot beforeInvoke 256 | bot > rgx before 257 | bot > rgx beforeInvoke 258 | bot > world before 259 | bot > world invoke 260 | ``` 261 | 262 | ### Predefined middlewares 263 | There are two predefined middlewares: 264 | - `botanio` - tracks each incoming message. See http://botan.io/ 265 | - `typing` - shows typing status before each message. See https://core.telegram.org/bots/api#sendchataction 266 | 267 | Usage: 268 | ```js 269 | bot.use('before', bb.middlewares.typing()); 270 | bot.use('before', bb.middlewares.botanio('')); 271 | ``` 272 | 273 | 274 | ## Sessions 275 | Sessions can be implemented with Redis, with memory/fs storage or your custom storage 276 | ```js 277 | bot.command('memory') 278 | .invoke(function (ctx) { 279 | return ctx.sendMessage('Type some string'); 280 | }) 281 | .answer(function (ctx) { 282 | ctx.session.memory = ctx.session.memory || ''; 283 | ctx.session.memory += ctx.answer; 284 | ctx.data.memory = ctx.session.memory; 285 | return ctx.sendMessage('Memory: <%=memory%>'); 286 | }) 287 | ``` 288 | 289 | This dialog demonstrates how it works: 290 | ``` 291 | me > /memory 292 | bot > Type some string 293 | me > 1 294 | bot > 1 295 | me > 2 296 | bot > 12 297 | me > hello 298 | bot > 12hello 299 | ``` 300 | 301 | ### Redis storage 302 | ``` 303 | var bb = require('bot-brother') 304 | bot = bb({ 305 | key: '<_TELEGRAM_BOT_TOKEN>', 306 | sessionManager: bb.sessionManager.redis({port: '...', host: '...'}), 307 | polling: { interval: 0, timeout: 1 } 308 | }) 309 | ``` 310 | ### With custom Redis-client 311 | ``` 312 | var bb = require('bot-brother') 313 | bot = bb({ 314 | key: '<_TELEGRAM_BOT_TOKEN>', 315 | sessionManager: bb.sessionManager.redis({client: yourCustomRedisConnection}), 316 | polling: { interval: 0, timeout: 1 } 317 | }) 318 | ``` 319 | ### Memory storage 320 | ``` 321 | var bb = require('bot-brother') 322 | bot = bb({ 323 | key: '<_TELEGRAM_BOT_TOKEN>', 324 | // set the path where your session will be saved. You can skip this option 325 | sessionManager: bb.sessionManager.memory({dir: '/path/to/dir'}), 326 | polling: { interval: 0, timeout: 1 } 327 | }) 328 | ``` 329 | ### Your custom storage 330 | ``` 331 | var bb = require('bot-brother') 332 | bot = bb({ 333 | key: '<_TELEGRAM_BOT_TOKEN>', 334 | // set the path where your session will be saved. You can skip this option 335 | sessionManager: function (bot) { 336 | return bb.sessionManager.create({ 337 | save: function (id, session) { 338 | // save session 339 | // should return promise 340 | return Promise.resolve(true) 341 | }, 342 | get: function(id) { 343 | // get session by key 344 | // should return promise with {Object} 345 | return fetchYourSessionAsync(id) 346 | }, 347 | getMultiple: function(ids) { 348 | // optionally method 349 | // define it if you use expression: bot.withContexts(ids) 350 | // should return promise with array of session objects 351 | }, 352 | getAll: function() { 353 | // optionally method, same as 'getMultiple' 354 | // define it if you use bot.withAllContexts 355 | } 356 | }) 357 | }, 358 | polling: { interval: 0, timeout: 1 } 359 | }) 360 | ``` 361 | 362 | 363 | ## Localization and texts 364 | Localization can be used in texts and keyboards. 365 | For templates we use [ejs](https://github.com/tj/ejs). 366 | ```js 367 | // Setting keys and values for locale 'en'. 368 | bot.texts({ 369 | book: { 370 | chapter1: { 371 | page1: 'Hello <%=user.first_name%> :smile:' 372 | }, 373 | chapter2: { 374 | page3: 'How old are you, <%=user.first_name%>?' 375 | } 376 | } 377 | }, {locale: 'en'}) 378 | 379 | // Setting default localization values (used if key in certain locale did not found). 380 | bot.texts({ 381 | book: { 382 | chapter1: { 383 | page2: 'How are you, <%=user.first_name%>?' 384 | }, 385 | chapter2: { 386 | page4: 'Good bye, <%=user.first_name%>.' 387 | } 388 | } 389 | }) 390 | 391 | bot.use('before', function (ctx) { 392 | // Let's set data.user to Telegram user to use value in message templates. 393 | ctx.data.user = ctx.meta.user 394 | ctx.session.locale = ctx.session.locale || 'en'; 395 | ctx.setLocale(ctx.session.locale); 396 | }); 397 | 398 | bot.command('chapter1_page1').invoke(function (ctx) { 399 | ctx.sendMessage('book.chapter1.page1') 400 | }) 401 | bot.command('chapter1_page2').invoke(function (ctx) { 402 | ctx.sendMessage('book.chapter1.page2') 403 | }) 404 | bot.command('chapter2_page3').invoke(function (ctx) { 405 | ctx.sendMessage('book.chapter2.page3') 406 | }) 407 | bot.command('chapter2_page4').invoke(function (ctx) { 408 | ctx.sendMessage('book.chapter2.page4') 409 | }) 410 | ``` 411 | When bot-brother sends a message, it tries to interpret this message as a key from your localization set. If key's not found, it interprets the message as a template with variables and renders it via ejs. 412 | All local variables can be set via `ctx.data`. 413 | 414 | Texts can be set for following entities: 415 | - bot 416 | - command 417 | - context 418 | 419 | ```js 420 | bot.texts({ 421 | book: { 422 | chapter: { 423 | page: 'Page 1 text' 424 | } 425 | } 426 | }); 427 | 428 | bot.command('page1').invoke(function (ctx) { 429 | return ctx.sendMessage('book.chapter.page'); 430 | }); 431 | 432 | bot.command('page2').invoke(function (ctx) { 433 | return ctx.sendMessage('book.chapter.page'); 434 | }) 435 | .texts({ 436 | book: { 437 | chapter: { 438 | page: 'Page 2 text' 439 | } 440 | } 441 | }); 442 | 443 | bot.command('page3') 444 | .use('before', function (ctx) { 445 | ctx.texts({ 446 | book: { 447 | chapter: { 448 | page: 'Page 3 text' 449 | } 450 | } 451 | }); 452 | }) 453 | .invoke(function (ctx) { 454 | return ctx.sendMessage('book.chapter.page'); 455 | }) 456 | ``` 457 | 458 | Bot dialog: 459 | 460 | ``` 461 | me > /page1 462 | bot > Page 1 text 463 | me > /page2 464 | bot > Page 2 text 465 | me > /page3 466 | bot > Page 3 text 467 | ``` 468 | 469 | 470 | ## Keyboards 471 | You can set keyboard for context, command or bot. 472 | ```js 473 | // This keyboard is applied for any command. 474 | // Also you can use emoji in keyboard. 475 | bot.keyboard([ 476 | [{':one: go page 1': {go: 'page1'}}], 477 | [{':two: go page 2': {go: 'page2'}}], 478 | [{':three: go page 3': {go: 'page3'}}] 479 | ]) 480 | 481 | bot.command('page1').invoke(function (ctx) { 482 | return ctx.sendMessage('This is page 1') 483 | }) 484 | 485 | bot.command('page2').invoke(function (ctx) { 486 | return ctx.sendMessage('This is page 2') 487 | }).keyboard([ 488 | [{':one: go page 1': {go: 'page1'}}], 489 | [{':three: go page 3': {go: 'page3'}}] 490 | ]) 491 | 492 | bot.command('page3').invoke(function (ctx) { 493 | ctx.keyboard([ 494 | [{':one: go page 1': {go: 'page1'}}] 495 | [{':two: go page 2': {go: 'page2'}}] 496 | ]) 497 | }) 498 | ``` 499 | 500 | ### Going to command 501 | You can go to any command via keyboard. First argument for `go` method is a command name. 502 | ``` 503 | bot.keyboard([[ 504 | {'command1': {go: 'command1'}} 505 | ]]) 506 | 507 | ``` 508 | 509 | 510 | ### isShown flag 511 | `isShown` flag can be used to hide keyboard buttons in certain moment. 512 | 513 | ``` 514 | bot.use('before', function (ctx) { 515 | ctx.isButtonShown = Math.round() > 0.5; 516 | }).keyboard([[ 517 | { 518 | 'text1': { 519 | go: 'command1', 520 | isShown: function (ctx) { 521 | return ctx.isButtonShown; 522 | } 523 | } 524 | } 525 | ]]); 526 | ``` 527 | 528 | ### Localization in keyboards 529 | ```js 530 | bot.texts({ 531 | menu: { 532 | item1: ':one: page 1' 533 | item2: ':two: page 2' 534 | } 535 | }).keyboard([ 536 | [{'menu.item1': {go: 'page1'}}] 537 | [{'menu.item2': {go: 'page2'}}] 538 | ]) 539 | ``` 540 | 541 | ### Keyboard templates 542 | You can use keyboard templates 543 | ```js 544 | bot.keyboard('footer', [{':arrow_backward:': {go: 'start'}}]) 545 | 546 | bot.command('start', function (ctx) { 547 | ctx.sendMessage('Hello there') 548 | }).keyboard([ 549 | [{'Page 1': {go: 'page1'}}], 550 | [{'Page 2': {go: 'page2'}}] 551 | ]) 552 | 553 | bot.command('page1', function () { 554 | ctx.sendMessage('This is page 1') 555 | }) 556 | .keyboard([ 557 | [{'Page 2': {go: 'page2'}}], 558 | 'footer' 559 | ]) 560 | 561 | bot.command('page2', function () { 562 | ctx.sendMessage('This is page 1') 563 | }) 564 | .keyboard([ 565 | [{'Page 1': {go: 'page1'}}], 566 | 'footer' 567 | ]) 568 | ``` 569 | 570 | ### Keyboard answers 571 | If you want to handle a text answer from your keyboard, use following code: 572 | ```js 573 | bot.command('command1') 574 | .invoke(function (ctx) { 575 | return ctx.sendMessage('Hello') 576 | }) 577 | .keyboard([ 578 | [{'answer1': 'answer1'}], 579 | [{'answer2': {value: 'answer2'}}], 580 | [{'answer3': 3}], 581 | [{'answer4': {value: 4}}] 582 | ]) 583 | .answer(function (ctx) { 584 | ctx.data.answer = ctx.answer; 585 | return ctx.sendMessage('Your answer is <%=answer%>'); 586 | }); 587 | ``` 588 | 589 | Sometimes you want user to manually enter an answer. Use following code to do this: 590 | ```js 591 | // Use 'compliantKeyboard' flag. 592 | bot.command('command1', {compliantKeyboard: true}) 593 | .use('before', function (ctx) { 594 | ctx.keyboard([ 595 | [{'answer1': 1}], 596 | [{'answer2': 2}], 597 | [{'answer3': 3}], 598 | [{'answer4': 4}] 599 | ]); 600 | }) 601 | .invoke(function (ctx) { 602 | return ctx.sendMessage('Answer me!') 603 | }) 604 | .answer(function (ctx) { 605 | if (typeof ctx.answer === 'number') { 606 | return ctx.sendMessage('This is an answer from keyboard') 607 | } else { 608 | return ctx.sendMessage('This is not an answer from keyboard. Your answer is: ' + ctx.answer) 609 | } 610 | }); 611 | ``` 612 | 613 | ### Inline 2.0 keyboards 614 | You can use inline keyboards in the same way as default keyboards 615 | ```js 616 | bot.bommand('inline_example') 617 | .use('before', function (ctx) { 618 | // set any your data to callbackData. 619 | // IMPORTANT! Try to fit your data in 60 chars, because Telegram has limit for inline buttons 620 | ctx.inlineKeyboard([[ 621 | {'Option 1': {callbackData: {myVar: 1}, isShown: function (ctx) { return ctx.callbackData.myVar != 1 }}}, 622 | {'Option 2': {callbackData: {myVar: 2}, isShown: function (ctx) { return ctx.callbackData.myVar != 2 }}}, 623 | // use syntax: 624 | // 'callback${{CALLBACK_COMMAND}}' (or 'cb${{CALLBACK_COMMAND}}') 625 | // 'invoke${{INVOKE_COMMAND}}' 626 | // to go to another command 627 | {'Option 3': {go: 'cb$go_inline_example'}}, 628 | {'Option 4': {go: 'invoke$go_inline_example'}} 629 | ]]) 630 | }) 631 | .invoke(function (ctx) { 632 | ctx.sendMessage('Inline data example') 633 | }) 634 | .callback(function (ctx) { 635 | ctx.updateText('Callback data: ' + ctx.callbackData.myVar) 636 | }) 637 | 638 | bot.command('go_inline_example') 639 | .invoke(function (ctx) { 640 | ctx.sendMessage('This command invoked directly') 641 | }) 642 | .callback(function (ctx) { 643 | ctx.updateText('Command invoked via callback! type /inline_example to start again') 644 | }) 645 | ``` 646 | 647 | ## Api 648 | There are three base classes: 649 | - Bot 650 | - Command 651 | - Context 652 | 653 | ### Bot 654 | Bot represents a bot. 655 | ``` 656 | var bb = require('bot-brother'); 657 | var bot = bb({ 658 | key: '', 659 | // optional 660 | webHook: { 661 | url: 'https://mybot.com/updates', 662 | key: '', 663 | cert: '', 664 | port: 443, 665 | https: true 666 | } 667 | }) 668 | ``` 669 | 670 | Has following methods and fields: 671 | 672 | #### bot.api 673 | bot.api is an instance of [node-telegram-bot-api](https://github.com/yagop/node-telegram-bot-api) 674 | ```js 675 | bot.api.sendMessage(chatId, 'message'); 676 | ``` 677 | 678 | #### bot.command 679 | Creates a command. 680 | ```js 681 | bot.command('start').invoke(function (ctx) { 682 | ctx.sendMessage('Hello') 683 | }); 684 | ``` 685 | 686 | #### bot.keyboard 687 | ```js 688 | bot.keyboard([ 689 | [{column1: 'value1'}] 690 | [{column2: {go: 'command1'}}] 691 | ]) 692 | ``` 693 | 694 | 695 | #### bot.texts 696 | Defined texts can be used in keyboards, messages, photo captions 697 | ```js 698 | bot.texts({ 699 | key1: { 700 | embeddedKey2: 'Hello' 701 | } 702 | }) 703 | 704 | // With localization. 705 | bot.texts({ 706 | key1: { 707 | embeddedKey2: 'Hello2' 708 | } 709 | }, {locale: 'en'}) 710 | ``` 711 | 712 | 713 | #### Using webHook 714 | Webhook in telegram documentation: https://core.telegram.org/bots/api#setwebhook 715 | If your node.js process is running behind the proxy (nginx for example) use following code. 716 | We omit `webHook.key` parameter and run node.js on 3000 unsecure port. 717 | ```js 718 | var bb = require('bot-brother'); 719 | var bot = bb({ 720 | key: '', 721 | webHook: { 722 | // Your nginx should proxy this to 127.0.0.1:3000 723 | url: 'https://mybot.com/updates', 724 | cert: '', 725 | port: 3000, 726 | https: false 727 | } 728 | }) 729 | ``` 730 | 731 | Otherwise if your node.js server is available outside, use following code: 732 | ```js 733 | var bb = require('bot-brother'); 734 | var bot = bb({ 735 | key: '', 736 | webHook: { 737 | url: 'https://mybot.com/updates', 738 | cert: '', 739 | key: '', 740 | port: 443 741 | } 742 | }) 743 | ``` 744 | 745 | ### Command 746 | ```js 747 | bot.command('command1') 748 | .invoke(function (ctx) {}) 749 | .answer(function (ctx) {}) 750 | .keyboard([[]]) 751 | .texts([[]]) 752 | ``` 753 | 754 | ### Context 755 | The context is the essence that runs through all middlewares. You can put some data in the context and use this data in the next handler. Context is passed as the first argument in all middleware handlers. 756 | ```js 757 | // this is handler is invoke 758 | bot.use('before', function (ctx) { 759 | // 'ctx' is an instance of Context 760 | ctx.someProperty = 'hello'; 761 | }); 762 | 763 | bot.command('mycommand').invoke(function (ctx) { 764 | // You can use data from previous stage! 765 | ctx.someProperty === 'hello'; // true 766 | }); 767 | ``` 768 | 769 | You can put any property to context variable. But! You must observe the following rules: 770 | 1. Property name can not start with an underscore. `ctx._myVar` - bad!, `ctx.myVar` - good. 771 | 2. Names of properties should not overlap predefined properties or methods. `ctx.session = 'Hello'` - bad! `ctx.mySession = 'Hello'` - good. 772 | 773 | 774 | ### Context properties 775 | Context has following predefined properties available for reading. Some of them are available for editing. Let's take a look at them: 776 | #### context.session 777 | You can put any data in context.session. This data will be available in commands and middlewares invoked for the same user. 778 | Important! Currently for group chats session data is shared between all users in chat. 779 | 780 | ```js 781 | bot.command('hello').invoke(function (ctx) { 782 | return ctx.sendMessage('Hello! What is your name?'); 783 | }).answer(function (ctx) { 784 | // Sets user answer to session.name. 785 | ctx.session.name = ctx.answer; 786 | return ctx.sendMessage('OK! I got it.') 787 | }); 788 | 789 | bot.command('bye').invoke(function (ctx) { 790 | return ctx.sendMessage('Bye ' + ctx.session.name); 791 | }); 792 | ``` 793 | 794 | This is how it works: 795 | ``` 796 | me > /hello 797 | bot > Hello! What is your name? 798 | me > John 799 | bot > OK! I remembered it. 800 | me > /bye 801 | bot > Bye John 802 | ``` 803 | 804 | #### context.data 805 | This variable works when rendering message texts. For template rendering we use (ejs)[https://github.com/tj/ejs]. All the data you put in context.data is available in the templates. 806 | ``` 807 | bot.texts({ 808 | hello: { 809 | world: { 810 | friend: 'Hello world, <%=name%>!' 811 | } 812 | } 813 | }); 814 | 815 | bot.command('hello').invoke(function (ctx) { 816 | ctx.data.name = 'John'; 817 | ctx.sendMessage('hello.world.friend'); 818 | }); 819 | ``` 820 | 821 | This is how it works: 822 | ``` 823 | me > /hello 824 | bot > Hello world, John! 825 | ``` 826 | 827 | There is predefined method `render` in context.data. It can be used for rendering embedded keys: 828 | ``` 829 | bot.texts({ 830 | hello: { 831 | world: { 832 | friend: 'Hello world, <%=name%>!', 833 | bye: 'Good bye, <%=name%>', 834 | message: '<%=render("hello.world.friend")%> <%=render("hello.world.bye")%>' 835 | } 836 | } 837 | }); 838 | 839 | bot.command('hello').invoke(function (ctx) { 840 | ctx.data.name = 'John'; 841 | ctx.sendMessage('hello.world.message'); 842 | }); 843 | ``` 844 | 845 | Bot dialog: 846 | ``` 847 | me > /hello 848 | bot > Hello world, John! Good bye, John 849 | ``` 850 | 851 | 852 | #### context.meta 853 | context.meta contains following fields: 854 | - `user` - see https://core.telegram.org/bots/api#user 855 | - `chat` - see https://core.telegram.org/bots/api#chat 856 | - `sessionId` - key name for saving session, currently it is `meta.chat.id`. So for group chats your session data is shared between all users in chat. 857 | 858 | #### context.command 859 | Represents currently handled command. Has following properties: 860 | - `name` - the name of a command 861 | - `args` - arguments for a command 862 | - `type` - Can be `invoke` or `answer`. If handler is invoked with `.withContext` method, type is `synthetic` 863 | 864 | Suppose that we have the following code: 865 | ```js 866 | bot.command('hello') 867 | .invoke(function (ctx) { 868 | var args = ctx.command.args.join('-'); 869 | var type = ctx.command.type; 870 | var name = ctx.command.name; 871 | return ctx.sendMessage('Type '+type+'; Name: '+name+'; Arguments: '+args); 872 | }) 873 | .answer(function (ctx) { 874 | var type = ctx.command.type; 875 | var name = ctx.command.name; 876 | var answer = ctx.answer; 877 | ctx.sendMessage('Type '+type+'; Name: '+name+'; Answer: ' + answer) 878 | }); 879 | ``` 880 | 881 | The result is the following dialogue: 882 | ``` 883 | me > /hello world dear friend 884 | bot > Type: invoke; Name: hello; Arguments: world-dear-friend 885 | me > bye 886 | bot > Type: answer; Name: hello; Answer: bye 887 | ``` 888 | 889 | Also you can pass args in this way 890 | ``` 891 | me > /hello__world 892 | bot > Type: invoke; Name: hello; Arguments: world 893 | me > bye 894 | bot > Type: answer; Name: hello; Answer: bye 895 | ``` 896 | 897 | #### context.answer 898 | This is an answer for a command. Context.answer is defined only when user answers with a text message. 899 | 900 | #### context.message 901 | Represents message object. For more details see: https://core.telegram.org/bots/api#message 902 | 903 | #### context.bot 904 | Bot instance 905 | 906 | #### context.isRedirected 907 | Boolean. This flag is set to 'true' when a command was achieved via `go` method (user did not type text `/command` in bot). 908 | Let's look at the following example: 909 | ```js 910 | bot.command('hello').invoke(function (ctx) { 911 | return ctx.sendMessage('Type something.') 912 | }) 913 | .answer(function (ctx) { 914 | return ctx.go('world'); 915 | }); 916 | 917 | bot.command('world').invoke(function (ctx) { 918 | return ctx.sendMessage('isRedirected: ' + ctx.isRedirected); 919 | }); 920 | ``` 921 | User was typing something like this: 922 | ``` 923 | me > /hello 924 | bot > Type something 925 | me > lol 926 | bot > isRedirected: true 927 | ``` 928 | 929 | #### context.isSynthetic 930 | Boolean. This flag is true when we achieve the handler with `.withContext` method. 931 | ```js 932 | bot.use('before', function (ctx) { 933 | return ctx.sendMessage('isSynthetic before: ' + ctx.isSynthetic); 934 | }); 935 | 936 | bot.command('withcontext', function (ctx) { 937 | return ctx.sendMessage('hello').then(function () { 938 | return bot.withContext(ctx.meta.sessionId, function (ctx) { 939 | return ctx.sendMessage('isSynthetic in handler: ' + ctx.isSynthetic); 940 | }); 941 | }); 942 | }) 943 | ``` 944 | 945 | Dialog with bot: 946 | ``` 947 | me > /withcontext 948 | bot > isSynthetic before: false 949 | bot > hello 950 | bot > isSynthetic before: true 951 | bot > isSynthetic in handler: true 952 | ``` 953 | 954 | 955 | ### Context methods 956 | Context has the following methods. 957 | 958 | #### context.keyboard(keyboardDefinition) 959 | Sets keyboard 960 | ```js 961 | ctx.keyboard([[{'command 1': {go: 'command1'}}]]) 962 | ``` 963 | 964 | #### context.hideKeyboard() 965 | ```js 966 | ctx.hideKeyboard() 967 | ``` 968 | 969 | #### context.inlineKeyboard(keyboardDefinition) 970 | Sets keyboard 971 | ```js 972 | ctx.inlineKeyboard([[{'command 1': {callbackData: {myVar: 2}}}]]) 973 | ``` 974 | 975 | 976 | #### context.render(key, data) 977 | Returns rendered text or key 978 | ```js 979 | ctx.texts({ 980 | localization: { 981 | key: { 982 | name: 'Hi, <%=name%> <%=secondName%>' 983 | } 984 | } 985 | }) 986 | ctx.data.name = 'John'; 987 | var str = ctx.render('localization.key.name', {secondName: 'Doe'}); 988 | console.log(str); // outputs 'Hi, John Doe' 989 | ``` 990 | 991 | #### context.go() 992 | Returns Promise 993 | Goes to some command 994 | ```js 995 | var command1 = bot.command('command1') 996 | var command2 = bot.command('command2').invoke(function (ctx) { 997 | // Go to command1. 998 | return ctx.go('command1'); 999 | }) 1000 | ``` 1001 | 1002 | #### context.goParent() 1003 | Returns Promise 1004 | Goes to the parent command. A command is considered a descendant if its name begins with the parent command name, for example `setting` is a parent command, `settings_locale` is a descendant command. 1005 | ```js 1006 | var command1 = bot.command('command1') 1007 | var command1Child = bot.command('command1_child').invoke(function (ctx) { 1008 | return ctx.goParent(); // Goes to command1. 1009 | }); 1010 | ``` 1011 | 1012 | #### context.goBack() 1013 | Returns Promise 1014 | Goes to previously invoked command. 1015 | Useful in keyboard 'Back' button. 1016 | ```js 1017 | bot.command('hello') 1018 | .answer(function (context) { 1019 | return context.goBack() 1020 | }) 1021 | // or 1022 | bot.keyboard([[ 1023 | {'Back': {go: '$back'}} 1024 | ]]) 1025 | ``` 1026 | 1027 | #### context.repeat() 1028 | Returns Promise 1029 | Repeats current state, useful for handling wrong answers. 1030 | ```js 1031 | bot.command('command1') 1032 | .invoke(function (ctx) { 1033 | return ctx.sendMessage('How old are you?') 1034 | }) 1035 | .answer(function (ctx) { 1036 | if (isNaN(ctx.answer)) { 1037 | return ctx.repeat(); // Sends 'How old are your?', calls 'invoke' handler. 1038 | } 1039 | }); 1040 | ``` 1041 | 1042 | #### context.end() 1043 | Stops middlewares chain. 1044 | 1045 | #### context.setLocale(locale) 1046 | Sets locale for the context. Use it if you need localization. 1047 | ```js 1048 | bot.texts({ 1049 | greeting: 'Hello <%=name%>!' 1050 | }) 1051 | bot.use('before', function (ctx) { 1052 | ctx.setLocale('en'); 1053 | }); 1054 | ``` 1055 | 1056 | #### context.getLocale() 1057 | Returns current locale 1058 | 1059 | ### context.sendMessage(text, [options]) 1060 | Returns Promise 1061 | Sends text message. 1062 | 1063 | **See**: https://core.telegram.org/bots/api#sendmessage 1064 | 1065 | | Param | Type | Description | 1066 | | --- | --- | --- | 1067 | | text | String | Text or localization key to be sent | 1068 | | [options] | Object | Additional Telegram query options | 1069 | 1070 | #### context.forwardMessage(fromChatId, messageId) 1071 | Returns Promise 1072 | Forwards messages of any kind. 1073 | 1074 | | Param | Type | Description | 1075 | | --- | --- | --- | 1076 | | fromChatId | Number | String | Unique identifier for the chat where the original message was sent | 1077 | | messageId | Number | String | Unique message identifier | 1078 | 1079 | ### context.sendPhoto(photo, [options]) 1080 | Returns Promise 1081 | Sends photo 1082 | 1083 | **See**: https://core.telegram.org/bots/api#sendphoto 1084 | 1085 | | Param | Type | Description | 1086 | | --- | --- | --- | 1087 | | photo | String | stream.Stream | A file path or a Stream. Can also be a `file_id` previously uploaded | 1088 | | [options] | Object | Additional Telegram query options | 1089 | 1090 | ### context.sendAudio(audio, [options]) 1091 | Returns Promise 1092 | Sends audio 1093 | 1094 | **See**: https://core.telegram.org/bots/api#sendaudio 1095 | 1096 | | Param | Type | Description | 1097 | | --- | --- | --- | 1098 | | audio | String | stream.Stream | A file path or a Stream. Can also be a `file_id` previously uploaded. | 1099 | | [options] | Object | Additional Telegram query options | 1100 | 1101 | ### context.sendDocument(A, [options]) 1102 | Returns Promise 1103 | Sends Document 1104 | 1105 | **See**: https://core.telegram.org/bots/api#sendDocument 1106 | 1107 | | Param | Type | Description | 1108 | | --- | --- | --- | 1109 | | A | String | stream.Stream | file path or a Stream. Can also be a `file_id` previously uploaded. | 1110 | | [options] | Object | Additional Telegram query options | 1111 | 1112 | ### context.sendSticker(A, [options]) 1113 | Returns Promise 1114 | Sends .webp stickers. 1115 | 1116 | **See**: https://core.telegram.org/bots/api#sendsticker 1117 | 1118 | | Param | Type | Description | 1119 | | --- | --- | --- | 1120 | | A | String | stream.Stream | file path or a Stream. Can also be a `file_id` previously uploaded. | 1121 | | [options] | Object | Additional Telegram query options | 1122 | 1123 | ### context.sendVideo(A, [options]) 1124 | Returns Promise 1125 | Use this method to send video files, Telegram clients support mp4 videos (other formats may be sent as Document). 1126 | 1127 | **See**: https://core.telegram.org/bots/api#sendvideo 1128 | 1129 | | Param | Type | Description | 1130 | | --- | --- | --- | 1131 | | A | String | stream.Stream | file path or a Stream. Can also be a `file_id` previously uploaded. | 1132 | | [options] | Object | Additional Telegram query options | 1133 | 1134 | ### context.sendVoice(voice, [options]) 1135 | Returns Promise 1136 | Sends voice 1137 | 1138 | **Kind**: instance method of [TelegramBot](#TelegramBot) 1139 | **See**: https://core.telegram.org/bots/api#sendvoice 1140 | 1141 | | Param | Type | Description | 1142 | | --- | --- | --- | 1143 | | voice | String | stream.Stream | A file path or a Stream. Can also be a `file_id` previously uploaded. | 1144 | | [options] | Object | Additional Telegram query options | 1145 | 1146 | ### context.sendChatAction(action) 1147 | Returns Promise 1148 | Sends chat action. 1149 | `typing` for text messages, 1150 | `upload_photo` for photos, `record_video` or `upload_video` for videos, 1151 | `record_audio` or `upload_audio` for audio files, `upload_document` for general files, 1152 | `find_location` for location data. 1153 | 1154 | **See**: https://core.telegram.org/bots/api#sendchataction 1155 | 1156 | | Param | Type | Description | 1157 | | --- | --- | --- | 1158 | | action | String | Type of action to broadcast. | 1159 | 1160 | ### context.getUserProfilePhotos([offset], [limit]) 1161 | Returns Promise 1162 | Use this method to get the list of profile pictures for a user. 1163 | Returns a [UserProfilePhotos](https://core.telegram.org/bots/api#userprofilephotos) object. 1164 | 1165 | **See**: https://core.telegram.org/bots/api#getuserprofilephotos 1166 | 1167 | | Param | Type | Description | 1168 | | --- | --- | --- | 1169 | | [offset] | Number | Sequential number of the first photo to be returned. By default, all photos are returned. | 1170 | | [limit] | Number | Limits the number of photos to be retrieved. Values between 1—100 are accepted. Defaults to 100. | 1171 | 1172 | ### context.sendLocation(latitude, longitude, [options]) 1173 | Returns Promise 1174 | Sends location. 1175 | Use this method to send point on the map. 1176 | 1177 | **See**: https://core.telegram.org/bots/api#sendlocation 1178 | 1179 | | Param | Type | Description | 1180 | | --- | --- | --- | 1181 | | latitude | Float | Latitude of location | 1182 | | longitude | Float | Longitude of location | 1183 | | [options] | Object | Additional Telegram query options | 1184 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | - Добавить поддержку генераторов (co) 2 | - Добавить inMemoryStorage 3 | - -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | # v 2.0.0 2 | 3 | - Support generator functions 4 | - Support inline keyboards 5 | - Support custom session storages 6 | 7 | deprecate inline callbacks 8 | deprecate redis 9 | -------------------------------------------------------------------------------- /coffeelint.json: -------------------------------------------------------------------------------- 1 | { 2 | "coffeescript_error": { 3 | "level": "error" 4 | }, 5 | "arrow_spacing": { 6 | "name": "arrow_spacing", 7 | "level": "warn" 8 | }, 9 | "no_tabs": { 10 | "name": "no_tabs", 11 | "level": "error" 12 | }, 13 | "no_trailing_whitespace": { 14 | "name": "no_trailing_whitespace", 15 | "level": "warn", 16 | "allowed_in_comments": false, 17 | "allowed_in_empty_lines": true 18 | }, 19 | "max_line_length": { 20 | "name": "max_line_length", 21 | "value": 240, 22 | "level": "warn", 23 | "limitComments": true 24 | }, 25 | "line_endings": { 26 | "name": "line_endings", 27 | "level": "ignore", 28 | "value": "unix" 29 | }, 30 | "no_trailing_semicolons": { 31 | "name": "no_trailing_semicolons", 32 | "level": "error" 33 | }, 34 | "indentation": { 35 | "name": "indentation", 36 | "value": 2, 37 | "level": "error" 38 | }, 39 | "camel_case_classes": { 40 | "name": "camel_case_classes", 41 | "level": "error" 42 | }, 43 | "colon_assignment_spacing": { 44 | "name": "colon_assignment_spacing", 45 | "level": "warn", 46 | "spacing": { 47 | "left": 0, 48 | "right": 1 49 | } 50 | }, 51 | "no_implicit_braces": { 52 | "name": "no_implicit_braces", 53 | "level": "ignore", 54 | "strict": true 55 | }, 56 | "no_plusplus": { 57 | "name": "no_plusplus", 58 | "level": "ignore" 59 | }, 60 | "no_throwing_strings": { 61 | "name": "no_throwing_strings", 62 | "level": "error" 63 | }, 64 | "no_backticks": { 65 | "name": "no_backticks", 66 | "level": "error" 67 | }, 68 | "no_implicit_parens": { 69 | "name": "no_implicit_parens", 70 | "level": "ignore" 71 | }, 72 | "no_empty_param_list": { 73 | "name": "no_empty_param_list", 74 | "level": "warn" 75 | }, 76 | "no_stand_alone_at": { 77 | "name": "no_stand_alone_at", 78 | "level": "ignore" 79 | }, 80 | "space_operators": { 81 | "name": "space_operators", 82 | "level": "warn" 83 | }, 84 | "duplicate_key": { 85 | "name": "duplicate_key", 86 | "level": "error" 87 | }, 88 | "empty_constructor_needs_parens": { 89 | "name": "empty_constructor_needs_parens", 90 | "level": "ignore" 91 | }, 92 | "cyclomatic_complexity": { 93 | "name": "cyclomatic_complexity", 94 | "value": 10, 95 | "level": "ignore" 96 | }, 97 | "newlines_after_classes": { 98 | "name": "newlines_after_classes", 99 | "value": 3, 100 | "level": "ignore" 101 | }, 102 | "no_unnecessary_fat_arrows": { 103 | "name": "no_unnecessary_fat_arrows", 104 | "level": "warn" 105 | }, 106 | "missing_fat_arrows": { 107 | "name": "missing_fat_arrows", 108 | "level": "ignore" 109 | }, 110 | "non_empty_constructor_needs_parens": { 111 | "name": "non_empty_constructor_needs_parens", 112 | "level": "ignore" 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib'); -------------------------------------------------------------------------------- /inline.md: -------------------------------------------------------------------------------- 1 | bot.command('start') 2 | .invoke (ctx) -> 3 | ctx.sendMessage() 4 | .answer (ctx) -> 5 | ctx.sendMessage('done') 6 | .inlineAnswer (ctx) 7 | ctx.updateMessage({ 8 | message: '', 9 | inlineKeyboard: '' 10 | }) 11 | .keyboard() 12 | .inlineKeyboard() 13 | 14 | bot.invoke (ctx) -> 15 | yield ctx.sendMessage() 16 | .answer (ctx) -> 17 | yield ctx.sendMessage() 18 | .inlineAnswer (ctx) -> 19 | yield ctx.sendMessage() 20 | yield updateMessage(3) 21 | // инлайн клавиатура ведет на ту же комманду, где она была объявлена 22 | // при отсылке инлайн-клавиатуры, запоминаем название команды из которой она отправлена (запретить инлайн-обработчики) 23 | .inlineKeyboard([]) 24 | .keyboard([]) 25 | // установить клавиатуру для каждого значения 26 | .chooseSuggestResult (ctx) -> 27 | 28 | bot.inlineQuery() 29 | .invoke (ctx) -> 30 | .inlineAnswer (ctx) -> 31 | .inlineKeyboard([ 32 | 33 | ]) 34 | 35 | bot.command('weather_info') 36 | .callback (ctx) -> 37 | # inline_query попадает в inlineAnswer 38 | {city, day} = ctx.inlineData 39 | forecast = fetchForecast({city, day}) 40 | ctx.data = {forecast} 41 | yield ctx.updateText() 42 | yield ctx.updateDescription() 43 | yield ctx.updateInlineKeyboard() 44 | 45 | yield ctx.showTooltip(text) 46 | yield ctx.showAlert(text) 47 | .keyboard([]) 48 | 49 | .inlineKeyboard([]) 50 | 51 | # see https://core.telegram.org/bots/api#callbackquery 52 | bot.withContext(sessionId, callbackQueryMessageId) 53 | ctx.updateText() 54 | ctx.updateDescription() 55 | ctx.updateInlineKeyboard() 56 | 57 | 58 | bot.inlineQuery((ctx) -> 59 | # список сообщений, для каждого своя клавиатура 60 | messages = [ 61 | {text: 'blabla'} 62 | {keyboard: [[{'fullweather': 'fullweather', data: {city: '', dayOffset: 1, detailed: false, command: 'start'}}]]} 63 | ] 64 | ctx.sendInlineResults(messages) 65 | 66 | bot.inlineCommand 'fullweather', (ctx) -> 67 | ctx.inlineData === {a: 123} 68 | ctx.inlineKeyboard([[]]) 69 | ctx.updateMessage(newText) 70 | 71 | 72 | bot.inlineCommand 'todayweather', (ctx) -> 73 | ctx.inlineData === {a: 456} 74 | ctx.inlineKeyboard([[]]) 75 | ctx.updateMessage(newText, keyboard) 76 | 77 | bot-brother: 78 | - создание command-handler и context 79 | 80 | telegram-node-bot-api: 81 | Deprecate inline handlers + 82 | Реализация клавиатур + 83 | Реализация хранилищ + 84 | Переходим на новый API + 85 | Throttling на стороне bot-brother 86 | Тесты 87 | 88 | 89 | 90 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bot-brother", 3 | "version": "2.1.5", 4 | "description": "Framework for creation telegram bots", 5 | "main": "index.js", 6 | "directories": { 7 | "example": "examples" 8 | }, 9 | "scripts": { 10 | "test": "echo \"Error: no test specified\" && exit 1", 11 | "prepublish": "grunt prepublish" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+ssh://git@github.com/SerjoPepper/bot-brother.git" 16 | }, 17 | "keywords": [ 18 | "telegram", 19 | "bot", 20 | "telegram-bot", 21 | "framework" 22 | ], 23 | "author": "Sergey Pereskokov serjopepper@gmail.com", 24 | "license": "ISC", 25 | "bugs": { 26 | "url": "https://github.com/SerjoPepper/bot-brother/issues" 27 | }, 28 | "homepage": "https://github.com/SerjoPepper/bot-brother#readme", 29 | "dependencies": { 30 | "bluebird": "^2.9.34", 31 | "botanio": "0.0.6", 32 | "co": "^4.6.0", 33 | "dot-object": "^1.1.0", 34 | "ejs": "^2.3.3", 35 | "lodash": "^3.10.1", 36 | "mkdirp": "^0.5.1", 37 | "node-emoji": "^1.0.3", 38 | "node-telegram-bot-api": "^0.23.3", 39 | "redis": "^2.6.1", 40 | "underscore.string": "^3.1.1" 41 | }, 42 | "devDependencies": { 43 | "coffeelint": "^1.10.1", 44 | "grunt": "^0.4.5", 45 | "grunt-coffeelint": "0.0.13", 46 | "grunt-contrib-coffee": "^0.13.0" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/bot.coffee: -------------------------------------------------------------------------------- 1 | Command = require './command' 2 | CommandHandler = require './command-handler' 3 | sessionManager = require './session-manager' 4 | constants = require './constants' 5 | mixins = require './mixins' 6 | utils = require './utils' 7 | _ = require 'lodash' 8 | redis = require 'redis' 9 | promise = require 'bluebird' 10 | Api = require 'node-telegram-bot-api' 11 | co = require 'co' 12 | 13 | ### 14 | Bot class 15 | 16 | @property {String} key bot telegram key 17 | @property {Number} id bot id 18 | @property {Object} api telegram bot api 19 | ### 20 | class Bot 21 | 22 | defaultConfig: { 23 | rps: 30 24 | sessionManager: sessionManager.memory() 25 | } 26 | 27 | ### 28 | @param {Object} config config 29 | @option config {String} key telegram bot token 30 | @option config {Object} [redis] redis config; see https://github.com/NodeRedis/node_redis#options-is-an-object-with-the-following-possible-properties 31 | @option config {Object} [redis.client] redis client 32 | @option config {Boolean} [polling] enable polling 33 | @option config {Object} [webHook] config for webhook 34 | @option config {String} [webHook.url] webook url 35 | @option config {String} [webHook.key] PEM private key to webHook server 36 | @option config {String} [webHook.cert] PEM certificate key to webHook server 37 | @option config {Number} [webHook.port] port for node.js server 38 | @option config {Boolean} [webHook.https] create secure node.js server 39 | @option config {Number} [rps=30] Maximum requests per second 40 | ### 41 | constructor: (config) -> 42 | @config = _.extend({}, @defaultConfig, config) 43 | @key = @config.key 44 | @id = Number(@key.match(/^\d+/)?[0]) 45 | @commands = [] 46 | @sessionManager = @config.sessionManager(@) 47 | @rateLimiter = utils.rateLimiter(@config.rps) 48 | @_initApi() 49 | 50 | ### 51 | Returns middlewares for handling. 52 | @param {String} commandName the command name 53 | @param {Object} [params] params 54 | @option params {Boolean} [includeBot] include bot middleware layer 55 | @return {Array} middlewares 56 | ### 57 | getCommandsChain: (commandName, params = {}) -> 58 | unless commandName 59 | return if params.includeBot then [@] else [] 60 | commandName = commandName.toLowerCase() if _.isString(commandName) 61 | commands = @commands.slice().reverse() 62 | .filter (command) -> 63 | command.name is commandName or 64 | _.isRegExp(command.name) and command.name.test(commandName) 65 | .sort ({name: name1}, {name: name2}) -> 66 | [val1, val2] = [name1, name2].map (c) -> 67 | if _.isRegExp(c) then 0 else if c != commandName then -1 else 1 68 | if val1 < 0 && val2 < 0 69 | name2.length - name1.length 70 | else 71 | res = val2 - val1 72 | if params.includeBot 73 | commands.push(@) 74 | commands 75 | 76 | ### 77 | Return middlewares object. 78 | @param {Array} commands chain array 79 | @return {Object} middlewares object grouped by stages 80 | ### 81 | getMiddlewaresChains: (commandsChain) -> 82 | commands = commandsChain.concat([@]) # adding bot middlewares 83 | middlewares = {} 84 | constants.STAGES.forEach (stage) -> 85 | commands.forEach (command) -> 86 | middlewares[stage.name] ||= [] 87 | _commandMiddlewares = command.getMiddlewares(stage.name) 88 | if stage.invert 89 | middlewares[stage.name] = _commandMiddlewares.concat(middlewares[stage.name]) 90 | else 91 | middlewares[stage.name] = middlewares[stage.name].concat(_commandMiddlewares) 92 | middlewares 93 | 94 | ### 95 | Return default command. 96 | @return {Command} 97 | ### 98 | getDefaultCommand: -> 99 | _.find(@commands, {isDefault: true}) 100 | 101 | ### 102 | Register new command. 103 | @param {String|RegExp} name command name 104 | @param {Object} [options] options command options 105 | @option options {Boolean} [isDefault] is command default or not 106 | @option options {Boolean} [compliantKeyboard] handle answers not from keyboard 107 | @return {Command} 108 | ### 109 | command: (name, options = {}) -> 110 | command = new Command(name, _.extend({}, bot: @, options)) 111 | @commands.push(command) 112 | command 113 | 114 | ### 115 | Inline query handler 116 | @param {Function} handler this function should return promise. first argument is {Context} ctx 117 | ### 118 | inlineQuery: (handler) -> 119 | @_inlineQueryHandler = handler 120 | 121 | ### 122 | Inline query handler 123 | @param {Function} handler this function should return promise. first argument is {Context} ctx 124 | ### 125 | chosenInlineResult: (handler) -> 126 | @_choseInlineResultHandler = handler 127 | 128 | ### 129 | @param {Object} session session object 130 | @return {Promise} return context 131 | ### 132 | contextFromSession: (session, prepareContext, params) -> 133 | handler = new CommandHandler(_.extend({bot: @, session: session, isSynthetic: true}, params)) 134 | if prepareContext 135 | prepareContext(handler.context) 136 | promise.resolve(handler.handle()).then -> 137 | handler.context 138 | 139 | ### 140 | Invoke callback in context. 141 | @param {String} chatId 142 | @param {Funcion} handler 143 | @return {Promise} 144 | ### 145 | withContext: (chatId, prepareContext, handler) -> 146 | if !handler 147 | handler = prepareContext 148 | prepareContext = null 149 | @sessionManager.get(chatId).then (session) => 150 | @contextFromSession(session, prepareContext).then (context) -> 151 | co(handler(context)) 152 | # TODO save anytime 153 | .then => 154 | @sessionManager.save(chatId, session) 155 | 156 | ### 157 | Same as withContext, but with multiple ids. 158 | @param {Array} chatIds 159 | @param {Function} handler 160 | ### 161 | withContexts: (chatIds, handler) -> 162 | @sessionManager.getMultiple(chatIds).map (session) => 163 | @contextFromSession(session).then (context) -> 164 | co(handler(context)) 165 | .then => 166 | @sessionManager.save(session.meta.sessionId, session) 167 | 168 | ### 169 | Same as withContexts, but with all chats. 170 | @param {Function} handler 171 | ### 172 | withAllContexts: (handler) -> 173 | @sessionManager.getAll().map (session) => 174 | @contextFromSession(session).then (context) -> 175 | co(handler(context)) 176 | .then => 177 | @sessionManager.save(session.meta.sessionId, session) 178 | 179 | _onInlineQuery: (inlineQuery) => 180 | @withContext( 181 | inlineQuery.from.id 182 | (context) -> context.setInlineQuery(inlineQuery) 183 | (context) => @_inlineQueryHandler(context) 184 | ) 185 | 186 | _onChosenInlineResult: (chosenInlineResult) => 187 | @withContext( 188 | chosenInlineResult.from.id 189 | (context) -> context.setChosenInlineResult(chosenInlineResult) 190 | (context) => @_choseInlineResultHandler(context) 191 | ) 192 | 193 | _onMessage: (message) => 194 | sessionId = @_provideSessionId(message) 195 | # 5 minutes to handle message 196 | if message.date * 1e3 + 60e3 * 5 > Date.now() 197 | @sessionManager.get(sessionId).then (session) => 198 | if _.isEmpty(session) 199 | session = {meta: chat: id: sessionId} 200 | handler = new CommandHandler({message, session, bot: @}) 201 | promise.resolve(handler.handle()) 202 | .then => 203 | @sessionManager.save(sessionId, handler.session) 204 | else 205 | console.error('Bad time: ' + JSON.stringify(message)) 206 | 207 | _onCallbackQuery: (callbackQuery) => 208 | {message} = callbackQuery 209 | sessionId = message && @_provideSessionId(message) || callbackQuery.from.id 210 | @sessionManager.get(sessionId).then (session) => 211 | handler = new CommandHandler({callbackQuery, session, bot: @}) 212 | promise.resolve(handler.handle()) 213 | .then => 214 | @sessionManager.save(sessionId, handler.session) 215 | 216 | _initApi: -> 217 | options = {} 218 | if @config.webHook 219 | options.webHook = @config.webHook 220 | if @config.secure is false 221 | delete options.webHook.key 222 | else 223 | options.polling = @config.polling 224 | @api = new Api(@key, options) 225 | @api.on 'message', @_onMessage 226 | @api.on 'inline_query', @_onInlineQuery 227 | @api.on 'chosen_inline_result', @_onChosenInlineResult 228 | @api.on 'callback_query', @_onCallbackQuery 229 | if @config.webHook 230 | @_setWebhook() 231 | else if @config.polling 232 | @_unsetWebhook() 233 | 234 | _unsetWebhook: -> 235 | @api.setWebHook('') 236 | 237 | _setWebhook: -> 238 | @api.setWebHook(@config.webHook.url, @config.webHook.cert).finally (res) -> 239 | console.log('webhook res:', res) 240 | 241 | _provideSessionId: (message) -> 242 | message.chat.id 243 | 244 | 245 | 246 | _.extend(Bot::, mixins) 247 | 248 | module.exports = (config) -> new Bot(config) 249 | module.exports.middlewares = require('./middlewares') 250 | module.exports.sessionManager = sessionManager 251 | -------------------------------------------------------------------------------- /src/command-handler.coffee: -------------------------------------------------------------------------------- 1 | Context = require './context' 2 | constants = require './constants' 3 | promise = require 'bluebird' 4 | _s = require 'underscore.string' 5 | _ = require 'lodash' 6 | emoji = require 'node-emoji' 7 | ejs = require 'ejs' 8 | co = require 'co' 9 | Keyboard = require './keyboard' 10 | 11 | resolveYield = (value) -> 12 | if value && (value.then || _.isObject(value) and value.toString() == '[object Generator]' || _.isFunction(value)) 13 | value 14 | else 15 | Promise.resolve(value) 16 | 17 | ### 18 | CommandHandler class 19 | Creates for each incoming request. 20 | ### 21 | class CommandHandler 22 | 23 | 24 | ### 25 | @param {Object} params the command handler params 26 | ### 27 | constructor: (params) -> 28 | @name = params.name 29 | @message = params.message 30 | @inlineQuery = params.inlineQuery 31 | @chosenInlineResult = params.chosenInlineResult 32 | @callbackQuery = params.callbackQuery 33 | @callbackData = params.callbackData 34 | @bot = params.bot 35 | @locale = params.prevHandler?.locale 36 | @session = params.session || {} 37 | @type = params.type # 'invoke'/'answer'/'synthetic'/'callback' 38 | @isRedirected = !!params.prevHandler 39 | @session.meta ||= {} # current, prev, from, chat 40 | @session.meta.user ||= @message?.from 41 | @session.meta.chat ||= @message?.chat 42 | @session.meta.sessionId ||= @provideSessionId() 43 | @session.data ||= {} # user data 44 | @session.backHistory || = {} 45 | @session.backHistoryArgs ||= {} 46 | @prevHandler = params.prevHandler 47 | @noChangeHistory = params.noChangeHistory 48 | @args = params.args 49 | @chain = [@bot] 50 | @middlewaresChains = @bot.getMiddlewaresChains([]) 51 | 52 | @isSynthetic = params.isSynthetic 53 | @command = null 54 | @type = 'synthetic' if @isSynthetic 55 | @context = @prevHandler?.context.clone(@) || new Context(@) 56 | ### 57 | @param {String} locale current locale 58 | ### 59 | setLocale: (locale) -> 60 | @locale = locale 61 | @prevHandler?.setLocale(@locale) 62 | 63 | 64 | ### 65 | @return {String} current locale 66 | ### 67 | getLocale: -> 68 | @locale 69 | 70 | 71 | ### 72 | @return {String} sessionId 73 | ### 74 | provideSessionId: -> 75 | @session.meta.chat.id 76 | 77 | 78 | ### 79 | Start handling message 80 | @return {Promise} 81 | ### 82 | handle: -> 83 | if !@type && @message && !@prevHandler 84 | if @message?.text 85 | text = @message.text = _s.trim(@message.text) 86 | if text.indexOf('/') is 0 87 | @type = 'invoke' 88 | params = text.slice(1).split(/\s+|__/) 89 | @name = params[0].toLowerCase().replace(/@.+$/, '') 90 | else 91 | @type = 'answer' 92 | @name = @session.meta?.current 93 | if @type is 'answer' && !@name 94 | @name = 'start' 95 | @type = 'invoke' 96 | @args = [] 97 | else if !@isSynthetic 98 | @type = 'answer' 99 | @name = @session.meta?.current 100 | 101 | if !@type && @callbackQuery 102 | @type = 'callback' 103 | 104 | @commandsChain = @bot.getCommandsChain(@name) 105 | if _.isString(@commandsChain[0]?.name) 106 | @command = @commandsChain[0] 107 | @chain = @bot.getCommandsChain(@name, includeBot: true) 108 | 109 | if @commandsChain.length 110 | if @type is 'invoke' 111 | @args ||= params?[1..] || [] 112 | else if !@isSynthetic && @type is 'answer' 113 | @type = 'invoke' 114 | @name = @bot.getDefaultCommand()?.name 115 | @commandsChain = @bot.getCommandsChain(@name) 116 | 117 | return if !@name && !@isSynthetic && @type != 'callback' 118 | 119 | if @type is 'answer' 120 | @args = @session.invokeArgs 121 | unless _.isEmpty(@session.keyboardMap) 122 | @answer = @session.keyboardMap[@message.text] 123 | unless @answer? 124 | if @command?.compliantKeyboard || _.values(@session.keyboardMap).some((button) -> (button.requestContact || button.requestContact)) 125 | @answer = value: @message.text 126 | else 127 | return 128 | else if @answer.go 129 | @goHandler = switch @answer.go 130 | when '$back' 131 | (ctx) -> ctx.goBack() 132 | when '$parent' 133 | (ctx) -> ctx.goParent() 134 | else 135 | (ctx) => 136 | ctx.go(@answer.go, {args: @answer.args}) 137 | # backward compatibility 138 | else if @answer.handler 139 | @goHandler = eval("(#{@answer.handler})") 140 | else 141 | @answer = value: @message.text 142 | 143 | if @type is 'invoke' 144 | @session.invokeArgs = @args 145 | if !@noChangeHistory && @prevHandler?.name && @prevHandler.name != @name 146 | @session.backHistory[@name] = @prevHandler.name 147 | @session.backHistoryArgs[@name] = @prevHandler.args 148 | @session.meta.current = @name 149 | _.extend(@session.meta, _.pick(@message, 'from', 'chat')) 150 | @session.meta.user = @message?.from || @session.meta.user 151 | 152 | if @type is 'callback' && !@prevHandler 153 | [_name, _args, _value, _callbackData...] = @callbackQuery.data.split('|') 154 | _callbackData = JSON.parse(_callbackData.join('|')) 155 | _args = _.compact(_args.split(',')) 156 | @callbackData = _callbackData 157 | @goHandler = (ctx) -> ctx.go(_name, { 158 | args: _args 159 | value: _value 160 | callbackData: _callbackData 161 | callbackQuery: @callbackQuery 162 | }) 163 | 164 | @middlewaresChains = @bot.getMiddlewaresChains(@commandsChain) 165 | 166 | @context.init() 167 | 168 | if @goHandler 169 | @executeMiddleware(@goHandler) 170 | else 171 | promise.resolve( 172 | _(constants.STAGES) 173 | .sortBy('priority') 174 | .reject('noExecute') 175 | .filter (stage) => !stage.type || stage.type is @type 176 | .map('name') 177 | .value() 178 | ).each (stage) => 179 | # если в ответе есть обработчик - исполняем его 180 | @executeStage(stage) 181 | 182 | 183 | ### 184 | @return {Array} full command chain 185 | ### 186 | getFullChain: -> 187 | [@context].concat(@chain) 188 | 189 | 190 | ### 191 | Render text 192 | @param {String} key localization key 193 | @param {Object} data template data 194 | @param {Object} [options] options 195 | @return {String} 196 | ### 197 | renderText: (key, data, options = {}) -> 198 | locale = @getLocale() 199 | chain = @getFullChain() 200 | for command in chain 201 | textFn = command.getText(key, locale) || command.getText(key) 202 | break if textFn 203 | exData = 204 | render: (key) => @renderText(key, data, options) 205 | data = _.extend({}, exData, data) 206 | text = if typeof textFn is 'function' 207 | textFn(data) 208 | else if !options.strict 209 | ejs.compile(key)(data) 210 | text 211 | 212 | 213 | ### 214 | @param {String} stage 215 | @return {Promise} 216 | ### 217 | executeStage: co.wrap (stage) -> 218 | for middleware in @middlewaresChains[stage] || [] 219 | yield resolveYield(@executeMiddleware(middleware)) 220 | 221 | 222 | ### 223 | @param {Function} middleware 224 | @return {Promise} 225 | ### 226 | executeMiddleware: co.wrap (middleware) -> 227 | unless @context.isEnded 228 | yield resolveYield(middleware(@context)) 229 | 230 | 231 | ### 232 | Go to command 233 | 234 | @param {String} name command name 235 | @param {Object} params params 236 | @option params {Array} [args] Arguments for command 237 | @option params {Boolean} [noChangeHistory] No change chain history 238 | ### 239 | go: (name, params = {}) -> 240 | message = _.extend({}, @message) 241 | [name, type] = name.split('$') 242 | if type is 'cb' 243 | type = 'callback' 244 | handler = new CommandHandler({ 245 | name 246 | message 247 | bot: @bot 248 | session: @session 249 | prevHandler: @ 250 | noChangeHistory: params.noChangeHistory 251 | args: params.args, 252 | callbackData: params.callbackData || @callbackData, 253 | type: params.type || type || 'invoke' 254 | }) 255 | handler.handle() 256 | 257 | ### 258 | @return {String} Previous state name 259 | ### 260 | getPrevStateName: -> 261 | @session.backHistory[@name] 262 | 263 | getPrevStateArgs: -> 264 | @session.backHistoryArgs?[@name] 265 | 266 | ### 267 | Render keyboard 268 | @param {String} name custom keyboard name 269 | @return {Object} keyboard array of keyboard 270 | ### 271 | renderKeyboard: (name, params = {}) -> 272 | locale = @getLocale() 273 | chain = @getFullChain() 274 | data = @context.data 275 | isInline = params.inline 276 | keyboard = null 277 | for command in chain 278 | if command.prevKeyboard && !isInline 279 | return {prevKeyboard: true} 280 | keyboard = params.keyboard && new Keyboard(params.keyboard, params) || 281 | command.getKeyboard(name, locale, params) || 282 | command.getKeyboard(name, null, params) 283 | break if typeof keyboard != 'undefined' 284 | 285 | keyboard = keyboard?.render(locale, chain, data, @) 286 | if keyboard 287 | {markup, map} = keyboard 288 | unless isInline 289 | @session.keyboardMap = map 290 | @session.meta.current = @name 291 | markup 292 | else 293 | unless isInline 294 | @session.keyboardMap = {} 295 | @session.meta.current = @name 296 | null 297 | 298 | unsetKeyboardMap: -> 299 | @session.keyboardMap = {} 300 | 301 | resetBackHistory: -> 302 | unless @noChangeHistory 303 | currentBackName = @session.backHistory[@name] 304 | @session.backHistory[@name] = @session.backHistory[currentBackName] 305 | @session.backHistoryArgs[@name] = @session.backHistoryArgs[currentBackName] 306 | 307 | 308 | module.exports = CommandHandler 309 | -------------------------------------------------------------------------------- /src/command.coffee: -------------------------------------------------------------------------------- 1 | _ = require 'lodash' 2 | mixins = require './mixins' 3 | 4 | class Command 5 | 6 | constructor: (name, params) -> 7 | @bot = params.bot 8 | name = name.toLowerCase() if _.isString(name) 9 | @name = name 10 | @isDefault = params.isDefault 11 | @compliantKeyboard = params.compliantKeyboard # стоит ли принимать ответы, если они не введены с клавиатуры 12 | 13 | invoke: (handler) -> 14 | @use('invoke', handler) 15 | 16 | answer: (handler) -> 17 | @use('answer', handler) 18 | 19 | callback: (handler) -> 20 | @use('callback', handler) 21 | 22 | 23 | _.extend(Command::, mixins) 24 | 25 | 26 | module.exports = Command 27 | -------------------------------------------------------------------------------- /src/constants.coffee: -------------------------------------------------------------------------------- 1 | module.exports.STAGES = [ 2 | {name: 'before', priority: 1, invert: true} # first execute 3 | {name: 'beforeInvoke', priority: 2, invert: true, type: 'invoke'} 4 | {name: 'beforeAnswer', priority: 3, invert: true, type: 'answer'} 5 | {name: 'beforeCallback', priority: 3, invert: true, type: 'callback'} 6 | {name: 'invoke', priority: 4, type: 'invoke'} 7 | {name: 'answer', priority: 5, type: 'answer'} 8 | {name: 'callback', priority: 5, type: 'callback'} 9 | {name: 'beforeSend', priority: 6, noExecute: true, invert: true} 10 | {name: 'afterSend', priority: 7, noExecute: true} 11 | {name: 'afterAnswer', priority: 8, type: 'answer'} 12 | {name: 'afterInvoke', priority: 9, type: 'invoke'} 13 | {name: 'after', priority: 10} 14 | ] 15 | 16 | module.exports.DEFAULT_LOCALE = 'default' 17 | 18 | module.exports.DEFAULT_KEYBOARD = 'default_keyboard' 19 | -------------------------------------------------------------------------------- /src/context.coffee: -------------------------------------------------------------------------------- 1 | _ = require 'lodash' 2 | emoji = require 'node-emoji' 3 | mixins = require './mixins' 4 | co = require 'co' 5 | 6 | prepareText = (text) -> 7 | emoji.emojify(text) 8 | 9 | RETRIABLE_ERRORS = ['ECONNRESET', 'ENOTFOUND', 'ESOCKETTIMEDOUT', 'ETIMEDOUT', 'ECONNREFUSED', 'EHOSTUNREACH', 'EPIPE', 'EAI_AGAIN'] 10 | RESTRICTED_PROPS = [ 11 | 'isRedirected', 'isSynthetic', 'message', 'session' 12 | 'bot', 'command', 'isEnded', 'meta', 'type', 'args' 13 | 'callbackData', 'inlineQuery', 'chosenInlineResult' 14 | ] 15 | HTTP_RETRIES = 20 16 | 17 | ### 18 | Context of the bot command 19 | 20 | @property {Bot} bot 21 | @property {Object} session 22 | @property {Message} message telegram message 23 | @property {Boolean} isRedirected 24 | @property {Boolean} isSynthetic this context created with .withContext handler 25 | @property {Boolean} isEnded this command is ended 26 | @property {Object} data template data 27 | @property {Object} meta meta information 28 | @property {Object} command object tha represent current command. Has follow fields: name, args, type. Where type is 'answer' or 'invoke' 29 | ### 30 | class Context 31 | 32 | constructor: (handler) -> 33 | @_handler = handler 34 | @bot = @_handler.bot 35 | @type = @_handler.type 36 | @session = @_handler.session.data 37 | @message = @_handler.message 38 | @callbackData = @_handler.callbackData 39 | @callbackQuery = @_handler.callbackQuery 40 | @isRedirected = @_handler.isRedirected # we transit to that state with go 41 | @isSynthetic = @_handler.isSynthetic 42 | @meta = @_handler.session.meta # команда 43 | @command = { 44 | name: @_handler.name 45 | args: @_handler.args 46 | type: @_handler.type 47 | callbackData: @_handler.callbackData 48 | } 49 | @args = @_handler.args 50 | @_api = @_handler.bot.api 51 | @_user = @_handler.session.meta.user 52 | @_temp = {} # dont clone 53 | @data = {} # template data 54 | 55 | setInlineQuery: (@inlineQuery) -> 56 | 57 | setChosenInlineResult: (@chosenInlineResult) -> 58 | 59 | ### 60 | Initialize 61 | ### 62 | init: -> 63 | @command = { 64 | name: @_handler.name 65 | args: @_handler.args 66 | type: @_handler.type 67 | } 68 | @args = @_handler.args 69 | @answer = @_handler.answer?.value 70 | 71 | ### 72 | Hide keyboard 73 | ### 74 | hideKeyboard: -> 75 | @useKeyboard(null) 76 | 77 | 78 | ### 79 | Use previous state keyboard 80 | @return {Context} this 81 | ### 82 | usePrevKeyboard: -> 83 | @_temp.usePrevKeyboard = true 84 | @ 85 | 86 | 87 | ### 88 | Use named keyboard 89 | @return {Context} this 90 | ### 91 | useKeyboard: (name) -> 92 | @_temp.keyboardName = name 93 | @ 94 | 95 | 96 | ### 97 | Use this method to get a list of profile pictures for a user. 98 | Returns a [UserProfilePhotos](https://core.telegram.org/bots/api#userprofilephotos) object. 99 | @param {Number} [offset=0] Sequential number of the first photo to be returned. By default, offset is 0. 100 | @param {Number} [limit=1] Limits the number of photos to be retrieved. Values between 1—100 are accepted. Defaults to 1. 101 | @return {Promise} 102 | @see https://core.telegram.org/bots/api#getuserprofilephotos 103 | ### 104 | getUserProfilePhotos: (offset = 0, limit = 1) -> 105 | @bot.api.getUserProfilePhotos(@_user.id, offset, limit) 106 | 107 | 108 | ### 109 | Render text 110 | @param {String} key text or key from localization dictionary 111 | @param {Object} options 112 | ### 113 | render: (key, data, options) -> 114 | @_handler.renderText(key, _.extend({}, @data, data), options) 115 | 116 | 117 | ### 118 | Send message 119 | @param {String} text text or key from localization dictionary 120 | @param {Object} params additional telegram params 121 | @return {Promise} 122 | @see https://core.telegram.org/bots/api#sendmessage 123 | ### 124 | sendMessage: (text, params = {}) -> 125 | if params.render != false 126 | text = @render(text) 127 | @_executeApiAction 'sendMessage', @meta.chat.id, prepareText(text), @_prepareParams(params) 128 | 129 | 130 | ### 131 | Same as sendMessage 132 | ### 133 | sendText: (key, params) -> 134 | @sendMessage(key, params) 135 | 136 | 137 | ### 138 | Send photo 139 | @param {String|stream.Stream} photo A file path or a Stream. Can also be a 'file_id' previously uploaded 140 | @param {Object} [params] Additional Telegram query options 141 | @return {Promise} 142 | @see https://core.telegram.org/bots/api#sendphoto 143 | ### 144 | sendPhoto: (photo, params = {}) -> 145 | if params.caption 146 | if params.render != false 147 | params.caption = @render(params.caption) 148 | params.caption = prepareText(params.caption) 149 | @_executeApiAction 'sendPhoto', @meta.chat.id, photo, @_prepareParams(params) 150 | 151 | 152 | ### 153 | Forward message 154 | @param {Number|String} fromChatId Unique identifier for the chat where the 155 | original message was sent 156 | @param {Number|String} messageId Unique message identifier 157 | @return {Promise} 158 | ### 159 | forwardMessage: (fromChatId, messageId) -> 160 | @_executeApiAction 'forwardMessage', @meta.chat.id, fromChatId, messageId 161 | 162 | 163 | ### 164 | Send audio 165 | @param {String|stream.Stream} audio A file path or a Stream. Can also be a `file_id` previously uploaded. 166 | @param {Object} [params] Additional Telegram query options 167 | @return {Promise} 168 | @see https://core.telegram.org/bots/api#sendaudio 169 | ### 170 | sendAudio: (audio, params) -> 171 | @_executeApiAction 'sendAudio', @meta.chat.id, audio, @_prepareParams(params) 172 | 173 | 174 | ### 175 | Send Document 176 | @param {String|stream.Stream} doc A file path or a Stream. Can also be a `file_id` previously uploaded. 177 | @param {Object} [params] Additional Telegram query options 178 | @return {Promise} 179 | @see https://core.telegram.org/bots/api#sendDocument 180 | ### 181 | sendDocument: (doc, params) -> 182 | @_executeApiAction 'sendDocument', @meta.chat.id, doc, @_prepareParams(params) 183 | 184 | 185 | ### 186 | Send .webp stickers. 187 | @param {String|stream.Stream} sticker A file path or a Stream. Can also be a `file_id` previously uploaded. 188 | @param {Object} [params] Additional Telegram query options 189 | @return {Promise} 190 | @see https://core.telegram.org/bots/api#sendsticker 191 | ### 192 | sendSticker: (sticker, params) -> 193 | @_executeApiAction 'sendSticker', @meta.chat.id, sticker, @_prepareParams(params) 194 | 195 | 196 | ### 197 | Send video files, Telegram clients support mp4 videos (other formats may be sent with `sendDocument`) 198 | @param {String|stream.Stream} video A file path or a Stream. Can also be a `file_id` previously uploaded. 199 | @param {Object} [params] Additional Telegram query options 200 | @return {Promise} 201 | @see https://core.telegram.org/bots/api#sendvideo 202 | ### 203 | sendVideo: (video, params) -> 204 | @_executeApiAction 'sendVideo', @meta.chat.id, video, @_prepareParams(params) 205 | 206 | 207 | ### 208 | Send chat action. 209 | `typing` for text messages, 210 | `upload_photo` for photos, `record_video` or `upload_video` for videos, 211 | `record_audio` or `upload_audio` for audio files, `upload_document` for general files, 212 | `find_location` for location data. 213 | @param {Number|String} chatId Unique identifier for the message recipient 214 | @param {String} action Type of action to broadcast. 215 | @return {Promise} 216 | @see https://core.telegram.org/bots/api#sendchataction 217 | ### 218 | sendChatAction: (action) -> 219 | @_executeApiAction 'chatAction', @meta.chat.id, action 220 | 221 | 222 | ### 223 | Send location. 224 | Use this method to send point on the map. 225 | @param {Float} latitude Latitude of location 226 | @param {Float} longitude Longitude of location 227 | @param {Object} [params] Additional Telegram query options 228 | @return {Promise} 229 | @see https://core.telegram.org/bots/api#sendlocation 230 | ### 231 | sendLocation: (latitude, longitude, params) -> 232 | @_executeApiAction 'sendLocation', @meta.chat.id, latitude, longitude, @_prepareParams(params) 233 | 234 | 235 | updateCaption: (text, params = {}) -> 236 | text = @render(text) if params.render != false 237 | _params = { 238 | reply_markup: @_provideKeyboardMarkup(inline: true) 239 | } 240 | if @callbackQuery.inline_message_id 241 | _params.inline_message_id = @callbackQuery.inline_message_id 242 | else 243 | _.extend(_params, { 244 | chat_id: @meta.chat.id 245 | message_id: @callbackQuery.message.message_id 246 | }) 247 | @_executeApiAction 'editMessageCaption', prepareText(text), _.extend(_params, params) 248 | 249 | 250 | updateText: (text, params = {}) -> 251 | text = @render(text) if params.render != false 252 | _params = { 253 | reply_markup: @_provideKeyboardMarkup(inline: true) 254 | } 255 | if @callbackQuery.inline_message_id 256 | _params.inline_message_id = @callbackQuery.inline_message_id 257 | else 258 | _.extend(_params, { 259 | chat_id: @meta.chat.id 260 | message_id: @callbackQuery.message.message_id 261 | }) 262 | @_executeApiAction 'editMessageText', prepareText(text), _.extend(_params, params) 263 | 264 | 265 | updateKeyboard: (params = {}) -> 266 | _params = {} 267 | if @callbackQuery.inline_message_id 268 | _params.inline_message_id = @callbackQuery.inline_message_id 269 | else 270 | _.extend(_params, { 271 | chat_id: @meta.chat.id 272 | message_id: @callbackQuery.message.message_id 273 | }) 274 | @_executeApiAction 'editMessageReplyMarkup', @_provideKeyboardMarkup(inline: true), _.extend(_params, params) 275 | 276 | answerInlineQuery: (results, params) -> 277 | results.forEach (result) => 278 | if result.keyboard 279 | result.reply_markup = inline_keyboard: @_renderKeyboard(inline: true, keyboard: result.keyboard) 280 | delete result.keyboard 281 | @_executeApiAction 'answerInlineQuery', @inlineQuery.id, results, params 282 | 283 | ### 284 | Set locale for context 285 | @param {String} locale Locale 286 | ### 287 | setLocale: (locale) -> 288 | @_handler.setLocale(locale) 289 | 290 | 291 | ### 292 | Get current context locale 293 | @return {String} 294 | ### 295 | getLocale: -> 296 | @_handler.getLocale() 297 | 298 | 299 | ### 300 | Go to certain command 301 | 302 | @param {String} name command name 303 | @param {Object} params params 304 | @option params {Array} [args] Arguments for command 305 | @option params {Boolean} [noChangeHistory=false] No change chain history 306 | @option params {String} [stage='invoke'] 'invoke'|'answer'|'callback' 307 | @return {Promise} 308 | ### 309 | go: (name, params) -> 310 | @end() 311 | @_handler.go(name, params) 312 | 313 | ### 314 | Same as @go, but stage is 'callback' 315 | ### 316 | goCallback: (name, params) -> 317 | @go(name, _.extend(params, stage: 'callback')) 318 | 319 | ### 320 | Go to parent command. 321 | @return {Promise} 322 | ### 323 | goParent: -> 324 | @go(@_handler.name.split('_').slice(0, -1).join('_') || @_handler.name) 325 | 326 | 327 | ### 328 | Go to previous command. 329 | @return {Promise} 330 | ### 331 | goBack: -> 332 | prevCommandName = @_handler.getPrevStateName() 333 | @go(prevCommandName, {noChangeHistory: true, args: @_handler.getPrevStateArgs()}) 334 | 335 | ### 336 | Repeat current command 337 | @return {Promise} 338 | ### 339 | repeat: -> 340 | @go(@_handler.name, {noChangeHistory: true, args: @command.args}) 341 | 342 | 343 | ### 344 | Break middlewares chain 345 | ### 346 | end: -> 347 | @isEnded = true 348 | 349 | 350 | ### 351 | Clone context 352 | @param {CommandHandler} handler Command handler for new context 353 | @return {Context} 354 | ### 355 | clone: (handler) -> 356 | res = new Context(handler) 357 | setProps = Object.getOwnPropertyNames(@).filter (prop) -> 358 | !(prop in RESTRICTED_PROPS || prop.indexOf('_') is 0) 359 | _.extend(res, _.pick(@, setProps)) 360 | 361 | 362 | _executeApiAction: (method, args...) -> 363 | @_handler.executeStage('beforeSend').then => 364 | retries = HTTP_RETRIES 365 | execAction = => 366 | @bot.rateLimiter(=> @_api[method](args...)).catch (e) -> 367 | httpCode = parseInt(e.message) 368 | if retries-- > 0 && (e?.code in RETRIABLE_ERRORS || 500 <= httpCode < 600 || httpCode is 420) 369 | execAction() 370 | else 371 | throw e 372 | execAction() 373 | .then co.wrap (message) => 374 | if @_temp.inlineMarkupSent 375 | @_handler.resetBackHistory() 376 | else 377 | inlineMarkup = @_provideKeyboardMarkup(inline: true) 378 | if inlineMarkup && (method not in ['editMessageReplyMarkup', 'editMessageText', 'editMessageCaption']) && message?.message_id 379 | yield @_executeApiAction('editMessageReplyMarkup', JSON.stringify(inlineMarkup), { 380 | chat_id: @meta.chat.id 381 | message_id: message.message_id 382 | }) 383 | @_handler.executeStage('afterSend').then -> message 384 | 385 | 386 | _prepareParams: (params = {}) -> 387 | markup = @_provideKeyboardMarkup() 388 | unless markup 389 | markup = @_provideKeyboardMarkup(inline: true) 390 | @_temp.inlineMarkupSent = true 391 | _params = {} 392 | if params.caption 393 | params.caption = prepareText(params.caption) 394 | if markup 395 | _params.reply_markup = JSON.stringify(markup) 396 | _.extend(_params, params) 397 | 398 | 399 | _renderKeyboard: (params) -> 400 | if @_temp.keyboardName is null && !params.inline 401 | null 402 | else 403 | @_handler.renderKeyboard(@_temp.keyboardName, params) 404 | 405 | 406 | _provideKeyboardMarkup: (params = {}) -> 407 | noPrivate = @meta.chat.type != 'private' 408 | if params.inline 409 | markup = @_renderKeyboard(params) 410 | if markup && !_.isEmpty(markup) && markup.some((el) -> !_.isEmpty(el)) 411 | inline_keyboard: markup 412 | else 413 | null 414 | else 415 | # if @_handler.command?.compliantKeyboard && noPrivate 416 | # force_reply: true 417 | # else 418 | if @_temp.usePrevKeyboard || @_usePrevKeyboard 419 | null 420 | else 421 | markup = @_renderKeyboard(params) 422 | if markup?.prevKeyboard 423 | null 424 | else 425 | if markup && !_.isEmpty(markup) && markup.some((el) -> !_.isEmpty(el)) 426 | keyboard: markup, resize_keyboard: true 427 | else 428 | @_handler.unsetKeyboardMap() 429 | if noPrivate 430 | force_reply: true 431 | else 432 | hide_keyboard: true 433 | 434 | 435 | 436 | _.extend(Context::, mixins) 437 | 438 | 439 | module.exports = Context 440 | -------------------------------------------------------------------------------- /src/index.coffee: -------------------------------------------------------------------------------- 1 | module.exports = require './bot' 2 | -------------------------------------------------------------------------------- /src/keyboard.coffee: -------------------------------------------------------------------------------- 1 | ejs = require 'ejs' 2 | _ = require 'lodash' 3 | emoji = require 'node-emoji' 4 | 5 | ### 6 | 7 | Keyboard examples 8 | 9 | [ 10 | [ 11 | {'text.key': 10} 12 | {'text.key1': {value: 10}} 13 | {'text.key2': {value: 10}} 14 | {key: 'text.key3', value: 'text.key3'} 15 | {text: 'Hello <%=user.name%>'} # raw text, which we compile 16 | {text: 'Hello <%=user.name%>', value: 'hello'} # raw text, which we compile 17 | 'rowTemplate' # embed row 18 | ], [ 19 | {'text.key': {go: 'state.name'}} 20 | {'text.key': {go: 'state.name'}} 21 | {'text.key': {go: 'state.name'}} 22 | {'text.key': {go: 'state.name'}} 23 | {'text.key': {go: 'state.name$callback'}} 24 | {'text.key': {go: '$back', args: [123,123]}} 25 | {'text.key': {go: '$parent', args: [234, 567]}} 26 | {'text.key': {go: '$parent', args: [234, 567], isShown: (ctx) -> ctx.data.user.age > 18}} 27 | ], 28 | 'keyboardTemplate' # embed keyboard 29 | ] 30 | 31 | ### 32 | 33 | KEYS = [ 34 | 'key', 35 | 'text', 36 | 'value', 37 | 'go', 38 | 'args', 39 | 'isShown', 40 | # only for inlineKeyboard 41 | 'url', 42 | 'callbackData', 43 | 'switchInlineQuery', 44 | # only for no inlineKeyboard 45 | 'requestContact', 46 | 'requestLocation' 47 | ] 48 | 49 | class Keyboard 50 | 51 | constructor: (keyboard, params, @command) -> 52 | @type = params.type || 'table' # 'table' or 'row' 53 | @inline = params.inline 54 | @keyboard = _.cloneDeep(keyboard).map (row, i) => 55 | if @type is 'row' && _.isPlainObject(row) 56 | row = @processColumn(row) 57 | if _.isArray(row) 58 | row = row.map (column) => 59 | if _.isPlainObject(column) 60 | column = @processColumn(column) 61 | column 62 | row 63 | 64 | 65 | processColumn: (column) -> 66 | keys = Object.keys(column) 67 | unless keys[0] in KEYS 68 | val = column[keys[0]] 69 | if _.isString(val) 70 | column = {key: keys[0], value: val} 71 | else if _.isFunction(val) 72 | column = {key: keys[0], handler: val} 73 | else 74 | column = {key: keys[0]} 75 | _.extend(column, val) 76 | if column.text 77 | column.text = ejs.compile(column.text) 78 | column 79 | 80 | 81 | replaceLayouts: (chain, locale) -> 82 | if @type is 'table' 83 | keyboard = [] 84 | for row in @keyboard 85 | if _.isString(row) 86 | keyboard = keyboard.concat(@embedLayout(row, chain, locale, 'table')) 87 | else 88 | keyboard.push(row) 89 | for row, i in keyboard 90 | _row = [] 91 | for column in row 92 | if _.isString(column) 93 | _row = _row.concat(@embedLayout(column, chain, locale, 'row')) 94 | else 95 | _row.push(column) 96 | keyboard[i] = _row 97 | else 98 | keyboard = [] 99 | for column in @keyboard 100 | if _.isString(column) 101 | keyboard = keyboard.concat(@embedLayout(column, chain, locale, 'row')) 102 | else 103 | keyboard.push(column) 104 | keyboard 105 | 106 | 107 | 108 | embedLayout: (name, chain, locale, type) -> 109 | for command in chain 110 | keyboard = command.getKeyboard(name, locale, {type}) || command.getKeyboard(name, null, {type}) 111 | break if keyboard 112 | if !keyboard 113 | throw new Error("Can not find keyboard: #{name}") 114 | keyboard.replaceLayouts(chain, locale) 115 | 116 | 117 | render: (locale, chain, data, handler) -> 118 | keyboard = @replaceLayouts(chain, locale) 119 | map = {} 120 | markup = [] 121 | for row in keyboard 122 | markupRow = [] 123 | for column, i in row 124 | text = if column.text 125 | column.text(data) 126 | else 127 | handler.renderText(column.key, data) 128 | for k in ['args', 'callbackData', 'value'] 129 | if _.isFunction(column[k]) 130 | column[k] = column[k](handler.context) 131 | text = emoji.emojify(text) 132 | if !column.isShown? || _.isFunction(column.isShown) && column.isShown(handler.context) || _.isBoolean(column.isShown) && column.isShown 133 | button = {text} 134 | if @inline 135 | button.url = column.url if column.url 136 | button.switch_inline_query = column.switchInlineQuery if column.switchInlineQuery? 137 | button.callback_data = [ 138 | column.go || handler.name + '$cb', 139 | column.args?.join(',') || '', 140 | column.value || '', 141 | JSON.stringify(column.callbackData || {}) 142 | ].join('|') 143 | else 144 | button.request_contact = true if column.requestContact 145 | button.request_location = true if column.requestLocation 146 | markupRow.push(button) 147 | map[text] = _.pick( 148 | column, 149 | 'value', 'go', 'args', 150 | 'requestContact', 'requestLocation' 151 | ) 152 | markup.push(markupRow) if markupRow.length 153 | 154 | {markup, map} 155 | 156 | 157 | module.exports = Keyboard 158 | -------------------------------------------------------------------------------- /src/middlewares.coffee: -------------------------------------------------------------------------------- 1 | botanio = require 'botanio' 2 | 3 | module.exports.botanio = (key) -> 4 | botan = botanio(key) 5 | (context) -> 6 | if !context.isBotanioTracked && context.type != 'synthetic' && !context.isRedirected 7 | context.isBotanioTracked = true 8 | {message, inlineQuery, callbackQuery, command} = context 9 | botan.track(message || inlineQuery || callbackQuery, command.name) 10 | return 11 | 12 | module.exports.typing = -> 13 | (context) -> 14 | if context.message && context.type != 'callback' 15 | context.bot.api.sendChatAction(context.meta.chat.id, 'typing') 16 | return -------------------------------------------------------------------------------- /src/mixins.coffee: -------------------------------------------------------------------------------- 1 | _ = require 'lodash' 2 | ejs = require 'ejs' 3 | constants = require './constants' 4 | Keyboard = require './keyboard' 5 | dot = require 'dot-object' 6 | _s = require 'underscore.string' 7 | 8 | deepReplace = (val, fn) -> 9 | if _.isObject(val) 10 | for own k, v of val 11 | val[k] = deepReplace(fn(v, k), fn) 12 | else if _.isArray(val) 13 | for v, i in val 14 | val[i] = deepReplace(fn(v, i), fn) 15 | val 16 | 17 | compileKeys = (obj) -> 18 | deepReplace obj, (val, k) -> 19 | if _.isString(val) 20 | val = ejs.compile(_s.trim(val)) 21 | val 22 | 23 | module.exports = 24 | 25 | 26 | # Add keyboard 27 | # @param {String} [name] keyboard name 28 | # @param {Array} keyboard keyboard markup 29 | # @param {Object} params params 30 | # @option params {String} [lang] a lang of keyboard 31 | # @option params {String} [type] 'row' or 'table', default is 'table' 32 | # @option params {Boolean} [inline=false] 33 | keyboard: (name, keyboard, params) -> 34 | # union format 35 | if !_.isString(name) 36 | params = keyboard 37 | keyboard = name 38 | name = constants.DEFAULT_KEYBOARD 39 | params ||= {} 40 | if params.inline 41 | name += '__inline' 42 | locale = params.locale || constants.DEFAULT_LOCALE 43 | @_keyboards ||= {} 44 | @_keyboards[locale] ||= {} 45 | @_keyboards[locale][name] = if keyboard then new Keyboard(keyboard, params, @) else null 46 | @ 47 | 48 | usePrevKeyboard: -> 49 | @prevKeyboard = true 50 | @ 51 | 52 | # Add inline keyboard 53 | inlineKeyboard: (name, keyboard, params) -> 54 | if !_.isString(name) 55 | params = keyboard 56 | keyboard = name 57 | name = constants.DEFAULT_KEYBOARD 58 | params ||= {} 59 | @keyboard(name, keyboard, _.extend(params, inline: true)) 60 | @ 61 | 62 | getKeyboard: (name = constants.DEFAULT_KEYBOARD, locale = constants.DEFAULT_LOCALE, params = {}) -> 63 | {inline, type} = params 64 | if inline 65 | name += '__inline' 66 | keyboard = @_keyboards?[locale]?[name] 67 | if type 68 | type == keyboard?.type && keyboard 69 | else 70 | keyboard 71 | 72 | 73 | # добавляем текста 74 | texts: (texts, params = {}) -> 75 | locale = params.locale || constants.DEFAULT_LOCALE 76 | @_texts ||= {} 77 | @_texts[locale] ||= {} 78 | _.merge(@_texts[locale], compileKeys(_.cloneDeep(texts))) 79 | @ 80 | 81 | 82 | getText: (key, locale = constants.DEFAULT_LOCALE) -> 83 | dot.pick(key, @_texts?[locale]) 84 | 85 | 86 | # добавляем middleware 87 | use: (type, handler) -> 88 | @_middlewares ||= {} 89 | @_middlewares[type] ||= [] 90 | @_middlewares[type].push(handler) 91 | @ 92 | 93 | 94 | getMiddlewares: (type) -> 95 | @_middlewares ||= {} 96 | @_middlewares[type] || [] 97 | -------------------------------------------------------------------------------- /src/session-manager/index.coffee: -------------------------------------------------------------------------------- 1 | exports.create = (methods) -> 2 | {save, get, getMultiple, getAll} = methods 3 | if !save || !get 4 | throw new Error('You should define "save" and "get" methods') 5 | methods 6 | 7 | exports.redis = require './redis' 8 | exports.memory = require './memory' 9 | -------------------------------------------------------------------------------- /src/session-manager/memory.coffee: -------------------------------------------------------------------------------- 1 | promise = require 'bluebird' 2 | fs = promise.promisifyAll(require('fs')) 3 | create = require('./index').create 4 | path = require 'path' 5 | mkdirp = require 'mkdirp' 6 | 7 | module.exports = (config = {}) -> (bot) -> 8 | dir = if config.dir 9 | path.resolve(process.cwd(), config.dir) 10 | else 11 | path.resolve(__dirname, '../../__storage') 12 | mkdirp.sync(dir) 13 | 14 | parseSession = (session) -> 15 | session && JSON.parse(session) 16 | 17 | fileName = (id) -> 18 | path.join(dir, "#{bot.id}.#{id}.json") 19 | 20 | create({ 21 | 22 | save: (id, session) -> 23 | fs.writeFileAsync(fileName(id), JSON.stringify(session)) 24 | 25 | get: (id) -> 26 | fs.statAsync(fileName(id)).then (exists) -> 27 | if exists 28 | fs.readFileAsync(fileName(id)).then(parseSession) 29 | else 30 | null 31 | .catch -> null 32 | 33 | getMultiple: (ids) -> 34 | promise.resolve(ids).map (id) => @get(id) 35 | 36 | getAll: -> 37 | # TODO 38 | 39 | }) 40 | -------------------------------------------------------------------------------- /src/session-manager/redis.coffee: -------------------------------------------------------------------------------- 1 | promise = require 'bluebird' 2 | redis = require 'redis' 3 | create = require('./index').create 4 | 5 | DEFAULT_PREFIX = 'BOT_SESSIONS' 6 | DEFUALT_CONFIG = {host: '127.0.0.1', port: '6379'} 7 | 8 | promise.promisifyAll(redis) 9 | 10 | module.exports = (config, prefix = DEFAULT_PREFIX) -> (bot) -> 11 | config ||= DEFUALT_CONFIG 12 | client = config.client || redis.createClient(config) 13 | client.select(config.db) if config.db 14 | 15 | parseSession = (session) -> 16 | session && JSON.parse(session) 17 | 18 | create({ 19 | 20 | save: (id, session) -> 21 | client.hsetAsync("#{prefix}:#{bot.id}", id, JSON.stringify(session)) 22 | 23 | get: (id) -> 24 | client.hgetAsync("#{prefix}:#{bot.id}", id).then(parseSession) 25 | 26 | getMultiple: (ids) -> 27 | client.hmgetAsync(["#{prefix}:#{bot.id}"].concat(ids)).then (sessions) -> 28 | sessions.filter(Boolean).map(parseSession) 29 | 30 | getAll: -> 31 | client.hvalsAsync("#{prefix}:#{bot.id}").then (sessions) -> 32 | sessions.filter(Boolean).map(parseSession) 33 | 34 | }) -------------------------------------------------------------------------------- /src/utils.coffee: -------------------------------------------------------------------------------- 1 | Promise = require 'bluebird' 2 | co = require 'co' 3 | ### 4 | # limit messages to 10 5 | promiseRateLimit(30) -> 6 | ctx.sendMessage('Hello') 7 | ### 8 | exports.rateLimiter = (rps = 30) -> 9 | fifo = [] 10 | 11 | counter = 0 12 | interval = setInterval( 13 | -> 14 | counter = 0 15 | execNext() 16 | 1000 17 | ) 18 | 19 | execNext = -> 20 | if fifo.length && counter < rps 21 | {resolve, reject, handler} = fifo.pop() 22 | co(handler()) 23 | .then(resolve, reject) 24 | .then(execNext, execNext) 25 | counter++ 26 | execNext() 27 | 28 | limiter = (handler) -> 29 | promise = new Promise((resolve, reject) -> 30 | fifo.unshift({handler, resolve, reject}) 31 | ) 32 | execNext() 33 | promise 34 | 35 | limiter.destroy = -> 36 | reject(new Error('Destroy in rateLimiter')) for {reject} in fifo 37 | clearInterval(interval) 38 | 39 | limiter 40 | --------------------------------------------------------------------------------