Delta chat WIP

This commit is contained in:
Darren Clarke 2026-02-14 21:37:50 +01:00
parent 40c14ece94
commit 9601e179bc
32 changed files with 2037 additions and 1 deletions

View file

@ -0,0 +1,49 @@
FROM node:22-bookworm-slim AS base
FROM base AS builder
ARG APP_DIR=/opt/bridge-deltachat
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-deltachat --docker
FROM base AS installer
ARG APP_DIR=/opt/bridge-deltachat
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-deltachat
FROM base as runner
ARG BUILD_DATE
ARG VERSION
ARG APP_DIR=/opt/bridge-deltachat
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
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-deltachat/
RUN chmod +x docker-entrypoint.sh
USER node
RUN mkdir /home/node/deltachat-data
EXPOSE 5001
ENV PORT 5001
ENV NODE_ENV production
ENV COREPACK_ENABLE_NETWORK=0
ENTRYPOINT ["/opt/bridge-deltachat/apps/bridge-deltachat/docker-entrypoint.sh"]

View file

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

View file

@ -0,0 +1,28 @@
{
"name": "@link-stack/bridge-deltachat",
"version": "3.5.0-beta.1",
"main": "build/main/index.js",
"author": "Darren Clarke <darren@redaranj.com>",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@deltachat/jsonrpc-client": "^1.151.1",
"@deltachat/stdio-rpc-server": "^1.151.1",
"@hapi/hapi": "^21.4.3",
"@hapipal/schmervice": "^3.0.0",
"@hapipal/toys": "^4.0.0",
"hapi-pino": "^13.0.0",
"pino": "^9.6.0",
"pino-pretty": "^13.0.0"
},
"devDependencies": {
"@types/node": "*",
"dotenv-cli": "^10.0.0",
"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"
}
}

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 ? parseInt(envValue, 10) : 50;
if (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,42 @@
import * as Hapi from "@hapi/hapi";
import hapiPino from "hapi-pino";
import Schmervice from "@hapipal/schmervice";
import DeltaChatService from "./service.ts";
import {
ConfigureBotRoute,
GetBotRoute,
SendMessageRoute,
UnconfigureBotRoute,
HealthRoute,
} from "./routes.ts";
import { createLogger } from "./lib/logger";
const logger = createLogger("bridge-deltachat-index");
const server = Hapi.server({ port: 5001 });
const startServer = async () => {
await server.register({ plugin: hapiPino });
server.route(ConfigureBotRoute);
server.route(GetBotRoute);
server.route(SendMessageRoute);
server.route(UnconfigureBotRoute);
server.route(HealthRoute);
await server.register(Schmervice);
server.registerService(DeltaChatService);
await server.start();
return server;
};
const main = async () => {
await startServer();
};
main().catch((err) => {
logger.error(err);
process.exit(1);
});

View file

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

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

View file

@ -0,0 +1,381 @@
import { Server } from "@hapi/hapi";
import { Service } from "@hapipal/schmervice";
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";
const logger = createLogger("bridge-deltachat-service");
interface BotMapping {
[botId: string]: number;
}
export default class DeltaChatService extends Service {
private dc: DeltaChat | null = null;
private botMapping: BotMapping = {};
private dataDir: string;
private mappingFile: string;
constructor(server: Server, options: never) {
super(server, options);
this.dataDir = process.env.DELTACHAT_DATA_DIR || "/home/node/deltachat-data";
this.mappingFile = path.join(this.dataDir, "bot-mapping.json");
}
async initialize(): Promise<void> {
if (!fs.existsSync(this.dataDir)) {
fs.mkdirSync(this.dataDir, { recursive: true });
}
logger.info({ dataDir: this.dataDir }, "Starting deltachat-rpc-server");
this.dc = await startDeltaChat(this.dataDir);
logger.info("deltachat-rpc-server started");
this.loadBotMapping();
for (const [botId, accountId] of Object.entries(this.botMapping)) {
try {
const isConfigured = await this.dc.rpc.isConfigured(accountId);
if (isConfigured) {
await this.dc.rpc.startIo(accountId);
logger.info({ botId, accountId }, "Resumed IO for existing bot");
} else {
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");
delete this.botMapping[botId];
}
}
this.saveBotMapping();
this.registerEventListeners();
}
async teardown(): Promise<void> {
if (this.dc) {
for (const [botId, accountId] of Object.entries(this.botMapping)) {
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");
}
}
this.dc.close();
this.dc = null;
}
}
private loadBotMapping(): void {
if (fs.existsSync(this.mappingFile)) {
try {
const data = fs.readFileSync(this.mappingFile, "utf-8");
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");
this.botMapping = {};
}
}
}
private saveBotMapping(): void {
fs.writeFileSync(this.mappingFile, JSON.stringify(this.botMapping, null, 2), "utf-8");
}
private validateBotId(id: string): void {
if (!/^[a-zA-Z0-9_-]+$/.test(id)) {
throw new Error(`Invalid bot ID format: ${id}`);
}
}
private getBotIdForAccount(accountId: number): string | undefined {
return Object.entries(this.botMapping).find(([, aid]) => aid === accountId)?.[0];
}
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");
});
}
private async handleIncomingMessage(
accountId: number,
chatId: number,
msgId: number,
): Promise<void> {
if (!this.dc) return;
const botId = this.getBotIdForAccount(accountId);
if (!botId) {
logger.warn({ accountId }, "Received message for unknown account");
return;
}
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");
return;
}
const contact = await this.dc.rpc.getContact(accountId, msg.fromId);
const senderEmail = contact.address;
const botConfig = await this.dc.rpc.getConfig(accountId, "configured_addr");
const botEmail = botConfig || "";
logger.info({ botId, senderEmail, msgId }, "Processing incoming message");
let attachment: string | undefined;
let filename: string | undefined;
let mimeType: string | undefined;
if (msg.file) {
try {
const fileData = fs.readFileSync(msg.file);
attachment = fileData.toString("base64");
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");
}
}
const payload: Record<string, unknown> = {
from: senderEmail,
to: botEmail,
message: msg.text || "",
message_id: String(msgId),
sent_at: new Date(msg.timestamp * 1000).toISOString(),
};
if (attachment) {
payload.attachment = attachment;
payload.filename = filename;
payload.mime_type = mimeType;
}
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),
},
);
if (!response.ok) {
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");
}
try {
await this.dc.rpc.markseenMsgs(accountId, [msgId]);
} catch (err) {
logger.error({ err, msgId }, "Failed to mark message as seen");
}
}
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");
if (this.botMapping[botId] !== undefined) {
throw new Error(`Bot ${botId} is already configured`);
}
const accountId = await this.dc.rpc.addAccount();
logger.info({ botId, accountId, email }, "Created new account");
try {
await this.dc.rpc.batchSetConfig(accountId, {
addr: email,
mail_pw: password,
bot: "1",
e2ee_enabled: "1",
});
logger.info({ botId, accountId }, "Configuring account (verifying credentials)...");
await this.dc.rpc.configure(accountId);
logger.info({ botId, accountId }, "Account configured successfully");
await this.dc.rpc.startIo(accountId);
logger.info({ botId, accountId }, "IO started");
this.botMapping[botId] = accountId;
this.saveBotMapping();
return { accountId, email };
} catch (err) {
logger.error({ botId, accountId, err }, "Configuration failed, removing account");
try {
await this.dc.rpc.removeAccount(accountId);
} catch (removeErr) {
logger.error({ removeErr }, "Failed to clean up account after configuration failure");
}
throw err;
}
}
async getBot(botId: string): Promise<{ configured: boolean; email: string | null }> {
this.validateBotId(botId);
const accountId = this.botMapping[botId];
if (accountId === undefined || !this.dc) {
return { configured: false, email: null };
}
try {
const isConfigured = await this.dc.rpc.isConfigured(accountId);
const email = await this.dc.rpc.getConfig(accountId, "configured_addr");
return { configured: isConfigured, email: email || null };
} catch {
return { configured: false, email: null };
}
}
async unconfigure(botId: string): Promise<void> {
this.validateBotId(botId);
if (!this.dc) throw new Error("DeltaChat not initialized");
const accountId = this.botMapping[botId];
if (accountId === undefined) {
logger.warn({ botId }, "Bot not found for unconfigure");
return;
}
try {
await this.dc.rpc.stopIo(accountId);
} catch (err) {
logger.warn({ botId, accountId, err }, "Error stopping IO during unconfigure");
}
try {
await this.dc.rpc.removeAccount(accountId);
} catch (err) {
logger.warn({ botId, accountId, err }, "Error removing account during unconfigure");
}
delete this.botMapping[botId];
this.saveBotMapping();
logger.info({ botId, accountId }, "Bot unconfigured and removed");
}
async send(
botId: string,
email: string,
message: 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");
const accountId = this.botMapping[botId];
if (accountId === undefined) {
throw new Error(`Bot ${botId} is not configured`);
}
const contactId = await this.dc.rpc.createContact(accountId, email, "");
const chatId = await this.dc.rpc.createChatByContactId(accountId, contactId);
if (attachments && attachments.length > 0) {
const MAX_ATTACHMENT_SIZE = getMaxAttachmentSize();
const MAX_TOTAL_SIZE = getMaxTotalAttachmentSize();
if (attachments.length > MAX_ATTACHMENTS) {
throw new Error(`Too many attachments: ${attachments.length} (max ${MAX_ATTACHMENTS})`);
}
let totalSize = 0;
for (const att of attachments) {
const estimatedSize = (att.data.length * 3) / 4;
if (estimatedSize > MAX_ATTACHMENT_SIZE) {
logger.warn(
{ filename: att.filename, size: estimatedSize, maxSize: MAX_ATTACHMENT_SIZE },
"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");
break;
}
const buffer = Buffer.from(att.data, "base64");
const tmpFile = path.join(os.tmpdir(), `dc-${Date.now()}-${att.filename}`);
fs.writeFileSync(tmpFile, buffer);
try {
await this.dc.rpc.sendMsg(accountId, chatId, {
text: message,
file: tmpFile,
});
// Only include text with the first attachment; clear for subsequent
message = "";
} finally {
try {
fs.unlinkSync(tmpFile);
} catch {
// ignore cleanup errors
}
}
}
// If we had message text but all attachments were skipped, send text only
if (message) {
await this.dc.rpc.miscSendTextMessage(accountId, chatId, message);
}
} else {
await this.dc.rpc.miscSendTextMessage(accountId, chatId, message);
}
const botEmail = (await this.dc.rpc.getConfig(accountId, "configured_addr")) || botId;
return {
recipient: email,
timestamp: new Date().toISOString(),
source: botEmail,
};
}
}

View file

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

View file

@ -0,0 +1,27 @@
{
"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"]
},
"include": ["src/**/*.ts", "src/**/.*.ts"],
"exclude": ["node_modules/**"]
}