Signal group and Formstack fixes

This commit is contained in:
Darren Clarke 2025-11-13 10:42:16 +01:00
parent 00d1fe5eef
commit 0e8c9be247
16 changed files with 692 additions and 142 deletions

View file

@ -28,10 +28,13 @@ FROM base as runner
ARG BUILD_DATE ARG BUILD_DATE
ARG VERSION ARG VERSION
ARG APP_DIR=/opt/bridge-whatsapp ARG APP_DIR=/opt/bridge-whatsapp
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN mkdir -p ${APP_DIR}/ RUN mkdir -p ${APP_DIR}/
RUN DEBIAN_FRONTEND=noninteractive apt-get update && \ RUN DEBIAN_FRONTEND=noninteractive apt-get update && \
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \
dumb-init dumb-init
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
WORKDIR ${APP_DIR} WORKDIR ${APP_DIR}
COPY --from=installer ${APP_DIR} ./ COPY --from=installer ${APP_DIR} ./
RUN chown -R node:node ${APP_DIR} RUN chown -R node:node ${APP_DIR}

View file

@ -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 {}

View file

@ -3,7 +3,7 @@ import { createLogger } from "@link-stack/logger";
import * as path from "path"; import * as path from "path";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
const logger = createLogger('bridge-worker'); const logger = createLogger("bridge-worker");
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
@ -32,6 +32,15 @@ const main = async () => {
}; };
main().catch((err) => { 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); process.exit(1);
}); });

View file

