Add bridge-whatsapp

This commit is contained in:
Darren Clarke 2024-05-07 14:16:01 +02:00
parent 0d09ad1b7e
commit 0499287555
21 changed files with 3485 additions and 47 deletions

View file

@ -0,0 +1,14 @@
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

@ -0,0 +1,60 @@
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";
import {
GetAllWhatsappBotsRoute,
GetBotsRoute,
SendBotRoute,
ReceiveBotRoute,
RegisterBotRoute,
UnverifyBotRoute,
RefreshBotRoute,
CreateBotRoute,
} from "./routes";
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 };
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);
await server.register(Schmervice);
server.registerService(WhatsappService);
await server.start();
return server;
};
const main = async () => {
await startServer();
};
main().catch((err) => {
console.error(err);
process.exit(1);
});

View file

@ -0,0 +1,217 @@
import * as Hapi from "@hapi/hapi";
import * as Helpers from "./helpers";
import Boom from "@hapi/boom";
export const GetAllWhatsappBotsRoute = Helpers.withDefaults({
method: "get",
path: "/api/whatsapp/bots",
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);
},
},
});
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 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");
},
},
});
interface MessageRequest {
phoneNumber: string;
message: string;
}
export const SendBotRoute = Helpers.noAuth({
method: "post",
path: "/api/whatsapp/bots/{token}/send",
options: {
description: "Send a message",
async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) {
const { token } = request.params;
const { phoneNumber, message } = request.payload as MessageRequest;
const whatsappService = request.services("whatsapp");
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");
},
},
});
export const ReceiveBotRoute = Helpers.withDefaults({
method: "get",
path: "/api/whatsapp/bots/{token}/receive",
options: {
description: "Receive messages",
async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) {
const { token } = request.params;
const whatsappService = request.services("whatsapp");
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");
},
},
});
export const RegisterBotRoute = Helpers.withDefaults({
method: "get",
path: "/api/whatsapp/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 bot = await whatsappService.findById(id);
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");
},
},
});
export const UnverifyBotRoute = Helpers.withDefaults({
method: "post",
path: "/api/whatsapp/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 bot = await whatsappService.findById(id);
if (bot) {
return whatsappService.unverify(bot);
}
throw Boom.notFound("Bot not found");
},
},
});
export const RefreshBotRoute = Helpers.withDefaults({
method: "get",
path: "/api/whatsapp/bots/{id}/refresh",
options: {
description: "Refresh messages",
async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) {
const { id } = request.params;
const whatsappService = request.services("whatsapp");
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");
},
},
});

View file

@ -0,0 +1,347 @@
import { Server } from "@hapi/hapi";
import { Service } from "@hapipal/schmervice";
import { db, WhatsappBot } from "bridge-common";
import makeWASocket, {
DisconnectReason,
proto,
downloadContentFromMessage,
MediaType,
fetchLatestBaileysVersion,
isJidBroadcast,
isJidStatusBroadcast,
useMultiFileAuthState,
} from "@whiskeysockets/baileys";
import fs from "fs";
export type AuthCompleteCallback = (error?: string) => void;
export default class WhatsappService extends Service {
connections: { [key: string]: any } = {};
loginConnections: { [key: string]: any } = {};
static browserDescription: [string, string, string] = [
"Bridge",
"Chrome",
"2.0",
];
constructor(server: Server, options: never) {
super(server, options);
}
getAuthDirectory(bot: WhatsappBot): string {
return `/baileys/${bot.id}`;
}
async initialize(): Promise<void> {
this.updateConnections();
}
async teardown(): Promise<void> {
this.resetConnections();
}
private async sleep(ms: number): Promise<void> {
console.log(`pausing ${ms}`);
return new Promise((resolve) => setTimeout(resolve, ms));
}
private async resetConnections() {
for (const connection of Object.values(this.connections)) {
try {
connection.end(null);
} catch (error) {
console.log(error);
}
}
this.connections = {};
}
private async createConnection(
bot: WhatsappBot,
server: Server,
options: any,
authCompleteCallback?: any,
) {
const directory = this.getAuthDirectory(bot);
const { state, saveCreds } = await useMultiFileAuthState(directory);
const msgRetryCounterMap: any = {};
const socket = makeWASocket({
...options,
auth: state,
msgRetryCounterMap,
shouldIgnoreJid: (jid) =>
isJidBroadcast(jid) || isJidStatusBroadcast(jid),
});
let pause = 5000;
socket.ev.process(async (events) => {
if (events["connection.update"]) {
const update = events["connection.update"];
const {
connection: connectionState,
lastDisconnect,
qr,
isNewLogin,
} = update;
if (qr) {
console.log("got qr code");
await db
.updateTable("WhatsappBot")
.set({
qrCode: qr,
verified: false,
})
.where("id", "=", bot.id)
.executeTakeFirst();
} else if (isNewLogin) {
console.log("got new login");
await db
.updateTable("WhatsappBot")
.set({
verified: true,
})
.where("id", "=", bot.id)
.executeTakeFirst();
} else if (connectionState === "open") {
console.log("opened connection");
} else if (connectionState === "close") {
console.log("connection closed due to ", lastDisconnect.error);
const disconnectStatusCode = (lastDisconnect?.error as any)?.output
?.statusCode;
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);
authCompleteCallback?.();
} else if (disconnectStatusCode !== DisconnectReason.loggedOut) {
console.log("reconnecting");
await this.sleep(pause);
pause *= 2;
this.createConnection(bot, server, options);
}
}
}
if (events["creds.update"]) {
console.log("creds update");
await saveCreds();
}
if (events["messages.upsert"]) {
console.log("messages upsert");
const upsert = events["messages.upsert"];
const { messages } = upsert;
if (messages) {
await this.queueUnreadMessages(bot, messages);
}
}
});
this.connections[bot.id] = { 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 { version, isLatest } = await fetchLatestBaileysVersion();
console.log(`using WA v${version.join(".")}, isLatest: ${isLatest}`);
await this.createConnection(bot, this.server, {
browser: WhatsappService.browserDescription,
printQRInTerminal: false,
version,
});
}
}
}
private async queueMessage(
bot: WhatsappBot,
webMessageInfo: proto.IWebMessageInfo,
) {
const {
key: { id, fromMe, remoteJid },
message,
messageTimestamp,
} = webMessageInfo;
if (!fromMe && message && remoteJid !== "status@broadcast") {
const { audioMessage, documentMessage, imageMessage, videoMessage } =
message;
const isMediaMessage =
audioMessage || documentMessage || imageMessage || videoMessage;
const messageContent = Object.values(message)[0];
let messageType: MediaType;
let attachment: string;
let filename: string;
let mimetype: string;
if (isMediaMessage) {
if (audioMessage) {
messageType = "audio";
filename = id + "." + audioMessage.mimetype.split("/").pop();
mimetype = audioMessage.mimetype;
} else if (documentMessage) {
messageType = "document";
filename = documentMessage.fileName;
mimetype = documentMessage.mimetype;
} else if (imageMessage) {
messageType = "image";
filename = id + "." + imageMessage.mimetype.split("/").pop();
mimetype = imageMessage.mimetype;
} else if (videoMessage) {
messageType = "video";
filename = id + "." + videoMessage.mimetype.split("/").pop();
mimetype = videoMessage.mimetype;
}
const stream = await downloadContentFromMessage(
messageContent,
messageType,
);
let buffer = Buffer.from([]);
for await (const chunk of stream) {
buffer = Buffer.concat([buffer, chunk]);
}
attachment = buffer.toString("base64");
}
if (messageContent || attachment) {
const receivedMessage = {
waMessageId: id,
waMessage: JSON.stringify(webMessageInfo),
waTimestamp: new Date((messageTimestamp as number) * 1000),
attachment,
filename,
mimetype,
whatsappBotId: bot.id,
botPhoneNumber: bot.phoneNumber,
};
// switch to send to bridge-frontend
// workerUtils.addJob("whatsapp-message", receivedMessage, {
// jobKey: id,
// });
}
}
}
private async queueUnreadMessages(
bot: WhatsappBot,
messages: proto.IWebMessageInfo[],
) {
for await (const message of messages) {
await this.queueMessage(bot, 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();
return row;
}
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 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> {
const { version } = await fetchLatestBaileysVersion();
await this.createConnection(bot, this.server, { version }, callback);
}
async send(
bot: WhatsappBot,
phoneNumber: string,
message: string,
): Promise<void> {
const connection = this.connections[bot.id]?.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,
_lastReceivedDate: Date,
): Promise<proto.IWebMessageInfo[]> {
const connection = this.connections[bot.id]?.socket;
const messages = await connection.loadAllUnreadMessages();
return messages;
}
}

View file

@ -0,0 +1,8 @@
import type WhatsappService from "./service";
declare module "@hapipal/schmervice" {
interface SchmerviceDecorator {
(namespace: "whatsapp"): WhatsappService;
}
type ServiceFunctionalInterface = { name: string };
}