Switch to Hono

This commit is contained in:
Darren Clarke 2026-02-15 08:29:10 +01:00
parent 9601e179bc
commit 9f0e1f8b61
10 changed files with 152 additions and 331 deletions

View file

@ -7,10 +7,8 @@
"dependencies": { "dependencies": {
"@deltachat/jsonrpc-client": "^1.151.1", "@deltachat/jsonrpc-client": "^1.151.1",
"@deltachat/stdio-rpc-server": "^1.151.1", "@deltachat/stdio-rpc-server": "^1.151.1",
"@hapi/hapi": "^21.4.3", "@hono/node-server": "^1.13.8",
"@hapipal/schmervice": "^3.0.0", "hono": "^4.7.4",
"@hapipal/toys": "^4.0.0",
"hapi-pino": "^13.0.0",
"pino": "^9.6.0", "pino": "^9.6.0",
"pino-pretty": "^13.0.0" "pino-pretty": "^13.0.0"
}, },

View file

@ -1,39 +1,29 @@
import * as Hapi from "@hapi/hapi"; import { serve } from "@hono/node-server";
import hapiPino from "hapi-pino";
import Schmervice from "@hapipal/schmervice";
import DeltaChatService from "./service.ts"; import DeltaChatService from "./service.ts";
import { import { createRoutes } from "./routes.ts";
ConfigureBotRoute,
GetBotRoute,
SendMessageRoute,
UnconfigureBotRoute,
HealthRoute,
} from "./routes.ts";
import { createLogger } from "./lib/logger"; import { createLogger } from "./lib/logger";
const logger = createLogger("bridge-deltachat-index"); const logger = createLogger("bridge-deltachat-index");
const server = Hapi.server({ port: 5001 });
const startServer = async () => {
await server.register({ plugin: hapiPino });
server.route(ConfigureBotRoute);
server.route(GetBotRoute);
server.route(SendMessageRoute);
server.route(UnconfigureBotRoute);
server.route(HealthRoute);
await server.register(Schmervice);
server.registerService(DeltaChatService);
await server.start();
return server;
};
const main = async () => { const main = async () => {
await startServer(); const service = new DeltaChatService();
await service.initialize();
const app = createRoutes(service);
const port = parseInt(process.env.PORT || "5001", 10);
serve({ fetch: app.fetch, port }, (info) => {
logger.info({ port: info.port }, "bridge-deltachat listening");
});
const shutdown = async () => {
logger.info("Shutting down...");
await service.teardown();
process.exit(0);
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
}; };
main().catch((err) => { main().catch((err) => {

View file

@ -1,111 +1,54 @@
import * as Hapi from "@hapi/hapi"; import { Hono } from "hono";
import Toys from "@hapipal/toys"; import type DeltaChatService from "./service.ts";
import DeltaChatService from "./service.ts"; import { createLogger } from "./lib/logger";
const withDefaults = Toys.withRouteDefaults({ const logger = createLogger("bridge-deltachat-routes");
options: {
cors: true,
},
});
const getService = (request: Hapi.Request): DeltaChatService => { export function createRoutes(service: DeltaChatService): Hono {
const { deltaChatService } = request.services(); const app = new Hono();
return deltaChatService as DeltaChatService; app.post("/api/bots/:id/configure", async (c) => {
}; const id = c.req.param("id");
const { email, password } = await c.req.json<{ email: string; password: string }>();
interface ConfigureRequest {
email: string;
password: string;
}
interface SendMessageRequest {
email: string;
message: string;
attachments?: Array<{ data: string; filename: string; mime_type: string }>;
}
export const ConfigureBotRoute = withDefaults({
method: "post",
path: "/api/bots/{id}/configure",
options: {
description: "Configure a bot with email credentials",
async handler(request: Hapi.Request, h: Hapi.ResponseToolkit) {
const { id } = request.params;
const { email, password } = request.payload as ConfigureRequest;
const service = getService(request);
try { try {
const result = await service.configure(id, email, password); const result = await service.configure(id, email, password);
request.logger.info({ id, email }, "Bot configured at %s", new Date().toISOString()); logger.info({ id, email }, "Bot configured");
return h.response(result).code(200); return c.json(result);
} catch (err: any) { } catch (err: any) {
request.logger.error({ id, error: err.message }, "Failed to configure bot"); logger.error({ id, error: err.message }, "Failed to configure bot");
return h.response({ error: err.message }).code(500); return c.json({ error: err.message }, 500);
} }
}, });
},
});
export const GetBotRoute = withDefaults({ app.get("/api/bots/:id", async (c) => {
method: "get", const id = c.req.param("id");
path: "/api/bots/{id}", return c.json(await service.getBot(id));
options: { });
description: "Get bot status",
async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) {
const { id } = request.params;
const service = getService(request);
return service.getBot(id);
},
},
});
export const SendMessageRoute = withDefaults({ app.post("/api/bots/:id/send", async (c) => {
method: "post", const id = c.req.param("id");
path: "/api/bots/{id}/send", const { email, message, attachments } = await c.req.json<{
options: { email: string;
description: "Send a message", message: string;
async handler(request: Hapi.Request, h: Hapi.ResponseToolkit) { attachments?: Array<{ data: string; filename: string; mime_type: string }>;
const { id } = request.params; }>();
const { email, message, attachments } = request.payload as SendMessageRequest;
const service = getService(request);
const result = await service.send(id, email, message, attachments); const result = await service.send(id, email, message, attachments);
request.logger.info( logger.info({ id, attachmentCount: attachments?.length || 0 }, "Sent message");
{ id, attachmentCount: attachments?.length || 0 }, return c.json({ result });
"Sent a message at %s", });
new Date().toISOString(),
);
return h.response({ result }).code(200);
},
},
});
export const UnconfigureBotRoute = withDefaults({
method: "post",
path: "/api/bots/{id}/unconfigure",
options: {
description: "Unconfigure and remove a bot",
async handler(request: Hapi.Request, h: Hapi.ResponseToolkit) {
const { id } = request.params;
const service = getService(request);
app.post("/api/bots/:id/unconfigure", async (c) => {
const id = c.req.param("id");
await service.unconfigure(id); await service.unconfigure(id);
request.logger.info({ id }, "Bot unconfigured at %s", new Date().toISOString()); logger.info({ id }, "Bot unconfigured");
return c.body(null, 200);
});
return h.response().code(200); app.get("/api/health", (c) => {
}, return c.json({ status: "ok" });
}, });
});
export const HealthRoute = withDefaults({ return app;
method: "get", }
path: "/api/health",
options: {
description: "Health check",
async handler(_request: Hapi.Request, h: Hapi.ResponseToolkit) {
return h.response({ status: "ok" }).code(200);
},
},
});

View file

@ -1,5 +1,3 @@
import { Server } from "@hapi/hapi";
import { Service } from "@hapipal/schmervice";
import { startDeltaChat, DeltaChat } from "@deltachat/stdio-rpc-server"; import { startDeltaChat, DeltaChat } from "@deltachat/stdio-rpc-server";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
@ -17,14 +15,13 @@ interface BotMapping {
[botId: string]: number; [botId: string]: number;
} }
export default class DeltaChatService extends Service { export default class DeltaChatService {
private dc: DeltaChat | null = null; private dc: DeltaChat | null = null;
private botMapping: BotMapping = {}; private botMapping: BotMapping = {};
private dataDir: string; private dataDir: string;
private mappingFile: string; private mappingFile: string;
constructor(server: Server, options: never) { constructor() {
super(server, options);
this.dataDir = process.env.DELTACHAT_DATA_DIR || "/home/node/deltachat-data"; this.dataDir = process.env.DELTACHAT_DATA_DIR || "/home/node/deltachat-data";
this.mappingFile = path.join(this.dataDir, "bot-mapping.json"); this.mappingFile = path.join(this.dataDir, "bot-mapping.json");
} }

View file

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

View file

@ -6,11 +6,9 @@
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"@adiwajshing/keyed-db": "0.2.4", "@adiwajshing/keyed-db": "0.2.4",
"@hapi/hapi": "^21.4.3", "@hono/node-server": "^1.13.8",
"@hapipal/schmervice": "^3.0.0",
"@hapipal/toys": "^4.0.0",
"@whiskeysockets/baileys": "6.7.21", "@whiskeysockets/baileys": "6.7.21",
"hapi-pino": "^13.0.0", "hono": "^4.7.4",
"link-preview-js": "^3.1.0", "link-preview-js": "^3.1.0",
"pino": "^9.6.0", "pino": "^9.6.0",
"pino-pretty": "^13.0.0" "pino-pretty": "^13.0.0"

View file

@ -1,39 +1,29 @@
import * as Hapi from "@hapi/hapi"; import { serve } from "@hono/node-server";
import hapiPino from "hapi-pino";
import Schmervice from "@hapipal/schmervice";
import WhatsappService from "./service.ts"; import WhatsappService from "./service.ts";
import { import { createRoutes } from "./routes.ts";
RegisterBotRoute,
UnverifyBotRoute,
GetBotRoute,
SendMessageRoute,
ReceiveMessageRoute,
} from "./routes.ts";
import { createLogger } from "./lib/logger"; import { createLogger } from "./lib/logger";
const logger = createLogger("bridge-whatsapp-index"); const logger = createLogger("bridge-whatsapp-index");
const server = Hapi.server({ port: 5000 });
const startServer = async () => {
await server.register({ plugin: hapiPino });
server.route(RegisterBotRoute);
server.route(UnverifyBotRoute);
server.route(GetBotRoute);
server.route(SendMessageRoute);
server.route(ReceiveMessageRoute);
await server.register(Schmervice);
server.registerService(WhatsappService);
await server.start();
return server;
};
const main = async () => { const main = async () => {
await startServer(); const service = new WhatsappService();
await service.initialize();
const app = createRoutes(service);
const port = parseInt(process.env.PORT || "5000", 10);
serve({ fetch: app.fetch, port }, (info) => {
logger.info({ port: info.port }, "bridge-whatsapp listening");
});
const shutdown = async () => {
logger.info("Shutting down...");
await service.teardown();
process.exit(0);
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
}; };
main().catch((err) => { main().catch((err) => {

View file

@ -1,125 +1,58 @@
import * as Hapi from "@hapi/hapi"; import { Hono } from "hono";
import Toys from "@hapipal/toys"; import type WhatsappService from "./service.ts";
import WhatsappService from "./service.ts"; import { createLogger } from "./lib/logger";
const withDefaults = Toys.withRouteDefaults({ const logger = createLogger("bridge-whatsapp-routes");
options: {
cors: true,
},
});
const getService = (request: Hapi.Request): WhatsappService => { export function createRoutes(service: WhatsappService): Hono {
const { whatsappService } = request.services(); const app = new Hono();
return whatsappService as WhatsappService; app.post("/api/bots/:id/send", async (c) => {
}; const id = c.req.param("id");
const { phoneNumber, message, attachments } = await c.req.json<{
interface MessageRequest {
phoneNumber: string; phoneNumber: string;
message: string; message: string;
attachments?: Array<{ data: string; filename: string; mime_type: string }>; attachments?: Array<{ data: string; filename: string; mime_type: string }>;
} }>();
export const SendMessageRoute = withDefaults({ await service.send(id, phoneNumber, message, attachments);
method: "post", logger.info({ id, attachmentCount: attachments?.length || 0 }, "Sent message");
path: "/api/bots/{id}/send",
options: {
description: "Send a message",
async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) {
const { id } = request.params;
const { phoneNumber, message, attachments } = request.payload as MessageRequest;
const whatsappService = getService(request);
await whatsappService.send(id, phoneNumber, message as string, attachments);
request.logger.info(
{
id,
attachmentCount: attachments?.length || 0,
},
"Sent a message at %s",
new Date().toISOString(),
);
return _h return c.json({
.response({
result: { result: {
recipient: phoneNumber, recipient: phoneNumber,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
source: id, source: id,
}, },
}) });
.code(200); });
},
},
});
export const ReceiveMessageRoute = withDefaults({ app.get("/api/bots/:id/receive", async (c) => {
method: "get", const id = c.req.param("id");
path: "/api/bots/{id}/receive",
options: {
description: "Receive messages",
async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) {
const { id } = request.params;
const whatsappService = getService(request);
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);
request.logger.info({ id }, "Received messages at %s", new Date().toISOString());
return whatsappService.receive(id, twoDaysAgo); const messages = await service.receive(id, twoDaysAgo);
}, return c.json(messages);
},
});
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 = getService(request);
await whatsappService.register(id);
/*
, (error: string) => {
if (error) {
return _h.response(error).code(500);
}
request.logger.info({ id }, "Register bot at %s", new Date());
return _h.response().code(200);
}); });
*/
return _h.response().code(200); app.post("/api/bots/:id/register", async (c) => {
}, const id = c.req.param("id");
}, await service.register(id);
}); return c.body(null, 200);
});
export const UnverifyBotRoute = withDefaults({ app.post("/api/bots/:id/unverify", async (c) => {
method: "post", const id = c.req.param("id");
path: "/api/bots/{id}/unverify", await service.unverify(id);
options: { return c.body(null, 200);
description: "Unverify bot", });
async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) {
const { id } = request.params;
const whatsappService = getService(request);
return whatsappService.unverify(id); app.get("/api/bots/:id", async (c) => {
}, const id = c.req.param("id");
}, return c.json(service.getBot(id));
}); });
export const GetBotRoute = withDefaults({ return app;
method: "get", }
path: "/api/bots/{id}",
options: {
description: "Get bot info",
async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) {
const { id } = request.params;
const whatsappService = getService(request);
return whatsappService.getBot(id);
},
},
});

View file

@ -1,5 +1,3 @@
import { Server } from "@hapi/hapi";
import { Service } from "@hapipal/schmervice";
import makeWASocket, { import makeWASocket, {
DisconnectReason, DisconnectReason,
proto, proto,
@ -23,16 +21,12 @@ const logger = createLogger("bridge-whatsapp-service");
export type AuthCompleteCallback = (error?: string) => void; export type AuthCompleteCallback = (error?: string) => void;
export default class WhatsappService extends Service { export default class WhatsappService {
connections: { [key: string]: any } = {}; connections: { [key: string]: any } = {};
loginConnections: { [key: string]: any } = {}; loginConnections: { [key: string]: any } = {};
static browserDescription: [string, string, string] = ["Bridge", "Chrome", "2.0"]; static browserDescription: [string, string, string] = ["Bridge", "Chrome", "2.0"];
constructor(server: Server, options: never) {
super(server, options);
}
getBaseDirectory(): string { getBaseDirectory(): string {
return `/home/node/baileys`; return `/home/node/baileys`;
} }
@ -87,7 +81,6 @@ export default class WhatsappService extends Service {
private async createConnection( private async createConnection(
botID: string, botID: string,
server: Server,
options: any, options: any,
authCompleteCallback?: any, authCompleteCallback?: any,
) { ) {
@ -125,13 +118,13 @@ export default class WhatsappService extends Service {
const disconnectStatusCode = (lastDisconnect?.error as any)?.output?.statusCode; const disconnectStatusCode = (lastDisconnect?.error as any)?.output?.statusCode;
if (disconnectStatusCode === DisconnectReason.restartRequired) { if (disconnectStatusCode === DisconnectReason.restartRequired) {
logger.info("reconnecting after got new login"); logger.info("reconnecting after got new login");
await this.createConnection(botID, server, options); await this.createConnection(botID, options);
authCompleteCallback?.(); authCompleteCallback?.();
} else if (disconnectStatusCode !== DisconnectReason.loggedOut) { } else if (disconnectStatusCode !== DisconnectReason.loggedOut) {
logger.info("reconnecting"); logger.info("reconnecting");
await this.sleep(pause); await this.sleep(pause);
pause *= 2; pause *= 2;
this.createConnection(botID, server, options); this.createConnection(botID, options);
} }
} }
} }
@ -178,7 +171,7 @@ export default class WhatsappService extends Service {
const { version, isLatest } = await fetchLatestBaileysVersion(); const { version, isLatest } = await fetchLatestBaileysVersion();
logger.info({ version: version.join("."), isLatest }, "using WA version"); logger.info({ version: version.join("."), isLatest }, "using WA version");
await this.createConnection(botID, this.server, { await this.createConnection(botID, {
browser: WhatsappService.browserDescription, browser: WhatsappService.browserDescription,
version, version,
}); });
@ -355,7 +348,6 @@ export default class WhatsappService extends Service {
const { version } = await fetchLatestBaileysVersion(); const { version } = await fetchLatestBaileysVersion();
await this.createConnection( await this.createConnection(
botID, botID,
this.server,
{ version, browser: WhatsappService.browserDescription }, { version, browser: WhatsappService.browserDescription },
callback, callback,
); );
@ -452,10 +444,6 @@ export default class WhatsappService extends Service {
_botID: string, _botID: string,
_lastReceivedDate: Date, _lastReceivedDate: Date,
): Promise<proto.IWebMessageInfo[]> { ): Promise<proto.IWebMessageInfo[]> {
// loadAllUnreadMessages() was removed in Baileys 7.x
// Messages are now delivered via events (messages.upsert, messaging-history.set)
// and forwarded to webhooks automatically.
// See: https://baileys.wiki/docs/migration/to-v7.0.0/
throw new Error( throw new Error(
"Message polling is no longer supported in Baileys 7.x. " + "Message polling is no longer supported in Baileys 7.x. " +
"Please configure a webhook to receive messages instead. " + "Please configure a webhook to receive messages instead. " +

View file

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