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,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 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);

View file

@ -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);
},
},
});

View file

@ -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<void> {
@ -42,7 +45,6 @@ export default class WhatsappService extends Service {
}
private async sleep(ms: number): Promise<void> {
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<WhatsappBot> {
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<string, any> {
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<WhatsappBot> {
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<void> {
const botDirectory = this.getBotDirectory(botID);
fs.rmSync(botDirectory, { recursive: true, force: true });
}
async remove(bot: WhatsappBot): Promise<number> {
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> {
async register(botID: string, callback: AuthCompleteCallback): Promise<void> {
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<void> {
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<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(
bot: WhatsappBot,
botID: string,
_lastReceivedDate: Date,
): Promise<proto.IWebMessageInfo[]> {
const connection = this.connections[bot.id]?.socket;
const connection = this.connections[botID]?.socket;
const messages = await connection.loadAllUnreadMessages();
return messages;
}
}