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:
Mohamed Sohail 2022-01-17 15:35:36 +03:00
parent 4372632d93
commit 2a42a5882c
Signed by: kamikazechaser
GPG Key ID: 7DD45520C01CD85D
18 changed files with 3355 additions and 54 deletions

3
.gitignore vendored
View File

@ -1 +1,2 @@
node_modules node_modules
.nyc*

8
.taprc Normal file
View 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
View File

@ -0,0 +1,4 @@
[redis]
host=127.0.0.1
port=6379
db=0

4
config/server.ini Normal file
View File

@ -0,0 +1,4 @@
[server]
host=0.0.0.0
port=3030
endpoint=

3
config/telegram.ini Normal file
View File

@ -0,0 +1,3 @@
[telegram]
polling=true
token=

3
config/ussd.ini Normal file
View File

@ -0,0 +1,3 @@
[ussd]
endpoint=
code=

View File

@ -1,15 +1,26 @@
{ {
"name": "ussd-tg-proxy", "name": "ussd-tg-proxy",
"version": "0.0.1", "version": "0.0.2",
"description": "ussd-tg-proxy", "description": "ussd-tg-proxy",
"main": "src/bot.js", "main": "src/index.js",
"repository": "https://git.grassecon.net/grassrootseconomics/ussd-tg-proxy.git", "repository": "https://git.grassecon.net/grassrootseconomics/ussd-tg-proxy.git",
"author": "Mohamed Sohail <sohalazim@pm.me>", "author": "Mohamed Sohail <sohalazim@pm.me>",
"license": "GPL-3", "license": "GPL-3.0-or-later",
"private": false, "scripts": {
"dev": "LOG_LEVEL=debug nodemon src/ | pino-pretty",
"test": "tap"
},
"dependencies": { "dependencies": {
"confini": "^0.0.7",
"express": "^4.17.2",
"grammy": "^1.6.1", "grammy": "^1.6.1",
"ioredis": "^4.28.3", "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"
} }
} }

View File

@ -1,26 +1,35 @@
// std lib
const crypto = require("crypto"); const crypto = require("crypto");
// npm imports
const { Bot, Keyboard } = require("grammy"); const { Bot, Keyboard } = require("grammy");
// module imports const log = require("./log");
const util = require("./util");
const cache = require("./cache"); const cache = require("./cache");
const config = require("./config");
const request = require("./request"); const request = require("./request");
// TODO: get value from confini const bot = new Bot(config.get("TELEGRAM_TOKEN"));
// TODO: webhook support log.debug("bot initialized");
const bot = new Bot("");
// TODO: handle errors
bot.command("start", async (ctx) => { bot.command("start", async (ctx) => {
log.debug(ctx.update, "/start cmd executed");
const tgLinked = await cache.get(ctx.msg.from.id); const tgLinked = await cache.get(ctx.msg.from.id);
log.debug({ cache: tgLinked }, "cache request:tgLinked");
if (tgLinked) { if (tgLinked) {
await cache.set(tgLinked, crypto.randomBytes(16).toString("hex")); const localSessionId = crypto.randomBytes(16).toString("hex");
const res = await request.proxy(tgLinked); 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(); const keyboard = new Keyboard();
@ -32,18 +41,31 @@ bot.command("start", async (ctx) => {
); );
}); });
// TODO: handle errors
bot.on("message:text", async (ctx) => { bot.on("message:text", async (ctx) => {
log.debug(ctx.update, "msg:text received");
const tgLinked = await cache.get(ctx.msg.from.id); const tgLinked = await cache.get(ctx.msg.from.id);
const localSessionId = await cache.get(tgLinked);
log.debug({ cache: localSessionId }, "cache request:localSessionId");
if (tgLinked) { if (tgLinked && localSessionId) {
const res = await request.proxy(tgLinked, ctx.msg.text); const res = util.parseUssdResponse(
await request.proxy(localSessionId, tgLinked, ctx.msg.text)
);
if (res.code !== "END") { if (res.code === "END") {
return ctx.reply(res.text); 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( return await ctx.reply(
@ -51,20 +73,24 @@ bot.on("message:text", async (ctx) => {
); );
}); });
// TODO: handle errors
bot.on("msg:contact", async (ctx) => { 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; 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 log.info(ctx.update.user, "contact spoof attempted");
await cache.set(ctx.msg.from.id, contact.phone_number.slice(1)); return ctx.reply("Could not verify sent contact.");
return ctx.reply(
"Phone number successfully linked. /start the bot again to access your Sarafu account."
);
}); });
bot.start(); module.exports = bot;

View File

@ -1,6 +1,13 @@
const Redis = require("ioredis"); const Redis = require("ioredis");
// TODO: get value from confini const log = require("./log");
const cache = new Redis(); 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; module.exports = cache;

12
src/config.js Normal file
View 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
View 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
View File

@ -0,0 +1,8 @@
const pino = require("pino").pino;
const log = pino({
base: undefined,
level: "debug",
});
module.exports = log;

View File

@ -1,30 +1,30 @@
// npm imports
const phin = require("phin"); const phin = require("phin");
// module imports const config = require("./config");
const cache = require("./cache"); const log = require("./log");
const util = require("./utils");
// proxy requests to ussd-server async function proxy(sessionId, phone, input = "") {
// TODO: handle errors const requestOptions = {
async function proxy(phone, input = "") { url: config.get("USSD_ENDPOINT"),
const sessionId = await cache.get(phone);
const { body } = await phin({
// TODO: get value from confini
url: "",
method: "POST", method: "POST",
parse: "string", parse: "string",
form: { form: {
sessionId: sessionId, sessionId: sessionId,
phoneNumber: phone, phoneNumber: phone,
// TODO: get value from confini serviceCode: config.get("USSD_CODE"),
serviceCode: "",
text: input, 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 }; module.exports = { proxy };

View File

@ -1,9 +1,9 @@
// this regex extracts ussd reply options // 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 // TODO: converts the text to a telegram keyboard
function createKeyboard(input) { 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 // parses ussd responses to code and text
@ -11,6 +11,7 @@ function parseUssdResponse(input) {
return { return {
code: input.slice(0, 3), code: input.slice(0, 3),
text: input.slice(4), text: input.slice(4),
keyboard: createKeyboard(input),
}; };
} }

6
tests/cache.test.js Normal file
View 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
View 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
View 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();
});

3056
yarn.lock

File diff suppressed because it is too large Load Diff