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
1
.gitignore
vendored
1
.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",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
80
src/bot.js
80
src/bot.js
@ -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;
|
||||||
|
11
src/cache.js
11
src/cache.js
@ -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
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");
|
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 };
|
||||||
|
@ -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
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