release: v0.2.0
updates: * add tests for most modules * add confini for loading configs * add contact spoof check * add optional webhook support * add error handling * add pino logger * update keyboard regex
This commit is contained in:
parent
4372632d93
commit
2a42a5882c
3
.gitignore
vendored
3
.gitignore
vendored
@ -1 +1,2 @@
|
||||
node_modules
|
||||
node_modules
|
||||
.nyc*
|
8
.taprc
Normal file
8
.taprc
Normal file
@ -0,0 +1,8 @@
|
||||
reporter: spec
|
||||
comments: true
|
||||
coverage: true
|
||||
check-coverage: true
|
||||
branches: 90
|
||||
lines: 90
|
||||
functions: 90
|
||||
statements: 90
|
4
config/redis.ini
Normal file
4
config/redis.ini
Normal file
@ -0,0 +1,4 @@
|
||||
[redis]
|
||||
host=127.0.0.1
|
||||
port=6379
|
||||
db=0
|
4
config/server.ini
Normal file
4
config/server.ini
Normal file
@ -0,0 +1,4 @@
|
||||
[server]
|
||||
host=0.0.0.0
|
||||
port=3030
|
||||
endpoint=
|
3
config/telegram.ini
Normal file
3
config/telegram.ini
Normal file
@ -0,0 +1,3 @@
|
||||
[telegram]
|
||||
polling=true
|
||||
token=
|
3
config/ussd.ini
Normal file
3
config/ussd.ini
Normal file
@ -0,0 +1,3 @@
|
||||
[ussd]
|
||||
endpoint=
|
||||
code=
|
21
package.json
21
package.json
@ -1,15 +1,26 @@
|
||||
{
|
||||
"name": "ussd-tg-proxy",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.2",
|
||||
"description": "ussd-tg-proxy",
|
||||
"main": "src/bot.js",
|
||||
"main": "src/index.js",
|
||||
"repository": "https://git.grassecon.net/grassrootseconomics/ussd-tg-proxy.git",
|
||||
"author": "Mohamed Sohail <sohalazim@pm.me>",
|
||||
"license": "GPL-3",
|
||||
"private": false,
|
||||
"license": "GPL-3.0-or-later",
|
||||
"scripts": {
|
||||
"dev": "LOG_LEVEL=debug nodemon src/ | pino-pretty",
|
||||
"test": "tap"
|
||||
},
|
||||
"dependencies": {
|
||||
"confini": "^0.0.7",
|
||||
"express": "^4.17.2",
|
||||
"grammy": "^1.6.1",
|
||||
"ioredis": "^4.28.3",
|
||||
"phin": "^3.6.1"
|
||||
"phin": "^3.6.1",
|
||||
"pino": "^7.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^2.0.15",
|
||||
"pino-pretty": "^7.3.0",
|
||||
"tap": "^15.1.6"
|
||||
}
|
||||
}
|
||||
|
80
src/bot.js
80
src/bot.js
@ -1,26 +1,35 @@
|
||||
// std lib
|
||||
const crypto = require("crypto");
|
||||
|
||||
// npm imports
|
||||
const { Bot, Keyboard } = require("grammy");
|
||||
|
||||
// module imports
|
||||
const log = require("./log");
|
||||
const util = require("./util");
|
||||
const cache = require("./cache");
|
||||
const config = require("./config");
|
||||
const request = require("./request");
|
||||
|
||||
// TODO: get value from confini
|
||||
// TODO: webhook support
|
||||
const bot = new Bot("");
|
||||
const bot = new Bot(config.get("TELEGRAM_TOKEN"));
|
||||
log.debug("bot initialized");
|
||||
|
||||
// TODO: handle errors
|
||||
bot.command("start", async (ctx) => {
|
||||
log.debug(ctx.update, "/start cmd executed");
|
||||
const tgLinked = await cache.get(ctx.msg.from.id);
|
||||
log.debug({ cache: tgLinked }, "cache request:tgLinked");
|
||||
|
||||
if (tgLinked) {
|
||||
await cache.set(tgLinked, crypto.randomBytes(16).toString("hex"));
|
||||
const res = await request.proxy(tgLinked);
|
||||
const localSessionId = crypto.randomBytes(16).toString("hex");
|
||||
await cache.set(tgLinked, localSessionId);
|
||||
|
||||
return ctx.reply(res.text);
|
||||
const res = util.parseUssdResponse(
|
||||
await request.proxy(localSessionId, tgLinked)
|
||||
);
|
||||
|
||||
return ctx.reply(res.text, {
|
||||
reply_markup: {
|
||||
resize_keyboard: true,
|
||||
keyboard: res.keyboard,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const keyboard = new Keyboard();
|
||||
@ -32,18 +41,31 @@ bot.command("start", async (ctx) => {
|
||||
);
|
||||
});
|
||||
|
||||
// TODO: handle errors
|
||||
bot.on("message:text", async (ctx) => {
|
||||
log.debug(ctx.update, "msg:text received");
|
||||
const tgLinked = await cache.get(ctx.msg.from.id);
|
||||
const localSessionId = await cache.get(tgLinked);
|
||||
log.debug({ cache: localSessionId }, "cache request:localSessionId");
|
||||
|
||||
if (tgLinked) {
|
||||
const res = await request.proxy(tgLinked, ctx.msg.text);
|
||||
if (tgLinked && localSessionId) {
|
||||
const res = util.parseUssdResponse(
|
||||
await request.proxy(localSessionId, tgLinked, ctx.msg.text)
|
||||
);
|
||||
|
||||
if (res.code !== "END") {
|
||||
return ctx.reply(res.text);
|
||||
if (res.code === "END") {
|
||||
await cache.del(tgLinked);
|
||||
|
||||
return await ctx.reply(res.text, {
|
||||
reply_markup: { remove_keyboard: true },
|
||||
});
|
||||
}
|
||||
|
||||
return await cache.del(tgLinked);
|
||||
return await ctx.reply(res.text, {
|
||||
reply_markup: {
|
||||
resize_keyboard: true,
|
||||
keyboard: res.keyboard,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return await ctx.reply(
|
||||
@ -51,20 +73,24 @@ bot.on("message:text", async (ctx) => {
|
||||
);
|
||||
});
|
||||
|
||||
// TODO: handle errors
|
||||
bot.on("msg:contact", async (ctx) => {
|
||||
// TODO: check if msg is reply from bot to prevent contact share bypass
|
||||
log.debug(ctx.update, "msg:contact received");
|
||||
const contact = ctx.msg.contact;
|
||||
if (contact.phone_number.slice(0, 4) !== "+254") {
|
||||
return ctx.reply("Sarafu is only available in Kenya at the moment.");
|
||||
|
||||
if (ctx.msg.reply_to_message.from.is_bot && ctx.from.id === contact.user_id) {
|
||||
if (contact.phone_number.slice(0, 4) !== "+254") {
|
||||
return ctx.reply("Sarafu is only available in Kenya at the moment.");
|
||||
}
|
||||
|
||||
await cache.set(contact.user_id, contact.phone_number.slice(1));
|
||||
|
||||
return ctx.reply(
|
||||
"Phone number successfully linked. /start the bot again to access your Sarafu account."
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: use contact.user_id in actual guard to prevent contact share bypass
|
||||
await cache.set(ctx.msg.from.id, contact.phone_number.slice(1));
|
||||
|
||||
return ctx.reply(
|
||||
"Phone number successfully linked. /start the bot again to access your Sarafu account."
|
||||
);
|
||||
log.info(ctx.update.user, "contact spoof attempted");
|
||||
return ctx.reply("Could not verify sent contact.");
|
||||
});
|
||||
|
||||
bot.start();
|
||||
module.exports = bot;
|
||||
|
11
src/cache.js
11
src/cache.js
@ -1,6 +1,13 @@
|
||||
const Redis = require("ioredis");
|
||||
|
||||
// TODO: get value from confini
|
||||
const cache = new Redis();
|
||||
const log = require("./log");
|
||||
const config = require("./config");
|
||||
|
||||
const cache = new Redis({
|
||||
host: config.get("REDIS_HOST"),
|
||||
port: config.get("REDIS_PORT"),
|
||||
db: config.get("REDIS_DB"),
|
||||
});
|
||||
log.debug("cache initialized");
|
||||
|
||||
module.exports = cache;
|
||||
|
12
src/config.js
Normal file
12
src/config.js
Normal file
@ -0,0 +1,12 @@
|
||||
const path = require("path");
|
||||
const Confini = require("confini").Config;
|
||||
|
||||
const log = require("./log");
|
||||
|
||||
const config = new Confini(path.join(__dirname, "../config"));
|
||||
|
||||
config.process();
|
||||
config.override(process.env, "CIC_");
|
||||
log.debug(config.store, "confini loaded");
|
||||
|
||||
module.exports = config;
|
51
src/index.js
Normal file
51
src/index.js
Normal file
@ -0,0 +1,51 @@
|
||||
const express = require("express");
|
||||
const { webhookCallback } = require("grammy");
|
||||
|
||||
const config = require("./config");
|
||||
const cache = require("./cache");
|
||||
const bot = require("./bot");
|
||||
const log = require("./log");
|
||||
|
||||
if (config.get("TELEGRAM_POLLING")) {
|
||||
bot.api.deleteWebhook().then(() => {
|
||||
log.info("starting bot in polling mode");
|
||||
bot.start();
|
||||
});
|
||||
|
||||
bot.catch((err) => {
|
||||
log.error(err);
|
||||
});
|
||||
} else {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
log.info("starting bot in webhook mode");
|
||||
app.use(webhookCallback(bot, "express"));
|
||||
app.use(function (err, _, res, _) {
|
||||
log.error(err);
|
||||
return res.status(500);
|
||||
});
|
||||
|
||||
const webhookEndpoint = `${config.get("SERVER_ENDPOINT")}/${config.get(
|
||||
"TELEGRAM_TOKEN"
|
||||
)}`;
|
||||
log.debug(`setting webhook at ${webhookEndpoint}`);
|
||||
bot.api.setWebhook(webhookEndpoint).then(async () => {
|
||||
const webhookInfo = await bot.api.getWebhookInfo();
|
||||
log.info(webhookInfo, "webhook successfully set");
|
||||
});
|
||||
|
||||
log.info("starting server");
|
||||
const server = app.listen(
|
||||
config.get("SERVER_PORT"),
|
||||
config.get("SERVER_HOST")
|
||||
);
|
||||
|
||||
for (const signal of ["SIGINT", "SIGTERM"]) {
|
||||
process.once(signal, async () => {
|
||||
log.info("shutting down server");
|
||||
await server.close();
|
||||
await cache.disconnect();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
}
|
8
src/log.js
Normal file
8
src/log.js
Normal file
@ -0,0 +1,8 @@
|
||||
const pino = require("pino").pino;
|
||||
|
||||
const log = pino({
|
||||
base: undefined,
|
||||
level: "debug",
|
||||
});
|
||||
|
||||
module.exports = log;
|
@ -1,30 +1,30 @@
|
||||
// npm imports
|
||||
const phin = require("phin");
|
||||
|
||||
// module imports
|
||||
const cache = require("./cache");
|
||||
const util = require("./utils");
|
||||
const config = require("./config");
|
||||
const log = require("./log");
|
||||
|
||||
// proxy requests to ussd-server
|
||||
// TODO: handle errors
|
||||
async function proxy(phone, input = "") {
|
||||
const sessionId = await cache.get(phone);
|
||||
|
||||
const { body } = await phin({
|
||||
// TODO: get value from confini
|
||||
url: "",
|
||||
async function proxy(sessionId, phone, input = "") {
|
||||
const requestOptions = {
|
||||
url: config.get("USSD_ENDPOINT"),
|
||||
method: "POST",
|
||||
parse: "string",
|
||||
form: {
|
||||
sessionId: sessionId,
|
||||
phoneNumber: phone,
|
||||
// TODO: get value from confini
|
||||
serviceCode: "",
|
||||
serviceCode: config.get("USSD_CODE"),
|
||||
text: input,
|
||||
},
|
||||
});
|
||||
};
|
||||
log.debug(requestOptions, "request options");
|
||||
|
||||
return util.parseUssdResponse(body);
|
||||
try {
|
||||
const { body } = await phin(requestOptions);
|
||||
log.debug({ body: body }, "response body");
|
||||
return body;
|
||||
} catch (error) {
|
||||
log.error(error);
|
||||
return "ERR Something went wrong, try again later.";
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { proxy };
|
||||
|
@ -1,9 +1,9 @@
|
||||
// this regex extracts ussd reply options
|
||||
const regex = /(\d).\s/g;
|
||||
const regex = /\s(\d{1,2}).\s/g;
|
||||
|
||||
// TODO: converts the text to a telegram keyboard
|
||||
function createKeyboard(input) {
|
||||
return Array.from(input.matchAll(regex), (m) => m[1]);
|
||||
return Array.from(input.matchAll(regex), (m) => [m[1]]);
|
||||
}
|
||||
|
||||
// parses ussd responses to code and text
|
||||
@ -11,6 +11,7 @@ function parseUssdResponse(input) {
|
||||
return {
|
||||
code: input.slice(0, 3),
|
||||
text: input.slice(4),
|
||||
keyboard: createKeyboard(input),
|
||||
};
|
||||
}
|
||||
|
6
tests/cache.test.js
Normal file
6
tests/cache.test.js
Normal file
@ -0,0 +1,6 @@
|
||||
const test = require("tap").test;
|
||||
|
||||
test("cache", (t) => {
|
||||
t.skip("redis connection");
|
||||
t.end();
|
||||
});
|
31
tests/request.test.js
Normal file
31
tests/request.test.js
Normal file
@ -0,0 +1,31 @@
|
||||
const test = require("tap").test;
|
||||
|
||||
const request = require("../src/request");
|
||||
const config = require("../src/config");
|
||||
|
||||
test("request", (t) => {
|
||||
t.plan(3);
|
||||
|
||||
t.test("correctly formed request", async (t) => {
|
||||
const body = await request.proxy("123", "254711777734");
|
||||
|
||||
t.equal(body.slice(0, 3), "CON");
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("bad request (4XX)", async (t) => {
|
||||
// non ussd response
|
||||
const body = await request.proxy("123", "255711777734");
|
||||
|
||||
t.not(body.slice(0, 3), "CON");
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("request error or (5XX)", async (t) => {
|
||||
config.store.USSD_ENDPOINT = "https://nonexistent";
|
||||
const body = await request.proxy();
|
||||
|
||||
t.equal(body.slice(0, 3), "ERR");
|
||||
t.end();
|
||||
});
|
||||
});
|
71
tests/util.test.js
Normal file
71
tests/util.test.js
Normal file
@ -0,0 +1,71 @@
|
||||
const test = require("tap").test;
|
||||
|
||||
const util = require("../src/util");
|
||||
|
||||
test("util", (t) => {
|
||||
t.plan(4);
|
||||
|
||||
t.test("keyboard from single digit reply options", (t) => {
|
||||
const rawText = `CON Balance 80.0 GFT
|
||||
1. Send
|
||||
2. My Sarafu
|
||||
3. My Account
|
||||
4. Help`;
|
||||
|
||||
const expectKeyboard = [["1"], ["2"], ["3"], ["4"]];
|
||||
const output = util.createKeyboard(rawText);
|
||||
|
||||
t.same(output, expectKeyboard);
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("keyboard from multi digit reply options", (t) => {
|
||||
const rawText = `CON For assistance call 0757628885
|
||||
00. Back
|
||||
99. Exit`;
|
||||
|
||||
const expectKeyboard = [["00"], ["99"]];
|
||||
const output = util.createKeyboard(rawText);
|
||||
|
||||
t.same(output, expectKeyboard);
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("keyboard from mixed count digit reply options", (t) => {
|
||||
const rawText = `CON For assistance call 0757628885
|
||||
1. Option
|
||||
00. Back
|
||||
99. Exit`;
|
||||
|
||||
const expectKeyboard = [["1"], ["00"], ["99"]];
|
||||
const output = util.createKeyboard(rawText);
|
||||
|
||||
t.same(output, expectKeyboard);
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("code, text and keyboard from raw response", (t) => {
|
||||
const rawText = `CON Balance 80.0 GFT
|
||||
1. Send
|
||||
2. My Sarafu
|
||||
3. My Account
|
||||
4. Help`;
|
||||
|
||||
const expectKeyboard = [["1"], ["2"], ["3"], ["4"]];
|
||||
const expect = {
|
||||
code: "CON",
|
||||
text: `Balance 80.0 GFT
|
||||
1. Send
|
||||
2. My Sarafu
|
||||
3. My Account
|
||||
4. Help`,
|
||||
keyboard: expectKeyboard,
|
||||
};
|
||||
const output = util.parseUssdResponse(rawText);
|
||||
|
||||
t.same(output, expect);
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.endAll();
|
||||
});
|
Loading…
Reference in New Issue
Block a user