From f6dc60eb083eaec091dcf7b277329fac69897d0d Mon Sep 17 00:00:00 2001 From: Darren Clarke Date: Wed, 15 May 2024 14:39:33 +0200 Subject: [PATCH] Bridge whatsapp simplification --- apps/bridge-whatsapp/package.json | 7 +- apps/bridge-whatsapp/src/helpers.ts | 14 -- apps/bridge-whatsapp/src/index.ts | 35 +---- apps/bridge-whatsapp/src/routes.ts | 209 ++++++------------------- apps/bridge-whatsapp/src/service.ts | 186 ++++++++-------------- apps/bridge-whatsapp/tsconfig.json | 4 + docker/compose/signal-cli-rest-api.yml | 14 ++ docker/scripts/docker.js | 6 +- 8 files changed, 142 insertions(+), 333 deletions(-) delete mode 100644 apps/bridge-whatsapp/src/helpers.ts create mode 100644 docker/compose/signal-cli-rest-api.yml diff --git a/apps/bridge-whatsapp/package.json b/apps/bridge-whatsapp/package.json index ffdadff..fb97b35 100644 --- a/apps/bridge-whatsapp/package.json +++ b/apps/bridge-whatsapp/package.json @@ -1,7 +1,6 @@ { "name": "bridge-whatsapp", "version": "0.3.0", - "type": "module", "main": "build/main/index.js", "author": "Darren Clarke ", "license": "AGPL-3.0-or-later", @@ -11,12 +10,8 @@ "@hapi/boom": "^10.0.1", "@hapipal/schmervice": "^3.0.0", "@hapipal/toys": "^4.0.0", - "@types/hapi-auth-bearer-token": "^6.1.8", "@whiskeysockets/baileys": "^6.7.2", - "bridge-common": "*", - "hapi-auth-bearer-token": "^8.0.0", - "hapi-pino": "^12.1.0", - "kysely": "^0.26.1" + "hapi-pino": "^12.1.0" }, "devDependencies": { "@types/node": "*", diff --git a/apps/bridge-whatsapp/src/helpers.ts b/apps/bridge-whatsapp/src/helpers.ts deleted file mode 100644 index 3a123f0..0000000 --- a/apps/bridge-whatsapp/src/helpers.ts +++ /dev/null @@ -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, - }, -}); diff --git a/apps/bridge-whatsapp/src/index.ts b/apps/bridge-whatsapp/src/index.ts index c60ebad..23d9b7b 100644 --- a/apps/bridge-whatsapp/src/index.ts +++ b/apps/bridge-whatsapp/src/index.ts @@ -1,46 +1,25 @@ import * as Hapi from "@hapi/hapi"; -import * as AuthBearer from "hapi-auth-bearer-token"; import hapiPino from "hapi-pino"; import Schmervice from "@hapipal/schmervice"; import WhatsappService from "./service.js"; import { - GetAllWhatsappBotsRoute, - GetBotsRoute, - SendBotRoute, - ReceiveBotRoute, RegisterBotRoute, UnverifyBotRoute, - RefreshBotRoute, - CreateBotRoute, + GetBotRoute, + SendMessageRoute, + ReceiveMessageRoute, } from "./routes.js"; const server = Hapi.server({ host: "localhost", port: 5000 }); const startServer = async () => { - await server.register({ - 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 }; + await server.register({ plugin: hapiPino }); - return { isValid, credentials }; - }, - }); - - server.route(GetAllWhatsappBotsRoute); - server.route(GetBotsRoute); - server.route(SendBotRoute); - server.route(ReceiveBotRoute); server.route(RegisterBotRoute); server.route(UnverifyBotRoute); - server.route(RefreshBotRoute); - server.route(CreateBotRoute); + server.route(GetBotRoute); + server.route(SendMessageRoute); + server.route(ReceiveMessageRoute); await server.register(Schmervice); server.registerService(WhatsappService); diff --git a/apps/bridge-whatsapp/src/routes.ts b/apps/bridge-whatsapp/src/routes.ts index bac7a4e..8351241 100644 --- a/apps/bridge-whatsapp/src/routes.ts +++ b/apps/bridge-whatsapp/src/routes.ts @@ -1,217 +1,112 @@ import * as Hapi from "@hapi/hapi"; -import Boom from "@hapi/boom"; -import * as Helpers from "./helpers.js"; +import Toys from "@hapipal/toys"; +import WhatsappService from "./service"; -export const GetAllWhatsappBotsRoute = Helpers.withDefaults({ - method: "get", - path: "/api/whatsapp/bots", +const withDefaults = Toys.withRouteDefaults({ options: { - description: "Get all bots", - 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); - }, + cors: true, }, }); -export const GetBotsRoute = Helpers.noAuth({ - method: "get", - 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 getService = (request: Hapi.Request): WhatsappService => { + const { whatsappService } = request.services(); - const bot = await whatsappService.findByToken(token); - - if (bot) { - // @ts-ignore - request.logger.info({ bot }, "Retrieved bot(s) at %s", new Date()); - - return bot; - } - - throw Boom.notFound("Bot not found"); - }, - }, -}); + return whatsappService as WhatsappService; +}; interface MessageRequest { phoneNumber: string; message: string; } -export const SendBotRoute = Helpers.noAuth({ +export const SendMessageRoute = withDefaults({ method: "post", - path: "/api/whatsapp/bots/{token}/send", + path: "/api/bots/{id}/send", options: { description: "Send a message", async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) { - const { token } = request.params; + const { id } = request.params; 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 - .response({ - result: { - recipient: phoneNumber, - timestamp: new Date().toISOString(), - source: bot.phoneNumber, - }, - }) - .code(200); // temp - } - - throw Boom.notFound("Bot not found"); + return _h + .response({ + result: { + recipient: phoneNumber, + timestamp: new Date().toISOString(), + source: id, + }, + }) + .code(200); }, }, }); -export const ReceiveBotRoute = Helpers.withDefaults({ +export const ReceiveMessageRoute = withDefaults({ method: "get", - path: "/api/whatsapp/bots/{token}/receive", + path: "/api/bots/{id}/receive", options: { description: "Receive messages", async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) { - const { token } = request.params; - const whatsappService = request.services("whatsapp"); + const { id } = request.params; + const whatsappService = getService(request); + const date = new Date(); + const twoDaysAgo = new Date(date.getTime()); + twoDaysAgo.setDate(date.getDate() - 2); + request.logger.info({ id }, "Received messages at %s", new Date()); - 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 twoDaysAgo = new Date(date.getTime()); - twoDaysAgo.setDate(date.getDate() - 2); - return whatsappService.receive(bot, twoDaysAgo); - } - - throw Boom.notFound("Bot not found"); + return whatsappService.receive(id, twoDaysAgo); }, }, }); -export const RegisterBotRoute = Helpers.withDefaults({ - method: "get", - path: "/api/whatsapp/bots/{id}/register", +export const RegisterBotRoute = withDefaults({ + method: "post", + path: "/api/bots/{id}/register", options: { description: "Register a bot", async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) { 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 (error) { + return _h.response(error).code(500); + } + request.logger.info({ id }, "Register bot at %s", new Date()); - if (bot) { - await whatsappService.register(bot, (error: string) => { - if (error) { - return _h.response(error).code(500); - } - - // @ts-ignore - request.logger.info({ bot }, "Register bot at %s", new Date()); - return _h.response().code(200); - }); - } - - throw Boom.notFound("Bot not found"); + return _h.response().code(200); + }); }, }, }); -export const UnverifyBotRoute = Helpers.withDefaults({ +export const UnverifyBotRoute = withDefaults({ method: "post", - path: "/api/whatsapp/bots/{id}/unverify", + path: "/api/bots/{id}/unverify", options: { description: "Unverify bot", async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) { const { id } = request.params; - const whatsappService = request.services("whatsapp"); + const whatsappService = getService(request); - const bot = await whatsappService.findById(id); - - if (bot) { - return whatsappService.unverify(bot); - } - - throw Boom.notFound("Bot not found"); + return whatsappService.unverify(id); }, }, }); -export const RefreshBotRoute = Helpers.withDefaults({ +export const GetBotRoute = withDefaults({ method: "get", - path: "/api/whatsapp/bots/{id}/refresh", + path: "/api/bots/{id}", options: { - description: "Refresh messages", + description: "Get bot info", async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) { const { id } = request.params; - const whatsappService = request.services("whatsapp"); + const whatsappService = getService(request); - const bot = await whatsappService.findById(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"); + return whatsappService.getBot(id); }, }, }); diff --git a/apps/bridge-whatsapp/src/service.ts b/apps/bridge-whatsapp/src/service.ts index 40bf968..8b7fc5f 100644 --- a/apps/bridge-whatsapp/src/service.ts +++ b/apps/bridge-whatsapp/src/service.ts @@ -1,6 +1,5 @@ import { Server } from "@hapi/hapi"; import { Service } from "@hapipal/schmervice"; -import { db, WhatsappBot } from "bridge-common"; import makeWASocket, { DisconnectReason, proto, @@ -29,8 +28,12 @@ export default class WhatsappService extends Service { super(server, options); } - getAuthDirectory(bot: WhatsappBot): string { - return `/baileys/${bot.id}`; + getBotDirectory(id: string): string { + return `/baileys/${id}`; + } + + getAuthDirectory(id: string): string { + return `${this.getBotDirectory(id)}/auth`; } async initialize(): Promise { @@ -42,7 +45,6 @@ export default class WhatsappService extends Service { } private async sleep(ms: number): Promise { - console.log(`pausing ${ms}`); return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -58,13 +60,13 @@ export default class WhatsappService extends Service { } private async createConnection( - bot: WhatsappBot, + botID: string, server: Server, options: any, authCompleteCallback?: any, ) { - const directory = this.getAuthDirectory(bot); - const { state, saveCreds } = await useMultiFileAuthState(directory); + const authDirectory = this.getAuthDirectory(botID); + const { state, saveCreds } = await useMultiFileAuthState(authDirectory); const msgRetryCounterMap: any = {}; const socket = makeWASocket({ ...options, @@ -86,23 +88,18 @@ export default class WhatsappService extends Service { } = update; if (qr) { console.log("got qr code"); - await db - .updateTable("WhatsappBot") - .set({ - qrCode: qr, - verified: false, - }) - .where("id", "=", bot.id) - .executeTakeFirst(); + const botDirectory = this.getBotDirectory(botID); + const qrPath = `${botDirectory}/qr.png`; + fs.writeFileSync(qrPath, qr, "base64"); + const verifiedFile = `${botDirectory}/verified`; + if (fs.existsSync(verifiedFile)) { + fs.rmSync(verifiedFile); + } } else if (isNewLogin) { console.log("got new login"); - await db - .updateTable("WhatsappBot") - .set({ - verified: true, - }) - .where("id", "=", bot.id) - .executeTakeFirst(); + const botDirectory = this.getBotDirectory(botID); + const verifiedFile = `${botDirectory}/verified`; + fs.writeFileSync(verifiedFile, ""); } else if (connectionState === "open") { console.log("opened connection"); } else if (connectionState === "close") { @@ -112,18 +109,13 @@ export default class WhatsappService extends Service { if (disconnectStatusCode === DisconnectReason.restartRequired) { console.log("reconnecting after got new login"); - const updatedBot = await db - .selectFrom("WhatsappBot") - .selectAll() - .where("id", "=", bot.id) - .executeTakeFirstOrThrow(); - await this.createConnection(updatedBot, server, options); + await this.createConnection(botID, server, options); authCompleteCallback?.(); } else if (disconnectStatusCode !== DisconnectReason.loggedOut) { console.log("reconnecting"); await this.sleep(pause); 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 { messages } = upsert; 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() { this.resetConnections(); - const bots = await db.selectFrom("WhatsappBot").selectAll().execute(); - for await (const bot of bots) { - if (bot.verified) { + + const botIDs = fs.readdirSync("/baileys"); + 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(); console.log(`using WA v${version.join(".")}, isLatest: ${isLatest}`); - await this.createConnection(bot, this.server, { + await this.createConnection(botID, this.server, { browser: WhatsappService.browserDescription, - printQRInTerminal: false, + printQRInTerminal: true, version, }); } @@ -164,7 +160,7 @@ export default class WhatsappService extends Service { } private async queueMessage( - bot: WhatsappBot, + botID: string, webMessageInfo: proto.IWebMessageInfo, ) { const { @@ -221,127 +217,67 @@ export default class WhatsappService extends Service { attachment, filename, mimetype, - whatsappBotId: bot.id, - botPhoneNumber: bot.phoneNumber, + whatsappBotId: botID, }; - // switch to send to bridge-frontend - // workerUtils.addJob("whatsapp-message", receivedMessage, { - // jobKey: id, - // }); + await fetch(`http://localhost:3000/api/whatsapp/${botID}/receive`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(receivedMessage), + }); } } } private async queueUnreadMessages( - bot: WhatsappBot, + botID: string, messages: proto.IWebMessageInfo[], ) { for await (const message of messages) { - await this.queueMessage(bot, message); + await this.queueMessage(botID, message); } } - async create( - phoneNumber: string, - description: string, - email: string, - ): Promise { - const user = await db - .selectFrom("User") - .selectAll() - .where("email", "=", email) - .executeTakeFirstOrThrow(); - const row = await db - .insertInto("WhatsappBot") - .values({ - phoneNumber, - description, - userId: user.id, - }) - .returningAll() - .executeTakeFirst(); + getBot(botID: string): Record { + const botDirectory = this.getBotDirectory(botID); + const qrPath = `${botDirectory}/qr.png`; + const verifiedFile = `${botDirectory}/verified`; + const qr = fs.existsSync(qrPath) ? fs.readFileSync(qrPath, "base64") : null; + const verified = fs.existsSync(verifiedFile); - return row; + return { qr, verified }; } - async unverify(bot: WhatsappBot): Promise { - const directory = this.getAuthDirectory(bot); - fs.rmSync(directory, { recursive: true, force: true }); - return db - .updateTable("WhatsappBot") - .set({ verified: false }) - .where("id", "=", bot.id) - .returningAll() - .executeTakeFirst(); + async unverify(botID: string): Promise { + const botDirectory = this.getBotDirectory(botID); + fs.rmSync(botDirectory, { recursive: true, force: true }); } - async remove(bot: WhatsappBot): Promise { - 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 { - return db.selectFrom("WhatsappBot").selectAll().execute(); - } - - async findById(id: string): Promise { - return db - .selectFrom("WhatsappBot") - .selectAll() - .where("id", "=", id) - .executeTakeFirstOrThrow(); - } - - async findByToken(token: string): Promise { - return db - .selectFrom("WhatsappBot") - .selectAll() - .where("token", "=", token) - .executeTakeFirstOrThrow(); - } - - async register( - bot: WhatsappBot, - callback: AuthCompleteCallback, - ): Promise { + async register(botID: string, callback: AuthCompleteCallback): Promise { const { version } = await fetchLatestBaileysVersion(); - await this.createConnection(bot, this.server, { version }, callback); + await this.createConnection(botID, this.server, { version }, callback); + callback(); } async send( - bot: WhatsappBot, + botID: string, phoneNumber: string, message: string, ): Promise { - const connection = this.connections[bot.id]?.socket; + const connection = this.connections[botID]?.socket; const recipient = `${phoneNumber.replace(/\D+/g, "")}@s.whatsapp.net`; await connection.sendMessage(recipient, { text: message }); } - async receiveSince(bot: WhatsappBot, lastReceivedDate: Date): Promise { - 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( - bot: WhatsappBot, + botID: string, _lastReceivedDate: Date, ): Promise { - const connection = this.connections[bot.id]?.socket; + const connection = this.connections[botID]?.socket; const messages = await connection.loadAllUnreadMessages(); + return messages; } } diff --git a/apps/bridge-whatsapp/tsconfig.json b/apps/bridge-whatsapp/tsconfig.json index dacf432..b828499 100644 --- a/apps/bridge-whatsapp/tsconfig.json +++ b/apps/bridge-whatsapp/tsconfig.json @@ -1,6 +1,10 @@ { "extends": "ts-config", "compilerOptions": { + "module": "esnext", + "target": "esnext", + "esModuleInterop": true, + "moduleResolution": "node", "outDir": "build/main", "rootDir": "src", "skipLibCheck": true, diff --git a/docker/compose/signal-cli-rest-api.yml b/docker/compose/signal-cli-rest-api.yml new file mode 100644 index 0000000..01afc2c --- /dev/null +++ b/docker/compose/signal-cli-rest-api.yml @@ -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 diff --git a/docker/scripts/docker.js b/docker/scripts/docker.js index 1e6eed7..d593b90 100644 --- a/docker/scripts/docker.js +++ b/docker/scripts/docker.js @@ -4,14 +4,14 @@ const app = process.argv[2]; const command = process.argv[3]; const files = { - all: ["zammad", "postgresql", "bridge", "opensearch", "leafcutter", "link"], + all: ["zammad", "postgresql", "bridge", "opensearch", "leafcutter", "link", "signal-cli-rest-api"], linkDev: ["zammad", "postgresql", "opensearch"], link: ["zammad", "postgresql", "opensearch", "link"], leafcutterDev: ["opensearch"], leafcutter: ["opensearch", "leafcutter"], opensearch: ["opensearch"], - bridgeDev: ["zammad", "postgresql"], - bridge: ["zammad", "postgresql", "bridge"], + bridgeDev: ["zammad", "postgresql", "signal-cli-rest-api"], + bridge: ["zammad", "postgresql", "bridge", "signal-cli-rest-api"], zammad: ["zammad", "postgresql", "opensearch"], };