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