Compare commits

...
Sign in to create a new pull request.

11 commits

Author SHA1 Message Date
Darren Clarke
1f2809080a WIP: Add Signal notification support for Zammad agents 2026-01-21 09:44:40 +01:00
Darren Clarke
ac42d7df78 Use _uid instead of _id to please Rails 2026-01-19 16:51:51 +01:00
Darren Clarke
87bb05fdd5 Bump version to 3.4.0-beta.5 2026-01-15 16:51:20 +01:00
Darren Clarke
3d8f794cab Add user ID support for Baileys 7 LIDs and Signal UUIDs
Baileys 7 uses LIDs (Linked IDs) instead of phone numbers in remoteJid for
some messages. This caused messages to be matched to wrong tickets because
the LID was used as the sender identifier. This commit adds proper support
for both phone numbers and user IDs across WhatsApp and Signal channels.

Changes:

Database:
- Add migration for whatsapp_user_id and signal_user_id fields on users table

Zammad controllers:
- Update user lookup with 3-step fallback: phone → dedicated user_id field →
  user_id in phone field (legacy)
- Store user IDs in dedicated fields when available
- Update phone field when we receive actual phone number for legacy records
- Fix redundant condition in Signal controller

Bridge services:
- Extract both phone (from senderPn/participantPn) and LID (from remoteJid)
- Send both identifiers to Zammad via webhooks
- Use camelCase (userId) in bridge-whatsapp, convert to snake_case (user_id)
  in bridge-worker for Zammad compatibility

Baileys 7 compliance:
- Remove broken loadAllUnreadMessages() call (removed in Baileys 7)
- Return descriptive error directing users to use webhooks instead

Misc:
- Add docs/ to .gitignore
2026-01-15 13:08:56 +01:00
Darren Clarke
57d7173485 Bump version to 3.4.0-beta.4 2026-01-14 11:33:11 +01:00
Darren Clarke
8688efc5af Regenerate pnpm-lock.yaml after rebase 2026-01-14 11:02:11 +01:00
Darren Clarke
d6dab5fb1f Build updates 2026-01-14 11:01:31 +01:00
Darren Clarke
7a6e7d0748 Update docker.js 2026-01-14 11:00:48 +01:00
Darren Clarke
57f3ccbaeb Fetch message history at startup 2026-01-14 11:00:41 +01:00
Darren Clarke
e202eeb9d2 Remove deprecated property 2026-01-14 11:00:41 +01:00
Darren Clarke
e952973f7f Update Baileys to 7RC 2026-01-14 11:00:41 +01:00
44 changed files with 1846 additions and 900 deletions

2
.gitignore vendored
View file

@ -31,3 +31,5 @@ project.org
apps/bridge-worker/scripts/* apps/bridge-worker/scripts/*
ENVIRONMENT_VARIABLES_MIGRATION.md ENVIRONMENT_VARIABLES_MIGRATION.md
local-scripts/* local-scripts/*
docs/
packages/zammad-addon-bridge/test/

2
.nvmrc
View file

@ -1 +1 @@
v22.18.0 v24

View file

@ -1,6 +1,6 @@
{ {
"name": "@link-stack/bridge-frontend", "name": "@link-stack/bridge-frontend",
"version": "3.3.5", "version": "3.4.0-beta.6",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",

View file

@ -1,6 +1,6 @@
{ {
"name": "@link-stack/bridge-migrations", "name": "@link-stack/bridge-migrations",
"version": "3.3.5", "version": "3.4.0-beta.6",
"type": "module", "type": "module",
"scripts": { "scripts": {
"migrate:up:all": "tsx migrate.ts up:all", "migrate:up:all": "tsx migrate.ts up:all",

View file

@ -1,17 +1,17 @@
{ {
"name": "@link-stack/bridge-whatsapp", "name": "@link-stack/bridge-whatsapp",
"version": "3.3.5", "version": "3.4.0-beta.6",
"type": "module",
"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",
"dependencies": { "dependencies": {
"@adiwajshing/keyed-db": "0.2.4",
"@hapi/hapi": "^21.4.3", "@hapi/hapi": "^21.4.3",
"@hapipal/schmervice": "^3.0.0", "@hapipal/schmervice": "^3.0.0",
"@hapipal/toys": "^4.0.0", "@hapipal/toys": "^4.0.0",
"@link-stack/bridge-common": "workspace:*", "@link-stack/bridge-common": "workspace:*",
"@link-stack/logger": "workspace:*", "@link-stack/logger": "workspace:*",
"@whiskeysockets/baileys": "6.7.21", "@whiskeysockets/baileys": "7.0.0-rc.9",
"hapi-pino": "^13.0.0", "hapi-pino": "^13.0.0",
"link-preview-js": "^3.1.0" "link-preview-js": "^3.1.0"
}, },
@ -19,15 +19,12 @@
"@link-stack/eslint-config": "workspace:*", "@link-stack/eslint-config": "workspace:*",
"@link-stack/jest-config": "workspace:*", "@link-stack/jest-config": "workspace:*",
"@link-stack/typescript-config": "workspace:*", "@link-stack/typescript-config": "workspace:*",
"@types/long": "^5",
"@types/node": "*", "@types/node": "*",
"dotenv-cli": "^10.0.0",
"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": "node --env-file=.env --experimental-transform-types src/index.ts",
"start": "node build/main/index.js" "start": "node build/main/index.js"
} }
} }

View file

@ -1,17 +1,17 @@
import * as Hapi from "@hapi/hapi"; import * as Hapi from "@hapi/hapi";
import hapiPino from "hapi-pino"; import hapiPino from "hapi-pino";
import Schmervice from "@hapipal/schmervice"; import Schmervice from "@hapipal/schmervice";
import WhatsappService from "./service.js"; import WhatsappService from "./service.ts";
import { import {
RegisterBotRoute, RegisterBotRoute,
UnverifyBotRoute, UnverifyBotRoute,
GetBotRoute, GetBotRoute,
SendMessageRoute, SendMessageRoute,
ReceiveMessageRoute, ReceiveMessageRoute,
} from "./routes.js"; } from "./routes.ts";
import { createLogger } from "@link-stack/logger"; import { createLogger } from "@link-stack/logger";
const logger = createLogger('bridge-whatsapp-index'); const logger = createLogger("bridge-whatsapp-index");
const server = Hapi.server({ port: 5000 }); const server = Hapi.server({ port: 5000 });

View file

@ -1,6 +1,6 @@
import * as Hapi from "@hapi/hapi"; import * as Hapi from "@hapi/hapi";
import Toys from "@hapipal/toys"; import Toys from "@hapipal/toys";
import WhatsappService from "./service"; import WhatsappService from "./service.ts";
const withDefaults = Toys.withRouteDefaults({ const withDefaults = Toys.withRouteDefaults({
options: { options: {
@ -27,15 +27,9 @@ export const SendMessageRoute = withDefaults({
description: "Send a message", description: "Send a message",
async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) { async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) {
const { id } = request.params; const { id } = request.params;
const { phoneNumber, message, attachments } = const { phoneNumber, message, attachments } = request.payload as MessageRequest;
request.payload as MessageRequest;
const whatsappService = getService(request); const whatsappService = getService(request);
await whatsappService.send( await whatsappService.send(id, phoneNumber, message as string, attachments);
id,
phoneNumber,
message as string,
attachments,
);
request.logger.info( request.logger.info(
{ {
id, id,

View file

@ -4,12 +4,13 @@ import makeWASocket, {
DisconnectReason, DisconnectReason,
proto, proto,
downloadContentFromMessage, downloadContentFromMessage,
MediaType,
fetchLatestBaileysVersion, fetchLatestBaileysVersion,
isJidBroadcast, isJidBroadcast,
isJidStatusBroadcast, isJidStatusBroadcast,
useMultiFileAuthState, useMultiFileAuthState,
} from "@whiskeysockets/baileys"; } from "@whiskeysockets/baileys";
type MediaType = "audio" | "document" | "image" | "video" | "sticker";
import fs from "fs"; import fs from "fs";
import { createLogger } from "@link-stack/logger"; import { createLogger } from "@link-stack/logger";
import { import {
@ -97,6 +98,7 @@ export default class WhatsappService extends Service {
...options, ...options,
auth: state, auth: state,
generateHighQualityLinkPreview: false, generateHighQualityLinkPreview: false,
syncFullHistory: true,
msgRetryCounterMap, msgRetryCounterMap,
shouldIgnoreJid: (jid) => isJidBroadcast(jid) || isJidStatusBroadcast(jid), shouldIgnoreJid: (jid) => isJidBroadcast(jid) || isJidStatusBroadcast(jid),
}); });
@ -147,6 +149,17 @@ export default class WhatsappService extends Service {
await this.queueUnreadMessages(botID, messages); await this.queueUnreadMessages(botID, messages);
} }
} }
if (events["messaging-history.set"]) {
const { messages, isLatest } = events["messaging-history.set"];
logger.info(
{ messageCount: messages.length, isLatest },
"received message history on connection",
);
if (messages.length > 0) {
await this.queueUnreadMessages(botID, messages);
}
}
}); });
this.connections[botID] = { socket, msgRetryCounterMap }; this.connections[botID] = { socket, msgRetryCounterMap };
@ -167,7 +180,6 @@ export default class WhatsappService extends Service {
await this.createConnection(botID, this.server, { await this.createConnection(botID, this.server, {
browser: WhatsappService.browserDescription, browser: WhatsappService.browserDescription,
printQRInTerminal: true,
version, version,
}); });
} }
@ -175,18 +187,20 @@ export default class WhatsappService extends Service {
} }
private async queueMessage(botID: string, webMessageInfo: proto.IWebMessageInfo) { private async queueMessage(botID: string, webMessageInfo: proto.IWebMessageInfo) {
const { const { key, message, messageTimestamp } = webMessageInfo;
key: { id, fromMe, remoteJid }, if (!key) {
message, logger.warn("Message missing key, skipping");
messageTimestamp, return;
} = webMessageInfo;
logger.info("Message type debug");
for (const key in message) {
logger.info(
{ key, exists: !!message[key as keyof proto.IMessage] },
"Message field",
);
} }
const { id, fromMe, remoteJid } = key;
// Baileys 7 uses LIDs (Linked IDs) instead of phone numbers in some cases.
// senderPn contains the actual phone number when available.
const senderPn = (key as any).senderPn as string | undefined;
const participantPn = (key as any).participantPn as string | undefined;
logger.info(
{ 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;
@ -244,9 +258,27 @@ export default class WhatsappService extends Service {
videoMessage, videoMessage,
].find((text) => text && text !== ""); ].find((text) => text && text !== "");
// Extract phone number and user ID (LID) separately
// remoteJid may contain LIDs (Baileys 7+) which are not phone numbers
const jidValue = remoteJid?.split("@")[0];
const isLidJid = remoteJid?.endsWith("@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);
// User ID (LID): extract from remoteJid if it's a LID format
const senderUserId = isLidJid ? jidValue : undefined;
// Must have at least one identifier
if (!senderPhone && !senderUserId) {
logger.warn({ remoteJid, senderPn, participantPn }, "Could not determine sender identity, skipping message");
return;
}
const payload = { const payload = {
to: botID, to: botID,
from: remoteJid?.split("@")[0], from: senderPhone,
userId: senderUserId,
messageId: id, messageId: id,
sentAt: new Date((messageTimestamp as number) * 1000).toISOString(), sentAt: new Date((messageTimestamp as number) * 1000).toISOString(),
message: messageText, message: messageText,
@ -410,12 +442,17 @@ export default class WhatsappService extends Service {
} }
async receive( async receive(
botID: string, _botID: string,
_lastReceivedDate: Date, _lastReceivedDate: Date,
): Promise<proto.IWebMessageInfo[]> { ): Promise<proto.IWebMessageInfo[]> {
const connection = this.connections[botID]?.socket; // loadAllUnreadMessages() was removed in Baileys 7.x
const messages = await connection.loadAllUnreadMessages(); // Messages are now delivered via events (messages.upsert, messaging-history.set)
// and forwarded to webhooks automatically.
return messages; // See: https://baileys.wiki/docs/migration/to-v7.0.0/
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 BRIDGE_FRONTEND_URL/api/whatsapp/bots/{id}/receive"
);
} }
} }

View file

@ -1,4 +1,4 @@
import type WhatsappService from "./service.js"; import type WhatsappService from "./service.ts";
declare module "@hapipal/schmervice" { declare module "@hapipal/schmervice" {
interface SchmerviceDecorator { interface SchmerviceDecorator {

View file

@ -1,16 +1,17 @@
{ {
"extends": "@link-stack/typescript-config/tsconfig.node.json", "extends": "@link-stack/typescript-config/tsconfig.node.json",
"compilerOptions": { "compilerOptions": {
"module": "commonjs", "module": "NodeNext",
"target": "es2018", "target": "es2022",
"esModuleInterop": true, "esModuleInterop": true,
"moduleResolution": "node", "moduleResolution": "NodeNext",
"outDir": "build/main", "outDir": "build/main",
"rootDir": "src", "rootDir": "src",
"skipLibCheck": true, "skipLibCheck": true,
"types": ["node"], "types": ["node"],
"lib": ["es2020", "DOM"], "lib": ["es2022", "DOM"],
"composite": true "composite": true,
"rewriteRelativeImportExtensions": true
}, },
"include": ["src/**/*.ts", "src/**/.*.ts"], "include": ["src/**/*.ts", "src/**/.*.ts"],
"exclude": ["node_modules/**"] "exclude": ["node_modules/**"]

View file

@ -1,6 +1,6 @@
{ {
"name": "@link-stack/bridge-worker", "name": "@link-stack/bridge-worker",
"version": "3.3.5", "version": "3.4.0-beta.6",
"type": "module", "type": "module",
"main": "build/main/index.js", "main": "build/main/index.js",
"author": "Darren Clarke <darren@redaranj.com>", "author": "Darren Clarke <darren@redaranj.com>",

View file

@ -168,6 +168,7 @@ const processMessage = async ({
token: id, token: id,
to: toRecipient, to: toRecipient,
from: source, from: source,
userId: sourceUuid, // Signal user UUID for user identification
messageId: `${sourceUuid}-${rawTimestamp}`, messageId: `${sourceUuid}-${rawTimestamp}`,
message: dataMessage?.message, message: dataMessage?.message,
sentAt: timestamp.toISOString(), sentAt: timestamp.toISOString(),

View file

@ -9,6 +9,7 @@ interface ReceiveSignalMessageTaskOptions {
token: string; token: string;
to: string; to: string;
from: string; from: string;
userId?: string; // Signal user UUID for user identification
messageId: string; messageId: string;
sentAt: string; sentAt: string;
message: string; message: string;
@ -22,6 +23,7 @@ const receiveSignalMessageTask = async ({
token, token,
to, to,
from, from,
userId,
messageId, messageId,
sentAt, sentAt,
message, message,
@ -212,6 +214,7 @@ const receiveSignalMessageTask = async ({
const payload = { const payload = {
to: finalTo, to: finalTo,
from, from,
user_id: userId, // Signal user UUID for user identification
message_id: messageId, message_id: messageId,
sent_at: sentAt, sent_at: sentAt,
message, message,

View file

@ -64,13 +64,14 @@ const sendSignalMessageTask = async ({
let groupCreated = false; let groupCreated = false;
try { try {
// Check if 'to' is a group ID (UUID format, group.base64 format, or base64) vs phone number // Check if 'to' is a group ID (group.base64 format or base64 internal ID) vs individual recipient
const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( // Signal group IDs are 32 bytes = 44 chars base64 (or 43 without padding)
to, // Signal user UUIDs (ACIs) are 36 chars with hyphens: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
); // Phone numbers start with +, usernames with u:, PNIs with PNI:
const isGroupPrefix = to.startsWith("group."); const isGroupPrefix = to.startsWith("group.");
const isBase64 = /^[A-Za-z0-9+/]+=*$/.test(to) && to.length > 20; // Base64 internal_id const isBase64GroupId =
const isGroupId = isUUID || isGroupPrefix || isBase64; /^[A-Za-z0-9+/]+=*$/.test(to) && to.length >= 43 && to.length <= 44;
const isGroupId = isGroupPrefix || isBase64GroupId;
const enableAutoGroups = process.env.BRIDGE_SIGNAL_AUTO_GROUPS === "true"; const enableAutoGroups = process.env.BRIDGE_SIGNAL_AUTO_GROUPS === "true";
logger.debug( logger.debug(

View file

@ -3,7 +3,8 @@ import { db, getWorkerUtils } from "@link-stack/bridge-common";
interface ReceiveWhatsappMessageTaskOptions { interface ReceiveWhatsappMessageTaskOptions {
token: string; token: string;
to: string; to: string;
from: string; from?: string;
userId?: string;
messageId: string; messageId: string;
sentAt: string; sentAt: string;
message: string; message: string;
@ -16,6 +17,7 @@ const receiveWhatsappMessageTask = async ({
token, token,
to, to,
from, from,
userId,
messageId, messageId,
sentAt, sentAt,
message, message,
@ -33,6 +35,7 @@ const receiveWhatsappMessageTask = async ({
const payload = { const payload = {
to, to,
from, from,
user_id: userId,
message_id: messageId, message_id: messageId,
sent_at: sentAt, sent_at: sentAt,
message, message,

View file

@ -1,6 +1,6 @@
{ {
"name": "@link-stack/link", "name": "@link-stack/link",
"version": "3.3.5", "version": "3.4.0-beta.6",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "next dev -H 0.0.0.0", "dev": "next dev -H 0.0.0.0",

View file

@ -15,7 +15,7 @@ COPY --from=node /usr/local/lib /usr/local/lib
COPY --from=node /usr/local/include /usr/local/include COPY --from=node /usr/local/include /usr/local/include
COPY --from=node /usr/local/bin /usr/local/bin COPY --from=node /usr/local/bin /usr/local/bin
# Prepare pnpm (corepack is already enabled in node:22-alpine) # Prepare pnpm (corepack symlinks already copied from node image)
RUN corepack prepare pnpm@9.15.4 --activate RUN corepack prepare pnpm@9.15.4 --activate
# Set up pnpm home # Set up pnpm home

View file

@ -5,13 +5,13 @@ const app = process.argv[2];
const command = process.argv[3]; const command = process.argv[3];
const files = { const files = {
all: ["zammad", "postgresql", "bridge", "opensearch", "link", "signal-cli-rest-api"], all: ["zammad", "postgresql", "bridge", "opensearch", "link", "signal-cli-rest-api", "bridge-whatsapp"],
linkDev: ["zammad", "postgresql", "opensearch"], linkDev: ["zammad", "postgresql", "opensearch"],
link: ["zammad", "postgresql", "opensearch", "link"], link: ["zammad", "postgresql", "opensearch", "link"],
linkOnly: ["link"], linkOnly: ["link"],
opensearch: ["opensearch"], opensearch: ["opensearch"],
bridgeDev: ["zammad", "postgresql", "signal-cli-rest-api"], bridgeDev: ["zammad", "postgresql", "signal-cli-rest-api", "bridge-whatsapp"],
bridge: ["zammad", "postgresql", "bridge", "signal-cli-rest-api"], bridge: ["zammad", "postgresql", "bridge", "signal-cli-rest-api", "bridge-whatsapp"],
zammad: ["zammad", "postgresql", "opensearch"], zammad: ["zammad", "postgresql", "opensearch"],
}; };
@ -21,7 +21,7 @@ const finalFiles = files[app]
.map((file) => ['-f', `docker/compose/${file}.yml`]).flat(); .map((file) => ['-f', `docker/compose/${file}.yml`]).flat();
// Add bridge-dev.yml for dev commands that include zammad // Add bridge-dev.yml for dev commands that include zammad
const devAppsWithZammad = ['linkDev', 'bridgeDev', 'all']; const devAppsWithZammad = ['linkDev', 'bridgeDev'];
if (devAppsWithZammad.includes(app) && files[app].includes('zammad')) { if (devAppsWithZammad.includes(app) && files[app].includes('zammad')) {
finalFiles.push('-f', 'docker-compose.bridge-dev.yml'); finalFiles.push('-f', 'docker-compose.bridge-dev.yml');
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "@link-stack", "name": "@link-stack",
"version": "3.3.5", "version": "3.4.0-beta.6",
"description": "Link from the Center for Digital Resilience", "description": "Link from the Center for Digital Resilience",
"scripts": { "scripts": {
"dev": "dotenv -- turbo dev", "dev": "dotenv -- turbo dev",
@ -49,7 +49,7 @@
"react": "19.2.0", "react": "19.2.0",
"react-dom": "19.2.0", "react-dom": "19.2.0",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"turbo": "^2.5.8", "turbo": "^2.6.0",
"typescript": "latest" "typescript": "latest"
}, },
"pnpm": { "pnpm": {

View file

@ -1,6 +1,6 @@
{ {
"name": "@link-stack/bridge-common", "name": "@link-stack/bridge-common",
"version": "3.3.5", "version": "3.4.0-beta.6",
"main": "build/main/index.js", "main": "build/main/index.js",
"type": "module", "type": "module",
"author": "Darren Clarke <darren@redaranj.com>", "author": "Darren Clarke <darren@redaranj.com>",

View file

@ -1,6 +1,6 @@
{ {
"name": "@link-stack/bridge-ui", "name": "@link-stack/bridge-ui",
"version": "3.3.5", "version": "3.4.0-beta.6",
"scripts": { "scripts": {
"build": "tsc -p tsconfig.json" "build": "tsc -p tsconfig.json"
}, },

View file

@ -1,6 +1,6 @@
{ {
"name": "@link-stack/eslint-config", "name": "@link-stack/eslint-config",
"version": "3.3.5", "version": "3.4.0-beta.6",
"description": "amigo's eslint config", "description": "amigo's eslint config",
"main": "index.js", "main": "index.js",
"author": "Abel Luck <abel@guardianproject.info>", "author": "Abel Luck <abel@guardianproject.info>",

View file

@ -1,6 +1,6 @@
{ {
"name": "@link-stack/jest-config", "name": "@link-stack/jest-config",
"version": "3.3.5", "version": "3.4.0-beta.6",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"author": "Abel Luck <abel@guardianproject.info>", "author": "Abel Luck <abel@guardianproject.info>",

View file

@ -1,6 +1,6 @@
{ {
"name": "@link-stack/logger", "name": "@link-stack/logger",
"version": "3.3.5", "version": "3.4.0-beta.6",
"description": "Shared logging utility for Link Stack monorepo", "description": "Shared logging utility for Link Stack monorepo",
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.mjs", "module": "./dist/index.mjs",

View file

@ -1,6 +1,6 @@
{ {
"name": "@link-stack/signal-api", "name": "@link-stack/signal-api",
"version": "3.3.5", "version": "3.4.0-beta.6",
"type": "module", "type": "module",
"main": "build/index.js", "main": "build/index.js",
"exports": { "exports": {

View file

@ -1,6 +1,6 @@
{ {
"name": "@link-stack/typescript-config", "name": "@link-stack/typescript-config",
"version": "3.3.5", "version": "3.4.0-beta.6",
"description": "Shared TypeScript config", "description": "Shared TypeScript config",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"author": "Abel Luck <abel@guardianproject.info>", "author": "Abel Luck <abel@guardianproject.info>",

View file

@ -1,6 +1,6 @@
{ {
"name": "@link-stack/ui", "name": "@link-stack/ui",
"version": "3.3.5", "version": "3.4.0-beta.6",
"description": "", "description": "",
"scripts": { "scripts": {
"build": "tsc -p tsconfig.json" "build": "tsc -p tsconfig.json"

View file

@ -1,7 +1,7 @@
{ {
"name": "@link-stack/zammad-addon-bridge", "name": "@link-stack/zammad-addon-bridge",
"displayName": "Bridge", "displayName": "Bridge",
"version": "3.3.5", "version": "3.4.0-beta.6",
"description": "An addon that adds CDR Bridge channels to Zammad.", "description": "An addon that adds CDR Bridge channels to Zammad.",
"scripts": { "scripts": {
"build": "node '../zammad-addon-common/dist/build.js'", "build": "node '../zammad-addon-common/dist/build.js'",

View file

@ -0,0 +1,89 @@
class ProfileSignalNotifications extends App.ControllerSubContent
@requiredPermission: 'user_preferences.signal_notifications+ticket.agent'
header: __('Signal Notifications')
events:
'submit form': 'update'
constructor: ->
super
App.User.full(App.Session.get().id, @render, true, true)
render: =>
config =
enabled: false
events:
create: true
update: true
escalation: true
reminder_reached: true
user = App.User.find(App.Session.get().id)
user_config = user.preferences?.signal_notifications
if user_config
config = $.extend(true, {}, config, user_config)
@html App.view('profile/signal_notifications')
config: config
signal_uid: user.signal_uid || ''
signal_notification_enabled: App.Config.get('signal_notification_enabled')
update: (e) =>
e.preventDefault()
params = @formParam(e.target)
preferences = {}
preferences.signal_notifications =
enabled: params.enabled == 'true'
events:
create: params.event_create == 'true'
update: params.event_update == 'true'
escalation: params.event_escalation == 'true'
reminder_reached: params.event_reminder_reached == 'true'
@formDisable(e)
@ajax(
id: 'preferences_signal_notifications'
type: 'PUT'
url: @apiPath + '/users/preferences'
data: JSON.stringify(preferences)
processData: true
success: @successPreferences
error: @error
)
if params.signal_uid?
user = App.User.find(App.Session.get().id)
user.signal_uid = params.signal_uid
user.save(
done: =>
# User saved successfully
fail: (settings, details) =>
@notify(
type: 'error'
msg: details.error || __('Failed to save Signal phone number')
)
)
successPreferences: (data, status, xhr) =>
App.User.full(
App.Session.get('id'),
=>
App.Event.trigger('ui:rerender')
@notify(
type: 'success'
msg: __('Update successful.')
)
,
true
)
error: (xhr, status, error) =>
@render()
data = JSON.parse(xhr.responseText)
@notify(
type: 'error'
msg: data.message
)
App.Config.set('SignalNotifications', { prio: 2650, name: __('Signal Notifications'), parent: '#profile', target: '#profile/signal_notifications', permission: ['user_preferences.signal_notifications+ticket.agent'], controller: ProfileSignalNotifications }, 'NavBarProfile')

View file

@ -0,0 +1,86 @@
<div class="page-header">
<div class="page-header-title"><h1><%- @T('Signal Notifications') %></h1></div>
</div>
<% if !@signal_notification_enabled: %>
<div class="alert alert--warning" role="alert">
<%- @T('Signal notifications are currently disabled by the administrator.') %>
</div>
<% end %>
<form class="page-content form--flexibleWidth">
<h2><%- @T('Signal Phone Number') %></h2>
<p class="help-text">
<%- @T('Enter your Signal phone number to receive ticket notifications via Signal.') %>
</p>
<div class="form-group">
<label for="signal-uid"><%- @T('Signal Phone Number') %></label>
<input type="text" id="signal-uid" name="signal_uid" class="form-control" value="<%= @signal_uid %>" placeholder="+1234567890">
<p class="help-block"><%- @T('Use international format with country code (e.g., +1234567890)') %></p>
</div>
<h2><%- @T('Notification Settings') %></h2>
<div class="form-group">
<label class="inline-label">
<span class="checkbox-replacement checkbox-replacement--inline">
<input type="checkbox" name="enabled" value="true" <% if @config.enabled: %> checked<% end %>>
<%- @Icon('checkbox', 'icon-unchecked') %>
<%- @Icon('checkbox-checked', 'icon-checked') %>
</span>
<%- @T('Enable Signal notifications') %>
</label>
</div>
<h3><%- @T('Notification Events') %></h3>
<p class="help-text">
<%- @T('Select which events should trigger Signal notifications.') %>
</p>
<div class="form-group">
<label class="inline-label">
<span class="checkbox-replacement checkbox-replacement--inline">
<input type="checkbox" name="event_create" value="true" <% if @config.events?.create: %> checked<% end %>>
<%- @Icon('checkbox', 'icon-unchecked') %>
<%- @Icon('checkbox-checked', 'icon-checked') %>
</span>
<%- @T('New Ticket') %>
</label>
</div>
<div class="form-group">
<label class="inline-label">
<span class="checkbox-replacement checkbox-replacement--inline">
<input type="checkbox" name="event_update" value="true" <% if @config.events?.update: %> checked<% end %>>
<%- @Icon('checkbox', 'icon-unchecked') %>
<%- @Icon('checkbox-checked', 'icon-checked') %>
</span>
<%- @T('Ticket update') %>
</label>
</div>
<div class="form-group">
<label class="inline-label">
<span class="checkbox-replacement checkbox-replacement--inline">
<input type="checkbox" name="event_escalation" value="true" <% if @config.events?.escalation: %> checked<% end %>>
<%- @Icon('checkbox', 'icon-unchecked') %>
<%- @Icon('checkbox-checked', 'icon-checked') %>
</span>
<%- @T('Ticket escalation') %>
</label>
</div>
<div class="form-group">
<label class="inline-label">
<span class="checkbox-replacement checkbox-replacement--inline">
<input type="checkbox" name="event_reminder_reached" value="true" <% if @config.events?.reminder_reached: %> checked<% end %>>
<%- @Icon('checkbox', 'icon-unchecked') %>
<%- @Icon('checkbox-checked', 'icon-checked') %>
</span>
<%- @T('Ticket reminder reached') %>
</label>
</div>
<button type="submit" class="btn btn--primary"><%- @T('Submit') %></button>
</form>

View file

@ -154,16 +154,31 @@ class ChannelsCdrSignalController < ApplicationController
return if Ticket::Article.exists?(message_id: "cdr_signal.#{message_id}") return if Ticket::Article.exists?(message_id: "cdr_signal.#{message_id}")
receiver_phone_number = params[:to].strip receiver_phone_number = params[:to].strip
sender_phone_number = params[:from].strip sender_phone_number = params[:from].present? ? params[:from].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 # Check if this is a group message using the is_group flag from bridge-worker
# This flag is set when: # This flag is set when:
# 1. The original message came from a Signal group # 1. The original message came from a Signal group
# 2. Bridge-worker created a new group for the conversation # 2. Bridge-worker created a new group for the conversation
is_group_message = params[:is_group].to_s == 'true' || params[:is_group].to_s == 'true' is_group_message = params[:is_group].to_s == 'true'
# 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
if sender_phone_number.present?
customer = User.find_by(phone: sender_phone_number) customer = User.find_by(phone: sender_phone_number)
customer ||= User.find_by(mobile: sender_phone_number) customer ||= User.find_by(mobile: sender_phone_number)
end
if customer.nil? && sender_user_id.present?
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(mobile: sender_user_id)
end
unless customer unless customer
role_ids = Role.signup_role_ids role_ids = Role.signup_role_ids
customer = User.create( customer = User.create(
@ -171,7 +186,8 @@ class ChannelsCdrSignalController < ApplicationController
lastname: '', lastname: '',
email: '', email: '',
password: '', password: '',
phone: sender_phone_number, phone: sender_phone_number.presence || sender_user_id,
signal_uid: sender_user_id,
note: 'CDR Signal', note: 'CDR Signal',
active: true, active: true,
role_ids: role_ids, role_ids: role_ids,
@ -180,6 +196,15 @@ 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?
customer.update(signal_uid: sender_user_id)
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
customer.update(phone: sender_phone_number)
end
# set current user # 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')
@ -208,7 +233,8 @@ class ChannelsCdrSignalController < ApplicationController
attachment_data_base64 = params[:attachment] attachment_data_base64 = params[:attachment]
attachment_filename = params[:filename] attachment_filename = params[:filename]
attachment_mimetype = params[:mime_type] attachment_mimetype = params[:mime_type]
title = "Message from #{sender_phone_number} at #{sent_at}" sender_display = sender_phone_number.presence || sender_user_id
title = "Message from #{sender_display} at #{sent_at}"
body = message body = message
# find ticket or create one # find ticket or create one
@ -218,7 +244,7 @@ class ChannelsCdrSignalController < ApplicationController
Rails.logger.info "=== SIGNAL GROUP TICKET LOOKUP ===" Rails.logger.info "=== SIGNAL GROUP TICKET LOOKUP ==="
Rails.logger.info "Looking for ticket with group_id: #{receiver_phone_number}" Rails.logger.info "Looking for ticket with group_id: #{receiver_phone_number}"
Rails.logger.info "Customer ID: #{customer.id}" Rails.logger.info "Customer ID: #{customer.id}"
Rails.logger.info "Customer Phone: #{sender_phone_number}" Rails.logger.info "Customer Phone: #{sender_display}"
Rails.logger.info "Channel ID: #{channel.id}" Rails.logger.info "Channel ID: #{channel.id}"
begin begin
@ -256,12 +282,14 @@ class ChannelsCdrSignalController < ApplicationController
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 # Set up chat_id based on whether this is a group message
chat_id = is_group_message ? receiver_phone_number : sender_phone_number # 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)
# Build preferences with group_id included if needed # Build preferences with group_id included if needed
cdr_signal_prefs = { cdr_signal_prefs = {
bot_token: channel.options[:bot_token], # change to bot id bot_token: channel.options[:bot_token],
chat_id: chat_id chat_id: chat_id,
user_id: sender_user_id
} }
Rails.logger.info "=== CREATING NEW TICKET ===" Rails.logger.info "=== CREATING NEW TICKET ==="
@ -283,7 +311,7 @@ class ChannelsCdrSignalController < ApplicationController
ticket.save! ticket.save!
article_params = { article_params = {
from: sender_phone_number, from: sender_display,
to: receiver_phone_number, to: receiver_phone_number,
sender_id: Ticket::Article::Sender.find_by(name: 'Customer').id, sender_id: Ticket::Article::Sender.find_by(name: 'Customer').id,
subject: title, subject: title,
@ -296,7 +324,8 @@ class ChannelsCdrSignalController < ApplicationController
cdr_signal: { cdr_signal: {
timestamp: sent_at, timestamp: sent_at,
message_id: message_id, message_id: message_id,
from: sender_phone_number from: sender_phone_number,
user_id: sender_user_id
} }
} }
} }

View file

@ -123,12 +123,16 @@ class ChannelsCdrWhatsappController < ApplicationController
errors = {} errors = {}
%i[to %i[to
from
message_id message_id
sent_at].each do |field| sent_at].each do |field|
errors[field] = 'required' if params[field].blank? errors[field] = 'required' if params[field].blank?
end end
# At least one of from (phone) or user_id must be present
if params[:from].blank? && params[:user_id].blank?
errors[:from] = 'required (or user_id)'
end
if errors.present? if errors.present?
render json: { render json: {
errors: errors errors: errors
@ -141,9 +145,25 @@ class ChannelsCdrWhatsappController < ApplicationController
return if Ticket::Article.exists?(message_id: "cdr_whatsapp.#{message_id}") return if Ticket::Article.exists?(message_id: "cdr_whatsapp.#{message_id}")
receiver_phone_number = params[:to].strip receiver_phone_number = params[:to].strip
sender_phone_number = params[:from].strip sender_phone_number = params[:from].present? ? params[:from].strip : nil
sender_user_id = params[:user_id].present? ? params[:user_id].strip : nil
# Lookup customer with fallback chain:
# 1. Phone number in phone/mobile fields (preferred)
# 2. WhatsApp user ID in whatsapp_uid field
# 3. User ID in phone/mobile fields (legacy - we used to store LIDs there)
customer = nil
if sender_phone_number.present?
customer = User.find_by(phone: sender_phone_number) customer = User.find_by(phone: sender_phone_number)
customer ||= User.find_by(mobile: sender_phone_number) customer ||= User.find_by(mobile: sender_phone_number)
end
if customer.nil? && sender_user_id.present?
customer = User.find_by(whatsapp_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(mobile: sender_user_id)
end
unless customer unless customer
role_ids = Role.signup_role_ids role_ids = Role.signup_role_ids
customer = User.create( customer = User.create(
@ -151,7 +171,8 @@ class ChannelsCdrWhatsappController < ApplicationController
lastname: '', lastname: '',
email: '', email: '',
password: '', password: '',
phone: sender_phone_number, phone: sender_phone_number.presence || sender_user_id,
whatsapp_uid: sender_user_id,
note: 'CDR Whatsapp', note: 'CDR Whatsapp',
active: true, active: true,
role_ids: role_ids, role_ids: role_ids,
@ -160,6 +181,15 @@ class ChannelsCdrWhatsappController < ApplicationController
) )
end end
# Update whatsapp_uid if we have it and customer doesn't
if sender_user_id.present? && customer.whatsapp_uid.blank?
customer.update(whatsapp_uid: sender_user_id)
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
customer.update(phone: sender_phone_number)
end
# set current user # 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')
@ -188,7 +218,8 @@ class ChannelsCdrWhatsappController < ApplicationController
attachment_data_base64 = params[:attachment] attachment_data_base64 = params[:attachment]
attachment_filename = params[:filename] attachment_filename = params[:filename]
attachment_mimetype = params[:mime_type] attachment_mimetype = params[:mime_type]
title = "Message from #{sender_phone_number} at #{sent_at}" sender_display = sender_phone_number.presence || sender_user_id
title = "Message from #{sender_display} at #{sent_at}"
body = message body = message
# find ticket or create one # find ticket or create one
@ -207,8 +238,9 @@ class ChannelsCdrWhatsappController < ApplicationController
preferences: { preferences: {
channel_id: channel.id, channel_id: channel.id,
cdr_whatsapp: { cdr_whatsapp: {
bot_token: channel.options[:bot_token], # change to bot id bot_token: channel.options[:bot_token],
chat_id: sender_phone_number chat_id: sender_phone_number.presence || sender_user_id,
user_id: sender_user_id
} }
} }
) )
@ -217,7 +249,7 @@ class ChannelsCdrWhatsappController < ApplicationController
ticket.save! ticket.save!
article_params = { article_params = {
from: sender_phone_number, from: sender_display,
to: receiver_phone_number, to: receiver_phone_number,
sender_id: Ticket::Article::Sender.find_by(name: 'Customer').id, sender_id: Ticket::Article::Sender.find_by(name: 'Customer').id,
subject: title, subject: title,
@ -230,7 +262,8 @@ class ChannelsCdrWhatsappController < ApplicationController
cdr_whatsapp: { cdr_whatsapp: {
timestamp: sent_at, timestamp: sent_at,
message_id: message_id, message_id: message_id,
from: sender_phone_number from: sender_phone_number,
user_id: sender_user_id
} }
} }
} }

View file

@ -0,0 +1,63 @@
# frozen_string_literal: true
class SignalNotificationJob < ApplicationJob
retry_on StandardError, attempts: 3, wait: lambda { |executions|
executions * 60.seconds
}
def perform(ticket_id:, article_id:, user_id:, type:, changes:)
ticket = Ticket.find_by(id: ticket_id)
return if !ticket
user = User.find_by(id: user_id)
return if !user
return if user.signal_uid.blank?
article = article_id ? Ticket::Article.find_by(id: article_id) : nil
channel = signal_channel
return if !channel
message = SignalNotificationSender.build_message(
ticket: ticket,
article: article,
user: user,
type: type,
changes: changes
)
return if message.blank?
SignalNotificationSender.send_message(
channel: channel,
recipient: user.signal_uid,
message: message
)
add_history(ticket, user, type)
Rails.logger.info "Sent Signal notification to #{user.signal_uid} for ticket ##{ticket.number} (#{type})"
end
private
def signal_channel
channel_id = Setting.get('signal_notification_channel_id')
return unless channel_id
Channel.find_by(id: channel_id, area: 'Signal::Account', active: true)
end
def add_history(ticket, user, type)
identifier = user.signal_uid.presence || user.login
recipient_list = "#{identifier}(#{type}:signal)"
History.add(
o_id: ticket.id,
history_type: 'notification',
history_object: 'Ticket',
value_to: recipient_list,
created_by_id: 1
)
end
end

View file

@ -0,0 +1,144 @@
# frozen_string_literal: true
class Transaction::SignalNotification
include ChecksHumanChanges
def initialize(item, params = {})
@item = item
@params = params
end
def perform
return if Setting.get('import_mode')
return if %w[Ticket Ticket::Article].exclude?(@item[:object])
return if @params[:disable_notification]
return if !ticket
return if !signal_notifications_enabled?
return if !signal_channel
collect_signal_recipients.each do |user|
SignalNotificationJob.perform_later(
ticket_id: ticket.id,
article_id: @item[:article_id],
user_id: user.id,
type: @item[:type],
changes: human_changes(@item[:changes], ticket, user)
)
end
end
private
def ticket
@ticket ||= Ticket.find_by(id: @item[:object_id])
end
def article
return if !@item[:article_id]
@article ||= begin
art = Ticket::Article.find_by(id: @item[:article_id])
return unless art
sender = Ticket::Article::Sender.lookup(id: art.sender_id)
if sender&.name == 'System'
return if @item[:changes].blank? && art.preferences[:notification] != true
return if art.preferences[:notification] != true
end
art
end
end
def current_user
@current_user ||= User.lookup(id: @item[:user_id]) || User.lookup(id: 1)
end
def signal_notifications_enabled?
Setting.get('signal_notification_enabled') == true
end
def signal_channel
@signal_channel ||= begin
channel_id = Setting.get('signal_notification_channel_id')
return unless channel_id
Channel.find_by(id: channel_id, area: 'Signal::Account', active: true)
end
end
def collect_signal_recipients
recipients = []
possible_recipients = possible_recipients_of_group(ticket.group_id)
mention_users = Mention.where(mentionable_type: @item[:object], mentionable_id: @item[:object_id]).map(&:user)
mention_users.each do |user|
next if !user.group_access?(ticket.group_id, 'read')
possible_recipients.push(user)
end
if ticket.owner_id != 1
possible_recipients.push(ticket.owner)
end
possible_recipients_with_ooo = Set.new(possible_recipients)
possible_recipients.each do |user|
add_out_of_office_replacement(user, possible_recipients_with_ooo)
end
possible_recipients_with_ooo.each do |user|
next if recipient_is_current_user?(user)
next if !user.active?
next if !user_has_signal_notifications_enabled?(user)
next if user.signal_uid.blank?
next if !should_notify_for_event?(user)
recipients.push(user)
end
recipients.uniq(&:id)
end
def possible_recipients_of_group(group_id)
Rails.cache.fetch("User/signal_notification/possible_recipients_of_group/#{group_id}/#{User.latest_change}", expires_in: 20.seconds) do
User.group_access(group_id, 'full').sort_by(&:login)
end
end
def add_out_of_office_replacement(user, recipients)
replacement = user.out_of_office_agent
return unless replacement
return unless TicketPolicy.new(replacement, ticket).agent_read_access?
recipients.add(replacement)
end
def recipient_is_current_user?(user)
return false if @params[:interface_handle] != 'application_server'
return true if article&.updated_by_id == user.id
return true if !article && @item[:user_id] == user.id
false
end
def user_has_signal_notifications_enabled?(user)
user.preferences.dig('signal_notifications', 'enabled') == true
end
def should_notify_for_event?(user)
event_type = @item[:type]
return false if event_type.blank?
event_key = case event_type
when 'create' then 'create'
when 'update', 'update.merged_into', 'update.received_merge', 'update.reaction' then 'update'
when 'reminder_reached' then 'reminder_reached'
when 'escalation', 'escalation_warning' then 'escalation'
else return false
end
user.preferences.dig('signal_notifications', 'events', event_key) == true
end
end

View file

@ -0,0 +1,16 @@
[Ticket #<%= ticket.number %>] <%= ticket.title %>
NEW TICKET
Group: <%= ticket.group.name %>
Owner: <%= ticket.owner.fullname %>
State: <%= t(ticket.state.name) %>
Priority: <%= t(ticket.priority.name) %>
Customer: <%= ticket.customer.fullname %>
Created by: <%= current_user.fullname %>
<% if article -%>
<%= article_body_preview(500) %>
<% end -%>
<%= ticket_url %>

View file

@ -0,0 +1,11 @@
[Ticket #<%= ticket.number %>] <%= ticket.title %>
ESCALATION
Group: <%= ticket.group.name %>
Owner: <%= ticket.owner.fullname %>
State: <%= t(ticket.state.name) %>
Priority: <%= t(ticket.priority.name) %>
Customer: <%= ticket.customer.fullname %>
<%= ticket_url %>

View file

@ -0,0 +1,10 @@
[Ticket #<%= ticket.number %>] <%= ticket.title %>
REMINDER REACHED
Group: <%= ticket.group.name %>
Owner: <%= ticket.owner.fullname %>
State: <%= t(ticket.state.name) %>
Pending till: <%= ticket.pending_time&.strftime('%Y-%m-%d %H:%M') %>
<%= ticket_url %>

View file

@ -0,0 +1,14 @@
[Ticket #<%= ticket.number %>] <%= ticket.title %>
TICKET UPDATED by <%= current_user.fullname %>
<% if changes.present? -%>
Changes:
<%= changes_summary %>
<% end -%>
<% if article -%>
<%= article_body_preview(500) %>
<% end -%>
<%= ticket_url_with_article %>

View file

@ -0,0 +1,123 @@
class AddMessagingUserIds < ActiveRecord::Migration[5.2]
def self.up
# Add WhatsApp UID column
unless column_exists?(:users, :whatsapp_uid)
add_column :users, :whatsapp_uid, :string, limit: 50
add_index :users, :whatsapp_uid
end
User.reset_column_information
# Add Signal UID column
unless column_exists?(:users, :signal_uid)
add_column :users, :signal_uid, :string, limit: 50
add_index :users, :signal_uid
end
User.reset_column_information
# Register WhatsApp UID with ObjectManager for UI
# Column name: whatsapp_uid, Display name: "WhatsApp User ID"
ObjectManager::Attribute.add(
force: true,
object: 'User',
name: 'whatsapp_uid',
display: 'WhatsApp User ID',
data_type: 'input',
data_option: {
type: 'text',
maxlength: 50,
null: true,
item_class: 'formGroup--halfSize',
},
editable: false,
active: true,
screens: {
signup: {},
invite_agent: {},
invite_customer: {},
edit: {
'-all-' => {
null: true,
},
},
create: {
'-all-' => {
null: true,
},
},
view: {
'-all-' => {
shown: true,
},
},
},
to_create: false,
to_migrate: false,
to_delete: false,
position: 710,
created_by_id: 1,
updated_by_id: 1,
)
# Register Signal UID with ObjectManager for UI
# Column name: signal_uid, Display name: "Signal User ID"
ObjectManager::Attribute.add(
force: true,
object: 'User',
name: 'signal_uid',
display: 'Signal User ID',
data_type: 'input',
data_option: {
type: 'text',
maxlength: 50,
null: true,
item_class: 'formGroup--halfSize',
},
editable: false,
active: true,
screens: {
signup: {},
invite_agent: {},
invite_customer: {},
edit: {
'-all-' => {
null: true,
},
},
create: {
'-all-' => {
null: true,
},
},
view: {
'-all-' => {
shown: true,
},
},
},
to_create: false,
to_migrate: false,
to_delete: false,
position: 720,
created_by_id: 1,
updated_by_id: 1,
)
end
def self.down
ObjectManager::Attribute.remove(
object: 'User',
name: 'whatsapp_uid',
)
ObjectManager::Attribute.remove(
object: 'User',
name: 'signal_uid',
)
remove_index :users, :whatsapp_uid if index_exists?(:users, :whatsapp_uid)
remove_column :users, :whatsapp_uid if column_exists?(:users, :whatsapp_uid)
remove_index :users, :signal_uid if index_exists?(:users, :signal_uid)
remove_column :users, :signal_uid if column_exists?(:users, :signal_uid)
end
end

View file

@ -0,0 +1,76 @@
# frozen_string_literal: true
class AddSignalNotificationSettings < ActiveRecord::Migration[5.2]
def self.up
# Register Signal notification transaction backend
# Using 0105 to run after email notifications (0100)
Setting.create_if_not_exists(
title: 'Defines transaction backend.',
name: '0105_signal_notification',
area: 'Transaction::Backend::Async',
description: 'Defines the transaction backend to send Signal notifications.',
options: {},
state: 'Transaction::SignalNotification',
frontend: false
)
# Global enable/disable for Signal notifications
Setting.create_if_not_exists(
title: 'Signal Notifications',
name: 'signal_notification_enabled',
area: 'Integration::Switch',
description: 'Enable or disable Signal notifications for agents.',
options: {
form: [
{
display: '',
null: true,
name: 'signal_notification_enabled',
tag: 'boolean',
options: {
true => 'yes',
false => 'no',
},
},
],
},
state: false,
preferences: {
prio: 1,
permission: ['admin.integration'],
},
frontend: true
)
# Which Signal channel/bot to use for sending notifications
Setting.create_if_not_exists(
title: 'Signal Notification Channel',
name: 'signal_notification_channel_id',
area: 'Integration::SignalNotification',
description: 'The Signal channel (bot) used to send notifications to agents.',
options: {},
state: nil,
preferences: {
prio: 2,
permission: ['admin.integration'],
},
frontend: false
)
# Permission for Signal notifications profile page
Permission.create_if_not_exists(
name: 'user_preferences.signal_notifications',
description: 'Manage Signal notification preferences',
preferences: {
translations: ['Profile - Signal Notifications']
}
)
end
def self.down
Setting.find_by(name: '0105_signal_notification')&.destroy
Setting.find_by(name: 'signal_notification_enabled')&.destroy
Setting.find_by(name: 'signal_notification_channel_id')&.destroy
Permission.find_by(name: 'user_preferences.signal_notifications')&.destroy
end
end

View file

@ -0,0 +1,139 @@
# frozen_string_literal: true
require 'erb'
class SignalNotificationSender
TEMPLATE_DIR = Rails.root.join('app', 'views', 'signal_notification')
class << self
def build_message(ticket:, article:, user:, type:, changes:)
template_name = template_for_type(type)
return if template_name.blank?
locale = user.locale || Setting.get('locale_default') || 'en'
template_path = find_template(template_name, locale)
return if template_path.blank?
render_template(template_path, binding_for(ticket, article, user, changes))
end
def send_message(channel:, recipient:, message:)
return if Rails.env.test?
return if channel.blank?
return if recipient.blank?
return if message.blank?
api_url = channel.options[:api_url]
api_token = channel.options[:api_token]
return if api_url.blank? || api_token.blank?
api = CdrSignalApi.new(api_url, api_token)
api.send_message(recipient, message)
end
private
def template_for_type(type)
case type
when 'create'
'ticket_create'
when 'update', 'update.merged_into', 'update.received_merge', 'update.reaction'
'ticket_update'
when 'reminder_reached'
'ticket_reminder_reached'
when 'escalation', 'escalation_warning'
'ticket_escalation'
end
end
def find_template(template_name, locale)
base_locale = locale.split('-').first
[locale, base_locale, 'en'].uniq.each do |try_locale|
path = TEMPLATE_DIR.join(template_name, "#{try_locale}.txt.erb")
return path if File.exist?(path)
end
nil
end
def binding_for(ticket, article, user, changes)
TemplateContext.new(
ticket: ticket,
article: article,
user: user,
changes: changes,
config: {
http_type: Setting.get('http_type'),
fqdn: Setting.get('fqdn'),
product_name: Setting.get('product_name')
}
).get_binding
end
def render_template(template_path, binding)
template = File.read(template_path)
erb = ERB.new(template, trim_mode: '-')
erb.result(binding).strip
end
end
class TemplateContext
attr_reader :ticket, :article, :recipient, :changes, :config
def initialize(ticket:, article:, user:, changes:, config:)
@ticket = ticket
@article = article
@recipient = user
@changes = changes
@config = OpenStruct.new(config)
end
def get_binding
binding
end
def ticket_url
"#{config.http_type}://#{config.fqdn}/#ticket/zoom/#{ticket.id}"
end
def ticket_url_with_article
if article
"#{ticket_url}/#{article.id}"
else
ticket_url
end
end
def current_user
@current_user ||= User.lookup(id: ticket.updated_by_id) || User.lookup(id: 1)
end
def changes_summary
return '' if changes.blank?
changes.map { |key, values| "#{key}: #{values[0]} -> #{values[1]}" }.join("\n")
end
def article_body_preview(max_length = 500)
return '' unless article
return '' if article.body.blank?
body = article.body.to_s
body = ActionController::Base.helpers.strip_tags(body) if article.content_type&.include?('html')
body = body.gsub(/\s+/, ' ').strip
if body.length > max_length
"#{body[0, max_length]}..."
else
body
end
end
def t(text)
locale = recipient.locale || Setting.get('locale_default') || 'en'
Translation.translate(locale, text)
end
end
end

View file

@ -1,6 +1,6 @@
{ {
"name": "@link-stack/zammad-addon-common", "name": "@link-stack/zammad-addon-common",
"version": "3.3.5", "version": "3.4.0-beta.6",
"description": "", "description": "",
"bin": { "bin": {
"zpm-build": "./dist/build.js", "zpm-build": "./dist/build.js",

View file

@ -1,7 +1,7 @@
{ {
"name": "@link-stack/zammad-addon-hardening", "name": "@link-stack/zammad-addon-hardening",
"displayName": "Hardening", "displayName": "Hardening",
"version": "3.3.5", "version": "3.4.0-beta.6",
"description": "A Zammad addon that hardens a Zammad instance according to CDR's needs.", "description": "A Zammad addon that hardens a Zammad instance according to CDR's needs.",
"scripts": { "scripts": {
"build": "node '../zammad-addon-common/dist/build.js'", "build": "node '../zammad-addon-common/dist/build.js'",

1682
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff