Group refinements

This commit is contained in:
Darren Clarke 2025-07-02 12:07:12 +02:00
parent c8ccee7ada
commit f20cd5a53c
15 changed files with 287 additions and 23 deletions

View file

@ -0,0 +1,62 @@
#!/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();

View file

@ -0,0 +1,67 @@
version: '3.8'
services:
zammad-railsserver:
volumes:
# Controllers
- ${PWD}/packages/zammad-addon-bridge/src/app/controllers/channels_cdr_signal_controller.rb:/opt/zammad/app/controllers/channels_cdr_signal_controller.rb:ro
- ${PWD}/packages/zammad-addon-bridge/src/app/controllers/channels_cdr_voice_controller.rb:/opt/zammad/app/controllers/channels_cdr_voice_controller.rb:ro
- ${PWD}/packages/zammad-addon-bridge/src/app/controllers/channels_cdr_whatsapp_controller.rb:/opt/zammad/app/controllers/channels_cdr_whatsapp_controller.rb:ro
# Models
- ${PWD}/packages/zammad-addon-bridge/src/app/models/channel/driver/cdr_signal.rb:/opt/zammad/app/models/channel/driver/cdr_signal.rb:ro
- ${PWD}/packages/zammad-addon-bridge/src/app/models/channel/driver/cdr_whatsapp.rb:/opt/zammad/app/models/channel/driver/cdr_whatsapp.rb:ro
- ${PWD}/packages/zammad-addon-bridge/src/app/models/ticket/article/enqueue_communicate_cdr_signal_job.rb:/opt/zammad/app/models/ticket/article/enqueue_communicate_cdr_signal_job.rb:ro
- ${PWD}/packages/zammad-addon-bridge/src/app/models/ticket/article/enqueue_communicate_cdr_whatsapp_job.rb:/opt/zammad/app/models/ticket/article/enqueue_communicate_cdr_whatsapp_job.rb:ro
# Jobs
- ${PWD}/packages/zammad-addon-bridge/src/app/jobs/communicate_cdr_signal_job.rb:/opt/zammad/app/jobs/communicate_cdr_signal_job.rb:ro
- ${PWD}/packages/zammad-addon-bridge/src/app/jobs/communicate_cdr_whatsapp_job.rb:/opt/zammad/app/jobs/communicate_cdr_whatsapp_job.rb:ro
# Policies
- ${PWD}/packages/zammad-addon-bridge/src/app/policies/controllers/channels_cdr_signal_controller_policy.rb:/opt/zammad/app/policies/controllers/channels_cdr_signal_controller_policy.rb:ro
- ${PWD}/packages/zammad-addon-bridge/src/app/policies/controllers/channels_cdr_voice_controller_policy.rb:/opt/zammad/app/policies/controllers/channels_cdr_voice_controller_policy.rb:ro
- ${PWD}/packages/zammad-addon-bridge/src/app/policies/controllers/channels_cdr_whatsapp_controller_policy.rb:/opt/zammad/app/policies/controllers/channels_cdr_whatsapp_controller_policy.rb:ro
# Config - initializers
- ${PWD}/packages/zammad-addon-bridge/src/config/initializers/cdr_signal.rb:/opt/zammad/config/initializers/cdr_signal.rb:ro
- ${PWD}/packages/zammad-addon-bridge/src/config/initializers/cdr_whatsapp.rb:/opt/zammad/config/initializers/cdr_whatsapp.rb:ro
# Config - routes
- ${PWD}/packages/zammad-addon-bridge/src/config/routes/channel_cdr_signal.rb:/opt/zammad/config/routes/channel_cdr_signal.rb:ro
- ${PWD}/packages/zammad-addon-bridge/src/config/routes/channel_cdr_voice.rb:/opt/zammad/config/routes/channel_cdr_voice.rb:ro
- ${PWD}/packages/zammad-addon-bridge/src/config/routes/channel_cdr_whatsapp.rb:/opt/zammad/config/routes/channel_cdr_whatsapp.rb:ro
# Database migrations
- ${PWD}/packages/zammad-addon-bridge/src/db/addon/bridge/20210525091356_cdr_signal_channel.rb:/opt/zammad/db/addon/bridge/20210525091356_cdr_signal_channel.rb:ro
- ${PWD}/packages/zammad-addon-bridge/src/db/addon/bridge/20210525091357_cdr_voice_channel.rb:/opt/zammad/db/addon/bridge/20210525091357_cdr_voice_channel.rb:ro
- ${PWD}/packages/zammad-addon-bridge/src/db/addon/bridge/20210525091358_cdr_whatsapp_channel.rb:/opt/zammad/db/addon/bridge/20210525091358_cdr_whatsapp_channel.rb:ro
# Lib files
- ${PWD}/packages/zammad-addon-bridge/src/lib/cdr_signal.rb:/opt/zammad/lib/cdr_signal.rb:ro
- ${PWD}/packages/zammad-addon-bridge/src/lib/cdr_signal_api.rb:/opt/zammad/lib/cdr_signal_api.rb:ro
- ${PWD}/packages/zammad-addon-bridge/src/lib/cdr_whatsapp.rb:/opt/zammad/lib/cdr_whatsapp.rb:ro
- ${PWD}/packages/zammad-addon-bridge/src/lib/cdr_whatsapp_api.rb:/opt/zammad/lib/cdr_whatsapp_api.rb:ro
# Also map to scheduler for background jobs
zammad-scheduler:
volumes:
# Models
- ${PWD}/packages/zammad-addon-bridge/src/app/models/channel/driver/cdr_signal.rb:/opt/zammad/app/models/channel/driver/cdr_signal.rb:ro
- ${PWD}/packages/zammad-addon-bridge/src/app/models/channel/driver/cdr_whatsapp.rb:/opt/zammad/app/models/channel/driver/cdr_whatsapp.rb:ro
- ${PWD}/packages/zammad-addon-bridge/src/app/models/ticket/article/enqueue_communicate_cdr_signal_job.rb:/opt/zammad/app/models/ticket/article/enqueue_communicate_cdr_signal_job.rb:ro
- ${PWD}/packages/zammad-addon-bridge/src/app/models/ticket/article/enqueue_communicate_cdr_whatsapp_job.rb:/opt/zammad/app/models/ticket/article/enqueue_communicate_cdr_whatsapp_job.rb:ro
# Jobs
- ${PWD}/packages/zammad-addon-bridge/src/app/jobs/communicate_cdr_signal_job.rb:/opt/zammad/app/jobs/communicate_cdr_signal_job.rb:ro
- ${PWD}/packages/zammad-addon-bridge/src/app/jobs/communicate_cdr_whatsapp_job.rb:/opt/zammad/app/jobs/communicate_cdr_whatsapp_job.rb:ro
# Config - initializers
- ${PWD}/packages/zammad-addon-bridge/src/config/initializers/cdr_signal.rb:/opt/zammad/config/initializers/cdr_signal.rb:ro
- ${PWD}/packages/zammad-addon-bridge/src/config/initializers/cdr_whatsapp.rb:/opt/zammad/config/initializers/cdr_whatsapp.rb:ro
# Lib files
- ${PWD}/packages/zammad-addon-bridge/src/lib/cdr_signal.rb:/opt/zammad/lib/cdr_signal.rb:ro
- ${PWD}/packages/zammad-addon-bridge/src/lib/cdr_signal_api.rb:/opt/zammad/lib/cdr_signal_api.rb:ro
- ${PWD}/packages/zammad-addon-bridge/src/lib/cdr_whatsapp.rb:/opt/zammad/lib/cdr_whatsapp.rb:ro
- ${PWD}/packages/zammad-addon-bridge/src/lib/cdr_whatsapp_api.rb:/opt/zammad/lib/cdr_whatsapp_api.rb:ro

View file

@ -20,6 +20,7 @@ x-bridge-vars: &common-bridge-variables
NEXTAUTH_URL: ${BRIDGE_DOMAIN}
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
BRIDGE_SIGNAL_URL: ${BRIDGE_SIGNAL_URL}
BRIDGE_SIGNAL_AUTO_GROUPS: ${BRIDGE_SIGNAL_AUTO_GROUPS}
services:
bridge-frontend:

View file

@ -1,4 +1,5 @@
const { spawn } = require("child_process");
const path = require('path');
const app = process.argv[2];
const command = process.argv[3];
@ -16,10 +17,19 @@ const files = {
zammad: ["zammad", "postgresql", "opensearch"],
};
const envFile = path.resolve(process.cwd(), '.env');
const finalFiles = files[app]
.map((file) => ['-f', `docker/compose/${file}.yml`]).flat();
// Add bridge-dev.yml for dev commands that include zammad
const devAppsWithZammad = ['linkDev', 'bridgeDev', 'all'];
if (devAppsWithZammad.includes(app) && files[app].includes('zammad')) {
finalFiles.push('-f', 'docker-compose.bridge-dev.yml');
}
const finalCommand = command === "up" ? ["up", "-d", "--remove-orphans"] : [command];
const dockerCompose = spawn('docker', ['compose', '--env-file', '.env', ...finalFiles, ...finalCommand]);
const dockerCompose = spawn('docker', ['compose', '--env-file', envFile, ...finalFiles, ...finalCommand]);
dockerCompose.stdout.on('data', (data) => {
console.info(`${data}`);

View file

@ -1,5 +1,8 @@
# frozen_string_literal: true
require 'json'
require 'base64'
ROOT = '/opt/zammad'
def _read_file(file, fullpath: false)

View file

@ -36,7 +36,8 @@
"docker:bridge:dev:down": "node docker/scripts/docker.js bridgeDev down",
"docker:bridge:up": "node docker/scripts/docker.js bridge up",
"docker:bridge:down": "node docker/scripts/docker.js bridge down",
"docker:bridge:build": "node docker/scripts/docker.js bridge build"
"docker:bridge:build": "node docker/scripts/docker.js bridge build",
"docker:zammad:restart": "docker restart zammad-railsserver zammad-scheduler"
},
"workspaces": [
"apps/*",

View file

@ -1,4 +1,4 @@
class Index extends App.ControllerSubContent
class ChannelCdrSignal extends App.ControllerSubContent
requiredPermission: 'admin.channel_cdr_signal'
events:
'click .js-new': 'new'
@ -246,4 +246,4 @@ class FormEdit extends App.ControllerModal
@el.find('.alert').removeClass('hidden').text(error_message)
)
App.Config.set('cdr_signal', { prio: 5100, name: 'Signal', parent: '#channels', target: '#channels/cdr_signal', controller: Index, permission: ['admin.channel_cdr_signal'] }, 'NavBarAdmin')
App.Config.set('cdr_signal', { prio: 5100, name: 'Signal', parent: '#channels', target: '#channels/cdr_signal', controller: ChannelCdrSignal, permission: ['admin.channel_cdr_signal'] }, 'NavBarAdmin')

View file

@ -1,4 +1,4 @@
class Index extends App.ControllerSubContent
class ChannelCdrVoice extends App.ControllerSubContent
requiredPermission: 'admin.channel_cdr_voice'
events:
'click .js-new': 'new'
@ -246,4 +246,4 @@ class FormEdit extends App.ControllerModal
@el.find('.alert').removeClass('hidden').text(error_message)
)
App.Config.set('cdr_voice', { prio: 5100, name: 'Voice', parent: '#channels', target: '#channels/cdr_voice', controller: Index, permission: ['admin.channel_cdr_voice'] }, 'NavBarAdmin')
App.Config.set('cdr_voice', { prio: 5100, name: 'Voice', parent: '#channels', target: '#channels/cdr_voice', controller: ChannelCdrVoice, permission: ['admin.channel_cdr_voice'] }, 'NavBarAdmin')

View file

@ -1,4 +1,4 @@
class Index extends App.ControllerSubContent
class ChannelCdrWhatsapp extends App.ControllerSubContent
requiredPermission: 'admin.channel_cdr_whatsapp'
events:
'click .js-new': 'new'
@ -246,4 +246,4 @@ class FormEdit extends App.ControllerModal
@el.find('.alert').removeClass('hidden').text(error_message)
)
App.Config.set('cdr_whatsapp', { prio: 5100, name: 'Whatsapp', parent: '#channels', target: '#channels/cdr_whatsapp', controller: Index, permission: ['admin.channel_cdr_whatsapp'] }, 'NavBarAdmin')
App.Config.set('cdr_whatsapp', { prio: 5100, name: 'Whatsapp', parent: '#channels', target: '#channels/cdr_whatsapp', controller: ChannelCdrWhatsapp, permission: ['admin.channel_cdr_whatsapp'] }, 'NavBarAdmin')

View file

@ -117,6 +117,11 @@ class ChannelsCdrSignalController < ApplicationController
return render json: {}, status: 401 if !channel || !channel.active
return render json: {}, status: 401 if channel.options[:token] != token
# Handle group creation events
if params[:event] == 'group_created'
return update_group
end
channel_id = channel.id
# validate input
@ -141,6 +146,17 @@ class ChannelsCdrSignalController < ApplicationController
receiver_phone_number = params[:to].strip
sender_phone_number = params[:from].strip
# Check if this is a group message using the isGroup flag from bridge-worker
# This flag is set when:
# 1. The original message came from a Signal group
# 2. Bridge-worker created a new group for the conversation
# 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
customer = User.find_by(phone: sender_phone_number)
customer ||= User.find_by(mobile: sender_phone_number)
unless customer
@ -192,13 +208,28 @@ class ChannelsCdrSignalController < ApplicationController
# find ticket or create one
state_ids = Ticket::State.where(name: %w[closed merged removed]).pluck(:id)
ticket = Ticket.where(customer_id: customer.id).where.not(state_id: state_ids).order(:updated_at).first
# 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
else
ticket = Ticket.where(customer_id: customer.id).where.not(state_id: state_ids).order(:updated_at).first
end
if ticket
# check if title need to be updated
ticket.title = title if ticket.title == '-'
new_state = Ticket::State.find_by(default_create: true)
ticket.state = Ticket::State.find_by(default_follow_up: true) if ticket.state_id != new_state.id
else
# Set up chat_id based on whether this is a group message
chat_id = is_group_message ? group_id : sender_phone_number
ticket = Ticket.new(
group_id: channel.group_id,
title: title,
@ -207,10 +238,15 @@ class ChannelsCdrSignalController < ApplicationController
channel_id: channel.id,
cdr_signal: {
bot_token: channel.options[:bot_token], # change to bot id
chat_id: sender_phone_number
chat_id: chat_id
}
}
)
# 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!
@ -265,4 +301,66 @@ class ChannelsCdrSignalController < ApplicationController
render json: result, status: :ok
end
# Webhook endpoint for receiving group creation notifications from bridge-worker
# This is called when a Signal group is created for a conversation
# Expected payload:
# {
# "event": "group_created",
# "conversation_id": "ticket_id_or_number",
# "original_recipient": "+1234567890",
# "group_id": "uuid-of-signal-group",
# "timestamp": "ISO8601 timestamp"
# }
def update_group
# Validate required parameters
errors = {}
errors['event'] = 'required' unless params[:event].present?
errors['conversation_id'] = 'required' unless params[:conversation_id].present?
errors['group_id'] = 'required' unless params[:group_id].present?
if errors.present?
render json: {
errors: errors
}, status: :bad_request
return
end
# Only handle group_created events for now
unless params[:event] == 'group_created'
render json: { error: 'Unsupported event type' }, status: :bad_request
return
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
unless ticket
Rails.logger.error "Signal group update: Ticket not found for conversation_id #{params[:conversation_id]}"
render json: { error: 'Ticket not found' }, status: :not_found
return
end
# Update ticket preferences with the group information
ticket.preferences ||= {}
ticket.preferences[:cdr_signal] ||= {}
ticket.preferences[:cdr_signal][:group_id] = params[:group_id]
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?
ticket.save!
Rails.logger.info "Signal group #{params[:group_id]} associated with ticket #{ticket.id}"
render json: {
success: true,
ticket_id: ticket.id,
ticket_number: ticket.number
}, status: :ok
end
end

