From 0e8c9be247fe24d7b993fb5121ac71ec6b9ce8e1 Mon Sep 17 00:00:00 2001 From: Darren Clarke Date: Thu, 13 Nov 2025 10:42:16 +0100 Subject: [PATCH] Signal group and Formstack fixes --- apps/bridge-whatsapp/Dockerfile | 3 + apps/bridge-worker/crontab | 3 +- apps/bridge-worker/index.ts | 13 +- .../formstack/create-ticket-from-form.ts | 308 +++++++++++++----- .../tasks/signal/check-group-membership.ts | 117 +++++++ .../{ => signal}/fetch-signal-messages.ts | 119 +++++-- .../tasks/signal/send-signal-message.ts | 49 +-- docker/compose/bridge-whatsapp.yml | 19 ++ package.json | 2 +- .../cdr_signal_channels_controller.rb | 18 + .../channels_cdr_signal_controller.rb | 69 ++++ .../app/jobs/communicate_cdr_signal_job.rb | 19 ++ .../cdr_signal_channels_controller_policy.rb | 9 + .../src/config/routes/cdr_signal_channels.rb | 5 + packages/zammad-addon-hardening/package.json | 2 +- ...transaction_notification_no_attachments.rb | 79 +++++ 16 files changed, 692 insertions(+), 142 deletions(-) create mode 100644 apps/bridge-worker/tasks/signal/check-group-membership.ts rename apps/bridge-worker/tasks/{ => signal}/fetch-signal-messages.ts (61%) create mode 100644 docker/compose/bridge-whatsapp.yml create mode 100644 packages/zammad-addon-bridge/src/app/controllers/cdr_signal_channels_controller.rb create mode 100644 packages/zammad-addon-bridge/src/app/policies/controllers/cdr_signal_channels_controller_policy.rb create mode 100644 packages/zammad-addon-bridge/src/config/routes/cdr_signal_channels.rb create mode 100644 packages/zammad-addon-hardening/src/config/initializers/transaction_notification_no_attachments.rb diff --git a/apps/bridge-whatsapp/Dockerfile b/apps/bridge-whatsapp/Dockerfile index 631020d..181fcab 100644 --- a/apps/bridge-whatsapp/Dockerfile +++ b/apps/bridge-whatsapp/Dockerfile @@ -28,10 +28,13 @@ FROM base as runner ARG BUILD_DATE ARG VERSION ARG APP_DIR=/opt/bridge-whatsapp +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" RUN mkdir -p ${APP_DIR}/ RUN DEBIAN_FRONTEND=noninteractive apt-get update && \ apt-get install -y --no-install-recommends \ dumb-init +RUN corepack enable && corepack prepare pnpm@9.15.4 --activate WORKDIR ${APP_DIR} COPY --from=installer ${APP_DIR} ./ RUN chown -R node:node ${APP_DIR} diff --git a/apps/bridge-worker/crontab b/apps/bridge-worker/crontab index cb76da9..9c8bb8c 100644 --- a/apps/bridge-worker/crontab +++ b/apps/bridge-worker/crontab @@ -1 +1,2 @@ -*/1 * * * * fetch-signal-messages ?max=1&id=fetchSignalMessagesCron {"scheduleTasks": "true"} +*/1 * * * * signal:fetch-signal-messages ?max=1&id=fetchSignalMessagesCron {"scheduleTasks": "true"} +*/2 * * * * signal:check-group-membership ?max=1&id=checkGroupMembershipCron {} diff --git a/apps/bridge-worker/index.ts b/apps/bridge-worker/index.ts index 5c83505..2c3509c 100644 --- a/apps/bridge-worker/index.ts +++ b/apps/bridge-worker/index.ts @@ -3,7 +3,7 @@ import { createLogger } from "@link-stack/logger"; import * as path from "path"; import { fileURLToPath } from "url"; -const logger = createLogger('bridge-worker'); +const logger = createLogger("bridge-worker"); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -32,6 +32,15 @@ const main = async () => { }; main().catch((err) => { - logger.error({ error: err }, 'Worker failed to start'); + logger.error( + { + error: err, + message: err.message, + stack: err.stack, + name: err.name, + }, + "Worker failed to start", + ); + console.error("Full error:", err); process.exit(1); }); diff --git a/apps/bridge-worker/tasks/formstack/create-ticket-from-form.ts b/apps/bridge-worker/tasks/formstack/create-ticket-from-form.ts index 492e3db..bc5ea72 100644 --- a/apps/bridge-worker/tasks/formstack/create-ticket-from-form.ts +++ b/apps/bridge-worker/tasks/formstack/create-ticket-from-form.ts @@ -10,7 +10,7 @@ import { type FieldMappingConfig, } from "../../lib/formstack-field-mapping.js"; -const logger = createLogger('create-ticket-from-form'); +const logger = createLogger("create-ticket-from-form"); export interface CreateTicketFromFormOptions { formData: any; @@ -26,40 +26,49 @@ const createTicketFromFormTask = async ( const mapping = loadFieldMapping(); // Log only non-PII metadata using configured field names - const formId = getFieldValue(formData, 'formId', mapping); - const uniqueId = getFieldValue(formData, 'uniqueId', mapping); + const formId = getFieldValue(formData, "formId", mapping); + const uniqueId = getFieldValue(formData, "uniqueId", mapping); - logger.info({ - formId, - uniqueId, - receivedAt, - fieldCount: Object.keys(formData).length - }, 'Processing Formstack form submission'); + logger.info( + { + formId, + uniqueId, + receivedAt, + fieldCount: Object.keys(formData).length, + }, + "Processing Formstack form submission", + ); // Extract fields using dynamic mapping - const nameField = getFieldValue(formData, 'name', mapping); + const nameField = getFieldValue(formData, "name", mapping); const firstName = mapping.nestedFields?.name?.firstNamePath - ? getNestedFieldValue(nameField, mapping.nestedFields.name.firstNamePath) || '' - : ''; + ? getNestedFieldValue(nameField, mapping.nestedFields.name.firstNamePath) || "" + : ""; const lastName = mapping.nestedFields?.name?.lastNamePath - ? getNestedFieldValue(nameField, mapping.nestedFields.name.lastNamePath) || '' - : ''; - const fullName = (firstName && lastName) - ? `${firstName} ${lastName}`.trim() - : firstName || lastName || 'Unknown'; + ? getNestedFieldValue(nameField, mapping.nestedFields.name.lastNamePath) || "" + : ""; + const fullName = + firstName && lastName + ? `${firstName} ${lastName}`.trim() + : firstName || lastName || "Unknown"; // Extract well-known fields used for special logic (all optional) - const email = getFieldValue(formData, 'email', mapping); - const phone = getFieldValue(formData, 'phone', mapping); - const signalAccount = getFieldValue(formData, 'signalAccount', mapping); - const organization = getFieldValue(formData, 'organization', mapping); - const typeOfSupport = getFieldValue(formData, 'typeOfSupport', mapping); - const descriptionOfIssue = getFieldValue(formData, 'descriptionOfIssue', mapping); + const email = getFieldValue(formData, "email", mapping); + const phone = getFieldValue(formData, "phone", mapping); + const signalAccount = getFieldValue(formData, "signalAccount", mapping); + const organization = getFieldValue(formData, "organization", mapping); + const typeOfSupport = getFieldValue(formData, "typeOfSupport", mapping); + const descriptionOfIssue = getFieldValue(formData, "descriptionOfIssue", mapping); // Validate that at least one contact method is provided if (!email && !phone && !signalAccount) { - logger.error({ formId, uniqueId }, 'No contact information provided - at least one of email, phone, or signalAccount is required'); - throw new Error('At least one contact method (email, phone, or signalAccount) is required for ticket creation'); + logger.error( + { formId, uniqueId }, + "No contact information provided - at least one of email, phone, or signalAccount is required", + ); + throw new Error( + "At least one contact method (email, phone, or signalAccount) is required for ticket creation", + ); } // Build ticket title using configured template @@ -72,10 +81,10 @@ const createTicketFromFormTask = async ( // Build article body - format all fields as HTML const formatAllFields = (data: any): string => { - let html = ''; + let html = ""; // Add formatted name field first if we have it - if (fullName && fullName !== 'Unknown') { + if (fullName && fullName !== "Unknown") { html += `Name:
${fullName}
`; } @@ -84,15 +93,18 @@ const createTicketFromFormTask = async ( const skipFields = [ mapping.sourceFields.formId, mapping.sourceFields.uniqueId, - mapping.sourceFields.name, // Skip raw name field - 'HandshakeKey', + mapping.sourceFields.name, // Skip raw name field + "HandshakeKey", ].filter(Boolean); if (skipFields.includes(key)) continue; - if (value === null || value === undefined || value === '') continue; + if (value === null || value === undefined || value === "") continue; - const displayValue = Array.isArray(value) ? value.join(', ') : - typeof value === 'object' ? JSON.stringify(value) : value; + const displayValue = Array.isArray(value) + ? value.join(", ") + : typeof value === "object" + ? JSON.stringify(value) + : value; html += `${key}:
${displayValue}
`; } return html; @@ -101,12 +113,12 @@ const createTicketFromFormTask = async ( const body = formatAllFields(formData); // Get Zammad configuration from environment - const zammadUrl = process.env.ZAMMAD_URL || 'http://zammad-nginx:8080'; + const zammadUrl = process.env.ZAMMAD_URL || "http://zammad-nginx:8080"; const zammadToken = process.env.ZAMMAD_API_TOKEN; if (!zammadToken) { - logger.error('ZAMMAD_API_TOKEN environment variable is not configured'); - throw new Error('ZAMMAD_API_TOKEN is required'); + logger.error("ZAMMAD_API_TOKEN environment variable is not configured"); + throw new Error("ZAMMAD_API_TOKEN is required"); } const zammad = Zammad({ token: zammadToken }, zammadUrl); @@ -115,35 +127,39 @@ const createTicketFromFormTask = async ( // Look up the configured article type let articleTypeId: number | undefined; try { - const articleTypes = await zammad.get('ticket_article_types'); - const configuredType = articleTypes.find((t: any) => t.name === mapping.ticket.defaultArticleType); + const articleTypes = await zammad.get("ticket_article_types"); + const configuredType = articleTypes.find( + (t: any) => t.name === mapping.ticket.defaultArticleType, + ); articleTypeId = configuredType?.id; if (articleTypeId) { - logger.info({ articleTypeId, typeName: mapping.ticket.defaultArticleType }, 'Found configured article type'); + logger.info( + { articleTypeId, typeName: mapping.ticket.defaultArticleType }, + "Found configured article type", + ); } else { - logger.warn({ typeName: mapping.ticket.defaultArticleType }, 'Configured article type not found, ticket will use default type'); + logger.warn( + { typeName: mapping.ticket.defaultArticleType }, + "Configured article type not found, ticket will use default type", + ); } } catch (error: any) { - logger.warn({ error: error.message }, 'Failed to look up article type'); + logger.warn({ error: error.message }, "Failed to look up article type"); } // Get or create user - // Try to find existing user by: signalAccount -> phone -> email + // Try to find existing user by: phone -> email + // Note: We can't search by Signal account since Signal group IDs aren't phone numbers let customer; - // Try Signal account first if provided - if (signalAccount) { - customer = await getUser(zammad, signalAccount); - if (customer) { - logger.info({ customerId: customer.id, method: 'signal' }, 'Found existing user by Signal account'); - } - } - - // Fall back to phone if no customer found yet - if (!customer && phone) { + // Try phone if provided + if (phone) { customer = await getUser(zammad, phone); if (customer) { - logger.info({ customerId: customer.id, method: 'phone' }, 'Found existing user by phone'); + logger.info( + { customerId: customer.id, method: "phone" }, + "Found existing user by phone", + ); } } @@ -155,22 +171,25 @@ const createTicketFromFormTask = async ( const emailResults = await zammad.user.search(`email:${email}`); if (emailResults.length > 0) { customer = emailResults[0]; - logger.info({ customerId: customer.id, method: 'email' }, 'Found existing user by email'); + logger.info( + { customerId: customer.id, method: "email" }, + "Found existing user by email", + ); } } else { - logger.warn({ email }, 'Invalid email format provided, skipping email search'); + logger.warn({ email }, "Invalid email format provided, skipping email search"); } } if (!customer) { // Create new user - logger.info('Creating new user from form submission'); + logger.info("Creating new user from form submission"); // Build user data with whatever contact info we have const userData: any = { firstname: firstName, lastname: lastName, - roles: ['Customer'], + roles: ["Customer"], }; // Add contact info only if provided @@ -178,47 +197,105 @@ const createTicketFromFormTask = async ( userData.email = email; } - const userPhone = signalAccount || phone; - if (userPhone) { - userData.phone = userPhone; + // Use phone number if provided (don't use Signal group ID as phone) + if (phone) { + userData.phone = phone; } customer = await zammad.user.create(userData); } - logger.info({ - customerId: customer.id, - email: customer.email, - }, 'Using customer for ticket'); + logger.info( + { + customerId: customer.id, + email: customer.email, + }, + "Using customer for ticket", + ); // Look up the configured group - const groups = await zammad.get('groups'); + const groups = await zammad.get("groups"); const targetGroup = groups.find((g: any) => g.name === mapping.ticket.group); if (!targetGroup) { - logger.error({ groupName: mapping.ticket.group }, 'Configured group not found'); + logger.error({ groupName: mapping.ticket.group }, "Configured group not found"); throw new Error(`Zammad group "${mapping.ticket.group}" not found`); } - logger.info({ groupId: targetGroup.id, groupName: targetGroup.name }, 'Using configured group'); + logger.info( + { groupId: targetGroup.id, groupName: targetGroup.name }, + "Using configured group", + ); // Build custom fields using Zammad field mapping // This dynamically maps all configured fields without hardcoding const customFields = getZammadFieldValues(formData, mapping); + // Check if this is a Signal ticket + let signalArticleType = null; + let signalChannel = null; + + if (signalAccount) { + try { + logger.info({ signalAccount }, "Looking up Signal channel and article type"); + + // Look up Signal channels (admin-only endpoint) + const channels = await zammad.get("cdr_signal_channels"); + if (channels.length > 0) { + signalChannel = channels[0]; // Use first active Signal channel + logger.info( + { + channelId: signalChannel.id, + phoneNumber: signalChannel.phone_number, + }, + "Found active Signal channel", + ); + } else { + logger.warn("No active Signal channels found"); + } + + // Look up cdr_signal article type + const articleTypes = await zammad.get("ticket_article_types"); + signalArticleType = articleTypes.find((t: any) => t.name === "cdr_signal"); + + if (!signalArticleType) { + logger.warn("Signal article type (cdr_signal) not found, using default type"); + } else { + logger.info( + { articleTypeId: signalArticleType.id }, + "Found Signal article type", + ); + } + } catch (error: any) { + logger.warn( + { error: error.message }, + "Failed to look up Signal article type, creating regular ticket", + ); + } + } + // Create the ticket const articleData: any = { - subject: descriptionOfIssue || 'Support Request', + subject: descriptionOfIssue || "Support Request", body, - content_type: 'text/html', + content_type: "text/html", internal: false, }; - if (articleTypeId) { + // Use Signal article type if available, otherwise use configured default + if (signalArticleType) { + articleData.type_id = signalArticleType.id; + logger.info({ typeId: signalArticleType.id }, "Using Signal article type"); + + // IMPORTANT: Set sender to "Customer" for Signal tickets created from Formstack + // This prevents the article from being echoed back to the user via Signal + // (enqueue_communicate_cdr_signal_job only sends if sender != 'Customer') + articleData.sender = "Customer"; + } else if (articleTypeId) { articleData.type_id = articleTypeId; } - const ticketData = { + const ticketData: any = { title, group_id: targetGroup.id, customer_id: customer.id, @@ -226,29 +303,84 @@ const createTicketFromFormTask = async ( ...customFields, }; - logger.info({ - title, - groupId: targetGroup.id, - customerId: customer.id, - hasArticleType: !!articleTypeId, - customFieldCount: Object.keys(customFields).length, - }, 'Creating ticket'); + // Add Signal preferences if we have Signal channel and article type + // Note: signalAccount from Formstack is the phone number the user typed in + // Groups are added later via update_group webhook from bridge-worker + if (signalChannel && signalArticleType && signalAccount) { + ticketData.preferences = { + channel_id: signalChannel.id, + cdr_signal: { + bot_token: signalChannel.bot_token, + chat_id: signalAccount, // Use Signal phone number as chat_id + }, + }; + + logger.info( + { + channelId: signalChannel.id, + chatId: signalAccount, + }, + "Adding Signal preferences to ticket", + ); + } + + logger.info( + { + title, + groupId: targetGroup.id, + customerId: customer.id, + hasArticleType: !!articleTypeId || !!signalArticleType, + isSignalTicket: !!signalArticleType && !!signalAccount, + customFieldCount: Object.keys(customFields).length, + }, + "Creating ticket", + ); const ticket = await zammad.ticket.create(ticketData); - logger.info({ - ticketId: ticket.id, - ticketNumber: ticket.id, - title, - }, 'Successfully created ticket from Formstack submission'); + // Set create_article_type_id for Signal tickets to enable proper replies + if (signalArticleType && signalChannel) { + try { + await zammad.ticket.update(ticket.id, { + create_article_type_id: signalArticleType.id, + }); + logger.info( + { + ticketId: ticket.id, + articleTypeId: signalArticleType.id, + }, + "Set create_article_type_id for Signal ticket", + ); + } catch (error: any) { + logger.warn( + { + error: error.message, + ticketId: ticket.id, + }, + "Failed to set create_article_type_id, ticket may not support Signal replies", + ); + } + } + logger.info( + { + ticketId: ticket.id, + ticketNumber: ticket.id, + title, + isSignalTicket: !!signalChannel, + }, + "Successfully created ticket from Formstack submission", + ); } catch (error: any) { - logger.error({ - error: error.message, - stack: error.stack, - formId, - uniqueId, - }, 'Failed to create ticket from Formstack submission'); + logger.error( + { + error: error.message, + stack: error.stack, + formId, + uniqueId, + }, + "Failed to create ticket from Formstack submission", + ); throw error; } }; diff --git a/apps/bridge-worker/tasks/signal/check-group-membership.ts b/apps/bridge-worker/tasks/signal/check-group-membership.ts new file mode 100644 index 0000000..2e41efb --- /dev/null +++ b/apps/bridge-worker/tasks/signal/check-group-membership.ts @@ -0,0 +1,117 @@ +#!/usr/bin/env node +/** + * Check Signal group membership status and update Zammad tickets + * + * This task queries the Signal CLI API to check if users have joined + * their assigned groups. When a user joins (moves from pendingInvites to members), + * it updates the ticket's group_joined flag in Zammad. + */ + +import { db, getWorkerUtils } from "@link-stack/bridge-common"; +import { createLogger } from "@link-stack/logger"; +import * as signalApi from "@link-stack/signal-api"; + +const logger = createLogger("check-group-membership"); + +const { Configuration, GroupsApi } = signalApi; + +interface CheckGroupMembershipTaskOptions { + // Optional: Check specific group. If not provided, checks all groups with group_joined=false + groupId?: string; + botToken?: string; +} + +const checkGroupMembershipTask = async ( + options: CheckGroupMembershipTaskOptions = {}, +): Promise => { + const config = new Configuration({ + basePath: process.env.BRIDGE_SIGNAL_URL, + }); + const groupsClient = new GroupsApi(config); + const worker = await getWorkerUtils(); + + // Get all Signal bots + const bots = await db.selectFrom("SignalBot").selectAll().execute(); + + for (const bot of bots) { + try { + logger.debug( + { botId: bot.id, phoneNumber: bot.phoneNumber }, + "Checking groups for bot", + ); + + // Get all groups for this bot + const groups = await groupsClient.v1GroupsNumberGet({ + number: bot.phoneNumber, + }); + + logger.debug( + { botId: bot.id, groupCount: groups.length }, + "Retrieved groups from Signal CLI", + ); + + // For each group, check if we have tickets waiting for members to join + for (const group of groups) { + if (!group.id || !group.internalId) { + logger.debug({ groupName: group.name }, "Skipping group without ID"); + continue; + } + + // Log info about each group temporarily for debugging + logger.info( + { + groupId: group.id, + groupName: group.name, + membersCount: group.members?.length || 0, + members: group.members, + pendingInvitesCount: group.pendingInvites?.length || 0, + pendingInvites: group.pendingInvites, + pendingRequestsCount: group.pendingRequests?.length || 0, + }, + "Checking group membership", + ); + + // Notify Zammad about each member who has joined + // This handles both cases: + // 1. New contacts who must accept invite (they move from pendingInvites to members) + // 2. Existing contacts who are auto-added (they appear directly in members) + if (group.members && group.members.length > 0) { + for (const memberPhone of group.members) { + // Check if this member was previously pending + // We'll send the webhook and let Zammad decide if it needs to update + await worker.addJob("common/notify-webhooks", { + backendId: bot.id, + payload: { + event: "group_member_joined", + group_id: group.id, + member_phone: memberPhone, + timestamp: new Date().toISOString(), + }, + }); + + logger.info( + { + groupId: group.id, + memberPhone, + }, + "Notified Zammad about group member", + ); + } + } + } + } catch (error: any) { + logger.error( + { + botId: bot.id, + error: error.message, + stack: error.stack, + }, + "Error checking group membership for bot", + ); + } + } + + logger.info("Completed group membership check"); +}; + +export default checkGroupMembershipTask; diff --git a/apps/bridge-worker/tasks/fetch-signal-messages.ts b/apps/bridge-worker/tasks/signal/fetch-signal-messages.ts similarity index 61% rename from apps/bridge-worker/tasks/fetch-signal-messages.ts rename to apps/bridge-worker/tasks/signal/fetch-signal-messages.ts index f6529e2..f4bac04 100644 --- a/apps/bridge-worker/tasks/fetch-signal-messages.ts +++ b/apps/bridge-worker/tasks/signal/fetch-signal-messages.ts @@ -2,7 +2,7 @@ import { db, getWorkerUtils } from "@link-stack/bridge-common"; import { createLogger } from "@link-stack/logger"; import * as signalApi from "@link-stack/signal-api"; -const logger = createLogger('fetch-signal-messages'); +const logger = createLogger("fetch-signal-messages"); const { Configuration, MessagesApi, AttachmentsApi } = signalApi; const config = new Configuration({ @@ -28,13 +28,13 @@ const fetchAttachments = async (attachments: any[] | undefined) => { let defaultFilename = name; if (!defaultFilename) { // Check if id already has an extension - const hasExtension = id.includes('.'); + const hasExtension = id.includes("."); if (hasExtension) { // ID already includes extension defaultFilename = id; } else { // Add extension based on content type - const extension = contentType?.split('/')[1] || 'bin'; + const extension = contentType?.split("/")[1] || "bin"; defaultFilename = `${id}.${extension}`; } } @@ -64,7 +64,22 @@ const processMessage = async ({ message: msg, }: ProcessMessageArgs): Promise[]> => { const { envelope } = msg; - const { source, sourceUuid, dataMessage } = envelope; + const { source, sourceUuid, dataMessage, syncMessage, receiptMessage, typingMessage } = + envelope; + + // Log all envelope types to understand what events we're receiving + logger.info( + { + source, + sourceUuid, + hasDataMessage: !!dataMessage, + hasSyncMessage: !!syncMessage, + hasReceiptMessage: !!receiptMessage, + hasTypingMessage: !!typingMessage, + envelopeKeys: Object.keys(envelope), + }, + "Received Signal envelope", + ); const isGroup = !!( dataMessage?.groupV2 || @@ -72,23 +87,69 @@ const processMessage = async ({ dataMessage?.groupInfo ); + // Check if this is a group membership change event + const groupInfo = dataMessage?.groupInfo; + if (groupInfo) { + logger.info( + { + type: groupInfo.type, + groupId: groupInfo.groupId, + source, + groupInfoKeys: Object.keys(groupInfo), + fullGroupInfo: groupInfo, + }, + "Received group info event", + ); + + // If user joined the group, notify Zammad + if (groupInfo.type === "JOIN" || groupInfo.type === "JOINED") { + const worker = await getWorkerUtils(); + const groupId = groupInfo.groupId + ? `group.${Buffer.from(groupInfo.groupId).toString("base64")}` + : null; + + if (groupId) { + await worker.addJob("common/notify-webhooks", { + backendId: id, + payload: { + event: "group_member_joined", + group_id: groupId, + member_phone: source, + timestamp: new Date().toISOString(), + }, + }); + + logger.info( + { + groupId, + memberPhone: source, + }, + "User joined Signal group, notifying Zammad", + ); + } + } + } + if (!dataMessage) return []; const { attachments } = dataMessage; const rawTimestamp = dataMessage?.timestamp; - logger.debug({ - sourceUuid, - source, - rawTimestamp, - hasGroupV2: !!dataMessage?.groupV2, - hasGroupContext: !!dataMessage?.groupContext, - hasGroupInfo: !!dataMessage?.groupInfo, - isGroup, - groupV2Id: dataMessage?.groupV2?.id, - groupContextType: dataMessage?.groupContext?.type, - groupInfoType: dataMessage?.groupInfo?.type, - }, 'Processing message'); + logger.debug( + { + sourceUuid, + source, + rawTimestamp, + hasGroupV2: !!dataMessage?.groupV2, + hasGroupContext: !!dataMessage?.groupContext, + hasGroupInfo: !!dataMessage?.groupInfo, + isGroup, + groupV2Id: dataMessage?.groupV2?.id, + groupContextType: dataMessage?.groupContext?.type, + groupInfoType: dataMessage?.groupInfo?.type, + }, + "Processing message", + ); const timestamp = new Date(rawTimestamp); const formattedAttachments = await fetchAttachments(attachments); @@ -165,7 +226,7 @@ const fetchSignalMessagesTask = async ({ number: phoneNumber, }); - logger.debug({ botId: id, phoneNumber }, 'Fetching messages for bot'); + logger.debug({ botId: id, phoneNumber }, "Fetching messages for bot"); for (const message of messages) { const formattedMessages = await processMessage({ @@ -175,19 +236,19 @@ const fetchSignalMessagesTask = async ({ }); for (const formattedMessage of formattedMessages) { if (formattedMessage.to !== formattedMessage.from) { - logger.debug({ - messageId: formattedMessage.messageId, - from: formattedMessage.from, - to: formattedMessage.to, - isGroup: formattedMessage.isGroup, - hasMessage: !!formattedMessage.message, - hasAttachment: !!formattedMessage.attachment, - }, 'Creating job for message'); - - await worker.addJob( - "signal/receive-signal-message", - formattedMessage, + logger.debug( + { + messageId: formattedMessage.messageId, + from: formattedMessage.from, + to: formattedMessage.to, + isGroup: formattedMessage.isGroup, + hasMessage: !!formattedMessage.message, + hasAttachment: !!formattedMessage.attachment, + }, + "Creating job for message", ); + + await worker.addJob("signal/receive-signal-message", formattedMessage); } } } diff --git a/apps/bridge-worker/tasks/signal/send-signal-message.ts b/apps/bridge-worker/tasks/signal/send-signal-message.ts index 65fac2e..3235be2 100644 --- a/apps/bridge-worker/tasks/signal/send-signal-message.ts +++ b/apps/bridge-worker/tasks/signal/send-signal-message.ts @@ -65,10 +65,9 @@ const sendSignalMessageTask = async ({ 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, - ); + 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, + ); const isGroupPrefix = to.startsWith("group."); const isBase64 = /^[A-Za-z0-9+/]+=*$/.test(to) && to.length > 20; // Base64 internal_id const isGroupId = isUUID || isGroupPrefix || isBase64; @@ -79,8 +78,7 @@ const sendSignalMessageTask = async ({ to, isGroupId, enableAutoGroups, - shouldCreateGroup: - enableAutoGroups && !isGroupId && to && conversationId, + shouldCreateGroup: enableAutoGroups && !isGroupId && to && conversationId, }, "Recipient analysis", ); @@ -140,6 +138,7 @@ const sendSignalMessageTask = async ({ ); // Notify Zammad about the new group ID via webhook + // Set group_joined: false initially - will be updated when user accepts invitation await worker.addJob("common/notify-webhooks", { backendId: bot.id, payload: { @@ -148,6 +147,7 @@ const sendSignalMessageTask = async ({ original_recipient: to, group_id: finalTo, internal_group_id: internalId, + group_joined: false, timestamp: new Date().toISOString(), }, }); @@ -155,8 +155,7 @@ const sendSignalMessageTask = async ({ } catch (groupError) { logger.error( { - error: - groupError instanceof Error ? groupError.message : groupError, + error: groupError instanceof Error ? groupError.message : groupError, to, conversationId, }, @@ -217,7 +216,9 @@ const sendSignalMessageTask = async ({ const MAX_TOTAL_SIZE = getMaxTotalAttachmentSize(); if (attachments.length > MAX_ATTACHMENTS) { - throw new Error(`Too many attachments: ${attachments.length} (max ${MAX_ATTACHMENTS})`); + throw new Error( + `Too many attachments: ${attachments.length} (max ${MAX_ATTACHMENTS})`, + ); } let totalSize = 0; @@ -228,20 +229,26 @@ const sendSignalMessageTask = async ({ const estimatedSize = (attachment.data.length * 3) / 4; if (estimatedSize > MAX_ATTACHMENT_SIZE) { - logger.warn({ - filename: attachment.filename, - size: estimatedSize, - maxSize: MAX_ATTACHMENT_SIZE - }, 'Attachment exceeds size limit, skipping'); + logger.warn( + { + filename: attachment.filename, + size: estimatedSize, + maxSize: MAX_ATTACHMENT_SIZE, + }, + "Attachment exceeds size limit, skipping", + ); continue; } totalSize += estimatedSize; if (totalSize > MAX_TOTAL_SIZE) { - logger.warn({ - totalSize, - maxTotalSize: MAX_TOTAL_SIZE - }, 'Total attachment size exceeds limit, skipping remaining'); + logger.warn( + { + totalSize, + maxTotalSize: MAX_TOTAL_SIZE, + }, + "Total attachment size exceeds limit, skipping remaining", + ); break; } @@ -253,8 +260,10 @@ const sendSignalMessageTask = async ({ logger.debug( { attachmentCount: validatedAttachments.length, - attachmentNames: attachments.slice(0, validatedAttachments.length).map((att) => att.filename), - totalSizeBytes: totalSize + attachmentNames: attachments + .slice(0, validatedAttachments.length) + .map((att) => att.filename), + totalSizeBytes: totalSize, }, "Including attachments in message", ); diff --git a/docker/compose/bridge-whatsapp.yml b/docker/compose/bridge-whatsapp.yml new file mode 100644 index 0000000..e45bf88 --- /dev/null +++ b/docker/compose/bridge-whatsapp.yml @@ -0,0 +1,19 @@ +services: + bridge-whatsapp: + container_name: bridge-whatsapp + build: + context: ../../ + dockerfile: ./apps/bridge-whatsapp/Dockerfile + image: registry.gitlab.com/digiresilience/link/link-stack/bridge-whatsapp:${LINK_STACK_VERSION} + restart: ${RESTART} + environment: + PORT: 5000 + NODE_ENV: production + volumes: + - bridge-whatsapp-data:/home/node/baileys + ports: + - 5000:5000 + +volumes: + bridge-whatsapp-data: + driver: local diff --git a/package.json b/package.json index 4fc618f..f28ff10 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@link-stack", - "version": "3.3.0-beta.1", + "version": "3.3.0-beta.2", "description": "Link from the Center for Digital Resilience", "scripts": { "dev": "dotenv -- turbo dev", diff --git a/packages/zammad-addon-bridge/src/app/controllers/cdr_signal_channels_controller.rb b/packages/zammad-addon-bridge/src/app/controllers/cdr_signal_channels_controller.rb new file mode 100644 index 0000000..f478f44 --- /dev/null +++ b/packages/zammad-addon-bridge/src/app/controllers/cdr_signal_channels_controller.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class CdrSignalChannelsController < ApplicationController + prepend_before_action -> { authentication_check && authorize! } + + def index + channels = Channel.where(area: 'Signal::Number', active: true).map do |channel| + { + id: channel.id, + phone_number: channel.options['phone_number'], + bot_token: channel.options['bot_token'], + bot_endpoint: channel.options['bot_endpoint'] + } + end + + render json: channels + end +end diff --git a/packages/zammad-addon-bridge/src/app/controllers/channels_cdr_signal_controller.rb b/packages/zammad-addon-bridge/src/app/controllers/channels_cdr_signal_controller.rb index d8c9f23..812750c 100644 --- a/packages/zammad-addon-bridge/src/app/controllers/channels_cdr_signal_controller.rb +++ b/packages/zammad-addon-bridge/src/app/controllers/channels_cdr_signal_controller.rb @@ -122,6 +122,11 @@ class ChannelsCdrSignalController < ApplicationController return update_group end + # Handle group member joined events + if params[:event] == 'group_member_joined' + return handle_group_member_joined + end + channel_id = channel.id # validate input @@ -397,6 +402,10 @@ class ChannelsCdrSignalController < ApplicationController ticket.preferences[:cdr_signal][:original_recipient] = params[:original_recipient] if params[:original_recipient].present? ticket.preferences[:cdr_signal][:group_created_at] = params[:timestamp] if params[:timestamp].present? + # Track whether user has joined the group (initially false) + # This will be updated to true when we receive a group join event from Signal + ticket.preferences[:cdr_signal][:group_joined] = params[:group_joined] if params.key?(:group_joined) + ticket.save! Rails.logger.info "Signal group #{params[:group_id]} associated with ticket #{ticket.id}" @@ -407,4 +416,64 @@ class ChannelsCdrSignalController < ApplicationController ticket_number: ticket.number }, status: :ok end + + # Webhook endpoint for receiving group member joined notifications from bridge-worker + # This is called when a user accepts the Signal group invitation + # Expected payload: + # { + # "event": "group_member_joined", + # "group_id": "group.base64encodedid", + # "member_phone": "+1234567890", + # "timestamp": "ISO8601 timestamp" + # } + def handle_group_member_joined + # Validate required parameters + errors = {} + errors['event'] = 'required' unless params[:event].present? + errors['group_id'] = 'required' unless params[:group_id].present? + errors['member_phone'] = 'required' unless params[:member_phone].present? + + if errors.present? + render json: { + errors: errors + }, status: :bad_request + return + end + + # Find ticket(s) with this group_id in preferences + # Search all active tickets for matching group + state_ids = Ticket::State.where(name: %w[closed merged removed]).pluck(:id) + + ticket = Ticket.where.not(state_id: state_ids).find do |t| + t.preferences.is_a?(Hash) && + t.preferences['cdr_signal'].is_a?(Hash) && + t.preferences['cdr_signal']['chat_id'] == params[:group_id] + end + + unless ticket + Rails.logger.warn "Signal group member joined: Ticket not found for group_id #{params[:group_id]}" + render json: { error: 'Ticket not found for this group' }, status: :not_found + return + end + + # Check if the member who joined matches the original recipient + original_recipient = ticket.preferences.dig('cdr_signal', 'original_recipient') + member_phone = params[:member_phone] + + # Update group_joined flag + ticket.preferences[:cdr_signal][:group_joined] = true + ticket.preferences[:cdr_signal][:group_joined_at] = params[:timestamp] if params[:timestamp].present? + ticket.preferences[:cdr_signal][:group_joined_by] = member_phone + + ticket.save! + + Rails.logger.info "Signal group member #{member_phone} joined group #{params[:group_id]} for ticket #{ticket.id}" + + render json: { + success: true, + ticket_id: ticket.id, + ticket_number: ticket.number, + group_joined: true + }, status: :ok + end end diff --git a/packages/zammad-addon-bridge/src/app/jobs/communicate_cdr_signal_job.rb b/packages/zammad-addon-bridge/src/app/jobs/communicate_cdr_signal_job.rb index bacd982..37e026a 100644 --- a/packages/zammad-addon-bridge/src/app/jobs/communicate_cdr_signal_job.rb +++ b/packages/zammad-addon-bridge/src/app/jobs/communicate_cdr_signal_job.rb @@ -30,6 +30,25 @@ class CommunicateCdrSignalJob < ApplicationJob log_error(article, "Can't find ticket.preferences['cdr_signal']['chat_id'] for Ticket.find(#{article.ticket_id})") end + + # Check if this is a group chat and if the user has joined + chat_id = ticket.preferences['cdr_signal']['chat_id'] + is_group_chat = chat_id&.start_with?('group.') + group_joined = ticket.preferences.dig('cdr_signal', 'group_joined') + + # If this is a group chat and user hasn't joined yet, don't send the message + if is_group_chat && group_joined == false + Rails.logger.info "Ticket ##{ticket.number}: User hasn't joined Signal group yet, skipping message delivery" + + # 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 + article.save! + + # Retry later when user might have joined + raise 'User has not joined Signal group yet' + end channel = ::CdrSignal.bot_by_bot_token(ticket.preferences['cdr_signal']['bot_token']) channel ||= ::Channel.lookup(id: ticket.preferences['channel_id']) unless channel diff --git a/packages/zammad-addon-bridge/src/app/policies/controllers/cdr_signal_channels_controller_policy.rb b/packages/zammad-addon-bridge/src/app/policies/controllers/cdr_signal_channels_controller_policy.rb new file mode 100644 index 0000000..ae653aa --- /dev/null +++ b/packages/zammad-addon-bridge/src/app/policies/controllers/cdr_signal_channels_controller_policy.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Controllers + class CdrSignalChannelsControllerPolicy < Controllers::ApplicationControllerPolicy + def index? + user.permissions?('admin.channel') + end + end +end diff --git a/packages/zammad-addon-bridge/src/config/routes/cdr_signal_channels.rb b/packages/zammad-addon-bridge/src/config/routes/cdr_signal_channels.rb new file mode 100644 index 0000000..7c053f5 --- /dev/null +++ b/packages/zammad-addon-bridge/src/config/routes/cdr_signal_channels.rb @@ -0,0 +1,5 @@ +Zammad::Application.routes.draw do + api_path = Rails.configuration.api_path + + match api_path + '/cdr_signal_channels', to: 'cdr_signal_channels#index', via: :get +end diff --git a/packages/zammad-addon-hardening/package.json b/packages/zammad-addon-hardening/package.json index 7d00ddc..81dcae8 100644 --- a/packages/zammad-addon-hardening/package.json +++ b/packages/zammad-addon-hardening/package.json @@ -1,7 +1,7 @@ { "name": "@link-stack/zammad-addon-hardening", "displayName": "Hardening", - "version": "3.3.0-beta.1", + "version": "3.3.0-beta.2", "description": "A Zammad addon that hardens a Zammad instance according to CDR's needs.", "scripts": { "build": "node '../zammad-addon-common/dist/build.js'", diff --git a/packages/zammad-addon-hardening/src/config/initializers/transaction_notification_no_attachments.rb b/packages/zammad-addon-hardening/src/config/initializers/transaction_notification_no_attachments.rb new file mode 100644 index 0000000..badc5d7 --- /dev/null +++ b/packages/zammad-addon-hardening/src/config/initializers/transaction_notification_no_attachments.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +# Monkey patch Transaction::Notification to prevent attachments from being +# included in ticket notification emails for security/privacy reasons. +# +# This overrides the send_notification_email method to always pass an empty +# attachments array instead of article.attachments_inline. + +module TransactionNotificationNoAttachments + def send_notification_email(user:, ticket:, article:, changes:, current_user:, recipients_reason:) + template = case @item[:type] + when 'create' + 'ticket_create' + when 'update' + 'ticket_update' + when 'reminder_reached' + 'ticket_reminder_reached' + when 'escalation' + 'ticket_escalation' + when 'escalation_warning' + 'ticket_escalation_warning' + when 'update.merged_into', 'update.received_merge' + 'ticket_update_merged' + when 'update.reaction' + 'ticket_article_update_reaction' + else + raise "unknown type for notification #{@item[:type]}" + end + + # HARDENING: Always use empty attachments array to prevent leaking sensitive files + original_attachment_count = article&.attachments_inline&.count || 0 + attachments = [] + + if original_attachment_count > 0 + Rails.logger.info "[HARDENING] Stripped #{original_attachment_count} attachment(s) from notification email for ticket ##{ticket.id}" + end + + NotificationFactory::Mailer.notification( + template: template, + user: user, + objects: { + ticket: ticket, + article: article, + recipient: user, + current_user: current_user, + changes: changes, + reason: recipients_reason[user.id], + }, + message_id: "", + references: ticket.get_references, + main_object: ticket, + attachments: attachments, + ) + Rails.logger.debug { "sent ticket email notification to agent (#{@item[:type]}/#{ticket.id}/#{user.email})" } + rescue Channel::DeliveryError => e + status_code = begin + e.original_error.response.status.to_i + rescue + raise e + end + + if Transaction::Notification::SILENCABLE_SMTP_ERROR_CODES.any? { |elem| elem.include? status_code } + Rails.logger.info do + "could not send ticket email notification to agent (#{@item[:type]}/#{ticket.id}/#{user.email}) #{e.original_error}" + end + + return + end + + raise e + end +end + +# Apply the monkey patch after Rails initialization when all classes are loaded +Rails.application.config.after_initialize do + Rails.logger.info '[HARDENING] Loading TransactionNotificationNoAttachments monkey patch...' + Transaction::Notification.prepend(TransactionNotificationNoAttachments) + Rails.logger.info '[HARDENING] TransactionNotificationNoAttachments monkey patch successfully applied - email attachments will be stripped from notifications' +end