jesusfer/node-telegram-bot-emtbus

View on GitHub
src/emtBot.js

Summary

Maintainability
D
2 days
Test Coverage
// Copyright (c) 2018 Jesús Fernández <jesus@nublar.net>
// MIT License
'use strict';

const settings = require('./settings.js');
const TelegramBot = require('node-telegram-bot-api');
// FUTURE: seems like a better library https://github.com/Lorengamboa/EMT-library
const EMTAPI = require('node-emtmad-bus-promise');
const _ = require('lodash');
const uuid = require('uuid');
const xml2json = require('./xml2json.js');
const debug = require('debug')('bot');
const debugCache = require('debug')('bot-cache');
const debugBuild = require('debug')('bot-build');
const P = require('bluebird');
const utm = require('utm');

// TELEGRAM BOT ///////////////////////////////////////////////////////////////

const bot = new TelegramBot(settings.token, {
    polling: true
});

// Azure Application Insights /////////////////////////////////////////////////

const appInsights = require('applicationinsights');
const instrumentationKey = _.isNil(process.env.APPINSIGHTS_INSTRUMENTATIONKEY) ?
    'testingKey' :
    process.env.APPINSIGHTS_INSTRUMENTATIONKEY;

appInsights
    .setup(instrumentationKey)
    .setAutoCollectConsole(false)
    .setAutoCollectPerformance(false)
    .setAutoCollectRequests(false)
    .setAutoCollectDependencies(false)
    .start();
const telemetryClient = appInsights.defaultClient;

const telemetryEvents = {
    InlineQuery: 'InlineQuery',
    QueryWithLocation: 'QueryWithLocation',
    QueryWithText: 'QueryWithText',
    RefreshQuery: 'RefreshQuery'
};

// CONSTANTS //////////////////////////////////////////////////////////////////

const xmlLines = xml2json(settings.emt_linesxml).TABLA.DocumentElement[0].REG;
const emptyLocation = {
    latitude: 0,
    longitude: 0
};
const utmXoffset = -110;
const utmYoffset = -197;

// Properties of a stop that will be rendered in a table
const columns = ['lineId', 'destination', 'time'];

// CACHES //////////////////////////////////////////////////////////////////////
/**
 * Cache of Stop objects indexed by their ID.
 */
var stopCache = {};

function buildStopCache() {
    debugCache('Stop cache: building...');
    const maxId = 6500;
    let firstId = 1,
        step = 100,
        timeout = 0,
        timeoutStep = 2000;
    while (firstId < maxId) {
        loadStopBatch(firstId, firstId + step, timeout);
        timeout += timeoutStep;
        firstId += step;
    }
    debugCache('Stop cache: complete');
}

function loadStopBatch(first, last, timeout) {
    setTimeout(function () {
        EMTAPI.getNodesLines(_.range(first, last))
            .then(function (results) {
                debugCache(`Stop cache: results from ${first} to ${last}`);
                return Promise.all(_.map(results, buildStop));
            })
            .then(function (stops) {
                _.map(stops, function (item) {
                    _.set(stopCache, item.Id, item);
                });
            })
            .catch(function (err) {
                debugCache(`Stop cache: Error: ${err} ${err.stack}`);
                loadStopBatch(first, last, 0);
            });
    }, timeout);
}

buildStopCache();


// UTILS //////////////////////////////////////////////////////////////////////
/**
 * Look for a bus line in the Lines XML using the line Id, which is a 3 digit
 * code that identifies the line, but it's not the same as the label the line has
 * For example, the code 516 is the line N16.
 */
const findXmlLine = function (lineId) {
    return xmlLines.find(function (o) {
        return o.Line[0] === _.padStart(lineId, 3, 0);
    });
};

/**
 * Get the buses arriving to this stop and set the arriving property.
 * Returns a Promise object.
 */
const getArrivingBuses = function (stop) {
    // Return a promise
    return new P(function (resolve) {
        EMTAPI.getIncomingBusesToStop(stop.Id)
            .then(function (arriving) {
                arriving = _.concat([], arriving);
                stop.arriving = _.map(arriving, function (bus) {
                    // Pretty print the arriving time
                    let time = bus.busTimeLeft;
                    let timeMin = _.floor(time / 60);
                    if (time === 0 || timeMin === 0) {
                        time = '<<<';
                    } else if (time === 999999) {
                        time = '+20';
                    } else {
                        time = timeMin + '';
                    }
                    _.set(bus, 'time', time);
                    return bus;
                });
                resolve(stop);
            })
            .catch(function (error) {
                debug(`Error: ${error}`);
                resolve(`Error: ${error}`);
            });
    });
};

/**
 * In order to print the arriving times in a table-like fashion, we need to know
 * in advance the max width of each column's text so that we can pad each column
 * with spaces according to their width.
 */
const getColumnWidths = function (stop, columns) {
    let format = {};
    _.forEach(stop.arriving, function (bus) {
        // bus is an arriving bus
        _.forEach(columns, function (col) {
            // col is a column that will be printed
            let currentMax = _.get(format, col, 0);
            // Enforce a max column width of so that the line is not too long
            // Helps reading the results better in mobile phones.
            let current = Math.min(_.get(bus, col, 0).length, settings.maxColumnWidth);
            let max = Math.max(currentMax, current);
            _.set(format, col, max);
        });
    });
    return format;
};

const getStopLocation = function (stopId, line, direction) {
    debug(`Getting location for stop ${stopId} with line ${line} and direction ${direction}`);
    let cachedStop = stopCache.filter(function (o) {
        return o.Node == stopId;
    })[0];

    if (cachedStop) {
        debug(`Found cached stop for ${stopId}`);
        let x, y, location;
        try {
            x = parseInt(cachedStop.PosxNode[0]) + utmXoffset;
            y = parseInt(cachedStop.PosyNode[0]) + utmYoffset;
            location = utm.toLatLon(x, y, 30, 'T');
        } catch (error) {
            return new P(function (resolve, reject) {
                reject(Error('Could not transform UTM to LatLon'));
            });
        }
        debug(`Cached location: ${location.latitude},${location.longitude}`);
        return new P(function (resolve) {
            resolve(location);
        });
    }
    return EMTAPI.getStopsLine(line, direction)
        .then(function (results) {
            let stops = _.get(results, 'stop', []);
            let stop = stops.find(function (stop) {
                return stop.stopId === stopId;
            });
            let location = {
                latitude: stop.latitude,
                longitude: stop.longitude
            };
            debug(`Location for stop ${stopId} and line ${line}/${direction}: ${location.latitude},${location.longitude}`);
            return location;
        })
        .catch(function (error) {
            console.error(`Error: ${error}`);
        });
};

/* Example of a stop object from the XML file
{
    Node: ['4230'],
    PosxNode: ['447148,3'],
    PosyNode: ['4474608'],
    Name: ['HNOS.GARCIANOBLEJAS-PZA.DEALSACIA'],
    Lines: ['70/1']
}
Stop object from API
{
    stopId: '2443',
    name: 'AV.ABRANTES-PZA.LASMENINAS',
    postalAddress: 'Av.deAbrantes, 106',
    longitude: -3.7324823992585,
    latitude: 40.377653538528,
    line: [Object]
}

From API Node:
{ Wifi: '0',
  node: 1,
  name: 'Avenida Valdemarín-Blanca de Castilla',
  lines: [ '', '161/1/1' ],
  latitude: 40.47004454502,
  longitude: -3.782887713069 }
*/

/*
 * Since the stop objects are different in the REST API and the XML, we build
 * a new object to have a consistent object across the rest of the functions.
 */
const buildStop = function (rawStop) {
    return new P(function (resolve, reject) {
        let newStop = {};
        let nodeId = _.get(rawStop, 'Node[0]', -1);
        let stopId = _.get(rawStop, 'stopId', -1);
        let node = _.get(rawStop, 'node', -1);
        if (nodeId !== -1) {
            // Build from XML
            debugBuild(`StopBuild->Building stop from XML (${nodeId})`);
            newStop.Id = nodeId;
            newStop.Name = _.get(rawStop, 'Name[0]', `Parada ${newStop.Id}`);
            let rawLines = _.get(rawStop, 'Lines[0]', '').split(' ');
            let aLine = '';
            newStop.Lines = _.map(rawLines, function (rawLine) {
                aLine = rawLine;
                let temp = rawLine.split('/');
                let label = findXmlLine(temp[0]).Label[0];
                if (temp[1] === '1') {
                    return `${label} ida`;
                } else {
                    return `${label} vuelta`;
                }
            });
            getStopLocation(nodeId, aLine.split('/')[0], aLine.split('/')[1])
                .then(function (position) {
                    newStop.position = position;
                    resolve(newStop);
                });
        } else if (stopId !== -1) {
            // Build from API
            debugBuild(`StopBuild->Building stop from API:Stop (${stopId})`);
            newStop.Id = stopId;
            newStop.Name = _.get(rawStop, 'name', `Parada ${newStop.Id}`);
            newStop.Lines = _.map(_.concat(rawStop.line, []), function (line) {
                let label = line.line;
                if (line.direction === 'B') {
                    return `${label} ida`;
                } else {
                    return `${label} vuelta`;
                }
            });
            newStop.position = {
                latitude: rawStop.latitude,
                longitude: rawStop.longitude
            };
            resolve(newStop);
        } else if (node !== -1) {
            // Build from API NodesLines
            debugBuild(`StopBuild->Building stop from API:Node (${node})`);
            newStop.Id = node;
            newStop.Name = _.get(rawStop, 'name', `Parada ${newStop.Id}`);
            let rawLines = _.get(rawStop, 'lines', '').filter(x => x.length > 0);
            newStop.Lines = _.map(rawLines, function (rawLine) {
                let temp = rawLine.split('/');
                // FIX: there may be missing lines in the XML too so this has to be built in some other way (API call)
                let label = '';
                try {
                    label = findXmlLine(temp[0]).Label[0];
                    if (temp[1] === '1') {
                        return `${label} ida`;
                    } else {
                        return `${label} vuelta`;
                    }
                } catch (err) {
                    debugBuild(`StopBuild->Line '${rawLine}' not found`);
                }
            });
            newStop.position = {
                latitude: rawStop.latitude,
                longitude: rawStop.longitude
            };
            resolve(newStop);
        } else {
            debugBuild('Bad raw stop');
            reject('StopBuild->Bad raw stop');
        }
    });
};

const logErrors = function (query, id, error) {
    console.error(`Inline Query with error: ${query}`);
    console.error(error);
    bot.answerInlineQuery(id, []);
};

/**
 * Given a query text and a location object, both coming from the user, find
 * a list of stops whose stop ID start with the query of the user or that are
 * close to the location of the user.
 * Returns a Promise object that fulfills to an array of Stops.
 */
const findStops = function (query, location, exact = false) {
    return new P(function (resolve, reject) {
        let isEmptyQuery = false;
        let isLocationQuery = false;
        let isNaNQuery = false;

        if (query.length === 0) {
            debug('Empty query');
            isEmptyQuery = true;
        }
        // Query must be a number
        if (!isEmptyQuery && isNaN(+query)) {
            debug('Query is not a number');
            isNaNQuery = true;
            isEmptyQuery = true;
        }
        if (location.latitude !== 0 || location.longitude !== 0) {
            debug('Query contains location');
            isLocationQuery = true;
            telemetryClient.trackEvent(telemetryEvents.QueryWithLocation);
        }
        if ((isEmptyQuery && !isLocationQuery) || isNaNQuery) {
            debug('Query is empty and the user didn\'t send a location');
            return reject('Query is empty and the user didn\'t send a location');
        }

        let foundByQuery = [];
        let stopsFound = [];

        if (!isEmptyQuery) {
            debug('Query is not empty, find a matching stop in the cache');
            telemetryClient.trackEvent(telemetryEvents.QueryWithText);
            let findFunction = function (o) {
                return _.startsWith(o, query);
            };
            if (exact) {
                findFunction = function (o) {
                    return o == query;
                };
            }
            // Look for stops that start with that number in the cache
            foundByQuery = _.slice(Object.keys(stopCache).filter(findFunction), 0, settings.maxResults);
            if (foundByQuery.length > 0) {
                // There was a query that matched some stops so return these
                return resolve(Promise.all(_.map(foundByQuery, id => stopCache[id])));
            } else {
                debug('The stop is not in the cache!!!');
            }
        }

        debug('Query was empty, matching by location');
        return EMTAPI.getStopsFromLocation(location, settings.searchRadius)
            .then(function (stops) {
                // Got some stops with the location, convert the type and limit results
                stops = _.slice(stops, 0, settings.maxResults);
                return Promise.all(_.map(stops, buildStop));
            })
            .then(function (stopsByLocation) {
                // Now we can add the converted to the results
                debug(`Stops found with location: ${stopsByLocation.length}`);
                resolve(_.concat(stopsFound, stopsByLocation));
            })
            .catch(function (error) {
                telemetryClient.trackException(error);
            });
    });
};

/**
 * We want to format the estimations in a table that it's easier to read
 * We pad the column text with spaces and render each line with a monospace font
 */
const renderStop = function (stop) {
    return new P(function (resolve) {
        let arriving = 'Sin estimaciones';
        if (stop.arriving.length > 0) {
            let widths = getColumnWidths(stop, columns);
            arriving = _.join(_.map(stop.arriving, function (e) {
                // Build the bus arriving line, padding the columns as needed
                let keys = _.keys(widths);
                let s = _.map(keys, function (w) {
                    let value = _.get(e, w, '');
                    value = _.truncate(value, {
                        length: settings.maxColumnWidth
                    });
                    value = _.padEnd(value, widths[w], ' ');
                    return value;
                });
                s = '`' + _.join(s, ' ') + '`';
                return s;
            }), '\r\n');
        }
        let mapa = '';
        if (stop.position != undefined) {
            let url = `https://www.google.com/maps/@${stop.position.latitude},${stop.position.longitude},19z`;
            mapa = `

[¿Dónde está la parada?](${url})`;
        }
        const content = `*${stop.Id}* ${stop.Name}
${arriving}${mapa}`;
        const result = {
            type: 'article'
        };
        result.id = uuid.v4();
        result.title = `${stop.Id} - ${stop.Name}`;
        result.input_message_content = {
            message_text: content,
            parse_mode: 'Markdown',
            disable_web_page_preview: true
        };
        result.description = 'Líneas: ' + _.join(stop.Lines, ', ');
        result.thumb_url = settings.result_thumb;
        result.reply_markup = {
            inline_keyboard: [
                [{
                    text: 'Actualizar',
                    callback_data: `refresh:${stop.Id}`
                }]
            ]
        };
        resolve(result);
    });
};

// COMMANDS ///////////////////////////////////////////////////////////////////

const helpText =
    'This bot is intended to be used in inline mode, just type ' +
    '@emtbusbot and a bus stop number to get an estimation.' +
    '\r\nIf you allow your Telegram client to send your location, ' +
    'you will be shown a list of the bus stops closer to you.';

bot.onText(/\/start.*/, function (msg) {
    bot.sendMessage(msg.from.id, helpText);
});

bot.onText(/\/help.*/, function (msg) {
    bot.sendMessage(msg.from.id, helpText);
});

// TELEGRAM INLINE MODE ////////////////////////////////////////////////////////
/*
Arriving example
{
    stopId: 2441,
    lineId: '47',
    isHead: 'False',
    destination: 'CARABANCHELALTO',
    busId: '8753',
    busTimeLeft: 693,
    busDistance: 2831,
    longitude: -3.7001946964466,
    latitude: 40.387599946339,
    busPositionType: 1
}
*/
bot.on('inline_query', function (request) {
    const inlineId = request.id;
    const query = request.query.trim();
    const location = _.get(request, 'location', emptyLocation);
    debug(`New inline query: ${query}`);
    debug(`Location: ${location.latitude} ${location.longitude}`);

    telemetryClient.trackEvent(telemetryEvents.InlineQuery);

    findStops(query, location)
        .then(function (stops) {
            // Once we have some stops, find the buses arriving to them
            debug(`We got ${stops.length} stops`);
            return P.all(_.map(stops, getArrivingBuses));
        })
        .then(function (stops) {
            // Once we have the stop with the arriving buses, build the results
            // we are going to return to Telegram
            stops = _.reject(stops, function (result) {
                // If the result is a String, then an error ocurred
                return _.isString(result);
            });
            return P.all(_.map(stops, renderStop));
        })
        .then(function (results) {
            debug(`Final results: ${results.length}`);
            bot.answerInlineQuery(inlineId, results, {
                cache_time: 10
            });
        })
        .catch(function (error) {
            console.error(error);
            telemetryClient.trackException(error);
        });
    // logErrors(request.query, inlineId, 'No results');
});

const processRefresh = function (request, stopId) {
    if (_.isNaN(+stopId)) {
        debug('Bad refresh stopId');
        return;
    }
    let options = {
        'answerText': 'Actualizando...'
    };
    bot.answerCallbackQuery(request.id, options);

    // This is basically the same as in the inline query
    findStops(stopId, emptyLocation, true)
        .then(function (stops) {
            if (stops.length !== 1) {
                return P.reject('Error: more than one stop in refresh');
                // This is a refresh, we should never get more than one result
            }
            return stops[0];
        })
        .then(getArrivingBuses)
        .then(function (stop) {
            if (_.isString(stop)) {
                return P.reject('Error: getting arriving buses');
            }
            return stop;
        })
        .then(renderStop)
        .then(function (result) {
            bot.editMessageText(
                result.input_message_content.message_text, {
                    inline_message_id: request.inline_message_id,
                    parse_mode: 'Markdown',
                    reply_markup: {
                        inline_keyboard: [
                            [{
                                text: 'Actualizar',
                                callback_data: `refresh:${stopId}`
                            }]
                        ]
                    }
                }
            );
        })
        .catch(function (error) {
            console.error(error);
            telemetryClient.trackException(error);
        });
};

bot.on('callback_query', function (request) {
    debug('New CallbackQuery');
    const data = _.get(request, 'data', 0);
    debug(`Callback query data: ${data}`);
    try {
        const operation = data.split(':')[0];
        switch (operation) {
            case 'refresh':
                telemetryClient.trackEvent(telemetryEvents.RefreshQuery);
                processRefresh(request, data.split(':')[1]);
                break;
            default:
                bot.answerCallbackQuery(request.id);
        }
    } catch (error) {
        console.error(`Bad callback data: ${error}`);
        bot.answerCallbackQuery(request.id);
    }
});

// FUTURE: Act on use sending a location
// bot.on('location', (msg) => {
//     console.log(msg.location.latitude);
//     console.log(msg.location.longitude);
// });

module.exports = bot;