View file

@ -43,10 +43,7 @@ class CommunicateCdrSignalJob < ApplicationJob
has_error = false
begin
result = channel.deliver(
to: ticket.preferences[:cdr_signal][:chat_id],
body: article.body
)
result = channel.deliver(article)
rescue StandardError => e
log_error(article, e.message)
has_error = true

View file

@ -43,10 +43,7 @@ class CommunicateCdrWhatsappJob < ApplicationJob
has_error = false
begin
result = channel.deliver(
to: ticket.preferences[:cdr_whatsapp][:chat_id],
body: article.body
)
result = channel.deliver(article)
rescue StandardError => e
log_error(article, e.message)
has_error = true

View file

@ -312,8 +312,21 @@ class CdrSignal
def from_article(article)
# sends a message from a zammad article
Rails.logger.debug { "Create signal message from article to '#{article[:to]}'..." }
Rails.logger.debug { "Create signal message from article..." }
@api.send_message(article[:to], article[:body])
# Get the recipient from ticket preferences
ticket = Ticket.find_by(id: article.ticket_id)
raise "No ticket found for article #{article.id}" unless ticket
recipient = ticket.preferences.dig('cdr_signal', 'chat_id')
raise "No Signal chat_id found in ticket preferences" unless recipient
Rails.logger.debug { "Sending to recipient: '#{recipient}'" }
# Include ticket ID as conversationId for group creation callback
options = {}
options[:conversationId] = ticket.number if ticket
@api.send_message(recipient, article[:body], options)
end
end

View file

@ -35,6 +35,12 @@ class CdrSignalApi
end
def send_message(recipient, text, options = {})
post('send', { to: recipient.to_s, message: text }.merge(parse_hash(options)))
# Don't encode conversationId with CGI
params = { to: recipient.to_s, message: text }
if options[:conversationId]
params[:conversationId] = options[:conversationId]
options.delete(:conversationId)
end
post('send', params.merge(parse_hash(options)))
end
end

View file

@ -312,8 +312,17 @@ class CdrWhatsapp
def from_article(article)
# sends a message from a zammad article
Rails.logger.debug { "Create whatsapp message from article to '#{article[:to]}'..." }
Rails.logger.debug { "Create whatsapp message from article..." }
@api.send_message(article[:to], article[:body])
# Get the recipient from ticket preferences
ticket = Ticket.find_by(id: article.ticket_id)
raise "No ticket found for article #{article.id}" unless ticket
recipient = ticket.preferences.dig('cdr_whatsapp', 'chat_id')
raise "No WhatsApp chat_id found in ticket preferences" unless recipient
Rails.logger.debug { "Sending to recipient: '#{recipient}'" }
@api.send_message(recipient, article[:body])
end
end