Make APIs more similar

This commit is contained in:
Darren Clarke 2026-02-15 10:29:52 +01:00
parent 9f0e1f8b61
commit c40d7d056e
57 changed files with 3994 additions and 1801 deletions

View file

@ -0,0 +1,3 @@
import config from "@link-stack/eslint-config/node";
export default config;

View file

@ -4,25 +4,33 @@
"main": "build/main/index.js",
"author": "Darren Clarke <darren@redaranj.com>",
"license": "AGPL-3.0-or-later",
"prettier": "@link-stack/prettier-config",
"dependencies": {
"@adiwajshing/keyed-db": "0.2.4",
"@hono/node-server": "^1.13.8",
"@whiskeysockets/baileys": "6.7.21",
"hono": "^4.7.4",
"link-preview-js": "^3.1.0",
"pino": "^9.6.0",
"pino-pretty": "^13.0.0"
"@link-stack/logger": "workspace:*"
},
"devDependencies": {
"@link-stack/eslint-config": "workspace:*",
"@link-stack/prettier-config": "workspace:*",
"@link-stack/typescript-config": "workspace:*",
"@types/long": "^5",
"@types/node": "*",
"dotenv-cli": "^10.0.0",
"eslint": "^9.23.0",
"prettier": "^3.5.3",
"tsx": "^4.20.6",
"typescript": "^5.9.3"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"dev": "dotenv -- tsx src/index.ts",
"start": "node build/main/index.js"
"start": "node build/main/index.js",
"lint": "eslint src/",
"format": "prettier --write src/",
"format:check": "prettier --check src/"
}
}

View file

@ -11,10 +11,10 @@
*/
export function getMaxAttachmentSize(): number {
const envValue = process.env.BRIDGE_MAX_ATTACHMENT_SIZE_MB;
const sizeInMB = envValue ? parseInt(envValue, 10) : 50;
const sizeInMB = envValue ? Number.parseInt(envValue, 10) : 50;
// Validate the value
if (isNaN(sizeInMB) || sizeInMB <= 0) {
if (Number.isNaN(sizeInMB) || sizeInMB <= 0) {
console.warn(`Invalid BRIDGE_MAX_ATTACHMENT_SIZE_MB value: ${envValue}, using default 50MB`);
return 50 * 1024 * 1024;
}

View file

@ -1,7 +1,8 @@
import { serve } from "@hono/node-server";
import WhatsappService from "./service.ts";
import { createLogger } from "@link-stack/logger";
import { createRoutes } from "./routes.ts";
import { createLogger } from "./lib/logger";
import WhatsappService from "./service.ts";
const logger = createLogger("bridge-whatsapp-index");
@ -10,7 +11,7 @@ const main = async () => {
await service.initialize();
const app = createRoutes(service);
const port = parseInt(process.env.PORT || "5000", 10);
const port = Number.parseInt(process.env.PORT || "5000", 10);
serve({ fetch: app.fetch, port }, (info) => {
logger.info({ port: info.port }, "bridge-whatsapp listening");
@ -26,7 +27,7 @@ const main = async () => {
process.on("SIGINT", shutdown);
};
main().catch((err) => {
logger.error(err);
main().catch((error) => {
logger.error(error);
process.exit(1);
});

View file

@ -1,77 +0,0 @@
import pino, { Logger as PinoLogger, LoggerOptions } from 'pino';
export type Logger = PinoLogger;
const getLogLevel = (): string => {
return process.env.LOG_LEVEL || (process.env.NODE_ENV === 'production' ? 'info' : 'debug');
};
const getPinoConfig = (): LoggerOptions => {
const isDevelopment = process.env.NODE_ENV !== 'production';
const baseConfig: LoggerOptions = {
level: getLogLevel(),
formatters: {
level: (label) => {
return { level: label.toUpperCase() };
},
},
timestamp: () => `,"timestamp":"${new Date(Date.now()).toISOString()}"`,
redact: {
paths: [
'password',
'token',
'secret',
'api_key',
'apiKey',
'authorization',
'cookie',
'access_token',
'refresh_token',
'*.password',
'*.token',
'*.secret',
'*.api_key',
'*.apiKey',
'*.authorization',
'*.cookie',
'*.access_token',
'*.refresh_token',
'headers.authorization',
'headers.cookie',
'headers.Authorization',
'headers.Cookie',
'credentials.password',
'credentials.secret',
'credentials.token',
],
censor: '[REDACTED]',
},
};
if (isDevelopment) {
return {
...baseConfig,
transport: {
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'SYS:standard',
ignore: 'pid,hostname',
singleLine: false,
messageFormat: '{msg}',
},
},
};
}
return baseConfig;
};
export const logger: Logger = pino(getPinoConfig());
export const createLogger = (name: string, context?: Record<string, any>): Logger => {
return logger.child({ name, ...context });
};
export default logger;

View file

@ -1,12 +1,36 @@
import { createLogger } from "@link-stack/logger";
import { Hono } from "hono";
import type WhatsappService from "./service.ts";
import { createLogger } from "./lib/logger";
const logger = createLogger("bridge-whatsapp-routes");
const errorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error));
export function createRoutes(service: WhatsappService): Hono {
const app = new Hono();
app.post("/api/bots/:id/register", async (c) => {
const id = c.req.param("id");
try {
await service.register(id);
logger.info({ id }, "Bot registered");
return c.body(null, 200);
} catch (error) {
logger.error({ id, error: errorMessage(error) }, "Failed to register bot");
return c.json({ error: errorMessage(error) }, 500);
}
});
app.get("/api/bots/:id", async (c) => {
const id = c.req.param("id");
try {
return c.json(service.getBot(id));
} catch (error) {
return c.json({ error: errorMessage(error) }, 500);
}
});
app.post("/api/bots/:id/send", async (c) => {
const id = c.req.param("id");
const { phoneNumber, message, attachments } = await c.req.json<{
@ -15,43 +39,30 @@ export function createRoutes(service: WhatsappService): Hono {
attachments?: Array<{ data: string; filename: string; mime_type: string }>;
}>();
await service.send(id, phoneNumber, message, attachments);
logger.info({ id, attachmentCount: attachments?.length || 0 }, "Sent message");
return c.json({
result: {
recipient: phoneNumber,
timestamp: new Date().toISOString(),
source: id,
},
});
});
app.get("/api/bots/:id/receive", async (c) => {
const id = c.req.param("id");
const date = new Date();
const twoDaysAgo = new Date(date.getTime());
twoDaysAgo.setDate(date.getDate() - 2);
const messages = await service.receive(id, twoDaysAgo);
return c.json(messages);
});
app.post("/api/bots/:id/register", async (c) => {
const id = c.req.param("id");
await service.register(id);
return c.body(null, 200);
try {
const result = await service.send(id, phoneNumber, message, attachments);
logger.info({ id, attachmentCount: attachments?.length || 0 }, "Sent message");
return c.json({ result });
} catch (error) {
logger.error({ id, error: errorMessage(error) }, "Failed to send message");
return c.json({ error: errorMessage(error) }, 500);
}
});
app.post("/api/bots/:id/unverify", async (c) => {
const id = c.req.param("id");
await service.unverify(id);
return c.body(null, 200);
try {
await service.unverify(id);
logger.info({ id }, "Bot unverified");
return c.body(null, 200);
} catch (error) {
logger.error({ id, error: errorMessage(error) }, "Failed to unverify bot");
return c.json({ error: errorMessage(error) }, 500);
}
});
app.get("/api/bots/:id", async (c) => {
const id = c.req.param("id");
return c.json(service.getBot(id));
app.get("/api/health", (c) => {
return c.json({ status: "ok" });
});
return app;

View file

@ -1,4 +1,9 @@
import fs from "node:fs";
import { createLogger } from "@link-stack/logger";
import makeWASocket, {
type WASocket,
type SocketConfig,
DisconnectReason,
proto,
downloadContentFromMessage,
@ -8,22 +13,21 @@ import makeWASocket, {
useMultiFileAuthState,
} from "@whiskeysockets/baileys";
import { getMaxAttachmentSize, getMaxTotalAttachmentSize, MAX_ATTACHMENTS } from "./attachments";
type MediaType = "audio" | "document" | "image" | "video" | "sticker";
import fs from "fs";
import { createLogger } from "./lib/logger";
import {
getMaxAttachmentSize,
getMaxTotalAttachmentSize,
MAX_ATTACHMENTS,
} from "./attachments";
const logger = createLogger("bridge-whatsapp-service");
export type AuthCompleteCallback = (error?: string) => void;
interface BotConnection {
socket: WASocket;
}
export default class WhatsappService {
connections: { [key: string]: any } = {};
loginConnections: { [key: string]: any } = {};
connections: Record<string, BotConnection> = {};
loginConnections: Record<string, BotConnection> = {};
static browserDescription: [string, string, string] = ["Bridge", "Chrome", "2.0"];
@ -71,7 +75,7 @@ export default class WhatsappService {
private async resetConnections() {
for (const connection of Object.values(this.connections)) {
try {
connection.end(null);
connection.socket.end(new Error("Connection reset"));
} catch (error) {
logger.error({ error }, "Connection reset error");
}
@ -81,18 +85,16 @@ export default class WhatsappService {
private async createConnection(
botID: string,
options: any,
authCompleteCallback?: any,
options: Partial<SocketConfig>,
authCompleteCallback?: AuthCompleteCallback
) {
const authDirectory = this.getAuthDirectory(botID);
const { state, saveCreds } = await useMultiFileAuthState(authDirectory);
const msgRetryCounterMap: any = {};
const socket = makeWASocket({
...options,
auth: state,
generateHighQualityLinkPreview: false,
syncFullHistory: true,
msgRetryCounterMap,
shouldIgnoreJid: (jid) => isJidBroadcast(jid) || isJidStatusBroadcast(jid),
});
let pause = 5000;
@ -115,7 +117,8 @@ export default class WhatsappService {
logger.info("opened connection");
} else if (connectionState === "close") {
logger.info({ lastDisconnect }, "connection closed");
const disconnectStatusCode = (lastDisconnect?.error as any)?.output?.statusCode;
const disconnectStatusCode = (lastDisconnect?.error as { output?: { statusCode?: number } } | undefined)
?.output?.statusCode;
if (disconnectStatusCode === DisconnectReason.restartRequired) {
logger.info("reconnecting after got new login");
await this.createConnection(botID, options);
@ -145,17 +148,14 @@ export default class WhatsappService {
if (events["messaging-history.set"]) {
const { messages, isLatest } = events["messaging-history.set"];
logger.info(
{ messageCount: messages.length, isLatest },
"received message history on connection",
);
logger.info({ messageCount: messages.length, isLatest }, "received message history on connection");
if (messages.length > 0) {
await this.queueUnreadMessages(botID, messages);
}
}
});
this.connections[botID] = { socket, msgRetryCounterMap };
this.connections[botID] = { socket };
}
private async updateConnections() {
@ -188,17 +188,13 @@ export default class WhatsappService {
const { id, fromMe, remoteJid } = key;
// Baileys 7 uses LIDs (Linked IDs) instead of phone numbers in some cases.
// senderPn contains the actual phone number when available.
const senderPn = (key as any).senderPn as string | undefined;
const participantPn = (key as any).participantPn as string | undefined;
logger.info(
{ remoteJid, senderPn, participantPn, fromMe },
"Processing incoming message",
);
const senderPn = (key as { senderPn?: string }).senderPn;
const participantPn = (key as { participantPn?: string }).participantPn;
logger.info({ remoteJid, senderPn, participantPn, fromMe }, "Processing incoming message");
const isValidMessage = message && remoteJid !== "status@broadcast" && !fromMe;
if (isValidMessage) {
const { audioMessage, documentMessage, imageMessage, videoMessage } = message;
const isMediaMessage =
audioMessage || documentMessage || imageMessage || videoMessage;
const isMediaMessage = audioMessage || documentMessage || imageMessage || videoMessage;
const messageContent = Object.values(message)[0];
let messageType: MediaType;
@ -229,8 +225,8 @@ export default class WhatsappService {
const stream = await downloadContentFromMessage(
messageContent,
// @ts-ignore
messageType,
// @ts-expect-error messageType is dynamically resolved
messageType
);
let buffer = Buffer.from([]);
for await (const chunk of stream) {
@ -244,12 +240,9 @@ export default class WhatsappService {
const extendedTextMessage = message?.extendedTextMessage?.text;
const imageMessage = message?.imageMessage?.caption;
const videoMessage = message?.videoMessage?.caption;
const messageText = [
conversation,
extendedTextMessage,
imageMessage,
videoMessage,
].find((text) => text && text !== "");
const messageText = [conversation, extendedTextMessage, imageMessage, videoMessage].find(
(text) => text && text !== ""
);
// Extract phone number and user ID (LID) separately
// remoteJid may contain LIDs (Baileys 7+) which are not phone numbers
@ -257,7 +250,8 @@ export default class WhatsappService {
const isLidJid = remoteJid?.endsWith("@lid");
// Phone number: prefer senderPn/participantPn, fall back to remoteJid only if it's not a LID
const senderPhone = senderPn?.split("@")[0] || participantPn?.split("@")[0] || (!isLidJid ? jidValue : undefined);
const senderPhone =
senderPn?.split("@")[0] || participantPn?.split("@")[0] || (isLidJid ? undefined : jidValue);
// User ID (LID): extract from remoteJid if it's a LID format
const senderUserId = isLidJid ? jidValue : undefined;
@ -271,27 +265,24 @@ export default class WhatsappService {
const payload = {
to: botID,
from: senderPhone,
userId: senderUserId,
messageId: id,
sentAt: new Date((messageTimestamp as number) * 1000).toISOString(),
user_id: senderUserId,
message_id: id,
sent_at: new Date((messageTimestamp as number) * 1000).toISOString(),
message: messageText,
attachment,
filename,
mimeType,
mime_type: mimeType,
};
// Send directly to Zammad's WhatsApp webhook
const zammadUrl = process.env.ZAMMAD_URL || 'http://zammad-nginx:8080';
const response = await fetch(
`${zammadUrl}/api/v1/channels_cdr_whatsapp_bot_webhook/${botID}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
const zammadUrl = process.env.ZAMMAD_URL || "http://zammad-nginx:8080";
const response = await fetch(`${zammadUrl}/api/v1/channels_cdr_whatsapp_bot_webhook/${botID}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
);
body: JSON.stringify(payload),
});
if (!response.ok) {
const errorText = await response.text();
@ -307,7 +298,7 @@ export default class WhatsappService {
}
}
getBot(botID: string): Record<string, any> {
getBot(botID: string): Record<string, unknown> {
const botDirectory = this.getBotDirectory(botID);
const qrPath = `${botDirectory}/qr.txt`;
const verifiedFile = `${botDirectory}/verified`;
@ -327,7 +318,7 @@ export default class WhatsappService {
} catch (error) {
logger.warn({ botID, error }, "Error during logout, forcing disconnect");
try {
connection.socket.end(undefined);
connection.socket.end(new Error("Forced disconnect"));
} catch (endError) {
logger.warn({ botID, endError }, "Error ending socket connection");
}
@ -346,11 +337,7 @@ export default class WhatsappService {
async register(botID: string, callback?: AuthCompleteCallback): Promise<void> {
const { version } = await fetchLatestBaileysVersion();
await this.createConnection(
botID,
{ version, browser: WhatsappService.browserDescription },
callback,
);
await this.createConnection(botID, { version, browser: WhatsappService.browserDescription }, callback);
callback?.();
}
@ -358,10 +345,10 @@ export default class WhatsappService {
botID: string,
phoneNumber: string,
message: string,
attachments?: Array<{ data: string; filename: string; mime_type: string }>,
): Promise<void> {
attachments?: Array<{ data: string; filename: string; mime_type: string }>
): Promise<{ recipient: string; timestamp: string; source: string }> {
const connection = this.connections[botID]?.socket;
const digits = phoneNumber.replace(/\D+/g, "");
const digits = phoneNumber.replaceAll(/\D+/g, "");
// LIDs are 15+ digits, phone numbers with country code are typically 10-14 digits
const suffix = digits.length > 14 ? "@lid" : "@s.whatsapp.net";
const recipient = `${digits}${suffix}`;
@ -377,9 +364,7 @@ export default class WhatsappService {
const MAX_TOTAL_SIZE = getMaxTotalAttachmentSize();
if (attachments.length > MAX_ATTACHMENTS) {
throw new Error(
`Too many attachments: ${attachments.length} (max ${MAX_ATTACHMENTS})`,
);
throw new Error(`Too many attachments: ${attachments.length} (max ${MAX_ATTACHMENTS})`);
}
let totalSize = 0;
@ -395,7 +380,7 @@ export default class WhatsappService {
size: estimatedSize,
maxSize: MAX_ATTACHMENT_SIZE,
},
"Attachment exceeds size limit, skipping",
"Attachment exceeds size limit, skipping"
);
continue;
}
@ -407,7 +392,7 @@ export default class WhatsappService {
totalSize,
maxTotalSize: MAX_TOTAL_SIZE,
},
"Total attachment size exceeds limit, skipping remaining",
"Total attachment size exceeds limit, skipping remaining"
);
break;
}
@ -438,16 +423,11 @@ export default class WhatsappService {
}
}
}
}
async receive(
_botID: string,
_lastReceivedDate: Date,
): Promise<proto.IWebMessageInfo[]> {
throw new Error(
"Message polling is no longer supported in Baileys 7.x. " +
"Please configure a webhook to receive messages instead. " +
"Messages are automatically forwarded to Zammad via ZAMMAD_URL/api/v1/channels_cdr_whatsapp_bot_webhook/{id}"
);
return {
recipient: phoneNumber,
timestamp: new Date().toISOString(),
source: botID,
};
}
}

View file

@ -1,26 +1,8 @@
{
"extends": "@link-stack/typescript-config/tsconfig.node.json",
"compilerOptions": {
"target": "es2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "build/main",
"rootDir": "src",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"inlineSources": true,
"skipLibCheck": true,
"strict": true,
"allowJs": true,
"forceConsistentCasingInFileNames": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"isolatedModules": true,
"incremental": true,
"composite": true,
"rewriteRelativeImportExtensions": true,
"types": ["node"],
"lib": ["es2022", "DOM"]
"rootDir": "src"
},
"include": ["src/**/*.ts", "src/**/.*.ts"],
"exclude": ["node_modules/**"]