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