Delta chat WIP
This commit is contained in:
parent
40c14ece94
commit
9601e179bc
32 changed files with 2037 additions and 1 deletions
49
apps/bridge-deltachat/Dockerfile
Normal file
49
apps/bridge-deltachat/Dockerfile
Normal 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"]
|
||||
5
apps/bridge-deltachat/docker-entrypoint.sh
Executable file
5
apps/bridge-deltachat/docker-entrypoint.sh
Executable file
|
|
@ -0,0 +1,5 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
echo "starting bridge-deltachat"
|
||||
exec dumb-init pnpm run start
|
||||
28
apps/bridge-deltachat/package.json
Normal file
28
apps/bridge-deltachat/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
35
apps/bridge-deltachat/src/attachments.ts
Normal file
35
apps/bridge-deltachat/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 ? 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;
|
||||
42
apps/bridge-deltachat/src/index.ts
Normal file
42
apps/bridge-deltachat/src/index.ts
Normal 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);
|
||||
});
|
||||
77
apps/bridge-deltachat/src/lib/logger.ts
Normal file
77
apps/bridge-deltachat/src/lib/logger.ts
Normal 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;
|
||||
111
apps/bridge-deltachat/src/routes.ts
Normal file
111
apps/bridge-deltachat/src/routes.ts
Normal 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);
|
||||
},
|
||||
},
|
||||
});
|
||||
381
apps/bridge-deltachat/src/service.ts
Normal file
381
apps/bridge-deltachat/src/service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
8
apps/bridge-deltachat/src/types.ts
Normal file
8
apps/bridge-deltachat/src/types.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import type DeltaChatService from "./service.ts";
|
||||
|
||||
declare module "@hapipal/schmervice" {
|
||||
interface SchmerviceDecorator {
|
||||
(namespace: "deltachat"): DeltaChatService;
|
||||
}
|
||||
type ServiceFunctionalInterface = { name: string };
|
||||
}
|
||||
27
apps/bridge-deltachat/tsconfig.json
Normal file
27
apps/bridge-deltachat/tsconfig.json
Normal 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/**"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue