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,23 +4,31 @@
"main": "build/main/index.js",
"author": "Darren Clarke <darren@redaranj.com>",
"license": "AGPL-3.0-or-later",
"prettier": "@link-stack/prettier-config",
"dependencies": {
"@deltachat/jsonrpc-client": "^1.151.1",
"@deltachat/stdio-rpc-server": "^1.151.1",
"@hono/node-server": "^1.13.8",
"hono": "^4.7.4",
"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/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,9 +11,9 @@
*/
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;
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 DeltaChatService from "./service.ts";
import { createLogger } from "@link-stack/logger";
import { createRoutes } from "./routes.ts";
import { createLogger } from "./lib/logger";
import DeltaChatService from "./service.ts";
const logger = createLogger("bridge-deltachat-index");
@ -10,7 +11,7 @@ const main = async () => {
await service.initialize();
const app = createRoutes(service);
const port = parseInt(process.env.PORT || "5001", 10);
const port = Number.parseInt(process.env.PORT || "5001", 10);
serve({ fetch: app.fetch, port }, (info) => {
logger.info({ port: info.port }, "bridge-deltachat 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,9 +1,12 @@
import { createLogger } from "@link-stack/logger";
import { Hono } from "hono";
import type DeltaChatService from "./service.ts";
import { createLogger } from "./lib/logger";
const logger = createLogger("bridge-deltachat-routes");
const errorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error));
export function createRoutes(service: DeltaChatService): Hono {
const app = new Hono();
@ -15,9 +18,9 @@ export function createRoutes(service: DeltaChatService): Hono {
const result = await service.configure(id, email, password);
logger.info({ id, email }, "Bot configured");
return c.json(result);
} catch (err: any) {
logger.error({ id, error: err.message }, "Failed to configure bot");
return c.json({ error: err.message }, 500);
} catch (error) {
logger.error({ id, error: errorMessage(error) }, "Failed to configure bot");
return c.json({ error: errorMessage(error) }, 500);
}
});
@ -34,9 +37,14 @@ export function createRoutes(service: DeltaChatService): Hono {
attachments?: Array<{ data: string; filename: string; mime_type: string }>;
}>();
const result = await service.send(id, email, message, attachments);
logger.info({ id, attachmentCount: attachments?.length || 0 }, "Sent message");
return c.json({ result });
try {
const result = await service.send(id, email, 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/unconfigure", async (c) => {

View file

@ -1,13 +1,11 @@
import { startDeltaChat, DeltaChat } from "@deltachat/stdio-rpc-server";
import fs from "fs";
import path from "path";
import os from "os";
import { createLogger } from "./lib/logger";
import {
getMaxAttachmentSize,
getMaxTotalAttachmentSize,
MAX_ATTACHMENTS,
} from "./attachments";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { startDeltaChat, type DeltaChatOverJsonRpcServer } from "@deltachat/stdio-rpc-server";
import { createLogger } from "@link-stack/logger";
import { getMaxAttachmentSize, getMaxTotalAttachmentSize, MAX_ATTACHMENTS } from "./attachments";
const logger = createLogger("bridge-deltachat-service");
@ -16,7 +14,7 @@ interface BotMapping {
}
export default class DeltaChatService {
private dc: DeltaChat | null = null;
private dc: DeltaChatOverJsonRpcServer | null = null;
private botMapping: BotMapping = {};
private dataDir: string;
private mappingFile: string;
@ -47,8 +45,8 @@ export default class DeltaChatService {
logger.warn({ botId, accountId }, "Account not configured, removing from mapping");
delete this.botMapping[botId];
}
} catch (err) {
logger.error({ botId, accountId, err }, "Failed to resume bot, removing from mapping");
} catch (error) {
logger.error({ botId, accountId, err: error }, "Failed to resume bot, removing from mapping");
delete this.botMapping[botId];
}
}
@ -63,8 +61,8 @@ export default class DeltaChatService {
try {
await this.dc.rpc.stopIo(accountId);
logger.info({ botId, accountId }, "Stopped IO for bot");
} catch (err) {
logger.error({ botId, accountId, err }, "Error stopping IO");
} catch (error) {
logger.error({ botId, accountId, err: error }, "Error stopping IO");
}
}
this.dc.close();
@ -75,18 +73,18 @@ export default class DeltaChatService {
private loadBotMapping(): void {
if (fs.existsSync(this.mappingFile)) {
try {
const data = fs.readFileSync(this.mappingFile, "utf-8");
const data = fs.readFileSync(this.mappingFile, "utf8");
this.botMapping = JSON.parse(data);
logger.info({ botCount: Object.keys(this.botMapping).length }, "Loaded bot mapping");
} catch (err) {
logger.error({ err }, "Failed to load bot mapping, starting fresh");
} catch (error) {
logger.error({ err: error }, "Failed to load bot mapping, starting fresh");
this.botMapping = {};
}
}
}
private saveBotMapping(): void {
fs.writeFileSync(this.mappingFile, JSON.stringify(this.botMapping, null, 2), "utf-8");
fs.writeFileSync(this.mappingFile, JSON.stringify(this.botMapping, null, 2), "utf8");
}
private validateBotId(id: string): void {
@ -102,29 +100,14 @@ export default class DeltaChatService {
private registerEventListeners(): void {
if (!this.dc) return;
const dc = this.dc;
(async () => {
for await (const event of dc.events) {
try {
if (event.kind === "IncomingMsg") {
const { accountId, chatId, msgId } = event;
await this.handleIncomingMessage(accountId, chatId, msgId);
}
} catch (err) {
logger.error({ err, event: event.kind }, "Error handling event");
}
}
})().catch((err) => {
logger.error({ err }, "Event listener loop exited");
this.dc.on("IncomingMsg", (accountId, event) => {
this.handleIncomingMessage(accountId, event.chatId, event.msgId).catch((error) => {
logger.error({ err: error, accountId }, "Error handling incoming message");
});
});
}
private async handleIncomingMessage(
accountId: number,
chatId: number,
msgId: number,
): Promise<void> {
private async handleIncomingMessage(accountId: number, chatId: number, msgId: number): Promise<void> {
if (!this.dc) return;
const botId = this.getBotIdForAccount(accountId);
@ -135,9 +118,10 @@ export default class DeltaChatService {
const msg = await this.dc.rpc.getMessage(accountId, msgId);
// Skip bot messages and non-chat messages (plain email)
if (msg.isBot || !msg.isIncoming) {
logger.debug({ msgId, isBot: msg.isBot, isIncoming: msg.isIncoming }, "Skipping message");
// Incoming states: 10=fresh, 13=noticed, 16=seen
const isIncoming = msg.state === 10 || msg.state === 13 || msg.state === 16;
if (msg.isBot || !isIncoming) {
logger.debug({ msgId, isBot: msg.isBot, state: msg.state }, "Skipping message");
return;
}
@ -159,8 +143,8 @@ export default class DeltaChatService {
filename = msg.fileName || path.basename(msg.file);
mimeType = msg.fileMime || "application/octet-stream";
logger.info({ filename, mimeType, size: fileData.length }, "Attachment found");
} catch (err) {
logger.error({ err, file: msg.file }, "Failed to read attachment file");
} catch (error) {
logger.error({ err: error, file: msg.file }, "Failed to read attachment file");
}
}
@ -180,37 +164,30 @@ export default class DeltaChatService {
const zammadUrl = process.env.ZAMMAD_URL || "http://zammad-nginx:8080";
try {
const response = await fetch(
`${zammadUrl}/api/v1/channels_cdr_deltachat_bot_webhook/${botId}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
);
const response = await fetch(`${zammadUrl}/api/v1/channels_cdr_deltachat_bot_webhook/${botId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) {
if (response.ok) {
logger.info({ botId, msgId }, "Message forwarded to Zammad");
} else {
const errorText = await response.text();
logger.error({ status: response.status, error: errorText, botId }, "Failed to send message to Zammad");
} else {
logger.info({ botId, msgId }, "Message forwarded to Zammad");
}
} catch (err) {
logger.error({ err, botId }, "Failed to POST to Zammad webhook");
} catch (error) {
logger.error({ err: error, botId }, "Failed to POST to Zammad webhook");
}
try {
await this.dc.rpc.markseenMsgs(accountId, [msgId]);
} catch (err) {
logger.error({ err, msgId }, "Failed to mark message as seen");
} catch (error) {
logger.error({ err: error, msgId }, "Failed to mark message as seen");
}
}
async configure(
botId: string,
email: string,
password: string,
): Promise<{ accountId: number; email: string }> {
async configure(botId: string, email: string, password: string): Promise<{ accountId: number; email: string }> {
this.validateBotId(botId);
if (!this.dc) throw new Error("DeltaChat not initialized");
@ -240,14 +217,14 @@ export default class DeltaChatService {
this.saveBotMapping();
return { accountId, email };
} catch (err) {
logger.error({ botId, accountId, err }, "Configuration failed, removing account");
} catch (error) {
logger.error({ botId, accountId, err: error }, "Configuration failed, removing account");
try {
await this.dc.rpc.removeAccount(accountId);
} catch (removeErr) {
logger.error({ removeErr }, "Failed to clean up account after configuration failure");
} catch (error_) {
logger.error({ removeErr: error_ }, "Failed to clean up account after configuration failure");
}
throw err;
throw error;
}
}
@ -280,14 +257,14 @@ export default class DeltaChatService {
try {
await this.dc.rpc.stopIo(accountId);
} catch (err) {
logger.warn({ botId, accountId, err }, "Error stopping IO during unconfigure");
} catch (error) {
logger.warn({ botId, accountId, err: error }, "Error stopping IO during unconfigure");
}
try {
await this.dc.rpc.removeAccount(accountId);
} catch (err) {
logger.warn({ botId, accountId, err }, "Error removing account during unconfigure");
} catch (error) {
logger.warn({ botId, accountId, err: error }, "Error removing account during unconfigure");
}
delete this.botMapping[botId];
@ -299,7 +276,7 @@ export default class DeltaChatService {
botId: string,
email: string,
message: string,
attachments?: Array<{ data: string; filename: string; mime_type: string }>,
attachments?: Array<{ data: string; filename: string; mime_type: string }>
): Promise<{ recipient: string; timestamp: string; source: string }> {
this.validateBotId(botId);
if (!this.dc) throw new Error("DeltaChat not initialized");
@ -328,14 +305,17 @@ export default class DeltaChatService {
if (estimatedSize > MAX_ATTACHMENT_SIZE) {
logger.warn(
{ filename: att.filename, size: estimatedSize, maxSize: MAX_ATTACHMENT_SIZE },
"Attachment exceeds size limit, skipping",
"Attachment exceeds size limit, skipping"
);
continue;
}
totalSize += estimatedSize;
if (totalSize > MAX_TOTAL_SIZE) {
logger.warn({ totalSize, maxTotalSize: MAX_TOTAL_SIZE }, "Total attachment size exceeds limit, skipping remaining");
logger.warn(
{ totalSize, maxTotalSize: MAX_TOTAL_SIZE },
"Total attachment size exceeds limit, skipping remaining"
);
break;
}
@ -346,7 +326,14 @@ export default class DeltaChatService {
try {
await this.dc.rpc.sendMsg(accountId, chatId, {
text: message,
html: null,
viewtype: null,
file: tmpFile,
filename: att.filename,
location: null,
overrideSenderName: null,
quotedMessageId: null,
quotedText: null,
});
// Only include text with the first attachment; clear for subsequent
message = "";

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/**"]

View file

@ -0,0 +1,56 @@
FROM node:22-bookworm-slim AS base
FROM base AS builder
ARG APP_DIR=/opt/bridge-signal
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN mkdir -p ${APP_DIR}/
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
RUN pnpm add -g turbo
WORKDIR ${APP_DIR}
COPY . .
RUN turbo prune --scope=@link-stack/bridge-signal --docker
FROM base AS installer
ARG APP_DIR=/opt/bridge-signal
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
WORKDIR ${APP_DIR}
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
COPY --from=builder ${APP_DIR}/out/json/ .
COPY --from=builder ${APP_DIR}/out/full/ .
COPY --from=builder ${APP_DIR}/out/pnpm-lock.yaml ./pnpm-lock.yaml
RUN pnpm install --frozen-lockfile
RUN pnpm add -g turbo
RUN turbo run build --filter=@link-stack/bridge-signal
FROM base as runner
ARG BUILD_DATE
ARG VERSION
ARG APP_DIR=/opt/bridge-signal
ARG SIGNAL_CLI_VERSION=0.13.12
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN mkdir -p ${APP_DIR}/
RUN DEBIAN_FRONTEND=noninteractive apt-get update && \
apt-get install -y --no-install-recommends \
dumb-init curl && \
ARCH=$(dpkg --print-architecture) && \
curl -L "https://github.com/AsamK/signal-cli/releases/download/v${SIGNAL_CLI_VERSION}/signal-cli-native-linux-${ARCH}-${SIGNAL_CLI_VERSION}.tar.gz" \
| tar xz -C /opt && \
ln -s /opt/signal-cli-native-linux-*/bin/signal-cli-native /usr/local/bin/signal-cli && \
apt-get remove -y curl && apt-get autoremove -y && rm -rf /var/lib/apt/lists/*
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
WORKDIR ${APP_DIR}
COPY --from=installer ${APP_DIR} ./
RUN chown -R node:node ${APP_DIR}
WORKDIR ${APP_DIR}/apps/bridge-signal/
RUN chmod +x docker-entrypoint.sh
USER node
RUN mkdir /home/node/signal-data
EXPOSE 5002
ENV PORT 5002
ENV NODE_ENV production
ENV SIGNAL_DATA_DIR /home/node/signal-data
ENV COREPACK_ENABLE_NETWORK=0
ENTRYPOINT ["/opt/bridge-signal/apps/bridge-signal/docker-entrypoint.sh"]

View file

@ -0,0 +1,5 @@
#!/bin/bash
set -e
echo "starting bridge-signal"
exec dumb-init pnpm run start

View file

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

View file

@ -0,0 +1,32 @@
{
"name": "@link-stack/bridge-signal",
"version": "3.5.0-beta.1",
"main": "build/main/index.js",
"author": "Darren Clarke <darren@redaranj.com>",
"license": "AGPL-3.0-or-later",
"prettier": "@link-stack/prettier-config",
"dependencies": {
"@hono/node-server": "^1.13.8",
"hono": "^4.7.4",
"@link-stack/logger": "workspace:*"
},
"devDependencies": {
"@link-stack/eslint-config": "workspace:*",
"@link-stack/prettier-config": "workspace:*",
"@link-stack/typescript-config": "workspace:*",
"@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",
"lint": "eslint src/",
"format": "prettier --write src/",
"format:check": "prettier --check src/"
}
}

View file

@ -0,0 +1,35 @@
/**
* Attachment size configuration for messaging channels
*
* Environment variables:
* - BRIDGE_MAX_ATTACHMENT_SIZE_MB: Maximum size for a single attachment in MB (default: 50)
*/
/**
* Get the maximum attachment size in bytes from environment variable
* Defaults to 50MB if not set
*/
export function getMaxAttachmentSize(): number {
const envValue = process.env.BRIDGE_MAX_ATTACHMENT_SIZE_MB;
const sizeInMB = envValue ? Number.parseInt(envValue, 10) : 50;
if (Number.isNaN(sizeInMB) || sizeInMB <= 0) {
console.warn(`Invalid BRIDGE_MAX_ATTACHMENT_SIZE_MB value: ${envValue}, using default 50MB`);
return 50 * 1024 * 1024;
}
return sizeInMB * 1024 * 1024;
}
/**
* Get the maximum total size for all attachments in a message
* This is 4x the single attachment size
*/
export function getMaxTotalAttachmentSize(): number {
return getMaxAttachmentSize() * 4;
}
/**
* Maximum number of attachments per message
*/
export const MAX_ATTACHMENTS = 10;

View file

@ -0,0 +1,33 @@
import { serve } from "@hono/node-server";
import { createLogger } from "@link-stack/logger";
import { createRoutes } from "./routes.ts";
import SignalService from "./service.ts";
const logger = createLogger("bridge-signal-index");
const main = async () => {
const service = new SignalService();
await service.initialize();
const app = createRoutes(service);
const port = Number.parseInt(process.env.PORT || "5002", 10);
serve({ fetch: app.fetch, port }, (info) => {
logger.info({ port: info.port }, "bridge-signal listening");
});
const shutdown = async () => {
logger.info("Shutting down...");
await service.teardown();
process.exit(0);
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
};
main().catch((error) => {
logger.error(error);
process.exit(1);
});

View file

@ -0,0 +1,130 @@
import { createLogger } from "@link-stack/logger";
import { Hono } from "hono";
import type SignalService from "./service.ts";
const logger = createLogger("bridge-signal-routes");
const errorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error));
export function createRoutes(service: SignalService): Hono {
const app = new Hono();
// Start device linking
app.post("/api/bots/:id/register", async (c) => {
const id = c.req.param("id");
const { phoneNumber, deviceName } = await c.req.json<{
phoneNumber: string;
deviceName?: string;
}>();
try {
const result = await service.register(id, phoneNumber, deviceName);
logger.info({ id }, "Device linking started");
return c.json(result);
} catch (error) {
logger.error({ id, error: errorMessage(error) }, "Failed to start device linking");
return c.json({ error: errorMessage(error) }, 500);
}
});
// Bot status
app.get("/api/bots/:id", async (c) => {
const id = c.req.param("id");
try {
return c.json(await service.getBot(id));
} catch (error) {
return c.json({ error: errorMessage(error) }, 500);
}
});
// Send message
app.post("/api/bots/:id/send", async (c) => {
const id = c.req.param("id");
const { recipient, message, attachments, autoGroup } = await c.req.json<{
recipient: string;
message: string;
attachments?: Array<{ data: string; filename: string; mime_type: string }>;
autoGroup?: { ticketNumber: string };
}>();
try {
const result = await service.send(id, recipient, message, attachments, autoGroup);
logger.info({ id, recipient: result.recipient, 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);
}
});
// Unregister bot
app.post("/api/bots/:id/unregister", async (c) => {
const id = c.req.param("id");
try {
await service.unregister(id);
logger.info({ id }, "Bot unregistered");
return c.body(null, 200);
} catch (error) {
logger.error({ id, error: errorMessage(error) }, "Failed to unregister bot");
return c.json({ error: errorMessage(error) }, 500);
}
});
// Create group
app.post("/api/bots/:id/groups", async (c) => {
const id = c.req.param("id");
const { name, members, description } = await c.req.json<{
name: string;
members: string[];
description?: string;
}>();
try {
const result = await service.createGroup(id, name, members, description);
logger.info({ id, groupId: result.groupId }, "Group created");
return c.json(result);
} catch (error) {
logger.error({ id, error: errorMessage(error) }, "Failed to create group");
return c.json({ error: errorMessage(error) }, 500);
}
});
// Update group
app.put("/api/bots/:id/groups/:groupId", async (c) => {
const id = c.req.param("id");
const groupId = c.req.param("groupId");
const { name, description } = await c.req.json<{
name?: string;
description?: string;
}>();
try {
await service.updateGroup(id, groupId, name, description);
logger.info({ id, groupId }, "Group updated");
return c.json({ success: true });
} catch (error) {
logger.error({ id, error: errorMessage(error) }, "Failed to update group");
return c.json({ error: errorMessage(error) }, 500);
}
});
// List groups
app.get("/api/bots/:id/groups", async (c) => {
const id = c.req.param("id");
try {
const groups = await service.listGroups(id);
return c.json(groups);
} catch (error) {
logger.error({ id, error: errorMessage(error) }, "Failed to list groups");
return c.json({ error: errorMessage(error) }, 500);
}
});
// Health check
app.get("/api/health", (c) => {
return c.json({ status: "ok" });
});
return app;
}

View file

@ -0,0 +1,462 @@
import fs from "node:fs";
import path from "node:path";
import { createLogger } from "@link-stack/logger";
import { getMaxAttachmentSize, getMaxTotalAttachmentSize, MAX_ATTACHMENTS } from "./attachments.ts";
import { SignalCli, SignalEnvelope } from "./signal-cli.ts";
const logger = createLogger("bridge-signal-service");
interface BotMapping {
[botId: string]: {
phoneNumber: string;
webhookToken?: string;
};
}
interface Attachment {
data: string; // base64
filename: string;
mime_type: string;
}
interface SendResult {
recipient: string;
timestamp: number;
source: string;
groupId?: string;
}
export default class SignalService {
private cli: SignalCli | null = null;
private botMapping: BotMapping = {};
private dataDir: string;
private mappingFile: string;
private autoGroupsEnabled: boolean;
constructor() {
this.dataDir = process.env.SIGNAL_DATA_DIR || "/home/node/signal-data";
this.mappingFile = path.join(this.dataDir, "bot-mapping.json");
this.autoGroupsEnabled = process.env.BRIDGE_SIGNAL_AUTO_GROUPS?.toLowerCase() === "true";
}
async initialize(): Promise<void> {
// Ensure data directory exists
if (!fs.existsSync(this.dataDir)) {
fs.mkdirSync(this.dataDir, { recursive: true });
}
// Load bot mapping
this.loadBotMapping();
// Start signal-cli
this.cli = new SignalCli(this.dataDir);
await this.cli.start();
// Register message listener
this.cli.on("message", ({ account, envelope }: { account: string; envelope: SignalEnvelope }) => {
this.handleIncomingMessage(account, envelope).catch((error) => {
logger.error({ err: error, account }, "Error handling incoming message");
});
});
this.cli.on("close", (code: number | null) => {
logger.warn({ code }, "signal-cli process closed unexpectedly");
});
this.cli.on("error", (err: Error) => {
logger.error({ err }, "signal-cli process error");
});
logger.info({ dataDir: this.dataDir, botCount: Object.keys(this.botMapping).length }, "SignalService initialized");
}
async teardown(): Promise<void> {
if (this.cli) {
this.cli.close();
this.cli = null;
}
}
// --- Bot management ---
async register(botId: string, phoneNumber: string, deviceName = "Zammad"): Promise<{ linkUri: string }> {
this.validateBotId(botId);
if (!this.cli) throw new Error("SignalService not initialized");
logger.info({ botId, phoneNumber }, "Starting device linking");
const result = (await this.cli.call("startLink")) as Record<string, unknown> | undefined;
const linkUri = result?.deviceLinkUri as string;
if (!linkUri) {
throw new Error("signal-cli startLink did not return a deviceLinkUri");
}
// Finish linking in the background
(async () => {
try {
const finishResult = await this.cli!.call("finishLink", {
deviceLinkUri: linkUri,
deviceName,
});
const linkedNumber = (finishResult as string) || phoneNumber;
this.botMapping[botId] = { phoneNumber: linkedNumber };
this.saveBotMapping();
logger.info({ botId, phoneNumber: linkedNumber }, "Device linking completed");
} catch (error) {
logger.error({ err: error, botId }, "Device linking failed");
}
})();
return { linkUri };
}
async getBot(botId: string): Promise<{ registered: boolean; phoneNumber: string | null }> {
this.validateBotId(botId);
const mapping = this.botMapping[botId];
if (!mapping) {
return { registered: false, phoneNumber: null };
}
return { registered: true, phoneNumber: mapping.phoneNumber };
}
async unregister(botId: string): Promise<void> {
this.validateBotId(botId);
const mapping = this.botMapping[botId];
if (!mapping) {
logger.warn({ botId }, "Bot not found for unregister");
return;
}
delete this.botMapping[botId];
this.saveBotMapping();
logger.info({ botId }, "Bot unregistered");
}
// --- Messaging ---
async send(
botId: string,
recipient: string,
message: string,
attachments?: Attachment[],
autoGroup?: { ticketNumber: string }
): Promise<SendResult> {
this.validateBotId(botId);
if (!this.cli) throw new Error("SignalService not initialized");
const mapping = this.botMapping[botId];
if (!mapping) throw new Error(`Bot ${botId} is not registered`);
const account = mapping.phoneNumber;
let finalRecipient = recipient;
let groupId: string | undefined;
// Auto-group: create a group if enabled and recipient is a phone number
if (this.autoGroupsEnabled && autoGroup && !recipient.startsWith("group.")) {
try {
const groupName = `Support Request: ${autoGroup.ticketNumber}`;
logger.info({ botId, groupName, recipient }, "Creating auto-group");
const createResult = (await this.cli.call("updateGroup", {
account,
name: groupName,
members: [recipient],
description: "Private support conversation",
})) as Record<string, unknown> | undefined;
if (createResult?.groupId) {
groupId = createResult.groupId as string;
finalRecipient = groupId;
logger.info({ botId, groupId, groupName }, "Auto-group created");
}
} catch (error) {
logger.error({ err: error, botId }, "Failed to create auto-group, sending to original recipient");
}
}
// Build base64 attachments
const base64Attachments: string[] = [];
if (attachments && attachments.length > 0) {
const MAX_SIZE = getMaxAttachmentSize();
const MAX_TOTAL = getMaxTotalAttachmentSize();
if (attachments.length > MAX_ATTACHMENTS) {
logger.warn({ count: attachments.length, max: MAX_ATTACHMENTS }, "Too many attachments, truncating");
attachments = attachments.slice(0, MAX_ATTACHMENTS);
}
let totalSize = 0;
for (const att of attachments) {
const estimatedSize = (att.data.length * 3) / 4;
if (estimatedSize > MAX_SIZE) {
logger.warn({ filename: att.filename, size: estimatedSize }, "Attachment too large, skipping");
continue;
}
totalSize += estimatedSize;
if (totalSize > MAX_TOTAL) {
logger.warn({ totalSize }, "Total attachment size exceeded, skipping remaining");
break;
}
base64Attachments.push(att.data);
}
}
// Send the message
const isGroup = finalRecipient.startsWith("group.");
const sendParams: Record<string, unknown> = {
account,
message,
};
if (isGroup) {
sendParams.groupId = finalRecipient;
} else {
sendParams.recipients = [finalRecipient];
}
if (base64Attachments.length > 0) {
sendParams.base64Attachments = base64Attachments;
}
const result = (await this.cli.call("send", sendParams)) as Record<string, unknown> | undefined;
const timestamp = (result?.timestamp as number) || Date.now();
return {
recipient: finalRecipient,
timestamp,
source: account,
groupId,
};
}
// --- Groups ---
async createGroup(
botId: string,
name: string,
members: string[],
description?: string
): Promise<{ groupId: string }> {
this.validateBotId(botId);
if (!this.cli) throw new Error("SignalService not initialized");
const mapping = this.botMapping[botId];
if (!mapping) throw new Error(`Bot ${botId} is not registered`);
const params: Record<string, unknown> = {
account: mapping.phoneNumber,
name,
members,
};
if (description) params.description = description;
const result = (await this.cli.call("updateGroup", params)) as Record<string, unknown> | undefined;
return { groupId: (result?.groupId as string) || String(result) };
}
async updateGroup(botId: string, groupId: string, name?: string, description?: string): Promise<void> {
this.validateBotId(botId);
if (!this.cli) throw new Error("SignalService not initialized");
const mapping = this.botMapping[botId];
if (!mapping) throw new Error(`Bot ${botId} is not registered`);
const params: Record<string, unknown> = {
account: mapping.phoneNumber,
groupId,
};
if (name) params.name = name;
if (description) params.description = description;
await this.cli.call("updateGroup", params);
}
async listGroups(botId: string): Promise<unknown[]> {
this.validateBotId(botId);
if (!this.cli) throw new Error("SignalService not initialized");
const mapping = this.botMapping[botId];
if (!mapping) throw new Error(`Bot ${botId} is not registered`);
const result = await this.cli.call("listGroups", { account: mapping.phoneNumber });
return Array.isArray(result) ? result : [];
}
// --- Incoming message handler ---
private async handleIncomingMessage(account: string, envelope: SignalEnvelope): Promise<void> {
// Find botId for this account
const botId = this.findBotIdByAccount(account);
if (!botId) {
logger.debug({ account }, "No bot mapping for account, ignoring message");
return;
}
const source = envelope.sourceNumber || envelope.source;
const sourceUuid = envelope.sourceUuid;
// Skip messages from self
if (source === account) {
return;
}
const dataMessage = envelope.dataMessage;
if (!dataMessage) {
// Could be typing indicator, receipt, etc. -- ignore
return;
}
// Check for group info
const isGroup = !!dataMessage.groupInfo?.groupId;
const groupId = dataMessage.groupInfo?.groupId;
const groupType = dataMessage.groupInfo?.type;
// Detect group join events
if (
isGroup &&
groupType &&
["DELIVER", "UPDATE"].includes(groupType) && // Group update events (member joins) -- forward to Zammad
groupType === "UPDATE" &&
source
) {
await this.postGroupMemberJoined(botId, groupId!, source);
}
// Process data messages with content
const messageText = dataMessage.message;
if (!messageText && (!dataMessage.attachments || dataMessage.attachments.length === 0)) {
return;
}
// Handle attachments
let attachment: string | undefined;
let filename: string | undefined;
let mimeType: string | undefined;
if (dataMessage.attachments && dataMessage.attachments.length > 0) {
const att = dataMessage.attachments[0];
const storedFile = att.storedFilename;
if (storedFile) {
const filePath = path.join(this.dataDir, "attachments", storedFile);
try {
if (fs.existsSync(filePath)) {
const fileData = fs.readFileSync(filePath);
attachment = fileData.toString("base64");
filename = att.filename || storedFile;
mimeType = att.contentType || "application/octet-stream";
logger.info({ filename, mimeType, size: fileData.length }, "Attachment found");
}
} catch (error) {
logger.error({ err: error, filePath }, "Failed to read attachment file");
}
}
}
const messageId = `${source}@${dataMessage.timestamp || envelope.timestamp}`;
const sentAt = dataMessage.timestamp || envelope.timestamp;
const payload: Record<string, unknown> = {
from: source,
to: isGroup ? groupId : account,
user_id: sourceUuid,
message: messageText || "",
message_id: messageId,
sent_at: sentAt ? new Date(sentAt).toISOString() : new Date().toISOString(),
is_group: isGroup,
};
if (attachment) {
payload.attachment = attachment;
payload.filename = filename;
payload.mime_type = mimeType;
}
// POST to Zammad webhook
const zammadUrl = process.env.ZAMMAD_URL || "http://zammad-nginx:8080";
try {
const response = await fetch(`${zammadUrl}/api/v1/channels_cdr_signal_bot_webhook/${botId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (response.ok) {
logger.info({ botId, messageId }, "Message forwarded to Zammad");
} else {
const errorText = await response.text();
logger.error({ status: response.status, error: errorText, botId }, "Failed to send message to Zammad");
}
} catch (error) {
logger.error({ err: error, botId }, "Failed to POST to Zammad webhook");
}
}
private async postGroupMemberJoined(botId: string, groupId: string, memberPhone: string): Promise<void> {
const zammadUrl = process.env.ZAMMAD_URL || "http://zammad-nginx:8080";
const payload = {
event: "group_member_joined",
group_id: groupId,
member_phone: memberPhone,
timestamp: new Date().toISOString(),
};
try {
const response = await fetch(`${zammadUrl}/api/v1/channels_cdr_signal_bot_webhook/${botId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (response.ok) {
logger.info({ botId, groupId, memberPhone }, "Group member join notification sent to Zammad");
} else {
logger.error({ status: response.status, botId, groupId }, "Failed to notify Zammad of group member join");
}
} catch (error) {
logger.error({ err: error, botId }, "Failed to POST group_member_joined to Zammad");
}
}
private findBotIdByAccount(account: string): string | undefined {
for (const [botId, mapping] of Object.entries(this.botMapping)) {
if (mapping.phoneNumber === account) {
return botId;
}
}
return undefined;
}
private validateBotId(botId: string): void {
if (!botId || !/^[a-zA-Z0-9_-]+$/.test(botId)) {
throw new Error(`Invalid bot ID: ${botId}`);
}
}
private loadBotMapping(): void {
try {
if (fs.existsSync(this.mappingFile)) {
const data = fs.readFileSync(this.mappingFile, "utf8");
this.botMapping = JSON.parse(data);
logger.info({ count: Object.keys(this.botMapping).length }, "Loaded bot mapping");
}
} catch (error) {
logger.error({ err: error }, "Failed to load bot mapping, starting fresh");
this.botMapping = {};
}
}
private saveBotMapping(): void {
try {
fs.writeFileSync(this.mappingFile, JSON.stringify(this.botMapping, null, 2));
logger.debug("Saved bot mapping");
} catch (error) {
logger.error({ err: error }, "Failed to save bot mapping");
}
}
}

View file

@ -0,0 +1,247 @@
import { ChildProcess, spawn } from "node:child_process";
import { EventEmitter } from "node:events";
import { createInterface, Interface } from "node:readline";
import { createLogger } from "@link-stack/logger";
const logger = createLogger("bridge-signal-cli");
interface PendingRequest {
resolve: (value: unknown) => void;
reject: (reason: unknown) => void;
method: string;
timer: ReturnType<typeof setTimeout>;
}
interface JsonRpcRequest {
jsonrpc: "2.0";
method: string;
params?: Record<string, unknown>;
id: string;
}
interface JsonRpcResponse {
jsonrpc: "2.0";
id?: string;
result?: unknown;
error?: { code: number; message: string; data?: unknown };
method?: string;
params?: Record<string, unknown>;
}
export interface SignalEnvelope {
source?: string;
sourceNumber?: string;
sourceUuid?: string;
sourceName?: string;
timestamp?: number;
dataMessage?: {
timestamp?: number;
message?: string;
groupInfo?: {
groupId?: string;
type?: string;
};
attachments?: Array<{
id?: string;
contentType?: string;
filename?: string;
size?: number;
storedFilename?: string;
}>;
};
syncMessage?: {
sentMessage?: {
destination?: string;
destinationNumber?: string;
timestamp?: number;
message?: string;
groupInfo?: { groupId?: string };
};
};
typingMessage?: unknown;
receiptMessage?: unknown;
}
const REQUEST_TIMEOUT_MS = 60_000;
// eslint-disable-next-line unicorn/prefer-event-target
export class SignalCli extends EventEmitter {
private process: ChildProcess | null = null;
private readline: Interface | null = null;
private pending: Map<string, PendingRequest> = new Map();
private nextId = 1;
private configDir: string;
private closed = false;
constructor(configDir: string) {
super();
this.configDir = configDir;
}
async start(): Promise<void> {
const args = ["--config", this.configDir, "--output=json", "jsonRpc"];
logger.info({ configDir: this.configDir, args }, "Starting signal-cli subprocess");
this.process = spawn("signal-cli", args, {
stdio: ["pipe", "pipe", "pipe"],
});
if (!this.process.stdout || !this.process.stdin) {
throw new Error("Failed to open signal-cli stdio pipes");
}
this.readline = createInterface({
input: this.process.stdout,
crlfDelay: Infinity,
});
this.readline.on("line", (line: string) => {
this.handleLine(line);
});
this.process.stderr?.on("data", (data: Buffer) => {
const msg = data.toString().trim();
if (msg) {
logger.warn({ stderr: msg }, "signal-cli stderr");
}
});
this.process.on("close", (code: number | null) => {
this.closed = true;
logger.info({ code }, "signal-cli process exited");
this.rejectAllPending("signal-cli process exited");
this.emit("close", code);
});
this.process.on("error", (err: Error) => {
logger.error({ err }, "signal-cli process error");
this.emit("error", err);
});
// Wait briefly for the process to start (or fail immediately)
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
resolve();
}, 1000);
this.process!.on("error", (err) => {
clearTimeout(timer);
reject(err);
});
this.process!.on("close", (code) => {
if (code !== null && code !== 0) {
clearTimeout(timer);
reject(new Error(`signal-cli exited with code ${code}`));
}
});
});
logger.info("signal-cli subprocess started");
}
async call(method: string, params: Record<string, unknown> = {}): Promise<unknown> {
if (this.closed || !this.process?.stdin) {
throw new Error("signal-cli is not running");
}
const id = String(this.nextId++);
const request: JsonRpcRequest = {
jsonrpc: "2.0",
method,
params,
id,
};
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
reject(new Error(`signal-cli request timed out: ${method} (id=${id})`));
}, REQUEST_TIMEOUT_MS);
this.pending.set(id, { resolve, reject, method, timer });
const line = JSON.stringify(request) + "\n";
logger.debug({ method, id, params: Object.keys(params) }, "Sending JSON-RPC request");
this.process!.stdin!.write(line, (err) => {
if (err) {
clearTimeout(timer);
this.pending.delete(id);
reject(new Error(`Failed to write to signal-cli stdin: ${err.message}`));
}
});
});
}
close(): void {
this.closed = true;
this.rejectAllPending("signal-cli closing");
if (this.readline) {
this.readline.close();
this.readline = null;
}
if (this.process) {
this.process.kill("SIGTERM");
// Force kill after 5 seconds
setTimeout(() => {
if (this.process && !this.process.killed) {
this.process.kill("SIGKILL");
}
}, 5000);
this.process = null;
}
}
private handleLine(line: string): void {
if (!line.trim()) return;
let msg: JsonRpcResponse;
try {
msg = JSON.parse(line);
} catch {
logger.warn({ line: line.slice(0, 200) }, "Non-JSON output from signal-cli");
return;
}
// Response to a pending request
if (msg.id !== undefined) {
const pending = this.pending.get(String(msg.id));
if (pending) {
this.pending.delete(String(msg.id));
clearTimeout(pending.timer);
if (msg.error) {
logger.warn({ method: pending.method, error: msg.error }, "JSON-RPC error response");
pending.reject(new Error(`signal-cli ${pending.method}: ${msg.error.message}`));
} else {
logger.debug({ method: pending.method, id: msg.id }, "JSON-RPC response received");
pending.resolve(msg.result);
}
} else {
logger.warn({ id: msg.id }, "Received response for unknown request id");
}
return;
}
// Notification (no id field)
if (msg.method === "receive") {
const envelope = msg.params?.envelope as SignalEnvelope | undefined;
const account = msg.params?.account as string | undefined;
if (envelope) {
logger.debug({ account, source: envelope.source || envelope.sourceNumber }, "Received message notification");
this.emit("message", { account, envelope });
}
}
}
private rejectAllPending(reason: string): void {
for (const [_id, pending] of this.pending) {
clearTimeout(pending.timer);
pending.reject(new Error(reason));
}
this.pending.clear();
}
}

View file

@ -0,0 +1,9 @@
{
"extends": "@link-stack/typescript-config/tsconfig.node.json",
"compilerOptions": {
"outDir": "build/main",
"rootDir": "src"
},
"include": ["src/**/*.ts", "src/**/.*.ts"],
"exclude": ["node_modules/**"]
}

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/**"]