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/**"]
|
||||||
|
}
|
||||||
20
docker/compose/bridge-deltachat.yml
Normal file
20
docker/compose/bridge-deltachat.yml
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
services:
|
||||||
|
bridge-deltachat:
|
||||||
|
container_name: bridge-deltachat
|
||||||
|
build:
|
||||||
|
context: ../../
|
||||||
|
dockerfile: ./apps/bridge-deltachat/Dockerfile
|
||||||
|
image: registry.gitlab.com/digiresilience/link/link-stack/bridge-deltachat:${LINK_STACK_VERSION}
|
||||||
|
restart: ${RESTART}
|
||||||
|
environment:
|
||||||
|
PORT: 5001
|
||||||
|
NODE_ENV: production
|
||||||
|
ZAMMAD_URL: http://zammad-nginx:8080
|
||||||
|
volumes:
|
||||||
|
- bridge-deltachat-data:/home/node/deltachat-data
|
||||||
|
ports:
|
||||||
|
- 5001:5001
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
bridge-deltachat-data:
|
||||||
|
driver: local
|
||||||
|
|
@ -5,11 +5,12 @@ const app = process.argv[2];
|
||||||
const command = process.argv[3];
|
const command = process.argv[3];
|
||||||
|
|
||||||
const files = {
|
const files = {
|
||||||
all: ["zammad", "postgresql", "opensearch", "signal-cli-rest-api", "bridge-whatsapp"],
|
all: ["zammad", "postgresql", "opensearch", "signal-cli-rest-api", "bridge-whatsapp", "bridge-deltachat"],
|
||||||
dev: ["zammad", "postgresql", "opensearch", "signal-cli-rest-api"],
|
dev: ["zammad", "postgresql", "opensearch", "signal-cli-rest-api"],
|
||||||
opensearch: ["opensearch"],
|
opensearch: ["opensearch"],
|
||||||
zammad: ["zammad", "postgresql", "opensearch", "signal-cli-rest-api"],
|
zammad: ["zammad", "postgresql", "opensearch", "signal-cli-rest-api"],
|
||||||
whatsapp: ["bridge-whatsapp"],
|
whatsapp: ["bridge-whatsapp"],
|
||||||
|
deltachat: ["bridge-deltachat"],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@
|
||||||
"docker:zammad:build": "node docker/scripts/docker.js zammad build",
|
"docker:zammad:build": "node docker/scripts/docker.js zammad build",
|
||||||
"docker:whatsapp:up": "node docker/scripts/docker.js whatsapp up",
|
"docker:whatsapp:up": "node docker/scripts/docker.js whatsapp up",
|
||||||
"docker:whatsapp:down": "node docker/scripts/docker.js whatsapp down",
|
"docker:whatsapp:down": "node docker/scripts/docker.js whatsapp down",
|
||||||
|
"docker:deltachat:up": "node docker/scripts/docker.js deltachat up",
|
||||||
|
"docker:deltachat:down": "node docker/scripts/docker.js deltachat down",
|
||||||
"docker:zammad:restart": "docker restart zammad-railsserver zammad-scheduler"
|
"docker:zammad:restart": "docker restart zammad-railsserver zammad-scheduler"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,249 @@
|
||||||
|
class ChannelCdrDeltachat extends App.ControllerSubContent
|
||||||
|
requiredPermission: 'admin.channel_cdr_deltachat'
|
||||||
|
events:
|
||||||
|
'click .js-new': 'new'
|
||||||
|
'click .js-edit': 'edit'
|
||||||
|
'click .js-delete': 'delete'
|
||||||
|
'click .js-disable': 'disable'
|
||||||
|
'click .js-enable': 'enable'
|
||||||
|
'click .js-rotate-token': 'rotateToken'
|
||||||
|
|
||||||
|
constructor: ->
|
||||||
|
super
|
||||||
|
|
||||||
|
#@interval(@load, 60000)
|
||||||
|
@load()
|
||||||
|
|
||||||
|
load: =>
|
||||||
|
@startLoading()
|
||||||
|
@ajax(
|
||||||
|
id: 'cdr_deltachat_index'
|
||||||
|
type: 'GET'
|
||||||
|
url: "#{@apiPath}/channels_cdr_deltachat"
|
||||||
|
processData: true
|
||||||
|
success: (data) =>
|
||||||
|
@stopLoading()
|
||||||
|
App.Collection.loadAssets(data.assets)
|
||||||
|
@render(data)
|
||||||
|
)
|
||||||
|
|
||||||
|
render: (data) =>
|
||||||
|
|
||||||
|
channels = []
|
||||||
|
for channel_id in data.channel_ids
|
||||||
|
channel = App.Channel.find(channel_id)
|
||||||
|
if channel && channel.options
|
||||||
|
displayName = '-'
|
||||||
|
if channel.group_id
|
||||||
|
group = App.Group.find(channel.group_id)
|
||||||
|
displayName = group.displayName()
|
||||||
|
channel.options.groupName = displayName
|
||||||
|
channels.push channel
|
||||||
|
@html App.view('cdr_deltachat/index')(
|
||||||
|
channels: channels
|
||||||
|
)
|
||||||
|
|
||||||
|
new: (e) =>
|
||||||
|
e.preventDefault()
|
||||||
|
new FormAdd(
|
||||||
|
container: @el.parents('.content')
|
||||||
|
load: @load
|
||||||
|
)
|
||||||
|
|
||||||
|
edit: (e) =>
|
||||||
|
e.preventDefault()
|
||||||
|
id = $(e.target).closest('.action').data('id')
|
||||||
|
channel = App.Channel.find(id)
|
||||||
|
new FormEdit(
|
||||||
|
channel: channel
|
||||||
|
container: @el.parents('.content')
|
||||||
|
load: @load
|
||||||
|
)
|
||||||
|
|
||||||
|
delete: (e) =>
|
||||||
|
e.preventDefault()
|
||||||
|
id = $(e.target).closest('.action').data('id')
|
||||||
|
new App.ControllerConfirm(
|
||||||
|
message: 'Sure?'
|
||||||
|
callback: =>
|
||||||
|
@ajax(
|
||||||
|
id: 'cdr_deltachat_delete'
|
||||||
|
type: 'DELETE'
|
||||||
|
url: "#{@apiPath}/channels_cdr_deltachat"
|
||||||
|
data: JSON.stringify(id: id)
|
||||||
|
processData: true
|
||||||
|
success: =>
|
||||||
|
@load()
|
||||||
|
)
|
||||||
|
container: @el.closest('.content')
|
||||||
|
)
|
||||||
|
|
||||||
|
rotateToken: (e) =>
|
||||||
|
e.preventDefault()
|
||||||
|
id = $(e.target).closest('.action').data('id')
|
||||||
|
|
||||||
|
new App.ControllerConfirm(
|
||||||
|
message: 'This will break the submission form!'
|
||||||
|
buttonSubmit: 'Reset token'
|
||||||
|
head: 'Reset the submission token?'
|
||||||
|
callback: =>
|
||||||
|
@ajax(
|
||||||
|
id: 'cdr_deltachat_disable'
|
||||||
|
type: 'POST'
|
||||||
|
url: "#{@apiPath}/channels_cdr_deltachat_rotate_token"
|
||||||
|
data: JSON.stringify(id: id)
|
||||||
|
processData: true
|
||||||
|
success: =>
|
||||||
|
@load()
|
||||||
|
)
|
||||||
|
container: @el.closest('.content')
|
||||||
|
)
|
||||||
|
disable: (e) =>
|
||||||
|
e.preventDefault()
|
||||||
|
id = $(e.target).closest('.action').data('id')
|
||||||
|
@ajax(
|
||||||
|
id: 'cdr_deltachat_disable'
|
||||||
|
type: 'POST'
|
||||||
|
url: "#{@apiPath}/channels_cdr_deltachat_disable"
|
||||||
|
data: JSON.stringify(id: id)
|
||||||
|
processData: true
|
||||||
|
success: =>
|
||||||
|
@load()
|
||||||
|
)
|
||||||
|
|
||||||
|
enable: (e) =>
|
||||||
|
e.preventDefault()
|
||||||
|
id = $(e.target).closest('.action').data('id')
|
||||||
|
@ajax(
|
||||||
|
id: 'cdr_deltachat_enable'
|
||||||
|
type: 'POST'
|
||||||
|
url: "#{@apiPath}/channels_cdr_deltachat_enable"
|
||||||
|
data: JSON.stringify(id: id)
|
||||||
|
processData: true
|
||||||
|
success: =>
|
||||||
|
@load()
|
||||||
|
)
|
||||||
|
|
||||||
|
class FormAdd extends App.ControllerModal
|
||||||
|
head: 'Add DeltaChat Bot'
|
||||||
|
shown: true
|
||||||
|
button: 'Add'
|
||||||
|
buttonCancel: true
|
||||||
|
small: true
|
||||||
|
|
||||||
|
content: ->
|
||||||
|
content = $(App.view('cdr_deltachat/form_add')())
|
||||||
|
createOrgSelection = (selected_id) ->
|
||||||
|
return App.UiElement.select.render(
|
||||||
|
name: 'organization_id'
|
||||||
|
multiple: false
|
||||||
|
limit: 100
|
||||||
|
null: false
|
||||||
|
relation: 'Organization'
|
||||||
|
nulloption: true
|
||||||
|
value: selected_id
|
||||||
|
class: 'form-control--small'
|
||||||
|
)
|
||||||
|
createGroupSelection = (selected_id) ->
|
||||||
|
return App.UiElement.select.render(
|
||||||
|
name: 'group_id'
|
||||||
|
multiple: false
|
||||||
|
limit: 100
|
||||||
|
null: false
|
||||||
|
relation: 'Group'
|
||||||
|
nulloption: true
|
||||||
|
value: selected_id
|
||||||
|
class: 'form-control--small'
|
||||||
|
)
|
||||||
|
|
||||||
|
content.find('.js-select').on('click', (e) =>
|
||||||
|
@selectAll(e)
|
||||||
|
)
|
||||||
|
content.find('.js-messagesGroup').replaceWith createGroupSelection(1)
|
||||||
|
content.find('.js-organization').replaceWith createOrgSelection(null)
|
||||||
|
content
|
||||||
|
|
||||||
|
onClosed: =>
|
||||||
|
return if !@isChanged
|
||||||
|
@isChanged = false
|
||||||
|
@load()
|
||||||
|
|
||||||
|
onSubmit: (e) =>
|
||||||
|
@formDisable(e)
|
||||||
|
@ajax(
|
||||||
|
id: 'cdr_deltachat_app_verify'
|
||||||
|
type: 'POST'
|
||||||
|
url: "#{@apiPath}/channels_cdr_deltachat"
|
||||||
|
data: JSON.stringify(@formParams())
|
||||||
|
processData: true
|
||||||
|
success: =>
|
||||||
|
@isChanged = true
|
||||||
|
@close()
|
||||||
|
error: (xhr) =>
|
||||||
|
data = JSON.parse(xhr.responseText)
|
||||||
|
@formEnable(e)
|
||||||
|
error_message = App.i18n.translateContent(data.error || 'Unable to save DeltaChat bot.')
|
||||||
|
@el.find('.alert').removeClass('hidden').text(error_message)
|
||||||
|
)
|
||||||
|
|
||||||
|
class FormEdit extends App.ControllerModal
|
||||||
|
head: 'DeltaChat Bot Info'
|
||||||
|
shown: true
|
||||||
|
buttonCancel: true
|
||||||
|
|
||||||
|
content: ->
|
||||||
|
content = $(App.view('cdr_deltachat/form_edit')(channel: @channel))
|
||||||
|
|
||||||
|
createOrgSelection = (selected_id) ->
|
||||||
|
return App.UiElement.select.render(
|
||||||
|
name: 'organization_id'
|
||||||
|
multiple: false
|
||||||
|
limit: 100
|
||||||
|
null: false
|
||||||
|
relation: 'Organization'
|
||||||
|
nulloption: true
|
||||||
|
value: selected_id
|
||||||
|
class: 'form-control--small'
|
||||||
|
)
|
||||||
|
createGroupSelection = (selected_id) ->
|
||||||
|
return App.UiElement.select.render(
|
||||||
|
name: 'group_id'
|
||||||
|
multiple: false
|
||||||
|
limit: 100
|
||||||
|
null: false
|
||||||
|
relation: 'Group'
|
||||||
|
nulloption: true
|
||||||
|
value: selected_id
|
||||||
|
class: 'form-control--small'
|
||||||
|
)
|
||||||
|
|
||||||
|
content.find('.js-messagesGroup').replaceWith createGroupSelection(@channel.group_id)
|
||||||
|
content.find('.js-organization').replaceWith createOrgSelection(@channel.options.organization_id)
|
||||||
|
content
|
||||||
|
|
||||||
|
onClosed: =>
|
||||||
|
return if !@isChanged
|
||||||
|
@isChanged = false
|
||||||
|
@load()
|
||||||
|
|
||||||
|
onSubmit: (e) =>
|
||||||
|
@formDisable(e)
|
||||||
|
params = @formParams()
|
||||||
|
@channel.options = params
|
||||||
|
@ajax(
|
||||||
|
id: 'channel_cdr_deltachat_update'
|
||||||
|
type: 'PUT'
|
||||||
|
url: "#{@apiPath}/channels_cdr_deltachat/#{@channel.id}"
|
||||||
|
data: JSON.stringify(@formParams())
|
||||||
|
processData: true
|
||||||
|
success: =>
|
||||||
|
@isChanged = true
|
||||||
|
@close()
|
||||||
|
error: (xhr) =>
|
||||||
|
data = JSON.parse(xhr.responseText)
|
||||||
|
@formEnable(e)
|
||||||
|
error_message = App.i18n.translateContent(data.error || 'Unable to save changes.')
|
||||||
|
@el.find('.alert').removeClass('hidden').text(error_message)
|
||||||
|
)
|
||||||
|
|
||||||
|
App.Config.set('cdr_deltachat', { prio: 5200, name: 'DeltaChat', parent: '#channels', target: '#channels/cdr_deltachat', controller: ChannelCdrDeltachat, permission: ['admin.channel_cdr_deltachat'] }, 'NavBarAdmin')
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
class CdrDeltachatReply
|
||||||
|
@action: (actions, ticket, article, ui) ->
|
||||||
|
return actions if ui.permissionCheck('ticket.customer')
|
||||||
|
|
||||||
|
if article.sender.name is 'Customer' && article.type.name is 'cdr_deltachat'
|
||||||
|
actions.push {
|
||||||
|
name: 'reply'
|
||||||
|
type: 'cdrDeltachatMessageReply'
|
||||||
|
icon: 'reply'
|
||||||
|
href: '#'
|
||||||
|
}
|
||||||
|
|
||||||
|
actions
|
||||||
|
|
||||||
|
@perform: (articleContainer, type, ticket, article, ui) ->
|
||||||
|
return true if type isnt 'cdrDeltachatMessageReply'
|
||||||
|
|
||||||
|
ui.scrollToCompose()
|
||||||
|
|
||||||
|
# get reference article
|
||||||
|
type = App.TicketArticleType.find(article.type_id)
|
||||||
|
|
||||||
|
articleNew = {
|
||||||
|
to: ''
|
||||||
|
cc: ''
|
||||||
|
body: ''
|
||||||
|
in_reply_to: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if article.message_id
|
||||||
|
articleNew.in_reply_to = article.message_id
|
||||||
|
|
||||||
|
# get current body
|
||||||
|
articleNew.body = ui.el.closest('.ticketZoom').find('.article-add [data-name="body"]').html().trim() || ''
|
||||||
|
|
||||||
|
App.Event.trigger('ui::ticket::setArticleType', {
|
||||||
|
ticket: ticket
|
||||||
|
type: type
|
||||||
|
article: articleNew
|
||||||
|
position: 'end'
|
||||||
|
})
|
||||||
|
|
||||||
|
true
|
||||||
|
|
||||||
|
@articleTypes: (articleTypes, ticket, ui) ->
|
||||||
|
return articleTypes if !ui.permissionCheck('ticket.agent')
|
||||||
|
|
||||||
|
return articleTypes if !ticket || !ticket.create_article_type_id
|
||||||
|
|
||||||
|
articleTypeCreate = App.TicketArticleType.find(ticket.create_article_type_id).name
|
||||||
|
|
||||||
|
return articleTypes if articleTypeCreate isnt 'cdr_deltachat'
|
||||||
|
articleTypes.push {
|
||||||
|
name: 'cdr_deltachat'
|
||||||
|
icon: 'cdr-deltachat'
|
||||||
|
attributes: []
|
||||||
|
internal: false,
|
||||||
|
features: ['attachment']
|
||||||
|
maxTextLength: 10000
|
||||||
|
warningTextLength: 5000
|
||||||
|
}
|
||||||
|
articleTypes
|
||||||
|
|
||||||
|
@setArticleTypePost: (type, ticket, ui) ->
|
||||||
|
return if type isnt 'cdr_deltachat'
|
||||||
|
rawHTML = ui.$('[data-name=body]').html()
|
||||||
|
cleanHTML = App.Utils.htmlRemoveRichtext(rawHTML)
|
||||||
|
if cleanHTML && cleanHTML.html() != rawHTML
|
||||||
|
ui.$('[data-name=body]').html(cleanHTML)
|
||||||
|
|
||||||
|
@params: (type, params, ui) ->
|
||||||
|
if type is 'cdr_deltachat'
|
||||||
|
App.Utils.htmlRemoveRichtext(ui.$('[data-name=body]'), false)
|
||||||
|
params.content_type = 'text/plain'
|
||||||
|
params.body = App.Utils.html2text(params.body, true)
|
||||||
|
|
||||||
|
params
|
||||||
|
|
||||||
|
App.Config.set('310-CdrDeltachatReply', CdrDeltachatReply, 'TicketZoomArticleAction')
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
<div class="alert alert--danger hidden" role="alert"></div>
|
||||||
|
<fieldset>
|
||||||
|
<div class="input form-group">
|
||||||
|
<div class="formGroup-label">
|
||||||
|
<label for="form_name"><%- @T('Email Address') %> <span>*</span></label>
|
||||||
|
</div>
|
||||||
|
<div class="controls">
|
||||||
|
<input id="email_address" type="text" name="email_address" value="" class="form-control" required autocomplete="off">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input form-group">
|
||||||
|
<div class="formGroup-label">
|
||||||
|
<label for="form_name"><%- @T('Bot Token') %> <span>*</span></label>
|
||||||
|
</div>
|
||||||
|
<div class="controls">
|
||||||
|
<input id="bot_token" type="text" name="bot_token" value="" class="form-control" required autocomplete="off">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input form-group">
|
||||||
|
<div class="formGroup-label">
|
||||||
|
<label for="form_name"><%- @T('Bot Endpoint') %> <span>*</span></label>
|
||||||
|
</div>
|
||||||
|
<div class="controls">
|
||||||
|
<input id="bot_endpoint" type="text" name="bot_endpoint" value="" class="form-control" required autocomplete="off">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input form-group">
|
||||||
|
<div class="formGroup-label">
|
||||||
|
<label for=""><%- @T('Choose the group in which form submissions will get added to.') %> <span>*</span></label>
|
||||||
|
</div>
|
||||||
|
<div class="controls">
|
||||||
|
<div class="js-messagesGroup"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input form-group">
|
||||||
|
<div class="formGroup-label">
|
||||||
|
<label for=""><%- @T('Choose the organization to which submitters will be added to when they submit via this form.') %> <span>*</span></label>
|
||||||
|
</div>
|
||||||
|
<div class="controls">
|
||||||
|
<div class="profile-organization js-organization"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
<div class="alert alert--danger hidden" role="alert"></div>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<div class="input form-group">
|
||||||
|
<div class="formGroup-label">
|
||||||
|
<label for="form_name"><%- @T('Email Address') %> <span>*</span></label>
|
||||||
|
</div>
|
||||||
|
<div class="controls">
|
||||||
|
<input id="email_address" type="text" name="email_address" value="<%= @channel.options.email_address %>" class="form-control" required autocomplete="off">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input form-group">
|
||||||
|
<div class="formGroup-label">
|
||||||
|
<label for="form_name"><%- @T('Bot Token') %> <span>*</span></label>
|
||||||
|
</div>
|
||||||
|
<div class="controls">
|
||||||
|
<input id="bot_token" type="text" name="bot_token" value="<%= @channel.options.bot_token %>" class="form-control" required autocomplete="off">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input form-group">
|
||||||
|
<div class="formGroup-label">
|
||||||
|
<label for="form_name"><%- @T('Bot Endpoint') %> <span>*</span></label>
|
||||||
|
</div>
|
||||||
|
<div class="controls">
|
||||||
|
<input id="bot_endpoint" type="text" name="bot_endpoint" value="<%= @channel.options.bot_endpoint %>" class="form-control" required autocomplete="off">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input form-group">
|
||||||
|
<div class="formGroup-label">
|
||||||
|
<label for=""><%- @T('Choose the group in which incoming messages will be added to.') %> <span>*</span></label>
|
||||||
|
</div>
|
||||||
|
<div class="controls">
|
||||||
|
<div class="js-messagesGroup"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="input form-group">
|
||||||
|
<div class="formGroup-label">
|
||||||
|
<label for=""><%- @T('Choose the organization to which users will be added to when they send a message to this bot.') %> <span>*</span></label>
|
||||||
|
</div>
|
||||||
|
<div class="controls">
|
||||||
|
<div class="profile-organization js-organization"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="input form-group">
|
||||||
|
<div class="formGroup-label">
|
||||||
|
<label for="token"><%- @T('Endpoint URL') %> <span>*</span></label>
|
||||||
|
</div>
|
||||||
|
<div class="controls">
|
||||||
|
<input id="token" type="text" value="<%= "#{App.Config.get('http_type')}://#{App.Config.get('fqdn')}/api/v1/channels_cdr_deltachat_webhook/#{@channel.options.token}" %>" class="form-control input js-select" readonly>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="page-header-title">
|
||||||
|
<h1><%- @T('DeltaChat') %></h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-header-meta">
|
||||||
|
<a class="btn btn--success js-new"><%- @T('Add DeltaChat bot') %></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-content">
|
||||||
|
|
||||||
|
<% if _.isEmpty(@channels): %>
|
||||||
|
<div class="page-description">
|
||||||
|
<p><%- @T('You have no configured %s right now.', 'DeltaChat bots') %></p>
|
||||||
|
</div>
|
||||||
|
<% else: %>
|
||||||
|
|
||||||
|
<% for channel in @channels: %>
|
||||||
|
<div class="action <% if channel.active isnt true: %>is-inactive<% end %>" data-id="<%= channel.id %>">
|
||||||
|
<div class="action-block action-row">
|
||||||
|
<h2><%- @Icon('status', 'supergood-color inline') %> <%= channel.options.email_address %></h2>
|
||||||
|
</div>
|
||||||
|
<div class="action-flow action-flow--row">
|
||||||
|
<div class="action-block">
|
||||||
|
<h3><%- @T('Group') %></h3>
|
||||||
|
<% if channel.options: %>
|
||||||
|
<%= channel.options.groupName %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-block">
|
||||||
|
<h3><%- @T('Endpoint URL') %></h3>
|
||||||
|
<%- @T('Click the edit button to view the endpoint details ') %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="action-controls">
|
||||||
|
<div class="btn btn--danger btn--secondary js-delete"><%- @T('Delete') %></div>
|
||||||
|
<div class="btn btn--danger btn--secondary js-rotate-token"><%- @T('Reset Token') %></div>
|
||||||
|
<% if channel.active is true: %>
|
||||||
|
<div class="btn btn--secondary js-disable"><%- @T('Disable') %></div>
|
||||||
|
<% else: %>
|
||||||
|
<div class="btn btn--secondary js-enable"><%- @T('Enable') %></div>
|
||||||
|
<% end %>
|
||||||
|
<div class="btn js-edit"><%- @T('Edit') %></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
.icon-cdr-deltachat {
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,290 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class ChannelsCdrDeltachatController < ApplicationController
|
||||||
|
prepend_before_action -> { authentication_check && authorize! }, except: [:webhook, :bot_webhook]
|
||||||
|
skip_before_action :verify_csrf_token, only: [:webhook, :bot_webhook]
|
||||||
|
|
||||||
|
include CreatesTicketArticles
|
||||||
|
|
||||||
|
def index
|
||||||
|
assets = {}
|
||||||
|
channel_ids = []
|
||||||
|
Channel.where(area: 'DeltaChat::Account').order(:id).each do |channel|
|
||||||
|
assets = channel.assets(assets)
|
||||||
|
channel_ids.push channel.id
|
||||||
|
end
|
||||||
|
render json: {
|
||||||
|
assets: assets,
|
||||||
|
channel_ids: channel_ids
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def add
|
||||||
|
begin
|
||||||
|
errors = {}
|
||||||
|
errors['group_id'] = 'required' if params[:group_id].blank?
|
||||||
|
|
||||||
|
if errors.present?
|
||||||
|
render json: {
|
||||||
|
errors: errors
|
||||||
|
}, status: :bad_request
|
||||||
|
return
|
||||||
|
end
|
||||||
|
channel = Channel.create(
|
||||||
|
area: 'DeltaChat::Account',
|
||||||
|
options: {
|
||||||
|
adapter: 'cdr_deltachat',
|
||||||
|
email_address: params[:email_address],
|
||||||
|
bot_token: params[:bot_token],
|
||||||
|
bot_endpoint: params[:bot_endpoint],
|
||||||
|
token: SecureRandom.urlsafe_base64(48),
|
||||||
|
organization_id: params[:organization_id]
|
||||||
|
},
|
||||||
|
group_id: params[:group_id],
|
||||||
|
active: true
|
||||||
|
)
|
||||||
|
rescue StandardError => e
|
||||||
|
raise Exceptions::UnprocessableEntity, e.message
|
||||||
|
end
|
||||||
|
render json: channel
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
errors = {}
|
||||||
|
errors['group_id'] = 'required' if params[:group_id].blank?
|
||||||
|
|
||||||
|
if errors.present?
|
||||||
|
render json: {
|
||||||
|
errors: errors
|
||||||
|
}, status: :bad_request
|
||||||
|
return
|
||||||
|
end
|
||||||
|
channel = Channel.find_by(id: params[:id], area: 'DeltaChat::Account')
|
||||||
|
begin
|
||||||
|
channel.options[:email_address] = params[:email_address]
|
||||||
|
channel.options[:bot_token] = params[:bot_token]
|
||||||
|
channel.options[:bot_endpoint] = params[:bot_endpoint]
|
||||||
|
channel.options[:organization_id] = params[:organization_id]
|
||||||
|
channel.group_id = params[:group_id]
|
||||||
|
channel.save!
|
||||||
|
rescue StandardError => e
|
||||||
|
raise Exceptions::UnprocessableEntity, e.message
|
||||||
|
end
|
||||||
|
render json: channel
|
||||||
|
end
|
||||||
|
|
||||||
|
def rotate_token
|
||||||
|
channel = Channel.find_by(id: params[:id], area: 'DeltaChat::Account')
|
||||||
|
channel.options[:token] = SecureRandom.urlsafe_base64(48)
|
||||||
|
channel.save!
|
||||||
|
render json: {}
|
||||||
|
end
|
||||||
|
|
||||||
|
def enable
|
||||||
|
channel = Channel.find_by(id: params[:id], area: 'DeltaChat::Account')
|
||||||
|
channel.active = true
|
||||||
|
channel.save!
|
||||||
|
render json: {}
|
||||||
|
end
|
||||||
|
|
||||||
|
def disable
|
||||||
|
channel = Channel.find_by(id: params[:id], area: 'DeltaChat::Account')
|
||||||
|
channel.active = false
|
||||||
|
channel.save!
|
||||||
|
render json: {}
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
channel = Channel.find_by(id: params[:id], area: 'DeltaChat::Account')
|
||||||
|
channel.destroy
|
||||||
|
render json: {}
|
||||||
|
end
|
||||||
|
|
||||||
|
def channel_for_token(token)
|
||||||
|
return false unless token
|
||||||
|
|
||||||
|
Channel.where(area: 'DeltaChat::Account').each do |channel|
|
||||||
|
return channel if channel.options[:token] == token
|
||||||
|
end
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def channel_for_bot_token(bot_token)
|
||||||
|
return false unless bot_token
|
||||||
|
|
||||||
|
Channel.where(area: 'DeltaChat::Account').each do |channel|
|
||||||
|
return channel if channel.options[:bot_token] == bot_token
|
||||||
|
end
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def bot_webhook
|
||||||
|
bot_token = params['bot_token']
|
||||||
|
return render json: {}, status: :unauthorized unless bot_token
|
||||||
|
|
||||||
|
channel = channel_for_bot_token(bot_token)
|
||||||
|
return render json: { error: 'Channel not found' }, status: :not_found if !channel || !channel.active
|
||||||
|
|
||||||
|
# Use the channel's webhook token to reuse existing logic
|
||||||
|
params[:token] = channel.options[:token]
|
||||||
|
|
||||||
|
webhook
|
||||||
|
end
|
||||||
|
|
||||||
|
def webhook
|
||||||
|
token = params['token']
|
||||||
|
return render json: {}, status: :unauthorized unless token
|
||||||
|
|
||||||
|
channel = channel_for_token(token)
|
||||||
|
return render json: {}, status: :unauthorized if !channel || !channel.active
|
||||||
|
return render json: {}, status: :unauthorized if channel.options[:token] != token
|
||||||
|
|
||||||
|
channel_id = channel.id
|
||||||
|
|
||||||
|
# validate input
|
||||||
|
errors = {}
|
||||||
|
|
||||||
|
%i[from
|
||||||
|
to
|
||||||
|
message_id
|
||||||
|
sent_at].each do |field|
|
||||||
|
errors[field] = 'required' if params[field].blank?
|
||||||
|
end
|
||||||
|
|
||||||
|
if errors.present?
|
||||||
|
render json: {
|
||||||
|
errors: errors
|
||||||
|
}, status: :bad_request
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
message_id = params[:message_id]
|
||||||
|
|
||||||
|
return if Ticket::Article.exists?(message_id: "cdr_deltachat.#{message_id}")
|
||||||
|
|
||||||
|
sender_email = params[:from].strip
|
||||||
|
bot_email = params[:to].strip
|
||||||
|
|
||||||
|
# Customer lookup by email
|
||||||
|
customer = User.find_by(email: sender_email)
|
||||||
|
|
||||||
|
unless customer
|
||||||
|
role_ids = Role.signup_role_ids
|
||||||
|
customer = User.create(
|
||||||
|
firstname: '',
|
||||||
|
lastname: '',
|
||||||
|
email: sender_email,
|
||||||
|
password: '',
|
||||||
|
note: 'CDR DeltaChat',
|
||||||
|
active: true,
|
||||||
|
role_ids: role_ids,
|
||||||
|
updated_by_id: 1,
|
||||||
|
created_by_id: 1
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# set current user
|
||||||
|
UserInfo.current_user_id = customer.id
|
||||||
|
current_user_set(customer, 'token_auth')
|
||||||
|
|
||||||
|
group = Group.find_by(id: channel.group_id)
|
||||||
|
if group.blank?
|
||||||
|
Rails.logger.error "DeltaChat channel #{channel_id} paired with Group #{channel.group_id}, but group does not exist!"
|
||||||
|
return render json: { error: 'There was an error during DeltaChat submission' }, status: :internal_server_error
|
||||||
|
end
|
||||||
|
|
||||||
|
organization_id = channel.options['organization_id']
|
||||||
|
if organization_id.present?
|
||||||
|
organization = Organization.find_by(id: organization_id)
|
||||||
|
if organization.blank?
|
||||||
|
Rails.logger.error "DeltaChat channel #{channel_id} paired with Organization #{organization_id}, but organization does not exist!"
|
||||||
|
return render json: { error: 'There was an error during DeltaChat submission' }, status: :internal_server_error
|
||||||
|
end
|
||||||
|
if customer.organization_id.blank?
|
||||||
|
customer.organization_id = organization.id
|
||||||
|
customer.save!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
message = params[:message] ||= 'No text content'
|
||||||
|
sent_at = params[:sent_at]
|
||||||
|
attachment_data_base64 = params[:attachment]
|
||||||
|
attachment_filename = params[:filename]
|
||||||
|
attachment_mimetype = params[:mime_type]
|
||||||
|
title = "Message from #{sender_email} at #{sent_at}"
|
||||||
|
body = message
|
||||||
|
|
||||||
|
# find ticket or create one
|
||||||
|
state_ids = Ticket::State.where(name: %w[closed merged removed]).pluck(:id)
|
||||||
|
ticket = Ticket.where(customer_id: customer.id).where.not(state_id: state_ids).order(:updated_at).first
|
||||||
|
if ticket
|
||||||
|
# check if title need to be updated
|
||||||
|
ticket.title = title if ticket.title == '-'
|
||||||
|
new_state = Ticket::State.find_by(default_create: true)
|
||||||
|
ticket.state = Ticket::State.find_by(default_follow_up: true) if ticket.state_id != new_state.id
|
||||||
|
else
|
||||||
|
ticket = Ticket.new(
|
||||||
|
group_id: channel.group_id,
|
||||||
|
title: title,
|
||||||
|
customer_id: customer.id,
|
||||||
|
preferences: {
|
||||||
|
channel_id: channel.id,
|
||||||
|
cdr_deltachat: {
|
||||||
|
bot_token: channel.options[:bot_token],
|
||||||
|
chat_id: sender_email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
ticket.save!
|
||||||
|
|
||||||
|
article_params = {
|
||||||
|
from: sender_email,
|
||||||
|
to: bot_email,
|
||||||
|
sender_id: Ticket::Article::Sender.find_by(name: 'Customer').id,
|
||||||
|
subject: title,
|
||||||
|
body: body,
|
||||||
|
content_type: 'text/plain',
|
||||||
|
message_id: "cdr_deltachat.#{message_id}",
|
||||||
|
ticket_id: ticket.id,
|
||||||
|
internal: false,
|
||||||
|
preferences: {
|
||||||
|
cdr_deltachat: {
|
||||||
|
timestamp: sent_at,
|
||||||
|
message_id: message_id,
|
||||||
|
from: sender_email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if attachment_data_base64.present?
|
||||||
|
article_params[:attachments] = [
|
||||||
|
{
|
||||||
|
'filename' => attachment_filename,
|
||||||
|
:filename => attachment_filename,
|
||||||
|
:data => attachment_data_base64,
|
||||||
|
'data' => attachment_data_base64,
|
||||||
|
'mime-type' => attachment_mimetype
|
||||||
|
}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
# setting the article type after saving seems to be the only way to get it to stick
|
||||||
|
ticket.with_lock do
|
||||||
|
ta = article_create(ticket, article_params)
|
||||||
|
ta.update!(type_id: Ticket::Article::Type.find_by(name: 'cdr_deltachat').id)
|
||||||
|
end
|
||||||
|
|
||||||
|
ticket.update!(create_article_type_id: Ticket::Article::Type.find_by(name: 'cdr_deltachat').id)
|
||||||
|
|
||||||
|
result = {
|
||||||
|
ticket: {
|
||||||
|
id: ticket.id,
|
||||||
|
number: ticket.number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render json: result, status: :ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import type { ChannelModule } from "#desktop/pages/ticket/components/TicketDetailView/article-type/types.ts";
|
||||||
|
|
||||||
|
export default <ChannelModule>{
|
||||||
|
name: "deltachat message",
|
||||||
|
label: __("DeltaChat Message"),
|
||||||
|
icon: "cdr-deltachat",
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { EnumTicketArticleSenderName } from '#shared/graphql/types.ts'
|
||||||
|
|
||||||
|
import type { TicketArticleAction, TicketArticleActionPlugin, TicketArticleType } from './types.ts'
|
||||||
|
|
||||||
|
const actionPlugin: TicketArticleActionPlugin = {
|
||||||
|
order: 370,
|
||||||
|
|
||||||
|
addActions(ticket, article) {
|
||||||
|
const sender = article.sender?.name
|
||||||
|
const type = article.type?.name
|
||||||
|
|
||||||
|
if (sender !== EnumTicketArticleSenderName.Customer || type !== 'deltachat message')
|
||||||
|
return []
|
||||||
|
|
||||||
|
const action: TicketArticleAction = {
|
||||||
|
apps: ['mobile', 'desktop'],
|
||||||
|
label: __('Reply'),
|
||||||
|
name: 'deltachat message',
|
||||||
|
icon: 'cdr-deltachat',
|
||||||
|
view: {
|
||||||
|
agent: ['change'],
|
||||||
|
},
|
||||||
|
perform(ticket, article, { openReplyForm }) {
|
||||||
|
const articleData = {
|
||||||
|
articleType: type,
|
||||||
|
inReplyTo: article.messageId,
|
||||||
|
}
|
||||||
|
|
||||||
|
openReplyForm(articleData)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return [action]
|
||||||
|
},
|
||||||
|
|
||||||
|
addTypes(ticket) {
|
||||||
|
const descriptionType = ticket.createArticleType?.name
|
||||||
|
|
||||||
|
if (descriptionType !== 'deltachat message') return []
|
||||||
|
|
||||||
|
const type: TicketArticleType = {
|
||||||
|
apps: ['mobile', 'desktop'],
|
||||||
|
value: 'deltachat message',
|
||||||
|
label: __('DeltaChat'),
|
||||||
|
buttonLabel: __('Add DeltaChat message'),
|
||||||
|
icon: 'cdr-deltachat',
|
||||||
|
view: {
|
||||||
|
agent: ['change'],
|
||||||
|
},
|
||||||
|
internal: false,
|
||||||
|
contentType: 'text/plain',
|
||||||
|
fields: {
|
||||||
|
body: {
|
||||||
|
required: true,
|
||||||
|
validation: 'length:1,10000',
|
||||||
|
},
|
||||||
|
attachments: {},
|
||||||
|
},
|
||||||
|
editorMeta: {
|
||||||
|
footer: {
|
||||||
|
maxlength: 10000,
|
||||||
|
warningLength: 5000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return [type]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default actionPlugin
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class CommunicateCdrDeltachatJob < ApplicationJob
|
||||||
|
retry_on StandardError, attempts: 4, wait: lambda { |executions|
|
||||||
|
executions * 120.seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
def perform(article_id)
|
||||||
|
article = Ticket::Article.find(article_id)
|
||||||
|
|
||||||
|
# set retry count
|
||||||
|
article.preferences['delivery_retry'] ||= 0
|
||||||
|
article.preferences['delivery_retry'] += 1
|
||||||
|
|
||||||
|
ticket = Ticket.lookup(id: article.ticket_id)
|
||||||
|
unless ticket.preferences
|
||||||
|
log_error(article,
|
||||||
|
"Can't find ticket.preferences for Ticket.find(#{article.ticket_id})")
|
||||||
|
end
|
||||||
|
unless ticket.preferences['cdr_deltachat']
|
||||||
|
log_error(article,
|
||||||
|
"Can't find ticket.preferences['cdr_deltachat'] for Ticket.find(#{article.ticket_id})")
|
||||||
|
end
|
||||||
|
unless ticket.preferences['cdr_deltachat']['bot_token']
|
||||||
|
log_error(article,
|
||||||
|
"Can't find ticket.preferences['cdr_deltachat']['bot_token'] for Ticket.find(#{article.ticket_id})")
|
||||||
|
end
|
||||||
|
unless ticket.preferences['cdr_deltachat']['chat_id']
|
||||||
|
log_error(article,
|
||||||
|
"Can't find ticket.preferences['cdr_deltachat']['chat_id'] for Ticket.find(#{article.ticket_id})")
|
||||||
|
end
|
||||||
|
channel = ::CdrDeltachat.bot_by_bot_token(ticket.preferences['cdr_deltachat']['bot_token'])
|
||||||
|
channel ||= ::Channel.lookup(id: ticket.preferences['channel_id'])
|
||||||
|
unless channel
|
||||||
|
log_error(article,
|
||||||
|
"No such channel for bot #{ticket.preferences['cdr_deltachat']['bot_token']} or channel id #{ticket.preferences['channel_id']}")
|
||||||
|
end
|
||||||
|
if channel.options[:bot_token].blank?
|
||||||
|
log_error(article,
|
||||||
|
"Channel.find(#{channel.id}) has no cdr deltachat api token!")
|
||||||
|
end
|
||||||
|
|
||||||
|
has_error = false
|
||||||
|
|
||||||
|
begin
|
||||||
|
result = channel.deliver(article)
|
||||||
|
rescue StandardError => e
|
||||||
|
log_error(article, e.message)
|
||||||
|
has_error = true
|
||||||
|
end
|
||||||
|
|
||||||
|
Rails.logger.debug { "send result: #{result}" }
|
||||||
|
|
||||||
|
if result.nil? || result[:error].present?
|
||||||
|
log_error(article, 'Delivering deltachat message failed!')
|
||||||
|
has_error = true
|
||||||
|
end
|
||||||
|
|
||||||
|
return if has_error
|
||||||
|
|
||||||
|
article.to = result['result']['to']
|
||||||
|
article.from = result['result']['from']
|
||||||
|
|
||||||
|
message_id = format('%<from>s@%<timestamp>s', from: result['result']['from'],
|
||||||
|
timestamp: result['result']['timestamp'])
|
||||||
|
article.preferences['cdr_deltachat'] = {
|
||||||
|
timestamp: result['result']['timestamp'],
|
||||||
|
message_id: message_id,
|
||||||
|
from: result['result']['from'],
|
||||||
|
to: result['result']['to']
|
||||||
|
}
|
||||||
|
|
||||||
|
# set delivery status
|
||||||
|
article.preferences['delivery_status_message'] = nil
|
||||||
|
article.preferences['delivery_status'] = 'success'
|
||||||
|
article.preferences['delivery_status_date'] = Time.zone.now
|
||||||
|
|
||||||
|
article.message_id = "cdr_deltachat.#{message_id}"
|
||||||
|
|
||||||
|
article.save!
|
||||||
|
|
||||||
|
Rails.logger.info "Sent deltachat message to: '#{article.to}' (from #{article.from})"
|
||||||
|
|
||||||
|
article
|
||||||
|
end
|
||||||
|
|
||||||
|
def log_error(local_record, message)
|
||||||
|
local_record.preferences['delivery_status'] = 'fail'
|
||||||
|
local_record.preferences['delivery_status_message'] =
|
||||||
|
message.encode!('UTF-8', 'UTF-8', invalid: :replace, replace: '?')
|
||||||
|
local_record.preferences['delivery_status_date'] = Time.zone.now
|
||||||
|
local_record.save
|
||||||
|
Rails.logger.error message
|
||||||
|
|
||||||
|
if local_record.preferences['delivery_retry'] > 3
|
||||||
|
Ticket::Article.create(
|
||||||
|
ticket_id: local_record.ticket_id,
|
||||||
|
content_type: 'text/plain',
|
||||||
|
body: "Unable to send cdr deltachat message: #{message}",
|
||||||
|
internal: true,
|
||||||
|
sender: Ticket::Article::Sender.find_by(name: 'System'),
|
||||||
|
type: Ticket::Article::Type.find_by(name: 'note'),
|
||||||
|
preferences: {
|
||||||
|
delivery_article_id_related: local_record.id,
|
||||||
|
delivery_message: true
|
||||||
|
},
|
||||||
|
updated_by_id: 1,
|
||||||
|
created_by_id: 1
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
raise message
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Channel::Driver::CdrDeltachat
|
||||||
|
def fetchable?(_channel)
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def disconnect; end
|
||||||
|
|
||||||
|
def deliver(options, article, _notification = false)
|
||||||
|
# return if we run import mode
|
||||||
|
return if Setting.get('import_mode')
|
||||||
|
|
||||||
|
options = check_external_credential(options)
|
||||||
|
|
||||||
|
Rails.logger.debug { 'deltachat send started' }
|
||||||
|
Rails.logger.debug { options.inspect }
|
||||||
|
@deltachat = ::CdrDeltachat.new(options[:bot_endpoint], options[:bot_token])
|
||||||
|
@deltachat.from_article(article)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def check_external_credential(options)
|
||||||
|
if options[:auth] && options[:auth][:external_credential_id]
|
||||||
|
external_credential = ExternalCredential.find_by(id: options[:auth][:external_credential_id])
|
||||||
|
raise "No such ExternalCredential.find(#{options[:auth][:external_credential_id]})" unless external_credential
|
||||||
|
|
||||||
|
options[:auth][:api_key] = external_credential.credentials['api_key']
|
||||||
|
end
|
||||||
|
options
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Ticket::Article::EnqueueCommunicateCdrDeltachatJob
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
included do
|
||||||
|
after_create :ticket_article_enqueue_communicate_cdr_deltachat_job
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def ticket_article_enqueue_communicate_cdr_deltachat_job
|
||||||
|
# return if we run import mode
|
||||||
|
return true if Setting.get('import_mode')
|
||||||
|
|
||||||
|
# if sender is customer, do not communicate
|
||||||
|
return true unless sender_id
|
||||||
|
|
||||||
|
sender = Ticket::Article::Sender.lookup(id: sender_id)
|
||||||
|
return true if sender.nil?
|
||||||
|
return true if sender.name == 'Customer'
|
||||||
|
|
||||||
|
# only apply on cdr deltachat messages
|
||||||
|
return true unless type_id
|
||||||
|
|
||||||
|
type = Ticket::Article::Type.lookup(id: type_id)
|
||||||
|
return true unless type.name.match?(/\Acdr_deltachat/i)
|
||||||
|
|
||||||
|
CommunicateCdrDeltachatJob.perform_later(id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Controllers
|
||||||
|
class ChannelsCdrDeltachatControllerPolicy < Controllers::ApplicationControllerPolicy
|
||||||
|
default_permit!('admin.channel_cdr_deltachat')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
Rails.application.config.after_initialize do
|
||||||
|
class Ticket::Article
|
||||||
|
include Ticket::Article::EnqueueCommunicateCdrDeltachatJob
|
||||||
|
end
|
||||||
|
|
||||||
|
icon = File.read('public/assets/images/icons/cdr_deltachat.svg')
|
||||||
|
doc = File.open('public/assets/images/icons.svg') { |f| Nokogiri::XML(f) }
|
||||||
|
if !doc.at_css('#icon-cdr-deltachat')
|
||||||
|
doc.at('svg').add_child(icon)
|
||||||
|
Rails.logger.debug 'deltachat icon added to icon set'
|
||||||
|
else
|
||||||
|
Rails.logger.debug 'deltachat icon already in icon set'
|
||||||
|
end
|
||||||
|
File.write('public/assets/images/icons.svg', doc.to_xml)
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
Zammad::Application.routes.draw do
|
||||||
|
api_path = Rails.configuration.api_path
|
||||||
|
|
||||||
|
match "#{api_path}/channels_cdr_deltachat", to: 'channels_cdr_deltachat#index', via: :get
|
||||||
|
match "#{api_path}/channels_cdr_deltachat", to: 'channels_cdr_deltachat#add', via: :post
|
||||||
|
match "#{api_path}/channels_cdr_deltachat/:id", to: 'channels_cdr_deltachat#update', via: :put
|
||||||
|
match "#{api_path}/channels_cdr_deltachat_webhook/:token", to: 'channels_cdr_deltachat#webhook', via: :post
|
||||||
|
match "#{api_path}/channels_cdr_deltachat_bot_webhook/:bot_token", to: 'channels_cdr_deltachat#bot_webhook', via: :post
|
||||||
|
match "#{api_path}/channels_cdr_deltachat_disable", to: 'channels_cdr_deltachat#disable', via: :post
|
||||||
|
match "#{api_path}/channels_cdr_deltachat_enable", to: 'channels_cdr_deltachat#enable', via: :post
|
||||||
|
match "#{api_path}/channels_cdr_deltachat", to: 'channels_cdr_deltachat#destroy', via: :delete
|
||||||
|
match "#{api_path}/channels_cdr_deltachat_rotate_token", to: 'channels_cdr_deltachat#rotate_token', via: :post
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class CdrDeltachatChannel < ActiveRecord::Migration[5.2]
|
||||||
|
def self.up
|
||||||
|
Ticket::Article::Type.create_if_not_exists(
|
||||||
|
name: 'cdr_deltachat',
|
||||||
|
communication: true,
|
||||||
|
updated_by_id: 1,
|
||||||
|
created_by_id: 1
|
||||||
|
)
|
||||||
|
Permission.create_if_not_exists(
|
||||||
|
name: 'admin.channel_cdr_deltachat',
|
||||||
|
description: 'Manage %s',
|
||||||
|
preferences: {
|
||||||
|
translations: ['Channel - DeltaChat']
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.down
|
||||||
|
t = Ticket::Article::Type.find_by(name: 'cdr_deltachat')
|
||||||
|
t&.destroy
|
||||||
|
|
||||||
|
p = Permission.find_by(name: 'admin.channel_cdr_deltachat')
|
||||||
|
p&.destroy
|
||||||
|
end
|
||||||
|
end
|
||||||
75
packages/zammad-addon-link/src/lib/cdr_deltachat.rb
Normal file
75
packages/zammad-addon-link/src/lib/cdr_deltachat.rb
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'cdr_deltachat_api'
|
||||||
|
|
||||||
|
class CdrDeltachat
|
||||||
|
attr_accessor :client
|
||||||
|
|
||||||
|
def self.check_token(api_url, token)
|
||||||
|
api = CdrDeltachatApi.new(api_url, token)
|
||||||
|
begin
|
||||||
|
bot = api.fetch_self
|
||||||
|
rescue StandardError => e
|
||||||
|
raise "invalid api token: #{e.message}"
|
||||||
|
end
|
||||||
|
bot
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.bot_by_bot_token(bot_token)
|
||||||
|
Channel.where(area: 'DeltaChat::Account').each do |channel|
|
||||||
|
next unless channel.options
|
||||||
|
next unless channel.options[:bot_token]
|
||||||
|
return channel if channel.options[:bot_token].to_s == bot_token.to_s
|
||||||
|
end
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.timestamp_to_date(timestamp_str)
|
||||||
|
Time.at(timestamp_str.to_i).utc.to_datetime
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.message_id(message_raw)
|
||||||
|
format('%<from>s@%<timestamp>s', from: message_raw['from'], timestamp: message_raw['timestamp'])
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(api_url, token)
|
||||||
|
@token = token
|
||||||
|
@api_url = api_url
|
||||||
|
@api = CdrDeltachatApi.new(api_url, token)
|
||||||
|
end
|
||||||
|
|
||||||
|
def send_message(recipient, message)
|
||||||
|
return if Rails.env.test?
|
||||||
|
|
||||||
|
@api.send_message(recipient, message)
|
||||||
|
end
|
||||||
|
|
||||||
|
def from_article(article)
|
||||||
|
Rails.logger.debug { "Create deltachat message from article..." }
|
||||||
|
|
||||||
|
ticket = Ticket.find_by(id: article.ticket_id)
|
||||||
|
raise "No ticket found for article #{article.id}" unless ticket
|
||||||
|
|
||||||
|
recipient = ticket.preferences.dig('cdr_deltachat', 'chat_id')
|
||||||
|
raise "No DeltaChat chat_id found in ticket preferences" unless recipient
|
||||||
|
|
||||||
|
Rails.logger.debug { "Sending to recipient: '#{recipient}'" }
|
||||||
|
|
||||||
|
options = {}
|
||||||
|
|
||||||
|
attachments = Store.list(object: 'Ticket::Article', o_id: article.id)
|
||||||
|
if attachments.any?
|
||||||
|
attachment_data = attachments.map do |attachment|
|
||||||
|
{
|
||||||
|
data: Base64.strict_encode64(attachment.content),
|
||||||
|
filename: attachment.filename,
|
||||||
|
mime_type: attachment.preferences['Mime-Type'] || attachment.preferences['Content-Type'] || 'application/octet-stream'
|
||||||
|
}
|
||||||
|
end
|
||||||
|
options[:attachments] = attachment_data
|
||||||
|
Rails.logger.debug { "Sending #{attachment_data.length} attachment(s) with message" }
|
||||||
|
end
|
||||||
|
|
||||||
|
@api.send_message(recipient, article[:body], options)
|
||||||
|
end
|
||||||
|
end
|
||||||
77
packages/zammad-addon-link/src/lib/cdr_deltachat_api.rb
Normal file
77
packages/zammad-addon-link/src/lib/cdr_deltachat_api.rb
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'json'
|
||||||
|
require 'net/http'
|
||||||
|
require 'net/https'
|
||||||
|
require 'uri'
|
||||||
|
|
||||||
|
class CdrDeltachatApi
|
||||||
|
def initialize(api_url, token)
|
||||||
|
@token = token
|
||||||
|
@last_update = 0
|
||||||
|
@api_url = ENV.fetch('BRIDGE_DELTACHAT_URL', api_url || 'http://bridge-deltachat:5001')
|
||||||
|
end
|
||||||
|
|
||||||
|
def get(api)
|
||||||
|
url = "#{@api_url}/api/bots/#{@token}/#{api}"
|
||||||
|
response = Faraday.get(url, nil, { 'Accept' => 'application/json' })
|
||||||
|
return {} unless response.success?
|
||||||
|
|
||||||
|
JSON.parse(response.body)
|
||||||
|
rescue JSON::ParserError, Faraday::Error => e
|
||||||
|
Rails.logger.error "CdrDeltachatApi: GET #{api} failed: #{e.message}"
|
||||||
|
{}
|
||||||
|
end
|
||||||
|
|
||||||
|
def post(api, params = {})
|
||||||
|
url = "#{@api_url}/api/bots/#{@token}/#{api}"
|
||||||
|
response = Faraday.post(url, params.to_json, {
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
'Accept' => 'application/json'
|
||||||
|
})
|
||||||
|
|
||||||
|
unless response.success?
|
||||||
|
Rails.logger.error "CdrDeltachatApi: POST #{api} failed: #{response.status} #{response.body}"
|
||||||
|
raise "Failed to call DeltaChat API: #{response.status}"
|
||||||
|
end
|
||||||
|
|
||||||
|
JSON.parse(response.body)
|
||||||
|
rescue JSON::ParserError => e
|
||||||
|
Rails.logger.error "CdrDeltachatApi: Failed to parse response: #{e.message}"
|
||||||
|
{}
|
||||||
|
rescue Faraday::Error => e
|
||||||
|
Rails.logger.error "CdrDeltachatApi: POST #{api} failed: #{e.message}"
|
||||||
|
raise "Failed to call DeltaChat API: #{e.message}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_self
|
||||||
|
get('')
|
||||||
|
end
|
||||||
|
|
||||||
|
def send_message(recipient, text, options = {})
|
||||||
|
params = {
|
||||||
|
email: recipient.to_s,
|
||||||
|
message: text
|
||||||
|
}
|
||||||
|
|
||||||
|
if options[:attachments].present?
|
||||||
|
params[:attachments] = options[:attachments].map do |att|
|
||||||
|
{
|
||||||
|
data: att[:data],
|
||||||
|
filename: att[:filename],
|
||||||
|
mime_type: att[:mime_type]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
result = post('send', params)
|
||||||
|
|
||||||
|
{
|
||||||
|
'result' => {
|
||||||
|
'to' => result.dig('result', 'recipient') || recipient,
|
||||||
|
'from' => result.dig('result', 'source') || @token,
|
||||||
|
'timestamp' => result.dig('result', 'timestamp') || Time.current.iso8601
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<symbol id="icon-cdr-deltachat" viewBox="0 0 17 17"><title>deltachat</title>
|
||||||
|
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" viewBox="0 0 48 48">
|
||||||
|
<g><path fill="#C6C7C8" d="M24,2C11.85,2,2,11.85,2,24c0,4.22,1.19,8.16,3.25,11.51L2.08,45.92l10.41-3.17C15.84,44.81,19.78,46,24,46 c12.15,0,22-9.85,22-22S36.15,2,24,2z M24,40c-3.44,0-6.65-1.08-9.28-2.92l-6.53,1.99l1.99-6.53C8.34,29.91,7.26,26.71,7.26,23.26 c0-9.24,7.5-16.74,16.74-16.74s16.74,7.5,16.74,16.74S33.24,40,24,40z"/><path fill="#C6C7C8" d="M24,11.5c-6.9,0-12.5,5.6-12.5,12.5c0,2.76,0.9,5.31,2.41,7.38l-0.3,3.72l3.72-0.3 C19.35,36.31,21.57,37,24,37c6.9,0,12.5-5.6,12.5-12.5S30.9,11.5,24,11.5z M24,33c-1.2,0-2.35-0.24-3.4-0.67l-0.73,0.06 l0.06-0.73C18.24,30.35,17,28.3,17,26c0-3.87,3.13-7,7-7s7,3.13,7,7S27.87,33,24,33z"/></g>
|
||||||
|
</svg>
|
||||||
|
</symbol>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue