From 7be5cb1478285c57cd43cb830267204e75b6dfcb Mon Sep 17 00:00:00 2001 From: Darren Clarke Date: Mon, 7 Jul 2025 20:02:54 +0200 Subject: [PATCH] More groups WIP --- .../scripts/test-signal-groups.ts | 120 ------------ .../scripts/test-zammad-group-webhook.ts | 62 ------- .../tasks/common/notify-webhooks.ts | 47 ++++- .../tasks/fetch-signal-messages.ts | 45 ++++- .../tasks/signal/receive-signal-message.ts | 171 +++++++++++++++++- .../tasks/signal/send-signal-message.ts | 102 ++++++++++- apps/link/next.config.js | 77 ++++---- .../channels_cdr_signal_controller.rb | 125 ++++++++++--- 8 files changed, 488 insertions(+), 261 deletions(-) delete mode 100755 apps/bridge-worker/scripts/test-signal-groups.ts delete mode 100755 apps/bridge-worker/scripts/test-zammad-group-webhook.ts diff --git a/apps/bridge-worker/scripts/test-signal-groups.ts b/apps/bridge-worker/scripts/test-signal-groups.ts deleted file mode 100755 index 837bf6b..0000000 --- a/apps/bridge-worker/scripts/test-signal-groups.ts +++ /dev/null @@ -1,120 +0,0 @@ -#!/usr/bin/env node -import { config } from "dotenv"; -import * as signalApi from "@link-stack/signal-api"; - -// Load environment variables -config(); - -const { Configuration, GroupsApi, MessagesApi } = signalApi; - -async function testSignalGroups() { - console.log("Signal Groups Test Script"); - console.log("=========================\n"); - - // Check environment - const autoGroupsEnabled = process.env.BRIDGE_SIGNAL_AUTO_GROUPS === "true"; - console.log(`Auto-groups enabled: ${autoGroupsEnabled}`); - console.log(`Signal API URL: ${process.env.BRIDGE_SIGNAL_URL}\n`); - - if (!process.env.BRIDGE_SIGNAL_URL) { - console.error("Error: BRIDGE_SIGNAL_URL not set"); - process.exit(1); - } - - try { - const config = new Configuration({ - basePath: process.env.BRIDGE_SIGNAL_URL, - }); - const groupsClient = new GroupsApi(config); - const messagesClient = new MessagesApi(config); - - // List existing groups - console.log("Listing existing groups..."); - const groups = await groupsClient.v1GroupsNumberGet({ - number: process.env.BRIDGE_SIGNAL_BOT_NUMBER, - }); - - console.log(`Found ${groups.length} groups:`); - groups.forEach((group, i) => { - console.log(` ${i + 1}. ${group.name} (${group.id})`); - console.log(` Members: ${group.members?.length || 0}`); - }); - console.log(); - - // Test creating a group (if requested via command line) - if (process.argv.includes("--create-group")) { - const testNumber = process.argv[process.argv.indexOf("--create-group") + 1]; - if (!testNumber) { - console.error("Error: Please provide a phone number after --create-group"); - process.exit(1); - } - - console.log(`Creating test group with ${testNumber}...`); - const createResponse = await groupsClient.v1GroupsNumberPost({ - number: process.env.BRIDGE_SIGNAL_BOT_NUMBER, - data: { - name: `Test Support: ${testNumber}`, - members: [testNumber], - description: "Test private support conversation", - }, - }); - console.log(`Created group: ${createResponse.id}\n`); - - // Send a test message to the group - if (process.argv.includes("--send-message")) { - console.log("Sending test message to group..."); - await messagesClient.v2SendPost({ - data: { - number: process.env.BRIDGE_SIGNAL_BOT_NUMBER, - recipients: [createResponse.id!], - message: "Hello! This is a test message to the new Signal group.", - isGroup: true, - }, - }); - console.log("Message sent successfully!\n"); - } - } - - // Test the receive flow simulation - if (process.argv.includes("--simulate-receive")) { - const fromNumber = process.argv[process.argv.indexOf("--simulate-receive") + 1]; - if (!fromNumber) { - console.error("Error: Please provide a phone number after --simulate-receive"); - process.exit(1); - } - - console.log(`Simulating message receive from ${fromNumber}...`); - console.log("This would trigger the following in production:"); - console.log("1. Message received from individual"); - console.log("2. Auto-group creation (if enabled)"); - console.log("3. Webhook notification to Zammad with group ID in 'to' field\n"); - - if (autoGroupsEnabled) { - console.log("Since auto-groups is enabled, a new group would be created."); - console.log(`Group name would be: Support: ${fromNumber}`); - } else { - console.log("Auto-groups is disabled, so message would be processed normally."); - } - } - - console.log("\nTest script completed successfully!"); - } catch (error) { - console.error("Error during test:", error); - process.exit(1); - } -} - -// Show usage if no arguments -if (process.argv.length === 2) { - console.log("Usage:"); - console.log(" List groups: ts-node test-signal-groups.ts"); - console.log(" Create group: ts-node test-signal-groups.ts --create-group +1234567890"); - console.log(" Create & send: ts-node test-signal-groups.ts --create-group +1234567890 --send-message"); - console.log(" Simulate receive: ts-node test-signal-groups.ts --simulate-receive +1234567890"); - console.log("\nEnvironment variables:"); - console.log(" BRIDGE_SIGNAL_AUTO_GROUPS=true Enable auto-group creation"); - console.log(" BRIDGE_SIGNAL_URL=http://... Signal CLI REST API URL"); - process.exit(0); -} - -testSignalGroups().catch(console.error); diff --git a/apps/bridge-worker/scripts/test-zammad-group-webhook.ts b/apps/bridge-worker/scripts/test-zammad-group-webhook.ts deleted file mode 100755 index 9afa720..0000000 --- a/apps/bridge-worker/scripts/test-zammad-group-webhook.ts +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env tsx -/** - * Test script to verify Zammad group webhook integration - * - * This tests the group_created event webhook that is sent from bridge-worker - * to Zammad when a Signal group is created. - */ - -// Using native fetch which is available in Node.js 18+ - -const ZAMMAD_URL = process.env.ZAMMAD_URL || 'http://localhost:8001'; -const CHANNEL_TOKEN = process.env.CHANNEL_TOKEN || 'test-token'; - -async function testGroupWebhook() { - console.log('Testing Zammad Signal group webhook...\n'); - - const webhookUrl = `${ZAMMAD_URL}/api/v1/channels_cdr_signal_webhook/${CHANNEL_TOKEN}`; - - const payload = { - event: 'group_created', - conversation_id: '12345', // This would be a real ticket number - original_recipient: '+1234567890', - group_id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', - timestamp: new Date().toISOString() - }; - - console.log('Webhook URL:', webhookUrl); - console.log('Payload:', JSON.stringify(payload, null, 2)); - console.log('\nSending request...\n'); - - try { - const response = await fetch(webhookUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), - }); - - const responseText = await response.text(); - console.log('Response Status:', response.status); - console.log('Response Headers:', [...response.headers.entries()]); - console.log('Response Body:', responseText); - - if (response.ok) { - console.log('\n✅ Webhook test successful!'); - try { - const data = JSON.parse(responseText); - console.log('Parsed response:', data); - } catch (e) { - // Response might not be JSON - } - } else { - console.log('\n❌ Webhook test failed!'); - } - } catch (error) { - console.error('\n❌ Error testing webhook:', error); - } -} - -// Run the test -testGroupWebhook(); \ No newline at end of file diff --git a/apps/bridge-worker/tasks/common/notify-webhooks.ts b/apps/bridge-worker/tasks/common/notify-webhooks.ts index 3d81f93..019009f 100644 --- a/apps/bridge-worker/tasks/common/notify-webhooks.ts +++ b/apps/bridge-worker/tasks/common/notify-webhooks.ts @@ -9,6 +9,12 @@ const notifyWebhooksTask = async ( options: NotifyWebhooksOptions, ): Promise => { const { backendId, payload } = options; + + console.log(`[notify-webhooks] Processing webhook notification:`, { + backendId, + payloadKeys: Object.keys(payload), + payload: JSON.stringify(payload, null, 2), + }); const webhooks = await db .selectFrom("Webhook") @@ -16,14 +22,49 @@ const notifyWebhooksTask = async ( .where("backendId", "=", backendId) .execute(); + console.log(`[notify-webhooks] Found ${webhooks.length} webhooks for backend ${backendId}`); + for (const webhook of webhooks) { const { endpointUrl, httpMethod, headers } = webhook; const finalHeaders = { "Content-Type": "application/json", ...headers }; - const result = await fetch(endpointUrl, { + const body = JSON.stringify(payload); + + console.log(`[notify-webhooks] Sending webhook:`, { + url: endpointUrl, method: httpMethod, - headers: finalHeaders, - body: JSON.stringify(payload), + bodyLength: body.length, + headers: Object.keys(finalHeaders), + payload: body, }); + + try { + const result = await fetch(endpointUrl, { + method: httpMethod, + headers: finalHeaders, + body, + }); + + console.log(`[notify-webhooks] Webhook response:`, { + url: endpointUrl, + status: result.status, + statusText: result.statusText, + ok: result.ok, + }); + + if (!result.ok) { + const responseText = await result.text(); + console.error(`[notify-webhooks] Webhook error response:`, { + url: endpointUrl, + status: result.status, + response: responseText.substring(0, 500), // First 500 chars + }); + } + } catch (error) { + console.error(`[notify-webhooks] Webhook request failed:`, { + url: endpointUrl, + error: error instanceof Error ? error.message : error, + }); + } } }; diff --git a/apps/bridge-worker/tasks/fetch-signal-messages.ts b/apps/bridge-worker/tasks/fetch-signal-messages.ts index 304c88c..bd7a470 100644 --- a/apps/bridge-worker/tasks/fetch-signal-messages.ts +++ b/apps/bridge-worker/tasks/fetch-signal-messages.ts @@ -58,14 +58,44 @@ const processMessage = async ({ const { attachments } = dataMessage; const rawTimestamp = dataMessage?.timestamp; + + // Debug logging for group detection + console.log(`[fetch-signal-messages] Processing message:`, { + 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, + }); const timestamp = new Date(rawTimestamp); const formattedAttachments = await fetchAttachments(attachments); const primaryAttachment = formattedAttachments[0] ?? {}; const additionalAttachments = formattedAttachments.slice(1); + + // Extract group ID if this is a group message + const groupId = dataMessage?.groupV2?.id || dataMessage?.groupContext?.id || dataMessage?.groupInfo?.groupId; + + // IMPORTANT: Always use phoneNumber as 'to' for compatibility with Zammad + // The group ID will be passed via the isGroup flag and potentially in metadata + const toRecipient = phoneNumber; + + console.log(`[fetch-signal-messages] Setting recipient:`, { + isGroup, + groupId, + phoneNumber, + toRecipient, + note: 'Using phoneNumber as to for Zammad compatibility', + }); + const primaryMessage = { token: id, - to: phoneNumber, + to: toRecipient, from: source, messageId: `${sourceUuid}-${rawTimestamp}`, message: dataMessage?.message, @@ -74,6 +104,8 @@ const processMessage = async ({ filename: primaryAttachment.filename, mimeType: primaryAttachment.mimeType, isGroup, + // Include groupId if this is a group message + ...(isGroup && groupId ? { groupId } : {}), }; const formattedMessages = [primaryMessage]; @@ -125,6 +157,8 @@ const fetchSignalMessagesTask = async ({ number: phoneNumber, }); + console.log(`[fetch-signal-messages] Fetching messages for bot ${id} (${phoneNumber})`); + for (const message of messages) { const formattedMessages = await processMessage({ id, @@ -133,6 +167,15 @@ const fetchSignalMessagesTask = async ({ }); for (const formattedMessage of formattedMessages) { if (formattedMessage.to !== formattedMessage.from) { + console.log(`[fetch-signal-messages] Creating job for message:`, { + messageId: formattedMessage.messageId, + from: formattedMessage.from, + to: formattedMessage.to, + isGroup: formattedMessage.isGroup, + hasMessage: !!formattedMessage.message, + hasAttachment: !!formattedMessage.attachment, + }); + await worker.addJob( "signal/receive-signal-message", formattedMessage, diff --git a/apps/bridge-worker/tasks/signal/receive-signal-message.ts b/apps/bridge-worker/tasks/signal/receive-signal-message.ts index 67e2568..9401f9a 100644 --- a/apps/bridge-worker/tasks/signal/receive-signal-message.ts +++ b/apps/bridge-worker/tasks/signal/receive-signal-message.ts @@ -13,6 +13,7 @@ interface ReceiveSignalMessageTaskOptions { filename?: string; mimeType?: string; isGroup?: boolean; + groupId?: string; // Group ID from the message envelope } const receiveSignalMessageTask = async ({ @@ -26,7 +27,19 @@ const receiveSignalMessageTask = async ({ filename, mimeType, isGroup, + groupId, }: ReceiveSignalMessageTaskOptions): Promise => { + console.log(`[receive-signal-message] Processing incoming message:`, { + messageId, + from, + to, + isGroup, + groupId, + hasMessage: !!message, + hasAttachment: !!attachment, + token, + toFormat: to?.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i) ? 'UUID' : 'phone', + }); const worker = await getWorkerUtils(); const row = await db .selectFrom("SignalBot") @@ -36,11 +49,24 @@ const receiveSignalMessageTask = async ({ const backendId = row.id; let finalTo = to; + let createdInternalId: string | undefined; // Check if auto-group creation is enabled and this is NOT already a group message const enableAutoGroups = process.env.BRIDGE_SIGNAL_AUTO_GROUPS === "true"; + + console.log(`[receive-signal-message] Auto-groups config:`, { + enableAutoGroups, + isGroup, + shouldCreateGroup: enableAutoGroups && !isGroup && from && to, + }); - if (enableAutoGroups && !isGroup && from && to) { + // If this is already a group message and auto-groups is enabled, + // use the provided groupId or keep the original 'to' + if (enableAutoGroups && isGroup && groupId) { + // Store the group ID for metadata but keep using phone number for 'to' + console.log(`[receive-signal-message] Message is from existing group: ${groupId}`); + finalTo = groupId; // Store for metadata + } else if (enableAutoGroups && !isGroup && from && to) { try { const config = new Configuration({ basePath: process.env.BRIDGE_SIGNAL_URL, @@ -59,19 +85,136 @@ const receiveSignalMessageTask = async ({ }); if (createGroupResponse.id) { + // We need to get the internal_id for this group + // Sometimes the API needs a moment to return the new group, so retry if needed + let internalId: string | undefined; + let retries = 3; + + while (retries > 0 && !internalId) { + try { + const groupsResponse = await groupsClient.v1GroupsNumberGet({ + number: row.phoneNumber, + }); + + // Find the group we just created to get its internal_id + const createdGroup = groupsResponse.find(g => g.id === createGroupResponse.id); + internalId = createdGroup?.internal_id; + + if (!internalId && retries > 1) { + console.log(`[receive-signal-message] Group not found in list yet, retrying...`); + await new Promise(resolve => setTimeout(resolve, 1000)); + } + } catch (error) { + console.error(`[receive-signal-message] Error fetching groups:`, error); + } + retries--; + } + + createdInternalId = internalId; // Store for use in webhook payload + + if (!internalId) { + console.warn( + `[receive-signal-message] Warning: Could not get internal_id for group ${createGroupResponse.id}. Group matching may fail for subsequent messages.`, + ); + } else { + console.log( + `[receive-signal-message] Successfully retrieved internal_id: ${internalId} for group: ${createGroupResponse.id}`, + ); + } + finalTo = createGroupResponse.id; console.log( - `Created new Signal group ${finalTo} for conversation with ${from}`, + `[receive-signal-message] Created new Signal group:`, + { + groupId: finalTo, + internalId, + groupName, + forPhoneNumber: from, + botNumber: row.phoneNumber, + response: createGroupResponse, + }, ); + + // Send a system notification to Zammad about group creation + try { + const systemNote = `Signal Auto-Group Created\n` + + `Group Name: ${groupName}\n` + + `Group ID: ${finalTo}\n\n` + + `Note: The user's initial message has been forwarded to the Signal group with attribution.`; + + await worker.addJob("common/notify-webhooks", { + backendId, + payload: { + to: to, + from: "system", + message_id: `system-group-created-${Date.now()}`, + sent_at: new Date().toISOString(), + message: systemNote, + isGroup: true, + group_id: finalTo, + internal_group_id: internalId, // Also pass the internal_id + internal: true, // Mark as internal note + system_message: true // Additional flag to indicate system message + } + }); + + console.log(`[receive-signal-message] Sent group creation notification to Zammad`); + } catch (notifyError) { + console.error("[receive-signal-message] Error sending system notification:", notifyError); + // Continue processing even if notification fails + } + + // Forward the user's initial message to the group using quote feature + try { + console.log(`[receive-signal-message] Forwarding initial message to group using quote feature`); + + // Build the attribution message + const attributionMessage = `Message from ${from}:\n"${message}"\n\n---\nSupport team: Your request has been received. An agent will respond shortly.`; + + await worker.addJob("signal/send-signal-message", { + token: backendId, + to: finalTo, // Send to the newly created group + message: attributionMessage, + conversationId: null, // No ticket ID yet since we're still processing the initial message + // Quote the original message for context + quoteMessage: message, + quoteAuthor: from, + quoteTimestamp: Date.parse(sentAt), // Convert ISO string to milliseconds + }); + + console.log(`[receive-signal-message] Successfully forwarded initial message to group ${finalTo}`); + } catch (forwardError) { + console.error("[receive-signal-message] Error forwarding message to group:", forwardError); + // Continue processing even if forwarding fails + } + } + } catch (error: any) { + // Check if error is because group already exists + const errorMessage = error?.response?.data?.error || error?.message || error; + const isAlreadyExists = errorMessage?.toString().toLowerCase().includes('already') || + errorMessage?.toString().toLowerCase().includes('exists'); + + if (isAlreadyExists) { + console.log(`[receive-signal-message] Group might already exist for ${from}, continuing with original recipient`); + } else { + console.error("[receive-signal-message] Error creating Signal group:", { + error: errorMessage, + from, + to, + botNumber: row.phoneNumber, + }); } - } catch (error) { - console.error("Error creating Signal group:", error); // Continue with original 'to' if group creation fails } } + // WORKAROUND: Zammad doesn't accept UUID group IDs in the 'to' field + // Instead, we use the bot's phone number and include group metadata + const isGroupMessage = isGroup || finalTo !== to || !!groupId; + const effectiveGroupId = groupId || (finalTo !== to ? finalTo : undefined); + const payload = { - to: finalTo, + to: to, // Always use the bot's phone number, not the group ID from, message_id: messageId, sent_at: sentAt, @@ -79,7 +222,25 @@ const receiveSignalMessageTask = async ({ attachment, filename, mime_type: mimeType, + isGroup: isGroupMessage, + // Include group ID as metadata if this is a group message + ...(effectiveGroupId ? { group_id: effectiveGroupId } : {}), + // Include internal_group_id if we created a new group + ...(createdInternalId ? { internal_group_id: createdInternalId } : {}), }; + + console.log(`[receive-signal-message] Sending webhook notification:`, { + backendId, + originalTo: to, + finalTo, + toChanged: to !== finalTo, + payloadIsGroup: payload.isGroup, + payloadTo: payload.to, + payloadGroupId: (payload as any).group_id, + payloadInternalGroupId: (payload as any).internal_group_id, + messageId: payload.message_id, + createdNewGroup: !!createdInternalId, + }); await worker.addJob("common/notify-webhooks", { backendId, payload }); }; diff --git a/apps/bridge-worker/tasks/signal/send-signal-message.ts b/apps/bridge-worker/tasks/signal/send-signal-message.ts index 7b93150..6ae90d2 100644 --- a/apps/bridge-worker/tasks/signal/send-signal-message.ts +++ b/apps/bridge-worker/tasks/signal/send-signal-message.ts @@ -7,6 +7,9 @@ interface SendSignalMessageTaskOptions { to: string; message: any; conversationId?: string; // Zammad ticket/conversation ID for callback + quoteMessage?: string; // Optional: message text to quote + quoteAuthor?: string; // Optional: author of quoted message (phone number) + quoteTimestamp?: number; // Optional: timestamp of quoted message in milliseconds } const sendSignalMessageTask = async ({ @@ -14,7 +17,16 @@ const sendSignalMessageTask = async ({ to, message, conversationId, + quoteMessage, + quoteAuthor, + quoteTimestamp, }: SendSignalMessageTaskOptions): Promise => { + console.log(`[send-signal-message] Processing outgoing message:`, { + token, + to, + conversationId, + messageLength: message?.length, + }); const bot = await db .selectFrom("SignalBot") .selectAll() @@ -39,6 +51,13 @@ const sendSignalMessageTask = async ({ to, ); const enableAutoGroups = process.env.BRIDGE_SIGNAL_AUTO_GROUPS === "true"; + + console.log(`[send-signal-message] Recipient analysis:`, { + to, + isGroupId, + enableAutoGroups, + shouldCreateGroup: enableAutoGroups && !isGroupId && to && conversationId, + }); // If sending to a phone number and auto-groups is enabled, create a group first if (enableAutoGroups && !isGroupId && to && conversationId) { @@ -54,10 +73,43 @@ const sendSignalMessageTask = async ({ }); if (createGroupResponse.id) { + // Get the internal_id for this group + // Sometimes the API needs a moment to return the new group, so retry if needed + let internalId: string | undefined; + let retries = 3; + + while (retries > 0 && !internalId) { + try { + const groupsResponse = await groupsClient.v1GroupsNumberGet({ + number: bot.phoneNumber, + }); + + // Find the group we just created to get its internal_id + const createdGroup = groupsResponse.find(g => g.id === createGroupResponse.id); + internalId = createdGroup?.internal_id; + + if (!internalId && retries > 1) { + console.log(`[send-signal-message] Group not found in list yet, retrying...`); + await new Promise(resolve => setTimeout(resolve, 1000)); + } + } catch (error) { + console.error(`[send-signal-message] Error fetching groups:`, error); + } + retries--; + } + finalTo = createGroupResponse.id; groupCreated = true; console.log( - `Created new Signal group ${finalTo} for conversation ${conversationId} with ${to}`, + `[send-signal-message] Created new Signal group:`, + { + groupId: finalTo, + internalId, + groupName, + conversationId, + originalRecipient: to, + botNumber: bot.phoneNumber, + }, ); // Notify Zammad about the new group ID via webhook @@ -68,26 +120,60 @@ const sendSignalMessageTask = async ({ conversation_id: conversationId, original_recipient: to, group_id: finalTo, + internal_group_id: internalId, timestamp: new Date().toISOString(), }, }); } } catch (groupError) { - console.error("Error creating Signal group:", groupError); + console.error("[send-signal-message] Error creating Signal group:", { + error: groupError instanceof Error ? groupError.message : groupError, + to, + conversationId, + }); // Continue with original recipient if group creation fails } } + console.log(`[send-signal-message] Sending message via API:`, { + fromNumber: number, + toRecipient: finalTo, + originalTo: to, + recipientChanged: to !== finalTo, + groupCreated, + }); + + // Build the message data with optional quote parameters + const messageData: signalApi.ApiSendMessageV2 = { + number, + recipients: [finalTo], + message, + }; + + // Add quote parameters if all are provided + if (quoteMessage && quoteAuthor && quoteTimestamp) { + messageData.quoteTimestamp = quoteTimestamp; + messageData.quoteAuthor = quoteAuthor; + messageData.quoteMessage = quoteMessage; + + console.log(`[send-signal-message] Including quote in message:`, { + quoteAuthor, + quoteMessage: quoteMessage.substring(0, 50) + '...', + quoteTimestamp, + }); + } + const response = await messagesClient.v2SendPost({ - data: { - number, - recipients: [finalTo], - message, - }, + data: messageData, }); console.log( - `Message sent successfully to ${finalTo}${groupCreated ? " (new group)" : ""}`, + `[send-signal-message] Message sent successfully:`, + { + to: finalTo, + groupCreated, + response: response?.timestamp || 'no timestamp', + }, ); } catch (error) { console.error({ error }); diff --git a/apps/link/next.config.js b/apps/link/next.config.js index c9765ea..71143fb 100644 --- a/apps/link/next.config.js +++ b/apps/link/next.config.js @@ -1,36 +1,43 @@ -/** @type {import('next').NextConfig} */ -const nextConfig = { - basePath: '/link', - poweredByHeader: false, - transpilePackages: [ - "@link-stack/leafcutter-ui", - "@link-stack/opensearch-common", - "@link-stack/ui", - "@link-stack/bridge-common", - "@link-stack/bridge-ui", - "mui-chips-input", - ], - headers: async () => { - return [ - { - source: "/((?!zammad).*)", - headers: [ - { - key: "Strict-Transport-Security", - value: "max-age=63072000; includeSubDomains; preload", - }, - { - key: "X-Frame-Options", - value: "SAMEORIGIN", - }, - { - key: "X-Content-Type-Options", - value: "nosniff", - }, - ], - }, - ]; - }, -}; +/** @type {(phase: string) => import('next').NextConfig} */ +export default function () { + const base = { + basePath: '/link', + poweredByHeader: false, + transpilePackages: [ + '@link-stack/leafcutter-ui', + '@link-stack/opensearch-common', + '@link-stack/ui', + '@link-stack/bridge-common', + '@link-stack/bridge-ui', + 'mui-chips-input', + ], + async headers() { + return [ + { + source: '/((?!zammad).*)', + headers: [ + { key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' }, + { key: 'X-Frame-Options', value: 'SAMEORIGIN' }, + { key: 'X-Content-Type-Options', value: 'nosniff' }, + ], + }, + ]; + }, + }; -export default nextConfig; + /** dev-only extras */ + if (process.env.NODE_ENV === 'development') { + return { + ...base, + experimental: { + ...(base.experimental ?? {}), + serverActions: { + allowedOrigins: ['localhost:8001'], + allowedForwardedHosts: ['localhost'], + }, + }, + }; + } + + return base; +} 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 3bd34af..1837726 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 @@ -154,8 +154,10 @@ class ChannelsCdrSignalController < ApplicationController # Note: We can't rely on UUID format alone because users with privacy settings # may have UUID identifiers instead of phone numbers is_group_message = params[:isGroup] == true || params[:is_group] == true - # If it's a group message, the 'to' field contains the group ID - group_id = is_group_message ? params[:to] : nil + # Group ID is now sent as a separate field to avoid 422 errors + # when 'to' contains a UUID instead of a phone number + group_id = params[:group_id] || (is_group_message ? params[:to] : nil) + customer = User.find_by(phone: sender_phone_number) customer ||= User.find_by(mobile: sender_phone_number) @@ -209,15 +211,66 @@ class ChannelsCdrSignalController < ApplicationController # find ticket or create one state_ids = Ticket::State.where(name: %w[closed merged removed]).pluck(:id) - # For group messages, find ticket by group_id in preferences - if is_group_message - ticket = Ticket.joins("LEFT JOIN tickets AS t2 ON t2.preferences::jsonb -> 'cdr_signal' ->> 'group_id' = '#{group_id}'") - .where(customer_id: customer.id) - .where.not(state_id: state_ids) - .where("tickets.preferences::jsonb -> 'cdr_signal' ->> 'group_id' = ?", group_id) - .order(:updated_at) - .first + # For group messages, find ticket by group_id (either UUID or internal_id) + # Signal uses two different IDs for the same group: + # - UUID (management ID): returned when creating groups via API + # - internal_id (base64): used in message envelopes + if is_group_message && group_id + Rails.logger.info "=== SIGNAL GROUP TICKET LOOKUP ===" + Rails.logger.info "Looking for ticket with group_id: #{group_id}" + Rails.logger.info "Customer ID: #{customer.id}" + Rails.logger.info "Channel ID: #{channel.id}" + + begin + # Find ticket using Ruby filtering to match group_id or internal_group_id + all_tickets = Ticket.where(customer_id: customer.id) + .where.not(state_id: state_ids) + .order(:updated_at) + + Rails.logger.info "Found #{all_tickets.count} active tickets for customer" + + ticket = all_tickets.find do |t| + begin + has_preferences = t.preferences.is_a?(Hash) + has_cdr_signal = has_preferences && t.preferences['cdr_signal'].is_a?(Hash) + + if has_cdr_signal + stored_group_id = t.preferences['cdr_signal']['group_id'] + stored_internal_id = t.preferences['cdr_signal']['internal_group_id'] + stored_channel_id = t.preferences['channel_id'] + + Rails.logger.info "Ticket ##{t.number} (ID: #{t.id}):" + Rails.logger.info " - stored_group_id: #{stored_group_id}" + Rails.logger.info " - stored_internal_id: #{stored_internal_id}" + Rails.logger.info " - stored_channel_id: #{stored_channel_id}" + Rails.logger.info " - incoming_group_id: #{group_id}" + + # Match on either the UUID or the internal_id + matches = group_id == stored_group_id || group_id == stored_internal_id + Rails.logger.info " - MATCH: #{matches}" + + matches + else + Rails.logger.info "Ticket ##{t.number} has no cdr_signal preferences" + false + end + rescue => e + Rails.logger.error "Error checking ticket #{t.id}: #{e.message}" + false + end + end + + if ticket + Rails.logger.info "=== FOUND MATCHING TICKET: ##{ticket.number} ===" + else + Rails.logger.info "=== NO MATCHING TICKET FOUND - WILL CREATE NEW ===" + end + rescue ActiveRecord::StatementInvalid => e + Rails.logger.error "Error finding ticket by group_id: #{e.message}" + ticket = nil + end else + Rails.logger.info "Not a group message or no group_id, finding most recent ticket" ticket = Ticket.where(customer_id: customer.id).where.not(state_id: state_ids).order(:updated_at).first end @@ -230,37 +283,54 @@ class ChannelsCdrSignalController < ApplicationController # Set up chat_id based on whether this is a group message chat_id = is_group_message ? group_id : sender_phone_number + # Build preferences with group_id included if needed + cdr_signal_prefs = { + bot_token: channel.options[:bot_token], # change to bot id + chat_id: chat_id + } + + # Add group_id to preferences if this is a group message + if is_group_message && group_id + cdr_signal_prefs[:group_id] = group_id + # Also store internal_group_id if provided + cdr_signal_prefs[:internal_group_id] = params[:internal_group_id] if params[:internal_group_id].present? + + Rails.logger.info "Creating ticket with group preferences: group_id=#{group_id}, internal_group_id=#{params[:internal_group_id]}" + end + + Rails.logger.info "=== CREATING NEW TICKET ===" + Rails.logger.info "Preferences to be stored:" + Rails.logger.info " - channel_id: #{channel.id}" + Rails.logger.info " - cdr_signal: #{cdr_signal_prefs.inspect}" + ticket = Ticket.new( group_id: channel.group_id, title: title, customer_id: customer.id, preferences: { channel_id: channel.id, - cdr_signal: { - bot_token: channel.options[:bot_token], # change to bot id - chat_id: chat_id - } + cdr_signal: cdr_signal_prefs } ) - - # Store group_id if this is a group message - if is_group_message - ticket.preferences[:cdr_signal][:group_id] = group_id - end end ticket.save! + # Check if this is a system message + is_system_message = params[:system_message] == true || params[:from] == 'system' + article_params = { from: sender_phone_number, to: receiver_phone_number, - sender_id: Ticket::Article::Sender.find_by(name: 'Customer').id, + sender_id: is_system_message ? + Ticket::Article::Sender.find_by(name: 'System').id : + Ticket::Article::Sender.find_by(name: 'Customer').id, subject: title, body: body, content_type: 'text/plain', message_id: "cdr_signal.#{message_id}", ticket_id: ticket.id, - internal: false, + internal: params[:internal] == true || is_system_message, preferences: { cdr_signal: { timestamp: sent_at, @@ -287,7 +357,9 @@ class ChannelsCdrSignalController < ApplicationController ticket.with_lock do ta = article_create(ticket, article_params) - ta.update!(type_id: Ticket::Article::Type.find_by(name: 'cdr_signal').id) + # Use 'note' type for system messages, 'cdr_signal' for regular messages + article_type = is_system_message ? 'note' : 'cdr_signal' + ta.update!(type_id: Ticket::Article::Type.find_by(name: article_type).id) end ticket.update!(create_article_type_id: Ticket::Article::Type.find_by(name: 'cdr_signal').id) @@ -333,11 +405,9 @@ class ChannelsCdrSignalController < ApplicationController end # Find the ticket by ID or number - ticket = if params[:conversation_id].to_s.match?(/^\d+$/) - Ticket.find_by(id: params[:conversation_id]) - else - Ticket.find_by(number: params[:conversation_id]) - end + # Try to find by both ID and number since ticket numbers can be numeric + ticket = Ticket.find_by(id: params[:conversation_id]) || + Ticket.find_by(number: params[:conversation_id]) unless ticket Rails.logger.error "Signal group update: Ticket not found for conversation_id #{params[:conversation_id]}" @@ -349,6 +419,7 @@ class ChannelsCdrSignalController < ApplicationController ticket.preferences ||= {} ticket.preferences[:cdr_signal] ||= {} ticket.preferences[:cdr_signal][:group_id] = params[:group_id] + ticket.preferences[:cdr_signal][:internal_group_id] = params[:internal_group_id] if params[:internal_group_id].present? ticket.preferences[:cdr_signal][:chat_id] = params[:group_id] 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?