Bridge whatsapp simplification

This commit is contained in:
Darren Clarke 2024-05-15 14:39:33 +02:00
parent 6305a8b0bc
commit f6dc60eb08
8 changed files with 142 additions and 333 deletions

View file

@ -1,7 +1,6 @@
{ {
"name": "bridge-whatsapp", "name": "bridge-whatsapp",
"version": "0.3.0", "version": "0.3.0",
"type": "module",
"main": "build/main/index.js", "main": "build/main/index.js",
"author": "Darren Clarke <darren@redaranj.com>", "author": "Darren Clarke <darren@redaranj.com>",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
@ -11,12 +10,8 @@
"@hapi/boom": "^10.0.1", "@hapi/boom": "^10.0.1",
"@hapipal/schmervice": "^3.0.0", "@hapipal/schmervice": "^3.0.0",
"@hapipal/toys": "^4.0.0", "@hapipal/toys": "^4.0.0",
"@types/hapi-auth-bearer-token": "^6.1.8",
"@whiskeysockets/baileys": "^6.7.2", "@whiskeysockets/baileys": "^6.7.2",
"bridge-common": "*", "hapi-pino": "^12.1.0"
"hapi-auth-bearer-token": "^8.0.0",
"hapi-pino": "^12.1.0",
"kysely": "^0.26.1"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "*", "@types/node": "*",

View file

@ -1,14 +0,0 @@
import Toys from "@hapipal/toys";
export const withDefaults = Toys.withRouteDefaults({
options: {
cors: true,
auth: "bearer",
},
});
export const noAuth = Toys.withRouteDefaults({
options: {
cors: true,
},
});

View file

@ -1,46 +1,25 @@
import * as Hapi from "@hapi/hapi"; import * as Hapi from "@hapi/hapi";
import * as AuthBearer from "hapi-auth-bearer-token";
import hapiPino from "hapi-pino"; import hapiPino from "hapi-pino";
import Schmervice from "@hapipal/schmervice"; import Schmervice from "@hapipal/schmervice";
import WhatsappService from "./service.js"; import WhatsappService from "./service.js";
import { import {
GetAllWhatsappBotsRoute,
GetBotsRoute,
SendBotRoute,
ReceiveBotRoute,
RegisterBotRoute, RegisterBotRoute,
UnverifyBotRoute, UnverifyBotRoute,
RefreshBotRoute, GetBotRoute,
CreateBotRoute, SendMessageRoute,
ReceiveMessageRoute,
} from "./routes.js"; } from "./routes.js";
const server = Hapi.server({ host: "localhost", port: 5000 }); const server = Hapi.server({ host: "localhost", port: 5000 });
const startServer = async () => { const startServer = async () => {
await server.register({ await server.register({ plugin: hapiPino });
plugin: hapiPino,
options: {
redact: ["req.headers.authorization"],
},
});
await server.register(AuthBearer);
server.auth.strategy("bearer", "bearer-access-token", {
validate: async (_request, token, _h) => {
const isValid = token === "1234";
const credentials = { token };
return { isValid, credentials };
},
});
server.route(GetAllWhatsappBotsRoute);
server.route(GetBotsRoute);
server.route(SendBotRoute);
server.route(ReceiveBotRoute);
server.route(RegisterBotRoute); server.route(RegisterBotRoute);
server.route(UnverifyBotRoute); server.route(UnverifyBotRoute);
server.route(RefreshBotRoute); server.route(GetBotRoute);
server.route(CreateBotRoute); server.route(SendMessageRoute);
server.route(ReceiveMessageRoute);
await server.register(Schmervice); await server.register(Schmervice);
server.registerService(WhatsappService); server.registerService(WhatsappService);

View file

@ -1,217 +1,112 @@
import * as Hapi from "@hapi/hapi"; import * as Hapi from "@hapi/hapi";
import Boom from "@hapi/boom"; import Toys from "@hapipal/toys";
import * as Helpers from "./helpers.js"; import WhatsappService from "./service";
export const GetAllWhatsappBotsRoute = Helpers.withDefaults({ const withDefaults = Toys.withRouteDefaults({
method: "get",
path: "/api/whatsapp/bots",
options: { options: {
description: "Get all bots", cors: true,
async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) {
const whatsappService = request.services("whatsapp");
const bots = await whatsappService.findAll();
if (bots) {
// @ts-ignore
request.logger.info({ bots }, "Retrieved bot(s) at %s", new Date());
return { bots };
}
return _h.response().code(204);
},
}, },
}); });
export const GetBotsRoute = Helpers.noAuth({ const getService = (request: Hapi.Request): WhatsappService => {
method: "get", const { whatsappService } = request.services();
path: "/api/whatsapp/bots/{token}",
options: {
description: "Get one bot",
async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) {
const { token } = request.params;
const whatsappService = request.services("whatsapp");
const bot = await whatsappService.findByToken(token); return whatsappService as WhatsappService;
};
if (bot) {
// @ts-ignore
request.logger.info({ bot }, "Retrieved bot(s) at %s", new Date());
return bot;
}
throw Boom.notFound("Bot not found");
},
},
});
interface MessageRequest { interface MessageRequest {
phoneNumber: string; phoneNumber: string;
message: string; message: string;
} }
export const SendBotRoute = Helpers.noAuth({ export const SendMessageRoute = withDefaults({
method: "post", method: "post",
path: "/api/whatsapp/bots/{token}/send", path: "/api/bots/{id}/send",
options: { options: {
description: "Send a message", description: "Send a message",
async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) { async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) {
const { token } = request.params; const { id } = request.params;
const { phoneNumber, message } = request.payload as MessageRequest; const { phoneNumber, message } = request.payload as MessageRequest;
const whatsappService = request.services("whatsapp"); const whatsappService = getService(request);
await whatsappService.send(id, phoneNumber, message as string);
request.logger.info({ id }, "Sent a message at %s", new Date());
const bot = await whatsappService.findByToken(token);
if (bot) {
// @ts-ignore
request.logger.info({ bot }, "Sent a message at %s", new Date());
await whatsappService.send(bot, phoneNumber, message as string);
return _h return _h
.response({ .response({
result: { result: {
recipient: phoneNumber, recipient: phoneNumber,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
source: bot.phoneNumber, source: id,
}, },
}) })
.code(200); // temp .code(200);
}
throw Boom.notFound("Bot not found");
}, },
}, },
}); });
export const ReceiveBotRoute = Helpers.withDefaults({ export const ReceiveMessageRoute = withDefaults({
method: "get", method: "get",
path: "/api/whatsapp/bots/{token}/receive", path: "/api/bots/{id}/receive",
options: { options: {
description: "Receive messages", description: "Receive messages",
async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) { async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) {
const { token } = request.params; const { id } = request.params;
const whatsappService = request.services("whatsapp"); const whatsappService = getService(request);
const bot = await whatsappService.findByToken(token);
if (bot) {
// @ts-ignore
request.logger.info({ bot }, "Received messages at %s", new Date());
// temp
const date = new Date(); const date = new Date();
const twoDaysAgo = new Date(date.getTime()); const twoDaysAgo = new Date(date.getTime());
twoDaysAgo.setDate(date.getDate() - 2); twoDaysAgo.setDate(date.getDate() - 2);
return whatsappService.receive(bot, twoDaysAgo); request.logger.info({ id }, "Received messages at %s", new Date());
}
throw Boom.notFound("Bot not found"); return whatsappService.receive(id, twoDaysAgo);
}, },
}, },
}); });
export const RegisterBotRoute = Helpers.withDefaults({ export const RegisterBotRoute = withDefaults({
method: "get", method: "post",
path: "/api/whatsapp/bots/{id}/register", path: "/api/bots/{id}/register",
options: { options: {
description: "Register a bot", description: "Register a bot",
async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) { async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) {
const { id } = request.params; const { id } = request.params;
const whatsappService = request.services("whatsapp"); const whatsappService = getService(request);
const bot = await whatsappService.findById(id); await whatsappService.register(id, (error: string) => {
if (bot) {
await whatsappService.register(bot, (error: string) => {
if (error) { if (error) {
return _h.response(error).code(500); return _h.response(error).code(500);
} }
request.logger.info({ id }, "Register bot at %s", new Date());
// @ts-ignore
request.logger.info({ bot }, "Register bot at %s", new Date());
return _h.response().code(200); return _h.response().code(200);
}); });
}
throw Boom.notFound("Bot not found");
}, },
}, },
}); });
export const UnverifyBotRoute = Helpers.withDefaults({ export const UnverifyBotRoute = withDefaults({
method: "post", method: "post",
path: "/api/whatsapp/bots/{id}/unverify", path: "/api/bots/{id}/unverify",
options: { options: {
description: "Unverify bot", description: "Unverify bot",
async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) { async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) {
const { id } = request.params; const { id } = request.params;
const whatsappService = request.services("whatsapp"); const whatsappService = getService(request);
const bot = await whatsappService.findById(id); return whatsappService.unverify(id);
if (bot) {
return whatsappService.unverify(bot);
}
throw Boom.notFound("Bot not found");
}, },
}, },
}); });
export const RefreshBotRoute = Helpers.withDefaults({ export const GetBotRoute = withDefaults({
method: "get", method: "get",
path: "/api/whatsapp/bots/{id}/refresh", path: "/api/bots/{id}",
options: { options: {
description: "Refresh messages", description: "Get bot info",
async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) { async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) {
const { id } = request.params; const { id } = request.params;
const whatsappService = request.services("whatsapp"); const whatsappService = getService(request);
const bot = await whatsappService.findById(id); return whatsappService.getBot(id);
if (bot) {
// @ts-ignore
request.logger.info({ bot }, "Refreshed messages at %s", new Date());
// await whatsappService.refresh(bot);
return;
}
throw Boom.notFound("Bot not found");
},
},
});
interface BotRequest {
phoneNumber: string;
description: string;
}
export const CreateBotRoute = Helpers.withDefaults({
method: "post",
path: "/api/whatsapp/bots",
options: {
description: "Register a bot",
async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) {
const { phoneNumber, description } = request.payload as BotRequest;
const whatsappService = request.services("whatsapp");
console.log("request.auth.credentials:", request.auth.credentials);
const bot = await whatsappService.create(
phoneNumber,
description,
request.auth.credentials.email as string,
);
if (bot) {
// @ts-ignore
request.logger.info({ bot }, "Register bot at %s", new Date());
return bot;
}
throw Boom.notFound("Bot not found");
}, },
}, },
}); });

View file

@ -1,6 +1,5 @@
import { Server } from "@hapi/hapi"; import { Server } from "@hapi/hapi";
import { Service } from "@hapipal/schmervice"; import { Service } from "@hapipal/schmervice";
import { db, WhatsappBot } from "bridge-common";
import makeWASocket, { import makeWASocket, {
DisconnectReason, DisconnectReason,
proto, proto,
@ -29,8 +28,12 @@ export default class WhatsappService extends Service {
super(server, options); super(server, options);
} }
getAuthDirectory(bot: WhatsappBot): string { getBotDirectory(id: string): string {
return `/baileys/${bot.id}`; return `/baileys/${id}`;
}
getAuthDirectory(id: string): string {
return `${this.getBotDirectory(id)}/auth`;
} }
async initialize(): Promise<void> { async initialize(): Promise<void> {
@ -42,7 +45,6 @@ export default class WhatsappService extends Service {
} }
private async sleep(ms: number): Promise<void> { private async sleep(ms: number): Promise<void> {
console.log(`pausing ${ms}`);
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }
@ -58,13 +60,13 @@ export default class WhatsappService extends Service {
} }
private async createConnection( private async createConnection(
bot: WhatsappBot, botID: string,
server: Server, server: Server,
options: any, options: any,
authCompleteCallback?: any, authCompleteCallback?: any,
) { ) {
const directory = this.getAuthDirectory(bot); const authDirectory = this.getAuthDirectory(botID);
const { state, saveCreds } = await useMultiFileAuthState(directory); const { state, saveCreds } = await useMultiFileAuthState(authDirectory);
const msgRetryCounterMap: any = {}; const msgRetryCounterMap: any = {};
const socket = makeWASocket({ const socket = makeWASocket({
...options, ...options,
@ -86,23 +88,18 @@ export default class WhatsappService extends Service {
} = update; } = update;
if (qr) { if (qr) {
console.log("got qr code"); console.log("got qr code");
await db const botDirectory = this.getBotDirectory(botID);
.updateTable("WhatsappBot") const qrPath = `${botDirectory}/qr.png`;
.set({ fs.writeFileSync(qrPath, qr, "base64");
qrCode: qr, const verifiedFile = `${botDirectory}/verified`;
verified: false, if (fs.existsSync(verifiedFile)) {
}) fs.rmSync(verifiedFile);
.where("id", "=", bot.id) }
.executeTakeFirst();
} else if (isNewLogin) { } else if (isNewLogin) {
console.log("got new login"); console.log("got new login");
await db const botDirectory = this.getBotDirectory(botID);
.updateTable("WhatsappBot") const verifiedFile = `${botDirectory}/verified`;
.set({ fs.writeFileSync(verifiedFile, "");
verified: true,
})
.where("id", "=", bot.id)
.executeTakeFirst();
} else if (connectionState === "open") { } else if (connectionState === "open") {
console.log("opened connection"); console.log("opened connection");
} else if (connectionState === "close") { } else if (connectionState === "close") {
@ -112,18 +109,13 @@ export default class WhatsappService extends Service {
if (disconnectStatusCode === DisconnectReason.restartRequired) { if (disconnectStatusCode === DisconnectReason.restartRequired) {
console.log("reconnecting after got new login"); console.log("reconnecting after got new login");
const updatedBot = await db await this.createConnection(botID, server, options);
.selectFrom("WhatsappBot")
.selectAll()
.where("id", "=", bot.id)
.executeTakeFirstOrThrow();
await this.createConnection(updatedBot, server, options);
authCompleteCallback?.(); authCompleteCallback?.();
} else if (disconnectStatusCode !== DisconnectReason.loggedOut) { } else if (disconnectStatusCode !== DisconnectReason.loggedOut) {
console.log("reconnecting"); console.log("reconnecting");
await this.sleep(pause); await this.sleep(pause);
pause *= 2; pause *= 2;
this.createConnection(bot, server, options); this.createConnection(botID, server, options);
} }
} }
} }
@ -138,25 +130,29 @@ export default class WhatsappService extends Service {
const upsert = events["messages.upsert"]; const upsert = events["messages.upsert"];
const { messages } = upsert; const { messages } = upsert;
if (messages) { if (messages) {
await this.queueUnreadMessages(bot, messages); await this.queueUnreadMessages(botID, messages);
} }
} }
}); });
this.connections[bot.id] = { socket, msgRetryCounterMap }; this.connections[botID] = { socket, msgRetryCounterMap };
} }
private async updateConnections() { private async updateConnections() {
this.resetConnections(); this.resetConnections();
const bots = await db.selectFrom("WhatsappBot").selectAll().execute();
for await (const bot of bots) { const botIDs = fs.readdirSync("/baileys");
if (bot.verified) { console.log({ botIDs });
for await (const botID of botIDs) {
const directory = this.getBotDirectory(botID);
const verifiedFile = `${directory}/verified`;
if (fs.existsSync(verifiedFile)) {
const { version, isLatest } = await fetchLatestBaileysVersion(); const { version, isLatest } = await fetchLatestBaileysVersion();
console.log(`using WA v${version.join(".")}, isLatest: ${isLatest}`); console.log(`using WA v${version.join(".")}, isLatest: ${isLatest}`);
await this.createConnection(bot, this.server, { await this.createConnection(botID, this.server, {
browser: WhatsappService.browserDescription, browser: WhatsappService.browserDescription,
printQRInTerminal: false, printQRInTerminal: true,
version, version,
}); });
} }
@ -164,7 +160,7 @@ export default class WhatsappService extends Service {
} }
private async queueMessage( private async queueMessage(
bot: WhatsappBot, botID: string,
webMessageInfo: proto.IWebMessageInfo, webMessageInfo: proto.IWebMessageInfo,
) { ) {
const { const {
@ -221,127 +217,67 @@ export default class WhatsappService extends Service {
attachment, attachment,
filename, filename,
mimetype, mimetype,
whatsappBotId: bot.id, whatsappBotId: botID,
botPhoneNumber: bot.phoneNumber,
}; };
// switch to send to bridge-frontend await fetch(`http://localhost:3000/api/whatsapp/${botID}/receive`, {
// workerUtils.addJob("whatsapp-message", receivedMessage, { method: "POST",
// jobKey: id, headers: {
// }); "Content-Type": "application/json",
},
body: JSON.stringify(receivedMessage),
});
} }
} }
} }
private async queueUnreadMessages( private async queueUnreadMessages(
bot: WhatsappBot, botID: string,
messages: proto.IWebMessageInfo[], messages: proto.IWebMessageInfo[],
) { ) {
for await (const message of messages) { for await (const message of messages) {
await this.queueMessage(bot, message); await this.queueMessage(botID, message);
} }
} }
async create( getBot(botID: string): Record<string, any> {
phoneNumber: string, const botDirectory = this.getBotDirectory(botID);
description: string, const qrPath = `${botDirectory}/qr.png`;
email: string, const verifiedFile = `${botDirectory}/verified`;
): Promise<WhatsappBot> { const qr = fs.existsSync(qrPath) ? fs.readFileSync(qrPath, "base64") : null;
const user = await db const verified = fs.existsSync(verifiedFile);
.selectFrom("User")
.selectAll()
.where("email", "=", email)
.executeTakeFirstOrThrow();
const row = await db
.insertInto("WhatsappBot")
.values({
phoneNumber,
description,
userId: user.id,
})
.returningAll()
.executeTakeFirst();
return row; return { qr, verified };
} }
async unverify(bot: WhatsappBot): Promise<WhatsappBot> { async unverify(botID: string): Promise<void> {
const directory = this.getAuthDirectory(bot); const botDirectory = this.getBotDirectory(botID);
fs.rmSync(directory, { recursive: true, force: true }); fs.rmSync(botDirectory, { recursive: true, force: true });
return db
.updateTable("WhatsappBot")
.set({ verified: false })
.where("id", "=", bot.id)
.returningAll()
.executeTakeFirst();
} }
async remove(bot: WhatsappBot): Promise<number> { async register(botID: string, callback: AuthCompleteCallback): Promise<void> {
const directory = this.getAuthDirectory(bot);
fs.rmSync(directory, { recursive: true, force: true });
const result = await db
.deleteFrom("WhatsappBot")
.where("id", "=", bot.id)
.execute();
return result.length;
}
async findAll(): Promise<WhatsappBot[]> {
return db.selectFrom("WhatsappBot").selectAll().execute();
}
async findById(id: string): Promise<WhatsappBot> {
return db
.selectFrom("WhatsappBot")
.selectAll()
.where("id", "=", id)
.executeTakeFirstOrThrow();
}
async findByToken(token: string): Promise<WhatsappBot> {
return db
.selectFrom("WhatsappBot")
.selectAll()
.where("token", "=", token)
.executeTakeFirstOrThrow();
}
async register(
bot: WhatsappBot,
callback: AuthCompleteCallback,
): Promise<void> {
const { version } = await fetchLatestBaileysVersion(); const { version } = await fetchLatestBaileysVersion();
await this.createConnection(bot, this.server, { version }, callback); await this.createConnection(botID, this.server, { version }, callback);
callback();
} }
async send( async send(
bot: WhatsappBot, botID: string,
phoneNumber: string, phoneNumber: string,
message: string, message: string,
): Promise<void> { ): Promise<void> {
const connection = this.connections[bot.id]?.socket; const connection = this.connections[botID]?.socket;
const recipient = `${phoneNumber.replace(/\D+/g, "")}@s.whatsapp.net`; const recipient = `${phoneNumber.replace(/\D+/g, "")}@s.whatsapp.net`;
await connection.sendMessage(recipient, { text: message }); await connection.sendMessage(recipient, { text: message });
} }
async receiveSince(bot: WhatsappBot, lastReceivedDate: Date): Promise<void> {
const connection = this.connections[bot.id]?.socket;
const messages = await connection.messagesReceivedAfter(
lastReceivedDate,
false,
);
for (const message of messages) {
this.queueMessage(bot, message);
}
}
async receive( async receive(
bot: WhatsappBot, botID: string,
_lastReceivedDate: Date, _lastReceivedDate: Date,
): Promise<proto.IWebMessageInfo[]> { ): Promise<proto.IWebMessageInfo[]> {
const connection = this.connections[bot.id]?.socket; const connection = this.connections[botID]?.socket;
const messages = await connection.loadAllUnreadMessages(); const messages = await connection.loadAllUnreadMessages();
return messages; return messages;
} }
} }

View file

@ -1,6 +1,10 @@
{ {
"extends": "ts-config", "extends": "ts-config",
"compilerOptions": { "compilerOptions": {
"module": "esnext",
"target": "esnext",
"esModuleInterop": true,
"moduleResolution": "node",
"outDir": "build/main", "outDir": "build/main",
"rootDir": "src", "rootDir": "src",
"skipLibCheck": true, "skipLibCheck": true,

View file

@ -0,0 +1,14 @@
services:
signal-cli-rest-api:
image: registry.gitlab.com/digiresilience/link/link-stack/signal-cli-rest-api:develop
platform: linux/amd64
environment:
- MODE=json-rpc
volumes:
- signal-cli-rest-api-data:/home/.local/share/signal-cli
ports:
- 8080:8080
volumes:
signal-cli-rest-api-data:
driver: local

View file

@ -4,14 +4,14 @@ const app = process.argv[2];
const command = process.argv[3]; const command = process.argv[3];
const files = { const files = {
all: ["zammad", "postgresql", "bridge", "opensearch", "leafcutter", "link"], all: ["zammad", "postgresql", "bridge", "opensearch", "leafcutter", "link", "signal-cli-rest-api"],
linkDev: ["zammad", "postgresql", "opensearch"], linkDev: ["zammad", "postgresql", "opensearch"],
link: ["zammad", "postgresql", "opensearch", "link"], link: ["zammad", "postgresql", "opensearch", "link"],
leafcutterDev: ["opensearch"], leafcutterDev: ["opensearch"],
leafcutter: ["opensearch", "leafcutter"], leafcutter: ["opensearch", "leafcutter"],
opensearch: ["opensearch"], opensearch: ["opensearch"],
bridgeDev: ["zammad", "postgresql"], bridgeDev: ["zammad", "postgresql", "signal-cli-rest-api"],
bridge: ["zammad", "postgresql", "bridge"], bridge: ["zammad", "postgresql", "bridge", "signal-cli-rest-api"],
zammad: ["zammad", "postgresql", "opensearch"], zammad: ["zammad", "postgresql", "opensearch"],
}; };