@ -10,7 +10,7 @@ import {
type FieldMappingConfig, type FieldMappingConfig,
} from "../../lib/formstack-field-mapping.js"; } from "../../lib/formstack-field-mapping.js";
const logger = createLogger('create-ticket-from-form'); const logger = createLogger("create-ticket-from-form");
export interface CreateTicketFromFormOptions { export interface CreateTicketFromFormOptions {
formData: any; formData: any;
@ -26,40 +26,49 @@ const createTicketFromFormTask = async (
const mapping = loadFieldMapping(); const mapping = loadFieldMapping();
// Log only non-PII metadata using configured field names // Log only non-PII metadata using configured field names
const formId = getFieldValue(formData, 'formId', mapping); const formId = getFieldValue(formData, "formId", mapping);
const uniqueId = getFieldValue(formData, 'uniqueId', mapping); const uniqueId = getFieldValue(formData, "uniqueId", mapping);
logger.info({ logger.info(
{
formId, formId,
uniqueId, uniqueId,
receivedAt, receivedAt,
fieldCount: Object.keys(formData).length fieldCount: Object.keys(formData).length,
}, 'Processing Formstack form submission'); },
"Processing Formstack form submission",
);
// Extract fields using dynamic mapping // Extract fields using dynamic mapping
const nameField = getFieldValue(formData, 'name', mapping); const nameField = getFieldValue(formData, "name", mapping);
const firstName = mapping.nestedFields?.name?.firstNamePath const firstName = mapping.nestedFields?.name?.firstNamePath
? getNestedFieldValue(nameField, mapping.nestedFields.name.firstNamePath) || '' ? getNestedFieldValue(nameField, mapping.nestedFields.name.firstNamePath) || ""
: ''; : "";
const lastName = mapping.nestedFields?.name?.lastNamePath const lastName = mapping.nestedFields?.name?.lastNamePath
? getNestedFieldValue(nameField, mapping.nestedFields.name.lastNamePath) || '' ? getNestedFieldValue(nameField, mapping.nestedFields.name.lastNamePath) || ""
: ''; : "";
const fullName = (firstName && lastName) const fullName =
firstName && lastName
? `${firstName} ${lastName}`.trim() ? `${firstName} ${lastName}`.trim()
: firstName || lastName || 'Unknown'; : firstName || lastName || "Unknown";
// Extract well-known fields used for special logic (all optional) // Extract well-known fields used for special logic (all optional)
const email = getFieldValue(formData, 'email', mapping); const email = getFieldValue(formData, "email", mapping);
const phone = getFieldValue(formData, 'phone', mapping); const phone = getFieldValue(formData, "phone", mapping);
const signalAccount = getFieldValue(formData, 'signalAccount', mapping); const signalAccount = getFieldValue(formData, "signalAccount", mapping);
const organization = getFieldValue(formData, 'organization', mapping); const organization = getFieldValue(formData, "organization", mapping);
const typeOfSupport = getFieldValue(formData, 'typeOfSupport', mapping); const typeOfSupport = getFieldValue(formData, "typeOfSupport", mapping);
const descriptionOfIssue = getFieldValue(formData, 'descriptionOfIssue', mapping); const descriptionOfIssue = getFieldValue(formData, "descriptionOfIssue", mapping);
// Validate that at least one contact method is provided // Validate that at least one contact method is provided
if (!email && !phone && !signalAccount) { if (!email && !phone && !signalAccount) {
logger.error({ formId, uniqueId }, 'No contact information provided - at least one of email, phone, or signalAccount is required'); logger.error(
throw new Error('At least one contact method (email, phone, or signalAccount) is required for ticket creation'); { 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 // Build ticket title using configured template
@ -72,10 +81,10 @@ const createTicketFromFormTask = async (
// Build article body - format all fields as HTML // Build article body - format all fields as HTML
const formatAllFields = (data: any): string => { const formatAllFields = (data: any): string => {
let html = ''; let html = "";
// Add formatted name field first if we have it // Add formatted name field first if we have it
if (fullName && fullName !== 'Unknown') { if (fullName && fullName !== "Unknown") {
html += `<strong>Name:</strong><br>${fullName}<br>`; html += `<strong>Name:</strong><br>${fullName}<br>`;
} }
@ -85,14 +94,17 @@ const createTicketFromFormTask = async (
mapping.sourceFields.formId, mapping.sourceFields.formId,
mapping.sourceFields.uniqueId, mapping.sourceFields.uniqueId,
mapping.sourceFields.name, // Skip raw name field mapping.sourceFields.name, // Skip raw name field
'HandshakeKey', "HandshakeKey",
].filter(Boolean); ].filter(Boolean);
if (skipFields.includes(key)) continue; 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(', ') : const displayValue = Array.isArray(value)
typeof value === 'object' ? JSON.stringify(value) : value; ? value.join(", ")
: typeof value === "object"
? JSON.stringify(value)
: value;
html += `<strong>${key}:</strong><br>${displayValue}<br>`; html += `<strong>${key}:</strong><br>${displayValue}<br>`;
} }
return html; return html;
@ -101,12 +113,12 @@ const createTicketFromFormTask = async (
const body = formatAllFields(formData); const body = formatAllFields(formData);
// Get Zammad configuration from environment // 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; const zammadToken = process.env.ZAMMAD_API_TOKEN;
if (!zammadToken) { if (!zammadToken) {
logger.error('ZAMMAD_API_TOKEN environment variable is not configured'); logger.error("ZAMMAD_API_TOKEN environment variable is not configured");
throw new Error('ZAMMAD_API_TOKEN is required'); throw new Error("ZAMMAD_API_TOKEN is required");
} }
const zammad = Zammad({ token: zammadToken }, zammadUrl); const zammad = Zammad({ token: zammadToken }, zammadUrl);
@ -115,35 +127,39 @@ const createTicketFromFormTask = async (
// Look up the configured article type // Look up the configured article type
let articleTypeId: number | undefined; let articleTypeId: number | undefined;
try { try {
const articleTypes = await zammad.get('ticket_article_types'); const articleTypes = await zammad.get("ticket_article_types");
const configuredType = articleTypes.find((t: any) => t.name === mapping.ticket.defaultArticleType); const configuredType = articleTypes.find(
(t: any) => t.name === mapping.ticket.defaultArticleType,
);
articleTypeId = configuredType?.id; articleTypeId = configuredType?.id;
if (articleTypeId) { 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 { } 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) { } 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 // 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; let customer;
// Try Signal account first if provided // Try phone if provided
if (signalAccount) { if (phone) {
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) {
customer = await getUser(zammad, phone); customer = await getUser(zammad, phone);
if (customer) { 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}`); const emailResults = await zammad.user.search(`email:${email}`);
if (emailResults.length > 0) { if (emailResults.length > 0) {
customer = emailResults[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 { } else {
logger.warn({ email }, 'Invalid email format provided, skipping email search'); logger.warn({ email }, "Invalid email format provided, skipping email search");
} }
} }
if (!customer) { if (!customer) {
// Create new user // 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 // Build user data with whatever contact info we have
const userData: any = { const userData: any = {
firstname: firstName, firstname: firstName,
lastname: lastName, lastname: lastName,
roles: ['Customer'], roles: ["Customer"],
}; };
// Add contact info only if provided // Add contact info only if provided
@ -178,47 +197,105 @@ const createTicketFromFormTask = async (
userData.email = email; userData.email = email;
} }
const userPhone = signalAccount || phone; // Use phone number if provided (don't use Signal group ID as phone)
if (userPhone) { if (phone) {
userData.phone = userPhone; userData.phone = phone;
} }
customer = await zammad.user.create(userData); customer = await zammad.user.create(userData);
} }
logger.info({ logger.info(
{
customerId: customer.id, customerId: customer.id,
email: customer.email, email: customer.email,
}, 'Using customer for ticket'); },
"Using customer for ticket",
);
// Look up the configured group // 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); const targetGroup = groups.find((g: any) => g.name === mapping.ticket.group);
if (!targetGroup) { 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`); 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 // Build custom fields using Zammad field mapping
// This dynamically maps all configured fields without hardcoding // This dynamically maps all configured fields without hardcoding
const customFields = getZammadFieldValues(formData, mapping); 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 // Create the ticket
const articleData: any = { const articleData: any = {
subject: descriptionOfIssue || 'Support Request', subject: descriptionOfIssue || "Support Request",
body, body,
content_type: 'text/html', content_type: "text/html",
internal: false, 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; articleData.type_id = articleTypeId;
} }
const ticketData = { const ticketData: any = {
title, title,
group_id: targetGroup.id, group_id: targetGroup.id,
customer_id: customer.id, customer_id: customer.id,
@ -226,29 +303,84 @@ const createTicketFromFormTask = async (
...customFields, ...customFields,
}; };
logger.info({ // 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, title,
groupId: targetGroup.id, groupId: targetGroup.id,
customerId: customer.id, customerId: customer.id,
hasArticleType: !!articleTypeId, hasArticleType: !!articleTypeId || !!signalArticleType,
isSignalTicket: !!signalArticleType && !!signalAccount,
customFieldCount: Object.keys(customFields).length, customFieldCount: Object.keys(customFields).length,
}, 'Creating ticket'); },
"Creating ticket",
);
const ticket = await zammad.ticket.create(ticketData); const ticket = await zammad.ticket.create(ticketData);
logger.info({ // 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, ticketId: ticket.id,
ticketNumber: ticket.id, ticketNumber: ticket.id,
title, title,
}, 'Successfully created ticket from Formstack submission'); isSignalTicket: !!signalChannel,
},
"Successfully created ticket from Formstack submission",
);
} catch (error: any) { } catch (error: any) {
logger.error({ logger.error(
{
error: error.message, error: error.message,
stack: error.stack, stack: error.stack,
formId, formId,
uniqueId, uniqueId,
}, 'Failed to create ticket from Formstack submission'); },
"Failed to create ticket from Formstack submission",
);
throw error; throw error;
} }
}; };

View file

@ -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<void> => {
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;

View file

@ -2,7 +2,7 @@ import { db, getWorkerUtils } from "@link-stack/bridge-common";
import { createLogger } from "@link-stack/logger"; import { createLogger } from "@link-stack/logger";
import * as signalApi from "@link-stack/signal-api"; 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 { Configuration, MessagesApi, AttachmentsApi } = signalApi;
const config = new Configuration({ const config = new Configuration({
@ -28,13 +28,13 @@ const fetchAttachments = async (attachments: any[] | undefined) => {
let defaultFilename = name; let defaultFilename = name;
if (!defaultFilename) { if (!defaultFilename) {
// Check if id already has an extension // Check if id already has an extension
const hasExtension = id.includes('.'); const hasExtension = id.includes(".");
if (hasExtension) { if (hasExtension) {
// ID already includes extension // ID already includes extension
defaultFilename = id; defaultFilename = id;
} else { } else {
// Add extension based on content type // Add extension based on content type
const extension = contentType?.split('/')[1] || 'bin'; const extension = contentType?.split("/")[1] || "bin";
defaultFilename = `${id}.${extension}`; defaultFilename = `${id}.${extension}`;
} }
} }
@ -64,7 +64,22 @@ const processMessage = async ({
message: msg, message: msg,
}: ProcessMessageArgs): Promise<Record<string, any>[]> => { }: ProcessMessageArgs): Promise<Record<string, any>[]> => {
const { envelope } = msg; 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 = !!( const isGroup = !!(
dataMessage?.groupV2 || dataMessage?.groupV2 ||
@ -72,12 +87,56 @@ const processMessage = async ({
dataMessage?.groupInfo 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 []; if (!dataMessage) return [];
const { attachments } = dataMessage; const { attachments } = dataMessage;
const rawTimestamp = dataMessage?.timestamp; const rawTimestamp = dataMessage?.timestamp;
logger.debug({ logger.debug(
{
sourceUuid, sourceUuid,
source, source,
rawTimestamp, rawTimestamp,
@ -88,7 +147,9 @@ const processMessage = async ({
groupV2Id: dataMessage?.groupV2?.id, groupV2Id: dataMessage?.groupV2?.id,
groupContextType: dataMessage?.groupContext?.type, groupContextType: dataMessage?.groupContext?.type,
groupInfoType: dataMessage?.groupInfo?.type, groupInfoType: dataMessage?.groupInfo?.type,
}, 'Processing message'); },
"Processing message",
);
const timestamp = new Date(rawTimestamp); const timestamp = new Date(rawTimestamp);
const formattedAttachments = await fetchAttachments(attachments); const formattedAttachments = await fetchAttachments(attachments);
@ -165,7 +226,7 @@ const fetchSignalMessagesTask = async ({
number: phoneNumber, 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) { for (const message of messages) {
const formattedMessages = await processMessage({ const formattedMessages = await processMessage({
@ -175,19 +236,19 @@ const fetchSignalMessagesTask = async ({
}); });
for (const formattedMessage of formattedMessages) { for (const formattedMessage of formattedMessages) {
if (formattedMessage.to !== formattedMessage.from) { if (formattedMessage.to !== formattedMessage.from) {
logger.debug({ logger.debug(
{
messageId: formattedMessage.messageId, messageId: formattedMessage.messageId,
from: formattedMessage.from, from: formattedMessage.from,
to: formattedMessage.to, to: formattedMessage.to,
isGroup: formattedMessage.isGroup, isGroup: formattedMessage.isGroup,
hasMessage: !!formattedMessage.message, hasMessage: !!formattedMessage.message,
hasAttachment: !!formattedMessage.attachment, hasAttachment: !!formattedMessage.attachment,
}, 'Creating job for message'); },
"Creating job for message",
await worker.addJob(
"signal/receive-signal-message",
formattedMessage,
); );
await worker.addJob("signal/receive-signal-message", formattedMessage);
} }
} }
} }

View file

@ -65,8 +65,7 @@ const sendSignalMessageTask = async ({
try { try {
// Check if 'to' is a group ID (UUID format, group.base64 format, or base64) vs phone number // Check if 'to' is a group ID (UUID format, group.base64 format, or base64) vs phone number
const isUUID = const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
to, to,
); );
const isGroupPrefix = to.startsWith("group."); const isGroupPrefix = to.startsWith("group.");
@ -79,8 +78,7 @@ const sendSignalMessageTask = async ({
to, to,
isGroupId, isGroupId,
enableAutoGroups, enableAutoGroups,
shouldCreateGroup: shouldCreateGroup: enableAutoGroups && !isGroupId && to && conversationId,
enableAutoGroups && !isGroupId && to && conversationId,
}, },
"Recipient analysis", "Recipient analysis",
); );
@ -140,6 +138,7 @@ const sendSignalMessageTask = async ({
); );
// Notify Zammad about the new group ID via webhook // 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", { await worker.addJob("common/notify-webhooks", {
backendId: bot.id, backendId: bot.id,
payload: { payload: {
@ -148,6 +147,7 @@ const sendSignalMessageTask = async ({
original_recipient: to, original_recipient: to,
group_id: finalTo, group_id: finalTo,
internal_group_id: internalId, internal_group_id: internalId,
group_joined: false,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}, },
}); });
@ -155,8 +155,7 @@ const sendSignalMessageTask = async ({
} catch (groupError) { } catch (groupError) {
logger.error( logger.error(
{ {
error: error: groupError instanceof Error ? groupError.message : groupError,
groupError instanceof Error ? groupError.message : groupError,
to, to,
conversationId, conversationId,
}, },
@ -217,7 +216,9 @@ const sendSignalMessageTask = async ({
const MAX_TOTAL_SIZE = getMaxTotalAttachmentSize(); const MAX_TOTAL_SIZE = getMaxTotalAttachmentSize();
if (attachments.length > MAX_ATTACHMENTS) { 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; let totalSize = 0;
@ -228,20 +229,26 @@ const sendSignalMessageTask = async ({
const estimatedSize = (attachment.data.length * 3) / 4; const estimatedSize = (attachment.data.length * 3) / 4;
if (estimatedSize > MAX_ATTACHMENT_SIZE) { if (estimatedSize > MAX_ATTACHMENT_SIZE) {
logger.warn({ logger.warn(
{
filename: attachment.filename, filename: attachment.filename,
size: estimatedSize, size: estimatedSize,
maxSize: MAX_ATTACHMENT_SIZE maxSize: MAX_ATTACHMENT_SIZE,
}, 'Attachment exceeds size limit, skipping'); },
"Attachment exceeds size limit, skipping",
);
continue; continue;
} }
totalSize += estimatedSize; totalSize += estimatedSize;
if (totalSize > MAX_TOTAL_SIZE) { if (totalSize > MAX_TOTAL_SIZE) {
logger.warn({ logger.warn(
{
totalSize, totalSize,
maxTotalSize: MAX_TOTAL_SIZE maxTotalSize: MAX_TOTAL_SIZE,
}, 'Total attachment size exceeds limit, skipping remaining'); },
"Total attachment size exceeds limit, skipping remaining",
);
break; break;
} }
@ -253,8 +260,10 @@ const sendSignalMessageTask = async ({
logger.debug( logger.debug(
{ {
attachmentCount: validatedAttachments.length, attachmentCount: validatedAttachments.length,
attachmentNames: attachments.slice(0, validatedAttachments.length).map((att) => att.filename), attachmentNames: attachments
totalSizeBytes: totalSize .slice(0, validatedAttachments.length)
.map((att) => att.filename),
totalSizeBytes: totalSize,
}, },
"Including attachments in message", "Including attachments in message",
); );

View file

@ -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

View file

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

View file

@ -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

View file

@ -122,6 +122,11 @@ class ChannelsCdrSignalController < ApplicationController
return update_group return update_group
end end
# Handle group member joined events
if params[:event] == 'group_member_joined'
return handle_group_member_joined
end
channel_id = channel.id channel_id = channel.id
# validate input # 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][:original_recipient] = params[:original_recipient] if params[:original_recipient].present?
ticket.preferences[:cdr_signal][:group_created_at] = params[:timestamp] if params[:timestamp].present? ticket.preferences[:cdr_signal][:group_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! ticket.save!
Rails.logger.info "Signal group #{params[:group_id]} associated with ticket #{ticket.id}" Rails.logger.info "Signal group #{params[:group_id]} associated with ticket #{ticket.id}"
@ -407,4 +416,64 @@ class ChannelsCdrSignalController < ApplicationController
ticket_number: ticket.number ticket_number: ticket.number
}, status: :ok }, status: :ok
end 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 end

View file

@ -30,6 +30,25 @@ class CommunicateCdrSignalJob < ApplicationJob
log_error(article, log_error(article,
"Can't find ticket.preferences['cdr_signal']['chat_id'] for Ticket.find(#{article.ticket_id})") "Can't find ticket.preferences['cdr_signal']['chat_id'] for Ticket.find(#{article.ticket_id})")
end 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 = ::CdrSignal.bot_by_bot_token(ticket.preferences['cdr_signal']['bot_token'])
channel ||= ::Channel.lookup(id: ticket.preferences['channel_id']) channel ||= ::Channel.lookup(id: ticket.preferences['channel_id'])
unless channel unless channel

View file

@ -0,0 +1,9 @@
# frozen_string_literal: true
module Controllers
class CdrSignalChannelsControllerPolicy < Controllers::ApplicationControllerPolicy
def index?
user.permissions?('admin.channel')
end
end
end

View file

@ -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

View file

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

View file

@ -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: "<notification.#{DateTime.current.to_fs(:number)}.#{ticket.id}.#{user.id}.#{SecureRandom.uuid}@#{Setting.get('fqdn')}>",
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