@ntlab/sms-gateway
Version:
780 lines (737 loc) • 27.2 kB
JavaScript
/**
* The MIT License (MIT)
*
* Copyright (c) 2018-2024 Toha <tohenk@yahoo.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
* of the Software, and to permit persons to whom the Software is furnished to do
* so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
/*
* Terminal handler.
*/
const fs = require('fs');
const ini = require('ini');
const path = require('path');
const util = require('util');
const EventEmitter = require('events');
const Logger = require('@ntlab/ntlib/logger');
const { Work } = require('@ntlab/work');
const AppStorage = require('./storage');
const { AppTerminalDispatcher, AppActivityDispatcher } = require('./dispatcher');
class AppTerm {
Storage = AppStorage
ClientRoom = 'client'
UiRoom = 'ui'
init(config) {
this.config = config;
this.operatorFilename = config.operatorFilename;
this.configdir = config.configdir;
this.countryCode = config.countryCode;
this.pools = [];
this.terminals = [];
this.groups = {};
this.aliases = {};
this.gwclients = [];
this.plugins = [];
this.dispatcher = new AppActivityDispatcher(this);
this.dispatcher.on('queue-processed', queue => this.uiSend('queue-processed', queue));
return Work.works([
[w => this.initializeLogger()],
[w => AppStorage.init(config.database)],
[w => this.loadPlugins()],
[w => this.loadOperator()],
]);
}
initializeLogger() {
return new Promise((resolve, reject) => {
this.logdir = this.config.logdir || path.join(__dirname, 'logs');
this.logfile = path.join(this.logdir, 'gateway.log');
this.logger = new Logger(this.logfile);
resolve();
});
}
loadPlugins() {
if (!this.config.plugins) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
// create plugins data directory if needed
if (!fs.existsSync(this.config.datadir)) {
fs.mkdirSync(this.config.datadir);
}
const plugins = Array.isArray(this.config.plugins) ? this.config.plugins : this.config.plugins.split(',');
for (let i = 0; i < plugins.length; i++) {
const plugin = plugins[i].trim();
let pluginSrc;
[
plugin,
path.join(__dirname, 'plugins', plugin),
path.join(__dirname, 'plugins', plugin, 'index')
].forEach(file => {
if (fs.existsSync(file + '.js')) {
pluginSrc = file;
return true;
}
});
if (!pluginSrc) {
// is plugin a package
const res = require.resolve(plugin);
if (res) {
pluginSrc = res;
}
if (!pluginSrc) {
console.log('Unknown plugin: %s', plugin);
continue;
}
}
const p = require(pluginSrc);
if (typeof p === 'function') {
const instance = new p(this);
if (instance.name && typeof instance.handle === 'function') {
instance.src = pluginSrc;
if (typeof instance.initialize === 'function') {
instance.initialize();
}
this.plugins.push(instance);
console.log('Plugin loaded: %s', plugin);
} else {
console.log('Invalid plugin instance: %s', plugin);
}
}
}
resolve();
});
}
loadOperator() {
return new Promise((resolve, reject) => {
if (this.operatorFilename && fs.existsSync(this.operatorFilename)) {
this.operators = ini.parse(fs.readFileSync(this.operatorFilename, 'utf-8'));
}
resolve();
});
}
get(imsi) {
let terminal = null;
this.terminals.forEach(term => {
if (term.name === imsi) {
terminal = term;
return true;
}
});
return terminal;
}
getOperator(number) {
if (!this.countryCode || this.countryCode === 'auto') {
this.terminals.forEach(term => {
if (term.info.network.country) {
this.countryCode = term.info.network.country;
return true;
}
});
}
if (!this.countryCode || this.countryCode === 'auto') {
throw new Error('Country code is not set.');
}
if (number.charAt(0) === '+') {
number = '0' + number.substr(this.countryCode.length + 1);
}
let result;
Object.keys(this.operators).forEach(operator => {
Object.values(this.operators[operator]).forEach(prefix => {
const prefixes = prefix.split('-');
if (number.substr(0, prefixes[0].length) === prefixes[0]) {
result = operator;
return true;
}
});
if (result) {
return true;
}
});
return result;
}
getNetworkOperator(imsi) {
const term = this.get(imsi);
if (term) {
return term.info.network.operator;
}
}
changed() {
this.terminals = [];
this.groups = {};
this.pools.forEach(pool => {
pool.terminals.forEach(term => {
let group = term.options.group || '';
if (group) {
term.options.groups = group.split(',').map(g => g.trim());
group = term.options.groups[0];
} else {
term.options.groups = [];
}
this.terminals.push(term);
if (!this.groups[group]) {
this.groups[group] = [];
}
this.groups[group].push(term);
});
});
if (this.plugins.length) {
this.dispatcher.reload();
}
}
setTermIo(io) {
this.io = io;
this.config.pools.forEach(pool => {
const p = new AppTermPool(this, pool);
this.pools.push(p);
});
return this;
}
setSocketIo(sio) {
this.sio = sio;
this.uiCon = this.sio.of('/ui');
this.uiCon.on('connection', socket => {
console.log('UI client connected: %s', socket.id);
socket.join(this.UiRoom);
socket.on('disconnect', () => {
console.log('UI client disconnected: %s', socket.id);
socket.leave(this.UiRoom);
});
});
this.gwCon = this.sio.of('/gw');
this.gwCon.on('connection', socket => {
console.log('Gateway client connected: %s', socket.id);
socket.time = new Date();
const timeout = setTimeout(() => {
console.log('Closing connection due to no auth: %s', socket.id);
socket.disconnect();
}, 10000);
socket.on('disconnect', () => {
console.log('Gateway client disconnected: %s', socket.id);
socket.leave(this.ClientRoom);
if (socket.group) {
socket.leave(socket.group);
}
const idx = this.gwclients.indexOf(socket);
if (idx >= 0) {
this.gwclients.splice(idx, 1);
this.uiSend('client');
}
});
socket.on('auth', secret => {
const authenticated = this.config.secret === secret;
if (authenticated) {
console.log('Client is authenticated: %s', socket.id);
clearTimeout(timeout);
if (this.gwclients.indexOf(socket) < 0) {
this.gwclients.push(socket);
}
this.dispatcher.reload();
socket.join(this.ClientRoom);
this.uiSend('client');
} else {
console.log('Client is NOT authenticated: %s', socket.id);
}
socket.emit('auth', authenticated);
});
socket.on('group', data => {
if (this.gwclients.indexOf(socket) < 0) {
return;
}
console.log('Group changed for %s => %s', socket.id, data);
if (socket.group) {
socket.leave(socket.group);
}
socket.group = data;
socket.join(socket.group);
});
socket.on('message', data => {
if (this.gwclients.indexOf(socket) < 0) {
return;
}
this.handleMessage(socket, data);
});
socket.on('message-retry', data => {
if (this.gwclients.indexOf(socket) < 0) {
return;
}
this.handleMessageRetry(socket, data);
});
});
return this;
}
uiSend(message, data = null) {
if (this.uiCon) {
if (data) {
this.uiCon.to(this.UiRoom).emit(message, data);
} else {
this.uiCon.to(this.UiRoom).emit(message);
}
}
}
handleMessage(socket, data) {
this.dispatcher.add({
type: AppStorage.ACTIVITY_SMS,
hash: data.hash || null,
address: data.address,
data: data.data
}, socket.group, queue => {
if (queue) {
this.log('<-- SMS: %s', util.inspect({hash: queue.hash, address: queue.address, data: queue.data}));
socket.emit('status', {
type: queue.type,
hash: queue.hash,
time: queue.time,
status: true
});
this.uiSend('new-activity', queue.type);
}
});
}
handleMessageRetry(socket, data) {
this.log('<-- Checking SMS: %s', data.hash);
const condition = {
hash: data.hash,
type: AppStorage.ACTIVITY_SMS
}
AppStorage.GwQueue.count({where: condition})
.then(count => {
if (0 === count) {
this.handleMessage(socket, data);
} else {
AppStorage.GwLog.findOne({where: condition})
.then(gwlog => {
// message report already confirmed
if (gwlog.code !== null) {
socket.emit('status-report', {
hash: gwlog.hash,
address: gwlog.address,
code: gwlog.code,
sent: gwlog.sent,
received: gwlog.received,
time: gwlog.time
});
} else if (gwlog.status === 0) {
AppStorage.GwQueue.findOne({where: condition})
.then(gwqueue => {
const updates = {processed: false, retry: null};
let term = this.get(gwqueue.imsi);
// allow to use other terminal in case destined terminal is not exist
// or not able to send message
if (!term || !term.options.sendMessage) {
term = this.dispatcher.selectTerminal(AppStorage.ACTIVITY_SMS, gwqueue.address, socket.group);
if (term) {
updates.imsi = term.name;
console.log('Relocating message %s using %s', data.hash, term.name);
}
}
// only retry when terminal is available
if (term) {
gwqueue.update(updates)
.then(() => {
console.log('Resetting message %s status for retry', data.hash);
term.dispatcher.reload();
})
;
}
})
;
}
})
;
}
})
;
}
log() {
this.logger.log.apply(this.logger, Array.from(arguments))
.then(message => {
this.uiSend('activity', {time: Date.now(), message: message});
})
;
}
}
class AppTermPool {
constructor(parent, parameters) {
this.parent = parent;
this.name = parameters.name;
this.url = parameters.url;
this.key = parameters.key;
this.options = parameters.options || {};
this.terminals = [];
this.init();
}
init() {
this.con = this.parent.io(this.url + '/ctrl', this.options);
const done = result => {
if (result) {
this.parent.uiSend('new-activity', result.type);
this.parent.dispatcher.reload();
}
}
this.con.on('connect', () => {
console.log('Connected to terminal: %s', this.url);
this.con.emit('auth', this.key);
});
this.con.on('disconnect', () => {
console.log('Disconnected from: %s', this.url);
this.reset(true);
});
this.con.on('auth', success => {
if (success) {
this.con.emit('init');
} else {
console.log('Authentication failed!');
}
});
this.con.on('ready', terms => {
console.log('Terminal ready: %s', util.inspect(terms));
this.build(terms);
});
this.con.on('status-report', data => {
this.parent.log('<-- REPORT: %s', util.inspect(data));
AppStorage.updateReport(data.imsi, data);
if (this.parent.gwCon) {
const term = this.parent.get(data.imsi);
const rooms = term && term.options.groups.length ? term.options.groups : [this.parent.ClientRoom];
for (const room of rooms) {
this.parent.gwCon.to(room).emit('status-report', data);
}
}
});
this.con.on('message', data => {
this.parent.log('<-- MESSAGE: %s', util.inspect(data));
AppStorage.saveQueue(data.imsi, {
hash: data.hash,
type: AppStorage.ACTIVITY_INBOX,
address: data.address,
data: data.data
}, done);
});
this.con.on('ussd', data => {
this.parent.log('<-- USSD: %s', util.inspect(data));
AppStorage.saveQueue(data.imsi, {
hash: data.hash,
type: AppStorage.ACTIVITY_CUSD,
address: data.address,
data: data.data
}, done);
this.parent.uiSend('ussd', {
imsi: data.imsi,
address: data.address,
message: data.data
});
});
this.con.on('ring', data => {
this.parent.log('<-- RING: %s', util.inspect(data));
AppStorage.saveQueue(data.imsi, {
hash: data.hash,
type: AppStorage.ACTIVITY_RING,
address: data.address,
data: null
}, done);
});
}
checkPending() {
if (this.con && this.terminals.length) {
this.con.emit('check-pending');
}
}
build(terms) {
this.reset();
terms.forEach(imsi => {
const con = this.parent.io(this.url + '/' + imsi, this.options);
const term = new AppTerminal(imsi, con, {configFilename: path.join(this.parent.configdir, imsi + '.cfg')});
term.operatorList = Object.keys(this.parent.operators);
term
.on('pre-queue', queue => {
this.parent.uiSend('queue', queue);
})
.on('post-queue', queue => {
this.parent.uiSend('queue-done', queue);
})
;
this.terminals.push(term);
});
let timeout;
const f = () => {
let readyCnt = 0;
this.terminals.forEach(term => {
if (term.connected) {
readyCnt++;
}
});
if (terms.length && readyCnt === terms.length) {
if (timeout !== undefined) {
clearTimeout(timeout);
}
this.checkPending();
} else {
timeout = setTimeout(f, 500);
}
}
f();
this.parent.changed();
}
reset(update) {
this.terminals.forEach(term => {
delete term.dispatcher;
term.con.disconnect();
});
this.terminals = [];
if (update) {
this.parent.changed();
}
return this;
}
}
class AppTerminal extends EventEmitter {
constructor(name, con, options) {
super();
options = options || {};
this.name = name;
this.con = con;
this.connected = false;
this.busy = false;
this.options = this.defaultOptions();
this.operatorList = [];
if (options.configFilename) {
this.configFilename = options.configFilename;
}
if (this.configFilename && fs.existsSync(this.configFilename)) {
this.readOptions(JSON.parse(fs.readFileSync(this.configFilename, 'utf-8')));
} else {
this.readOptions(options);
}
// terminal operation timeout is max at 10 seconds
this.timeout = options.timeout || 12000;
this.dispatcher = new AppTerminalDispatcher(this);
this.dispatcher
.on('pre-queue', queue => {
this._queue = queue;
this.emit('pre-queue', queue);
})
.on('post-queue', queue => {
this.emit('post-queue', queue);
})
;
this.con.on('connect', () => {
this.connected = true;
this.syncOptions(false);
this.getInfo()
.then(info => {
this.info = info;
this.dispatcher.reload();
})
;
});
this.con.on('disconnect', () => {
this.connected = false;
this.busy = false;
this.synced = false;
});
this.con.on('state', state => {
if (state.idle) {
this.emit('idle');
}
});
}
defaultOptions() {
return {
rejectCall: false,
allowCall: true,
receiveMessage: true,
sendMessage: true,
deleteMessage: false,
replyBlockedMessage: false,
deliveryReport: true,
requestReply: false,
emptyWhenFull: false,
priority: 0,
group: null,
operators: []
}
}
readOptions(options) {
const newOptions = {};
Object.keys(this.options).forEach(opt => {
if (options[opt] !== undefined) {
newOptions[opt] = options[opt];
}
});
this.applyOptions(newOptions);
return this;
}
applyOptions(options) {
const oldOptions = JSON.stringify(this.options, null, 4);
Object.assign(this.options, options);
const newOptions = JSON.stringify(this.options, null, 4);
if (oldOptions != newOptions) {
this.syncOptions(true);
if (this.configFilename) {
fs.writeFile(this.configFilename, newOptions, err => {
if (err) {
console.error(err);
}
});
}
}
return this;
}
syncOptions(force) {
if (!this.synced || force) {
this.synced = true;
this.con.once('getopt', options => {
const setopts = {};
Object.keys(options).forEach(opt => {
if (options[opt] !== this.options[opt]) {
setopts[opt] = this.options[opt];
}
});
if (Object.keys(setopts).length) {
this.con.emit('setopt', setopts);
}
});
this.con.emit('getopt');
}
return this;
}
query(cmd, data) {
if (!this.connected) {
return Promise.reject('Not connected');
}
return new Promise((resolve, reject) => {
this.busy = true;
this.reply = null;
let timeout = null;
const t = () => {
this.busy = false;
reject('Timeout');
}
this.con.once(cmd, result => {
this.busy = false;
this.reply = result;
if (timeout) {
clearTimeout(timeout);
}
resolve(result);
});
timeout = setTimeout(t, this.timeout);
if (data) {
this.con.emit(cmd, data);
} else {
this.con.emit(cmd);
}
});
}
getStat() {
return new Promise((resolve, reject) => {
const res = {
unprocessed: {
label: 'Unprocessed queue',
value: this.dispatcher.queues.length
},
last: {
label: 'Last queue',
value: this._queue ? this._queue.hash.substr(0, 8) : null
}
}
AppStorage.countStats(this.name)
.then(rows => {
rows.forEach(row => {
res[row.type == 1 ? 'fail' : 'success'] = {
label: row.type == 1 ? 'Total failed queues' : 'Total succeeded queues',
value: row.count
}
});
resolve(res);
})
.catch(err => reject(err));
});
}
getInfo() {
return this.query('info');
}
dial(data) {
return this.query('dial', data);
}
sendMessage(data) {
return this.query('message', data);
}
ussd(data) {
return this.query('ussd', data);
}
fixData(data) {
return new Promise((resolve, reject) => {
if (!data.imsi) {
data.imsi = this.name;
}
if (!data.time) {
data.time = new Date();
}
if (!data.hash) {
this.query('hash', data)
.then(result => resolve(result))
.catch(err => {
console.error(err);
resolve(data);
})
;
} else {
resolve(data);
}
})
}
addQueue(data, cb) {
this.fixData(data)
.then(result => {
AppStorage.saveQueue(this.name, result, queue => {
if (queue) {
this.dispatcher.reload();
}
if (typeof cb === 'function') {
cb(queue);
}
});
})
;
}
addCallQueue(phoneNumber, cb) {
this.addQueue({
imsi: this.name,
type: AppStorage.ACTIVITY_CALL,
address: phoneNumber
}, cb);
}
addMessageQueue(phoneNumber, message, cb) {
this.addQueue({
imsi: this.name,
type: AppStorage.ACTIVITY_SMS,
address: phoneNumber,
data: message
}, cb);
}
addUssdQueue(service, cb) {
this.addQueue({
imsi: this.name,
type: AppStorage.ACTIVITY_USSD,
address: service
}, cb);
}
}
module.exports = new AppTerm();