Make APIs more similar
This commit is contained in:
parent
9f0e1f8b61
commit
c40d7d056e
57 changed files with 3994 additions and 1801 deletions
3
apps/bridge-deltachat/eslint.config.mjs
Normal file
3
apps/bridge-deltachat/eslint.config.mjs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import config from "@link-stack/eslint-config/node";
|
||||
|
||||
export default config;
|
||||
|
|
@ -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/"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 = "";
|
||||
|
|
|
|||
|
|
@ -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/**"]
|
||||
|
|
|
|||
56
apps/bridge-signal/Dockerfile
Normal file
56
apps/bridge-signal/Dockerfile
Normal 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"]
|
||||
5
apps/bridge-signal/docker-entrypoint.sh
Normal file
5
apps/bridge-signal/docker-entrypoint.sh
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
echo "starting bridge-signal"
|
||||
exec dumb-init pnpm run start
|
||||
3
apps/bridge-signal/eslint.config.mjs
Normal file
3
apps/bridge-signal/eslint.config.mjs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import config from "@link-stack/eslint-config/node";
|
||||
|
||||
export default config;
|
||||
32
apps/bridge-signal/package.json
Normal file
32
apps/bridge-signal/package.json
Normal 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/"
|
||||
}
|
||||
}
|
||||
35
apps/bridge-signal/src/attachments.ts
Normal file
35
apps/bridge-signal/src/attachments.ts
Normal 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;
|
||||
33
apps/bridge-signal/src/index.ts
Normal file
33
apps/bridge-signal/src/index.ts
Normal 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);
|
||||
});
|
||||
130
apps/bridge-signal/src/routes.ts
Normal file
130
apps/bridge-signal/src/routes.ts
Normal 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;
|
||||
}
|
||||
462
apps/bridge-signal/src/service.ts
Normal file
462
apps/bridge-signal/src/service.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
247
apps/bridge-signal/src/signal-cli.ts
Normal file
247
apps/bridge-signal/src/signal-cli.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
9
apps/bridge-signal/tsconfig.json
Normal file
9
apps/bridge-signal/tsconfig.json
Normal 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/**"]
|
||||
}
|
||||
3
apps/bridge-whatsapp/eslint.config.mjs
Normal file
3
apps/bridge-whatsapp/eslint.config.mjs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import config from "@link-stack/eslint-config/node";
|
||||
|
||||
export default config;
|
||||
|
|
@ -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/"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/**"]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue