├── .gitignore ├── Procfile ├── README.md ├── analyzer.js ├── app.js ├── bin └── collect ├── bot.js ├── collector.js ├── history.js ├── index.js ├── kiosk.js ├── locales ├── en.js └── ru.js ├── maintenance ├── collect.js ├── integrity.js └── reindex.js ├── package.json ├── polyglot.js ├── settings.js ├── storage.js ├── test └── test.kiosk.js └── url.js /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .DS_Store/ 3 | assets/ 4 | node_modules/ 5 | certificate/ 6 | npm-debug.log 7 | *.env -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: node app.js 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Starting the bot 2 | ``` 3 | npm start 4 | ``` 5 | Open @sapsanasapbot in Telegram. 6 | 7 | # Collecting tickets 8 | ``` 9 | npm run-script collect 10 | ``` 11 | 12 | # Testing scheduled collect 13 | Make the script executable, if it isn't already: 14 | ``` 15 | chmod u+x bin/collect 16 | ``` 17 | Then execute the script: 18 | ``` 19 | bin/collect 20 | ``` -------------------------------------------------------------------------------- /analyzer.js: -------------------------------------------------------------------------------- 1 | var Kiosk = require('./kiosk'); 2 | 3 | var _ = require('lodash'); 4 | var moment = require('moment'); 5 | var debug = require('debug')('analyzer'); 6 | const polyglot = require('./polyglot')(); 7 | 8 | const moreTicketsLimit = 5; 9 | 10 | /** 11 | * @param {Object} data 12 | * @param {Object} [data.filter] Request for more results (means multiple entries, approximate cost) 13 | * @param {Boolean} [data.more] Request for more results (means multiple entries, approximate cost) 14 | * @returns {Promise} 15 | */ 16 | var analyze = function(data) { 17 | var filter = data.filter; 18 | var more = data.more; 19 | 20 | filter = _.extend({ 21 | route: Kiosk.defaultRoute.toObject() 22 | }, filter); 23 | 24 | debug('Selecting the cheapest roundtrip with options', filter, 'segment:', data.segment); 25 | 26 | return Kiosk.getAll() 27 | .then(function(roundtrips) { 28 | var totalCost = filter.totalCost; 29 | var specificDate = filter.originatingTicket && filter.originatingTicket.date; 30 | var month = !_.isUndefined(filter.month) ? filter.month : (specificDate && specificDate.getMonth()); 31 | var monthName = !_.isUndefined(month) ? moment(month + 1, 'M').format('MMMM').toLowerCase() : null; 32 | var monthBeyondTimespanMessage = monthName ? polyglot.t('monthBeyondTimespanMessage', { 33 | monthName: monthName, 34 | timespan: Kiosk.timespan 35 | }) : null; 36 | 37 | // Remove total cost from filter, since filter only test values for equalty. 38 | delete filter.totalCost; 39 | 40 | // Remove day of week filter if it is set to "any" 41 | if (filter.weekday === Kiosk.weekdays.any) { 42 | delete filter.weekday; 43 | } 44 | 45 | // Remove originating ticket date, if set to null, to cancel specific date filter 46 | if (filter.originatingTicket && filter.originatingTicket.date === null) { 47 | delete filter.originatingTicket.date; 48 | } 49 | 50 | var filteredRoundtrips = _.filter(roundtrips, filter); 51 | var result; 52 | var message; 53 | 54 | // If multiple results required, oder by cost and time and return the first five 55 | if (more) { 56 | result = _.sortBy(filteredRoundtrips, ['totalCost', 'originatingTicket.datetime']); 57 | result = excludeMin(result); 58 | var offset = data.segment * moreTicketsLimit; 59 | result = result.slice(offset, offset + moreTicketsLimit); 60 | if (result.length > 1) { 61 | message = polyglot.t('moreTicketsCheapestFirst'); 62 | } else if (result.length === 1) { 63 | message = polyglot.t('lastPairOfTickets'); 64 | // If specific date is set and asking for more tickets 65 | } else if (!result.length && specificDate) { 66 | message = polyglot.t('onlyOneCheapestPairPerDay'); 67 | } else { 68 | // TODO Handle the situation when there should be tickets, but are not (like now, when there are no more morning trains to Moscow in October). 69 | message = polyglot.t('noMoreTickets'); 70 | } 71 | } 72 | // If price limit is specified, find tickets below price limit, and return nearest ticket by date. 73 | else if (totalCost) { 74 | var cheapEnoughRoundtrips = _.filter(filteredRoundtrips, function(roundtrip) { 75 | return roundtrip.totalCost <= totalCost; 76 | }); 77 | // If cheap enough roundtrips are found, select nearest by date 78 | if (cheapEnoughRoundtrips.length) { 79 | result = _.minBy(cheapEnoughRoundtrips, 'originatingTicket.datetime'); 80 | // If cheap enough roundtrips are not found, simply select the cheapest roundtrip 81 | } else { 82 | message = polyglot.t('noTicketsWithGivenPrice', {totalCost: totalCost}); 83 | result = _.minBy(filteredRoundtrips, 'totalCost'); 84 | } 85 | // If month is set but no tickets found 86 | } else if (!_.isUndefined(filter.month) && !filteredRoundtrips.length) { 87 | if (Kiosk.isMonthWithinTimespan(month)) { 88 | message = polyglot.t('noTicketsForGivenMonth', {monthName: monthName}); 89 | } else { 90 | message = monthBeyondTimespanMessage; 91 | } 92 | // If date is set but no tickets found 93 | } else if (specificDate && !filteredRoundtrips.length) { 94 | message = Kiosk.isMonthWithinTimespan(month) ? polyglot.t('noTicketsForGivenDate') : monthBeyondTimespanMessage; 95 | // If no special condition is specified, simply find the cheapest roundtrip. 96 | } else { 97 | result = _.minBy(filteredRoundtrips, 'totalCost'); 98 | } 99 | 100 | result = result && !_.isArray(result) ? [result] : result; 101 | return {roundtrips: result, message: message}; 102 | }); 103 | }; 104 | 105 | /** 106 | * Finds tickets with the same price or with price that is just slightly higher. 107 | */ 108 | var excludeMin = function(roundtrips) { 109 | var excludedRoundtrip = _.minBy(roundtrips, ['totalCost', 'originatingTicket.datetime']); 110 | _.remove(roundtrips, {_id: excludedRoundtrip._id}); 111 | return roundtrips; 112 | }; 113 | 114 | module.exports = { 115 | analyze: analyze 116 | }; 117 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config({silent: true}); 2 | var debug = require('debug')('app'); 3 | 4 | var Bot = require('./bot'); 5 | Bot.main(); 6 | -------------------------------------------------------------------------------- /bin/collect: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | require('dotenv').config({silent: true}); 4 | var moment = require('moment'); 5 | 6 | var Settings = require('./../settings'); 7 | 8 | /** 9 | * Interval in minutes 10 | * @type {number} 11 | */ 12 | const interval = 20; 13 | 14 | Settings 15 | // Get previous collector launch time 16 | .getValue('collectorRunAt') 17 | .then(function(collectorRunAt) { 18 | var elapsed = collectorRunAt ? moment().valueOf() - moment(collectorRunAt).valueOf() : null; 19 | // If this is the first collector run or enough time has elapsed between previous run, launch collector. 20 | if (!collectorRunAt || elapsed >= interval * 60 * 1000) { 21 | require('./../maintenance/collect'); 22 | 23 | // Mark collector launch time 24 | return Settings.setValue('collectorRunAt', new Date()); 25 | } else { 26 | throw new Error('Not enough time elapsed'); 27 | } 28 | }); -------------------------------------------------------------------------------- /bot.js: -------------------------------------------------------------------------------- 1 | var Analyzer = require('./analyzer'); 2 | var Kiosk = require('./kiosk'); 3 | var History = require('./history'); 4 | 5 | var TelegramBot = require('node-telegram-bot-api'); 6 | var botan = require('botanio')(process.env.TELEGRAM_BOT_ANALYTICS_TOKEN); 7 | var _ = require('lodash'); 8 | var moment = require('moment'); 9 | var debug = require('debug')('bot'); 10 | 11 | const polyglot = require('./polyglot')(); 12 | 13 | var Promise = require('bluebird'); 14 | 15 | const useWebhook = Boolean(process.env.USE_WEBHOOK); 16 | 17 | const minPriceLimit = 1000; 18 | 19 | const states = { 20 | helpCommand: 'helpCommand', 21 | purchase: 'purchase', 22 | link: 'link', 23 | greeting: 'greeting', 24 | roundtrip: 'roundtrip', 25 | start: 'start', 26 | unclear: 'unclear' 27 | }; 28 | 29 | // Webhook for remote, polling for local 30 | const options = useWebhook ? { 31 | webHook: { 32 | port: process.env.PORT || 5000 33 | } 34 | } : {polling: true}; 35 | 36 | const bot = new TelegramBot(process.env.TELEGRAM_BOT_TOKEN, options); 37 | 38 | const defaultFilter = { 39 | // Set morning originating hours by default 40 | originatingHours: Kiosk.hourAliases.morning 41 | }; 42 | 43 | /** 44 | * Extracts search options from user message 45 | * @param {String} text 46 | * @returns {Promise} 47 | */ 48 | var extractOptions = function(text) { 49 | var filter = {}; 50 | 51 | // To Moscow 52 | if (polyglot.t('toMoscowPattern').test(text)) { 53 | filter.route = Kiosk.Route.toMoscow(); 54 | } 55 | // To Spb 56 | if (polyglot.t('toSpbPattern').test(text)) { 57 | filter.route = Kiosk.Route.toSpb() 58 | } 59 | // If asking for "туда и обратно", assuming destination is Moscow, since the question is "В Москву или Петербург?" 60 | if (polyglot.t('thereAndBackPattern').test(text)) { 61 | filter.route = Kiosk.Route.toMoscow(); 62 | } 63 | 64 | // Early morning 65 | if (polyglot.t('earlyMorningPattern').test(text)) { 66 | filter.originatingHours = Kiosk.hourAliases.earlyMorning; 67 | // Morning 68 | } else if (polyglot.t('morningPattern').test(text)) { 69 | filter.originatingHours = Kiosk.hourAliases.morning; 70 | // Day 71 | } else if (polyglot.t('afternoonPattern').test(text)) { 72 | filter.originatingHours = Kiosk.hourAliases.day; 73 | } 74 | 75 | // Any day of the week 76 | if (polyglot.t('anyDayOfWeekPattern').test(text)) { 77 | filter.weekday = Kiosk.weekdays.any; 78 | // Weekend 79 | } else if (polyglot.t('weekendPattern').test(text)) { 80 | filter.weekday = Kiosk.weekdays.weekend; 81 | } 82 | 83 | // Price limit 84 | var priceLimit = text && text.match(polyglot.t('pricePattern')); 85 | if (priceLimit && priceLimit.length) { 86 | priceLimit = parseInt(priceLimit[0].replace(/ \./g, '')); 87 | filter.totalCost = !_.isNaN(priceLimit) && priceLimit >= minPriceLimit ? priceLimit : null; 88 | } 89 | // Month 90 | var month = extractMonth(text); 91 | if (!_.isUndefined(month)) { 92 | filter.month = month; 93 | } 94 | // Specific date in various formats 95 | var specificDate1 = text && text.match(polyglot.t('specificDatePattern1')); 96 | if (specificDate1 && !_.isUndefined(month)) { 97 | var year = (new Date()).getFullYear(); 98 | var dateMatch = specificDate1[0] && specificDate1[0].match(/\d+/g); 99 | var day = dateMatch && dateMatch.length && parseInt(dateMatch[0]); 100 | // If it was a secific date request, remove month from filter. 101 | delete filter.month; 102 | var specificDate1moment = moment([year, month, day]); 103 | // If the date is in the past (days difference is positive) as it happens in the last months of the year, try next year 104 | if (moment().diff(specificDate1moment, 'days') > 0) { 105 | specificDate1moment = moment([year + 1, month, day]); 106 | } 107 | filter.originatingTicket = { 108 | date: specificDate1moment.toDate() 109 | }; 110 | } 111 | var specificDate2 = text && text.match(polyglot.t('specificDatePattern2')); 112 | if (specificDate2) { 113 | filter.originatingTicket = { 114 | date: moment(specificDate2[0], polyglot.t('dateFormat2')).toDate() 115 | }; 116 | } 117 | var specificDate3 = text && text.match(polyglot.t('specificDatePattern3')); 118 | if (specificDate3) { 119 | filter.originatingTicket = { 120 | date: moment(specificDate3[0], polyglot.t('dateFormat3')).toDate() 121 | }; 122 | } 123 | var tomorrow = text && text.match(polyglot.t('tomorrowPattern')); 124 | if (tomorrow) { 125 | filter.originatingTicket = { 126 | date: moment().add(1, 'day').startOf('day').toDate() 127 | }; 128 | } 129 | if (polyglot.t('cancelSpecificDatePattern').test(text)) { 130 | filter.originatingTicket = { 131 | date: null 132 | }; 133 | } 134 | 135 | // If asked for specific date, set to any weekday 136 | if (filter.originatingTicket && filter.originatingTicket.date) { 137 | filter.weekday = Kiosk.weekdays.any; 138 | } 139 | 140 | // More 141 | var more = polyglot.t('morePattern').test(text); 142 | 143 | return { 144 | filter: filter, 145 | more: more, 146 | nothingExtracted: _.isEmpty(filter) && !more 147 | }; 148 | }; 149 | 150 | /** 151 | * @param {String} text 152 | * @param {Number} [chatId] 153 | * @returns {*|Promise} 154 | */ 155 | var getOptions = function(text, chatId) { 156 | var extractedOptions = extractOptions(text); 157 | var filter = _.extend(_.clone(defaultFilter), extractedOptions.filter); 158 | var more = extractedOptions.more; 159 | 160 | return History.get(chatId) 161 | .then(function(previousData) { 162 | var previousFilter = previousData.filter || {}; 163 | var segment = previousData.segment; 164 | 165 | // Make sure month or date from current filter do not conflict with previous filter month or date 166 | if (filter.month && previousFilter.originatingTicket && previousFilter.originatingTicket.date) { 167 | delete previousFilter.originatingTicket.date; 168 | } 169 | if (filter.originatingTicket && filter.originatingTicket.date) { 170 | delete previousFilter.month; 171 | } 172 | 173 | // If route is set, reset segment parameter 174 | if (filter.route) { 175 | segment = 0; 176 | // If there are some parameters in the new filter (except for the route parameter, which resets the filter), 177 | // add the previous filter parameters. 178 | } else { 179 | filter = !_.isEmpty(filter) || more ? _.extend(previousFilter, filter) : {}; 180 | } 181 | // If asked for "more tickets" multiple times, increase segment 182 | if (previousData.more && more) { 183 | segment = !_.isUndefined(segment) ? segment + 1 : 0; 184 | } 185 | // If weekdays is not defined, set to any 186 | if (!filter.weekday) { 187 | filter.weekday = Kiosk.weekdays.any; 188 | } 189 | 190 | return { 191 | filter: filter, 192 | more: more, 193 | segment: segment, 194 | roundtrips: previousData.roundtrips, 195 | previousState: previousData.state, 196 | nothingExtracted: extractedOptions.nothingExtracted 197 | }; 198 | }) 199 | .catch(function() { 200 | return extractedOptions; 201 | }); 202 | }; 203 | 204 | /** 205 | * Extracts month from text 206 | * @param {String} text 207 | * @returns {Number} Integer from 0 to 11 208 | */ 209 | var extractMonth = function(text) { 210 | var month = text && text.match(polyglot.t('monthPattern')); 211 | var monthNumber; 212 | if (month && month.length) { 213 | month = month[0].replace(/ /g, ''); 214 | var monthKey = _.find(_.keys(polyglot.t('monthMap')), function(key) { 215 | var pattern = new RegExp(`^${key}`, 'gi'); 216 | return pattern.test(month); 217 | }); 218 | monthNumber = polyglot.t('monthMap')[monthKey]; 219 | } 220 | return monthNumber; 221 | }; 222 | 223 | var getChatUserName = function(userMessage) { 224 | var userName = [userMessage.chat.first_name, userMessage.chat.last_name]; 225 | return _.compact(userName).join(' '); 226 | }; 227 | 228 | var getInlineQueryUserName = function(inlineQuery) { 229 | var userName = [inlineQuery.from.first_name, inlineQuery.from.last_name]; 230 | return _.compact(userName).join(' '); 231 | }; 232 | 233 | /** 234 | * @param {Object} previousRoundtrip 235 | */ 236 | var getLink = function(previousRoundtrip) { 237 | var response; 238 | if (previousRoundtrip) { 239 | response = Kiosk.rzdDateRouteUrl(previousRoundtrip) 240 | .then(function(url) { 241 | return polyglot.t('ticketUrlMessage', {url: url}); 242 | }); 243 | } 244 | return response; 245 | }; 246 | 247 | var sendMessage = function(chatId, userName, botMessage, previousOptions) { 248 | // Save history 249 | var options = _.clone(previousOptions); 250 | options.state = botMessage.state; 251 | var roundtrips = botMessage.roundtrips; 252 | // Delete previous roundtrips and add new roundtrips, if any 253 | delete options.roundtrips; 254 | if (roundtrips) { 255 | options.roundtrips = !_.isArray(roundtrips) ? [roundtrips] : roundtrips; 256 | } 257 | 258 | // Store options extracted from user message and roundtrips, if any. 259 | return Promise.all([botMessage, options, History.save(options, chatId)]) 260 | // Sending the message 261 | .then(function(result) { 262 | var botMessage = result[0]; 263 | var rountripOptions = result[1]; 264 | var botMessageText = botMessage.message ? botMessage.message : botMessage; 265 | var options = _.extend(botMessage.options, { 266 | reply_markup: getReplyMarkup(botMessage.roundtrips, rountripOptions), 267 | parse_mode: 'HTML', 268 | disable_web_page_preview: true 269 | }); 270 | 271 | return bot.sendMessage(chatId, botMessageText, options); 272 | }) 273 | // Logging 274 | .then(function(botMessage) { 275 | var botMessageTextLog = botMessage.text.replace(/\n/g, ' '); 276 | debug(`Chat ${chatId} ${userName}, message: ${botMessageTextLog}.`); 277 | }) 278 | }; 279 | 280 | var analytics = function(userMessage, event) { 281 | botan.track(userMessage, event); 282 | }; 283 | 284 | var main = function() { 285 | if (useWebhook) { 286 | setWebhook(); 287 | } else { 288 | unsetWebhook(); 289 | } 290 | 291 | // Listen for user messages 292 | bot.on('message', function(userMessage) { 293 | var chatId = userMessage.chat.id; 294 | var userName = getChatUserName(userMessage); 295 | var userMessageText = userMessage.text; 296 | 297 | getOptions(userMessage.text, userMessage.chat.id) 298 | // Formatting a message 299 | .then(function(options) { 300 | var previousRoundtrip = options.roundtrips && options.roundtrips.length ? options.roundtrips[0] : null; 301 | 302 | debug(`Chat ${chatId} ${userName}, message: ${userMessageText}`); 303 | var result; 304 | 305 | if (polyglot.t('helpPattern').test(userMessage.text)) { 306 | analytics(userMessage, '/help'); 307 | result = {message: polyglot.t('helpText'), state: states.helpCommand}; 308 | } else if (polyglot.t('purchasePattern').test(userMessage.text)) { 309 | analytics(userMessage, 'purchase'); 310 | result = getLink(previousRoundtrip) 311 | .then(function(text) { 312 | return {message: text, state: states.purchase}; 313 | }); 314 | } else if (polyglot.t('linkPattern').test(userMessage.text)) { 315 | analytics(userMessage, 'link'); 316 | result = getLink(previousRoundtrip) 317 | .then(function(text) { 318 | return {message: text, state: states.link}; 319 | }); 320 | } else if (polyglot.t('greetingPattern').test(userMessage.text)) { 321 | analytics(userMessage, 'greeting'); 322 | result = {message: polyglot.t('greetingText'), state: states.greeting}; 323 | // Start 324 | } else if (polyglot.t('startPattern').test(userMessageText)) { 325 | analytics(userMessage, 'start'); 326 | result = {message: polyglot.t('routeQuestion'), state: states.start}; 327 | // If nothing is extracted from this user message or route is not clear 328 | } else if (options.nothingExtracted || !options.filter.route) { 329 | analytics(userMessage, 'unclear'); 330 | result = { 331 | // If user message is unclear two times in a row, show help 332 | message: options.previousState === states.unclear ? polyglot.t('helpText') : polyglot.t('routeQuestion'), 333 | state: states.unclear 334 | }; 335 | // If the route is clear, search for tickets 336 | } else { 337 | analytics(userMessage, 'route'); 338 | debug(`Chat: ${chatId} ${userName}, extracted options: ${JSON.stringify(options)}`); 339 | result = getRoundtrips(options) 340 | .then(function(result) { 341 | return _.extend(result, {state: states.roundtrip}); 342 | }); 343 | } 344 | 345 | return Promise.all([result, options]); 346 | }) 347 | // Send message 348 | .then(function(result) { 349 | var botMessage = result[0]; 350 | var options = result[1]; 351 | return sendMessage(chatId, userName, botMessage, options); 352 | }) 353 | .catch(function(error) { 354 | console.log(error && error.stack); 355 | }); 356 | }); 357 | 358 | bot.on('callback_query', function(callbackQuery) { 359 | var callbackQueryId = callbackQuery.id; 360 | var message = callbackQuery.message; 361 | var chatId = message.chat.id; 362 | var text = callbackQuery.data; 363 | var userName = getChatUserName(message); 364 | 365 | // Send an empty callback query answer to prevent button throbber from spinning endlessly. 366 | bot.answerCallbackQuery(callbackQueryId); 367 | 368 | getOptions(text, chatId) 369 | .then(function(options) { 370 | return Promise.all([getRoundtrips(options), options]); 371 | }) 372 | .then(function(result) { 373 | var botMessage = result[0]; 374 | var options = result[1]; 375 | botMessage = _.extend(botMessage, {state: states.roundtrip}); 376 | return sendMessage(chatId, userName, botMessage, options); 377 | }) 378 | .catch(function(error) { 379 | console.log(error && error.stack); 380 | }); 381 | }); 382 | 383 | bot.on('inline_query', function(inlineQuery) { 384 | var queryId = inlineQuery.id; 385 | var queryText = inlineQuery.query; 386 | var userName = getInlineQueryUserName(inlineQuery); 387 | 388 | debug(`Inline query ${queryId} ${userName}, message: ${queryText}`); 389 | 390 | getOptions(queryText) 391 | // Getting the tickets 392 | .then(function(options) { 393 | var promise; 394 | 395 | debug(`Inline query ${queryId} ${userName}, extracted options: ${JSON.stringify(options)}`); 396 | 397 | // Empty query (default suggest): show a cheapest roundtrip to Spb and a roundtrip to Moscow 398 | if (!queryText) { 399 | var toSpbData = { 400 | filter: { 401 | route: Kiosk.Route.toSpb() 402 | } 403 | }; 404 | var toMoscowData = { 405 | filter: { 406 | route: Kiosk.Route.toMoscow() 407 | } 408 | }; 409 | analytics(inlineQuery, 'inline query: empty'); 410 | promise = Promise.all([Analyzer.analyze(toSpbData), Analyzer.analyze(toMoscowData)]); 411 | // Non-empty query: fetch a cheapest ticket plus extra 5 tickets 412 | } else { 413 | if (!options.filter.route) { 414 | // If route is unclear, use default route 415 | options.filter.route = Kiosk.defaultRoute; 416 | } 417 | // First ticket 418 | var firstTicketData = _.extend(_.clone(options), {more: false}); 419 | // Next five tickets (first ticket is excluded) 420 | var nextTicketsData = _.extend(_.clone(options), { 421 | more: true, 422 | segment: 0 423 | }); 424 | analytics(inlineQuery, 'inline query: route'); 425 | promise = Promise.all([Analyzer.analyze(firstTicketData), Analyzer.analyze(nextTicketsData)]); 426 | } 427 | return promise; 428 | }) 429 | // Formatting 430 | .then(function(analyzerResults) { 431 | var roundtrips = []; 432 | _.each(analyzerResults, function(analyzerResult) { 433 | if (analyzerResult && analyzerResult.roundtrips) { 434 | roundtrips.push(analyzerResult.roundtrips); 435 | } 436 | }); 437 | roundtrips = _.compact(_.flatten(roundtrips)); 438 | 439 | var promises = []; 440 | 441 | _.forEach(roundtrips, function(roundtrip) { 442 | promises.push(generateInlineQueryResult(roundtrip)); 443 | }); 444 | return Promise.all(promises); 445 | }) 446 | .then(function(queryResults) { 447 | return bot.answerInlineQuery(queryId, queryResults, {cache_time: process.env.INLINE_RESULT_CACHE_TIME}); 448 | }) 449 | .catch(function(error) { 450 | console.log(error && error.stack); 451 | }); 452 | }); 453 | }; 454 | 455 | var getReplyMarkup = function(roundtrips, options) { 456 | var markup = {}; 457 | switch (options.state) { 458 | case states.roundtrip: 459 | case states.unclear: 460 | markup = {inline_keyboard: getInlineButtons(roundtrips, options)}; 461 | break; 462 | default: 463 | // With two keyboards specified, inline keyboard does not show. It's either reply keyboard or inline keyboard. 464 | markup = { 465 | keyboard: getReplyButtons(), 466 | resize_keyboard: true, 467 | one_time_keyboard: true 468 | }; 469 | break; 470 | } 471 | return JSON.stringify(markup); 472 | }; 473 | 474 | /** 475 | * Determines the set of buttons depending on context 476 | * @param {Array} roundtrips 477 | * @param {Object} options 478 | * @returns {Array.>} Array of arrays of button captions 479 | */ 480 | var getInlineButtons = function(roundtrips, options) { 481 | var firstRoundtrip = roundtrips && roundtrips.length && roundtrips[0]; 482 | var keys = []; 483 | var filter = options.filter; 484 | if (firstRoundtrip) { 485 | // More tickets 486 | var specificDate = filter.originatingTicket && filter.originatingTicket.date; 487 | var firstRow = [{text: polyglot.t('moreTicketsButton'), callback_data: specificDate ? polyglot.t('moreTicketsForAnyDateCallback') : polyglot.t('moreTicketsCallback')}]; 488 | keys.push(firstRow); 489 | 490 | var when = []; 491 | if (!specificDate) { 492 | var isAnyDayOfWeek = filter.weekday && filter.weekday !== Kiosk.weekdays.weekend; 493 | when.push(isAnyDayOfWeek ? {text: polyglot.t('weekendsButton'), callback_data: polyglot.t('weekendsCallback')} : {text: polyglot.t('anyDayOfWeekButton'), callback_data: polyglot.t('anyDayOfWeekCallback')}); 494 | } 495 | // Available months, excluding previously mentioned month, if any 496 | var months = _.map(Kiosk.getMonthsWithinTimespan(filter.month), function(month) { 497 | var monthName = moment(month + 1, 'M').format('MMMM').toLowerCase(); 498 | return {text: monthName, callback_data: monthName}; 499 | }); 500 | when = when.concat(months); 501 | keys.push(when); 502 | } else { 503 | keys = [[{text: polyglot.t('toMoscowButton'), callback_data: polyglot.t('toMoscowCallback')}, {text: polyglot.t('toPetersburgButton'), callback_data: polyglot.t('toPetersburgCallback')}]]; 504 | } 505 | return keys; 506 | }; 507 | 508 | var getReplyButtons = function() { 509 | return [[polyglot.t('toMoscowButton'), polyglot.t('toPetersburgButton')]]; 510 | }; 511 | 512 | var getRoundtrips = function(options) { 513 | return Analyzer.analyze(options) 514 | .then(function(result) { 515 | var promise = result.roundtrips && result.roundtrips.length ? Kiosk.formatRoundtrip(result.roundtrips, true) : ''; 516 | return Promise.all([result, promise]); 517 | }) 518 | .then(function(data) { 519 | var result = data[0]; 520 | var roundtripsFormatted = data[1]; 521 | var message = ''; 522 | 523 | if (result.message) { 524 | message += `${result.message}\n\n`; 525 | } 526 | message += roundtripsFormatted; 527 | // Make sure bot message text is not empty. 528 | if (!message) { 529 | message = polyglot.t('noTicketsText'); 530 | } 531 | message = _.trim(message); 532 | 533 | return { 534 | message: message, 535 | roundtrips: result.roundtrips 536 | }; 537 | }); 538 | }; 539 | 540 | /** 541 | * Generates an object of type InlineQueryResult used by answerInlineQuery() Telegram API method 542 | * @param roundtrip 543 | * @returns {Promise} 544 | */ 545 | var generateInlineQueryResult = function(roundtrip) { 546 | var toAlias = roundtrip.route.to; 547 | return Kiosk.formatRoundtrip(roundtrip, true) 548 | .then(function(text) { 549 | return { 550 | type: 'article', 551 | id: _.random(0, 999999999).toString(), 552 | title: Kiosk.formatRoundtripTitle(roundtrip), 553 | input_message_content: { 554 | message_text: text, 555 | parse_mode: 'HTML', 556 | disable_web_page_preview: true 557 | }, 558 | thumb_url: `https://s3.amazonaws.com/swivel-sew-booth/${toAlias}-logo.png`, 559 | thumb_width: 50, 560 | thumb_height: 50 561 | } 562 | }); 563 | }; 564 | 565 | var setWebhook = function() { 566 | bot.setWebHook(`https://${process.env.APP_NAME}/?token=${process.env.TELEGRAM_BOT_TOKEN}`); 567 | }; 568 | 569 | var unsetWebhook = function() { 570 | bot.setWebHook(); 571 | }; 572 | 573 | module.exports = { 574 | main: main, 575 | setWebhook: setWebhook, 576 | unsetWebhook: unsetWebhook 577 | }; 578 | -------------------------------------------------------------------------------- /collector.js: -------------------------------------------------------------------------------- 1 | var Kiosk = require('./kiosk'); 2 | var Storage = require('./storage'); 3 | 4 | var assert = require('assert'); 5 | var moment = require('moment'); 6 | var _ = require('lodash'); 7 | var debug = require('debug')('collector'); 8 | 9 | var Promise = require('bluebird'); 10 | 11 | /** 12 | * Maximum requests to rzd site at a time 13 | * @type {number} 14 | */ 15 | const maximumRequests = 30; 16 | 17 | const collectionName = 'tickets'; 18 | 19 | /** 20 | * @param {Number} limit Days limit 21 | * @param {Number} [offset] Days offset 22 | * @returns {Promise} promise containing all tickets for the timespan 23 | */ 24 | var getTimespanTickets = function(limit, offset) { 25 | var promises = []; 26 | var route = Kiosk.defaultRoute; 27 | 28 | // If start date offset is not specified, collect all tickets starting today 29 | var startDate = moment().add(offset || 0, 'days'); 30 | 31 | // Collect tickets for each day in timespan 32 | for (var i = 0; i < limit; i++) { 33 | var date = startDate.clone().add(i, 'days').toDate(); 34 | promises.push(Kiosk.getTicketsForDate(date, route)); 35 | } 36 | 37 | return Promise.all(promises).then(function(result) { 38 | return _.flatten(result); 39 | }); 40 | }; 41 | 42 | /** 43 | * Returns request portions, max maximumRequests per portion, timespan requests total 44 | * @returns {Array} 45 | */ 46 | var getPortions = function() { 47 | var dayPortions = []; 48 | 49 | for (var i = 0; i < Math.floor(Kiosk.timespan / maximumRequests); i++) { 50 | dayPortions.push(maximumRequests); 51 | } 52 | 53 | // Remainder 54 | if (Kiosk.timespan % maximumRequests) { 55 | dayPortions.push(Kiosk.timespan % maximumRequests); 56 | } 57 | 58 | return dayPortions; 59 | }; 60 | 61 | /** 62 | * Collects tickets for timespan days, breaking into portions of maximumRequests if necessary. 63 | * Waits for the previous request portion to finish / resolve, before sending a next portion of requests. 64 | * @returns {Promise} 65 | */ 66 | var getAllTickets = function() { 67 | var portions = getPortions(); 68 | var allTickets = []; 69 | 70 | var getTicketsPortion = function(i) { 71 | i = i || 0; 72 | 73 | return getTimespanTickets(portions[i], i > 0 ? maximumRequests * i : null) 74 | .then(function(tickets) { 75 | allTickets = allTickets.concat(tickets); 76 | i ++; 77 | if (i < portions.length) { 78 | return getTicketsPortion(i); 79 | } else { 80 | return allTickets; 81 | } 82 | }); 83 | }; 84 | 85 | return getTicketsPortion(); 86 | }; 87 | 88 | /** 89 | * Returns a range of timespan dates 90 | * @returns {Array} 91 | */ 92 | var getTimespanDates = function() { 93 | var dates = []; 94 | var startDate = moment().startOf('day'); 95 | for (var i = 0; i < Kiosk.timespan; i++) { 96 | dates.push(startDate.clone().add(i, 'days').toDate()); 97 | } 98 | return dates; 99 | }; 100 | 101 | /** 102 | * Checks that tickets for all timespan dates were fetched 103 | */ 104 | var testIntegrity = function() { 105 | return Storage 106 | .find(collectionName) 107 | .then(function(allTickets) { 108 | var datesInStorage = Kiosk.extractDates(allTickets); 109 | assert.deepEqual(datesInStorage, getTimespanDates()); 110 | }) 111 | .then(function() { 112 | debug('Fetched tickets for all available dates.'); 113 | }) 114 | .catch(function(error) { 115 | console.log(error && error.stack); 116 | }); 117 | }; 118 | 119 | 120 | var fetch = function() { 121 | var now = moment().valueOf(); 122 | 123 | return getAllTickets() 124 | .then(function(tickets) { 125 | // If no tickets fetched, do not overwrite existing tickets 126 | if (!tickets.length) { 127 | throw new Error('No tickets fetched.'); 128 | // If tickets fetched, but count too low, do not overwrite existing tickets 129 | } else if (tickets.length < Kiosk.ticketsCountThreshold) { 130 | throw new Error(`Tickets fetched, but count too low: ${tickets.length}`); 131 | } 132 | var id = 1; 133 | _.each(tickets, function(ticket) { 134 | ticket.collectedAt = now; 135 | ticket.id = id; 136 | id ++; 137 | }); 138 | 139 | return Promise.all([tickets, Storage.drop(collectionName)]); 140 | }) 141 | .then(function(result) { 142 | var tickets = result[0]; 143 | debug(`Collected tickets length: ${tickets && tickets.length}`); 144 | return Storage.insert(collectionName, tickets); 145 | }) 146 | .then(function() { 147 | debug('Successfully collected tickets.'); 148 | }); 149 | }; 150 | 151 | var getAll = function() { 152 | return Storage.find(collectionName); 153 | }; 154 | 155 | module.exports = { 156 | fetch: fetch, 157 | getAll: getAll, 158 | testIntegrity: testIntegrity 159 | }; 160 | -------------------------------------------------------------------------------- /history.js: -------------------------------------------------------------------------------- 1 | const Promise = require('bluebird'); 2 | const debug = require('debug')('history'); 3 | const moment = require('moment'); 4 | 5 | const Storage = require('./storage'); 6 | 7 | const collectionName = 'history'; 8 | 9 | /** 10 | * Stores last chat options for each chat 11 | * @param {Object} data 12 | * @param {Number} chatId 13 | * @returns {Promise} 14 | */ 15 | var save = function(data, chatId) { 16 | return Storage 17 | .remove(collectionName, {chatId: chatId}) 18 | .then(function() { 19 | return Storage.insert(collectionName, { 20 | data: data, 21 | date: moment().toDate(), 22 | chatId: chatId 23 | }); 24 | }); 25 | }; 26 | 27 | var get = function(chatId) { 28 | return Storage 29 | .find(collectionName, {chatId: chatId}) 30 | .then(function(entries) { 31 | return entries.length ? entries[0].data : {}; 32 | }); 33 | }; 34 | 35 | module.exports = { 36 | save: save, 37 | get: get 38 | }; -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config({silent: true}); 2 | 3 | var http = require('http'); 4 | var Analyzer = require('./analyzer'); 5 | var Kiosk = require('./kiosk'); 6 | 7 | var requestListener = function(request, response) { 8 | response.writeHead(200, {'Content-Type': 'text/plain; charset=utf-8'}); 9 | Analyzer.analyze() 10 | .then(function(roundtrip) { 11 | response.end(Kiosk.formatRoundtrip(roundtrip)); 12 | }); 13 | }; 14 | 15 | var main = function() { 16 | http.createServer(requestListener).listen(process.env.PORT || 5000); 17 | }; 18 | 19 | main(); 20 | -------------------------------------------------------------------------------- /kiosk.js: -------------------------------------------------------------------------------- 1 | var Storage = require('./storage'); 2 | var Url = require('./url'); 3 | 4 | var Promise = require('bluebird'); 5 | 6 | var request = require('request-promise'); 7 | 8 | var moment = require('moment'); 9 | var _ = require('lodash'); 10 | 11 | var debug = require('debug')('kiosk'); 12 | const polyglot = require('./polyglot')(); 13 | 14 | const cityAliases = { 15 | mow: 'mow', 16 | spb: 'spb' 17 | }; 18 | 19 | const cities = { 20 | [cityAliases.mow]: { 21 | alias: cityAliases.mow, 22 | name: 'МОСКВА', 23 | formattedName: polyglot.t('moscowCapitalized'), 24 | code: 2000000 25 | }, 26 | [cityAliases.spb]: { 27 | alias: cityAliases.spb, 28 | name: 'САНКТ-ПЕТЕРБУРГ', 29 | formattedName: polyglot.t('spbCapitalized'), 30 | code: 2004000 31 | } 32 | }; 33 | 34 | const hourAliases = { 35 | earlyMorning: 'earlyMorning', 36 | morning: 'morning', 37 | day: 'day', 38 | evening: 'evening' 39 | }; 40 | 41 | const hourNames = { 42 | [hourAliases.earlyMorning]: 'рано утром', 43 | [hourAliases.morning]: 'утром', 44 | [hourAliases.day]: 'днём', 45 | [hourAliases.evening]: 'вечером' 46 | }; 47 | 48 | /** 49 | * The hours that are passed to _.inRange function (which means a value should be between the start hour and up to, but not including, the end hour) 50 | * @type {Object} 51 | */ 52 | const hours = { 53 | [hourAliases.earlyMorning]: [5, 7], 54 | [hourAliases.morning]: [7, 10], 55 | [hourAliases.day]: [10, 17], 56 | [hourAliases.evening]: [17, 20] 57 | }; 58 | 59 | const weekdays = { 60 | any: 'any', 61 | weekend: 'weekend', 62 | weekday: 'weekday' 63 | }; 64 | 65 | const afterCredentialsDelay = 15000; 66 | 67 | /** 68 | * Timespan length in days. Rzd only allows searching for tickets within 60 days. 69 | * @type {number} 70 | */ 71 | const timespan = 60; 72 | 73 | /** 74 | * Maximum number of attempts to fetch tickets from rzd site. 75 | * @type {number} 76 | */ 77 | const maxAttempts = 15; 78 | 79 | /** 80 | * Tickets count is never expected to go beneath this threshold. 81 | * @type {number} 82 | */ 83 | const ticketsCountThreshold = 1200; 84 | 85 | const collectionName = 'roundtrips'; 86 | 87 | /** 88 | * @param {Object} to 89 | * @constructor 90 | */ 91 | var Route = function(to) { 92 | var cityCodes = Object.keys(cities); 93 | this.to = cityCodes.indexOf(to) === -1 ? cities.spb.alias : cities[to].alias; 94 | this.from = this.to === cityAliases.spb ? cities.mow.alias : cities.spb.alias; 95 | }; 96 | 97 | Route.prototype.getCityFrom = function() { 98 | return cities[this.from] || {}; 99 | }; 100 | 101 | Route.prototype.getCityTo = function() { 102 | return cities[this.to] || {}; 103 | }; 104 | 105 | Route.prototype.getSummary = function() { 106 | var fromName = this.getCityFrom()['formattedName']; 107 | var toName = this.getCityTo()['formattedName']; 108 | return `${fromName} → ${toName}`; 109 | }; 110 | 111 | Route.prototype.isToSpb = function() { 112 | return this.to === cityAliases.spb; 113 | }; 114 | 115 | Route.prototype.toObject = function() { 116 | return { 117 | to: this.to, 118 | from: this.from 119 | } 120 | }; 121 | 122 | /** 123 | * Returns a new reversed route 124 | * @param {Route} route 125 | * @returns {Route} 126 | */ 127 | Route.getReversed = function(route) { 128 | return new Route(route.from); 129 | }; 130 | 131 | /** 132 | * @returns {Route} 133 | */ 134 | Route.toMoscow = function() { 135 | return new Route(cityAliases.mow); 136 | }; 137 | 138 | /** 139 | * @returns {Route} 140 | */ 141 | Route.toSpb = function() { 142 | return new Route(cityAliases.spb); 143 | }; 144 | 145 | /** 146 | * Creates a Route object if necessary 147 | * @param {Object} route 148 | * @returns {Route} 149 | */ 150 | Route.hydrate = function(route) { 151 | if (route && route instanceof Route) { 152 | return route; 153 | } else if (route && route.to) { 154 | return new Route(route.to); 155 | } else if (route && route.from) { 156 | return Route.getReversed(route); 157 | } else { 158 | return new Route(); 159 | } 160 | }; 161 | 162 | const defaultRoute = Route.toMoscow(); 163 | 164 | /** 165 | * Returns request url and parameters 166 | * @param {Route} route 167 | * @param {Moment} date1 168 | * @param {Moment} [date2] 169 | * @param {String} [rid] 170 | * @returns {Object} 171 | */ 172 | var getRequestOptions = function(route, date1, date2, rid) { 173 | date2 = date2 || date1.clone(); 174 | 175 | var dateFormat = 'DD.MM.YYYY'; 176 | var cityFrom = route.getCityFrom(); 177 | var cityTo = route.getCityTo(); 178 | 179 | var parameters = { 180 | STRUCTURE_ID: '735', 181 | layer_id: '5371', 182 | dir: '1', 183 | tfl: '3', 184 | checkSeats: '1', 185 | st0: cityFrom.name, 186 | code0: cityFrom.code, 187 | dt0: date1.format(dateFormat), 188 | st1: cityTo.name, 189 | code1: cityTo.code, 190 | dt1: date2.format(dateFormat) 191 | }; 192 | 193 | if (rid) { 194 | parameters.rid = rid; 195 | } 196 | 197 | return { 198 | url: 'https://pass.rzd.ru/timetable/public/ru', 199 | parameters: parameters 200 | }; 201 | }; 202 | 203 | var getSessionCookie = function(response) { 204 | var cookieHeader = response.headers['set-cookie']; 205 | var cookie = {}; 206 | /** 207 | * @example JSESSIONID=00004ADS7pUenJiasDpQq4maKIR:17obq8rib; Path=/ 208 | */ 209 | var sessionCookie = _.find(cookieHeader, function(item) { 210 | return item.indexOf('JSESSIONID') !== -1; 211 | }); 212 | if (sessionCookie) { 213 | var pair = sessionCookie.split(';')[0].split('='); 214 | cookie = { 215 | name: pair[0], 216 | value: pair[1] 217 | }; 218 | } 219 | return cookie; 220 | }; 221 | 222 | /** 223 | * Fetches rid 224 | * @param {Object} requestOptions 225 | * @returns {Promise} 226 | */ 227 | var getCredentials = function(requestOptions) { 228 | return request({ 229 | method: 'GET', 230 | url: requestOptions.url, 231 | qs: requestOptions.parameters, 232 | resolveWithFullResponse: true, 233 | json: true 234 | }) 235 | .then(function(response) { 236 | return extractCredentials(response, response.body); 237 | }) 238 | .catch(function() { 239 | throw new Error('Failed to get credentials.'); 240 | }); 241 | }; 242 | 243 | /** 244 | * @param {Object} response 245 | * @param {String} body 246 | * @returns {{rid: (*|String), sessionCookie}} 247 | */ 248 | var extractCredentials = function(response, body) { 249 | var sessionCookie = getSessionCookie(response); 250 | return { 251 | rid: body.rid, 252 | sessionCookie: sessionCookie 253 | }; 254 | }; 255 | 256 | /** 257 | * Fetches tickets for a given date 258 | * @param {Date} date 259 | * @param {Route} route 260 | * @returns {Promise} 261 | */ 262 | var getTicketsForDate = function(date, route) { 263 | date = date || new Date(); 264 | var momentDate = moment(date); 265 | var credentialsOptions = getRequestOptions(route, momentDate); 266 | var attemptsCount = 0; 267 | 268 | // Using promise constructor because of setTimeout 269 | var promise = new Promise(function(resolve, reject) { 270 | 271 | var getTicketsWithCredentials = function(credentials) { 272 | debug('credentials', credentials); 273 | setTimeout(function() { 274 | // Add rid query parameter 275 | var ticketOptions = _.clone(credentialsOptions); 276 | ticketOptions.parameters.rid = credentials.rid; 277 | 278 | // Add session cookie 279 | var cookie = `${credentials.sessionCookie.name}=${credentials.sessionCookie.value};`; 280 | 281 | request({ 282 | method: 'GET', 283 | url: ticketOptions.url, 284 | qs: ticketOptions.parameters, 285 | resolveWithFullResponse: true, 286 | json: true, 287 | // Setting cookie via jar does not work. 288 | headers: { 289 | Cookie: cookie 290 | } 291 | }).then(function(response) { 292 | var body = response.body; 293 | var allTickets = []; 294 | if (body.tp) { 295 | allTickets = allTickets.concat(body.tp[0].list); 296 | allTickets = allTickets.concat(body.tp[1] ? body.tp[1].list : []); 297 | } 298 | debug('allTickets.length', allTickets.length); 299 | attemptsCount++; 300 | // Occasionally a new rid is returned instead of tickets. 301 | // If so, make another attempt to fetch tickets. 302 | if (!allTickets.length && attemptsCount <= maxAttempts) { 303 | debug('unexpected response: ', body); 304 | debug('attemptsCount', attemptsCount); 305 | if (attemptsCount <= maxAttempts) { 306 | 307 | getCredentials(credentialsOptions) 308 | .then(getTicketsWithCredentials); 309 | } else { 310 | reject('Maximum attempts reached, no tickets in response.'); 311 | } 312 | } else { 313 | var relevantTickets = filterTickets(allTickets, { 314 | highSpeed: true 315 | }); 316 | if (!relevantTickets.length && allTickets.length) { 317 | debug('All tickets filtered out'); 318 | } 319 | resolve(relevantTickets); 320 | } 321 | }) 322 | .catch(function(e) { 323 | throw new Error('Unable to get tickets'); 324 | }); 325 | }, afterCredentialsDelay); 326 | }; 327 | 328 | getCredentials(credentialsOptions) 329 | .then(getTicketsWithCredentials); 330 | }); 331 | 332 | 333 | 334 | return promise; 335 | }; 336 | 337 | /** 338 | * Filters only relevant tickets 339 | * @param {Array} allTickets 340 | * @param {Object} options 341 | * @returns {Array} 342 | */ 343 | var filterTickets = function(allTickets, options) { 344 | var brand = options.highSpeed ? 'САПСАН' : null; 345 | var station0; 346 | if (options.route) { 347 | station0 = _.isObject(options.route) ? Route.hydrate(options.route).getCityFrom().name : cities[options.route].name; 348 | } 349 | var date = options.date; 350 | var hourAlias = options.hours; 351 | var hourRange = hours[hourAlias]; 352 | 353 | return _.filter(allTickets, function(ticket) { 354 | var hit = true; 355 | var departureMoment = moment(getTicketDepartureDate(ticket)); 356 | 357 | if (brand && ticket.brand !== brand) { 358 | hit = false; 359 | } 360 | if (station0 && ticket.station0 !== station0) { 361 | hit = false; 362 | } 363 | if (date && !departureMoment.isSame(date, 'day')) { 364 | hit = false; 365 | } 366 | if (hourRange && !_.inRange(departureMoment.get('hours'), hourRange[0], hourRange[1])) { 367 | hit = false; 368 | } 369 | return hit; 370 | }); 371 | }; 372 | 373 | var formatCity = function(name) { 374 | return _.map(name.toLowerCase().split('-'), function(part) { 375 | return _.upperFirst(part); 376 | }) 377 | .join('-'); 378 | }; 379 | 380 | /** 381 | * @param {Object} json 382 | * @returns {String} 383 | */ 384 | var formatTicket = function(json) { 385 | var cityFrom = formatCity(json.station0); 386 | var cityTo = formatCity(json.station1); 387 | var date = moment(getTicketDepartureDate(json)); 388 | var dayFormatted = date.format(polyglot.t('dateFormat4')); 389 | var timeFormatted = date.format(polyglot.t('timeFormat1')); 390 | 391 | //Санкт-Петербург → Москва, 392 | //16 марта, среда, отправление в 5:30 393 | //1290 ₽ 394 | return polyglot.t('ticketTemplate', { 395 | cityFrom: cityFrom, 396 | cityTo: cityTo, 397 | dayFormatted: dayFormatted, 398 | timeFormatted: timeFormatted, 399 | rate: json.cars[0].tariff 400 | }); 401 | }; 402 | 403 | /** 404 | * Returns a link to rzd site with route and dates selected. 405 | * @param {Object} roundtrip 406 | * @returns {Promise} 407 | */ 408 | var rzdDateRouteUrl = function(roundtrip) { 409 | var route = Route.hydrate(roundtrip.originatingTicket.route); 410 | var momentDate1 = moment(roundtrip.originatingTicket.datetime); 411 | var momentDate2 = moment(roundtrip.returnTicket.datetime); 412 | var toHash = function(obj) { 413 | return _.map(obj, function(v, k) { 414 | return encodeURIComponent(k) + '=' + encodeURIComponent(v); 415 | }).join('|'); 416 | }; 417 | 418 | var requestOptions = getRequestOptions(route, momentDate1, momentDate2); 419 | var structureId = requestOptions.parameters.STRUCTURE_ID; 420 | delete requestOptions.parameters.layer_id; 421 | delete requestOptions.parameters.STRUCTURE_ID; 422 | requestOptions.parameters.st0 = route.getCityFrom().formattedName; 423 | requestOptions.parameters.st1 = route.getCityTo().formattedName; 424 | 425 | var parametersHash = toHash(requestOptions.parameters); 426 | var url = `${requestOptions.url}?STRUCTURE_ID=${structureId}#${parametersHash}`; 427 | 428 | // Using url shortener because when a link in Telegram gets clicked, the '|' characters in rzd url are url-encoded into %7C, which breaks the url. 429 | // There is no way to avoid the '|' character usage. 430 | return Url.shorten(url); 431 | }; 432 | 433 | 434 | // TODO Move to date utility 435 | var formatWeekday = function(dateMoment) { 436 | var localeData = moment.localeData(); 437 | var formatted; 438 | if (localeData._weekdays.format) { 439 | formatted = localeData._weekdays.format[dateMoment.day()]; 440 | formatted = (formatted.substring(0, 2) === 'вт' ? 'во ' : 'в ') + formatted; 441 | } else { 442 | formatted = dateMoment.format('dddd'); 443 | } 444 | return formatted; 445 | }; 446 | 447 | var formatNestedTicket = function(ticket) { 448 | var datetimeMoment = moment(ticket.datetime); 449 | var dayFormatted = datetimeMoment.format(polyglot.t('dateFormat4')); 450 | var timeFormatted = datetimeMoment.format(polyglot.t('timeFormat1')); 451 | var route = Route.hydrate(ticket.route); 452 | //Санкт-Петербург → Москва, 453 | //16 марта, среда, отправление в 5:30 454 | //1290 ₽ 455 | return polyglot.t('ticketTemplate', { 456 | cityFrom: route.getCityFrom().formattedName, 457 | cityTo: route.getCityTo().formattedName, 458 | dayFormatted: dayFormatted, 459 | timeFormatted: timeFormatted, 460 | rate: ticket.price 461 | }); 462 | }; 463 | 464 | /** 465 | * @param {Object} roundtrip 466 | * @param {Boolean} [includeLink] 467 | * @returns {Promise} 468 | */ 469 | var fullFormat = function(roundtrip, includeLink) { 470 | var originatingTicketText = formatNestedTicket(roundtrip.originatingTicket); 471 | var returnTicketText = formatNestedTicket(roundtrip.returnTicket); 472 | var promise; 473 | var text = `${originatingTicketText}\n\n${returnTicketText}\n----------\n${roundtrip.totalCost} ₽`; 474 | if (includeLink) { 475 | promise = rzdDateRouteUrl(roundtrip) 476 | .then(function(url) { 477 | return `${text}\n\n${url}`; 478 | }); 479 | } else { 480 | promise = Promise.resolve(text); 481 | } 482 | return promise; 483 | }; 484 | 485 | /** 486 | * @param {Object} roundtrip 487 | * @param {Boolean} [includeLink] 488 | * @returns {Promise} 489 | */ 490 | var shortFormat = function(roundtrip, includeLink) { 491 | var localeData = moment.localeData(); 492 | var originatingTicket = roundtrip.originatingTicket; 493 | var originatingMoment = moment(originatingTicket.datetime); 494 | var formatWeekday = polyglot.t('formatWeekday'); 495 | var originatingWeekday = formatWeekday && formatWeekday(originatingMoment, localeData._weekdays.format) || originatingMoment.format('dddd'); 496 | var originatingTicketDateFormatted = originatingWeekday + ' ' + originatingMoment.format(polyglot.t('dateFormat5')); 497 | 498 | var returnTicket = roundtrip.returnTicket; 499 | var returnMoment = moment(returnTicket.datetime); 500 | var returnWeekday = formatWeekday && formatWeekday(returnMoment, localeData._weekdays.format) || originatingMoment.format('dddd'); 501 | var returnTicketDateFormatted = returnWeekday + ' ' + returnMoment.format(polyglot.t('dateFormat5')); 502 | var originationRoute = Route.hydrate(originatingTicket.route); 503 | var promise; 504 | 505 | // Санкт-Петербург → Москва и обратно за 3447 ₽ 506 | // Туда в среду 18 мая в 7:00, обратно в четверг 19 мая в 18:00 507 | var routeText = polyglot.t('routeTemplate', { 508 | routeFrom: originationRoute.getCityFrom().formattedName, 509 | routeTo: originationRoute.getCityTo().formattedName 510 | }); 511 | var text = polyglot.t('ticketShortTemplate', { 512 | totalCost: roundtrip.totalCost, 513 | originatingTicketDateFormatted: originatingTicketDateFormatted, 514 | returnTicketDateFormatted: returnTicketDateFormatted 515 | }); 516 | if (includeLink) { 517 | promise = rzdDateRouteUrl(roundtrip) 518 | .then(function(url) { 519 | var link = `${routeText}`; 520 | return `${link} ${text}`; 521 | }); 522 | } else { 523 | promise = Promise.resolve(`${routeText} ${text}`); 524 | } 525 | return promise; 526 | }; 527 | 528 | /** 529 | * @param {Array} roundtrips 530 | * @param {Boolean} [includeLink] 531 | * @returns {String} 532 | */ 533 | var formatRoundtrip = function(roundtrips, includeLink) { 534 | var promises = []; 535 | roundtrips = roundtrips && !_.isArray(roundtrips) ? [roundtrips] : roundtrips; 536 | var isShortFormat = roundtrips.length > 1; 537 | _.forEach(roundtrips, function(roundtrip) { 538 | promises.push(isShortFormat ? shortFormat(roundtrip, includeLink) : fullFormat(roundtrip, includeLink)); 539 | }); 540 | return Promise.all(promises) 541 | .then(function(texts) { 542 | return texts.join("\n\n"); 543 | }); 544 | }; 545 | 546 | var formatRoundtripTitle = function(roundtrip) { 547 | var originatingTicket = roundtrip.originatingTicket; 548 | var originatingMoment = moment(originatingTicket.datetime); 549 | var originatingTicketDateFormatted = originatingMoment.format(polyglot.t('dateFormat6')); 550 | var originatingRoute = Route.hydrate(originatingTicket.route); 551 | return `${originatingRoute.getCityFrom().formattedName} ⇄ ${originatingRoute.getCityTo().formattedName}, ${roundtrip.totalCost} ₽, ${originatingTicketDateFormatted}`; 552 | }; 553 | 554 | /** 555 | * @param {Object} json Ticket json 556 | * @returns {Date} 557 | */ 558 | var getTicketDepartureDate = function(json) { 559 | // This date format is for parsing, do not localize 560 | return moment(`${json.date0} ${json.time0}`, 'DD.MM.YYYY HH:mm').toDate(); 561 | }; 562 | 563 | /** 564 | * @param {Object} json Ticket json 565 | * @returns {Date} 566 | */ 567 | var getDayAfterTicket = function(json) { 568 | var ticketDepartureDate = getTicketDepartureDate(json); 569 | return moment(ticketDepartureDate).add(1, 'days').startOf('day').toDate(); 570 | }; 571 | /** 572 | * @param {Object} json Ticket json 573 | * @returns {Date} 574 | */ 575 | var getDayBeforeTicket = function(json) { 576 | var ticketDepartureDate = getTicketDepartureDate(json); 577 | return moment(ticketDepartureDate).subtract(1, 'days').startOf('day').toDate(); 578 | }; 579 | 580 | /** 581 | * Makes up ticket summary 582 | * @param {Object} json Ticket json 583 | * @returns {String} 584 | */ 585 | var getSummary = function(json) { 586 | return `${json.date0} ${json.station0} ${json.station1} ${json.cars ? json.cars[0].tariff : ''}`; 587 | }; 588 | 589 | /** 590 | * Returns an array of dates of all stored tickets 591 | * @param {Array} tickets 592 | * @returns {Array} 593 | */ 594 | var extractDates = function(tickets) { 595 | var datesInStorage = _.map(_.uniqBy(tickets, 'date0'), function(json) { 596 | // This date format is for parsing, do not attempt to localize 597 | return moment(json.date0, 'DD.MM.YYYY').toDate(); 598 | }); 599 | return _.sortBy(datesInStorage); 600 | }; 601 | 602 | /** 603 | * Generates a roundtrip for each departure date, route and other options 604 | * @param {Array} allTickets 605 | * @returns {Promise} 606 | */ 607 | var generateIndex = function(allTickets) { 608 | var cheapestTickets = findAllCheapestTicketsWithOptions(allTickets); 609 | var roundtrips = extractRoundtrips(cheapestTickets); 610 | 611 | // If no roundtrips found, do not overwrite existing roundtrips. 612 | if (!roundtrips.length) { 613 | throw new Error('No roundtrips found.'); 614 | } 615 | 616 | return Promise.all([roundtrips, Storage.drop(collectionName)]) 617 | .then(function(result) { 618 | var roundtrips = result[0]; 619 | // Converting dates to string and routes to string alias before persisting 620 | return Storage.insert(collectionName, roundtrips); 621 | }); 622 | }; 623 | 624 | /** 625 | * Finds the cheapest ticket for every day, route and hours combination 626 | * @param {Array} allTickets 627 | * @returns {Array} 628 | */ 629 | var findAllCheapestTicketsWithOptions = function(allTickets) { 630 | var allDates = extractDates(allTickets); 631 | var entries = []; 632 | var toMoscow = Route.toMoscow().toObject(); 633 | var toSpb = Route.toSpb().toObject(); 634 | 635 | var minCallback = function(ticket) { 636 | return parseInt(ticket.cars[0].tariff); 637 | }; 638 | 639 | _.forEach(allDates, function(date) { 640 | var optionSet = [ 641 | {date: date, route: toMoscow, hours: hourAliases.earlyMorning}, 642 | {date: date, route: toMoscow, hours: hourAliases.morning}, 643 | {date: date, route: toMoscow, hours: hourAliases.day}, 644 | {date: date, route: toMoscow, hours: hourAliases.evening}, 645 | 646 | {date: date, route: toSpb, hours: hourAliases.earlyMorning}, 647 | {date: date, route: toSpb, hours: hourAliases.morning}, 648 | {date: date, route: toSpb, hours: hourAliases.day}, 649 | {date: date, route: toSpb, hours: hourAliases.evening} 650 | ]; 651 | 652 | _.forEach(optionSet, function(options) { 653 | var filteredTickets = filterTickets(allTickets, options); 654 | var cheapestTicket = _.minBy(filteredTickets, minCallback); 655 | if (cheapestTicket) { 656 | var time = cheapestTicket.time0.split(':'); 657 | entries.push(_.extend({ 658 | ticket: cheapestTicket.id, 659 | datetime: moment(date).hours(parseInt(time[0])).minutes(parseInt(time[1])).toDate(), 660 | price: parseInt(cheapestTicket.cars[0].tariff) 661 | }, options)); 662 | } 663 | }); 664 | }); 665 | 666 | return entries; 667 | }; 668 | 669 | /** 670 | * Finds the cheapest roundtrips for every day, route and hours combination 671 | * @param {Array} cheapestTickets 672 | * @returns {Array} 673 | */ 674 | var extractRoundtrips = function(cheapestTickets) { 675 | var roundtrips = []; 676 | // Morning and early morning and day tickets are assumed to be originating (outbound). 677 | var originatingTickets = _.filter(cheapestTickets, function(ticket) { 678 | return ticket.hours === hourAliases.earlyMorning || ticket.hours === hourAliases.morning || ticket.hours === hourAliases.day; 679 | }); 680 | var lastAvailableDay = getLastAvailableDay(); 681 | // Find a return ticket for every originating ticket. 682 | _.forEach(originatingTickets, function(originatingTicket, i) { 683 | // Do not make a roundtrip if it is the last of available days (otherwise return ticket will not be available) 684 | if (originatingTicket.date < lastAvailableDay) { 685 | var originatingTicketMoment = moment(originatingTicket.date); 686 | var roundtrip = { 687 | originatingTicket: originatingTicket, 688 | returnTicket: null, 689 | totalCost: null, 690 | route: originatingTicket.route, 691 | // If originating ticket date is Saturday, mark roundtrip as weekend. 692 | weekday: originatingTicketMoment.isoWeekday() === 6 ? weekdays.weekend : weekdays.weekday, 693 | // Indicates if originating departure time is early in the morning. 694 | originatingHours: originatingTicket.hours, 695 | // Storing month to simplify filtering 696 | month: originatingTicketMoment.month() 697 | }; 698 | 699 | var returnOptions = { 700 | date: moment(originatingTicket.date).add(1, 'days').toDate(), 701 | hours: hourAliases.evening, 702 | route: Route.getReversed(originatingTicket.route).toObject() 703 | }; 704 | var returnTicket = _.find(cheapestTickets, returnOptions); 705 | 706 | // Only store the roundtrip if return ticket is found 707 | if (returnTicket) { 708 | roundtrip.returnTicket = returnTicket; 709 | roundtrip.totalCost = originatingTicket.price + returnTicket.price; 710 | roundtrips.push(roundtrip); 711 | } else { 712 | debug('No ticket found with options', returnOptions); 713 | } 714 | } 715 | }); 716 | 717 | return roundtrips; 718 | }; 719 | 720 | var getAll = function() { 721 | return Storage.find(collectionName) 722 | .then(function(roundtrips) { 723 | _.forEach(roundtrips, function(rountrip) { 724 | rountrip.route = new Route(rountrip.route.to); 725 | }); 726 | return roundtrips; 727 | }); 728 | }; 729 | 730 | /** 731 | * Returns the last of available days 732 | * @see timespan 733 | * @returns {Date} 734 | */ 735 | var getLastAvailableDay = function() { 736 | return moment().add(timespan - 1, 'days').startOf('day').toDate(); 737 | }; 738 | 739 | /** 740 | * Determines if the month is in timespan 741 | * @param {Number} month Month number, starting from 0 742 | * @returns {Boolean} 743 | */ 744 | var isMonthWithinTimespan = function(month) { 745 | var now = moment(); 746 | // Current month, zero-based 747 | var currentMonth = now.month(); 748 | var currentYear = now.year(); 749 | var nextYear = currentYear + 1; 750 | // month + 1 because month is zero-based, but parsing requires regular-numbered months 751 | var diffInDays = moment(month + 1, 'M').diff(now, 'days'); 752 | // See if timespan extends to the next year, add the number of days in current year to day difference (e.g., -362 + 366 = 4) 753 | if (moment(nextYear, 'YYYY').diff(moment(), 'days') < timespan) { 754 | diffInDays += moment(nextYear, 'YYYY').diff(moment(currentYear, 'YYYY'), 'days'); 755 | } 756 | return month === currentMonth || (diffInDays <= timespan && diffInDays > 0); 757 | }; 758 | 759 | /** 760 | * Returns a list of months 761 | * @param {Number} [excludeMonth] 762 | * @returns {Number[]} 763 | */ 764 | var getMonthsWithinTimespan = function(excludeMonth) { 765 | var currentMonth = moment().month(); 766 | // Making so that range would always start from the current month 767 | var months = _.range(currentMonth, 12).concat(_.range(0, currentMonth)); 768 | return _.filter(months, function(month) { 769 | var isExcluded = !_.isUndefined(excludeMonth) && excludeMonth === month; 770 | return isMonthWithinTimespan(month) && !isExcluded; 771 | }); 772 | }; 773 | 774 | /** 775 | * @param {String|String[]} [excludeHours] 776 | * @return {String[]} Hour names 777 | */ 778 | var getHourNames = function(excludeHours) { 779 | excludeHours = excludeHours && !_.isArray(excludeHours) ? [excludeHours] : excludeHours; 780 | var keys = _.difference(_.keys(hours), excludeHours); 781 | return _.values(_.pick(hourNames, keys)) || []; 782 | }; 783 | 784 | var remove = function() { 785 | return Storage.remove(collectionName); 786 | }; 787 | 788 | module.exports = { 789 | cityAliases: cityAliases, 790 | hourAliases: hourAliases, 791 | hours: hours, 792 | weekdays: weekdays, 793 | timespan: timespan, 794 | ticketsCountThreshold: ticketsCountThreshold, 795 | defaultRoute: defaultRoute, 796 | Route: Route, 797 | getRequestOptions: getRequestOptions, 798 | getTicketsForDate: getTicketsForDate, 799 | formatTicket: formatTicket, 800 | rzdDateRouteUrl: rzdDateRouteUrl, 801 | formatRoundtrip: formatRoundtrip, 802 | formatRoundtripTitle: formatRoundtripTitle, 803 | filterTickets: filterTickets, 804 | getTicketDepartureDate: getTicketDepartureDate, 805 | getDayAfterTicket: getDayAfterTicket, 806 | getDayBeforeTicket: getDayBeforeTicket, 807 | getSummary: getSummary, 808 | extractDates: extractDates, 809 | generateIndex: generateIndex, 810 | getAll: getAll, 811 | getLastAvailableDay: getLastAvailableDay, 812 | isMonthWithinTimespan: isMonthWithinTimespan, 813 | getMonthsWithinTimespan: getMonthsWithinTimespan, 814 | getHourNames: getHourNames, 815 | remove: remove 816 | }; -------------------------------------------------------------------------------- /locales/en.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | moscowCapitalized: 'Moscow', 3 | spbCapitalized: 'Saint Petersburg', 4 | routeQuestion: 'To Moscow or to Saint Peterburg?', 5 | helpText: 'Just type "to St Petersburg on weekend" or "to Moscow early morning" or "to Moscow for 3000" or simply "to Moscow". Type "more" to see more tickets.', 6 | noTicketsText: 'Something went wrong: I am unable to find any tickets.', 7 | greetingText: 'Hello', 8 | toMoscowPattern: /^moscow|^mow|^msc|.moscow|mocsow|moscwo|msocow/i, 9 | toSpbPattern: /^spb|saint|pete|piter|peter|petersburg/i, 10 | earlyMorningPattern: /early morning/i, 11 | morningPattern: /morning/i, 12 | afternoonPattern: /afternoon|day/i, 13 | anyDayOfWeekPattern: /weekday|any day/i, 14 | weekendPattern: /weekend/i, 15 | pricePattern: /\d+([ \,]{1}\d+)?/g, 16 | monthPattern: /(january|february|march|april|may|june|july|august|september|october|november|december)/gi, 17 | monthMap: { 18 | 'jan': 0, 19 | 'feb': 1, 20 | 'mar': 2, 21 | 'apr': 3, 22 | 'may': 4, 23 | 'jun': 5, 24 | 'jul': 6, 25 | 'aug': 7, 26 | 'sep': 8, 27 | 'oct': 9, 28 | 'nov': 10, 29 | 'dec': 11 30 | }, 31 | morePattern: /^more|^ more/i, 32 | specificDatePattern1: /(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec).* \d+/gi, 33 | specificDatePattern2: /\d+\/\d+\/\d{4}/, 34 | specificDatePattern3: /\d+\/\d+/, 35 | dateFormat2: 'DD/MM/YYYY', 36 | dateFormat3: 'DD/MM', 37 | dateFormat4: 'dddd, MMMM D', 38 | dateFormat5: 'MMMM, D [at] H:mm a', 39 | dateFormat6: 'dddd, MMMM D, H:mm a', 40 | dateFormat7: 'MM/DD/YYYY HH:mm a', 41 | timeFormat1: 'H:mm', 42 | weekdayFormat1: 'dddd', 43 | cancelSpecificDatePattern: /other dates|other days|any date/i, 44 | tomorrowPattern: /tomorrow/gi, 45 | thereAndBackPattern: /there and back/gi, 46 | helpPattern: /^\/(help|about)$/i, 47 | purchasePattern: /buy/i, 48 | linkPattern: /link/i, 49 | greetingPattern: /hello|hi/i, 50 | // Sometimes first message text is "/start Start" instead of just "/start": test with regexp 51 | startPattern: /^\/start/i, 52 | moreTicketsButton: 'more tickets', 53 | moreTicketsForAnyDateCallback: 'more tickets any date', 54 | moreTicketsCallback: 'more tickets', 55 | weekendsButton: 'weekend', 56 | weekendsCallback: 'weekend', 57 | anyDayOfWeekButton: 'any day', 58 | anyDayOfWeekCallback: 'any day', 59 | toMoscowButton: 'To Moscow', 60 | toMoscowCallback: 'To Moscow', 61 | toPetersburgButton: 'To Saint Petersburg', 62 | toPetersburgCallback: 'To Saint Petersburg', 63 | monthBeyondTimespanMessage: 'No tickets for %{monthName} yet: you can only buy tickets up %{timespan} days ahead.', 64 | moreTicketsCheapestFirst: 'More tickets, starting from the cheapest:', 65 | lastPairOfTickets: 'Here is the last pair of tickets:', 66 | onlyOneCheapestPairPerDay: 'There is only a single cheapest pair of tickets a day.', 67 | noMoreTickets: 'No more tickets.', 68 | noTicketsWithGivenPrice: 'I couldn\'t find tickets priced %{totalCost} ₽ or less. Here\'s the cheapest:', 69 | noTicketsForGivenMonth: 'I couldn\'t find tickets for %{monthName}.', 70 | noTicketsForGivenDate: 'I couldn\'t find tickets for this date.', 71 | ticketTemplate: '%{cityFrom} → %{cityTo} \n%{dayFormatted}, departing at %{timeFormatted} \n%{rate} ₽', 72 | formatWeekday: function(dateMoment) { 73 | return dateMoment.format('dddd'); 74 | }, 75 | routeTemplate: '%{routeFrom} → %{routeTo} and back', 76 | ticketShortTemplate: 'for %{totalCost} ₽ \noutbound %{originatingTicketDateFormatted}, return %{returnTicketDateFormatted}', 77 | ticketUrlMessage: 'Here\'s the date and location url — you will have to select tickets manually. RZD does not allow a direct link to tickets.' 78 | }; -------------------------------------------------------------------------------- /locales/ru.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | moscowCapitalized: 'Москва', 3 | spbCapitalized: 'Санкт-Петербург', 4 | routeQuestion: "В Москву или Петербург?", 5 | helpText: 'Напишите «в Питер на выходные» или «в Москву рано утром» или «в Москву за 3000» или просто «в Москву». Напишите «ещё» чтобы посмотреть другие варианты.', 6 | noTicketsText: 'Что-то пошло не так: не могу найти билет.', 7 | greetingText: 'Привет', 8 | toMoscowPattern: /^москва|^мск|.москва|в москву|москву|мовску|моску|мсокву|в мск|из питера|из петербурга|из санкт|из спб/i, 9 | toSpbPattern: /^питер|^петербург|^петебург|^петепбург|^петер|^петрбург|^санкт|^спб|из москвы|из мск|в питер|в петербург|в санкт|в спб/i, 10 | earlyMorningPattern: /рано утром/i, 11 | morningPattern: /утром/i, 12 | afternoonPattern: /днём|днем/i, 13 | anyDayOfWeekPattern: /не только (на |в )?выходн|любой день недели/i, 14 | weekendPattern: /выходн/i, 15 | pricePattern: /\d+([ \.]{1}\d+)?/g, 16 | monthPattern: /(январ|феврал|март|апрел|май|мая|мае|июн|июл|август|сентябр|октябр|ноябр|декабр)[а-я]*/gi, 17 | monthMap: { 18 | 'янв': 0, 19 | 'фев': 1, 20 | 'мар': 2, 21 | 'апр': 3, 22 | 'май': 4, 23 | 'мая': 4, 24 | 'мае': 4, 25 | 'июн': 5, 26 | 'июл': 6, 27 | 'авг': 7, 28 | 'сен': 8, 29 | 'окт': 9, 30 | 'ноя': 10, 31 | 'дек': 11 32 | }, 33 | morePattern: /^ещё|^еще|^ ещё|^ еще/i, 34 | specificDatePattern1: /\d+ (январ|феврал|март|апрел|май|мая|мае|июн|июл|август|сентябр|октябр|ноябр|декабр)/gi, 35 | specificDatePattern2: /\d+\.\d+\.\d{4}/, 36 | specificDatePattern3: /\d+\.\d+/, 37 | dateFormat2: 'DD.MM.YYYY', 38 | dateFormat3: 'DD.MM', 39 | dateFormat4: 'D MMMM, dddd', 40 | dateFormat5: 'D MMMM в H:mm', 41 | dateFormat6: 'dddd D MMMM H:mm', 42 | timeFormat1: 'H:mm', 43 | weekdayFormat1: 'dddd', 44 | cancelSpecificDatePattern: /другие даты/i, 45 | tomorrowPattern: /завтра/gi, 46 | thereAndBackPattern: /туда и обратно/gi, 47 | helpPattern: /^\/(help|about)$/i, 48 | purchasePattern: /беру/i, 49 | linkPattern: /ссылк/i, 50 | greetingPattern: /привет/i, 51 | // Sometimes first message text is "/start Start" instead of just "/start": test with regexp 52 | startPattern: /^\/start/i, 53 | moreTicketsButton: 'ещё билеты', 54 | moreTicketsForAnyDateCallback: 'ещё на другие даты', 55 | moreTicketsCallback: 'ещё билеты', 56 | weekendsButton: 'выходные', 57 | weekendsCallback: 'выходные', 58 | anyDayOfWeekButton: 'любой день', 59 | anyDayOfWeekCallback: 'любой день недели', 60 | toMoscowButton: 'В Москву', 61 | toMoscowCallback: 'В Москву', 62 | toPetersburgButton: 'в Петербург', 63 | toPetersburgCallback: 'в Петербург', 64 | monthBeyondTimespanMessage: 'Билетов на %{monthName} ещё нет: на сайте РЖД можно купить билеты на %{timespan} дней вперёд, не позже.', 65 | moreTicketsCheapestFirst: 'Ещё билеты, в порядке возрастания цены:', 66 | lastPairOfTickets: 'Вот последняя пара билетов:', 67 | onlyOneCheapestPairPerDay: 'Каждый день самая дешёвая пара билетов только одна.', 68 | noMoreTickets: 'Всё, больше билетов нет.', 69 | noTicketsWithGivenPrice: 'Я не нашёл билетов за %{totalCost} ₽ и меньше. Вот самый дешёвый:', 70 | noTicketsForGivenMonth: 'Я не нашёл билетов на %{monthName}.', 71 | noTicketsForGivenDate: 'Не могу найти билет на эту дату.', 72 | ticketTemplate: '%{cityFrom} → %{cityTo} \n%{dayFormatted}, отправление в %{timeFormatted} \n%{rate} ₽', 73 | formatWeekday: function(dateMoment, weekdaysFormat) { 74 | var formatted; 75 | if (weekdaysFormat) { 76 | formatted = weekdaysFormat[dateMoment.day()]; 77 | formatted = (formatted.substring(0, 2) === 'вт' ? 'во ' : 'в ') + formatted; 78 | } else { 79 | formatted = dateMoment.format('dddd'); 80 | } 81 | return formatted; 82 | }, 83 | routeTemplate: '%{routeFrom} → %{routeTo} и обратно', 84 | ticketShortTemplate: 'за %{totalCost} ₽ \nтуда %{originatingTicketDateFormatted}, обратно %{returnTicketDateFormatted}', 85 | ticketUrlMessage: 'Вот ссылка на день и направление — билеты придётся выбирать самому. РЖД не позволяет дать прямую ссылку на билеты.' 86 | }; -------------------------------------------------------------------------------- /maintenance/collect.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config({silent: true}); 2 | var debug = require('debug')('collect'); 3 | 4 | var Collector = require('./../collector'); 5 | var Kiosk = require('./../kiosk'); 6 | 7 | Collector.fetch() 8 | .then(function() { 9 | return Collector.getAll(); 10 | }) 11 | .then(function(allTickets) { 12 | return Kiosk.generateIndex(allTickets); 13 | }) 14 | .then(function() { 15 | debug('Successfully generated index.'); 16 | debug('Collector finished.'); 17 | }) 18 | .catch(function(error) { 19 | console.log(error && error.stack); 20 | }); -------------------------------------------------------------------------------- /maintenance/integrity.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config({silent: true}); 2 | var debug = require('debug')('collect'); 3 | 4 | var Collector = require('./../collector'); 5 | 6 | Collector.testIntegrity(); -------------------------------------------------------------------------------- /maintenance/reindex.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config({silent: true}); 2 | var debug = require('debug')('reindex'); 3 | 4 | var Collector = require('./../collector'); 5 | var Kiosk = require('./../kiosk'); 6 | 7 | Kiosk.remove() 8 | .then(function() { 9 | return Collector.getAll(); 10 | }) 11 | .then(function(allTickets) { 12 | return Kiosk.generateIndex(allTickets); 13 | }) 14 | .then(function() { 15 | debug('Successfully generated index.'); 16 | debug('Reindex finished.'); 17 | }) 18 | .catch(function(error) { 19 | console.log(error && error.stack); 20 | }); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sapsanasap", 3 | "version": "0.0.1", 4 | "description": "A Telegram bot that finds the cheapest tickets for Sapsan train", 5 | "author": "Anna Shishlyakova", 6 | "private": "true", 7 | "main": "index.js", 8 | "scripts": { 9 | "start": "node app.js", 10 | "collect": "node maintenance/collect.js", 11 | "reindex": "node maintenance/reindex.js", 12 | "integrity": "node maintenance/integrity.js" 13 | }, 14 | "dependencies": { 15 | "bluebird": "^3.4.1", 16 | "botanio": "^0.0.6", 17 | "debug": "^2.2.0", 18 | "dotenv": "^2.0.0", 19 | "lodash": "^4.5.0", 20 | "moment": "^2.11.2", 21 | "mongodb": "^2.1.7", 22 | "node-polyglot": "^2.2.1", 23 | "node-telegram-bot-api": "^0.23.3", 24 | "request-promise": "^3.0.0" 25 | }, 26 | "devDependencies": { 27 | "assert": "^1.3.0", 28 | "vows": "^0.8.1" 29 | }, 30 | "engines": { 31 | "node": "5.0.0" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /polyglot.js: -------------------------------------------------------------------------------- 1 | const Polyglot = require('node-polyglot'); 2 | const _ = require('lodash'); 3 | // TODO Determine locale 4 | // TODO Per-user locale, not per-instance locale 5 | const locale = process.env.LOCALE || 'en'; 6 | 7 | const moment = require('moment'); 8 | moment.locale(locale); 9 | 10 | const SapsanPolyglot = function() { 11 | Polyglot.call(this); 12 | }; 13 | 14 | SapsanPolyglot.prototype = Object.create(Polyglot.prototype); 15 | 16 | // Extending polyglot extend method to allow non-string values (e.g. regexps and functions) 17 | SapsanPolyglot.prototype.extend = function(morePhrases, prefix) { 18 | _.forEach(morePhrases, function(phrase, key) { 19 | var prefixedKey = prefix ? prefix + '.' + key : key; 20 | this.phrases[prefixedKey] = phrase; 21 | }.bind(this)); 22 | }; 23 | 24 | // Extending polyglot translate method to allow non-string values (e.g. regexps and functions) 25 | SapsanPolyglot.prototype.t = function(key, options) { 26 | var phrase = this.phrases[key]; 27 | var result; 28 | if (typeof phrase === 'function' || typeof phrase === 'object' || _.isRegExp(phrase)) { 29 | result = phrase 30 | } else { 31 | result = Polyglot.prototype.t.call(this, key, options); 32 | } 33 | return result; 34 | }; 35 | 36 | var polyglot; 37 | 38 | const getInstance = function() { 39 | if (!polyglot) { 40 | polyglot = new SapsanPolyglot(); 41 | polyglot.locale(locale); 42 | var locales; 43 | try { 44 | locales = require('./locales/' + locale); 45 | } 46 | catch (err) { 47 | locales = require('./locales/en'); 48 | } 49 | polyglot.extend(locales); 50 | } 51 | return polyglot; 52 | }; 53 | 54 | module.exports = getInstance; 55 | -------------------------------------------------------------------------------- /settings.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'); 2 | 3 | var Storage = require('./storage'); 4 | 5 | const collectionName = 'settings'; 6 | 7 | /** 8 | * Get settings value 9 | * @param {String} key 10 | * @returns {Promise} 11 | */ 12 | var getValue = function(key) { 13 | return Storage 14 | // Get previous collector launch time 15 | .find(collectionName) 16 | .then(function(result) { 17 | var settings = result && result.length ? result[0] : {}; 18 | return settings[key]; 19 | }); 20 | }; 21 | 22 | /** 23 | * Set settings value 24 | * @param {String} key 25 | * @param {*} value 26 | * @returns {Promise} 27 | */ 28 | var setValue = function(key, value) { 29 | return Storage 30 | // Get previous collector launch time 31 | .find(collectionName) 32 | .then(function(result) { 33 | var settings = result && result.length ? result[0] : {}; 34 | if (_.isObject(key)) { 35 | settings = _.extend(settings, key); 36 | } else { 37 | settings[key] = value; 38 | } 39 | return Storage 40 | .drop(collectionName) 41 | .then(function() { 42 | Storage.insert(collectionName, [settings]); 43 | }); 44 | }); 45 | }; 46 | 47 | module.exports = { 48 | getValue: getValue, 49 | setValue: setValue 50 | }; 51 | -------------------------------------------------------------------------------- /storage.js: -------------------------------------------------------------------------------- 1 | var mongoClient = require('mongodb').MongoClient; 2 | 3 | var connection; 4 | 5 | /** 6 | * @returns {Promise} 7 | */ 8 | var connect = function() { 9 | // If connection already exists, use existing connection. 10 | // It is recommended to only connect once and reuse that one connection: http://stackoverflow.com/questions/10656574 11 | var promise; 12 | 13 | if (connection) { 14 | promise = Promise.resolve(connection); 15 | // If connection is not yet created, connect and store resulting connection. 16 | } else { 17 | promise = mongoClient.connect(process.env.MONGOLAB_URI).then(function(db) { 18 | // Store connection 19 | connection = db; 20 | return db; 21 | }); 22 | } 23 | return promise; 24 | }; 25 | 26 | var insert = function(collectionName, items) { 27 | return connect().then(function(db) { 28 | var collection = db.collection(collectionName); 29 | return collection.insert(items); 30 | }); 31 | }; 32 | 33 | var find = function(collectionName, query) { 34 | query = query || {}; 35 | return connect().then(function(db) { 36 | var collection = db.collection(collectionName); 37 | return collection.find(query).toArray(); 38 | }); 39 | }; 40 | 41 | // TODO Make sure remove does not fail promise if there is no matching entry 42 | var remove = function(collectionName, query) { 43 | query = query || {}; 44 | return connect().then(function(db) { 45 | var collection = db.collection(collectionName); 46 | return collection.remove(query); 47 | }); 48 | }; 49 | 50 | var drop = function(collectionName) { 51 | return connect() 52 | .then(function(db) { 53 | var collection = db.collection(collectionName); 54 | return collection.drop(); 55 | }) 56 | // Collection.drop will throw exception if collection does not exist. Catch the exception and resolve promise anyway. 57 | .catch(function() { 58 | return true; 59 | }); 60 | }; 61 | 62 | module.exports = { 63 | insert: insert, 64 | find: find, 65 | remove: remove, 66 | drop: drop 67 | }; 68 | -------------------------------------------------------------------------------- /test/test.kiosk.js: -------------------------------------------------------------------------------- 1 | var vows = require('vows'); 2 | var assert = require('assert'); 3 | 4 | var Kiosk = require('./../kiosk'); 5 | 6 | vows.describe('Routes').addBatch({ 7 | 'To Moscow': { 8 | topic: Kiosk.toMoscow(), 9 | 'should be directed to Moscow': function(topic) { 10 | assert.equal('mow', topic.to); 11 | }, 12 | 'should be directed from St Peterburg': function(topic) { 13 | assert.equal('spb', topic.from); 14 | } 15 | }, 16 | 'To St Petersburg': { 17 | topic: Kiosk.Route.toSpb(), 18 | 'should be directed to St Peterburg': function(topic) { 19 | assert.equal('spb', topic.to); 20 | }, 21 | 'should be directed from Moscow': function(topic) { 22 | assert.equal('mow', topic.from); 23 | } 24 | } 25 | }).export(module); -------------------------------------------------------------------------------- /url.js: -------------------------------------------------------------------------------- 1 | var Storage = require('./storage'); 2 | 3 | var request = require('request-promise'); 4 | var Promise = require('bluebird'); 5 | 6 | var debug = require('debug')('url'); 7 | 8 | const shortenerUrl = 'https://www.googleapis.com/urlshortener/v1/url'; 9 | const collectioName = 'urls'; 10 | 11 | /** 12 | * Shortens an url 13 | * @param {String} longUrl 14 | * @returns {Promise} 15 | */ 16 | var shorten = function(longUrl) { 17 | return getCachedShortUrl(longUrl) 18 | .then(function(shortUrl) { 19 | var promise; 20 | if (!shortUrl) { 21 | var parameters = { 22 | key: process.env.GOOGLE_API_KEY 23 | }; 24 | var requestBody = { 25 | longUrl: longUrl 26 | }; 27 | 28 | // Using url shortener because when a link in Telegram gets clicked, the '|' characters in rzd url are url-encoded into %7C, which breaks the url. 29 | // There is no way to avoid the '|' character usage. 30 | promise = request({ 31 | method: 'POST', 32 | url: shortenerUrl, 33 | qs: parameters, 34 | body: requestBody, 35 | resolveWithFullResponse: true, 36 | json: true 37 | }) 38 | .then(function(response) { 39 | var body = response.body; 40 | return body.id; 41 | }) 42 | .then(function(shortUrl) { 43 | return Promise.all([shortUrl, cacheShortUrl(shortUrl, longUrl)]); 44 | }) 45 | .then(function(result) { 46 | return result[0]; 47 | }); 48 | 49 | } else { 50 | promise = Promise.resolve(shortUrl); 51 | } 52 | return promise; 53 | }) 54 | .catch(function() { 55 | throw new Error('Unable to shorten the url'); 56 | }); 57 | }; 58 | 59 | 60 | /** 61 | * Returns a shortened url from cache 62 | * @param {String} longUrl 63 | * @returns {Promise} 64 | */ 65 | var getCachedShortUrl = function(longUrl) { 66 | return Storage 67 | .find(collectioName, {longUrl: longUrl}) 68 | .then(function(results) { 69 | return results && results.length && results[0].shortUrl; 70 | }); 71 | }; 72 | 73 | /** 74 | * Caches shortened url 75 | * @param {String} shortUrl 76 | * @param {String} longUrl 77 | * @returns {Promise} 78 | */ 79 | var cacheShortUrl = function(shortUrl, longUrl) { 80 | var entry = { 81 | shortUrl: shortUrl, 82 | longUrl: longUrl 83 | }; 84 | 85 | return Storage.insert(collectioName, entry); 86 | }; 87 | 88 | module.exports = { 89 | shorten: shorten 90 | }; --------------------------------------------------------------------------------