Make APIs more similar
This commit is contained in:
parent
9f0e1f8b61
commit
c40d7d056e
57 changed files with 3994 additions and 1801 deletions
3
apps/bridge-deltachat/eslint.config.mjs
Normal file
3
apps/bridge-deltachat/eslint.config.mjs
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
import config from "@link-stack/eslint-config/node";
|
||||||
|
|
||||||
|
export default config;
|
||||||
|
|
@ -4,23 +4,31 @@
|
||||||
"main": "build/main/index.js",
|
"main": "build/main/index.js",
|
||||||
"author": "Darren Clarke <darren@redaranj.com>",
|
"author": "Darren Clarke <darren@redaranj.com>",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
|
"prettier": "@link-stack/prettier-config",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@deltachat/jsonrpc-client": "^1.151.1",
|
"@deltachat/jsonrpc-client": "^1.151.1",
|
||||||
"@deltachat/stdio-rpc-server": "^1.151.1",
|
"@deltachat/stdio-rpc-server": "^1.151.1",
|
||||||
"@hono/node-server": "^1.13.8",
|
"@hono/node-server": "^1.13.8",
|
||||||
"hono": "^4.7.4",
|
"hono": "^4.7.4",
|
||||||
"pino": "^9.6.0",
|
"@link-stack/logger": "workspace:*"
|
||||||
"pino-pretty": "^13.0.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@link-stack/eslint-config": "workspace:*",
|
||||||
|
"@link-stack/prettier-config": "workspace:*",
|
||||||
|
"@link-stack/typescript-config": "workspace:*",
|
||||||
"@types/node": "*",
|
"@types/node": "*",
|
||||||
"dotenv-cli": "^10.0.0",
|
"dotenv-cli": "^10.0.0",
|
||||||
|
"eslint": "^9.23.0",
|
||||||
|
"prettier": "^3.5.3",
|
||||||
"tsx": "^4.20.6",
|
"tsx": "^4.20.6",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
"dev": "dotenv -- tsx src/index.ts",
|
"dev": "dotenv -- tsx src/index.ts",
|
||||||
"start": "node build/main/index.js"
|
"start": "node build/main/index.js",
|
||||||
|
"lint": "eslint src/",
|
||||||
|
"format": "prettier --write src/",
|
||||||
|
"format:check": "prettier --check src/"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,9 @@
|
||||||
*/
|
*/
|
||||||
export function getMaxAttachmentSize(): number {
|
export function getMaxAttachmentSize(): number {
|
||||||
const envValue = process.env.BRIDGE_MAX_ATTACHMENT_SIZE_MB;
|
const envValue = process.env.BRIDGE_MAX_ATTACHMENT_SIZE_MB;
|
||||||
const sizeInMB = envValue ? parseInt(envValue, 10) : 50;
|
const sizeInMB = envValue ? Number.parseInt(envValue, 10) : 50;
|
||||||
|
|
||||||
if (isNaN(sizeInMB) || sizeInMB <= 0) {
|
if (Number.isNaN(sizeInMB) || sizeInMB <= 0) {
|
||||||
console.warn(`Invalid BRIDGE_MAX_ATTACHMENT_SIZE_MB value: ${envValue}, using default 50MB`);
|
console.warn(`Invalid BRIDGE_MAX_ATTACHMENT_SIZE_MB value: ${envValue}, using default 50MB`);
|
||||||
return 50 * 1024 * 1024;
|
return 50 * 1024 * 1024;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { serve } from "@hono/node-server";
|
import { serve } from "@hono/node-server";
|
||||||
import DeltaChatService from "./service.ts";
|
import { createLogger } from "@link-stack/logger";
|
||||||
|
|
||||||
import { createRoutes } from "./routes.ts";
|
import { createRoutes } from "./routes.ts";
|
||||||
import { createLogger } from "./lib/logger";
|
import DeltaChatService from "./service.ts";
|
||||||
|
|
||||||
const logger = createLogger("bridge-deltachat-index");
|
const logger = createLogger("bridge-deltachat-index");
|
||||||
|
|
||||||
|
|
@ -10,7 +11,7 @@ const main = async () => {
|
||||||
await service.initialize();
|
await service.initialize();
|
||||||
|
|
||||||
const app = createRoutes(service);
|
const app = createRoutes(service);
|
||||||
const port = parseInt(process.env.PORT || "5001", 10);
|
const port = Number.parseInt(process.env.PORT || "5001", 10);
|
||||||
|
|
||||||
serve({ fetch: app.fetch, port }, (info) => {
|
serve({ fetch: app.fetch, port }, (info) => {
|
||||||
logger.info({ port: info.port }, "bridge-deltachat listening");
|
logger.info({ port: info.port }, "bridge-deltachat listening");
|
||||||
|
|
@ -26,7 +27,7 @@ const main = async () => {
|
||||||
process.on("SIGINT", shutdown);
|
process.on("SIGINT", shutdown);
|
||||||
};
|
};
|
||||||
|
|
||||||
main().catch((err) => {
|
main().catch((error) => {
|
||||||
logger.error(err);
|
logger.error(error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
import pino, { Logger as PinoLogger, LoggerOptions } from 'pino';
|
|
||||||
|
|
||||||
export type Logger = PinoLogger;
|
|
||||||
|
|
||||||
const getLogLevel = (): string => {
|
|
||||||
return process.env.LOG_LEVEL || (process.env.NODE_ENV === 'production' ? 'info' : 'debug');
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPinoConfig = (): LoggerOptions => {
|
|
||||||
const isDevelopment = process.env.NODE_ENV !== 'production';
|
|
||||||
|
|
||||||
const baseConfig: LoggerOptions = {
|
|
||||||
level: getLogLevel(),
|
|
||||||
formatters: {
|
|
||||||
level: (label) => {
|
|
||||||
return { level: label.toUpperCase() };
|
|
||||||
},
|
|
||||||
},
|
|
||||||
timestamp: () => `,"timestamp":"${new Date(Date.now()).toISOString()}"`,
|
|
||||||
redact: {
|
|
||||||
paths: [
|
|
||||||
'password',
|
|
||||||
'token',
|
|
||||||
'secret',
|
|
||||||
'api_key',
|
|
||||||
'apiKey',
|
|
||||||
'authorization',
|
|
||||||
'cookie',
|
|
||||||
'access_token',
|
|
||||||
'refresh_token',
|
|
||||||
'*.password',
|
|
||||||
'*.token',
|
|
||||||
'*.secret',
|
|
||||||
'*.api_key',
|
|
||||||
'*.apiKey',
|
|
||||||
'*.authorization',
|
|
||||||
'*.cookie',
|
|
||||||
'*.access_token',
|
|
||||||
'*.refresh_token',
|
|
||||||
'headers.authorization',
|
|
||||||
'headers.cookie',
|
|
||||||
'headers.Authorization',
|
|
||||||
'headers.Cookie',
|
|
||||||
'credentials.password',
|
|
||||||
'credentials.secret',
|
|
||||||
'credentials.token',
|
|
||||||
],
|
|
||||||
censor: '[REDACTED]',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isDevelopment) {
|
|
||||||
return {
|
|
||||||
...baseConfig,
|
|
||||||
transport: {
|
|
||||||
target: 'pino-pretty',
|
|
||||||
options: {
|
|
||||||
colorize: true,
|
|
||||||
translateTime: 'SYS:standard',
|
|
||||||
ignore: 'pid,hostname',
|
|
||||||
singleLine: false,
|
|
||||||
messageFormat: '{msg}',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return baseConfig;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const logger: Logger = pino(getPinoConfig());
|
|
||||||
|
|
||||||
export const createLogger = (name: string, context?: Record<string, any>): Logger => {
|
|
||||||
return logger.child({ name, ...context });
|
|
||||||
};
|
|
||||||
|
|
||||||
export default logger;
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
|
import { createLogger } from "@link-stack/logger";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
|
|
||||||
import type DeltaChatService from "./service.ts";
|
import type DeltaChatService from "./service.ts";
|
||||||
import { createLogger } from "./lib/logger";
|
|
||||||
|
|
||||||
const logger = createLogger("bridge-deltachat-routes");
|
const logger = createLogger("bridge-deltachat-routes");
|
||||||
|
|
||||||
|
const errorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
||||||
|
|
||||||
export function createRoutes(service: DeltaChatService): Hono {
|
export function createRoutes(service: DeltaChatService): Hono {
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
|
|
@ -15,9 +18,9 @@ export function createRoutes(service: DeltaChatService): Hono {
|
||||||
const result = await service.configure(id, email, password);
|
const result = await service.configure(id, email, password);
|
||||||
logger.info({ id, email }, "Bot configured");
|
logger.info({ id, email }, "Bot configured");
|
||||||
return c.json(result);
|
return c.json(result);
|
||||||
} catch (err: any) {
|
} catch (error) {
|
||||||
logger.error({ id, error: err.message }, "Failed to configure bot");
|
logger.error({ id, error: errorMessage(error) }, "Failed to configure bot");
|
||||||
return c.json({ error: err.message }, 500);
|
return c.json({ error: errorMessage(error) }, 500);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -34,9 +37,14 @@ export function createRoutes(service: DeltaChatService): Hono {
|
||||||
attachments?: Array<{ data: string; filename: string; mime_type: string }>;
|
attachments?: Array<{ data: string; filename: string; mime_type: string }>;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const result = await service.send(id, email, message, attachments);
|
try {
|
||||||
logger.info({ id, attachmentCount: attachments?.length || 0 }, "Sent message");
|
const result = await service.send(id, email, message, attachments);
|
||||||
return c.json({ result });
|
logger.info({ id, attachmentCount: attachments?.length || 0 }, "Sent message");
|
||||||
|
return c.json({ result });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ id, error: errorMessage(error) }, "Failed to send message");
|
||||||
|
return c.json({ error: errorMessage(error) }, 500);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/bots/:id/unconfigure", async (c) => {
|
app.post("/api/bots/:id/unconfigure", async (c) => {
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,11 @@
|
||||||
import { startDeltaChat, DeltaChat } from "@deltachat/stdio-rpc-server";
|
import fs from "node:fs";
|
||||||
import fs from "fs";
|
import os from "node:os";
|
||||||
import path from "path";
|
import path from "node:path";
|
||||||
import os from "os";
|
|
||||||
import { createLogger } from "./lib/logger";
|
import { startDeltaChat, type DeltaChatOverJsonRpcServer } from "@deltachat/stdio-rpc-server";
|
||||||
import {
|
import { createLogger } from "@link-stack/logger";
|
||||||
getMaxAttachmentSize,
|
|
||||||
getMaxTotalAttachmentSize,
|
import { getMaxAttachmentSize, getMaxTotalAttachmentSize, MAX_ATTACHMENTS } from "./attachments";
|
||||||
MAX_ATTACHMENTS,
|
|
||||||
} from "./attachments";
|
|
||||||
|
|
||||||
const logger = createLogger("bridge-deltachat-service");
|
const logger = createLogger("bridge-deltachat-service");
|
||||||
|
|
||||||
|
|
@ -16,7 +14,7 @@ interface BotMapping {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class DeltaChatService {
|
export default class DeltaChatService {
|
||||||
private dc: DeltaChat | null = null;
|
private dc: DeltaChatOverJsonRpcServer | null = null;
|
||||||
private botMapping: BotMapping = {};
|
private botMapping: BotMapping = {};
|
||||||
private dataDir: string;
|
private dataDir: string;
|
||||||
private mappingFile: string;
|
private mappingFile: string;
|
||||||
|
|
@ -47,8 +45,8 @@ export default class DeltaChatService {
|
||||||
logger.warn({ botId, accountId }, "Account not configured, removing from mapping");
|
logger.warn({ botId, accountId }, "Account not configured, removing from mapping");
|
||||||
delete this.botMapping[botId];
|
delete this.botMapping[botId];
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (error) {
|
||||||
logger.error({ botId, accountId, err }, "Failed to resume bot, removing from mapping");
|
logger.error({ botId, accountId, err: error }, "Failed to resume bot, removing from mapping");
|
||||||
delete this.botMapping[botId];
|
delete this.botMapping[botId];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -63,8 +61,8 @@ export default class DeltaChatService {
|
||||||
try {
|
try {
|
||||||
await this.dc.rpc.stopIo(accountId);
|
await this.dc.rpc.stopIo(accountId);
|
||||||
logger.info({ botId, accountId }, "Stopped IO for bot");
|
logger.info({ botId, accountId }, "Stopped IO for bot");
|
||||||
} catch (err) {
|
} catch (error) {
|
||||||
logger.error({ botId, accountId, err }, "Error stopping IO");
|
logger.error({ botId, accountId, err: error }, "Error stopping IO");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.dc.close();
|
this.dc.close();
|
||||||
|
|
@ -75,18 +73,18 @@ export default class DeltaChatService {
|
||||||
private loadBotMapping(): void {
|
private loadBotMapping(): void {
|
||||||
if (fs.existsSync(this.mappingFile)) {
|
if (fs.existsSync(this.mappingFile)) {
|
||||||
try {
|
try {
|
||||||
const data = fs.readFileSync(this.mappingFile, "utf-8");
|
const data = fs.readFileSync(this.mappingFile, "utf8");
|
||||||
this.botMapping = JSON.parse(data);
|
this.botMapping = JSON.parse(data);
|
||||||
logger.info({ botCount: Object.keys(this.botMapping).length }, "Loaded bot mapping");
|
logger.info({ botCount: Object.keys(this.botMapping).length }, "Loaded bot mapping");
|
||||||
} catch (err) {
|
} catch (error) {
|
||||||
logger.error({ err }, "Failed to load bot mapping, starting fresh");
|
logger.error({ err: error }, "Failed to load bot mapping, starting fresh");
|
||||||
this.botMapping = {};
|
this.botMapping = {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private saveBotMapping(): void {
|
private saveBotMapping(): void {
|
||||||
fs.writeFileSync(this.mappingFile, JSON.stringify(this.botMapping, null, 2), "utf-8");
|
fs.writeFileSync(this.mappingFile, JSON.stringify(this.botMapping, null, 2), "utf8");
|
||||||
}
|
}
|
||||||
|
|
||||||
private validateBotId(id: string): void {
|
private validateBotId(id: string): void {
|
||||||
|
|
@ -102,29 +100,14 @@ export default class DeltaChatService {
|
||||||
private registerEventListeners(): void {
|
private registerEventListeners(): void {
|
||||||
if (!this.dc) return;
|
if (!this.dc) return;
|
||||||
|
|
||||||
const dc = this.dc;
|
this.dc.on("IncomingMsg", (accountId, event) => {
|
||||||
|
this.handleIncomingMessage(accountId, event.chatId, event.msgId).catch((error) => {
|
||||||
(async () => {
|
logger.error({ err: error, accountId }, "Error handling incoming message");
|
||||||
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(
|
private async handleIncomingMessage(accountId: number, chatId: number, msgId: number): Promise<void> {
|
||||||
accountId: number,
|
|
||||||
chatId: number,
|
|
||||||
msgId: number,
|
|
||||||
): Promise<void> {
|
|
||||||
if (!this.dc) return;
|
if (!this.dc) return;
|
||||||
|
|
||||||
const botId = this.getBotIdForAccount(accountId);
|
const botId = this.getBotIdForAccount(accountId);
|
||||||
|
|
@ -135,9 +118,10 @@ export default class DeltaChatService {
|
||||||
|
|
||||||
const msg = await this.dc.rpc.getMessage(accountId, msgId);
|
const msg = await this.dc.rpc.getMessage(accountId, msgId);
|
||||||
|
|
||||||
// Skip bot messages and non-chat messages (plain email)
|
// Incoming states: 10=fresh, 13=noticed, 16=seen
|
||||||
if (msg.isBot || !msg.isIncoming) {
|
const isIncoming = msg.state === 10 || msg.state === 13 || msg.state === 16;
|
||||||
logger.debug({ msgId, isBot: msg.isBot, isIncoming: msg.isIncoming }, "Skipping message");
|
if (msg.isBot || !isIncoming) {
|
||||||
|
logger.debug({ msgId, isBot: msg.isBot, state: msg.state }, "Skipping message");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -159,8 +143,8 @@ export default class DeltaChatService {
|
||||||
filename = msg.fileName || path.basename(msg.file);
|
filename = msg.fileName || path.basename(msg.file);
|
||||||
mimeType = msg.fileMime || "application/octet-stream";
|
mimeType = msg.fileMime || "application/octet-stream";
|
||||||
logger.info({ filename, mimeType, size: fileData.length }, "Attachment found");
|
logger.info({ filename, mimeType, size: fileData.length }, "Attachment found");
|
||||||
} catch (err) {
|
} catch (error) {
|
||||||
logger.error({ err, file: msg.file }, "Failed to read attachment file");
|
logger.error({ err: error, file: msg.file }, "Failed to read attachment file");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -180,37 +164,30 @@ export default class DeltaChatService {
|
||||||
|
|
||||||
const zammadUrl = process.env.ZAMMAD_URL || "http://zammad-nginx:8080";
|
const zammadUrl = process.env.ZAMMAD_URL || "http://zammad-nginx:8080";
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(`${zammadUrl}/api/v1/channels_cdr_deltachat_bot_webhook/${botId}`, {
|
||||||
`${zammadUrl}/api/v1/channels_cdr_deltachat_bot_webhook/${botId}`,
|
method: "POST",
|
||||||
{
|
headers: { "Content-Type": "application/json" },
|
||||||
method: "POST",
|
body: JSON.stringify(payload),
|
||||||
headers: { "Content-Type": "application/json" },
|
});
|
||||||
body: JSON.stringify(payload),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (response.ok) {
|
||||||
|
logger.info({ botId, msgId }, "Message forwarded to Zammad");
|
||||||
|
} else {
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
logger.error({ status: response.status, error: errorText, botId }, "Failed to send message to Zammad");
|
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) {
|
} catch (error) {
|
||||||
logger.error({ err, botId }, "Failed to POST to Zammad webhook");
|
logger.error({ err: error, botId }, "Failed to POST to Zammad webhook");
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.dc.rpc.markseenMsgs(accountId, [msgId]);
|
await this.dc.rpc.markseenMsgs(accountId, [msgId]);
|
||||||
} catch (err) {
|
} catch (error) {
|
||||||
logger.error({ err, msgId }, "Failed to mark message as seen");
|
logger.error({ err: error, msgId }, "Failed to mark message as seen");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async configure(
|
async configure(botId: string, email: string, password: string): Promise<{ accountId: number; email: string }> {
|
||||||
botId: string,
|
|
||||||
email: string,
|
|
||||||
password: string,
|
|
||||||
): Promise<{ accountId: number; email: string }> {
|
|
||||||
this.validateBotId(botId);
|
this.validateBotId(botId);
|
||||||
if (!this.dc) throw new Error("DeltaChat not initialized");
|
if (!this.dc) throw new Error("DeltaChat not initialized");
|
||||||
|
|
||||||
|
|
@ -240,14 +217,14 @@ export default class DeltaChatService {
|
||||||
this.saveBotMapping();
|
this.saveBotMapping();
|
||||||
|
|
||||||
return { accountId, email };
|
return { accountId, email };
|
||||||
} catch (err) {
|
} catch (error) {
|
||||||
logger.error({ botId, accountId, err }, "Configuration failed, removing account");
|
logger.error({ botId, accountId, err: error }, "Configuration failed, removing account");
|
||||||
try {
|
try {
|
||||||
await this.dc.rpc.removeAccount(accountId);
|
await this.dc.rpc.removeAccount(accountId);
|
||||||
} catch (removeErr) {
|
} catch (error_) {
|
||||||
logger.error({ removeErr }, "Failed to clean up account after configuration failure");
|
logger.error({ removeErr: error_ }, "Failed to clean up account after configuration failure");
|
||||||
}
|
}
|
||||||
throw err;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -280,14 +257,14 @@ export default class DeltaChatService {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.dc.rpc.stopIo(accountId);
|
await this.dc.rpc.stopIo(accountId);
|
||||||
} catch (err) {
|
} catch (error) {
|
||||||
logger.warn({ botId, accountId, err }, "Error stopping IO during unconfigure");
|
logger.warn({ botId, accountId, err: error }, "Error stopping IO during unconfigure");
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.dc.rpc.removeAccount(accountId);
|
await this.dc.rpc.removeAccount(accountId);
|
||||||
} catch (err) {
|
} catch (error) {
|
||||||
logger.warn({ botId, accountId, err }, "Error removing account during unconfigure");
|
logger.warn({ botId, accountId, err: error }, "Error removing account during unconfigure");
|
||||||
}
|
}
|
||||||
|
|
||||||
delete this.botMapping[botId];
|
delete this.botMapping[botId];
|
||||||
|
|
@ -299,7 +276,7 @@ export default class DeltaChatService {
|
||||||
botId: string,
|
botId: string,
|
||||||
email: string,
|
email: string,
|
||||||
message: string,
|
message: string,
|
||||||
attachments?: Array<{ data: string; filename: string; mime_type: string }>,
|
attachments?: Array<{ data: string; filename: string; mime_type: string }>
|
||||||
): Promise<{ recipient: string; timestamp: string; source: string }> {
|
): Promise<{ recipient: string; timestamp: string; source: string }> {
|
||||||
this.validateBotId(botId);
|
this.validateBotId(botId);
|
||||||
if (!this.dc) throw new Error("DeltaChat not initialized");
|
if (!this.dc) throw new Error("DeltaChat not initialized");
|
||||||
|
|
@ -328,14 +305,17 @@ export default class DeltaChatService {
|
||||||
if (estimatedSize > MAX_ATTACHMENT_SIZE) {
|
if (estimatedSize > MAX_ATTACHMENT_SIZE) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
{ filename: att.filename, size: estimatedSize, maxSize: MAX_ATTACHMENT_SIZE },
|
{ filename: att.filename, size: estimatedSize, maxSize: MAX_ATTACHMENT_SIZE },
|
||||||
"Attachment exceeds size limit, skipping",
|
"Attachment exceeds size limit, skipping"
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
totalSize += estimatedSize;
|
totalSize += estimatedSize;
|
||||||
if (totalSize > MAX_TOTAL_SIZE) {
|
if (totalSize > MAX_TOTAL_SIZE) {
|
||||||
logger.warn({ totalSize, maxTotalSize: MAX_TOTAL_SIZE }, "Total attachment size exceeds limit, skipping remaining");
|
logger.warn(
|
||||||
|
{ totalSize, maxTotalSize: MAX_TOTAL_SIZE },
|
||||||
|
"Total attachment size exceeds limit, skipping remaining"
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -346,7 +326,14 @@ export default class DeltaChatService {
|
||||||
try {
|
try {
|
||||||
await this.dc.rpc.sendMsg(accountId, chatId, {
|
await this.dc.rpc.sendMsg(accountId, chatId, {
|
||||||
text: message,
|
text: message,
|
||||||
|
html: null,
|
||||||
|
viewtype: null,
|
||||||
file: tmpFile,
|
file: tmpFile,
|
||||||
|
filename: att.filename,
|
||||||
|
location: null,
|
||||||
|
overrideSenderName: null,
|
||||||
|
quotedMessageId: null,
|
||||||
|
quotedText: null,
|
||||||
});
|
});
|
||||||
// Only include text with the first attachment; clear for subsequent
|
// Only include text with the first attachment; clear for subsequent
|
||||||
message = "";
|
message = "";
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,8 @@
|
||||||
{
|
{
|
||||||
|
"extends": "@link-stack/typescript-config/tsconfig.node.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "es2022",
|
|
||||||
"module": "NodeNext",
|
|
||||||
"moduleResolution": "NodeNext",
|
|
||||||
"outDir": "build/main",
|
"outDir": "build/main",
|
||||||
"rootDir": "src",
|
"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"],
|
"include": ["src/**/*.ts", "src/**/.*.ts"],
|
||||||
"exclude": ["node_modules/**"]
|
"exclude": ["node_modules/**"]
|
||||||
|
|
|
||||||
56
apps/bridge-signal/Dockerfile
Normal file
56
apps/bridge-signal/Dockerfile
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
FROM node:22-bookworm-slim AS base
|
||||||
|
|
||||||
|
FROM base AS builder
|
||||||
|
ARG APP_DIR=/opt/bridge-signal
|
||||||
|
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-signal --docker
|
||||||
|
|
||||||
|
FROM base AS installer
|
||||||
|
ARG APP_DIR=/opt/bridge-signal
|
||||||
|
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-signal
|
||||||
|
|
||||||
|
FROM base as runner
|
||||||
|
ARG BUILD_DATE
|
||||||
|
ARG VERSION
|
||||||
|
ARG APP_DIR=/opt/bridge-signal
|
||||||
|
ARG SIGNAL_CLI_VERSION=0.13.12
|
||||||
|
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 curl && \
|
||||||
|
ARCH=$(dpkg --print-architecture) && \
|
||||||
|
curl -L "https://github.com/AsamK/signal-cli/releases/download/v${SIGNAL_CLI_VERSION}/signal-cli-native-linux-${ARCH}-${SIGNAL_CLI_VERSION}.tar.gz" \
|
||||||
|
| tar xz -C /opt && \
|
||||||
|
ln -s /opt/signal-cli-native-linux-*/bin/signal-cli-native /usr/local/bin/signal-cli && \
|
||||||
|
apt-get remove -y curl && apt-get autoremove -y && rm -rf /var/lib/apt/lists/*
|
||||||
|
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-signal/
|
||||||
|
RUN chmod +x docker-entrypoint.sh
|
||||||
|
USER node
|
||||||
|
RUN mkdir /home/node/signal-data
|
||||||
|
EXPOSE 5002
|
||||||
|
ENV PORT 5002
|
||||||
|
ENV NODE_ENV production
|
||||||
|
ENV SIGNAL_DATA_DIR /home/node/signal-data
|
||||||
|
ENV COREPACK_ENABLE_NETWORK=0
|
||||||
|
ENTRYPOINT ["/opt/bridge-signal/apps/bridge-signal/docker-entrypoint.sh"]
|
||||||
5
apps/bridge-signal/docker-entrypoint.sh
Normal file
5
apps/bridge-signal/docker-entrypoint.sh
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
echo "starting bridge-signal"
|
||||||
|
exec dumb-init pnpm run start
|
||||||
3
apps/bridge-signal/eslint.config.mjs
Normal file
3
apps/bridge-signal/eslint.config.mjs
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
import config from "@link-stack/eslint-config/node";
|
||||||
|
|
||||||
|
export default config;
|
||||||
32
apps/bridge-signal/package.json
Normal file
32
apps/bridge-signal/package.json
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
{
|
||||||
|
"name": "@link-stack/bridge-signal",
|
||||||
|
"version": "3.5.0-beta.1",
|
||||||
|
"main": "build/main/index.js",
|
||||||
|
"author": "Darren Clarke <darren@redaranj.com>",
|
||||||
|
"license": "AGPL-3.0-or-later",
|
||||||
|
"prettier": "@link-stack/prettier-config",
|
||||||
|
"dependencies": {
|
||||||
|
"@hono/node-server": "^1.13.8",
|
||||||
|
"hono": "^4.7.4",
|
||||||
|
"@link-stack/logger": "workspace:*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@link-stack/eslint-config": "workspace:*",
|
||||||
|
"@link-stack/prettier-config": "workspace:*",
|
||||||
|
"@link-stack/typescript-config": "workspace:*",
|
||||||
|
"@types/node": "*",
|
||||||
|
"dotenv-cli": "^10.0.0",
|
||||||
|
"eslint": "^9.23.0",
|
||||||
|
"prettier": "^3.5.3",
|
||||||
|
"tsx": "^4.20.6",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"dev": "dotenv -- tsx src/index.ts",
|
||||||
|
"start": "node build/main/index.js",
|
||||||
|
"lint": "eslint src/",
|
||||||
|
"format": "prettier --write src/",
|
||||||
|
"format:check": "prettier --check src/"
|
||||||
|
}
|
||||||
|
}
|
||||||
35
apps/bridge-signal/src/attachments.ts
Normal file
35
apps/bridge-signal/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 ? Number.parseInt(envValue, 10) : 50;
|
||||||
|
|
||||||
|
if (Number.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;
|
||||||
33
apps/bridge-signal/src/index.ts
Normal file
33
apps/bridge-signal/src/index.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { serve } from "@hono/node-server";
|
||||||
|
import { createLogger } from "@link-stack/logger";
|
||||||
|
|
||||||
|
import { createRoutes } from "./routes.ts";
|
||||||
|
import SignalService from "./service.ts";
|
||||||
|
|
||||||
|
const logger = createLogger("bridge-signal-index");
|
||||||
|
|
||||||
|
const main = async () => {
|
||||||
|
const service = new SignalService();
|
||||||
|
await service.initialize();
|
||||||
|
|
||||||
|
const app = createRoutes(service);
|
||||||
|
const port = Number.parseInt(process.env.PORT || "5002", 10);
|
||||||
|
|
||||||
|
serve({ fetch: app.fetch, port }, (info) => {
|
||||||
|
logger.info({ port: info.port }, "bridge-signal listening");
|
||||||
|
});
|
||||||
|
|
||||||
|
const shutdown = async () => {
|
||||||
|
logger.info("Shutting down...");
|
||||||
|
await service.teardown();
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on("SIGTERM", shutdown);
|
||||||
|
process.on("SIGINT", shutdown);
|
||||||
|
};
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
logger.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
130
apps/bridge-signal/src/routes.ts
Normal file
130
apps/bridge-signal/src/routes.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
import { createLogger } from "@link-stack/logger";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
|
||||||
|
import type SignalService from "./service.ts";
|
||||||
|
|
||||||
|
const logger = createLogger("bridge-signal-routes");
|
||||||
|
|
||||||
|
const errorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
||||||
|
|
||||||
|
export function createRoutes(service: SignalService): Hono {
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
// Start device linking
|
||||||
|
app.post("/api/bots/:id/register", async (c) => {
|
||||||
|
const id = c.req.param("id");
|
||||||
|
const { phoneNumber, deviceName } = await c.req.json<{
|
||||||
|
phoneNumber: string;
|
||||||
|
deviceName?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await service.register(id, phoneNumber, deviceName);
|
||||||
|
logger.info({ id }, "Device linking started");
|
||||||
|
return c.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ id, error: errorMessage(error) }, "Failed to start device linking");
|
||||||
|
return c.json({ error: errorMessage(error) }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bot status
|
||||||
|
app.get("/api/bots/:id", async (c) => {
|
||||||
|
const id = c.req.param("id");
|
||||||
|
try {
|
||||||
|
return c.json(await service.getBot(id));
|
||||||
|
} catch (error) {
|
||||||
|
return c.json({ error: errorMessage(error) }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send message
|
||||||
|
app.post("/api/bots/:id/send", async (c) => {
|
||||||
|
const id = c.req.param("id");
|
||||||
|
const { recipient, message, attachments, autoGroup } = await c.req.json<{
|
||||||
|
recipient: string;
|
||||||
|
message: string;
|
||||||
|
attachments?: Array<{ data: string; filename: string; mime_type: string }>;
|
||||||
|
autoGroup?: { ticketNumber: string };
|
||||||
|
}>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await service.send(id, recipient, message, attachments, autoGroup);
|
||||||
|
logger.info({ id, recipient: result.recipient, attachmentCount: attachments?.length || 0 }, "Sent message");
|
||||||
|
return c.json({ result });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ id, error: errorMessage(error) }, "Failed to send message");
|
||||||
|
return c.json({ error: errorMessage(error) }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Unregister bot
|
||||||
|
app.post("/api/bots/:id/unregister", async (c) => {
|
||||||
|
const id = c.req.param("id");
|
||||||
|
try {
|
||||||
|
await service.unregister(id);
|
||||||
|
logger.info({ id }, "Bot unregistered");
|
||||||
|
return c.body(null, 200);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ id, error: errorMessage(error) }, "Failed to unregister bot");
|
||||||
|
return c.json({ error: errorMessage(error) }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create group
|
||||||
|
app.post("/api/bots/:id/groups", async (c) => {
|
||||||
|
const id = c.req.param("id");
|
||||||
|
const { name, members, description } = await c.req.json<{
|
||||||
|
name: string;
|
||||||
|
members: string[];
|
||||||
|
description?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await service.createGroup(id, name, members, description);
|
||||||
|
logger.info({ id, groupId: result.groupId }, "Group created");
|
||||||
|
return c.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ id, error: errorMessage(error) }, "Failed to create group");
|
||||||
|
return c.json({ error: errorMessage(error) }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update group
|
||||||
|
app.put("/api/bots/:id/groups/:groupId", async (c) => {
|
||||||
|
const id = c.req.param("id");
|
||||||
|
const groupId = c.req.param("groupId");
|
||||||
|
const { name, description } = await c.req.json<{
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await service.updateGroup(id, groupId, name, description);
|
||||||
|
logger.info({ id, groupId }, "Group updated");
|
||||||
|
return c.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ id, error: errorMessage(error) }, "Failed to update group");
|
||||||
|
return c.json({ error: errorMessage(error) }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// List groups
|
||||||
|
app.get("/api/bots/:id/groups", async (c) => {
|
||||||
|
const id = c.req.param("id");
|
||||||
|
try {
|
||||||
|
const groups = await service.listGroups(id);
|
||||||
|
return c.json(groups);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ id, error: errorMessage(error) }, "Failed to list groups");
|
||||||
|
return c.json({ error: errorMessage(error) }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
app.get("/api/health", (c) => {
|
||||||
|
return c.json({ status: "ok" });
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
462
apps/bridge-signal/src/service.ts
Normal file
462
apps/bridge-signal/src/service.ts
Normal file
|
|
@ -0,0 +1,462 @@
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { createLogger } from "@link-stack/logger";
|
||||||
|
|
||||||
|
import { getMaxAttachmentSize, getMaxTotalAttachmentSize, MAX_ATTACHMENTS } from "./attachments.ts";
|
||||||
|
import { SignalCli, SignalEnvelope } from "./signal-cli.ts";
|
||||||
|
|
||||||
|
const logger = createLogger("bridge-signal-service");
|
||||||
|
|
||||||
|
interface BotMapping {
|
||||||
|
[botId: string]: {
|
||||||
|
phoneNumber: string;
|
||||||
|
webhookToken?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Attachment {
|
||||||
|
data: string; // base64
|
||||||
|
filename: string;
|
||||||
|
mime_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SendResult {
|
||||||
|
recipient: string;
|
||||||
|
timestamp: number;
|
||||||
|
source: string;
|
||||||
|
groupId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class SignalService {
|
||||||
|
private cli: SignalCli | null = null;
|
||||||
|
private botMapping: BotMapping = {};
|
||||||
|
private dataDir: string;
|
||||||
|
private mappingFile: string;
|
||||||
|
private autoGroupsEnabled: boolean;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.dataDir = process.env.SIGNAL_DATA_DIR || "/home/node/signal-data";
|
||||||
|
this.mappingFile = path.join(this.dataDir, "bot-mapping.json");
|
||||||
|
this.autoGroupsEnabled = process.env.BRIDGE_SIGNAL_AUTO_GROUPS?.toLowerCase() === "true";
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
// Ensure data directory exists
|
||||||
|
if (!fs.existsSync(this.dataDir)) {
|
||||||
|
fs.mkdirSync(this.dataDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load bot mapping
|
||||||
|
this.loadBotMapping();
|
||||||
|
|
||||||
|
// Start signal-cli
|
||||||
|
this.cli = new SignalCli(this.dataDir);
|
||||||
|
await this.cli.start();
|
||||||
|
|
||||||
|
// Register message listener
|
||||||
|
this.cli.on("message", ({ account, envelope }: { account: string; envelope: SignalEnvelope }) => {
|
||||||
|
this.handleIncomingMessage(account, envelope).catch((error) => {
|
||||||
|
logger.error({ err: error, account }, "Error handling incoming message");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.cli.on("close", (code: number | null) => {
|
||||||
|
logger.warn({ code }, "signal-cli process closed unexpectedly");
|
||||||
|
});
|
||||||
|
|
||||||
|
this.cli.on("error", (err: Error) => {
|
||||||
|
logger.error({ err }, "signal-cli process error");
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info({ dataDir: this.dataDir, botCount: Object.keys(this.botMapping).length }, "SignalService initialized");
|
||||||
|
}
|
||||||
|
|
||||||
|
async teardown(): Promise<void> {
|
||||||
|
if (this.cli) {
|
||||||
|
this.cli.close();
|
||||||
|
this.cli = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Bot management ---
|
||||||
|
|
||||||
|
async register(botId: string, phoneNumber: string, deviceName = "Zammad"): Promise<{ linkUri: string }> {
|
||||||
|
this.validateBotId(botId);
|
||||||
|
if (!this.cli) throw new Error("SignalService not initialized");
|
||||||
|
|
||||||
|
logger.info({ botId, phoneNumber }, "Starting device linking");
|
||||||
|
|
||||||
|
const result = (await this.cli.call("startLink")) as Record<string, unknown> | undefined;
|
||||||
|
const linkUri = result?.deviceLinkUri as string;
|
||||||
|
if (!linkUri) {
|
||||||
|
throw new Error("signal-cli startLink did not return a deviceLinkUri");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finish linking in the background
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const finishResult = await this.cli!.call("finishLink", {
|
||||||
|
deviceLinkUri: linkUri,
|
||||||
|
deviceName,
|
||||||
|
});
|
||||||
|
const linkedNumber = (finishResult as string) || phoneNumber;
|
||||||
|
|
||||||
|
this.botMapping[botId] = { phoneNumber: linkedNumber };
|
||||||
|
this.saveBotMapping();
|
||||||
|
logger.info({ botId, phoneNumber: linkedNumber }, "Device linking completed");
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ err: error, botId }, "Device linking failed");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return { linkUri };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBot(botId: string): Promise<{ registered: boolean; phoneNumber: string | null }> {
|
||||||
|
this.validateBotId(botId);
|
||||||
|
|
||||||
|
const mapping = this.botMapping[botId];
|
||||||
|
if (!mapping) {
|
||||||
|
return { registered: false, phoneNumber: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { registered: true, phoneNumber: mapping.phoneNumber };
|
||||||
|
}
|
||||||
|
|
||||||
|
async unregister(botId: string): Promise<void> {
|
||||||
|
this.validateBotId(botId);
|
||||||
|
|
||||||
|
const mapping = this.botMapping[botId];
|
||||||
|
if (!mapping) {
|
||||||
|
logger.warn({ botId }, "Bot not found for unregister");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
delete this.botMapping[botId];
|
||||||
|
this.saveBotMapping();
|
||||||
|
logger.info({ botId }, "Bot unregistered");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Messaging ---
|
||||||
|
|
||||||
|
async send(
|
||||||
|
botId: string,
|
||||||
|
recipient: string,
|
||||||
|
message: string,
|
||||||
|
attachments?: Attachment[],
|
||||||
|
autoGroup?: { ticketNumber: string }
|
||||||
|
): Promise<SendResult> {
|
||||||
|
this.validateBotId(botId);
|
||||||
|
if (!this.cli) throw new Error("SignalService not initialized");
|
||||||
|
|
||||||
|
const mapping = this.botMapping[botId];
|
||||||
|
if (!mapping) throw new Error(`Bot ${botId} is not registered`);
|
||||||
|
|
||||||
|
const account = mapping.phoneNumber;
|
||||||
|
let finalRecipient = recipient;
|
||||||
|
let groupId: string | undefined;
|
||||||
|
|
||||||
|
// Auto-group: create a group if enabled and recipient is a phone number
|
||||||
|
if (this.autoGroupsEnabled && autoGroup && !recipient.startsWith("group.")) {
|
||||||
|
try {
|
||||||
|
const groupName = `Support Request: ${autoGroup.ticketNumber}`;
|
||||||
|
logger.info({ botId, groupName, recipient }, "Creating auto-group");
|
||||||
|
|
||||||
|
const createResult = (await this.cli.call("updateGroup", {
|
||||||
|
account,
|
||||||
|
name: groupName,
|
||||||
|
members: [recipient],
|
||||||
|
description: "Private support conversation",
|
||||||
|
})) as Record<string, unknown> | undefined;
|
||||||
|
|
||||||
|
if (createResult?.groupId) {
|
||||||
|
groupId = createResult.groupId as string;
|
||||||
|
finalRecipient = groupId;
|
||||||
|
logger.info({ botId, groupId, groupName }, "Auto-group created");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ err: error, botId }, "Failed to create auto-group, sending to original recipient");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build base64 attachments
|
||||||
|
const base64Attachments: string[] = [];
|
||||||
|
if (attachments && attachments.length > 0) {
|
||||||
|
const MAX_SIZE = getMaxAttachmentSize();
|
||||||
|
const MAX_TOTAL = getMaxTotalAttachmentSize();
|
||||||
|
|
||||||
|
if (attachments.length > MAX_ATTACHMENTS) {
|
||||||
|
logger.warn({ count: attachments.length, max: MAX_ATTACHMENTS }, "Too many attachments, truncating");
|
||||||
|
attachments = attachments.slice(0, MAX_ATTACHMENTS);
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalSize = 0;
|
||||||
|
for (const att of attachments) {
|
||||||
|
const estimatedSize = (att.data.length * 3) / 4;
|
||||||
|
if (estimatedSize > MAX_SIZE) {
|
||||||
|
logger.warn({ filename: att.filename, size: estimatedSize }, "Attachment too large, skipping");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
totalSize += estimatedSize;
|
||||||
|
if (totalSize > MAX_TOTAL) {
|
||||||
|
logger.warn({ totalSize }, "Total attachment size exceeded, skipping remaining");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
base64Attachments.push(att.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the message
|
||||||
|
const isGroup = finalRecipient.startsWith("group.");
|
||||||
|
const sendParams: Record<string, unknown> = {
|
||||||
|
account,
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isGroup) {
|
||||||
|
sendParams.groupId = finalRecipient;
|
||||||
|
} else {
|
||||||
|
sendParams.recipients = [finalRecipient];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (base64Attachments.length > 0) {
|
||||||
|
sendParams.base64Attachments = base64Attachments;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = (await this.cli.call("send", sendParams)) as Record<string, unknown> | undefined;
|
||||||
|
const timestamp = (result?.timestamp as number) || Date.now();
|
||||||
|
|
||||||
|
return {
|
||||||
|
recipient: finalRecipient,
|
||||||
|
timestamp,
|
||||||
|
source: account,
|
||||||
|
groupId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Groups ---
|
||||||
|
|
||||||
|
async createGroup(
|
||||||
|
botId: string,
|
||||||
|
name: string,
|
||||||
|
members: string[],
|
||||||
|
description?: string
|
||||||
|
): Promise<{ groupId: string }> {
|
||||||
|
this.validateBotId(botId);
|
||||||
|
if (!this.cli) throw new Error("SignalService not initialized");
|
||||||
|
|
||||||
|
const mapping = this.botMapping[botId];
|
||||||
|
if (!mapping) throw new Error(`Bot ${botId} is not registered`);
|
||||||
|
|
||||||
|
const params: Record<string, unknown> = {
|
||||||
|
account: mapping.phoneNumber,
|
||||||
|
name,
|
||||||
|
members,
|
||||||
|
};
|
||||||
|
if (description) params.description = description;
|
||||||
|
|
||||||
|
const result = (await this.cli.call("updateGroup", params)) as Record<string, unknown> | undefined;
|
||||||
|
return { groupId: (result?.groupId as string) || String(result) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateGroup(botId: string, groupId: string, name?: string, description?: string): Promise<void> {
|
||||||
|
this.validateBotId(botId);
|
||||||
|
if (!this.cli) throw new Error("SignalService not initialized");
|
||||||
|
|
||||||
|
const mapping = this.botMapping[botId];
|
||||||
|
if (!mapping) throw new Error(`Bot ${botId} is not registered`);
|
||||||
|
|
||||||
|
const params: Record<string, unknown> = {
|
||||||
|
account: mapping.phoneNumber,
|
||||||
|
groupId,
|
||||||
|
};
|
||||||
|
if (name) params.name = name;
|
||||||
|
if (description) params.description = description;
|
||||||
|
|
||||||
|
await this.cli.call("updateGroup", params);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listGroups(botId: string): Promise<unknown[]> {
|
||||||
|
this.validateBotId(botId);
|
||||||
|
if (!this.cli) throw new Error("SignalService not initialized");
|
||||||
|
|
||||||
|
const mapping = this.botMapping[botId];
|
||||||
|
if (!mapping) throw new Error(`Bot ${botId} is not registered`);
|
||||||
|
|
||||||
|
const result = await this.cli.call("listGroups", { account: mapping.phoneNumber });
|
||||||
|
return Array.isArray(result) ? result : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Incoming message handler ---
|
||||||
|
|
||||||
|
private async handleIncomingMessage(account: string, envelope: SignalEnvelope): Promise<void> {
|
||||||
|
// Find botId for this account
|
||||||
|
const botId = this.findBotIdByAccount(account);
|
||||||
|
if (!botId) {
|
||||||
|
logger.debug({ account }, "No bot mapping for account, ignoring message");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = envelope.sourceNumber || envelope.source;
|
||||||
|
const sourceUuid = envelope.sourceUuid;
|
||||||
|
|
||||||
|
// Skip messages from self
|
||||||
|
if (source === account) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataMessage = envelope.dataMessage;
|
||||||
|
if (!dataMessage) {
|
||||||
|
// Could be typing indicator, receipt, etc. -- ignore
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for group info
|
||||||
|
const isGroup = !!dataMessage.groupInfo?.groupId;
|
||||||
|
const groupId = dataMessage.groupInfo?.groupId;
|
||||||
|
const groupType = dataMessage.groupInfo?.type;
|
||||||
|
|
||||||
|
// Detect group join events
|
||||||
|
if (
|
||||||
|
isGroup &&
|
||||||
|
groupType &&
|
||||||
|
["DELIVER", "UPDATE"].includes(groupType) && // Group update events (member joins) -- forward to Zammad
|
||||||
|
groupType === "UPDATE" &&
|
||||||
|
source
|
||||||
|
) {
|
||||||
|
await this.postGroupMemberJoined(botId, groupId!, source);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process data messages with content
|
||||||
|
const messageText = dataMessage.message;
|
||||||
|
if (!messageText && (!dataMessage.attachments || dataMessage.attachments.length === 0)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle attachments
|
||||||
|
let attachment: string | undefined;
|
||||||
|
let filename: string | undefined;
|
||||||
|
let mimeType: string | undefined;
|
||||||
|
|
||||||
|
if (dataMessage.attachments && dataMessage.attachments.length > 0) {
|
||||||
|
const att = dataMessage.attachments[0];
|
||||||
|
const storedFile = att.storedFilename;
|
||||||
|
if (storedFile) {
|
||||||
|
const filePath = path.join(this.dataDir, "attachments", storedFile);
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
const fileData = fs.readFileSync(filePath);
|
||||||
|
attachment = fileData.toString("base64");
|
||||||
|
filename = att.filename || storedFile;
|
||||||
|
mimeType = att.contentType || "application/octet-stream";
|
||||||
|
logger.info({ filename, mimeType, size: fileData.length }, "Attachment found");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ err: error, filePath }, "Failed to read attachment file");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageId = `${source}@${dataMessage.timestamp || envelope.timestamp}`;
|
||||||
|
const sentAt = dataMessage.timestamp || envelope.timestamp;
|
||||||
|
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
from: source,
|
||||||
|
to: isGroup ? groupId : account,
|
||||||
|
user_id: sourceUuid,
|
||||||
|
message: messageText || "",
|
||||||
|
message_id: messageId,
|
||||||
|
sent_at: sentAt ? new Date(sentAt).toISOString() : new Date().toISOString(),
|
||||||
|
is_group: isGroup,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (attachment) {
|
||||||
|
payload.attachment = attachment;
|
||||||
|
payload.filename = filename;
|
||||||
|
payload.mime_type = mimeType;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST to Zammad webhook
|
||||||
|
const zammadUrl = process.env.ZAMMAD_URL || "http://zammad-nginx:8080";
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${zammadUrl}/api/v1/channels_cdr_signal_bot_webhook/${botId}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
logger.info({ botId, messageId }, "Message forwarded to Zammad");
|
||||||
|
} else {
|
||||||
|
const errorText = await response.text();
|
||||||
|
logger.error({ status: response.status, error: errorText, botId }, "Failed to send message to Zammad");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ err: error, botId }, "Failed to POST to Zammad webhook");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async postGroupMemberJoined(botId: string, groupId: string, memberPhone: string): Promise<void> {
|
||||||
|
const zammadUrl = process.env.ZAMMAD_URL || "http://zammad-nginx:8080";
|
||||||
|
const payload = {
|
||||||
|
event: "group_member_joined",
|
||||||
|
group_id: groupId,
|
||||||
|
member_phone: memberPhone,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${zammadUrl}/api/v1/channels_cdr_signal_bot_webhook/${botId}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
logger.info({ botId, groupId, memberPhone }, "Group member join notification sent to Zammad");
|
||||||
|
} else {
|
||||||
|
logger.error({ status: response.status, botId, groupId }, "Failed to notify Zammad of group member join");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ err: error, botId }, "Failed to POST group_member_joined to Zammad");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private findBotIdByAccount(account: string): string | undefined {
|
||||||
|
for (const [botId, mapping] of Object.entries(this.botMapping)) {
|
||||||
|
if (mapping.phoneNumber === account) {
|
||||||
|
return botId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateBotId(botId: string): void {
|
||||||
|
if (!botId || !/^[a-zA-Z0-9_-]+$/.test(botId)) {
|
||||||
|
throw new Error(`Invalid bot ID: ${botId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadBotMapping(): void {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(this.mappingFile)) {
|
||||||
|
const data = fs.readFileSync(this.mappingFile, "utf8");
|
||||||
|
this.botMapping = JSON.parse(data);
|
||||||
|
logger.info({ count: Object.keys(this.botMapping).length }, "Loaded bot mapping");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ err: error }, "Failed to load bot mapping, starting fresh");
|
||||||
|
this.botMapping = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private saveBotMapping(): void {
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(this.mappingFile, JSON.stringify(this.botMapping, null, 2));
|
||||||
|
logger.debug("Saved bot mapping");
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ err: error }, "Failed to save bot mapping");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
247
apps/bridge-signal/src/signal-cli.ts
Normal file
247
apps/bridge-signal/src/signal-cli.ts
Normal file
|
|
@ -0,0 +1,247 @@
|
||||||
|
import { ChildProcess, spawn } from "node:child_process";
|
||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
import { createInterface, Interface } from "node:readline";
|
||||||
|
|
||||||
|
import { createLogger } from "@link-stack/logger";
|
||||||
|
|
||||||
|
const logger = createLogger("bridge-signal-cli");
|
||||||
|
|
||||||
|
interface PendingRequest {
|
||||||
|
resolve: (value: unknown) => void;
|
||||||
|
reject: (reason: unknown) => void;
|
||||||
|
method: string;
|
||||||
|
timer: ReturnType<typeof setTimeout>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JsonRpcRequest {
|
||||||
|
jsonrpc: "2.0";
|
||||||
|
method: string;
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JsonRpcResponse {
|
||||||
|
jsonrpc: "2.0";
|
||||||
|
id?: string;
|
||||||
|
result?: unknown;
|
||||||
|
error?: { code: number; message: string; data?: unknown };
|
||||||
|
method?: string;
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignalEnvelope {
|
||||||
|
source?: string;
|
||||||
|
sourceNumber?: string;
|
||||||
|
sourceUuid?: string;
|
||||||
|
sourceName?: string;
|
||||||
|
timestamp?: number;
|
||||||
|
dataMessage?: {
|
||||||
|
timestamp?: number;
|
||||||
|
message?: string;
|
||||||
|
groupInfo?: {
|
||||||
|
groupId?: string;
|
||||||
|
type?: string;
|
||||||
|
};
|
||||||
|
attachments?: Array<{
|
||||||
|
id?: string;
|
||||||
|
contentType?: string;
|
||||||
|
filename?: string;
|
||||||
|
size?: number;
|
||||||
|
storedFilename?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
syncMessage?: {
|
||||||
|
sentMessage?: {
|
||||||
|
destination?: string;
|
||||||
|
destinationNumber?: string;
|
||||||
|
timestamp?: number;
|
||||||
|
message?: string;
|
||||||
|
groupInfo?: { groupId?: string };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
typingMessage?: unknown;
|
||||||
|
receiptMessage?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const REQUEST_TIMEOUT_MS = 60_000;
|
||||||
|
|
||||||
|
// eslint-disable-next-line unicorn/prefer-event-target
|
||||||
|
export class SignalCli extends EventEmitter {
|
||||||
|
private process: ChildProcess | null = null;
|
||||||
|
private readline: Interface | null = null;
|
||||||
|
private pending: Map<string, PendingRequest> = new Map();
|
||||||
|
private nextId = 1;
|
||||||
|
private configDir: string;
|
||||||
|
private closed = false;
|
||||||
|
|
||||||
|
constructor(configDir: string) {
|
||||||
|
super();
|
||||||
|
this.configDir = configDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
const args = ["--config", this.configDir, "--output=json", "jsonRpc"];
|
||||||
|
|
||||||
|
logger.info({ configDir: this.configDir, args }, "Starting signal-cli subprocess");
|
||||||
|
|
||||||
|
this.process = spawn("signal-cli", args, {
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!this.process.stdout || !this.process.stdin) {
|
||||||
|
throw new Error("Failed to open signal-cli stdio pipes");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.readline = createInterface({
|
||||||
|
input: this.process.stdout,
|
||||||
|
crlfDelay: Infinity,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.readline.on("line", (line: string) => {
|
||||||
|
this.handleLine(line);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.process.stderr?.on("data", (data: Buffer) => {
|
||||||
|
const msg = data.toString().trim();
|
||||||
|
if (msg) {
|
||||||
|
logger.warn({ stderr: msg }, "signal-cli stderr");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.process.on("close", (code: number | null) => {
|
||||||
|
this.closed = true;
|
||||||
|
logger.info({ code }, "signal-cli process exited");
|
||||||
|
this.rejectAllPending("signal-cli process exited");
|
||||||
|
this.emit("close", code);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.process.on("error", (err: Error) => {
|
||||||
|
logger.error({ err }, "signal-cli process error");
|
||||||
|
this.emit("error", err);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait briefly for the process to start (or fail immediately)
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
resolve();
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
this.process!.on("error", (err) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.process!.on("close", (code) => {
|
||||||
|
if (code !== null && code !== 0) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(new Error(`signal-cli exited with code ${code}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info("signal-cli subprocess started");
|
||||||
|
}
|
||||||
|
|
||||||
|
async call(method: string, params: Record<string, unknown> = {}): Promise<unknown> {
|
||||||
|
if (this.closed || !this.process?.stdin) {
|
||||||
|
throw new Error("signal-cli is not running");
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = String(this.nextId++);
|
||||||
|
const request: JsonRpcRequest = {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
method,
|
||||||
|
params,
|
||||||
|
id,
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.pending.delete(id);
|
||||||
|
reject(new Error(`signal-cli request timed out: ${method} (id=${id})`));
|
||||||
|
}, REQUEST_TIMEOUT_MS);
|
||||||
|
|
||||||
|
this.pending.set(id, { resolve, reject, method, timer });
|
||||||
|
|
||||||
|
const line = JSON.stringify(request) + "\n";
|
||||||
|
logger.debug({ method, id, params: Object.keys(params) }, "Sending JSON-RPC request");
|
||||||
|
|
||||||
|
this.process!.stdin!.write(line, (err) => {
|
||||||
|
if (err) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
this.pending.delete(id);
|
||||||
|
reject(new Error(`Failed to write to signal-cli stdin: ${err.message}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.closed = true;
|
||||||
|
this.rejectAllPending("signal-cli closing");
|
||||||
|
if (this.readline) {
|
||||||
|
this.readline.close();
|
||||||
|
this.readline = null;
|
||||||
|
}
|
||||||
|
if (this.process) {
|
||||||
|
this.process.kill("SIGTERM");
|
||||||
|
// Force kill after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.process && !this.process.killed) {
|
||||||
|
this.process.kill("SIGKILL");
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
this.process = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleLine(line: string): void {
|
||||||
|
if (!line.trim()) return;
|
||||||
|
|
||||||
|
let msg: JsonRpcResponse;
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(line);
|
||||||
|
} catch {
|
||||||
|
logger.warn({ line: line.slice(0, 200) }, "Non-JSON output from signal-cli");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response to a pending request
|
||||||
|
if (msg.id !== undefined) {
|
||||||
|
const pending = this.pending.get(String(msg.id));
|
||||||
|
if (pending) {
|
||||||
|
this.pending.delete(String(msg.id));
|
||||||
|
clearTimeout(pending.timer);
|
||||||
|
|
||||||
|
if (msg.error) {
|
||||||
|
logger.warn({ method: pending.method, error: msg.error }, "JSON-RPC error response");
|
||||||
|
pending.reject(new Error(`signal-cli ${pending.method}: ${msg.error.message}`));
|
||||||
|
} else {
|
||||||
|
logger.debug({ method: pending.method, id: msg.id }, "JSON-RPC response received");
|
||||||
|
pending.resolve(msg.result);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.warn({ id: msg.id }, "Received response for unknown request id");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notification (no id field)
|
||||||
|
if (msg.method === "receive") {
|
||||||
|
const envelope = msg.params?.envelope as SignalEnvelope | undefined;
|
||||||
|
const account = msg.params?.account as string | undefined;
|
||||||
|
if (envelope) {
|
||||||
|
logger.debug({ account, source: envelope.source || envelope.sourceNumber }, "Received message notification");
|
||||||
|
this.emit("message", { account, envelope });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private rejectAllPending(reason: string): void {
|
||||||
|
for (const [_id, pending] of this.pending) {
|
||||||
|
clearTimeout(pending.timer);
|
||||||
|
pending.reject(new Error(reason));
|
||||||
|
}
|
||||||
|
this.pending.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/bridge-signal/tsconfig.json
Normal file
9
apps/bridge-signal/tsconfig.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"extends": "@link-stack/typescript-config/tsconfig.node.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "build/main",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/.*.ts"],
|
||||||
|
"exclude": ["node_modules/**"]
|
||||||
|
}
|
||||||
3
apps/bridge-whatsapp/eslint.config.mjs
Normal file
3
apps/bridge-whatsapp/eslint.config.mjs
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
import config from "@link-stack/eslint-config/node";
|
||||||
|
|
||||||
|
export default config;
|
||||||
|
|
@ -4,25 +4,33 @@
|
||||||
"main": "build/main/index.js",
|
"main": "build/main/index.js",
|
||||||
"author": "Darren Clarke <darren@redaranj.com>",
|
"author": "Darren Clarke <darren@redaranj.com>",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
|
"prettier": "@link-stack/prettier-config",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@adiwajshing/keyed-db": "0.2.4",
|
"@adiwajshing/keyed-db": "0.2.4",
|
||||||
"@hono/node-server": "^1.13.8",
|
"@hono/node-server": "^1.13.8",
|
||||||
"@whiskeysockets/baileys": "6.7.21",
|
"@whiskeysockets/baileys": "6.7.21",
|
||||||
"hono": "^4.7.4",
|
"hono": "^4.7.4",
|
||||||
"link-preview-js": "^3.1.0",
|
"link-preview-js": "^3.1.0",
|
||||||
"pino": "^9.6.0",
|
"@link-stack/logger": "workspace:*"
|
||||||
"pino-pretty": "^13.0.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@link-stack/eslint-config": "workspace:*",
|
||||||
|
"@link-stack/prettier-config": "workspace:*",
|
||||||
|
"@link-stack/typescript-config": "workspace:*",
|
||||||
"@types/long": "^5",
|
"@types/long": "^5",
|
||||||
"@types/node": "*",
|
"@types/node": "*",
|
||||||
"dotenv-cli": "^10.0.0",
|
"dotenv-cli": "^10.0.0",
|
||||||
|
"eslint": "^9.23.0",
|
||||||
|
"prettier": "^3.5.3",
|
||||||
"tsx": "^4.20.6",
|
"tsx": "^4.20.6",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
"dev": "dotenv -- tsx src/index.ts",
|
"dev": "dotenv -- tsx src/index.ts",
|
||||||
"start": "node build/main/index.js"
|
"start": "node build/main/index.js",
|
||||||
|
"lint": "eslint src/",
|
||||||
|
"format": "prettier --write src/",
|
||||||
|
"format:check": "prettier --check src/"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,10 @@
|
||||||
*/
|
*/
|
||||||
export function getMaxAttachmentSize(): number {
|
export function getMaxAttachmentSize(): number {
|
||||||
const envValue = process.env.BRIDGE_MAX_ATTACHMENT_SIZE_MB;
|
const envValue = process.env.BRIDGE_MAX_ATTACHMENT_SIZE_MB;
|
||||||
const sizeInMB = envValue ? parseInt(envValue, 10) : 50;
|
const sizeInMB = envValue ? Number.parseInt(envValue, 10) : 50;
|
||||||
|
|
||||||
// Validate the value
|
// Validate the value
|
||||||
if (isNaN(sizeInMB) || sizeInMB <= 0) {
|
if (Number.isNaN(sizeInMB) || sizeInMB <= 0) {
|
||||||
console.warn(`Invalid BRIDGE_MAX_ATTACHMENT_SIZE_MB value: ${envValue}, using default 50MB`);
|
console.warn(`Invalid BRIDGE_MAX_ATTACHMENT_SIZE_MB value: ${envValue}, using default 50MB`);
|
||||||
return 50 * 1024 * 1024;
|
return 50 * 1024 * 1024;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { serve } from "@hono/node-server";
|
import { serve } from "@hono/node-server";
|
||||||
import WhatsappService from "./service.ts";
|
import { createLogger } from "@link-stack/logger";
|
||||||
|
|
||||||
import { createRoutes } from "./routes.ts";
|
import { createRoutes } from "./routes.ts";
|
||||||
import { createLogger } from "./lib/logger";
|
import WhatsappService from "./service.ts";
|
||||||
|
|
||||||
const logger = createLogger("bridge-whatsapp-index");
|
const logger = createLogger("bridge-whatsapp-index");
|
||||||
|
|
||||||
|
|
@ -10,7 +11,7 @@ const main = async () => {
|
||||||
await service.initialize();
|
await service.initialize();
|
||||||
|
|
||||||
const app = createRoutes(service);
|
const app = createRoutes(service);
|
||||||
const port = parseInt(process.env.PORT || "5000", 10);
|
const port = Number.parseInt(process.env.PORT || "5000", 10);
|
||||||
|
|
||||||
serve({ fetch: app.fetch, port }, (info) => {
|
serve({ fetch: app.fetch, port }, (info) => {
|
||||||
logger.info({ port: info.port }, "bridge-whatsapp listening");
|
logger.info({ port: info.port }, "bridge-whatsapp listening");
|
||||||
|
|
@ -26,7 +27,7 @@ const main = async () => {
|
||||||
process.on("SIGINT", shutdown);
|
process.on("SIGINT", shutdown);
|
||||||
};
|
};
|
||||||
|
|
||||||
main().catch((err) => {
|
main().catch((error) => {
|
||||||
logger.error(err);
|
logger.error(error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
import pino, { Logger as PinoLogger, LoggerOptions } from 'pino';
|
|
||||||
|
|
||||||
export type Logger = PinoLogger;
|
|
||||||
|
|
||||||
const getLogLevel = (): string => {
|
|
||||||
return process.env.LOG_LEVEL || (process.env.NODE_ENV === 'production' ? 'info' : 'debug');
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPinoConfig = (): LoggerOptions => {
|
|
||||||
const isDevelopment = process.env.NODE_ENV !== 'production';
|
|
||||||
|
|
||||||
const baseConfig: LoggerOptions = {
|
|
||||||
level: getLogLevel(),
|
|
||||||
formatters: {
|
|
||||||
level: (label) => {
|
|
||||||
return { level: label.toUpperCase() };
|
|
||||||
},
|
|
||||||
},
|
|
||||||
timestamp: () => `,"timestamp":"${new Date(Date.now()).toISOString()}"`,
|
|
||||||
redact: {
|
|
||||||
paths: [
|
|
||||||
'password',
|
|
||||||
'token',
|
|
||||||
'secret',
|
|
||||||
'api_key',
|
|
||||||
'apiKey',
|
|
||||||
'authorization',
|
|
||||||
'cookie',
|
|
||||||
'access_token',
|
|
||||||
'refresh_token',
|
|
||||||
'*.password',
|
|
||||||
'*.token',
|
|
||||||
'*.secret',
|
|
||||||
'*.api_key',
|
|
||||||
'*.apiKey',
|
|
||||||
'*.authorization',
|
|
||||||
'*.cookie',
|
|
||||||
'*.access_token',
|
|
||||||
'*.refresh_token',
|
|
||||||
'headers.authorization',
|
|
||||||
'headers.cookie',
|
|
||||||
'headers.Authorization',
|
|
||||||
'headers.Cookie',
|
|
||||||
'credentials.password',
|
|
||||||
'credentials.secret',
|
|
||||||
'credentials.token',
|
|
||||||
],
|
|
||||||
censor: '[REDACTED]',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isDevelopment) {
|
|
||||||
return {
|
|
||||||
...baseConfig,
|
|
||||||
transport: {
|
|
||||||
target: 'pino-pretty',
|
|
||||||
options: {
|
|
||||||
colorize: true,
|
|
||||||
translateTime: 'SYS:standard',
|
|
||||||
ignore: 'pid,hostname',
|
|
||||||
singleLine: false,
|
|
||||||
messageFormat: '{msg}',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return baseConfig;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const logger: Logger = pino(getPinoConfig());
|
|
||||||
|
|
||||||
export const createLogger = (name: string, context?: Record<string, any>): Logger => {
|
|
||||||
return logger.child({ name, ...context });
|
|
||||||
};
|
|
||||||
|
|
||||||
export default logger;
|
|
||||||
|
|
@ -1,12 +1,36 @@
|
||||||
|
import { createLogger } from "@link-stack/logger";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
|
|
||||||
import type WhatsappService from "./service.ts";
|
import type WhatsappService from "./service.ts";
|
||||||
import { createLogger } from "./lib/logger";
|
|
||||||
|
|
||||||
const logger = createLogger("bridge-whatsapp-routes");
|
const logger = createLogger("bridge-whatsapp-routes");
|
||||||
|
|
||||||
|
const errorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
||||||
|
|
||||||
export function createRoutes(service: WhatsappService): Hono {
|
export function createRoutes(service: WhatsappService): Hono {
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
|
app.post("/api/bots/:id/register", async (c) => {
|
||||||
|
const id = c.req.param("id");
|
||||||
|
try {
|
||||||
|
await service.register(id);
|
||||||
|
logger.info({ id }, "Bot registered");
|
||||||
|
return c.body(null, 200);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ id, error: errorMessage(error) }, "Failed to register bot");
|
||||||
|
return c.json({ error: errorMessage(error) }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/bots/:id", async (c) => {
|
||||||
|
const id = c.req.param("id");
|
||||||
|
try {
|
||||||
|
return c.json(service.getBot(id));
|
||||||
|
} catch (error) {
|
||||||
|
return c.json({ error: errorMessage(error) }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post("/api/bots/:id/send", async (c) => {
|
app.post("/api/bots/:id/send", async (c) => {
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const { phoneNumber, message, attachments } = await c.req.json<{
|
const { phoneNumber, message, attachments } = await c.req.json<{
|
||||||
|
|
@ -15,43 +39,30 @@ export function createRoutes(service: WhatsappService): Hono {
|
||||||
attachments?: Array<{ data: string; filename: string; mime_type: string }>;
|
attachments?: Array<{ data: string; filename: string; mime_type: string }>;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
await service.send(id, phoneNumber, message, attachments);
|
try {
|
||||||
logger.info({ id, attachmentCount: attachments?.length || 0 }, "Sent message");
|
const result = await service.send(id, phoneNumber, message, attachments);
|
||||||
|
logger.info({ id, attachmentCount: attachments?.length || 0 }, "Sent message");
|
||||||
return c.json({
|
return c.json({ result });
|
||||||
result: {
|
} catch (error) {
|
||||||
recipient: phoneNumber,
|
logger.error({ id, error: errorMessage(error) }, "Failed to send message");
|
||||||
timestamp: new Date().toISOString(),
|
return c.json({ error: errorMessage(error) }, 500);
|
||||||
source: id,
|
}
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get("/api/bots/:id/receive", async (c) => {
|
|
||||||
const id = c.req.param("id");
|
|
||||||
const date = new Date();
|
|
||||||
const twoDaysAgo = new Date(date.getTime());
|
|
||||||
twoDaysAgo.setDate(date.getDate() - 2);
|
|
||||||
|
|
||||||
const messages = await service.receive(id, twoDaysAgo);
|
|
||||||
return c.json(messages);
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post("/api/bots/:id/register", async (c) => {
|
|
||||||
const id = c.req.param("id");
|
|
||||||
await service.register(id);
|
|
||||||
return c.body(null, 200);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/bots/:id/unverify", async (c) => {
|
app.post("/api/bots/:id/unverify", async (c) => {
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
await service.unverify(id);
|
try {
|
||||||
return c.body(null, 200);
|
await service.unverify(id);
|
||||||
|
logger.info({ id }, "Bot unverified");
|
||||||
|
return c.body(null, 200);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ id, error: errorMessage(error) }, "Failed to unverify bot");
|
||||||
|
return c.json({ error: errorMessage(error) }, 500);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/api/bots/:id", async (c) => {
|
app.get("/api/health", (c) => {
|
||||||
const id = c.req.param("id");
|
return c.json({ status: "ok" });
|
||||||
return c.json(service.getBot(id));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,9 @@
|
||||||
|
import fs from "node:fs";
|
||||||
|
|
||||||
|
import { createLogger } from "@link-stack/logger";
|
||||||
import makeWASocket, {
|
import makeWASocket, {
|
||||||
|
type WASocket,
|
||||||
|
type SocketConfig,
|
||||||
DisconnectReason,
|
DisconnectReason,
|
||||||
proto,
|
proto,
|
||||||
downloadContentFromMessage,
|
downloadContentFromMessage,
|
||||||
|
|
@ -8,22 +13,21 @@ import makeWASocket, {
|
||||||
useMultiFileAuthState,
|
useMultiFileAuthState,
|
||||||
} from "@whiskeysockets/baileys";
|
} from "@whiskeysockets/baileys";
|
||||||
|
|
||||||
|
import { getMaxAttachmentSize, getMaxTotalAttachmentSize, MAX_ATTACHMENTS } from "./attachments";
|
||||||
|
|
||||||
type MediaType = "audio" | "document" | "image" | "video" | "sticker";
|
type MediaType = "audio" | "document" | "image" | "video" | "sticker";
|
||||||
import fs from "fs";
|
|
||||||
import { createLogger } from "./lib/logger";
|
|
||||||
import {
|
|
||||||
getMaxAttachmentSize,
|
|
||||||
getMaxTotalAttachmentSize,
|
|
||||||
MAX_ATTACHMENTS,
|
|
||||||
} from "./attachments";
|
|
||||||
|
|
||||||
const logger = createLogger("bridge-whatsapp-service");
|
const logger = createLogger("bridge-whatsapp-service");
|
||||||
|
|
||||||
export type AuthCompleteCallback = (error?: string) => void;
|
export type AuthCompleteCallback = (error?: string) => void;
|
||||||
|
|
||||||
|
interface BotConnection {
|
||||||
|
socket: WASocket;
|
||||||
|
}
|
||||||
|
|
||||||
export default class WhatsappService {
|
export default class WhatsappService {
|
||||||
connections: { [key: string]: any } = {};
|
connections: Record<string, BotConnection> = {};
|
||||||
loginConnections: { [key: string]: any } = {};
|
loginConnections: Record<string, BotConnection> = {};
|
||||||
|
|
||||||
static browserDescription: [string, string, string] = ["Bridge", "Chrome", "2.0"];
|
static browserDescription: [string, string, string] = ["Bridge", "Chrome", "2.0"];
|
||||||
|
|
||||||
|
|
@ -71,7 +75,7 @@ export default class WhatsappService {
|
||||||
private async resetConnections() {
|
private async resetConnections() {
|
||||||
for (const connection of Object.values(this.connections)) {
|
for (const connection of Object.values(this.connections)) {
|
||||||
try {
|
try {
|
||||||
connection.end(null);
|
connection.socket.end(new Error("Connection reset"));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error }, "Connection reset error");
|
logger.error({ error }, "Connection reset error");
|
||||||
}
|
}
|
||||||
|
|
@ -81,18 +85,16 @@ export default class WhatsappService {
|
||||||
|
|
||||||
private async createConnection(
|
private async createConnection(
|
||||||
botID: string,
|
botID: string,
|
||||||
options: any,
|
options: Partial<SocketConfig>,
|
||||||
authCompleteCallback?: any,
|
authCompleteCallback?: AuthCompleteCallback
|
||||||
) {
|
) {
|
||||||
const authDirectory = this.getAuthDirectory(botID);
|
const authDirectory = this.getAuthDirectory(botID);
|
||||||
const { state, saveCreds } = await useMultiFileAuthState(authDirectory);
|
const { state, saveCreds } = await useMultiFileAuthState(authDirectory);
|
||||||
const msgRetryCounterMap: any = {};
|
|
||||||
const socket = makeWASocket({
|
const socket = makeWASocket({
|
||||||
...options,
|
...options,
|
||||||
auth: state,
|
auth: state,
|
||||||
generateHighQualityLinkPreview: false,
|
generateHighQualityLinkPreview: false,
|
||||||
syncFullHistory: true,
|
syncFullHistory: true,
|
||||||
msgRetryCounterMap,
|
|
||||||
shouldIgnoreJid: (jid) => isJidBroadcast(jid) || isJidStatusBroadcast(jid),
|
shouldIgnoreJid: (jid) => isJidBroadcast(jid) || isJidStatusBroadcast(jid),
|
||||||
});
|
});
|
||||||
let pause = 5000;
|
let pause = 5000;
|
||||||
|
|
@ -115,7 +117,8 @@ export default class WhatsappService {
|
||||||
logger.info("opened connection");
|
logger.info("opened connection");
|
||||||
} else if (connectionState === "close") {
|
} else if (connectionState === "close") {
|
||||||
logger.info({ lastDisconnect }, "connection closed");
|
logger.info({ lastDisconnect }, "connection closed");
|
||||||
const disconnectStatusCode = (lastDisconnect?.error as any)?.output?.statusCode;
|
const disconnectStatusCode = (lastDisconnect?.error as { output?: { statusCode?: number } } | undefined)
|
||||||
|
?.output?.statusCode;
|
||||||
if (disconnectStatusCode === DisconnectReason.restartRequired) {
|
if (disconnectStatusCode === DisconnectReason.restartRequired) {
|
||||||
logger.info("reconnecting after got new login");
|
logger.info("reconnecting after got new login");
|
||||||
await this.createConnection(botID, options);
|
await this.createConnection(botID, options);
|
||||||
|
|
@ -145,17 +148,14 @@ export default class WhatsappService {
|
||||||
|
|
||||||
if (events["messaging-history.set"]) {
|
if (events["messaging-history.set"]) {
|
||||||
const { messages, isLatest } = events["messaging-history.set"];
|
const { messages, isLatest } = events["messaging-history.set"];
|
||||||
logger.info(
|
logger.info({ messageCount: messages.length, isLatest }, "received message history on connection");
|
||||||
{ messageCount: messages.length, isLatest },
|
|
||||||
"received message history on connection",
|
|
||||||
);
|
|
||||||
if (messages.length > 0) {
|
if (messages.length > 0) {
|
||||||
await this.queueUnreadMessages(botID, messages);
|
await this.queueUnreadMessages(botID, messages);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.connections[botID] = { socket, msgRetryCounterMap };
|
this.connections[botID] = { socket };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async updateConnections() {
|
private async updateConnections() {
|
||||||
|
|
@ -188,17 +188,13 @@ export default class WhatsappService {
|
||||||
const { id, fromMe, remoteJid } = key;
|
const { id, fromMe, remoteJid } = key;
|
||||||
// Baileys 7 uses LIDs (Linked IDs) instead of phone numbers in some cases.
|
// Baileys 7 uses LIDs (Linked IDs) instead of phone numbers in some cases.
|
||||||
// senderPn contains the actual phone number when available.
|
// senderPn contains the actual phone number when available.
|
||||||
const senderPn = (key as any).senderPn as string | undefined;
|
const senderPn = (key as { senderPn?: string }).senderPn;
|
||||||
const participantPn = (key as any).participantPn as string | undefined;
|
const participantPn = (key as { participantPn?: string }).participantPn;
|
||||||
logger.info(
|
logger.info({ remoteJid, senderPn, participantPn, fromMe }, "Processing incoming message");
|
||||||
{ remoteJid, senderPn, participantPn, fromMe },
|
|
||||||
"Processing incoming message",
|
|
||||||
);
|
|
||||||
const isValidMessage = message && remoteJid !== "status@broadcast" && !fromMe;
|
const isValidMessage = message && remoteJid !== "status@broadcast" && !fromMe;
|
||||||
if (isValidMessage) {
|
if (isValidMessage) {
|
||||||
const { audioMessage, documentMessage, imageMessage, videoMessage } = message;
|
const { audioMessage, documentMessage, imageMessage, videoMessage } = message;
|
||||||
const isMediaMessage =
|
const isMediaMessage = audioMessage || documentMessage || imageMessage || videoMessage;
|
||||||
audioMessage || documentMessage || imageMessage || videoMessage;
|
|
||||||
|
|
||||||
const messageContent = Object.values(message)[0];
|
const messageContent = Object.values(message)[0];
|
||||||
let messageType: MediaType;
|
let messageType: MediaType;
|
||||||
|
|
@ -229,8 +225,8 @@ export default class WhatsappService {
|
||||||
|
|
||||||
const stream = await downloadContentFromMessage(
|
const stream = await downloadContentFromMessage(
|
||||||
messageContent,
|
messageContent,
|
||||||
// @ts-ignore
|
// @ts-expect-error messageType is dynamically resolved
|
||||||
messageType,
|
messageType
|
||||||
);
|
);
|
||||||
let buffer = Buffer.from([]);
|
let buffer = Buffer.from([]);
|
||||||
for await (const chunk of stream) {
|
for await (const chunk of stream) {
|
||||||
|
|
@ -244,12 +240,9 @@ export default class WhatsappService {
|
||||||
const extendedTextMessage = message?.extendedTextMessage?.text;
|
const extendedTextMessage = message?.extendedTextMessage?.text;
|
||||||
const imageMessage = message?.imageMessage?.caption;
|
const imageMessage = message?.imageMessage?.caption;
|
||||||
const videoMessage = message?.videoMessage?.caption;
|
const videoMessage = message?.videoMessage?.caption;
|
||||||
const messageText = [
|
const messageText = [conversation, extendedTextMessage, imageMessage, videoMessage].find(
|
||||||
conversation,
|
(text) => text && text !== ""
|
||||||
extendedTextMessage,
|
);
|
||||||
imageMessage,
|
|
||||||
videoMessage,
|
|
||||||
].find((text) => text && text !== "");
|
|
||||||
|
|
||||||
// Extract phone number and user ID (LID) separately
|
// Extract phone number and user ID (LID) separately
|
||||||
// remoteJid may contain LIDs (Baileys 7+) which are not phone numbers
|
// remoteJid may contain LIDs (Baileys 7+) which are not phone numbers
|
||||||
|
|
@ -257,7 +250,8 @@ export default class WhatsappService {
|
||||||
const isLidJid = remoteJid?.endsWith("@lid");
|
const isLidJid = remoteJid?.endsWith("@lid");
|
||||||
|
|
||||||
// Phone number: prefer senderPn/participantPn, fall back to remoteJid only if it's not a LID
|
// Phone number: prefer senderPn/participantPn, fall back to remoteJid only if it's not a LID
|
||||||
const senderPhone = senderPn?.split("@")[0] || participantPn?.split("@")[0] || (!isLidJid ? jidValue : undefined);
|
const senderPhone =
|
||||||
|
senderPn?.split("@")[0] || participantPn?.split("@")[0] || (isLidJid ? undefined : jidValue);
|
||||||
|
|
||||||
// User ID (LID): extract from remoteJid if it's a LID format
|
// User ID (LID): extract from remoteJid if it's a LID format
|
||||||
const senderUserId = isLidJid ? jidValue : undefined;
|
const senderUserId = isLidJid ? jidValue : undefined;
|
||||||
|
|
@ -271,27 +265,24 @@ export default class WhatsappService {
|
||||||
const payload = {
|
const payload = {
|
||||||
to: botID,
|
to: botID,
|
||||||
from: senderPhone,
|
from: senderPhone,
|
||||||
userId: senderUserId,
|
user_id: senderUserId,
|
||||||
messageId: id,
|
message_id: id,
|
||||||
sentAt: new Date((messageTimestamp as number) * 1000).toISOString(),
|
sent_at: new Date((messageTimestamp as number) * 1000).toISOString(),
|
||||||
message: messageText,
|
message: messageText,
|
||||||
attachment,
|
attachment,
|
||||||
filename,
|
filename,
|
||||||
mimeType,
|
mime_type: mimeType,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Send directly to Zammad's WhatsApp webhook
|
// Send directly to Zammad's WhatsApp webhook
|
||||||
const zammadUrl = process.env.ZAMMAD_URL || 'http://zammad-nginx:8080';
|
const zammadUrl = process.env.ZAMMAD_URL || "http://zammad-nginx:8080";
|
||||||
const response = await fetch(
|
const response = await fetch(`${zammadUrl}/api/v1/channels_cdr_whatsapp_bot_webhook/${botID}`, {
|
||||||
`${zammadUrl}/api/v1/channels_cdr_whatsapp_bot_webhook/${botID}`,
|
method: "POST",
|
||||||
{
|
headers: {
|
||||||
method: "POST",
|
"Content-Type": "application/json",
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
},
|
},
|
||||||
);
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
|
|
@ -307,7 +298,7 @@ export default class WhatsappService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getBot(botID: string): Record<string, any> {
|
getBot(botID: string): Record<string, unknown> {
|
||||||
const botDirectory = this.getBotDirectory(botID);
|
const botDirectory = this.getBotDirectory(botID);
|
||||||
const qrPath = `${botDirectory}/qr.txt`;
|
const qrPath = `${botDirectory}/qr.txt`;
|
||||||
const verifiedFile = `${botDirectory}/verified`;
|
const verifiedFile = `${botDirectory}/verified`;
|
||||||
|
|
@ -327,7 +318,7 @@ export default class WhatsappService {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn({ botID, error }, "Error during logout, forcing disconnect");
|
logger.warn({ botID, error }, "Error during logout, forcing disconnect");
|
||||||
try {
|
try {
|
||||||
connection.socket.end(undefined);
|
connection.socket.end(new Error("Forced disconnect"));
|
||||||
} catch (endError) {
|
} catch (endError) {
|
||||||
logger.warn({ botID, endError }, "Error ending socket connection");
|
logger.warn({ botID, endError }, "Error ending socket connection");
|
||||||
}
|
}
|
||||||
|
|
@ -346,11 +337,7 @@ export default class WhatsappService {
|
||||||
|
|
||||||
async register(botID: string, callback?: AuthCompleteCallback): Promise<void> {
|
async register(botID: string, callback?: AuthCompleteCallback): Promise<void> {
|
||||||
const { version } = await fetchLatestBaileysVersion();
|
const { version } = await fetchLatestBaileysVersion();
|
||||||
await this.createConnection(
|
await this.createConnection(botID, { version, browser: WhatsappService.browserDescription }, callback);
|
||||||
botID,
|
|
||||||
{ version, browser: WhatsappService.browserDescription },
|
|
||||||
callback,
|
|
||||||
);
|
|
||||||
callback?.();
|
callback?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -358,10 +345,10 @@ export default class WhatsappService {
|
||||||
botID: string,
|
botID: string,
|
||||||
phoneNumber: string,
|
phoneNumber: string,
|
||||||
message: string,
|
message: string,
|
||||||
attachments?: Array<{ data: string; filename: string; mime_type: string }>,
|
attachments?: Array<{ data: string; filename: string; mime_type: string }>
|
||||||
): Promise<void> {
|
): Promise<{ recipient: string; timestamp: string; source: string }> {
|
||||||
const connection = this.connections[botID]?.socket;
|
const connection = this.connections[botID]?.socket;
|
||||||
const digits = phoneNumber.replace(/\D+/g, "");
|
const digits = phoneNumber.replaceAll(/\D+/g, "");
|
||||||
// LIDs are 15+ digits, phone numbers with country code are typically 10-14 digits
|
// LIDs are 15+ digits, phone numbers with country code are typically 10-14 digits
|
||||||
const suffix = digits.length > 14 ? "@lid" : "@s.whatsapp.net";
|
const suffix = digits.length > 14 ? "@lid" : "@s.whatsapp.net";
|
||||||
const recipient = `${digits}${suffix}`;
|
const recipient = `${digits}${suffix}`;
|
||||||
|
|
@ -377,9 +364,7 @@ export default class WhatsappService {
|
||||||
const MAX_TOTAL_SIZE = getMaxTotalAttachmentSize();
|
const MAX_TOTAL_SIZE = getMaxTotalAttachmentSize();
|
||||||
|
|
||||||
if (attachments.length > MAX_ATTACHMENTS) {
|
if (attachments.length > MAX_ATTACHMENTS) {
|
||||||
throw new Error(
|
throw new Error(`Too many attachments: ${attachments.length} (max ${MAX_ATTACHMENTS})`);
|
||||||
`Too many attachments: ${attachments.length} (max ${MAX_ATTACHMENTS})`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let totalSize = 0;
|
let totalSize = 0;
|
||||||
|
|
@ -395,7 +380,7 @@ export default class WhatsappService {
|
||||||
size: estimatedSize,
|
size: estimatedSize,
|
||||||
maxSize: MAX_ATTACHMENT_SIZE,
|
maxSize: MAX_ATTACHMENT_SIZE,
|
||||||
},
|
},
|
||||||
"Attachment exceeds size limit, skipping",
|
"Attachment exceeds size limit, skipping"
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -407,7 +392,7 @@ export default class WhatsappService {
|
||||||
totalSize,
|
totalSize,
|
||||||
maxTotalSize: MAX_TOTAL_SIZE,
|
maxTotalSize: MAX_TOTAL_SIZE,
|
||||||
},
|
},
|
||||||
"Total attachment size exceeds limit, skipping remaining",
|
"Total attachment size exceeds limit, skipping remaining"
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -438,16 +423,11 @@ export default class WhatsappService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
async receive(
|
return {
|
||||||
_botID: string,
|
recipient: phoneNumber,
|
||||||
_lastReceivedDate: Date,
|
timestamp: new Date().toISOString(),
|
||||||
): Promise<proto.IWebMessageInfo[]> {
|
source: botID,
|
||||||
throw new Error(
|
};
|
||||||
"Message polling is no longer supported in Baileys 7.x. " +
|
|
||||||
"Please configure a webhook to receive messages instead. " +
|
|
||||||
"Messages are automatically forwarded to Zammad via ZAMMAD_URL/api/v1/channels_cdr_whatsapp_bot_webhook/{id}"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,8 @@
|
||||||
{
|
{
|
||||||
|
"extends": "@link-stack/typescript-config/tsconfig.node.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "es2022",
|
|
||||||
"module": "NodeNext",
|
|
||||||
"moduleResolution": "NodeNext",
|
|
||||||
"outDir": "build/main",
|
"outDir": "build/main",
|
||||||
"rootDir": "src",
|
"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"],
|
"include": ["src/**/*.ts", "src/**/.*.ts"],
|
||||||
"exclude": ["node_modules/**"]
|
"exclude": ["node_modules/**"]
|
||||||
|
|
|
||||||
22
docker/compose/bridge-signal.yml
Normal file
22
docker/compose/bridge-signal.yml
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
services:
|
||||||
|
bridge-signal:
|
||||||
|
container_name: bridge-signal
|
||||||
|
build:
|
||||||
|
context: ../../
|
||||||
|
dockerfile: ./apps/bridge-signal/Dockerfile
|
||||||
|
image: registry.gitlab.com/digiresilience/link/link-stack/bridge-signal:${LINK_STACK_VERSION}
|
||||||
|
restart: ${RESTART}
|
||||||
|
environment:
|
||||||
|
PORT: 5002
|
||||||
|
NODE_ENV: production
|
||||||
|
ZAMMAD_URL: http://zammad-nginx:8080
|
||||||
|
SIGNAL_DATA_DIR: /home/node/signal-data
|
||||||
|
BRIDGE_SIGNAL_AUTO_GROUPS: ${BRIDGE_SIGNAL_AUTO_GROUPS:-false}
|
||||||
|
volumes:
|
||||||
|
- bridge-signal-data:/home/node/signal-data
|
||||||
|
ports:
|
||||||
|
- 5002:5002
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
bridge-signal-data:
|
||||||
|
driver: local
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
services:
|
|
||||||
signal-cli-rest-api:
|
|
||||||
container_name: signal-cli-rest-api
|
|
||||||
build: ../signal-cli-rest-api
|
|
||||||
image: registry.gitlab.com/digiresilience/link/link-stack/signal-cli-rest-api:develop
|
|
||||||
environment:
|
|
||||||
- MODE=normal
|
|
||||||
volumes:
|
|
||||||
- signal-cli-rest-api-data:/home/.local/share/signal-cli
|
|
||||||
ports:
|
|
||||||
- 8080:8080
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
signal-cli-rest-api-data:
|
|
||||||
driver: local
|
|
||||||
|
|
@ -15,7 +15,7 @@ x-zammad-vars: &common-zammad-variables
|
||||||
ELASTICSEARCH_SSL_VERIFY: "false" # this doesn't set es_ssl_verify as expected, but ideally it would
|
ELASTICSEARCH_SSL_VERIFY: "false" # this doesn't set es_ssl_verify as expected, but ideally it would
|
||||||
ELASTICSEARCH_SCHEMA: "https"
|
ELASTICSEARCH_SCHEMA: "https"
|
||||||
BRIDGE_SIGNAL_AUTO_GROUPS: ${BRIDGE_SIGNAL_AUTO_GROUPS}
|
BRIDGE_SIGNAL_AUTO_GROUPS: ${BRIDGE_SIGNAL_AUTO_GROUPS}
|
||||||
SIGNAL_CLI_URL: "http://signal-cli-rest-api:8080"
|
BRIDGE_SIGNAL_URL: "http://bridge-signal:5002"
|
||||||
BRIDGE_WHATSAPP_URL: "http://bridge-whatsapp:5000"
|
BRIDGE_WHATSAPP_URL: "http://bridge-whatsapp:5000"
|
||||||
FORMSTACK_FIELD_MAPPING: ${FORMSTACK_FIELD_MAPPING}
|
FORMSTACK_FIELD_MAPPING: ${FORMSTACK_FIELD_MAPPING}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,11 @@ 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", "bridge-deltachat"],
|
all: ["zammad", "postgresql", "opensearch", "bridge-signal", "bridge-whatsapp", "bridge-deltachat"],
|
||||||
dev: ["zammad", "postgresql", "opensearch", "signal-cli-rest-api"],
|
dev: ["zammad", "postgresql", "opensearch", "bridge-signal"],
|
||||||
opensearch: ["opensearch"],
|
opensearch: ["opensearch"],
|
||||||
zammad: ["zammad", "postgresql", "opensearch", "signal-cli-rest-api"],
|
zammad: ["zammad", "postgresql", "opensearch", "bridge-signal"],
|
||||||
|
signal: ["bridge-signal"],
|
||||||
whatsapp: ["bridge-whatsapp"],
|
whatsapp: ["bridge-whatsapp"],
|
||||||
deltachat: ["bridge-deltachat"],
|
deltachat: ["bridge-deltachat"],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
FROM bbernhard/signal-cli-rest-api:0.95
|
|
||||||
|
|
@ -5,6 +5,9 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm --filter @link-stack/bridge-whatsapp run dev",
|
"dev": "pnpm --filter @link-stack/bridge-whatsapp run dev",
|
||||||
"build": "turbo build",
|
"build": "turbo build",
|
||||||
|
"lint": "turbo lint",
|
||||||
|
"format": "turbo format",
|
||||||
|
"format:check": "turbo format:check",
|
||||||
"update-version": "node --experimental-strip-types scripts/update-version.ts",
|
"update-version": "node --experimental-strip-types scripts/update-version.ts",
|
||||||
"clean": "rm -f pnpm-lock.yaml && rm -rf node_modules && rm -rf .turbo && rm -rf apps/*/node_modules && rm -rf packages/*/node_modules && rm -rf packages/*/.turbo && rm -rf packages/*/build && rm -rf docker/zammad/addons/*",
|
"clean": "rm -f pnpm-lock.yaml && rm -rf node_modules && rm -rf .turbo && rm -rf apps/*/node_modules && rm -rf packages/*/node_modules && rm -rf packages/*/.turbo && rm -rf packages/*/build && rm -rf docker/zammad/addons/*",
|
||||||
"docker:all:up": "node docker/scripts/docker.js all up",
|
"docker:all:up": "node docker/scripts/docker.js all up",
|
||||||
|
|
@ -18,6 +21,8 @@
|
||||||
"docker:zammad:up": "node docker/scripts/docker.js zammad up",
|
"docker:zammad:up": "node docker/scripts/docker.js zammad up",
|
||||||
"docker:zammad:down": "node docker/scripts/docker.js zammad down",
|
"docker:zammad:down": "node docker/scripts/docker.js zammad down",
|
||||||
"docker:zammad:build": "node docker/scripts/docker.js zammad build",
|
"docker:zammad:build": "node docker/scripts/docker.js zammad build",
|
||||||
|
"docker:signal:up": "node docker/scripts/docker.js signal up",
|
||||||
|
"docker:signal:down": "node docker/scripts/docker.js signal down",
|
||||||
"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:up": "node docker/scripts/docker.js deltachat up",
|
||||||
|
|
|
||||||
52
packages/eslint-config/node.js
Normal file
52
packages/eslint-config/node.js
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import tseslint from "@typescript-eslint/eslint-plugin";
|
||||||
|
import tsparser from "@typescript-eslint/parser";
|
||||||
|
import prettier from "eslint-config-prettier";
|
||||||
|
import unicorn from "eslint-plugin-unicorn";
|
||||||
|
import importX from "eslint-plugin-import-x";
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
files: ["**/*.ts"],
|
||||||
|
languageOptions: {
|
||||||
|
parser: tsparser,
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 2022,
|
||||||
|
sourceType: "module",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
"@typescript-eslint": tseslint,
|
||||||
|
unicorn,
|
||||||
|
"import-x": importX,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...tseslint.configs.recommended.rules,
|
||||||
|
...unicorn.configs.recommended.rules,
|
||||||
|
|
||||||
|
// Relax unicorn rules
|
||||||
|
"unicorn/no-null": "off",
|
||||||
|
"unicorn/prevent-abbreviations": "off",
|
||||||
|
"unicorn/no-process-exit": "off",
|
||||||
|
"unicorn/prefer-top-level-await": "off",
|
||||||
|
|
||||||
|
// Import rules
|
||||||
|
"import-x/no-duplicates": "error",
|
||||||
|
"import-x/order": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
groups: ["builtin", "external", "internal", "parent", "sibling", "index"],
|
||||||
|
"newlines-between": "always",
|
||||||
|
alphabetize: { order: "asc" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
// TypeScript rules
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"error",
|
||||||
|
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
||||||
|
],
|
||||||
|
"@typescript-eslint/no-explicit-any": "warn",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
prettier,
|
||||||
|
];
|
||||||
20
packages/eslint-config/package.json
Normal file
20
packages/eslint-config/package.json
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"name": "@link-stack/eslint-config",
|
||||||
|
"version": "3.5.0-beta.1",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
"./node": "./node.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.29.0",
|
||||||
|
"@typescript-eslint/parser": "^8.29.0",
|
||||||
|
"eslint-config-prettier": "^10.1.1",
|
||||||
|
"eslint-plugin-unicorn": "^58.0.0",
|
||||||
|
"eslint-plugin-import-x": "^4.12.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"eslint": "^9",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
27
packages/logger/package.json
Normal file
27
packages/logger/package.json
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"name": "@link-stack/logger",
|
||||||
|
"version": "3.5.0-beta.1",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"module": "./dist/index.mjs",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.mjs",
|
||||||
|
"require": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsup src/index.ts --format cjs,esm --dts --clean"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"pino": "^9.6.0",
|
||||||
|
"pino-pretty": "^13.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@link-stack/typescript-config": "workspace:*",
|
||||||
|
"@types/node": "*",
|
||||||
|
"tsup": "^8.5.0",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
86
packages/logger/src/index.ts
Normal file
86
packages/logger/src/index.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
import pino, { type Logger as PinoLogger, type 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",
|
||||||
|
"HandshakeKey",
|
||||||
|
"receivedSecret",
|
||||||
|
"access_token",
|
||||||
|
"refresh_token",
|
||||||
|
"zammadCsrfToken",
|
||||||
|
"clientSecret",
|
||||||
|
"*.password",
|
||||||
|
"*.token",
|
||||||
|
"*.secret",
|
||||||
|
"*.api_key",
|
||||||
|
"*.apiKey",
|
||||||
|
"*.authorization",
|
||||||
|
"*.cookie",
|
||||||
|
"*.access_token",
|
||||||
|
"*.refresh_token",
|
||||||
|
"*.zammadCsrfToken",
|
||||||
|
"*.HandshakeKey",
|
||||||
|
"*.receivedSecret",
|
||||||
|
"*.clientSecret",
|
||||||
|
"payload.HandshakeKey",
|
||||||
|
"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, unknown>): Logger => {
|
||||||
|
return logger.child({ name, ...context });
|
||||||
|
};
|
||||||
|
|
||||||
|
export default logger;
|
||||||
11
packages/logger/tsconfig.json
Normal file
11
packages/logger/tsconfig.json
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"extends": "@link-stack/typescript-config/tsconfig.node.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"incremental": false,
|
||||||
|
"composite": false
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
8
packages/prettier-config/index.json
Normal file
8
packages/prettier-config/index.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"printWidth": 120,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false,
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"trailingComma": "es5"
|
||||||
|
}
|
||||||
6
packages/prettier-config/package.json
Normal file
6
packages/prettier-config/package.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"name": "@link-stack/prettier-config",
|
||||||
|
"version": "3.5.0-beta.1",
|
||||||
|
"private": true,
|
||||||
|
"main": "index.json"
|
||||||
|
}
|
||||||
6
packages/typescript-config/package.json
Normal file
6
packages/typescript-config/package.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"name": "@link-stack/typescript-config",
|
||||||
|
"version": "3.5.0-beta.1",
|
||||||
|
"private": true,
|
||||||
|
"files": ["tsconfig.node.json"]
|
||||||
|
}
|
||||||
23
packages/typescript-config/tsconfig.node.json
Normal file
23
packages/typescript-config/tsconfig.node.json
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,7 +13,6 @@ class ChannelCdrSignal extends App.ControllerSubContent
|
||||||
constructor: ->
|
constructor: ->
|
||||||
super
|
super
|
||||||
|
|
||||||
#@interval(@load, 60000)
|
|
||||||
@load()
|
@load()
|
||||||
|
|
||||||
load: =>
|
load: =>
|
||||||
|
|
@ -154,7 +153,7 @@ class ChannelCdrSignal extends App.ControllerSubContent
|
||||||
)
|
)
|
||||||
|
|
||||||
class FormAdd extends App.ControllerModal
|
class FormAdd extends App.ControllerModal
|
||||||
head: 'Add Web Form'
|
head: 'Add Signal Bot'
|
||||||
shown: true
|
shown: true
|
||||||
button: 'Add'
|
button: 'Add'
|
||||||
buttonCancel: true
|
buttonCancel: true
|
||||||
|
|
@ -199,24 +198,101 @@ class FormAdd extends App.ControllerModal
|
||||||
|
|
||||||
onSubmit: (e) =>
|
onSubmit: (e) =>
|
||||||
@formDisable(e)
|
@formDisable(e)
|
||||||
|
params = @formParams()
|
||||||
|
|
||||||
|
# Auto-generate bot_token if not provided
|
||||||
|
if !params.bot_token || params.bot_token.trim() == ''
|
||||||
|
params.bot_token = @generateToken()
|
||||||
|
|
||||||
@ajax(
|
@ajax(
|
||||||
id: 'cdr_signal_app_verify'
|
id: 'cdr_signal_app_verify'
|
||||||
type: 'POST'
|
type: 'POST'
|
||||||
url: "#{@apiPath}/channels_cdr_signal"
|
url: "#{@apiPath}/channels_cdr_signal"
|
||||||
data: JSON.stringify(@formParams())
|
data: JSON.stringify(params)
|
||||||
processData: true
|
processData: true
|
||||||
success: =>
|
success: (data) =>
|
||||||
@isChanged = true
|
@isChanged = true
|
||||||
@close()
|
channelId = data.id
|
||||||
|
|
||||||
|
# Start device linking if phone number provided
|
||||||
|
if params.phone_number && params.phone_number.trim() != ''
|
||||||
|
@startLinking(channelId, params.phone_number)
|
||||||
|
else
|
||||||
|
@close()
|
||||||
error: (xhr) =>
|
error: (xhr) =>
|
||||||
data = JSON.parse(xhr.responseText)
|
data = JSON.parse(xhr.responseText)
|
||||||
@formEnable(e)
|
@formEnable(e)
|
||||||
error_message = App.i18n.translateContent(data.error || 'Unable to save Web Form.')
|
error_message = App.i18n.translateContent(data.error || 'Unable to save.')
|
||||||
@el.find('.alert').removeClass('hidden').text(error_message)
|
@el.find('.alert').removeClass('hidden').text(error_message)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
startLinking: (channelId, phoneNumber) =>
|
||||||
|
@ajax(
|
||||||
|
id: 'cdr_signal_register'
|
||||||
|
type: 'POST'
|
||||||
|
url: "#{@apiPath}/channels_cdr_signal_register/#{channelId}"
|
||||||
|
data: JSON.stringify(phone_number: phoneNumber)
|
||||||
|
processData: true
|
||||||
|
success: (data) =>
|
||||||
|
if data.linkUri
|
||||||
|
@showLinkingStep(channelId, data.linkUri)
|
||||||
|
else
|
||||||
|
@close()
|
||||||
|
error: (xhr) =>
|
||||||
|
data = JSON.parse(xhr.responseText)
|
||||||
|
error_message = App.i18n.translateContent(data.error || 'Failed to start device linking.')
|
||||||
|
@el.find('.alert').removeClass('hidden').text(error_message)
|
||||||
|
@el.find('.js-submit').removeAttr('disabled')
|
||||||
|
)
|
||||||
|
|
||||||
|
showLinkingStep: (channelId, linkUri) =>
|
||||||
|
@el.find('.modal-body').html(App.view('cdr_signal/form_link')(
|
||||||
|
linkUri: linkUri
|
||||||
|
))
|
||||||
|
@el.find('.js-submit').text(App.i18n.translateContent('Done'))
|
||||||
|
|
||||||
|
# Generate QR code using an API service
|
||||||
|
qrImg = @el.find('.js-qr-image')
|
||||||
|
encodedUri = encodeURIComponent(linkUri)
|
||||||
|
qrImg.attr('src', "https://api.qrserver.com/v1/create-qr-code/?size=250x250&data=#{encodedUri}")
|
||||||
|
|
||||||
|
# Poll for registration status
|
||||||
|
@pollingInterval = setInterval(=>
|
||||||
|
@checkRegistration(channelId)
|
||||||
|
, 3000)
|
||||||
|
|
||||||
|
checkRegistration: (channelId) =>
|
||||||
|
# Check bot status by reading channel and querying bridge
|
||||||
|
channel = App.Channel.find(channelId)
|
||||||
|
return unless channel
|
||||||
|
|
||||||
|
@ajax(
|
||||||
|
id: 'cdr_signal_check_status'
|
||||||
|
type: 'GET'
|
||||||
|
url: "#{@apiPath}/channels_cdr_signal"
|
||||||
|
processData: true
|
||||||
|
success: (data) =>
|
||||||
|
# Linking is complete when bridge confirms registration
|
||||||
|
# For now we rely on manual "Done" click
|
||||||
|
)
|
||||||
|
|
||||||
|
onClosed: =>
|
||||||
|
if @pollingInterval
|
||||||
|
clearInterval(@pollingInterval)
|
||||||
|
@pollingInterval = null
|
||||||
|
return if !@isChanged
|
||||||
|
@isChanged = false
|
||||||
|
@load()
|
||||||
|
|
||||||
|
generateToken: ->
|
||||||
|
chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-'
|
||||||
|
result = ''
|
||||||
|
for i in [0...32]
|
||||||
|
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||||
|
result
|
||||||
|
|
||||||
class FormEdit extends App.ControllerModal
|
class FormEdit extends App.ControllerModal
|
||||||
head: 'Web Form Info'
|
head: 'Signal Bot Info'
|
||||||
shown: true
|
shown: true
|
||||||
buttonCancel: true
|
buttonCancel: true
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,34 +2,27 @@
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<div class="input form-group">
|
<div class="input form-group">
|
||||||
<div class="formGroup-label">
|
<div class="formGroup-label">
|
||||||
<label for="form_name"><%- @T('Phone number') %> <span>*</span></label>
|
<label for="phone_number"><%- @T('Phone number') %> <span>*</span></label>
|
||||||
</div>
|
</div>
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<input id="phone_number" type="text" name="phone_number" value="" class="form-control" required autocomplete="off">
|
<input id="phone_number" type="text" name="phone_number" value="" class="form-control" required autocomplete="off" placeholder="+1234567890">
|
||||||
|
<p class="help-text"><%- @T('The phone number linked to your Signal account. A QR code will be shown to link the device.') %></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="input form-group">
|
<div class="input form-group">
|
||||||
<div class="formGroup-label">
|
<div class="formGroup-label">
|
||||||
<label for="form_name"><%- @T('Bot Token') %> <span>*</span></label>
|
<label for="bot_token"><%- @T('Bot Token') %></label>
|
||||||
</div>
|
</div>
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<input id="bot_token" type="text" name="bot_token" value="" class="form-control" required autocomplete="off">
|
<input id="bot_token" type="text" name="bot_token" value="" class="form-control" autocomplete="off">
|
||||||
|
<p class="help-text"><%- @T('Leave blank to auto-generate.') %></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="input form-group">
|
<div class="input form-group">
|
||||||
<div class="formGroup-label">
|
<div class="formGroup-label">
|
||||||
<label for="form_name"><%- @T('Bot Endpoint') %> <span>*</span></label>
|
<label for=""><%- @T('Choose the group in which incoming messages will be added.') %> <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>
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<div class="js-messagesGroup"></div>
|
<div class="js-messagesGroup"></div>
|
||||||
|
|
@ -38,7 +31,7 @@
|
||||||
|
|
||||||
<div class="input form-group">
|
<div class="input form-group">
|
||||||
<div class="formGroup-label">
|
<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>
|
<label for=""><%- @T('Choose the organization to which users will be added to when they send a message to this number.') %></label>
|
||||||
</div>
|
</div>
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<div class="profile-organization js-organization"></div>
|
<div class="profile-organization js-organization"></div>
|
||||||
|
|
|
||||||
|
|
@ -3,31 +3,22 @@
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<div class="input form-group">
|
<div class="input form-group">
|
||||||
<div class="formGroup-label">
|
<div class="formGroup-label">
|
||||||
<label for="form_name"><%- @T('Voice Line Number') %> <span>*</span></label>
|
<label for="phone_number"><%- @T('Phone number') %> <span>*</span></label>
|
||||||
</div>
|
</div>
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<input id="phone_number" type="text" name="phone_number" value="<%= @channel.options.phone_number %>" class="form-control" required autocomplete="off">
|
<input id="phone_number" type="text" name="phone_number" value="<%= @channel.options.phone_number %>" class="form-control" required autocomplete="off">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="input form-group">
|
<div class="input form-group">
|
||||||
<div class="formGroup-label">
|
<div class="formGroup-label">
|
||||||
<label for="form_name"><%- @T('Bot Token') %> <span>*</span></label>
|
<label for="bot_token"><%- @T('Bot Token') %> <span>*</span></label>
|
||||||
</div>
|
</div>
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<input id="bot_token" type="text" name="bot_token" value="<%= @channel.options.bot_token %>" class="form-control" required autocomplete="off">
|
<input id="bot_token" type="text" name="bot_token" value="<%= @channel.options.bot_token %>" class="form-control" required autocomplete="off">
|
||||||
</div>
|
</div>
|
||||||
</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="input form-group">
|
||||||
<div class="formGroup-label">
|
<div class="formGroup-label">
|
||||||
<label for=""><%- @T('Choose the group in which incoming messages will be added.') %> <span>*</span></label>
|
<label for=""><%- @T('Choose the group in which incoming messages will be added.') %> <span>*</span></label>
|
||||||
|
|
@ -38,7 +29,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="input form-group">
|
<div class="input form-group">
|
||||||
<div class="formGroup-label">
|
<div class="formGroup-label">
|
||||||
<label for=""><%- @T('Choose the organization to which users will be added to when they send a message to this number.') %> <span>*</span></label>
|
<label for=""><%- @T('Choose the organization to which users will be added to when they send a message to this number.') %></label>
|
||||||
</div>
|
</div>
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<div class="profile-organization js-organization"></div>
|
<div class="profile-organization js-organization"></div>
|
||||||
|
|
@ -46,7 +37,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="input form-group">
|
<div class="input form-group">
|
||||||
<div class="formGroup-label">
|
<div class="formGroup-label">
|
||||||
<label for="token"><%- @T('Endpoint URL') %> <span>*</span></label>
|
<label for="token"><%- @T('Webhook URL') %></label>
|
||||||
</div>
|
</div>
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<input id="token" type="text" value="<%= "#{App.Config.get('http_type')}://#{App.Config.get('fqdn')}/api/v1/channels_cdr_signal_webhook/#{@channel.options.token}" %>" class="form-control input js-select" readonly>
|
<input id="token" type="text" value="<%= "#{App.Config.get('http_type')}://#{App.Config.get('fqdn')}/api/v1/channels_cdr_signal_webhook/#{@channel.options.token}" %>" class="form-control input js-select" readonly>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
<div class="alert alert--danger hidden" role="alert"></div>
|
||||||
|
<div style="text-align: center; padding: 20px;">
|
||||||
|
<h3><%- @T('Link Signal Device') %></h3>
|
||||||
|
<p><%- @T('Scan this QR code with your Signal app to link this device.') %></p>
|
||||||
|
<p style="margin-bottom: 5px;"><%- @T('In Signal, go to Settings > Linked Devices > Link New Device.') %></p>
|
||||||
|
<div style="margin: 20px 0;">
|
||||||
|
<img class="js-qr-image" src="" alt="QR Code" style="width: 250px; height: 250px; border: 1px solid #ddd;" />
|
||||||
|
</div>
|
||||||
|
<p class="text-muted" style="font-size: 12px; word-break: break-all;"><%= @linkUri %></p>
|
||||||
|
</div>
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class ChannelsCdrSignalController < ApplicationController
|
class ChannelsCdrSignalController < ApplicationController
|
||||||
prepend_before_action -> { authentication_check && authorize! }, except: [:webhook]
|
prepend_before_action -> { authentication_check && authorize! }, except: %i[webhook bot_webhook]
|
||||||
skip_before_action :verify_csrf_token, only: [:webhook]
|
skip_before_action :verify_csrf_token, only: %i[webhook bot_webhook]
|
||||||
|
|
||||||
include CreatesTicketArticles
|
include CreatesTicketArticles
|
||||||
|
|
||||||
|
|
@ -60,7 +60,6 @@ class ChannelsCdrSignalController < ApplicationController
|
||||||
adapter: 'cdr_signal',
|
adapter: 'cdr_signal',
|
||||||
phone_number: params[:phone_number],
|
phone_number: params[:phone_number],
|
||||||
bot_token: params[:bot_token],
|
bot_token: params[:bot_token],
|
||||||
bot_endpoint: params[:bot_endpoint],
|
|
||||||
token: SecureRandom.urlsafe_base64(48),
|
token: SecureRandom.urlsafe_base64(48),
|
||||||
organization_id: params[:organization_id]
|
organization_id: params[:organization_id]
|
||||||
},
|
},
|
||||||
|
|
@ -87,7 +86,6 @@ class ChannelsCdrSignalController < ApplicationController
|
||||||
begin
|
begin
|
||||||
channel.options[:phone_number] = params[:phone_number]
|
channel.options[:phone_number] = params[:phone_number]
|
||||||
channel.options[:bot_token] = params[:bot_token]
|
channel.options[:bot_token] = params[:bot_token]
|
||||||
channel.options[:bot_endpoint] = params[:bot_endpoint]
|
|
||||||
channel.options[:organization_id] = params[:organization_id]
|
channel.options[:organization_id] = params[:organization_id]
|
||||||
channel.group_id = params[:group_id]
|
channel.group_id = params[:group_id]
|
||||||
channel.save!
|
channel.save!
|
||||||
|
|
@ -97,6 +95,20 @@ class ChannelsCdrSignalController < ApplicationController
|
||||||
render json: channel
|
render json: channel
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def register
|
||||||
|
channel = Channel.find_by(id: params[:id], area: 'Signal::Number')
|
||||||
|
unless channel
|
||||||
|
render json: { error: 'Channel not found' }, status: :not_found
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
api = CdrSignalApi.new
|
||||||
|
result = api.register(channel.options[:bot_token], params[:phone_number])
|
||||||
|
render json: result
|
||||||
|
rescue StandardError => e
|
||||||
|
render json: { error: e.message }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
def rotate_token
|
def rotate_token
|
||||||
channel = Channel.find_by(id: params[:id], area: 'Signal::Number')
|
channel = Channel.find_by(id: params[:id], area: 'Signal::Number')
|
||||||
channel.options[:token] = SecureRandom.urlsafe_base64(48)
|
channel.options[:token] = SecureRandom.urlsafe_base64(48)
|
||||||
|
|
@ -133,13 +145,33 @@ class ChannelsCdrSignalController < ApplicationController
|
||||||
false
|
false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Webhook endpoint for incoming messages from bridge-signal (bot-centric)
|
||||||
|
# Bridge POSTs here with incoming messages, group events, etc.
|
||||||
|
def bot_webhook
|
||||||
|
bot_id = params[:id]
|
||||||
|
channel = Channel.where(area: 'Signal::Number', active: true)
|
||||||
|
.find { |c| c.options[:bot_token] == bot_id }
|
||||||
|
return render(json: {}, status: 401) unless channel
|
||||||
|
|
||||||
|
# Handle events
|
||||||
|
case params[:event]
|
||||||
|
when 'group_created'
|
||||||
|
return update_group
|
||||||
|
when 'group_member_joined'
|
||||||
|
return handle_group_member_joined
|
||||||
|
end
|
||||||
|
|
||||||
|
# Process incoming message
|
||||||
|
process_incoming_message(channel)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Legacy webhook endpoint (token-based)
|
||||||
def webhook
|
def webhook
|
||||||
token = params['token']
|
token = params['token']
|
||||||
return render json: {}, status: 401 unless token
|
return render json: {}, status: 401 unless token
|
||||||
|
|
||||||
channel = channel_for_token(token)
|
channel = channel_for_token(token)
|
||||||
return render json: {}, status: 401 if !channel || !channel.active
|
return render json: {}, status: 401 if !channel || !channel.active
|
||||||
# Use constant-time comparison to prevent timing attacks
|
|
||||||
return render json: {}, status: 401 unless ActiveSupport::SecurityUtils.secure_compare(
|
return render json: {}, status: 401 unless ActiveSupport::SecurityUtils.secure_compare(
|
||||||
channel.options[:token].to_s,
|
channel.options[:token].to_s,
|
||||||
token.to_s
|
token.to_s
|
||||||
|
|
@ -155,16 +187,14 @@ class ChannelsCdrSignalController < ApplicationController
|
||||||
return handle_group_member_joined
|
return handle_group_member_joined
|
||||||
end
|
end
|
||||||
|
|
||||||
channel_id = channel.id
|
process_incoming_message(channel)
|
||||||
|
end
|
||||||
|
|
||||||
# validate input
|
def update_group
|
||||||
errors = {}
|
errors = {}
|
||||||
|
errors['event'] = 'required' unless params[:event].present?
|
||||||
# %i[to
|
errors['conversation_id'] = 'required' unless params[:conversation_id].present?
|
||||||
# from
|
errors['group_id'] = 'required' unless params[:group_id].present?
|
||||||
# message_id
|
|
||||||
# sent_at].each | field |
|
|
||||||
# (errors[field] = 'required' if params[field].blank?)
|
|
||||||
|
|
||||||
if errors.present?
|
if errors.present?
|
||||||
render json: {
|
render json: {
|
||||||
|
|
@ -173,6 +203,139 @@ class ChannelsCdrSignalController < ApplicationController
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
unless params[:event] == 'group_created'
|
||||||
|
render json: { error: 'Unsupported event type' }, status: :bad_request
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
ticket = Ticket.find_by(id: params[:conversation_id]) ||
|
||||||
|
Ticket.find_by(number: params[:conversation_id])
|
||||||
|
|
||||||
|
unless ticket
|
||||||
|
Rails.logger.error "Signal group update: Ticket not found for conversation_id #{params[:conversation_id]}"
|
||||||
|
render json: { error: 'Ticket not found' }, status: :not_found
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
existing_chat_id = ticket.preferences&.dig(:cdr_signal, :chat_id) ||
|
||||||
|
ticket.preferences&.dig('cdr_signal', 'chat_id')
|
||||||
|
if existing_chat_id&.start_with?('group.')
|
||||||
|
Rails.logger.info "Signal group update: Ticket #{ticket.id} already has group #{existing_chat_id}, ignoring new group #{params[:group_id]}"
|
||||||
|
render json: {
|
||||||
|
success: true,
|
||||||
|
skipped: true,
|
||||||
|
reason: 'Ticket already has a group assigned',
|
||||||
|
existing_group_id: existing_chat_id,
|
||||||
|
ticket_id: ticket.id,
|
||||||
|
ticket_number: ticket.number
|
||||||
|
}, status: :ok
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
ticket.preferences ||= {}
|
||||||
|
ticket.preferences[:cdr_signal] ||= {}
|
||||||
|
ticket.preferences[:cdr_signal][:chat_id] = params[:group_id]
|
||||||
|
ticket.preferences[:cdr_signal][:original_recipient] = params[:original_recipient] if params[:original_recipient].present?
|
||||||
|
ticket.preferences[:cdr_signal][:group_created_at] = params[:timestamp] if params[:timestamp].present?
|
||||||
|
ticket.preferences[:cdr_signal][:group_joined] = params[:group_joined] if params.key?(:group_joined)
|
||||||
|
|
||||||
|
ticket.save!
|
||||||
|
|
||||||
|
Rails.logger.info "Signal group #{params[:group_id]} associated with ticket #{ticket.id}"
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
success: true,
|
||||||
|
ticket_id: ticket.id,
|
||||||
|
ticket_number: ticket.number
|
||||||
|
}, status: :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_group_member_joined
|
||||||
|
errors = {}
|
||||||
|
errors['event'] = 'required' unless params[:event].present?
|
||||||
|
errors['group_id'] = 'required' unless params[:group_id].present?
|
||||||
|
errors['member_phone'] = 'required' unless params[:member_phone].present?
|
||||||
|
|
||||||
|
if errors.present?
|
||||||
|
render json: {
|
||||||
|
errors: errors
|
||||||
|
}, status: :bad_request
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
state_ids = Ticket::State.where(name: %w[closed merged removed]).pluck(:id)
|
||||||
|
|
||||||
|
ticket = Ticket.where.not(state_id: state_ids)
|
||||||
|
.where("preferences LIKE ?", "%chat_id: #{params[:group_id]}%")
|
||||||
|
.order(updated_at: :desc)
|
||||||
|
.first
|
||||||
|
|
||||||
|
unless ticket
|
||||||
|
Rails.logger.warn "Signal group member joined: Ticket not found for group_id #{params[:group_id]}"
|
||||||
|
render json: { error: 'Ticket not found for this group' }, status: :not_found
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if ticket.preferences.dig('cdr_signal', 'group_joined') == true
|
||||||
|
render json: {
|
||||||
|
success: true,
|
||||||
|
ticket_id: ticket.id,
|
||||||
|
ticket_number: ticket.number,
|
||||||
|
group_joined: true,
|
||||||
|
already_joined: true
|
||||||
|
}, status: :ok
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
member_phone = params[:member_phone]
|
||||||
|
ticket.preferences[:cdr_signal][:group_joined] = true
|
||||||
|
ticket.preferences[:cdr_signal][:group_joined_at] = params[:timestamp] if params[:timestamp].present?
|
||||||
|
ticket.preferences[:cdr_signal][:group_joined_by] = member_phone
|
||||||
|
|
||||||
|
ticket.save!
|
||||||
|
|
||||||
|
Rails.logger.info "Signal group member #{member_phone} joined group #{params[:group_id]} for ticket #{ticket.id}"
|
||||||
|
|
||||||
|
articles_with_pending_notification = Ticket::Article.where(ticket_id: ticket.id)
|
||||||
|
.where("preferences LIKE ?", "%group_not_joined_note_added: true%")
|
||||||
|
|
||||||
|
if articles_with_pending_notification.exists?
|
||||||
|
resolution_note_exists = Ticket::Article.where(ticket_id: ticket.id)
|
||||||
|
.where("preferences LIKE ?", "%group_joined_resolution: true%")
|
||||||
|
.exists?
|
||||||
|
|
||||||
|
unless resolution_note_exists
|
||||||
|
Ticket::Article.create(
|
||||||
|
ticket_id: ticket.id,
|
||||||
|
content_type: 'text/plain',
|
||||||
|
body: 'Recipient has now joined the Signal group. Pending messages will be delivered shortly.',
|
||||||
|
internal: true,
|
||||||
|
sender: Ticket::Article::Sender.find_by(name: 'System'),
|
||||||
|
type: Ticket::Article::Type.find_by(name: 'note'),
|
||||||
|
preferences: {
|
||||||
|
delivery_message: true,
|
||||||
|
group_joined_resolution: true,
|
||||||
|
},
|
||||||
|
updated_by_id: 1,
|
||||||
|
created_by_id: 1,
|
||||||
|
)
|
||||||
|
Rails.logger.info "Ticket ##{ticket.number}: Added resolution note about customer joining Signal group"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
success: true,
|
||||||
|
ticket_id: ticket.id,
|
||||||
|
ticket_number: ticket.number,
|
||||||
|
group_joined: true
|
||||||
|
}, status: :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def process_incoming_message(channel)
|
||||||
|
channel_id = channel.id
|
||||||
|
|
||||||
message_id = params[:message_id]
|
message_id = params[:message_id]
|
||||||
|
|
||||||
return if Ticket::Article.exists?(message_id: "cdr_signal.#{message_id}")
|
return if Ticket::Article.exists?(message_id: "cdr_signal.#{message_id}")
|
||||||
|
|
@ -181,16 +344,9 @@ class ChannelsCdrSignalController < ApplicationController
|
||||||
sender_phone_number = params[:from].present? ? params[:from].strip : nil
|
sender_phone_number = params[:from].present? ? params[:from].strip : nil
|
||||||
sender_user_id = params[:user_id].present? ? params[:user_id].strip : nil
|
sender_user_id = params[:user_id].present? ? params[:user_id].strip : nil
|
||||||
|
|
||||||
# Check if this is a group message using the is_group flag from bridge-worker
|
|
||||||
# This flag is set when:
|
|
||||||
# 1. The original message came from a Signal group
|
|
||||||
# 2. Bridge-worker created a new group for the conversation
|
|
||||||
is_group_message = params[:is_group].to_s == 'true'
|
is_group_message = params[:is_group].to_s == 'true'
|
||||||
|
|
||||||
# Lookup customer with fallback chain:
|
# Lookup customer with fallback chain
|
||||||
# 1. Phone number in phone/mobile fields (preferred)
|
|
||||||
# 2. Signal user ID in signal_uid field
|
|
||||||
# 3. User ID in phone/mobile fields (legacy - we used to store UUIDs there)
|
|
||||||
customer = nil
|
customer = nil
|
||||||
if sender_phone_number.present?
|
if sender_phone_number.present?
|
||||||
customer = User.find_by(phone: sender_phone_number)
|
customer = User.find_by(phone: sender_phone_number)
|
||||||
|
|
@ -198,7 +354,6 @@ class ChannelsCdrSignalController < ApplicationController
|
||||||
end
|
end
|
||||||
if customer.nil? && sender_user_id.present?
|
if customer.nil? && sender_user_id.present?
|
||||||
customer = User.find_by(signal_uid: sender_user_id)
|
customer = User.find_by(signal_uid: sender_user_id)
|
||||||
# Legacy fallback: user ID might be stored in phone field
|
|
||||||
customer ||= User.find_by(phone: sender_user_id)
|
customer ||= User.find_by(phone: sender_user_id)
|
||||||
customer ||= User.find_by(mobile: sender_user_id)
|
customer ||= User.find_by(mobile: sender_user_id)
|
||||||
end
|
end
|
||||||
|
|
@ -220,16 +375,13 @@ class ChannelsCdrSignalController < ApplicationController
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Update signal_uid if we have it and customer doesn't
|
|
||||||
if sender_user_id.present? && customer.signal_uid.blank?
|
if sender_user_id.present? && customer.signal_uid.blank?
|
||||||
customer.update(signal_uid: sender_user_id)
|
customer.update(signal_uid: sender_user_id)
|
||||||
end
|
end
|
||||||
# Update phone if we have it and customer only has user_id in phone field
|
|
||||||
if sender_phone_number.present? && customer.phone == sender_user_id
|
if sender_phone_number.present? && customer.phone == sender_user_id
|
||||||
customer.update(phone: sender_phone_number)
|
customer.update(phone: sender_phone_number)
|
||||||
end
|
end
|
||||||
|
|
||||||
# set current user
|
|
||||||
UserInfo.current_user_id = customer.id
|
UserInfo.current_user_id = customer.id
|
||||||
current_user_set(customer, 'token_auth')
|
current_user_set(customer, 'token_auth')
|
||||||
|
|
||||||
|
|
@ -261,71 +413,40 @@ class ChannelsCdrSignalController < ApplicationController
|
||||||
title = "Message from #{sender_display} at #{sent_at}"
|
title = "Message from #{sender_display} at #{sent_at}"
|
||||||
body = message
|
body = message
|
||||||
|
|
||||||
# find ticket or create one
|
|
||||||
state_ids = Ticket::State.where(name: %w[closed merged removed]).pluck(:id)
|
state_ids = Ticket::State.where(name: %w[closed merged removed]).pluck(:id)
|
||||||
|
|
||||||
if is_group_message
|
if is_group_message
|
||||||
Rails.logger.info "=== SIGNAL GROUP TICKET LOOKUP ==="
|
|
||||||
Rails.logger.info "Looking for ticket with group_id: #{receiver_phone_number}"
|
|
||||||
Rails.logger.info "Customer ID: #{customer.id}"
|
|
||||||
Rails.logger.info "Customer Phone: #{sender_display}"
|
|
||||||
Rails.logger.info "Channel ID: #{channel.id}"
|
|
||||||
|
|
||||||
begin
|
begin
|
||||||
# Use text search on preferences YAML to efficiently find tickets without loading all into memory
|
|
||||||
# This prevents DoS attacks from memory exhaustion
|
|
||||||
ticket = Ticket.where.not(state_id: state_ids)
|
ticket = Ticket.where.not(state_id: state_ids)
|
||||||
.where("preferences LIKE ?", "%channel_id: #{channel.id}%")
|
.where("preferences LIKE ?", "%channel_id: #{channel.id}%")
|
||||||
.where("preferences LIKE ?", "%chat_id: #{receiver_phone_number}%")
|
.where("preferences LIKE ?", "%chat_id: #{receiver_phone_number}%")
|
||||||
.order(updated_at: :desc)
|
.order(updated_at: :desc)
|
||||||
.first
|
.first
|
||||||
|
|
||||||
if ticket
|
|
||||||
Rails.logger.info "=== FOUND MATCHING TICKET BY GROUP ID: ##{ticket.number} ==="
|
|
||||||
# Update customer if different (handles duplicate phone numbers)
|
|
||||||
if ticket.customer_id != customer.id
|
|
||||||
Rails.logger.info "Updating ticket customer from #{ticket.customer_id} to #{customer.id}"
|
|
||||||
ticket.customer_id = customer.id
|
|
||||||
end
|
|
||||||
else
|
|
||||||
Rails.logger.info "=== NO MATCHING TICKET BY GROUP ID - CHECKING BY PHONE NUMBER ==="
|
|
||||||
end
|
|
||||||
rescue => e
|
rescue => e
|
||||||
Rails.logger.error "Error during group ticket lookup: #{e.message}"
|
Rails.logger.error "Error during group ticket lookup: #{e.message}"
|
||||||
Rails.logger.error e.backtrace.join("\n")
|
Rails.logger.error e.backtrace.join("\n")
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
Rails.logger.info "Not a group message or no group_id, finding most recent ticket"
|
|
||||||
ticket = Ticket.where(customer_id: customer.id).where.not(state_id: state_ids).order(:updated_at).first
|
ticket = Ticket.where(customer_id: customer.id).where.not(state_id: state_ids).order(:updated_at).first
|
||||||
end
|
end
|
||||||
|
|
||||||
if ticket
|
if ticket
|
||||||
# check if title need to be updated
|
|
||||||
ticket.title = title if ticket.title == '-'
|
ticket.title = title if ticket.title == '-'
|
||||||
new_state = Ticket::State.find_by(default_create: true)
|
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
|
ticket.state = Ticket::State.find_by(default_follow_up: true) if ticket.state_id != new_state.id
|
||||||
else
|
else
|
||||||
# Set up chat_id based on whether this is a group message
|
|
||||||
# For direct messages, prefer UUID (more stable than phone numbers which can change)
|
|
||||||
chat_id = is_group_message ? receiver_phone_number : (sender_user_id.presence || sender_phone_number)
|
chat_id = is_group_message ? receiver_phone_number : (sender_user_id.presence || sender_phone_number)
|
||||||
|
|
||||||
# Build preferences with group_id included if needed
|
|
||||||
cdr_signal_prefs = {
|
cdr_signal_prefs = {
|
||||||
bot_token: channel.options[:bot_token],
|
bot_token: channel.options[:bot_token],
|
||||||
chat_id: chat_id,
|
chat_id: chat_id,
|
||||||
user_id: sender_user_id
|
user_id: sender_user_id
|
||||||
}
|
}
|
||||||
|
|
||||||
# Store original recipient phone for group tickets to enable ticket splitting
|
|
||||||
if is_group_message
|
if is_group_message
|
||||||
cdr_signal_prefs[:original_recipient] = sender_phone_number
|
cdr_signal_prefs[:original_recipient] = sender_phone_number
|
||||||
end
|
end
|
||||||
|
|
||||||
Rails.logger.info "=== CREATING NEW TICKET ==="
|
|
||||||
Rails.logger.info "Preferences to be stored:"
|
|
||||||
Rails.logger.info " - channel_id: #{channel.id}"
|
|
||||||
Rails.logger.info " - cdr_signal: #{cdr_signal_prefs.inspect}"
|
|
||||||
|
|
||||||
ticket = Ticket.new(
|
ticket = Ticket.new(
|
||||||
group_id: channel.group_id,
|
group_id: channel.group_id,
|
||||||
title: title,
|
title: title,
|
||||||
|
|
@ -361,9 +482,6 @@ class ChannelsCdrSignalController < ApplicationController
|
||||||
|
|
||||||
if attachment_data_base64.present?
|
if attachment_data_base64.present?
|
||||||
article_params[:attachments] = [
|
article_params[:attachments] = [
|
||||||
# i don't even...
|
|
||||||
# this is necessary because of what's going on in controllers/concerns/creates_ticket_articles.rb
|
|
||||||
# we need help from the ruby gods
|
|
||||||
{
|
{
|
||||||
'filename' => attachment_filename,
|
'filename' => attachment_filename,
|
||||||
:filename => attachment_filename,
|
:filename => attachment_filename,
|
||||||
|
|
@ -390,185 +508,4 @@ class ChannelsCdrSignalController < ApplicationController
|
||||||
|
|
||||||
render json: result, status: :ok
|
render json: result, status: :ok
|
||||||
end
|
end
|
||||||
|
|
||||||
# Webhook endpoint for receiving group creation notifications from bridge-worker
|
|
||||||
# This is called when a Signal group is created for a conversation
|
|
||||||
# Expected payload:
|
|
||||||
# {
|
|
||||||
# "event": "group_created",
|
|
||||||
# "conversation_id": "ticket_id_or_number",
|
|
||||||
# "original_recipient": "+1234567890",
|
|
||||||
# "group_id": "uuid-of-signal-group",
|
|
||||||
# "timestamp": "ISO8601 timestamp"
|
|
||||||
# }
|
|
||||||
def update_group
|
|
||||||
# Validate required parameters
|
|
||||||
errors = {}
|
|
||||||
errors['event'] = 'required' unless params[:event].present?
|
|
||||||
errors['conversation_id'] = 'required' unless params[:conversation_id].present?
|
|
||||||
errors['group_id'] = 'required' unless params[:group_id].present?
|
|
||||||
|
|
||||||
if errors.present?
|
|
||||||
render json: {
|
|
||||||
errors: errors
|
|
||||||
}, status: :bad_request
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
# Only handle group_created events for now
|
|
||||||
unless params[:event] == 'group_created'
|
|
||||||
render json: { error: 'Unsupported event type' }, status: :bad_request
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
# Find the ticket by ID or number
|
|
||||||
# Try to find by both ID and number since ticket numbers can be numeric
|
|
||||||
ticket = Ticket.find_by(id: params[:conversation_id]) ||
|
|
||||||
Ticket.find_by(number: params[:conversation_id])
|
|
||||||
|
|
||||||
unless ticket
|
|
||||||
Rails.logger.error "Signal group update: Ticket not found for conversation_id #{params[:conversation_id]}"
|
|
||||||
render json: { error: 'Ticket not found' }, status: :not_found
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
# Idempotency check: if chat_id is already a group ID, don't overwrite it
|
|
||||||
# This prevents race conditions where multiple group_created webhooks arrive
|
|
||||||
# (e.g., due to retries after API timeouts during group creation)
|
|
||||||
existing_chat_id = ticket.preferences&.dig(:cdr_signal, :chat_id) ||
|
|
||||||
ticket.preferences&.dig('cdr_signal', 'chat_id')
|
|
||||||
if existing_chat_id&.start_with?('group.')
|
|
||||||
Rails.logger.info "Signal group update: Ticket #{ticket.id} already has group #{existing_chat_id}, ignoring new group #{params[:group_id]}"
|
|
||||||
render json: {
|
|
||||||
success: true,
|
|
||||||
skipped: true,
|
|
||||||
reason: 'Ticket already has a group assigned',
|
|
||||||
existing_group_id: existing_chat_id,
|
|
||||||
ticket_id: ticket.id,
|
|
||||||
ticket_number: ticket.number
|
|
||||||
}, status: :ok
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
# Update ticket preferences with the group information
|
|
||||||
ticket.preferences ||= {}
|
|
||||||
ticket.preferences[:cdr_signal] ||= {}
|
|
||||||
ticket.preferences[:cdr_signal][:chat_id] = params[:group_id]
|
|
||||||
ticket.preferences[:cdr_signal][:original_recipient] = params[:original_recipient] if params[:original_recipient].present?
|
|
||||||
ticket.preferences[:cdr_signal][:group_created_at] = params[:timestamp] if params[:timestamp].present?
|
|
||||||
|
|
||||||
# Track whether user has joined the group (initially false)
|
|
||||||
# This will be updated to true when we receive a group join event from Signal
|
|
||||||
ticket.preferences[:cdr_signal][:group_joined] = params[:group_joined] if params.key?(:group_joined)
|
|
||||||
|
|
||||||
ticket.save!
|
|
||||||
|
|
||||||
Rails.logger.info "Signal group #{params[:group_id]} associated with ticket #{ticket.id}"
|
|
||||||
|
|
||||||
render json: {
|
|
||||||
success: true,
|
|
||||||
ticket_id: ticket.id,
|
|
||||||
ticket_number: ticket.number
|
|
||||||
}, status: :ok
|
|
||||||
end
|
|
||||||
|
|
||||||
# Webhook endpoint for receiving group member joined notifications from bridge-worker
|
|
||||||
# This is called when a user accepts the Signal group invitation
|
|
||||||
# Expected payload:
|
|
||||||
# {
|
|
||||||
# "event": "group_member_joined",
|
|
||||||
# "group_id": "group.base64encodedid",
|
|
||||||
# "member_phone": "+1234567890",
|
|
||||||
# "timestamp": "ISO8601 timestamp"
|
|
||||||
# }
|
|
||||||
def handle_group_member_joined
|
|
||||||
# Validate required parameters
|
|
||||||
errors = {}
|
|
||||||
errors['event'] = 'required' unless params[:event].present?
|
|
||||||
errors['group_id'] = 'required' unless params[:group_id].present?
|
|
||||||
errors['member_phone'] = 'required' unless params[:member_phone].present?
|
|
||||||
|
|
||||||
if errors.present?
|
|
||||||
render json: {
|
|
||||||
errors: errors
|
|
||||||
}, status: :bad_request
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
# Find ticket(s) with this group_id in preferences
|
|
||||||
# Use text search on preferences YAML for efficient lookup (prevents DoS from loading all tickets)
|
|
||||||
state_ids = Ticket::State.where(name: %w[closed merged removed]).pluck(:id)
|
|
||||||
|
|
||||||
ticket = Ticket.where.not(state_id: state_ids)
|
|
||||||
.where("preferences LIKE ?", "%chat_id: #{params[:group_id]}%")
|
|
||||||
.order(updated_at: :desc)
|
|
||||||
.first
|
|
||||||
|
|
||||||
unless ticket
|
|
||||||
Rails.logger.warn "Signal group member joined: Ticket not found for group_id #{params[:group_id]}"
|
|
||||||
render json: { error: 'Ticket not found for this group' }, status: :not_found
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
# Idempotency check: if already marked as joined, skip update and return success
|
|
||||||
# This prevents unnecessary database writes when the cron job sends duplicate notifications
|
|
||||||
if ticket.preferences.dig('cdr_signal', 'group_joined') == true
|
|
||||||
Rails.logger.debug "Signal group member #{params[:member_phone]} already marked as joined for group #{params[:group_id]} ticket #{ticket.id}, skipping update"
|
|
||||||
render json: {
|
|
||||||
success: true,
|
|
||||||
ticket_id: ticket.id,
|
|
||||||
ticket_number: ticket.number,
|
|
||||||
group_joined: true,
|
|
||||||
already_joined: true
|
|
||||||
}, status: :ok
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
# Update group_joined flag
|
|
||||||
member_phone = params[:member_phone]
|
|
||||||
ticket.preferences[:cdr_signal][:group_joined] = true
|
|
||||||
ticket.preferences[:cdr_signal][:group_joined_at] = params[:timestamp] if params[:timestamp].present?
|
|
||||||
ticket.preferences[:cdr_signal][:group_joined_by] = member_phone
|
|
||||||
|
|
||||||
ticket.save!
|
|
||||||
|
|
||||||
Rails.logger.info "Signal group member #{member_phone} joined group #{params[:group_id]} for ticket #{ticket.id}"
|
|
||||||
|
|
||||||
# Check if any articles had a group_not_joined notification and add resolution note
|
|
||||||
# Only add resolution note if we previously notified about the delivery issue
|
|
||||||
articles_with_pending_notification = Ticket::Article.where(ticket_id: ticket.id)
|
|
||||||
.where("preferences LIKE ?", "%group_not_joined_note_added: true%")
|
|
||||||
|
|
||||||
if articles_with_pending_notification.exists?
|
|
||||||
# Check if we already added a resolution note for this ticket
|
|
||||||
resolution_note_exists = Ticket::Article.where(ticket_id: ticket.id)
|
|
||||||
.where("preferences LIKE ?", "%group_joined_resolution: true%")
|
|
||||||
.exists?
|
|
||||||
|
|
||||||
unless resolution_note_exists
|
|
||||||
Ticket::Article.create(
|
|
||||||
ticket_id: ticket.id,
|
|
||||||
content_type: 'text/plain',
|
|
||||||
body: 'Recipient has now joined the Signal group. Pending messages will be delivered shortly.',
|
|
||||||
internal: true,
|
|
||||||
sender: Ticket::Article::Sender.find_by(name: 'System'),
|
|
||||||
type: Ticket::Article::Type.find_by(name: 'note'),
|
|
||||||
preferences: {
|
|
||||||
delivery_message: true,
|
|
||||||
group_joined_resolution: true,
|
|
||||||
},
|
|
||||||
updated_by_id: 1,
|
|
||||||
created_by_id: 1,
|
|
||||||
)
|
|
||||||
Rails.logger.info "Ticket ##{ticket.number}: Added resolution note about customer joining Signal group"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
render json: {
|
|
||||||
success: true,
|
|
||||||
ticket_id: ticket.id,
|
|
||||||
ticket_number: ticket.number,
|
|
||||||
group_joined: true
|
|
||||||
}, status: :ok
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -125,22 +125,8 @@ class ChannelsCdrWhatsappController < ApplicationController
|
||||||
channel = channel_for_bot_token(bot_token)
|
channel = channel_for_bot_token(bot_token)
|
||||||
return render json: { error: 'Channel not found' }, status: :not_found if !channel || !channel.active
|
return render json: { error: 'Channel not found' }, status: :not_found if !channel || !channel.active
|
||||||
|
|
||||||
# Normalize parameter names from bridge-whatsapp (camelCase) to Zammad (snake_case)
|
|
||||||
normalized_params = {
|
|
||||||
to: params[:to],
|
|
||||||
from: params[:from],
|
|
||||||
user_id: params[:userId] || params[:user_id],
|
|
||||||
message_id: params[:messageId] || params[:message_id],
|
|
||||||
sent_at: params[:sentAt] || params[:sent_at],
|
|
||||||
message: params[:message],
|
|
||||||
attachment: params[:attachment],
|
|
||||||
filename: params[:filename],
|
|
||||||
mime_type: params[:mimeType] || params[:mime_type]
|
|
||||||
}
|
|
||||||
|
|
||||||
# Use the channel's webhook token to reuse existing logic
|
# Use the channel's webhook token to reuse existing logic
|
||||||
params[:token] = channel.options[:token]
|
params[:token] = channel.options[:token]
|
||||||
normalized_params.each { |k, v| params[k] = v if v.present? }
|
|
||||||
|
|
||||||
webhook
|
webhook
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ class CommunicateCdrWhatsappJob < ApplicationJob
|
||||||
log_error(article,
|
log_error(article,
|
||||||
"Can't find ticket.preferences['cdr_whatsapp']['chat_id'] for Ticket.find(#{article.ticket_id})")
|
"Can't find ticket.preferences['cdr_whatsapp']['chat_id'] for Ticket.find(#{article.ticket_id})")
|
||||||
end
|
end
|
||||||
channel = ::CdrSignal.bot_by_bot_token(ticket.preferences['cdr_whatsapp']['bot_token'])
|
channel = ::CdrWhatsapp.bot_by_bot_token(ticket.preferences['cdr_whatsapp']['bot_token'])
|
||||||
channel ||= ::Channel.lookup(id: ticket.preferences['channel_id'])
|
channel ||= ::Channel.lookup(id: ticket.preferences['channel_id'])
|
||||||
unless channel
|
unless channel
|
||||||
log_error(article,
|
log_error(article,
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,10 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
# Configuration for direct Signal CLI REST API access
|
# Configuration for bridge-signal API access
|
||||||
# The SIGNAL_CLI_URL environment variable points to the signal-cli-rest-api container
|
# The BRIDGE_SIGNAL_URL environment variable points to the bridge-signal container
|
||||||
# Default: http://signal-cli-rest-api:8080
|
# Default: http://bridge-signal:5002
|
||||||
#
|
|
||||||
# This enables Zammad to poll for Signal messages directly without going through bridge-worker
|
|
||||||
|
|
||||||
Rails.application.config.after_initialize do
|
Rails.application.config.after_initialize do
|
||||||
signal_cli_url = ENV.fetch('SIGNAL_CLI_URL', 'http://signal-cli-rest-api:8080')
|
bridge_signal_url = ENV.fetch('BRIDGE_SIGNAL_URL', 'http://bridge-signal:5002')
|
||||||
Rails.logger.info "Signal CLI API URL configured: #{signal_cli_url}"
|
Rails.logger.info "Bridge Signal API URL configured: #{bridge_signal_url}"
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ Zammad::Application.routes.draw do
|
||||||
match "#{api_path}/channels_cdr_signal/:id", to: 'channels_cdr_signal#update', via: :put
|
match "#{api_path}/channels_cdr_signal/:id", to: 'channels_cdr_signal#update', via: :put
|
||||||
match "#{api_path}/channels_cdr_signal_webhook/:token", to: 'channels_cdr_signal#webhook', via: :post
|
match "#{api_path}/channels_cdr_signal_webhook/:token", to: 'channels_cdr_signal#webhook', via: :post
|
||||||
match "#{api_path}/channels_cdr_signal_webhook/:token/update_group", to: 'channels_cdr_signal#update_group', via: :post
|
match "#{api_path}/channels_cdr_signal_webhook/:token/update_group", to: 'channels_cdr_signal#update_group', via: :post
|
||||||
|
match "#{api_path}/channels_cdr_signal_bot_webhook/:id", to: 'channels_cdr_signal#bot_webhook', via: :post
|
||||||
|
match "#{api_path}/channels_cdr_signal_register/:id", to: 'channels_cdr_signal#register', via: :post
|
||||||
match "#{api_path}/channels_cdr_signal_disable", to: 'channels_cdr_signal#disable', via: :post
|
match "#{api_path}/channels_cdr_signal_disable", to: 'channels_cdr_signal#disable', via: :post
|
||||||
match "#{api_path}/channels_cdr_signal_enable", to: 'channels_cdr_signal#enable', via: :post
|
match "#{api_path}/channels_cdr_signal_enable", to: 'channels_cdr_signal#enable', via: :post
|
||||||
match "#{api_path}/channels_cdr_signal", to: 'channels_cdr_signal#destroy', via: :delete
|
match "#{api_path}/channels_cdr_signal", to: 'channels_cdr_signal#destroy', via: :delete
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class RemoveSignalSchedulers < ActiveRecord::Migration[5.2]
|
||||||
|
def self.up
|
||||||
|
# Remove polling schedulers -- bridge-signal now pushes messages via webhook
|
||||||
|
Scheduler.find_by(name: 'Fetch Signal messages')&.destroy
|
||||||
|
Scheduler.find_by(name: 'Check Signal group membership')&.destroy
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.down
|
||||||
|
# No-op: schedulers are no longer used
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -5,92 +5,8 @@ require 'cdr_signal_api'
|
||||||
class CdrSignal
|
class CdrSignal
|
||||||
attr_accessor :client
|
attr_accessor :client
|
||||||
|
|
||||||
#
|
|
||||||
# check token and return bot attributes of token
|
|
||||||
#
|
|
||||||
# bot = CdrSignal.check_token('token')
|
|
||||||
#
|
|
||||||
|
|
||||||
def self.check_token(phone_number)
|
|
||||||
api = CdrSignalApi.new
|
|
||||||
unless api.check_number(phone_number)
|
|
||||||
raise "Phone number #{phone_number} is not registered with Signal CLI"
|
|
||||||
end
|
|
||||||
{ 'id' => phone_number, 'number' => phone_number }
|
|
||||||
end
|
|
||||||
|
|
||||||
#
|
|
||||||
# create or update channel, store bot attributes and verify token
|
|
||||||
#
|
|
||||||
# channel = CdrSignal.create_or_update_channel('token', params)
|
|
||||||
#
|
|
||||||
# returns
|
|
||||||
#
|
|
||||||
# channel # instance of Channel
|
|
||||||
#
|
|
||||||
|
|
||||||
def self.create_or_update_channel(phone_number, params, channel = nil)
|
|
||||||
# verify phone number is registered with Signal CLI
|
|
||||||
bot = CdrSignal.check_token(phone_number)
|
|
||||||
|
|
||||||
raise 'Bot already exists!' unless channel && CdrSignal.bot_duplicate?(bot['id'])
|
|
||||||
|
|
||||||
raise 'Group needed!' if params[:group_id].blank?
|
|
||||||
|
|
||||||
group = Group.find_by(id: params[:group_id])
|
|
||||||
raise 'Group invalid!' unless group
|
|
||||||
|
|
||||||
unless channel
|
|
||||||
channel = CdrSignal.bot_by_bot_id(bot['id'])
|
|
||||||
channel ||= Channel.new
|
|
||||||
end
|
|
||||||
channel.area = 'Signal::Account'
|
|
||||||
channel.options = {
|
|
||||||
adapter: 'cdr_signal',
|
|
||||||
phone_number: phone_number,
|
|
||||||
welcome: params[:welcome]
|
|
||||||
}
|
|
||||||
channel.group_id = group.id
|
|
||||||
channel.active = true
|
|
||||||
channel.save!
|
|
||||||
channel
|
|
||||||
end
|
|
||||||
|
|
||||||
#
|
|
||||||
# check if bot already exists as channel
|
|
||||||
#
|
|
||||||
# success = CdrSignal.bot_duplicate?(bot_id)
|
|
||||||
#
|
|
||||||
# returns
|
|
||||||
#
|
|
||||||
# channel # instance of Channel
|
|
||||||
#
|
|
||||||
|
|
||||||
def self.bot_duplicate?(bot_id, channel_id = nil)
|
|
||||||
Channel.where(area: 'Signal::Account').each do |channel|
|
|
||||||
next unless channel.options
|
|
||||||
next unless channel.options[:bot]
|
|
||||||
next unless channel.options[:bot][:id]
|
|
||||||
next if channel.options[:bot][:id] != bot_id
|
|
||||||
next if channel.id.to_s == channel_id.to_s
|
|
||||||
|
|
||||||
return true
|
|
||||||
end
|
|
||||||
false
|
|
||||||
end
|
|
||||||
|
|
||||||
#
|
|
||||||
# get channel by bot_id
|
|
||||||
#
|
|
||||||
# channel = CdrSignal.bot_by_bot_id(bot_id)
|
|
||||||
#
|
|
||||||
# returns
|
|
||||||
#
|
|
||||||
# true|false
|
|
||||||
#
|
|
||||||
|
|
||||||
def self.bot_by_bot_token(bot_token)
|
def self.bot_by_bot_token(bot_token)
|
||||||
Channel.where(area: 'Signal::Account').each do |channel|
|
Channel.where(area: 'Signal::Number').each do |channel|
|
||||||
next unless channel.options
|
next unless channel.options
|
||||||
next unless channel.options[:bot_token]
|
next unless channel.options[:bot_token]
|
||||||
return channel if channel.options[:bot_token].to_s == bot_token.to_s
|
return channel if channel.options[:bot_token].to_s == bot_token.to_s
|
||||||
|
|
@ -98,14 +14,6 @@ class CdrSignal
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
#
|
|
||||||
# date = CdrSignal.timestamp_to_date('1543414973285')
|
|
||||||
#
|
|
||||||
# returns
|
|
||||||
#
|
|
||||||
# 2018-11-28T14:22:53.285Z
|
|
||||||
#
|
|
||||||
|
|
||||||
def self.timestamp_to_date(timestamp_str)
|
def self.timestamp_to_date(timestamp_str)
|
||||||
Time.at(timestamp_str.to_i).utc.to_datetime
|
Time.at(timestamp_str.to_i).utc.to_datetime
|
||||||
end
|
end
|
||||||
|
|
@ -114,32 +22,14 @@ class CdrSignal
|
||||||
format('%<from>s@%<timestamp>s', from: message_raw['from'], timestamp: message_raw['timestamp'])
|
format('%<from>s@%<timestamp>s', from: message_raw['from'], timestamp: message_raw['timestamp'])
|
||||||
end
|
end
|
||||||
|
|
||||||
#
|
|
||||||
# client = CdrSignal.new('token')
|
|
||||||
#
|
|
||||||
|
|
||||||
def initialize(phone_number)
|
def initialize(phone_number)
|
||||||
@phone_number = phone_number
|
@phone_number = phone_number
|
||||||
@api = CdrSignalApi.new
|
|
||||||
end
|
|
||||||
|
|
||||||
#
|
|
||||||
# client.send_message(chat_id, 'some message')
|
|
||||||
#
|
|
||||||
|
|
||||||
def send_message(recipient, message)
|
|
||||||
return if Rails.env.test?
|
|
||||||
|
|
||||||
@api.send_message(@phone_number, [recipient], message)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def user(number)
|
def user(number)
|
||||||
{
|
{
|
||||||
# id: params[:message][:from][:id],
|
|
||||||
id: number,
|
id: number,
|
||||||
username: number
|
username: number
|
||||||
# first_name: params[:message][:from][:first_name],
|
|
||||||
# last_name: params[:message][:from][:last_name]
|
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -147,10 +37,8 @@ class CdrSignal
|
||||||
Rails.logger.debug { 'Create user from message...' }
|
Rails.logger.debug { 'Create user from message...' }
|
||||||
Rails.logger.debug { message.inspect }
|
Rails.logger.debug { message.inspect }
|
||||||
|
|
||||||
# do message_user lookup
|
|
||||||
message_user = user(message[:source])
|
message_user = user(message[:source])
|
||||||
|
|
||||||
# create or update user
|
|
||||||
login = message_user[:username] || message_user[:id]
|
login = message_user[:username] || message_user[:id]
|
||||||
|
|
||||||
auth = Authorization.find_by(uid: message[:source], provider: 'cdr_signal')
|
auth = Authorization.find_by(uid: message[:source], provider: 'cdr_signal')
|
||||||
|
|
@ -177,7 +65,6 @@ class CdrSignal
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
# create or update authorization
|
|
||||||
auth_data = {
|
auth_data = {
|
||||||
uid: message_user[:id],
|
uid: message_user[:id],
|
||||||
username: login,
|
username: login,
|
||||||
|
|
@ -196,22 +83,13 @@ class CdrSignal
|
||||||
def to_ticket(message, user, group_id, channel)
|
def to_ticket(message, user, group_id, channel)
|
||||||
UserInfo.current_user_id = user.id
|
UserInfo.current_user_id = user.id
|
||||||
|
|
||||||
Rails.logger.debug { 'Create ticket from message...' }
|
|
||||||
Rails.logger.debug { message.inspect }
|
|
||||||
Rails.logger.debug { user.inspect }
|
|
||||||
Rails.logger.debug { group_id.inspect }
|
|
||||||
|
|
||||||
# prepare title
|
|
||||||
title = '-'
|
title = '-'
|
||||||
title = message[:message][:body] unless message[:message][:body].nil?
|
title = message[:message][:body] unless message[:message][:body].nil?
|
||||||
title = "#{title[0, 60]}..." if title.length > 60
|
title = "#{title[0, 60]}..." if title.length > 60
|
||||||
|
|
||||||
# find ticket or create one
|
|
||||||
state_ids = Ticket::State.where(name: %w[closed merged removed]).pluck(:id)
|
state_ids = Ticket::State.where(name: %w[closed merged removed]).pluck(:id)
|
||||||
ticket = Ticket.where(customer_id: user.id).where.not(state_id: state_ids).order(:updated_at).first
|
ticket = Ticket.where(customer_id: user.id).where.not(state_id: state_ids).order(:updated_at).first
|
||||||
if ticket
|
if ticket
|
||||||
|
|
||||||
# check if title need to be updated
|
|
||||||
ticket.title = title if ticket.title == '-'
|
ticket.title = title if ticket.title == '-'
|
||||||
new_state = Ticket::State.find_by(default_create: true)
|
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
|
ticket.state = Ticket::State.find_by(default_follow_up: true) if ticket.state_id != new_state.id
|
||||||
|
|
@ -238,16 +116,11 @@ class CdrSignal
|
||||||
end
|
end
|
||||||
|
|
||||||
def to_article(message, user, ticket, channel)
|
def to_article(message, user, ticket, channel)
|
||||||
Rails.logger.debug { 'Create article from message...' }
|
|
||||||
Rails.logger.debug { message.inspect }
|
|
||||||
Rails.logger.debug { user.inspect }
|
|
||||||
Rails.logger.debug { ticket.inspect }
|
|
||||||
|
|
||||||
UserInfo.current_user_id = user.id
|
UserInfo.current_user_id = user.id
|
||||||
|
|
||||||
article = Ticket::Article.new(
|
article = Ticket::Article.new(
|
||||||
from: message[:source],
|
from: message[:source],
|
||||||
to: channel[:options][:bot][:number],
|
to: channel[:options][:phone_number],
|
||||||
body: message[:message][:body],
|
body: message[:message][:body],
|
||||||
content_type: 'text/plain',
|
content_type: 'text/plain',
|
||||||
message_id: "cdr_signal.#{message[:id]}",
|
message_id: "cdr_signal.#{message[:id]}",
|
||||||
|
|
@ -264,12 +137,7 @@ class CdrSignal
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# TODO: attachments
|
|
||||||
# TODO voice
|
|
||||||
# TODO emojis
|
|
||||||
#
|
|
||||||
if message[:message][:body]
|
if message[:message][:body]
|
||||||
Rails.logger.debug { article.inspect }
|
|
||||||
article.save!
|
article.save!
|
||||||
|
|
||||||
Store.remove(
|
Store.remove(
|
||||||
|
|
@ -283,15 +151,11 @@ class CdrSignal
|
||||||
end
|
end
|
||||||
|
|
||||||
def to_group(message, group_id, channel)
|
def to_group(message, group_id, channel)
|
||||||
# begin import
|
|
||||||
Rails.logger.debug { 'signal import message' }
|
Rails.logger.debug { 'signal import message' }
|
||||||
|
|
||||||
# TODO: handle messages in group chats
|
|
||||||
|
|
||||||
return if Ticket::Article.find_by(message_id: message[:id])
|
return if Ticket::Article.find_by(message_id: message[:id])
|
||||||
|
|
||||||
ticket = nil
|
ticket = nil
|
||||||
# use transaction
|
|
||||||
Transaction.execute(reset_user_id: true) do
|
Transaction.execute(reset_user_id: true) do
|
||||||
user = to_user(message)
|
user = to_user(message)
|
||||||
ticket = to_ticket(message, user, group_id, channel)
|
ticket = to_ticket(message, user, group_id, channel)
|
||||||
|
|
@ -302,25 +166,20 @@ class CdrSignal
|
||||||
end
|
end
|
||||||
|
|
||||||
def from_article(article)
|
def from_article(article)
|
||||||
# sends a message from a zammad article using direct Signal CLI API
|
|
||||||
|
|
||||||
Rails.logger.debug { "Create signal message from article..." }
|
Rails.logger.debug { "Create signal message from article..." }
|
||||||
|
|
||||||
# Get the recipient from ticket preferences
|
|
||||||
ticket = Ticket.find_by(id: article.ticket_id)
|
ticket = Ticket.find_by(id: article.ticket_id)
|
||||||
raise "No ticket found for article #{article.id}" unless ticket
|
raise "No ticket found for article #{article.id}" unless ticket
|
||||||
|
|
||||||
# Get channel to find the bot phone number
|
|
||||||
channel = Channel.find_by(id: ticket.preferences[:channel_id])
|
channel = Channel.find_by(id: ticket.preferences[:channel_id])
|
||||||
raise "No channel found for ticket #{ticket.id}" unless channel
|
raise "No channel found for ticket #{ticket.id}" unless channel
|
||||||
|
|
||||||
bot_phone_number = channel.options[:phone_number]
|
bot_id = channel.options[:bot_token]
|
||||||
raise "No phone number configured for channel #{channel.id}" unless bot_phone_number
|
raise "No bot_token configured for channel #{channel.id}" unless bot_id
|
||||||
|
|
||||||
recipient = ticket.preferences.dig('cdr_signal', 'chat_id')
|
recipient = ticket.preferences.dig('cdr_signal', 'chat_id')
|
||||||
enable_auto_groups = ENV['BRIDGE_SIGNAL_AUTO_GROUPS'].to_s.downcase == 'true'
|
enable_auto_groups = ENV['BRIDGE_SIGNAL_AUTO_GROUPS'].to_s.downcase == 'true'
|
||||||
|
|
||||||
# If auto-groups is enabled and no chat_id, use original_recipient
|
|
||||||
if recipient.blank? && enable_auto_groups
|
if recipient.blank? && enable_auto_groups
|
||||||
recipient = ticket.preferences.dig('cdr_signal', 'original_recipient')
|
recipient = ticket.preferences.dig('cdr_signal', 'original_recipient')
|
||||||
raise "No Signal chat_id or original_recipient found in ticket preferences" unless recipient
|
raise "No Signal chat_id or original_recipient found in ticket preferences" unless recipient
|
||||||
|
|
@ -328,80 +187,45 @@ class CdrSignal
|
||||||
raise "No Signal chat_id found in ticket preferences"
|
raise "No Signal chat_id found in ticket preferences"
|
||||||
end
|
end
|
||||||
|
|
||||||
Rails.logger.debug { "Sending to recipient: '#{recipient}'" }
|
|
||||||
|
|
||||||
# Use Signal CLI API
|
|
||||||
api = CdrSignalApi.new
|
api = CdrSignalApi.new
|
||||||
|
|
||||||
# Check if we need to create a group (auto-groups enabled, recipient is not already a group)
|
|
||||||
is_group_id = recipient.start_with?('group.')
|
|
||||||
final_recipient = recipient
|
|
||||||
|
|
||||||
if enable_auto_groups && !is_group_id && recipient.present?
|
|
||||||
# Create a group for this conversation
|
|
||||||
begin
|
|
||||||
group_name = "Support Request: #{ticket.number}"
|
|
||||||
|
|
||||||
Rails.logger.info "Creating Signal group '#{group_name}' for ticket ##{ticket.number}"
|
|
||||||
|
|
||||||
create_result = api.create_group(
|
|
||||||
bot_phone_number,
|
|
||||||
name: group_name,
|
|
||||||
members: [recipient],
|
|
||||||
description: 'Private support conversation'
|
|
||||||
)
|
|
||||||
|
|
||||||
if create_result['id'].present?
|
|
||||||
final_recipient = create_result['id']
|
|
||||||
|
|
||||||
# Update ticket preferences with the new group ID
|
|
||||||
ticket.preferences[:cdr_signal] ||= {}
|
|
||||||
ticket.preferences[:cdr_signal][:chat_id] = final_recipient
|
|
||||||
ticket.preferences[:cdr_signal][:original_recipient] = recipient
|
|
||||||
ticket.preferences[:cdr_signal][:group_joined] = false
|
|
||||||
ticket.preferences[:cdr_signal][:group_created_at] = Time.current.iso8601
|
|
||||||
ticket.save!
|
|
||||||
|
|
||||||
Rails.logger.info "Created Signal group #{final_recipient} for ticket ##{ticket.number}"
|
|
||||||
end
|
|
||||||
rescue StandardError => e
|
|
||||||
Rails.logger.error "Failed to create Signal group: #{e.message}"
|
|
||||||
# Continue with original recipient if group creation fails
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Get attachments from the article
|
|
||||||
options = {}
|
options = {}
|
||||||
|
|
||||||
|
# Encode attachments
|
||||||
attachments = Store.list(object: 'Ticket::Article', o_id: article.id)
|
attachments = Store.list(object: 'Ticket::Article', o_id: article.id)
|
||||||
if attachments.any?
|
if attachments.any?
|
||||||
attachment_data = attachments.map do |attachment|
|
options[:attachments] = attachments.map do |a|
|
||||||
{
|
{
|
||||||
data: Base64.strict_encode64(attachment.content),
|
data: Base64.strict_encode64(a.content),
|
||||||
filename: attachment.filename,
|
filename: a.filename,
|
||||||
mime_type: attachment.preferences['Mime-Type'] || attachment.preferences['Content-Type'] || 'application/octet-stream'
|
mime_type: a.preferences['Mime-Type'] || a.preferences['Content-Type'] || 'application/octet-stream'
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
options[:attachments] = attachment_data
|
|
||||||
Rails.logger.debug { "Sending #{attachment_data.length} attachment(s) with message" }
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Send the message via direct Signal CLI API
|
# Auto-group: let bridge handle it
|
||||||
result = api.send_message(bot_phone_number, [final_recipient], article[:body], options)
|
if enable_auto_groups && !recipient&.start_with?('group.')
|
||||||
|
recipient = ticket.preferences.dig('cdr_signal', 'original_recipient') || recipient
|
||||||
Rails.logger.info "Sent Signal message to #{final_recipient}"
|
options[:auto_group] = { ticketNumber: ticket.number.to_s }
|
||||||
|
|
||||||
# Update group name if needed (for consistency)
|
|
||||||
if final_recipient.start_with?('group.')
|
|
||||||
expected_name = "Support Request: #{ticket.number}"
|
|
||||||
api.update_group(bot_phone_number, final_recipient, name: expected_name)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Return result in expected format
|
result = api.send_message(bot_id, recipient, article[:body], options)
|
||||||
|
send_result = result.is_a?(Hash) ? result['result'] : nil
|
||||||
|
|
||||||
|
# If bridge created a group, update ticket preferences
|
||||||
|
if send_result.is_a?(Hash) && send_result['groupId'].present?
|
||||||
|
ticket.preferences[:cdr_signal] ||= {}
|
||||||
|
ticket.preferences[:cdr_signal][:chat_id] = send_result['groupId']
|
||||||
|
ticket.preferences[:cdr_signal][:group_joined] = false
|
||||||
|
ticket.preferences[:cdr_signal][:group_created_at] = Time.current.iso8601
|
||||||
|
ticket.save!
|
||||||
|
end
|
||||||
|
|
||||||
|
# Return in expected format
|
||||||
{
|
{
|
||||||
'result' => {
|
'result' => {
|
||||||
'to' => final_recipient,
|
'to' => send_result&.dig('recipient') || recipient,
|
||||||
'from' => bot_phone_number,
|
'from' => send_result&.dig('source') || bot_id,
|
||||||
'timestamp' => result['timestamp'] || Time.current.to_i * 1000
|
'timestamp' => send_result&.dig('timestamp') || Time.current.to_i * 1000
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,148 +1,95 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
require 'json'
|
require 'json'
|
||||||
require 'net/http'
|
|
||||||
require 'net/https'
|
|
||||||
require 'uri'
|
|
||||||
|
|
||||||
# Direct Signal CLI API client for communicating with signal-cli-rest-api
|
# Bridge-signal API client
|
||||||
# All Signal operations go through this single class
|
# Communicates with the bridge-signal Node.js service (replaces signal-cli-rest-api)
|
||||||
class CdrSignalApi
|
class CdrSignalApi
|
||||||
def initialize(base_url = nil)
|
def initialize(base_url = nil)
|
||||||
@base_url = base_url || ENV.fetch('SIGNAL_CLI_URL', 'http://signal-cli-rest-api:8080')
|
@base_url = base_url || ENV.fetch('BRIDGE_SIGNAL_URL', 'http://bridge-signal:5002')
|
||||||
end
|
end
|
||||||
|
|
||||||
# Fetch pending messages for a phone number
|
# Register/link a phone number
|
||||||
# GET /v1/receive/{number}
|
def register(bot_id, phone_number, device_name = 'Zammad')
|
||||||
def fetch_messages(phone_number)
|
post("/api/bots/#{bot_id}/register", { phoneNumber: phone_number, deviceName: device_name })
|
||||||
url = "#{@base_url}/v1/receive/#{CGI.escape(phone_number)}"
|
end
|
||||||
|
|
||||||
|
# Get bot status
|
||||||
|
def get_bot(bot_id)
|
||||||
|
get("/api/bots/#{bot_id}")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Unregister a bot
|
||||||
|
def unregister(bot_id)
|
||||||
|
post("/api/bots/#{bot_id}/unregister", {})
|
||||||
|
end
|
||||||
|
|
||||||
|
# Send a message (with optional auto-group)
|
||||||
|
def send_message(bot_id, recipient, message, options = {})
|
||||||
|
data = { recipient: recipient, message: message }
|
||||||
|
data[:attachments] = options[:attachments] if options[:attachments].present?
|
||||||
|
data[:autoGroup] = options[:auto_group] if options[:auto_group].present?
|
||||||
|
post("/api/bots/#{bot_id}/send", data)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Group operations
|
||||||
|
def create_group(bot_id, name:, members:, description: nil)
|
||||||
|
post("/api/bots/#{bot_id}/groups", { name: name, members: members, description: description }.compact)
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_group(bot_id, group_id, name: nil, description: nil)
|
||||||
|
put("/api/bots/#{bot_id}/groups/#{CGI.escape(group_id)}", { name: name, description: description }.compact)
|
||||||
|
end
|
||||||
|
|
||||||
|
def list_groups(bot_id)
|
||||||
|
get("/api/bots/#{bot_id}/groups")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
def health
|
||||||
|
get('/api/health')
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def get(path)
|
||||||
|
url = "#{@base_url}#{path}"
|
||||||
response = Faraday.get(url, nil, { 'Accept' => 'application/json' })
|
response = Faraday.get(url, nil, { 'Accept' => 'application/json' })
|
||||||
return [] unless response.success?
|
|
||||||
|
unless response.success?
|
||||||
|
Rails.logger.error "CdrSignalApi: GET #{path} failed: #{response.status} #{response.body}"
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
JSON.parse(response.body)
|
JSON.parse(response.body)
|
||||||
rescue JSON::ParserError, Faraday::Error => e
|
rescue JSON::ParserError, Faraday::Error => e
|
||||||
Rails.logger.error "CdrSignalApi: Failed to fetch messages for #{phone_number}: #{e.message}"
|
Rails.logger.error "CdrSignalApi: GET #{path} error: #{e.message}"
|
||||||
[]
|
|
||||||
end
|
|
||||||
|
|
||||||
# Fetch an attachment by ID
|
|
||||||
# GET /v1/attachments/{id}
|
|
||||||
def fetch_attachment(attachment_id)
|
|
||||||
url = "#{@base_url}/v1/attachments/#{CGI.escape(attachment_id)}"
|
|
||||||
response = Faraday.get(url)
|
|
||||||
return nil unless response.success?
|
|
||||||
|
|
||||||
response.body
|
|
||||||
rescue Faraday::Error => e
|
|
||||||
Rails.logger.error "CdrSignalApi: Failed to fetch attachment #{attachment_id}: #{e.message}"
|
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
# List all groups for a phone number
|
def post(path, data)
|
||||||
# GET /v1/groups/{number}
|
url = "#{@base_url}#{path}"
|
||||||
def list_groups(phone_number)
|
|
||||||
url = "#{@base_url}/v1/groups/#{CGI.escape(phone_number)}"
|
|
||||||
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 "CdrSignalApi: Failed to list groups for #{phone_number}: #{e.message}"
|
|
||||||
[]
|
|
||||||
end
|
|
||||||
|
|
||||||
# Check if a phone number is registered with signal-cli
|
|
||||||
# GET /v1/about
|
|
||||||
def check_number(phone_number)
|
|
||||||
# Verify we can connect to signal-cli-rest-api
|
|
||||||
url = "#{@base_url}/v1/about"
|
|
||||||
response = Faraday.get(url, nil, { 'Accept' => 'application/json' })
|
|
||||||
return false unless response.success?
|
|
||||||
|
|
||||||
# Try to list groups for this number to verify it's registered
|
|
||||||
groups_url = "#{@base_url}/v1/groups/#{CGI.escape(phone_number)}"
|
|
||||||
groups_response = Faraday.get(groups_url, nil, { 'Accept' => 'application/json' })
|
|
||||||
groups_response.success?
|
|
||||||
rescue Faraday::Error => e
|
|
||||||
Rails.logger.error "CdrSignalApi: Failed to check number #{phone_number}: #{e.message}"
|
|
||||||
false
|
|
||||||
end
|
|
||||||
|
|
||||||
# Send a message via Signal CLI
|
|
||||||
# POST /v2/send
|
|
||||||
def send_message(from_number, recipients, message, options = {})
|
|
||||||
url = "#{@base_url}/v2/send"
|
|
||||||
|
|
||||||
data = {
|
|
||||||
number: from_number,
|
|
||||||
recipients: Array(recipients),
|
|
||||||
message: message
|
|
||||||
}
|
|
||||||
|
|
||||||
# Add base64 attachments if provided
|
|
||||||
if options[:attachments].present?
|
|
||||||
data[:base64Attachments] = options[:attachments].map { |a| a[:data] }
|
|
||||||
end
|
|
||||||
|
|
||||||
# Add quote parameters if provided
|
|
||||||
if options[:quote_timestamp] && options[:quote_author] && options[:quote_message]
|
|
||||||
data[:quoteTimestamp] = options[:quote_timestamp]
|
|
||||||
data[:quoteAuthor] = options[:quote_author]
|
|
||||||
data[:quoteMessage] = options[:quote_message]
|
|
||||||
end
|
|
||||||
|
|
||||||
response = Faraday.post(url, data.to_json, {
|
response = Faraday.post(url, data.to_json, {
|
||||||
'Content-Type' => 'application/json',
|
'Content-Type' => 'application/json',
|
||||||
'Accept' => 'application/json'
|
'Accept' => 'application/json'
|
||||||
})
|
})
|
||||||
|
|
||||||
unless response.success?
|
unless response.success?
|
||||||
Rails.logger.error "CdrSignalApi: Failed to send message: #{response.status} #{response.body}"
|
Rails.logger.error "CdrSignalApi: POST #{path} failed: #{response.status} #{response.body}"
|
||||||
raise "Failed to send Signal message: #{response.status}"
|
raise "Bridge-signal request failed: #{response.status}"
|
||||||
end
|
end
|
||||||
|
|
||||||
JSON.parse(response.body)
|
JSON.parse(response.body)
|
||||||
|
rescue JSON::ParserError => e
|
||||||
|
# POST may return empty body on success
|
||||||
|
nil
|
||||||
rescue Faraday::Error => e
|
rescue Faraday::Error => e
|
||||||
Rails.logger.error "CdrSignalApi: Failed to send message: #{e.message}"
|
Rails.logger.error "CdrSignalApi: POST #{path} error: #{e.message}"
|
||||||
raise "Failed to send Signal message: #{e.message}"
|
raise "Bridge-signal request failed: #{e.message}"
|
||||||
end
|
end
|
||||||
|
|
||||||
# Create a new Signal group
|
def put(path, data)
|
||||||
# POST /v1/groups/{number}
|
url = "#{@base_url}#{path}"
|
||||||
def create_group(phone_number, name:, members:, description: nil)
|
|
||||||
url = "#{@base_url}/v1/groups/#{CGI.escape(phone_number)}"
|
|
||||||
|
|
||||||
data = {
|
|
||||||
name: name,
|
|
||||||
members: Array(members),
|
|
||||||
description: description
|
|
||||||
}.compact
|
|
||||||
|
|
||||||
response = Faraday.post(url, data.to_json, {
|
|
||||||
'Content-Type' => 'application/json',
|
|
||||||
'Accept' => 'application/json'
|
|
||||||
})
|
|
||||||
|
|
||||||
unless response.success?
|
|
||||||
Rails.logger.error "CdrSignalApi: Failed to create group: #{response.status} #{response.body}"
|
|
||||||
raise "Failed to create Signal group: #{response.status}"
|
|
||||||
end
|
|
||||||
|
|
||||||
JSON.parse(response.body)
|
|
||||||
rescue Faraday::Error => e
|
|
||||||
Rails.logger.error "CdrSignalApi: Failed to create group: #{e.message}"
|
|
||||||
raise "Failed to create Signal group: #{e.message}"
|
|
||||||
end
|
|
||||||
|
|
||||||
# Update a Signal group
|
|
||||||
# PUT /v1/groups/{number}/{groupId}
|
|
||||||
def update_group(phone_number, group_id, name: nil, description: nil)
|
|
||||||
url = "#{@base_url}/v1/groups/#{CGI.escape(phone_number)}/#{CGI.escape(group_id)}"
|
|
||||||
|
|
||||||
data = {}
|
|
||||||
data[:name] = name if name.present?
|
|
||||||
data[:description] = description if description.present?
|
|
||||||
|
|
||||||
response = Faraday.put(url, data.to_json, {
|
response = Faraday.put(url, data.to_json, {
|
||||||
'Content-Type' => 'application/json',
|
'Content-Type' => 'application/json',
|
||||||
'Accept' => 'application/json'
|
'Accept' => 'application/json'
|
||||||
|
|
@ -150,7 +97,7 @@ class CdrSignalApi
|
||||||
|
|
||||||
response.success?
|
response.success?
|
||||||
rescue Faraday::Error => e
|
rescue Faraday::Error => e
|
||||||
Rails.logger.error "CdrSignalApi: Failed to update group: #{e.message}"
|
Rails.logger.error "CdrSignalApi: PUT #{path} error: #{e.message}"
|
||||||
false
|
false
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,428 +0,0 @@
|
||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
# CdrSignalPoller handles polling Signal CLI for incoming messages and group membership changes.
|
|
||||||
# This replaces the bridge-worker tasks:
|
|
||||||
# - fetch-signal-messages.ts
|
|
||||||
# - check-group-membership.ts
|
|
||||||
#
|
|
||||||
# It runs via Zammad schedulers to poll at regular intervals.
|
|
||||||
|
|
||||||
class CdrSignalPoller
|
|
||||||
class << self
|
|
||||||
# Fetch messages from all active Signal channels
|
|
||||||
# This is called by the scheduler every 30 seconds
|
|
||||||
def fetch_messages
|
|
||||||
api = CdrSignalApi.new
|
|
||||||
channels = Channel.where(area: 'Signal::Number', active: true)
|
|
||||||
|
|
||||||
channels.each do |channel|
|
|
||||||
phone_number = channel.options[:phone_number]
|
|
||||||
bot_token = channel.options[:bot_token]
|
|
||||||
next unless phone_number.present?
|
|
||||||
|
|
||||||
Rails.logger.debug { "CdrSignalPoller: Fetching messages for #{phone_number}" }
|
|
||||||
|
|
||||||
messages = api.fetch_messages(phone_number)
|
|
||||||
process_messages(channel, messages, api)
|
|
||||||
rescue StandardError => e
|
|
||||||
Rails.logger.error "CdrSignalPoller: Error fetching messages for #{phone_number}: #{e.message}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Check group membership for all active Signal channels
|
|
||||||
# This is called by the scheduler every 2 minutes
|
|
||||||
def check_group_membership
|
|
||||||
api = CdrSignalApi.new
|
|
||||||
channels = Channel.where(area: 'Signal::Number', active: true)
|
|
||||||
|
|
||||||
channels.each do |channel|
|
|
||||||
phone_number = channel.options[:phone_number]
|
|
||||||
next unless phone_number.present?
|
|
||||||
|
|
||||||
Rails.logger.debug { "CdrSignalPoller: Checking groups for #{phone_number}" }
|
|
||||||
|
|
||||||
groups = api.list_groups(phone_number)
|
|
||||||
process_group_membership(channel, groups)
|
|
||||||
rescue StandardError => e
|
|
||||||
Rails.logger.error "CdrSignalPoller: Error checking groups for #{phone_number}: #{e.message}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def process_messages(channel, messages, api)
|
|
||||||
messages.each do |msg|
|
|
||||||
envelope = msg['envelope']
|
|
||||||
next unless envelope
|
|
||||||
|
|
||||||
source = envelope['source']
|
|
||||||
source_uuid = envelope['sourceUuid']
|
|
||||||
data_message = envelope['dataMessage']
|
|
||||||
sync_message = envelope['syncMessage']
|
|
||||||
|
|
||||||
# Log envelope types for debugging
|
|
||||||
Rails.logger.debug do
|
|
||||||
"CdrSignalPoller: Received envelope - source: #{source}, uuid: #{source_uuid}, " \
|
|
||||||
"dataMessage: #{data_message.present?}, syncMessage: #{sync_message.present?}"
|
|
||||||
end
|
|
||||||
|
|
||||||
# Handle group join events from groupInfo
|
|
||||||
if data_message && data_message['groupInfo']
|
|
||||||
handle_group_info_event(channel, data_message['groupInfo'], source)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Process data messages with content
|
|
||||||
next unless data_message
|
|
||||||
|
|
||||||
process_data_message(channel, data_message, source, source_uuid, api)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_group_info_event(channel, group_info, source)
|
|
||||||
type = group_info['type']
|
|
||||||
return unless %w[JOIN JOINED].include?(type)
|
|
||||||
|
|
||||||
group_id_raw = group_info['groupId']
|
|
||||||
return unless group_id_raw
|
|
||||||
|
|
||||||
group_id = "group.#{Base64.strict_encode64(group_id_raw.pack('c*'))}"
|
|
||||||
|
|
||||||
Rails.logger.info "CdrSignalPoller: User #{source} joined group #{group_id}"
|
|
||||||
notify_group_member_joined(channel, group_id, source)
|
|
||||||
end
|
|
||||||
|
|
||||||
def process_data_message(channel, data_message, source, source_uuid, api)
|
|
||||||
# Determine if this is a group message
|
|
||||||
is_group = data_message['groupV2'].present? ||
|
|
||||||
data_message['groupContext'].present? ||
|
|
||||||
data_message['groupInfo'].present?
|
|
||||||
|
|
||||||
# Get group ID if applicable
|
|
||||||
group_id_raw = data_message.dig('groupV2', 'id') ||
|
|
||||||
data_message.dig('groupContext', 'id') ||
|
|
||||||
data_message.dig('groupInfo', 'groupId')
|
|
||||||
|
|
||||||
phone_number = channel.options[:phone_number]
|
|
||||||
to_recipient = if group_id_raw
|
|
||||||
"group.#{Base64.strict_encode64(group_id_raw.is_a?(Array) ? group_id_raw.pack('c*') : group_id_raw)}"
|
|
||||||
else
|
|
||||||
phone_number
|
|
||||||
end
|
|
||||||
|
|
||||||
# Skip if message is from self
|
|
||||||
return if source == phone_number
|
|
||||||
|
|
||||||
message_text = data_message['message']
|
|
||||||
raw_timestamp = data_message['timestamp']
|
|
||||||
attachments = data_message['attachments']
|
|
||||||
|
|
||||||
# Generate unique message ID
|
|
||||||
message_id = "#{source_uuid}-#{raw_timestamp}"
|
|
||||||
|
|
||||||
# Check for duplicate
|
|
||||||
return if Ticket::Article.exists?(message_id: "cdr_signal.#{message_id}")
|
|
||||||
|
|
||||||
# Fetch and encode attachments
|
|
||||||
attachment_data = fetch_attachments(attachments, api)
|
|
||||||
|
|
||||||
# Process the message through the webhook handler
|
|
||||||
process_incoming_message(
|
|
||||||
channel: channel,
|
|
||||||
to: to_recipient,
|
|
||||||
from: source,
|
|
||||||
user_id: source_uuid,
|
|
||||||
message_id: message_id,
|
|
||||||
message: message_text,
|
|
||||||
sent_at: raw_timestamp ? Time.at(raw_timestamp / 1000).iso8601 : Time.current.iso8601,
|
|
||||||
attachments: attachment_data,
|
|
||||||
is_group: is_group
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def fetch_attachments(attachments, api)
|
|
||||||
return [] unless attachments.is_a?(Array)
|
|
||||||
|
|
||||||
attachments.filter_map do |att|
|
|
||||||
id = att['id']
|
|
||||||
content_type = att['contentType']
|
|
||||||
filename = att['filename']
|
|
||||||
|
|
||||||
blob = api.fetch_attachment(id)
|
|
||||||
next unless blob
|
|
||||||
|
|
||||||
# Generate filename if not provided
|
|
||||||
default_filename = filename
|
|
||||||
unless default_filename
|
|
||||||
extension = content_type&.split('/')&.last || 'bin'
|
|
||||||
default_filename = id.include?('.') ? id : "#{id}.#{extension}"
|
|
||||||
end
|
|
||||||
|
|
||||||
{
|
|
||||||
filename: default_filename,
|
|
||||||
mime_type: content_type,
|
|
||||||
data: Base64.strict_encode64(blob)
|
|
||||||
}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def process_incoming_message(channel:, to:, from:, user_id:, message_id:, message:, sent_at:, attachments:, is_group:)
|
|
||||||
# Find or create customer
|
|
||||||
customer = find_or_create_customer(from, user_id)
|
|
||||||
return unless customer
|
|
||||||
|
|
||||||
# Set current user context
|
|
||||||
UserInfo.current_user_id = customer.id
|
|
||||||
|
|
||||||
# Find or create ticket
|
|
||||||
ticket = find_or_create_ticket(
|
|
||||||
channel: channel,
|
|
||||||
customer: customer,
|
|
||||||
to: to,
|
|
||||||
from: from,
|
|
||||||
user_id: user_id,
|
|
||||||
is_group: is_group,
|
|
||||||
sent_at: sent_at
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create article
|
|
||||||
create_article(
|
|
||||||
ticket: ticket,
|
|
||||||
from: from,
|
|
||||||
to: to,
|
|
||||||
user_id: user_id,
|
|
||||||
message_id: message_id,
|
|
||||||
message: message || 'No text content',
|
|
||||||
sent_at: sent_at,
|
|
||||||
attachments: attachments
|
|
||||||
)
|
|
||||||
|
|
||||||
Rails.logger.info "CdrSignalPoller: Created article for ticket ##{ticket.number} from #{from}"
|
|
||||||
end
|
|
||||||
|
|
||||||
def find_or_create_customer(phone_number, user_id)
|
|
||||||
# Try phone number first
|
|
||||||
customer = User.find_by(phone: phone_number) if phone_number.present?
|
|
||||||
customer ||= User.find_by(mobile: phone_number) if phone_number.present?
|
|
||||||
|
|
||||||
# Try user ID
|
|
||||||
if customer.nil? && user_id.present?
|
|
||||||
customer = User.find_by(signal_uid: user_id)
|
|
||||||
customer ||= User.find_by(phone: user_id)
|
|
||||||
customer ||= User.find_by(mobile: user_id)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Create new customer if not found
|
|
||||||
unless customer
|
|
||||||
role_ids = Role.signup_role_ids
|
|
||||||
customer = User.create!(
|
|
||||||
firstname: '',
|
|
||||||
lastname: '',
|
|
||||||
email: '',
|
|
||||||
password: '',
|
|
||||||
phone: phone_number.presence || user_id,
|
|
||||||
signal_uid: user_id,
|
|
||||||
note: 'CDR Signal',
|
|
||||||
active: true,
|
|
||||||
role_ids: role_ids,
|
|
||||||
updated_by_id: 1,
|
|
||||||
created_by_id: 1
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Update signal_uid if needed
|
|
||||||
customer.update!(signal_uid: user_id) if user_id.present? && customer.signal_uid.blank?
|
|
||||||
|
|
||||||
# Update phone if customer only has user_id
|
|
||||||
customer.update!(phone: phone_number) if phone_number.present? && customer.phone == user_id
|
|
||||||
|
|
||||||
customer
|
|
||||||
end
|
|
||||||
|
|
||||||
def find_or_create_ticket(channel:, customer:, to:, from:, user_id:, is_group:, sent_at:)
|
|
||||||
state_ids = Ticket::State.where(name: %w[closed merged removed]).pluck(:id)
|
|
||||||
sender_display = from.presence || user_id
|
|
||||||
|
|
||||||
if is_group
|
|
||||||
# Find ticket by group ID
|
|
||||||
ticket = Ticket.where.not(state_id: state_ids)
|
|
||||||
.where('preferences LIKE ?', "%channel_id: #{channel.id}%")
|
|
||||||
.where('preferences LIKE ?', "%chat_id: #{to}%")
|
|
||||||
.order(updated_at: :desc)
|
|
||||||
.first
|
|
||||||
else
|
|
||||||
# Find ticket by customer
|
|
||||||
ticket = Ticket.where(customer_id: customer.id)
|
|
||||||
.where.not(state_id: state_ids)
|
|
||||||
.order(:updated_at)
|
|
||||||
.first
|
|
||||||
end
|
|
||||||
|
|
||||||
if ticket
|
|
||||||
ticket.title = "Message from #{sender_display} at #{sent_at}" 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
|
|
||||||
chat_id = is_group ? to : (user_id.presence || from)
|
|
||||||
|
|
||||||
cdr_signal_prefs = {
|
|
||||||
bot_token: channel.options[:bot_token],
|
|
||||||
chat_id: chat_id,
|
|
||||||
user_id: user_id
|
|
||||||
}
|
|
||||||
cdr_signal_prefs[:original_recipient] = from if is_group
|
|
||||||
|
|
||||||
ticket = Ticket.new(
|
|
||||||
group_id: channel.group_id,
|
|
||||||
title: "Message from #{sender_display} at #{sent_at}",
|
|
||||||
customer_id: customer.id,
|
|
||||||
preferences: {
|
|
||||||
channel_id: channel.id,
|
|
||||||
cdr_signal: cdr_signal_prefs
|
|
||||||
}
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
ticket.save!
|
|
||||||
ticket.update!(create_article_type_id: Ticket::Article::Type.find_by(name: 'cdr_signal').id)
|
|
||||||
ticket
|
|
||||||
end
|
|
||||||
|
|
||||||
def create_article(ticket:, from:, to:, user_id:, message_id:, message:, sent_at:, attachments:)
|
|
||||||
sender_display = from.presence || user_id
|
|
||||||
|
|
||||||
article_params = {
|
|
||||||
ticket_id: ticket.id,
|
|
||||||
type_id: Ticket::Article::Type.find_by(name: 'cdr_signal').id,
|
|
||||||
sender_id: Ticket::Article::Sender.find_by(name: 'Customer').id,
|
|
||||||
from: sender_display,
|
|
||||||
to: to,
|
|
||||||
subject: "Message from #{sender_display} at #{sent_at}",
|
|
||||||
body: message,
|
|
||||||
content_type: 'text/plain',
|
|
||||||
message_id: "cdr_signal.#{message_id}",
|
|
||||||
internal: false,
|
|
||||||
preferences: {
|
|
||||||
cdr_signal: {
|
|
||||||
timestamp: sent_at,
|
|
||||||
message_id: message_id,
|
|
||||||
from: from,
|
|
||||||
user_id: user_id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Add primary attachment if present
|
|
||||||
if attachments.present? && attachments.first
|
|
||||||
primary = attachments.first
|
|
||||||
article_params[:attachments] = [{
|
|
||||||
'filename' => primary[:filename],
|
|
||||||
filename: primary[:filename],
|
|
||||||
data: primary[:data],
|
|
||||||
'data' => primary[:data],
|
|
||||||
'mime-type' => primary[:mime_type]
|
|
||||||
}]
|
|
||||||
end
|
|
||||||
|
|
||||||
ticket.with_lock do
|
|
||||||
article = Ticket::Article.create!(article_params)
|
|
||||||
|
|
||||||
# Create additional articles for extra attachments
|
|
||||||
((attachments || [])[1..] || []).each_with_index do |att, index|
|
|
||||||
Ticket::Article.create!(
|
|
||||||
article_params.merge(
|
|
||||||
message_id: "cdr_signal.#{message_id}-#{index + 1}",
|
|
||||||
subject: att[:filename],
|
|
||||||
body: att[:filename],
|
|
||||||
attachments: [{
|
|
||||||
'filename' => att[:filename],
|
|
||||||
filename: att[:filename],
|
|
||||||
data: att[:data],
|
|
||||||
'data' => att[:data],
|
|
||||||
'mime-type' => att[:mime_type]
|
|
||||||
}]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
article
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def process_group_membership(channel, groups)
|
|
||||||
groups.each do |group|
|
|
||||||
group_id = group['id']
|
|
||||||
internal_id = group['internalId']
|
|
||||||
members = group['members'] || []
|
|
||||||
next unless group_id && internal_id
|
|
||||||
|
|
||||||
Rails.logger.debug do
|
|
||||||
"CdrSignalPoller: Group #{group['name']} - #{members.length} members, " \
|
|
||||||
"#{(group['pendingInvites'] || []).length} pending"
|
|
||||||
end
|
|
||||||
|
|
||||||
members.each do |member_phone|
|
|
||||||
notify_group_member_joined(channel, group_id, member_phone)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def notify_group_member_joined(channel, group_id, member_phone)
|
|
||||||
# Find ticket with this group_id
|
|
||||||
state_ids = Ticket::State.where(name: %w[closed merged removed]).pluck(:id)
|
|
||||||
|
|
||||||
ticket = Ticket.where.not(state_id: state_ids)
|
|
||||||
.where('preferences LIKE ?', "%chat_id: #{group_id}%")
|
|
||||||
.order(updated_at: :desc)
|
|
||||||
.first
|
|
||||||
|
|
||||||
return unless ticket
|
|
||||||
|
|
||||||
# Idempotency check
|
|
||||||
return if ticket.preferences.dig('cdr_signal', 'group_joined') == true
|
|
||||||
|
|
||||||
# Update group_joined flag
|
|
||||||
ticket.preferences[:cdr_signal] ||= {}
|
|
||||||
ticket.preferences[:cdr_signal][:group_joined] = true
|
|
||||||
ticket.preferences[:cdr_signal][:group_joined_at] = Time.current.iso8601
|
|
||||||
ticket.preferences[:cdr_signal][:group_joined_by] = member_phone
|
|
||||||
ticket.save!
|
|
||||||
|
|
||||||
Rails.logger.info "CdrSignalPoller: Member #{member_phone} joined group #{group_id} for ticket ##{ticket.number}"
|
|
||||||
|
|
||||||
# Add resolution note if there were pending notifications
|
|
||||||
add_group_join_resolution_note(ticket)
|
|
||||||
end
|
|
||||||
|
|
||||||
def add_group_join_resolution_note(ticket)
|
|
||||||
# Check if any articles had a group_not_joined notification
|
|
||||||
articles_with_pending = Ticket::Article.where(ticket_id: ticket.id)
|
|
||||||
.where('preferences LIKE ?', '%group_not_joined_note_added: true%')
|
|
||||||
|
|
||||||
return unless articles_with_pending.exists?
|
|
||||||
|
|
||||||
# Check if resolution note already exists
|
|
||||||
resolution_exists = Ticket::Article.where(ticket_id: ticket.id)
|
|
||||||
.where('preferences LIKE ?', '%group_joined_resolution: true%')
|
|
||||||
.exists?
|
|
||||||
|
|
||||||
return if resolution_exists
|
|
||||||
|
|
||||||
Ticket::Article.create!(
|
|
||||||
ticket_id: ticket.id,
|
|
||||||
content_type: 'text/plain',
|
|
||||||
body: 'Recipient has now joined the Signal group. Pending messages will be delivered shortly.',
|
|
||||||
internal: true,
|
|
||||||
sender: Ticket::Article::Sender.find_by(name: 'System'),
|
|
||||||
type: Ticket::Article::Type.find_by(name: 'note'),
|
|
||||||
preferences: {
|
|
||||||
delivery_message: true,
|
|
||||||
group_joined_resolution: true
|
|
||||||
},
|
|
||||||
updated_by_id: 1,
|
|
||||||
created_by_id: 1
|
|
||||||
)
|
|
||||||
|
|
||||||
Rails.logger.info "CdrSignalPoller: Added resolution note for ticket ##{ticket.number}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -24,15 +24,11 @@ class SignalNotificationSender
|
||||||
return if recipient.blank?
|
return if recipient.blank?
|
||||||
return if message.blank?
|
return if message.blank?
|
||||||
|
|
||||||
# Get the phone number from channel options
|
bot_id = channel.options['bot_token'] || channel.options[:bot_token]
|
||||||
phone_number = channel.options['phone_number'] || channel.options[:phone_number] ||
|
return if bot_id.blank?
|
||||||
channel.options.dig('bot', 'number') || channel.options.dig(:bot, :number)
|
|
||||||
|
|
||||||
return if phone_number.blank?
|
|
||||||
|
|
||||||
# Use direct Signal CLI API
|
|
||||||
api = CdrSignalApi.new
|
api = CdrSignalApi.new
|
||||||
api.send_message(phone_number, [recipient], message)
|
api.send_message(bot_id, recipient, message)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
|
||||||
2390
pnpm-lock.yaml
generated
2390
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
11
turbo.json
11
turbo.json
|
|
@ -4,7 +4,7 @@
|
||||||
"globalEnv": [
|
"globalEnv": [
|
||||||
"NODE_ENV",
|
"NODE_ENV",
|
||||||
"ZAMMAD_URL",
|
"ZAMMAD_URL",
|
||||||
"SIGNAL_CLI_URL"
|
"BRIDGE_SIGNAL_URL"
|
||||||
],
|
],
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"dev": {
|
"dev": {
|
||||||
|
|
@ -19,6 +19,13 @@
|
||||||
"dist/**",
|
"dist/**",
|
||||||
"docker/zammad/addons/**"
|
"docker/zammad/addons/**"
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
|
"lint": {
|
||||||
|
"dependsOn": ["^build"]
|
||||||
|
},
|
||||||
|
"format": {
|
||||||
|
"cache": false
|
||||||
|
},
|
||||||
|
"format:check": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue