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/**"]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue