Make APIs more similar

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

View file

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

View file

@ -4,23 +4,31 @@
"main": "build/main/index.js",
"author": "Darren Clarke <darren@redaranj.com>",
"license": "AGPL-3.0-or-later",
"prettier": "@link-stack/prettier-config",
"dependencies": {
"@deltachat/jsonrpc-client": "^1.151.1",
"@deltachat/stdio-rpc-server": "^1.151.1",
"@hono/node-server": "^1.13.8",
"hono": "^4.7.4",
"pino": "^9.6.0",
"pino-pretty": "^13.0.0"
"@link-stack/logger": "workspace:*"
},
"devDependencies": {
"@link-stack/eslint-config": "workspace:*",
"@link-stack/prettier-config": "workspace:*",
"@link-stack/typescript-config": "workspace:*",
"@types/node": "*",
"dotenv-cli": "^10.0.0",
"eslint": "^9.23.0",
"prettier": "^3.5.3",
"tsx": "^4.20.6",
"typescript": "^5.9.3"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"dev": "dotenv -- tsx src/index.ts",
"start": "node build/main/index.js"
"start": "node build/main/index.js",
"lint": "eslint src/",
"format": "prettier --write src/",
"format:check": "prettier --check src/"
}
}

View file

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

View file

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

View file

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

View file

@ -1,9 +1,12 @@
import { createLogger } from "@link-stack/logger";
import { Hono } from "hono";
import type DeltaChatService from "./service.ts";
import { createLogger } from "./lib/logger";
const logger = createLogger("bridge-deltachat-routes");
const errorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error));
export function createRoutes(service: DeltaChatService): Hono {
const app = new Hono();
@ -15,9 +18,9 @@ export function createRoutes(service: DeltaChatService): Hono {
const result = await service.configure(id, email, password);
logger.info({ id, email }, "Bot configured");
return c.json(result);
} catch (err: any) {
logger.error({ id, error: err.message }, "Failed to configure bot");
return c.json({ error: err.message }, 500);
} catch (error) {
logger.error({ id, error: errorMessage(error) }, "Failed to configure bot");
return c.json({ error: errorMessage(error) }, 500);
}
});
@ -34,9 +37,14 @@ export function createRoutes(service: DeltaChatService): Hono {
attachments?: Array<{ data: string; filename: string; mime_type: string }>;
}>();
const result = await service.send(id, email, message, attachments);
logger.info({ id, attachmentCount: attachments?.length || 0 }, "Sent message");
return c.json({ result });
try {
const result = await service.send(id, email, message, attachments);
logger.info({ id, attachmentCount: attachments?.length || 0 }, "Sent message");
return c.json({ result });
} catch (error) {
logger.error({ id, error: errorMessage(error) }, "Failed to send message");
return c.json({ error: errorMessage(error) }, 500);
}
});
app.post("/api/bots/:id/unconfigure", async (c) => {

View file

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

View file

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