Compare commits

...

24 commits

Author SHA1 Message Date
Darren Clarke
59872f579a Signal notification fixes and UI updates 2026-02-09 22:18:35 +01:00
Darren Clarke
d93797172a Add preference for signal notifications to bot setup screen 2026-01-28 14:46:34 +01:00
Darren Clarke
38efae02d4 Merge feature/split-signal-improvements into combined branch
Combines Signal split/merge improvements with keycloak auth,
baileys-7 updates, and signal notifications support.

Resolved conflicts:
- Kept LID user ID support in bridge-whatsapp
- Kept bridge-dev.yml docker compose addition
- Used 3.5.0-beta.1 version from split-signal-improvements
2026-01-28 09:01:51 +01:00
Darren Clarke
b9324cf11c WIP: Add Signal notification support for Zammad agents 2026-01-28 08:58:29 +01:00
Darren Clarke
72b52463a2 Bump version to 3.4.0-beta.7 2026-01-21 10:10:55 +01:00
Darren Clarke
eea56dd50b Reduce Signal message polling frequency from 15s to 30s 2026-01-21 10:08:59 +01:00
Darren Clarke
e8f2cc4c50 Add Keycloak as a Link login option 2026-01-21 09:58:05 +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
2db6bc5047 Fix: Use senderPn for phone number instead of LID from remoteJid
Baileys 7 uses LIDs (Linked IDs) in remoteJid for some messages instead of
phone numbers. This caused messages to be matched to wrong tickets because
the LID was used as the sender identifier instead of the actual phone number.

Now we:
- Extract senderPn/participantPn from message key (Baileys 7 fields)
- Prefer these phone number fields over remoteJid
- Skip messages if we can't determine the phone number (LID with no phone)
2026-01-15 10:01:15 +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
Darren Clarke
3b91c98d5e Bump version to 3.5.0-beta.1 2026-01-12 10:44:49 +01:00
Darren Clarke
a882c9ecff Split ticket and group name fixes 2025-12-19 15:27:27 +01:00
Darren Clarke
69394c813d Prevent overwriting a Signal group in Zammad if one already exists 2025-12-19 12:52:47 +01:00
Darren Clarke
0b2ea19ebc Add Signal group ticket split compatibility 2025-12-19 12:38:49 +01:00
Darren Clarke
f059e75acd Add warning for unsent Signal groups messages. 2025-12-19 11:37:20 +01:00
Darren Clarke
d4ce94ddf8 Split/merge WIP 2025-12-19 11:37:20 +01:00
61 changed files with 3661 additions and 850 deletions

2
.gitignore vendored
View file

@ -31,3 +31,5 @@ project.org
apps/bridge-worker/scripts/*
ENVIRONMENT_VARIABLES_MIGRATION.md
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",
"version": "3.3.5",
"version": "3.5.0-beta.1",
"type": "module",
"scripts": {
"dev": "next dev",

View file

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

View file

@ -1,6 +1,6 @@
{
"name": "@link-stack/bridge-whatsapp",
"version": "3.3.5",
"version": "3.5.0-beta.1",
"main": "build/main/index.js",
"author": "Darren Clarke <darren@redaranj.com>",
"license": "AGPL-3.0-or-later",

View file

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

View file

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

View file

@ -4,12 +4,13 @@ import makeWASocket, {
DisconnectReason,
proto,
downloadContentFromMessage,
MediaType,
fetchLatestBaileysVersion,
isJidBroadcast,
isJidStatusBroadcast,
useMultiFileAuthState,
} from "@whiskeysockets/baileys";
type MediaType = "audio" | "document" | "image" | "video" | "sticker";
import fs from "fs";
import { createLogger } from "@link-stack/logger";
import {
@ -97,6 +98,7 @@ export default class WhatsappService extends Service {
...options,
auth: state,
generateHighQualityLinkPreview: false,
syncFullHistory: true,
msgRetryCounterMap,
shouldIgnoreJid: (jid) => isJidBroadcast(jid) || isJidStatusBroadcast(jid),
});
@ -147,6 +149,17 @@ export default class WhatsappService extends Service {
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 };
@ -167,7 +180,6 @@ export default class WhatsappService extends Service {
await this.createConnection(botID, this.server, {
browser: WhatsappService.browserDescription,
printQRInTerminal: true,
version,
});
}
@ -175,18 +187,20 @@ export default class WhatsappService extends Service {
}
private async queueMessage(botID: string, webMessageInfo: proto.IWebMessageInfo) {
const {
key: { id, fromMe, remoteJid },
message,
messageTimestamp,
} = webMessageInfo;
logger.info("Message type debug");
for (const key in message) {
logger.info(
{ key, exists: !!message[key as keyof proto.IMessage] },
"Message field",
);
const { key, message, messageTimestamp } = webMessageInfo;
if (!key) {
logger.warn("Message missing key, skipping");
return;
}
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;
if (isValidMessage) {
const { audioMessage, documentMessage, imageMessage, videoMessage } = message;
@ -244,9 +258,27 @@ export default class WhatsappService extends Service {
videoMessage,
].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 = {
to: botID,
from: remoteJid?.split("@")[0],
from: senderPhone,
userId: senderUserId,
messageId: id,
sentAt: new Date((messageTimestamp as number) * 1000).toISOString(),
message: messageText,
@ -410,12 +442,17 @@ export default class WhatsappService extends Service {
}
async receive(
botID: string,
_botID: string,
_lastReceivedDate: Date,
): Promise<proto.IWebMessageInfo[]> {
const connection = this.connections[botID]?.socket;
const messages = await connection.loadAllUnreadMessages();
return messages;
// loadAllUnreadMessages() was removed in Baileys 7.x
// Messages are now delivered via events (messages.upsert, messaging-history.set)
// and forwarded to webhooks automatically.
// 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" {
interface SchmerviceDecorator {

View file

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

View file

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

View file

@ -168,6 +168,7 @@ const processMessage = async ({
token: id,
to: toRecipient,
from: source,
userId: sourceUuid, // Signal user UUID for user identification
messageId: `${sourceUuid}-${rawTimestamp}`,
message: dataMessage?.message,
sentAt: timestamp.toISOString(),
@ -204,17 +205,16 @@ const fetchSignalMessagesTask = async ({
if (scheduleTasks === "true") {
// because cron only has minimum 1 minute resolution
for (const offset of [15000, 30000, 45000]) {
await worker.addJob(
"fetch-signal-messages",
{ scheduleTasks: "false" },
{
maxAttempts: 1,
runAt: new Date(Date.now() + offset),
jobKey: `fetchSignalMessages-${offset}`,
},
);
}
// schedule one additional job at 30s to achieve 30-second polling
await worker.addJob(
"fetch-signal-messages",
{ scheduleTasks: "false" },
{
maxAttempts: 1,
runAt: new Date(Date.now() + 30000),
jobKey: "fetchSignalMessages-30000",
},
);
}
const messagesClient = new MessagesApi(config);

View file

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

View file

@ -64,13 +64,14 @@ const sendSignalMessageTask = async ({
let groupCreated = false;
try {
// Check if 'to' is a group ID (UUID format, group.base64 format, or base64) vs phone number
const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
to,
);
// Check if 'to' is a group ID (group.base64 format or base64 internal ID) vs individual recipient
// Signal group IDs are 32 bytes = 44 chars base64 (or 43 without padding)
// 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 isBase64 = /^[A-Za-z0-9+/]+=*$/.test(to) && to.length > 20; // Base64 internal_id
const isGroupId = isUUID || isGroupPrefix || isBase64;
const isBase64GroupId =
/^[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";
logger.debug(
@ -282,6 +283,35 @@ const sendSignalMessageTask = async ({
},
"Message sent successfully",
);
// Update group name to use consistent template with ticket number
// This ensures groups created by receive-signal-message get renamed
// to match the template (e.g., "Support Request: 94085")
if (finalTo.startsWith("group.") && conversationId) {
try {
const expectedGroupName = buildSignalGroupName(conversationId);
await groupsClient.v1GroupsNumberGroupidPut({
number: bot.phoneNumber,
groupid: finalTo,
data: {
name: expectedGroupName,
},
});
logger.debug(
{ groupId: finalTo, newName: expectedGroupName },
"Updated group name",
);
} catch (renameError) {
// Non-fatal - group name update is best-effort
logger.warn(
{
error: renameError instanceof Error ? renameError.message : renameError,
groupId: finalTo,
},
"Could not update group name",
);
}
}
} catch (error: any) {
// Try to get the actual error message from the response
if (error.response) {

View file

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

View file

@ -42,10 +42,11 @@ RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
RUN DEBIAN_FRONTEND=noninteractive apt-get update && \
apt-get install -y --no-install-recommends \
dumb-init
RUN mkdir -p ${APP_DIR}
RUN chown -R node ${APP_DIR}/
RUN mkdir -p ${APP_DIR} /pnpm
RUN chown -R node ${APP_DIR}/ /pnpm
USER node
RUN corepack prepare pnpm@9.15.4 --activate
WORKDIR ${APP_DIR}
COPY --from=installer ${APP_DIR} ./
USER root

View file

@ -14,6 +14,7 @@ import {
Google as GoogleIcon,
Microsoft as MicrosoftIcon,
Key as KeyIcon,
VpnKey as KeycloakIcon,
} from "@mui/icons-material";
import { signIn, getProviders } from "next-auth/react";
import Image from "next/image";
@ -200,6 +201,21 @@ export const Login: FC<LoginProps> = ({ session, baseURL }) => {
</IconButton>
</Grid>
)}
{provider === "keycloak" && (
<Grid item sx={{ width: "100%" }}>
<IconButton
sx={buttonStyles}
onClick={() =>
signIn("keycloak", {
callbackUrl,
})
}
>
<KeycloakIcon sx={{ mr: 1 }} />
Sign in with Keycloak
</IconButton>
</Grid>
)}
{provider === "credentials" && (
<Grid item container spacing={3}>
<Grid item sx={{ width: "100%" }}>

View file

@ -11,6 +11,7 @@ import Google from "next-auth/providers/google";
import Credentials from "next-auth/providers/credentials";
import Apple from "next-auth/providers/apple";
import AzureADProvider from "next-auth/providers/azure-ad";
import Keycloak from "next-auth/providers/keycloak";
import { createLogger } from "@link-stack/logger";
const logger = createLogger('link-authentication');
@ -101,6 +102,18 @@ if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
tenantId: process.env.AZURE_AD_TENANT_ID,
}),
);
} else if (
process.env.KEYCLOAK_CLIENT_ID &&
process.env.KEYCLOAK_CLIENT_SECRET &&
process.env.KEYCLOAK_ISSUER
) {
providers.push(
Keycloak({
clientId: process.env.KEYCLOAK_CLIENT_ID,
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
issuer: process.env.KEYCLOAK_ISSUER,
}),
);
} else {
providers.push(
Credentials({

View file

@ -1,6 +1,6 @@
{
"name": "@link-stack/link",
"version": "3.3.5",
"version": "3.5.0-beta.1",
"type": "module",
"scripts": {
"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/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
# Set up pnpm home

View file

@ -5,13 +5,13 @@ const app = process.argv[2];
const command = process.argv[3];
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"],
link: ["zammad", "postgresql", "opensearch", "link"],
linkOnly: ["link"],
opensearch: ["opensearch"],
bridgeDev: ["zammad", "postgresql", "signal-cli-rest-api"],
bridge: ["zammad", "postgresql", "bridge", "signal-cli-rest-api"],
bridgeDev: ["zammad", "postgresql", "signal-cli-rest-api", "bridge-whatsapp"],
bridge: ["zammad", "postgresql", "bridge", "signal-cli-rest-api", "bridge-whatsapp"],
zammad: ["zammad", "postgresql", "opensearch"],
};
@ -21,11 +21,12 @@ const finalFiles = files[app]
.map((file) => ['-f', `docker/compose/${file}.yml`]).flat();
// 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')) {
finalFiles.push('-f', 'docker-compose.bridge-dev.yml');
}
const finalCommand = command === "up" ? ["up", "-d", "--remove-orphans"] : [command];
const dockerCompose = spawn('docker', ['compose', '--env-file', envFile, ...finalFiles, ...finalCommand]);

View file

@ -66,7 +66,7 @@ RUN if [ "$EMBEDDED" = "true" ] ; then \
sed -i '$ d' /opt/zammad/contrib/nginx/zammad.conf && \
echo "" >> /opt/zammad/contrib/nginx/zammad.conf && \
echo " location /link {" >> /opt/zammad/contrib/nginx/zammad.conf && \
echo " proxy_pass ${LINK_HOST};" >> /opt/zammad/contrib/nginx/zammad.conf && \
echo " set \$link_url ${LINK_HOST}; proxy_pass \$link_url;" >> /opt/zammad/contrib/nginx/zammad.conf && \
echo " proxy_set_header Host \$host;" >> /opt/zammad/contrib/nginx/zammad.conf && \
echo " proxy_set_header X-Real-IP \$remote_addr;" >> /opt/zammad/contrib/nginx/zammad.conf && \
echo " proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;" >> /opt/zammad/contrib/nginx/zammad.conf && \

View file

@ -0,0 +1,506 @@
# Zammad Ticket Splits & Merges with Bridge Channels
## Investigation Summary
This document analyzes how Zammad handles ticket splits and merges, and the implications for our custom bridge channels (WhatsApp, Signal, Voice).
## Current State
### How Zammad Handles Split/Merge (Built-in)
#### Merge (`ticket.merge_to` in `app/models/ticket.rb:330`)
When ticket A is merged into ticket B:
1. All articles from A are moved to B
2. A "parent" link is created between A and B
3. Mentions and external links are migrated
4. Source ticket A's state is set to "merged"
5. Source ticket A's owner is reset to System (id: 1)
**Critical issue:** Ticket preferences are NOT copied or migrated. The target ticket B keeps its original preferences, and source ticket A's preferences become orphaned.
```ruby
# From app/models/ticket.rb - merge_to method
# Articles are moved:
Ticket::Article.where(ticket_id: id).update_all(['ticket_id = ?', data[:ticket_id]])
# But preferences are never touched - they stay on the source ticket
```
#### Split (`app/models/form_updater/concerns/applies_split_ticket_article.rb`)
When an article is split from ticket A to create new ticket C:
1. Basic ticket attributes are copied (group, customer, state, priority, title)
2. Attachments are cloned
3. A link is created to the original ticket
4. `owner_id` is explicitly deleted (not copied)
**Critical issue:** Preferences are NOT copied. The new ticket C has no channel metadata.
```ruby
# From applies_split_ticket_article.rb
def attributes_to_apply
attrs = selected_ticket_article.ticket.attributes
attrs['title'] = selected_ticket_article.subject if selected_ticket_article.subject.present?
attrs['body'] = body_with_form_id_urls
attrs.delete 'owner_id' # Explicitly deleted
attrs
# Note: preferences are NOT included in .attributes
end
```
#### Email Follow-up Handling (`app/models/channel/filter/follow_up_merged.rb`)
Zammad has a postmaster filter that handles incoming emails to merged tickets:
```ruby
def self.run(_channel, mail, _transaction_params)
return if mail[:'x-zammad-ticket-id'].blank?
referenced_ticket = Ticket.find_by(id: mail[:'x-zammad-ticket-id'])
return if referenced_ticket.blank?
new_target_ticket = find_merge_follow_up_ticket(referenced_ticket)
return if new_target_ticket.blank?
mail[:'x-zammad-ticket-id'] = new_target_ticket.id
end
```
This follows the parent link to find the active target ticket. **This only works for email** - no equivalent exists for other channels like Telegram, WhatsApp, or Signal.
---
## Bridge Channel Metadata Structure
Our bridge channels store critical routing metadata in `ticket.preferences`:
### WhatsApp
```ruby
ticket.preferences = {
channel_id: 123,
cdr_whatsapp: {
bot_token: "abc123", # Identifies which bot/channel
chat_id: "+1234567890" # Customer's phone number - WHERE TO SEND
}
}
```
### Signal (Direct Message)
```ruby
ticket.preferences = {
channel_id: 456,
cdr_signal: {
bot_token: "xyz789",
chat_id: "+1234567890" # Customer's phone number
}
}
```
### Signal (Group)
```ruby
ticket.preferences = {
channel_id: 456,
cdr_signal: {
bot_token: "xyz789",
chat_id: "group.abc123...", # Signal group ID
group_joined: true, # Whether customer accepted invite
group_joined_at: "2024-01-01",
original_recipient: "+1234567890"
}
}
```
---
## How Bridge Channels Use This Metadata
### Outgoing Messages
The communication jobs (`CommunicateCdrWhatsappJob`, `CommunicateCdrSignalJob`) rely entirely on ticket preferences:
```ruby
# From communicate_cdr_whatsapp_job.rb
def perform(article_id)
article = Ticket::Article.find(article_id)
ticket = Ticket.lookup(id: article.ticket_id)
# These MUST exist or the job fails:
unless ticket.preferences['cdr_whatsapp']['bot_token']
log_error(article, "Can't find ticket.preferences['cdr_whatsapp']['bot_token']")
end
unless ticket.preferences['cdr_whatsapp']['chat_id']
log_error(article, "Can't find ticket.preferences['cdr_whatsapp']['chat_id']")
end
channel = Channel.lookup(id: ticket.preferences['channel_id'])
result = channel.deliver(article) # Uses chat_id to know where to send
end
```
### Incoming Messages
The webhook controllers look up existing tickets:
**WhatsApp** (`channels_cdr_whatsapp_controller.rb`):
```ruby
# Find open ticket for this customer
state_ids = Ticket::State.where(name: %w[closed merged removed]).pluck(:id)
ticket = Ticket.where(customer_id: customer.id)
.where.not(state_id: state_ids)
.order(:updated_at).first
```
**Signal Groups** (`channels_cdr_signal_controller.rb`):
```ruby
# Find ticket by group ID in preferences
ticket = Ticket.where.not(state_id: state_ids)
.where("preferences LIKE ?", "%channel_id: #{channel.id}%")
.where("preferences LIKE ?", "%chat_id: #{receiver_phone_number}%")
.order(updated_at: :desc)
.first
```
---
## Problem Scenarios
### Scenario 1: Merge Bridge Ticket → Non-Bridge Ticket
**Setup:** Ticket A (has WhatsApp metadata) merged into Ticket B (no bridge metadata)
**What happens:**
- A's articles move to B
- A's preferences stay on A (now in merged state)
- B still has no bridge preferences
**Result:** Agent replies on ticket B fail - no `chat_id` to send to.
### Scenario 2: Merge Bridge Ticket → Different Bridge Ticket
**Setup:** Ticket A (WhatsApp to +111) merged into Ticket B (WhatsApp to +222)
**What happens:**
- A's articles move to B
- B keeps its preferences (`chat_id: +222`)
**Result:** Agent replies go to +222, not to +111. Customer +111 never receives responses.
### Scenario 3: Split Article from Bridge Ticket
**Setup:** Split an article from Ticket A (has WhatsApp metadata) to create Ticket C
**What happens:**
- New ticket C is created with no preferences
- C is linked to A
**Result:** Agent cannot reply via WhatsApp on ticket C at all - job fails immediately.
### Scenario 4: Incoming Message to Merged Ticket's Customer
**Setup:** Ticket A (customer +111) was merged into B. Customer +111 sends new message.
**What happens:**
- Webhook finds customer by phone number
- Looks for open ticket for customer
- A is excluded (merged state)
- Either finds B (if same customer) or creates new ticket
**Result:** May work if B has same customer, but conversation context is fragmented.
### Scenario 5: Signal Group Ticket Merged
**Setup:** Ticket A (Signal group X) merged into Ticket B (no Signal metadata)
**What happens:**
- All group messages went to A
- A is now merged, B has no group reference
- New messages from group X create a new ticket (can't find existing by group ID)
**Result:** Conversation splits into multiple tickets unexpectedly.
---
## Recommended Solutions
### Option 1: Preferences Migration on Merge (Recommended)
Create a concern that copies bridge channel metadata when tickets are merged:
```ruby
# app/models/ticket/merge_bridge_channel_preferences.rb
module Ticket::MergeBridgeChannelPreferences
extend ActiveSupport::Concern
included do
after_update :migrate_bridge_preferences_on_merge
end
private
def migrate_bridge_preferences_on_merge
return unless saved_change_to_state_id?
return unless state.state_type.name == 'merged'
target_ticket = find_merge_target
return unless target_ticket
# Copy bridge preferences if target doesn't have them
%w[cdr_whatsapp cdr_signal cdr_voice].each do |channel_key|
next unless preferences[channel_key].present?
next if target_ticket.preferences[channel_key].present?
target_ticket.preferences[channel_key] = preferences[channel_key].deep_dup
target_ticket.preferences['channel_id'] ||= preferences['channel_id']
end
target_ticket.save! if target_ticket.changed?
end
def find_merge_target
Link.list(link_object: 'Ticket', link_object_value: id)
.find { |l| l['link_type'] == 'parent' && l['link_object'] == 'Ticket' }
&.then { |l| Ticket.find_by(id: l['link_object_value']) }
end
end
```
**Pros:**
- Handles the common case (merging bridge ticket into non-bridge ticket)
- Automatic, no agent action required
- Non-destructive (doesn't overwrite existing preferences)
**Cons:**
- Doesn't handle case where both tickets have different bridge metadata
- May need additional logic for conflicting preferences
### Option 2: Follow-up Filter for Bridge Channels
Create filters similar to `FollowUpMerged` that redirect incoming bridge messages:
```ruby
# Modify webhook controllers to check for merged tickets
def find_active_ticket_for_customer(customer, state_ids)
ticket = Ticket.where(customer_id: customer.id)
.where.not(state_id: state_ids)
.order(:updated_at).first
# If ticket is merged, follow parent link
if ticket&.state&.state_type&.name == 'merged'
ticket = find_merge_target(ticket) || ticket
end
ticket
end
def find_merge_target(ticket)
Link.list(link_object: 'Ticket', link_object_value: ticket.id)
.filter_map do |link|
next if link['link_type'] != 'parent'
next if link['link_object'] != 'Ticket'
Ticket.joins(state: :state_type)
.where.not(ticket_state_types: { name: 'merged' })
.find_by(id: link['link_object_value'])
end.first
end
```
**Pros:**
- Handles incoming messages to merged tickets correctly
- Follows same pattern as Zammad's email handling
**Cons:**
- Requires modifying webhook controllers
- Only handles incoming direction, not outgoing
### Option 3: Copy Preferences on Split
Modify the split form updater or add a callback to copy bridge preferences:
```ruby
# Add to ticket creation from split
module Ticket::SplitBridgeChannelPreferences
extend ActiveSupport::Concern
included do
after_create :copy_bridge_preferences_from_source
end
private
def copy_bridge_preferences_from_source
# Find source ticket via link
source_link = Link.list(link_object: 'Ticket', link_object_value: id)
.find { |l| l['link_type'] == 'child' }
return unless source_link
source_ticket = Ticket.find_by(id: source_link['link_object_value'])
return unless source_ticket
# Copy bridge preferences
%w[cdr_whatsapp cdr_signal cdr_voice channel_id].each do |key|
next unless source_ticket.preferences[key].present?
self.preferences[key] = source_ticket.preferences[key].deep_dup
end
save! if changed?
end
end
```
### Option 4: UI Warning + Manual Handling
Add frontend validation to warn agents:
1. Check for bridge preferences before merge/split
2. Show warning dialog explaining implications
3. Optionally provide UI to manually transfer channel association
```typescript
// In merge confirmation dialog
const hasBridgeChannel = ticket.preferences?.cdr_whatsapp ||
ticket.preferences?.cdr_signal;
if (hasBridgeChannel) {
showWarning(
"This ticket uses WhatsApp/Signal messaging. " +
"Merging may affect message routing. " +
"Replies will be sent to the target ticket's contact."
);
}
```
### Option 5: Multi-Channel Preferences (Long-term)
Allow tickets to have multiple channel associations:
```ruby
ticket.preferences = {
bridge_channels: [
{ type: 'cdr_whatsapp', chat_id: '+111...', channel_id: 1, customer_id: 100 },
{ type: 'cdr_whatsapp', chat_id: '+222...', channel_id: 1, customer_id: 101 },
{ type: 'cdr_signal', chat_id: 'group.xxx', channel_id: 2 }
]
}
```
This would require significant refactoring of communication jobs to handle multiple recipients.
---
## Signal Groups - Special Considerations
Signal groups add complexity:
1. **Group ID is the routing key**, not phone number
2. **Multiple customers** might be in the same group
3. **`group_joined` flag** tracks invite acceptance - messages can't be sent until true
4. **Group membership changes** could affect ticket routing
### Merge Rules for Signal Groups
| Source Ticket | Target Ticket | Recommendation |
|---------------|---------------|----------------|
| Signal group A | No Signal | Copy preferences (Option 1) |
| Signal group A | Signal group A (same) | Safe to merge |
| Signal group A | Signal group B (different) | **Block or warn** - can't merge different group conversations |
| Signal group A | Signal DM | **Block or warn** - different communication modes |
Consider adding validation:
```ruby
def validate_signal_group_merge(source, target)
source_group = source.preferences.dig('cdr_signal', 'chat_id')
target_group = target.preferences.dig('cdr_signal', 'chat_id')
return true if source_group.blank? || target_group.blank?
return true if source_group == target_group
# Different groups - this is problematic
raise Exceptions::UnprocessableEntity,
"Cannot merge tickets from different Signal groups"
end
```
---
## Recommended Implementation Path
### Phase 1: Immediate (Low Risk)
1. **Add preferences migration on merge** (Option 1)
- Only copies if target doesn't have existing preferences
- Handles most common case safely
2. **Add preferences copy on split** (Option 3)
- New tickets get parent's channel metadata
- Enables replies on split tickets
### Phase 2: Short-term
3. **Add follow-up handling in webhooks** (Option 2)
- Modify webhook controllers to follow merge parent links
- Handles incoming messages to merged ticket's customer
4. **Add UI warnings** (Option 4)
- Warn agents about implications
- Especially for conflicting metadata scenarios
### Phase 3: Medium-term
5. **Add merge validation for Signal groups**
- Block merging tickets from different groups
- Or add clear warning about implications
6. **Add audit logging**
- Track when preferences are migrated
- Help agents understand what happened
---
## Files to Modify
### Zammad Addon (zammad-addon-bridge)
| File | Change |
|------|--------|
| `src/app/models/ticket/merge_bridge_channel_preferences.rb` | New - preferences migration |
| `src/app/models/ticket/split_bridge_channel_preferences.rb` | New - preferences copy on split |
| `src/app/controllers/channels_cdr_whatsapp_controller.rb` | Add merge follow-up handling |
| `src/app/controllers/channels_cdr_signal_controller.rb` | Add merge follow-up handling |
| `src/config/initializers/bridge.rb` | Include new concerns in Ticket model |
### Link Frontend (optional)
| File | Change |
|------|--------|
| Merge dialog component | Add warning for bridge tickets |
---
## Testing Scenarios
1. Merge WhatsApp ticket → empty ticket → verify agent can reply
2. Merge WhatsApp ticket → WhatsApp ticket (same number) → verify routing
3. Merge WhatsApp ticket → WhatsApp ticket (different number) → verify warning/behavior
4. Split article from WhatsApp ticket → verify new ticket has preferences
5. Customer sends message after their ticket was merged → verify routing
6. Merge Signal group ticket → verify group_joined flag is preserved
7. Merge two different Signal group tickets → verify validation/warning
---
## References
- Zammad merge implementation: `app/models/ticket.rb:330-450`
- Zammad split implementation: `app/models/form_updater/concerns/applies_split_ticket_article.rb`
- Zammad email follow-up filter: `app/models/channel/filter/follow_up_merged.rb`
- Bridge WhatsApp controller: `packages/zammad-addon-bridge/src/app/controllers/channels_cdr_whatsapp_controller.rb`
- Bridge Signal controller: `packages/zammad-addon-bridge/src/app/controllers/channels_cdr_signal_controller.rb`
- Bridge WhatsApp job: `packages/zammad-addon-bridge/src/app/jobs/communicate_cdr_whatsapp_job.rb`
- Bridge Signal job: `packages/zammad-addon-bridge/src/app/jobs/communicate_cdr_signal_job.rb`

View file

@ -0,0 +1,906 @@
# Ticket Field Propagation System
## Overview
A configurable system for copying/syncing fields between related tickets (parent/child, merged, linked). This addresses the bridge channel preferences problem while providing a general-purpose solution for custom fields.
## Problem Statement
Zammad creates relationships between tickets through:
- **Split**: Creates child ticket from parent's article
- **Merge**: Source ticket becomes child of target (merged state)
- **Manual linking**: Agents can link tickets as parent/child or related
Currently, no field values are propagated across these relationships except basic attributes on split. This causes issues when:
- Bridge channel metadata needs to follow the conversation
- Custom fields (account ID, region, priority score) should be inherited
- Parent ticket context should flow to children (or vice versa)
## Use Cases
### Use Case 1: Bridge Channel Inheritance (Immediate Need)
When a ticket is split or merged, the bridge channel metadata (`preferences.cdr_whatsapp`, `preferences.cdr_signal`) should be copied so agents can reply via the same channel.
### Use Case 2: Custom Field Inheritance
Organization uses custom fields like `account_tier`, `region`, `contract_id`. When splitting a ticket, the child should inherit these values.
### Use Case 3: Escalation Propagation
When a child ticket is escalated (custom `escalation_level` field), the parent should be updated to reflect this.
### Use Case 4: SLA Context
Parent ticket has SLA deadline. Child tickets should inherit or reference this deadline.
### Use Case 5: Bulk Operations
When updating a parent ticket's category, optionally cascade to all children.
---
## Design
### Terminology
| Term | Definition |
|------|------------|
| **Source** | The ticket providing the field value |
| **Target** | The ticket receiving the field value |
| **Direction** | Which way data flows (parent→child, child→parent, source→target on merge) |
| **Trigger** | The event that initiates propagation (split, merge, update, link_create) |
| **Condition** | When to apply the copy (always, if_empty, if_greater, custom) |
| **Field Path** | Dot-notation path to the field (`preferences.cdr_whatsapp.chat_id`) |
### Field Types
The system must handle different field storage mechanisms:
```ruby
# 1. Standard ticket attributes
ticket.group_id
ticket.priority_id
ticket.organization_id
# 2. Preferences hash (nested)
ticket.preferences['channel_id']
ticket.preferences['cdr_whatsapp']['chat_id']
ticket.preferences['cdr_signal']['group_joined']
# 3. Custom object attributes (Zammad ObjectManager)
ticket.custom_account_id # Added via Admin → Objects → Ticket
ticket.custom_region
ticket.custom_escalation_level
# 4. Tags (special handling)
ticket.tag_list # Array of strings
```
### Configuration Schema
```ruby
# Stored in Setting or dedicated table
TicketFieldPropagation.configure do |config|
# Define field groups for convenience
config.field_group :bridge_channel, [
'preferences.channel_id',
'preferences.cdr_whatsapp', # Copies entire hash
'preferences.cdr_signal',
'preferences.cdr_voice'
]
config.field_group :customer_context, [
'organization_id',
'custom_account_id',
'custom_region',
'custom_contract_id'
]
config.field_group :sla_context, [
'custom_sla_deadline',
'custom_escalation_level'
]
# Define propagation rules
# Bridge preferences: copy to child on split if child doesn't have them
config.rule :bridge_on_split do |r|
r.fields :bridge_channel
r.trigger :split
r.direction :parent_to_child
r.condition :if_target_empty
r.timing :immediate
end
# Bridge preferences: copy to target on merge if target doesn't have them
config.rule :bridge_on_merge do |r|
r.fields :bridge_channel
r.trigger :merge
r.direction :source_to_target
r.condition :if_target_empty
r.timing :immediate
end
# Customer context: always copy to child on split
config.rule :customer_context_on_split do |r|
r.fields :customer_context
r.trigger :split
r.direction :parent_to_child
r.condition :always
r.timing :immediate
end
# Escalation: propagate highest level to parent
config.rule :escalation_to_parent do |r|
r.fields ['custom_escalation_level']
r.trigger :update
r.direction :child_to_parent
r.condition :if_greater
r.timing :deferred # Use job queue
end
# Manual sync: allow agent to trigger full sync
config.rule :manual_sync do |r|
r.fields [:customer_context, :sla_context]
r.trigger :manual
r.direction :parent_to_children # All children
r.condition :always
r.timing :immediate
end
end
```
### Alternative: JSON Configuration
For storage in Zammad's `Setting` table:
```json
{
"field_groups": {
"bridge_channel": [
"preferences.channel_id",
"preferences.cdr_whatsapp",
"preferences.cdr_signal"
],
"customer_context": [
"organization_id",
"custom_account_id",
"custom_region"
]
},
"rules": [
{
"name": "bridge_on_split",
"fields": ["@bridge_channel"],
"trigger": "split",
"direction": "parent_to_child",
"condition": "if_target_empty",
"enabled": true
},
{
"name": "bridge_on_merge",
"fields": ["@bridge_channel"],
"trigger": "merge",
"direction": "source_to_target",
"condition": "if_target_empty",
"enabled": true
},
{
"name": "customer_context_inherit",
"fields": ["@customer_context"],
"trigger": "split",
"direction": "parent_to_child",
"condition": "always",
"enabled": true
}
]
}
```
---
## Architecture
### Components
```
┌─────────────────────────────────────────────────────────────────┐
│ TicketFieldPropagation │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Configuration│ │ Engine │ │ FieldAccessor │ │
│ │ │───▶│ │───▶│ │ │
│ │ - field_groups │ - execute() │ │ - get(path) │ │
│ │ - rules │ │ - apply_rule │ │ - set(path, val) │ │
│ │ - load/save │ │ - find_related │ - deep_merge │ │
│ └──────────────┘ └──────────────┘ └──────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ RelationshipFinder │ │
│ │ │ │
│ │ - find_parent(ticket) │ │
│ │ - find_children(ticket) │ │
│ │ - find_merge_target(ticket) │ │
│ │ - find_merge_source(ticket) │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Triggers │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌────────────────┐ ┌────────────────┐ ┌─────────────────┐ │
│ │ Ticket Concern │ │ Transaction │ │ Manual API │ │
│ │ │ │ Observer │ │ Endpoint │ │
│ │ after_create │ │ │ │ │ │
│ │ after_update │ │ on merge event │ │ POST /tickets/ │ │
│ │ after_save │ │ on split event │ │ :id/propagate │ │
│ └────────────────┘ └────────────────┘ └─────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
```
### Class Design
```ruby
# lib/ticket_field_propagation/configuration.rb
module TicketFieldPropagation
class Configuration
attr_accessor :field_groups, :rules
def self.load
# Load from Setting table or YAML
end
def field_group(name, fields)
@field_groups[name] = fields
end
def rule(name, &block)
rule = Rule.new(name)
block.call(rule)
@rules << rule
end
def expand_fields(field_refs)
# Expand @group_name references to actual field list
field_refs.flat_map do |ref|
if ref.start_with?('@')
@field_groups[ref[1..].to_sym] || []
else
[ref]
end
end
end
end
class Rule
attr_accessor :name, :fields, :trigger, :direction, :condition, :timing
def initialize(name)
@name = name
@timing = :immediate
@condition = :always
end
def applies_to?(event_type)
@trigger == event_type || (@trigger.is_a?(Array) && @trigger.include?(event_type))
end
end
end
```
```ruby
# lib/ticket_field_propagation/engine.rb
module TicketFieldPropagation
class Engine
def initialize(source_ticket, event_type, target_ticket: nil)
@source = source_ticket
@event = event_type
@explicit_target = target_ticket
@config = Configuration.load
end
def execute
applicable_rules.each do |rule|
if rule.timing == :deferred
PropagationJob.perform_later(@source.id, rule.name)
else
apply_rule(rule)
end
end
end
private
def applicable_rules
@config.rules.select { |r| r.applies_to?(@event) && r.enabled }
end
def apply_rule(rule)
targets = find_targets(rule.direction)
fields = @config.expand_fields(rule.fields)
targets.each do |target|
source = determine_source(rule.direction, target)
PropagationResult.log(@source, target, rule)
fields.each do |field_path|
copy_field(source, target, field_path, rule.condition)
end
target.save! if target.changed?
end
end
def find_targets(direction)
case direction
when :parent_to_child, :parent_to_children
RelationshipFinder.find_children(@source)
when :child_to_parent
[RelationshipFinder.find_parent(@source)].compact
when :source_to_target
[@explicit_target || RelationshipFinder.find_merge_target(@source)].compact
else
[]
end
end
def determine_source(direction, target)
case direction
when :parent_to_child, :parent_to_children, :source_to_target
@source
when :child_to_parent
target # We're copying FROM child TO parent, so target is source here
# Wait, this is confusing. Let me reconsider...
@source # The ticket that triggered the event is the source
end
end
def copy_field(source, target, field_path, condition)
source_value = FieldAccessor.get(source, field_path)
return if source_value.nil?
target_value = FieldAccessor.get(target, field_path)
case condition
when :if_target_empty
return if target_value.present?
when :if_greater
return if target_value.present? && target_value >= source_value
when :always
# proceed
end
FieldAccessor.set(target, field_path, source_value)
end
end
end
```
```ruby
# lib/ticket_field_propagation/field_accessor.rb
module TicketFieldPropagation
class FieldAccessor
class << self
def get(ticket, field_path)
parts = field_path.split('.')
value = ticket
parts.each do |part|
value = access_part(value, part)
return nil if value.nil?
end
# Deep dup hashes to prevent mutation
value.is_a?(Hash) ? value.deep_dup : value
end
def set(ticket, field_path, value)
parts = field_path.split('.')
if parts.length == 1
# Direct attribute
set_attribute(ticket, parts[0], value)
else
# Nested in preferences or similar
set_nested(ticket, parts, value)
end
end
private
def access_part(object, part)
if object.is_a?(Hash)
object[part] || object[part.to_sym]
elsif object.respond_to?(part)
object.send(part)
elsif object.respond_to?(:[])
object[part]
else
nil
end
end
def set_attribute(ticket, attr_name, value)
if ticket.respond_to?("#{attr_name}=")
ticket.send("#{attr_name}=", value)
else
raise ArgumentError, "Unknown attribute: #{attr_name}"
end
end
def set_nested(ticket, parts, value)
# e.g., ['preferences', 'cdr_whatsapp']
root = parts[0]
if root == 'preferences'
ticket.preferences ||= {}
set_hash_path(ticket.preferences, parts[1..], value)
else
raise ArgumentError, "Unsupported nested path root: #{root}"
end
end
def set_hash_path(hash, remaining_parts, value)
if remaining_parts.length == 1
key = remaining_parts[0]
if value.is_a?(Hash) && hash[key].is_a?(Hash)
# Deep merge for hash values
hash[key] = hash[key].deep_merge(value)
else
hash[key] = value
end
else
key = remaining_parts[0]
hash[key] ||= {}
set_hash_path(hash[key], remaining_parts[1..], value)
end
end
end
end
end
```
```ruby
# lib/ticket_field_propagation/relationship_finder.rb
module TicketFieldPropagation
class RelationshipFinder
class << self
def find_parent(ticket)
# In Zammad links: parent ticket has link_type 'child' pointing to it
# Wait, need to verify Zammad's link semantics...
#
# From merge: source ticket gets a 'parent' link pointing TO target
# Link.add(link_type: 'parent', source: target_id, target: source_id)
#
# So to find parent of a ticket, look for 'parent' links where
# this ticket is the target (link_object_target_value)
links = Link.list(
link_object: 'Ticket',
link_object_value: ticket.id
)
parent_link = links.find { |l| l['link_type'] == 'parent' }
return nil unless parent_link
Ticket.find_by(id: parent_link['link_object_value'])
end
def find_children(ticket)
links = Link.list(
link_object: 'Ticket',
link_object_value: ticket.id
)
child_links = links.select { |l| l['link_type'] == 'child' }
child_ids = child_links.map { |l| l['link_object_value'] }
Ticket.where(id: child_ids).to_a
end
def find_merge_target(ticket)
# Merged ticket has 'parent' link to target
return nil unless ticket.state.state_type.name == 'merged'
find_parent(ticket)
end
def find_merge_sources(ticket)
# Find tickets that were merged into this one
links = Link.list(
link_object: 'Ticket',
link_object_value: ticket.id
)
# Look for child links where the child is in merged state
child_links = links.select { |l| l['link_type'] == 'child' }
child_links.filter_map do |link|
child = Ticket.find_by(id: link['link_object_value'])
child if child&.state&.state_type&.name == 'merged'
end
end
end
end
end
```
### Integration Points
#### 1. Ticket Concern (for create/update triggers)
```ruby
# app/models/ticket/field_propagation.rb
module Ticket::FieldPropagation
extend ActiveSupport::Concern
included do
after_create :trigger_propagation_on_create
after_update :trigger_propagation_on_update
end
private
def trigger_propagation_on_create
# Check if this is a split (has parent link created simultaneously)
# This is tricky because link might be created after ticket...
# May need to hook into Link.add instead
end
def trigger_propagation_on_update
return unless saved_change_to_attribute?(:state_id)
if state.state_type.name == 'merged'
TicketFieldPropagation::Engine.new(self, :merge).execute
end
end
end
```
#### 2. Transaction Observer (for merge/split events)
```ruby
# app/models/transaction/ticket_field_propagation.rb
class Transaction::TicketFieldPropagation
def self.execute(object, type, _changes, user_id, _options)
return unless object.is_a?(Ticket)
case type
when 'update.merged_into'
# Source ticket was merged - propagate to target
target = TicketFieldPropagation::RelationshipFinder.find_merge_target(object)
TicketFieldPropagation::Engine.new(object, :merge, target_ticket: target).execute
when 'update.received_merge'
# Target ticket received a merge - could trigger reverse propagation if needed
when 'create'
# Check if this is from a split (check for immediate parent link)
parent = TicketFieldPropagation::RelationshipFinder.find_parent(object)
if parent.present?
TicketFieldPropagation::Engine.new(parent, :split, target_ticket: object).execute
end
end
end
end
```
#### 3. Manual API Endpoint
```ruby
# app/controllers/ticket_field_propagation_controller.rb
class TicketFieldPropagationController < ApplicationController
before_action :authenticate_and_authorize
# POST /api/v1/tickets/:id/propagate
def propagate
ticket = Ticket.find(params[:id])
direction = params[:direction] || 'to_children'
fields = params[:fields] || 'all'
case direction
when 'to_children'
engine = TicketFieldPropagation::Engine.new(ticket, :manual)
engine.execute_for_fields(fields, direction: :parent_to_children)
when 'from_parent'
parent = TicketFieldPropagation::RelationshipFinder.find_parent(ticket)
return render json: { error: 'No parent ticket' }, status: :not_found unless parent
engine = TicketFieldPropagation::Engine.new(parent, :manual, target_ticket: ticket)
engine.execute_for_fields(fields, direction: :parent_to_child)
end
render json: { success: true }
end
# GET /api/v1/tickets/:id/propagation_preview
def preview
# Show what would be copied without doing it
end
end
```
---
## Handling Edge Cases
### 1. Circular Reference Prevention
```ruby
class Engine
MAX_DEPTH = 5
def execute(depth: 0)
return if depth >= MAX_DEPTH
# Track processed tickets in this chain
Thread.current[:propagation_chain] ||= Set.new
return if Thread.current[:propagation_chain].include?(@source.id)
Thread.current[:propagation_chain].add(@source.id)
begin
# ... execute rules
ensure
Thread.current[:propagation_chain].delete(@source.id)
end
end
end
```
### 2. Conflicting Values on Merge
When both source and target have values, the default is "don't overwrite" (`if_target_empty`). But we could support strategies:
```ruby
config.rule :merge_preferences do |r|
r.fields ['preferences.cdr_whatsapp']
r.trigger :merge
r.direction :source_to_target
r.condition :merge_hash # Deep merge instead of replace
end
```
Or with explicit conflict resolution:
```ruby
r.on_conflict do |source_val, target_val, field|
case field
when /escalation/
[source_val, target_val].max
when /preferences\.cdr_/
target_val.presence || source_val # Keep target if present
else
source_val # Default: source wins
end
end
```
### 3. Multiple Bridge Channels
If source has WhatsApp and target has Signal, we might want both:
```ruby
# Current behavior with if_target_empty:
# - Source: {cdr_whatsapp: {...}}
# - Target: {cdr_signal: {...}}
# - Result: Target keeps cdr_signal, gains cdr_whatsapp (both present)
# This works because we check per-field, not per-category
```
### 4. Signal Group Merge Validation
Special case: don't allow merging tickets from different Signal groups:
```ruby
config.rule :validate_signal_merge do |r|
r.trigger :merge
r.validator ->(source, target) {
source_group = source.preferences.dig('cdr_signal', 'chat_id')
target_group = target.preferences.dig('cdr_signal', 'chat_id')
# OK if either doesn't have signal, or same group
return true if source_group.blank? || target_group.blank?
return true if source_group == target_group
# Different groups - block the merge
raise Exceptions::UnprocessableEntity,
"Cannot merge tickets from different Signal groups"
}
end
```
---
## Audit Trail
Track what was propagated for debugging and transparency:
```ruby
# app/models/ticket_field_propagation_log.rb
class TicketFieldPropagationLog < ApplicationRecord
belongs_to :source_ticket, class_name: 'Ticket'
belongs_to :target_ticket, class_name: 'Ticket'
# Columns:
# - source_ticket_id
# - target_ticket_id
# - rule_name
# - field_path
# - old_value (serialized)
# - new_value (serialized)
# - trigger_event
# - created_by_id
# - created_at
end
```
Or simpler: add to ticket history:
```ruby
target_ticket.history_log(
'field_propagated',
UserInfo.current_user_id,
value_from: source_ticket.id,
value_to: { field: field_path, value: new_value.to_s.truncate(100) }
)
```
---
## Configuration UI (Future)
Admin interface at Settings → Ticket → Field Propagation:
```
┌─────────────────────────────────────────────────────────────────┐
│ Field Propagation Rules [+Add]│
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ [✓] Bridge Channel on Split [Edit] │ │
│ │ Copy: preferences.cdr_whatsapp, preferences.cdr_signal │ │
│ │ When: Ticket is split │ │
│ │ Direction: Parent → Child │ │
│ │ Condition: Only if child field is empty │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ [✓] Bridge Channel on Merge [Edit] │ │
│ │ Copy: preferences.cdr_whatsapp, preferences.cdr_signal │ │
│ │ When: Ticket is merged │ │
│ │ Direction: Source → Target │ │
│ │ Condition: Only if target field is empty │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ [ ] Customer Context Inheritance [Edit] │ │
│ │ Copy: organization_id, custom_account_id, custom_region │ │
│ │ When: Ticket is split │ │
│ │ Direction: Parent → Child │ │
│ │ Condition: Always │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
```
---
## Default Configuration
Out-of-the-box settings that solve the bridge preferences problem:
```json
{
"field_groups": {
"bridge_channel": [
"preferences.channel_id",
"preferences.cdr_whatsapp",
"preferences.cdr_signal",
"preferences.cdr_voice"
]
},
"rules": [
{
"name": "bridge_on_split",
"description": "Copy bridge channel info when splitting tickets",
"fields": ["@bridge_channel"],
"trigger": "split",
"direction": "parent_to_child",
"condition": "if_target_empty",
"enabled": true
},
{
"name": "bridge_on_merge",
"description": "Copy bridge channel info when merging tickets",
"fields": ["@bridge_channel"],
"trigger": "merge",
"direction": "source_to_target",
"condition": "if_target_empty",
"enabled": true
}
]
}
```
---
## Implementation Phases
### Phase 1: Core Engine (Solves Bridge Problem)
- FieldAccessor with dot-notation support
- RelationshipFinder for parent/child/merge relationships
- Engine with basic rule processing
- Hardcoded rules for bridge channel propagation
- Integration with Ticket merge (via concern or observer)
### Phase 2: Configuration System
- JSON configuration in Setting table
- Field groups support
- Multiple condition types (if_empty, always, if_greater)
- Deferred execution via jobs
### Phase 3: Split Integration
- Hook into ticket split workflow
- Detect parent relationship after split
- Apply split rules
### Phase 4: Manual Triggers
- API endpoint for manual propagation
- Preview endpoint
- Audit logging
### Phase 5: Admin UI
- Configuration interface in Zammad admin
- Visual rule builder
- Field picker for custom object attributes
### Phase 6: Advanced Features
- Bidirectional sync
- Conflict resolution strategies
- Cascading updates
- Validation rules (like Signal group merge prevention)
---
## Files to Create
```
packages/zammad-addon-bridge/src/
├── lib/
│ └── ticket_field_propagation/
│ ├── configuration.rb
│ ├── engine.rb
│ ├── field_accessor.rb
│ ├── relationship_finder.rb
│ └── propagation_job.rb
├── app/
│ ├── models/
│ │ └── ticket/
│ │ └── field_propagation.rb # Concern
│ └── controllers/
│ └── ticket_field_propagation_controller.rb
├── config/
│ └── initializers/
│ └── ticket_field_propagation.rb # Default config & include concern
└── db/
└── seeds/
└── field_propagation_settings.rb
```
---
## Relationship to Bridge Preferences Problem
The bridge preferences problem from the previous investigation is solved by:
1. **Default rule `bridge_on_merge`**: Copies `preferences.cdr_whatsapp` and `preferences.cdr_signal` from source to target when tickets are merged, if target doesn't already have them.
2. **Default rule `bridge_on_split`**: Copies the same preferences from parent to child when tickets are split.
3. **Extensibility**: Additional custom fields can be added to propagation rules without code changes.
This makes the field propagation system a superset solution that handles the immediate bridge problem while providing a framework for future field synchronization needs.

View file

@ -1,6 +1,6 @@
{
"name": "@link-stack",
"version": "3.3.5",
"version": "3.5.0-beta.1",
"description": "Link from the Center for Digital Resilience",
"scripts": {
"dev": "dotenv -- turbo dev",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -7,6 +7,8 @@ class ChannelCdrSignal extends App.ControllerSubContent
'click .js-disable': 'disable'
'click .js-enable': 'enable'
'click .js-rotate-token': 'rotateToken'
'click .js-set-notification': 'setNotification'
'click .js-unset-notification': 'unsetNotification'
constructor: ->
super
@ -41,6 +43,8 @@ class ChannelCdrSignal extends App.ControllerSubContent
channels.push channel
@html App.view('cdr_signal/index')(
channels: channels
notificationEnabled: data.notification_enabled
notificationChannelId: data.notification_channel_id
)
new: (e) =>
@ -124,6 +128,31 @@ class ChannelCdrSignal extends App.ControllerSubContent
@load()
)
setNotification: (e) =>
e.preventDefault()
id = $(e.target).closest('.action').data('id')
@ajax(
id: 'cdr_signal_set_notification'
type: 'POST'
url: "#{@apiPath}/channels_cdr_signal_set_notification"
data: JSON.stringify(id: id)
processData: true
success: =>
@load()
)
unsetNotification: (e) =>
e.preventDefault()
@ajax(
id: 'cdr_signal_unset_notification'
type: 'POST'
url: "#{@apiPath}/channels_cdr_signal_unset_notification"
data: JSON.stringify({})
processData: true
success: =>
@load()
)
class FormAdd extends App.ControllerModal
head: 'Add Web Form'
shown: true

View file

@ -0,0 +1,220 @@
class ProfileNotification extends App.ControllerSubContent
@include App.TicketNotificationMatrix
@requiredPermission: 'user_preferences.notifications+ticket.agent'
header: __('Notifications')
events:
'submit form': 'update'
'click .js-reset' : 'reset'
'change .js-notificationSound': 'previewSound'
'change #profile-groups-limit': 'didSwitchGroupsLimit'
'change input[name=group_ids]': 'didChangeGroupIds'
'change input[name$=".channel.signal"]': 'didChangeSignalCheckbox'
elements:
'#profile-groups-limit': 'profileGroupsLimitInput'
'.profile-groups-limit-settings-inner': 'groupsLimitSettings'
'.profile-groups-all-unchecked': 'groupsAllUncheckedWarning'
sounds: [
{
name: 'Bell'
file: 'Bell.mp3'
},
{
name: 'Kalimba'
file: 'Kalimba.mp3'
},
{
name: 'Marimba'
file: 'Marimba.mp3'
},
{
name: 'Peep'
file: 'Peep.mp3'
},
{
name: 'Plop'
file: 'Plop.mp3'
},
{
name: 'Ring'
file: 'Ring.mp3'
},
{
name: 'Space'
file: 'Space.mp3'
},
{
name: 'Wood'
file: 'Wood.mp3'
},
{
name: 'Xylo'
file: 'Xylo.mp3'
}
]
constructor: ->
super
App.User.full(App.Session.get().id, @render, true, true)
render: =>
matrix =
create:
name: __('New Ticket')
update:
name: __('Ticket update')
reminder_reached:
name: __('Ticket reminder reached')
escalation:
name: __('Ticket escalation')
config =
group_ids: []
matrix: {}
user_config = @Session.get('preferences').notification_config
if user_config
config = $.extend(true, {}, config, user_config)
# groups
user_group_config = true
if !user_config || !user_config['group_ids'] || _.isEmpty(user_config['group_ids']) || user_config['group_ids'][0] is '-'
user_group_config = false
groups = []
group_ids = App.User.find(@Session.get('id')).allGroupIds()
if group_ids
for group_id in group_ids
group = App.Group.find(group_id)
groups.push group
if !user_group_config
if !config['group_ids']
config['group_ids'] = []
config['group_ids'].push group_id.toString()
groups = _.sortBy(groups, (item) -> return item.name)
for sound in @sounds
sound.selected = sound.file is App.OnlineNotification.soundFile() ? true : false
signal_notification_enabled = App.Config.get('signal_notification_enabled')
signal_uid = config.signal_uid || ''
# Check if any signal checkbox is currently checked in the matrix
signal_has_checked = false
if signal_notification_enabled
for key, val of config.matrix
if val?.channel?.signal
signal_has_checked = true
break
@html App.view('profile/notification')
matrixTableHTML: @renderNotificationMatrix(config.matrix)
groups: groups
config: config
sounds: @sounds
notificationSoundEnabled: App.OnlineNotification.soundEnabled()
user_group_config: user_group_config
signal_notification_enabled: signal_notification_enabled
signal_uid: signal_uid
signal_has_checked: signal_has_checked
update: (e) =>
#notification_config
e.preventDefault()
params = {}
params.notification_config = {}
formParams = @formParam(e.target)
params.notification_config.matrix = @updatedNotificationMatrixValues(formParams)
if formParams.signal_uid?
params.notification_config.signal_uid = formParams.signal_uid
if @profileGroupsLimitInput.is(':checked')
params.notification_config.group_ids = formParams['group_ids']
if typeof params.notification_config.group_ids isnt 'object'
params.notification_config.group_ids = [params.notification_config.group_ids]
if _.isEmpty(params.notification_config.group_ids)
delete params.notification_config.group_ids
@formDisable(e)
params.notification_sound = formParams.notification_sound
if !params.notification_sound.enabled
params.notification_sound.enabled = false
else
params.notification_sound.enabled = true
# get data
@ajax(
id: 'preferences'
type: 'PUT'
url: @apiPath + '/users/preferences'
data: JSON.stringify(params)
processData: true
success: @success
error: @error
)
reset: (e) =>
new App.ControllerConfirm(
message: __('Are you sure? Your notifications settings will be reset to default.')
buttonClass: 'btn--danger'
callback: =>
@ajax(
id: 'preferences_notifications_reset'
type: 'POST'
url: "#{@apiPath}/users/preferences_notifications_reset"
processData: true
success: @success
)
container: @el.closest('.content')
)
success: (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
)
previewSound: (e) =>
params = @formParam(e.target)
return if !params.notification_sound
return if !params.notification_sound.file
App.OnlineNotification.play(params.notification_sound.file)
didSwitchGroupsLimit: (e) =>
@groupsLimitSettings.collapse('toggle')
didChangeGroupIds: (e) =>
@groupsAllUncheckedWarning.toggleClass 'hide', @el.find('input[name=group_ids]:checked').length != 0
didChangeSignalCheckbox: (e) =>
hasChecked = @el.find('input[name$=".channel.signal"]:checked').length > 0
@el.find('.js-signal-phone-container').toggle(hasChecked)
App.Config.set('Notifications', { prio: 2600, name: __('Notifications'), parent: '#profile', target: '#profile/notifications', permission: ['user_preferences.notifications+ticket.agent'], controller: ProfileNotification }, 'NavBarProfile')

View file

@ -0,0 +1,15 @@
# coffeelint: disable=camel_case_classes
class App.UiElement.notification_matrix
@render: (values, options = {}) ->
matrixYAxe =
create:
name: __('New Ticket')
update:
name: __('Ticket update')
reminder_reached:
name: __('Ticket reminder reached')
escalation:
name: __('Ticket escalation')
$( App.view('generic/notification_matrix')( matrixYAxe: matrixYAxe, values: values, signal_notification_enabled: options.signal_notification_enabled ) )

View file

@ -0,0 +1,29 @@
# Common handling for the notification matrix
App.TicketNotificationMatrix =
renderNotificationMatrix: (values) ->
App.UiElement.notification_matrix.render(values, signal_notification_enabled: App.Config.get('signal_notification_enabled'))[0].outerHTML
updatedNotificationMatrixValues: (formParams) ->
matrix = {}
for key, value of formParams
area = key.split('.')
continue if area[0] isnt 'matrix'
if !matrix[area[1]]
matrix[area[1]] = {}
switch area[2]
when 'criteria'
if !matrix[area[1]][area[2]]
matrix[area[1]][area[2]] = {}
matrix[area[1]][area[2]][area[3]] = value is 'true'
when 'channel'
if !matrix[area[1]][area[2]]
matrix[area[1]][area[2]] = { online: true }
matrix[area[1]][area[2]][area[3]] = value is 'true'
matrix

View file

@ -20,6 +20,9 @@
<div class="action <% if channel.active isnt true: %>is-inactive<% end %>" data-id="<%= channel.id %>">
<div class="action-block action-row">
<h2><%- @Icon('status', 'supergood-color inline') %> <%= channel.options.phone_number %></h2>
<% if @notificationEnabled and @notificationChannelId is channel.id: %>
<span class="label label--success" style="margin-left: 10px;"><%- @T('Agent Notifications') %></span>
<% end %>
</div>
<div class="action-flow action-flow--row">
<div class="action-block">
@ -42,6 +45,11 @@
<% else: %>
<div class="btn btn--secondary js-enable"><%- @T('Enable') %></div>
<% end %>
<% if @notificationEnabled and @notificationChannelId is channel.id: %>
<div class="btn btn--secondary js-unset-notification"><%- @T('Disable Agent Notifications') %></div>
<% else if channel.active is true: %>
<div class="btn btn--success btn--secondary js-set-notification"><%- @T('Use for Agent Notifications') %></div>
<% end %>
<div class="btn js-edit"><%- @T('Edit') %></div>
</div>
</div>

View file

@ -0,0 +1,70 @@
<% if @signal_notification_enabled: %>
<% colWidth = "13%" %>
<% channelWidth = "100px" %>
<% else: %>
<% colWidth = "16%" %>
<% channelWidth = "120px" %>
<% end %>
<table class="settings-list">
<thead>
<tr>
<th>
<th width="<%= colWidth %>" style="text-align: center;"><%- @T('My Tickets') %>
<th width="<%= colWidth %>" style="text-align: center;"><%- @T('Not Assigned') %>*
<th width="<%= colWidth %>" style="text-align: center;"><%- @T('Subscribed Tickets') %>
<th width="<%= colWidth %>" style="text-align: center;"><%- @T('All Tickets') %>*
<th width="<%= channelWidth %>" class="settings-list-separator" style="text-align: center;"><%- @T('Also notify via email') %>
<% if @signal_notification_enabled: %>
<th width="<%= channelWidth %>" class="settings-list-separator" style="text-align: center;"><%- @T('Also notify via Signal') %>
<% end %>
</thead>
<tbody>
<% if @matrixYAxe: %>
<% for key, value of @matrixYAxe: %>
<tr>
<td>
<%- @T(value.name) %>
<% criteria = @values[key]?.criteria %>
<% channel = @values[key]?.channel %>
<td class="u-positionOrigin">
<label class="checkbox-replacement checkbox-replacement--fullscreen">
<input type="checkbox" name="matrix.<%= key %>.criteria.owned_by_me" value="true"<% if criteria && criteria.owned_by_me: %> checked<% end %> />
<%- @Icon('checkbox', 'icon-unchecked') %>
<%- @Icon('checkbox-checked', 'icon-checked') %>
</label>
<td class="u-positionOrigin">
<label class="checkbox-replacement checkbox-replacement--fullscreen">
<input type="checkbox" name="matrix.<%= key %>.criteria.owned_by_nobody" value="true"<% if criteria && criteria.owned_by_nobody: %> checked<% end %> />
<%- @Icon('checkbox', 'icon-unchecked') %>
<%- @Icon('checkbox-checked', 'icon-checked') %>
</label>
<td class="u-positionOrigin">
<label class="checkbox-replacement checkbox-replacement--fullscreen">
<input type="checkbox" name="matrix.<%= key %>.criteria.subscribed" value="true"<% if criteria && criteria.subscribed: %> checked<% end %> />
<%- @Icon('checkbox', 'icon-unchecked') %>
<%- @Icon('checkbox-checked', 'icon-checked') %>
</label>
<td class="u-positionOrigin">
<label class="checkbox-replacement checkbox-replacement--fullscreen">
<input type="checkbox" name="matrix.<%= key %>.criteria.no" value="true"<% if criteria && criteria.no: %> checked<% end %> />
<%- @Icon('checkbox', 'icon-unchecked') %>
<%- @Icon('checkbox-checked', 'icon-checked') %>
</label>
<td class="u-positionOrigin settings-list-separator">
<label class="checkbox-replacement checkbox-replacement--fullscreen">
<input type="checkbox" name="matrix.<%= key %>.channel.email" value="true"<% if channel && channel.email: %> checked<% end %> />
<%- @Icon('checkbox', 'icon-unchecked') %>
<%- @Icon('checkbox-checked', 'icon-checked') %>
</label>
<% if @signal_notification_enabled: %>
<td class="u-positionOrigin settings-list-separator">
<label class="checkbox-replacement checkbox-replacement--fullscreen">
<input type="checkbox" name="matrix.<%= key %>.channel.signal" value="true"<% if channel && channel.signal: %> checked<% end %> />
<%- @Icon('checkbox', 'icon-unchecked') %>
<%- @Icon('checkbox-checked', 'icon-checked') %>
</label>
<% end %>
<% end %>
<% end %>
</tbody>
</table>

View file

@ -0,0 +1,86 @@
<div class="page-header">
<div class="page-header-title"><h1><%- @T('Notifications') %></h1></div>
</div>
<form class="page-content form--flexibleWidth profile-settings-notifications-content">
<div class="settings-entry">
<%- @matrixTableHTML %>
</div>
<% if @signal_notification_enabled: %>
<div class="js-signal-phone-container" style="<% if !@signal_has_checked: %>display: none;<% end %>">
<h2><%- @T('Signal Phone Number') %></h2>
<div class="form-group">
<input type="text" 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>
</div>
<% end %>
<% if @groups: %>
<div class="zammad-switch zammad-switch--small" data-name="profile-groups-limit">
<input type="checkbox" id="profile-groups-limit" <% if @user_group_config: %> checked <% end %>>
<label for="profile-groups-limit"></label>
</div>
<h2>
<%- @T('Limit Groups') %>
</h2>
<div class="settings-entry profile-groups-limit-settings">
<div class="profile-groups-limit-settings-inner collapse <% if @user_group_config: %>in<% end %>">
<div class="alert alert--warning profile-groups-all-unchecked hide" role="alert">
<%- @T('Disabling the notifications from all groups will turn off the limit. Instead, to disable the notifications use the settings above.') %>
</div>
<table class="settings-list">
<thead>
<tr>
<th><%- @T('Group') %>
<th><%- @T('Not Assigned') %> & <%- @T('All Tickets') %>
</thead>
<tbody>
<% for group in @groups: %>
<tr>
<td><%- @P(group, 'name') %>
<td class="u-positionOrigin">
<label class="checkbox-replacement checkbox-replacement--fullscreen">
<input type="checkbox" name="group_ids" value="<%= group.id %>" <% if _.include(_.map(@config.group_ids, (group_id) -> group_id.toString()), group.id.toString()): %>checked<% end %>/>
<%- @Icon('checkbox', 'icon-unchecked') %>
<%- @Icon('checkbox-checked', 'icon-checked') %>
</label>
<% end %>
</tbody>
</table>
</div>
</div>
<% end %>
<h2><%- @T('Sounds') %></h2>
<div class="form-group">
<div class="formGroup-label">
<label for="notification-sound"><%- @T('Notification Sound') %></label>
</div>
<div class="controls controls--select">
<select class="form-control js-notificationSound" id="notification-sound" name="notification_sound::file">
<% for sound in @sounds: %>
<option value="<%= sound.file %>"<%= ' selected' if sound.selected %>><%= sound.name %></option>
<% end %>
</select>
<%- @Icon('arrow-down') %>
</div>
</div>
<div class="form-group">
<label class="inline-label">
<span class="checkbox-replacement checkbox-replacement--inline">
<input type="checkbox" name="notification_sound::enabled" value="true" <% if @notificationSoundEnabled: %> checked<% end %> class="js-SoundEnableDisable">
<%- @Icon('checkbox', 'icon-unchecked') %>
<%- @Icon('checkbox-checked', 'icon-checked') %>
</span>
<%- @T('Play user interface sound effects') %>
</label>
</div>
<button type="submit" class="btn btn--primary"><%- @T( 'Submit' ) %></button>
<input type="button" class="btn btn--danger js-reset" value="<%- @T( 'Reset to default settings' ) %>">
</form>

View file

@ -15,10 +15,34 @@ class ChannelsCdrSignalController < ApplicationController
end
render json: {
assets: assets,
channel_ids: channel_ids
channel_ids: channel_ids,
notification_enabled: Setting.get('signal_notification_enabled') == true,
notification_channel_id: Setting.get('signal_notification_channel_id')
}
end
def set_notification_channel
channel_id = params[:id].to_i
channel = Channel.find_by(id: channel_id, area: 'Signal::Number')
unless channel
render json: { error: 'Channel not found' }, status: :not_found
return
end
Setting.set('signal_notification_channel_id', channel_id)
Setting.set('signal_notification_enabled', true)
render json: { success: true, notification_channel_id: channel_id }
end
def unset_notification_channel
Setting.set('signal_notification_enabled', false)
Setting.set('signal_notification_channel_id', nil)
render json: { success: true }
end
def add
begin
errors = {}
@ -154,16 +178,31 @@ class ChannelsCdrSignalController < ApplicationController
return if Ticket::Article.exists?(message_id: "cdr_signal.#{message_id}")
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
# 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' || 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(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
customer = User.find_by(phone: sender_phone_number)
customer ||= User.find_by(mobile: sender_phone_number)
unless customer
role_ids = Role.signup_role_ids
customer = User.create(
@ -171,7 +210,8 @@ class ChannelsCdrSignalController < ApplicationController
lastname: '',
email: '',
password: '',
phone: sender_phone_number,
phone: sender_phone_number.presence || sender_user_id,
signal_uid: sender_user_id,
note: 'CDR Signal',
active: true,
role_ids: role_ids,
@ -180,6 +220,15 @@ class ChannelsCdrSignalController < ApplicationController
)
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
UserInfo.current_user_id = customer.id
current_user_set(customer, 'token_auth')
@ -208,7 +257,8 @@ class ChannelsCdrSignalController < ApplicationController
attachment_data_base64 = params[:attachment]
attachment_filename = params[:filename]
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
# find ticket or create one
@ -218,7 +268,7 @@ class ChannelsCdrSignalController < ApplicationController
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_phone_number}"
Rails.logger.info "Customer Phone: #{sender_display}"
Rails.logger.info "Channel ID: #{channel.id}"
begin
@ -256,14 +306,21 @@ class ChannelsCdrSignalController < ApplicationController
ticket.state = Ticket::State.find_by(default_follow_up: true) if ticket.state_id != new_state.id
else
# 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
cdr_signal_prefs = {
bot_token: channel.options[:bot_token], # change to bot id
chat_id: chat_id
bot_token: channel.options[:bot_token],
chat_id: chat_id,
user_id: sender_user_id
}
# Store original recipient phone for group tickets to enable ticket splitting
if is_group_message
cdr_signal_prefs[:original_recipient] = sender_phone_number
end
Rails.logger.info "=== CREATING NEW TICKET ==="
Rails.logger.info "Preferences to be stored:"
Rails.logger.info " - channel_id: #{channel.id}"
@ -283,7 +340,7 @@ class ChannelsCdrSignalController < ApplicationController
ticket.save!
article_params = {
from: sender_phone_number,
from: sender_display,
to: receiver_phone_number,
sender_id: Ticket::Article::Sender.find_by(name: 'Customer').id,
subject: title,
@ -296,7 +353,8 @@ class ChannelsCdrSignalController < ApplicationController
cdr_signal: {
timestamp: sent_at,
message_id: message_id,
from: sender_phone_number
from: sender_phone_number,
user_id: sender_user_id
}
}
}
@ -374,6 +432,24 @@ class ChannelsCdrSignalController < ApplicationController
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] ||= {}
@ -458,6 +534,36 @@ class ChannelsCdrSignalController < ApplicationController
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,

View file

@ -123,12 +123,16 @@ class ChannelsCdrWhatsappController < ApplicationController
errors = {}
%i[to
from
message_id
sent_at].each do |field|
errors[field] = 'required' if params[field].blank?
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?
render json: {
errors: errors
@ -141,9 +145,25 @@ class ChannelsCdrWhatsappController < ApplicationController
return if Ticket::Article.exists?(message_id: "cdr_whatsapp.#{message_id}")
receiver_phone_number = params[:to].strip
sender_phone_number = params[:from].strip
customer = User.find_by(phone: sender_phone_number)
customer ||= User.find_by(mobile: sender_phone_number)
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(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
role_ids = Role.signup_role_ids
customer = User.create(
@ -151,7 +171,8 @@ class ChannelsCdrWhatsappController < ApplicationController
lastname: '',
email: '',
password: '',
phone: sender_phone_number,
phone: sender_phone_number.presence || sender_user_id,
whatsapp_uid: sender_user_id,
note: 'CDR Whatsapp',
active: true,
role_ids: role_ids,
@ -160,6 +181,15 @@ class ChannelsCdrWhatsappController < ApplicationController
)
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
UserInfo.current_user_id = customer.id
current_user_set(customer, 'token_auth')
@ -188,7 +218,8 @@ class ChannelsCdrWhatsappController < ApplicationController
attachment_data_base64 = params[:attachment]
attachment_filename = params[:filename]
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
# find ticket or create one
@ -207,8 +238,9 @@ class ChannelsCdrWhatsappController < ApplicationController
preferences: {
channel_id: channel.id,
cdr_whatsapp: {
bot_token: channel.options[:bot_token], # change to bot id
chat_id: sender_phone_number
bot_token: channel.options[:bot_token],
chat_id: sender_phone_number.presence || sender_user_id,
user_id: sender_user_id
}
}
)
@ -217,7 +249,7 @@ class ChannelsCdrWhatsappController < ApplicationController
ticket.save!
article_params = {
from: sender_phone_number,
from: sender_display,
to: receiver_phone_number,
sender_id: Ticket::Article::Sender.find_by(name: 'Customer').id,
subject: title,
@ -230,7 +262,8 @@ class ChannelsCdrWhatsappController < ApplicationController
cdr_whatsapp: {
timestamp: sent_at,
message_id: message_id,
from: sender_phone_number
from: sender_phone_number,
user_id: sender_user_id
}
}
}

View file

@ -40,10 +40,37 @@ class CommunicateCdrSignalJob < ApplicationJob
if is_group_chat && group_joined == false
Rails.logger.info "Ticket ##{ticket.number}: User hasn't joined Signal group yet, skipping message delivery"
# Track group_not_joined retry attempts separately
article.preferences['group_not_joined_retry'] ||= 0
article.preferences['group_not_joined_retry'] += 1
# Mark article as pending delivery
article.preferences['delivery_status'] = 'pending'
article.preferences['delivery_status_message'] = 'Waiting for user to join Signal group'
article.preferences['delivery_status_date'] = Time.zone.now
# After 3 failed attempts, add a note to inform the agent (only once)
if article.preferences['group_not_joined_retry'] == 3 && !article.preferences['group_not_joined_note_added']
Ticket::Article.create(
ticket_id: ticket.id,
content_type: 'text/plain',
body: 'Unable to send Signal message: Recipient has not yet joined the Signal group. ' \
'The message will be delivered automatically once they accept the group invitation.',
internal: true,
sender: Ticket::Article::Sender.find_by(name: 'System'),
type: Ticket::Article::Type.find_by(name: 'note'),
preferences: {
delivery_article_id_related: article.id,
delivery_message: true,
group_not_joined_notification: true,
},
updated_by_id: 1,
created_by_id: 1,
)
article.preferences['group_not_joined_note_added'] = true
Rails.logger.info "Ticket ##{ticket.number}: Added notification note about pending group join"
end
article.save!
# Retry later when user might have joined

View file

@ -0,0 +1,65 @@
# 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
signal_uid = user.preferences.dig('notification_config', 'signal_uid').presence
return if 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: signal_uid,
message: message
)
add_history(ticket, user, signal_uid, type)
Rails.logger.info "Sent Signal notification to #{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::Number', active: true)
end
def add_history(ticket, user, signal_uid, type)
identifier = 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,51 @@
# frozen_string_literal: true
module Link::SetupSplitSignalGroup
extend ActiveSupport::Concern
included do
after_create :setup_signal_group_for_split_ticket
end
private
def setup_signal_group_for_split_ticket
# Only if auto-groups enabled
return unless ENV['BRIDGE_SIGNAL_AUTO_GROUPS'].to_s.downcase == 'true'
# Only child links (splits create child->parent links)
return unless link_type_id == Link::Type.find_by(name: 'child')&.id
# Only Ticket-to-Ticket links
ticket_object_id = Link::Object.find_by(name: 'Ticket')&.id
return unless link_object_source_id == ticket_object_id
return unless link_object_target_id == ticket_object_id
child_ticket = Ticket.find_by(id: link_object_source_value)
parent_ticket = Ticket.find_by(id: link_object_target_value)
return unless child_ticket && parent_ticket
# Only if parent has Signal group (chat_id starts with "group.")
parent_signal_prefs = parent_ticket.preferences&.dig('cdr_signal')
return unless parent_signal_prefs.present?
return unless parent_signal_prefs['chat_id']&.start_with?('group.')
original_recipient = parent_signal_prefs['original_recipient']
return unless original_recipient.present?
# Set up child for lazy group creation:
# chat_id = phone number triggers new group on first message
child_ticket.preferences ||= {}
child_ticket.preferences['channel_id'] = parent_ticket.preferences['channel_id']
child_ticket.preferences['cdr_signal'] = {
'bot_token' => parent_signal_prefs['bot_token'],
'chat_id' => original_recipient, # Phone number, NOT group ID
'original_recipient' => original_recipient
}
# Set article type so Zammad shows Signal reply option
child_ticket.create_article_type_id = Ticket::Article::Type.find_by(name: 'cdr_signal')&.id
child_ticket.save!
Rails.logger.info "Signal split: Ticket ##{child_ticket.number} set up for new group (recipient: #{original_recipient})"
end
end

View file

@ -0,0 +1,143 @@
# 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::Number', 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_signal_uid(user).blank?
next if !user_wants_signal_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_signal_uid(user)
user.preferences.dig('notification_config', 'signal_uid').presence
end
def user_wants_signal_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('notification_config', 'matrix', event_key, 'channel', 'signal') == 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

@ -1,10 +1,15 @@
# frozen_string_literal: true
Rails.application.config.after_initialize do
Rails.application.config.after_initialize do
class Ticket::Article
include Ticket::Article::EnqueueCommunicateCdrSignalJob
end
# Handle Signal group setup for split tickets
class Link
include Link::SetupSplitSignalGroup
end
icon = File.read('public/assets/images/icons/cdr_signal.svg')
doc = File.open('public/assets/images/icons.svg') { |f| Nokogiri::XML(f) }
if !doc.at_css('#icon-cdr-signal')
@ -15,4 +20,3 @@ Rails.application.config.after_initialize do
end
File.write('public/assets/images/icons.svg', doc.to_xml)
end

View file

@ -12,4 +12,6 @@ Zammad::Application.routes.draw do
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_rotate_token", to: 'channels_cdr_signal#rotate_token', via: :post
match "#{api_path}/channels_cdr_signal_set_notification", to: 'channels_cdr_signal#set_notification_channel', via: :post
match "#{api_path}/channels_cdr_signal_unset_notification", to: 'channels_cdr_signal#unset_notification_channel', via: :post
end

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,69 @@
# 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
)
end
def self.down
# Only destroy the transaction backend registration.
# Preserve signal_notification_enabled and signal_notification_channel_id
# so admin configuration survives addon reinstalls (setup.rb runs
# uninstall + install on every container start).
Setting.find_by(name: '0105_signal_notification')&.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['bot_endpoint'] || channel.options[:bot_endpoint]
api_token = channel.options['bot_token'] || channel.options[:bot_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

@ -0,0 +1,9 @@
{
"$schema": "https://turbo.build/schema.json",
"extends": ["//"],
"tasks": {
"build": {
"outputs": ["../../docker/zammad/addons/zammad-addon-bridge-v*.zpm"]
}
}
}

View file

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

View file

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

View file

@ -0,0 +1,9 @@
{
"$schema": "https://turbo.build/schema.json",
"extends": ["//"],
"tasks": {
"build": {
"outputs": ["../../docker/zammad/addons/zammad-addon-hardening-v*.zpm"]
}
}
}

1498
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff