link-stack/packages/zammad-addon-bridge/src/app/controllers/channels_cdr_signal_controller.rb
Darren Clarke 3d8f794cab Add user ID support for Baileys 7 LIDs and Signal UUIDs
Baileys 7 uses LIDs (Linked IDs) instead of phone numbers in remoteJid for
some messages. This caused messages to be matched to wrong tickets because
the LID was used as the sender identifier. This commit adds proper support
for both phone numbers and user IDs across WhatsApp and Signal channels.

Changes:

Database:
- Add migration for whatsapp_user_id and signal_user_id fields on users table

Zammad controllers:
- Update user lookup with 3-step fallback: phone → dedicated user_id field →
  user_id in phone field (legacy)
- Store user IDs in dedicated fields when available
- Update phone field when we receive actual phone number for legacy records
- Fix redundant condition in Signal controller

Bridge services:
- Extract both phone (from senderPn/participantPn) and LID (from remoteJid)
- Send both identifiers to Zammad via webhooks
- Use camelCase (userId) in bridge-whatsapp, convert to snake_case (user_id)
  in bridge-worker for Zammad compatibility

Baileys 7 compliance:
- Remove broken loadAllUnreadMessages() call (removed in Baileys 7)
- Return descriptive error directing users to use webhooks instead

Misc:
- Add docs/ to .gitignore
2026-01-15 13:08:56 +01:00

496 lines
16 KiB
Ruby

# frozen_string_literal: true
class ChannelsCdrSignalController < ApplicationController
prepend_before_action -> { authentication_check && authorize! }, except: [:webhook]
skip_before_action :verify_csrf_token, only: [:webhook]
include CreatesTicketArticles
def index
assets = {}
channel_ids = []
Channel.where(area: 'Signal::Number').order(:id).each do |channel|
assets = channel.assets(assets)
channel_ids.push channel.id
end
render json: {
assets: assets,
channel_ids: channel_ids
}
end
def add
begin
errors = {}
errors['group_id'] = 'required' unless params[:group_id].present?
if errors.present?
render json: {
errors: errors
}, status: :bad_request
return
end
channel = Channel.create(
area: 'Signal::Number',
options: {
adapter: 'cdr_signal',
phone_number: params[:phone_number],
bot_token: params[:bot_token],
bot_endpoint: params[:bot_endpoint],
token: SecureRandom.urlsafe_base64(48),
organization_id: params[:organization_id]
},
group_id: params[:group_id],
active: true
)
rescue StandardError => e
raise Exceptions::UnprocessableEntity, e.message
end
render json: channel
end
def update
errors = {}
errors['group_id'] = 'required' unless params[:group_id].present?
if errors.present?
render json: {
errors: errors
}, status: :bad_request
return
end
channel = Channel.find_by(id: params[:id], area: 'Signal::Number')
begin
channel.options[:phone_number] = params[:phone_number]
channel.options[:bot_token] = params[:bot_token]
channel.options[:bot_endpoint] = params[:bot_endpoint]
channel.options[:organization_id] = params[:organization_id]
channel.group_id = params[:group_id]
channel.save!
rescue StandardError => e
raise Exceptions::UnprocessableEntity, e.message
end
render json: channel
end
def rotate_token
channel = Channel.find_by(id: params[:id], area: 'Signal::Number')
channel.options[:token] = SecureRandom.urlsafe_base64(48)
channel.save!
render json: {}
end
def enable
channel = Channel.find_by(id: params[:id], area: 'Signal::Number')
channel.active = true
channel.save!
render json: {}
end
def disable
channel = Channel.find_by(id: params[:id], area: 'Signal::Number')
channel.active = false
channel.save!
render json: {}
end
def destroy
channel = Channel.find_by(id: params[:id], area: 'Signal::Number')
channel.destroy
render json: {}
end
def channel_for_token(token)
return false unless token
Channel.where(area: 'Signal::Number').each do |channel|
return channel if channel.options[:token] == token
end
false
end
def webhook
token = params['token']
return render json: {}, status: 401 unless token
channel = channel_for_token(token)
return render json: {}, status: 401 if !channel || !channel.active
# Use constant-time comparison to prevent timing attacks
return render json: {}, status: 401 unless ActiveSupport::SecurityUtils.secure_compare(
channel.options[:token].to_s,
token.to_s
)
# Handle group creation events
if params[:event] == 'group_created'
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
errors = {}
# %i[to
# from
# message_id
# sent_at].each | field |
# (errors[field] = 'required' if params[field].blank?)
if errors.present?
render json: {
errors: errors
}, status: :bad_request
return
end
message_id = params[:message_id]
return if Ticket::Article.exists?(message_id: "cdr_signal.#{message_id}")
receiver_phone_number = params[:to].strip
sender_phone_number = params[:from].present? ? params[:from].strip : nil
sender_user_id = params[:user_id].present? ? params[:user_id].strip : nil
# Check if this is a group message using the is_group 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
is_group_message = params[:is_group].to_s == 'true'
# Lookup customer with fallback chain:
# 1. Phone number in phone/mobile fields (preferred)
# 2. Signal user ID in signal_user_id field
# 3. User ID in phone/mobile fields (legacy - we used to store UUIDs there)
customer = nil
if sender_phone_number.present?
customer = User.find_by(phone: sender_phone_number)
customer ||= User.find_by(mobile: sender_phone_number)
end
if customer.nil? && sender_user_id.present?
customer = User.find_by(signal_user_id: sender_user_id)
# Legacy fallback: user ID might be stored in phone field
customer ||= User.find_by(phone: sender_user_id)
customer ||= User.find_by(mobile: sender_user_id)
end
unless customer
role_ids = Role.signup_role_ids
customer = User.create(
firstname: '',
lastname: '',
email: '',
password: '',
phone: sender_phone_number.presence || sender_user_id,
signal_user_id: sender_user_id,
note: 'CDR Signal',
active: true,
role_ids: role_ids,
updated_by_id: 1,
created_by_id: 1
)
end
# Update signal_user_id if we have it and customer doesn't
if sender_user_id.present? && customer.signal_user_id.blank?
customer.update(signal_user_id: sender_user_id)
end
# Update phone if we have it and customer only has user_id in phone field
if sender_phone_number.present? && customer.phone == sender_user_id
customer.update(phone: sender_phone_number)
end
# set current user
UserInfo.current_user_id = customer.id
current_user_set(customer, 'token_auth')
group = Group.find_by(id: channel.group_id)
if group.blank?
Rails.logger.error "Signal channel #{channel_id} paired with Group #{channel.group_id}, but group does not exist!"
return render json: { error: 'There was an error during Signal submission' }, status: 500
end
organization_id = channel.options['organization_id']
if organization_id.present?
organization = Organization.find_by(id: organization_id)
unless organization.present?
Rails.logger.error "Signal channel #{channel_id} paired with Organization #{organization_id}, but organization does not exist!"
return render json: { error: 'There was an error during Signal submission' }, status: 500
end
unless customer.organization_id.present?
customer.organization_id = organization.id
customer.save!
end
end
message = params[:message] ||= 'No text content'
sent_at = params[:sent_at]
attachment_data_base64 = params[:attachment]
attachment_filename = params[:filename]
attachment_mimetype = params[:mime_type]
sender_display = sender_phone_number.presence || sender_user_id
title = "Message from #{sender_display} at #{sent_at}"
body = message
# find ticket or create one
state_ids = Ticket::State.where(name: %w[closed merged removed]).pluck(:id)
if is_group_message
Rails.logger.info "=== SIGNAL GROUP TICKET LOOKUP ==="
Rails.logger.info "Looking for ticket with group_id: #{receiver_phone_number}"
Rails.logger.info "Customer ID: #{customer.id}"
Rails.logger.info "Customer Phone: #{sender_display}"
Rails.logger.info "Channel ID: #{channel.id}"
begin
# Use text search on preferences YAML to efficiently find tickets without loading all into memory
# This prevents DoS attacks from memory exhaustion
ticket = Ticket.where.not(state_id: state_ids)
.where("preferences LIKE ?", "%channel_id: #{channel.id}%")
.where("preferences LIKE ?", "%chat_id: #{receiver_phone_number}%")
.order(updated_at: :desc)
.first
if ticket
Rails.logger.info "=== FOUND MATCHING TICKET BY GROUP ID: ##{ticket.number} ==="
# Update customer if different (handles duplicate phone numbers)
if ticket.customer_id != customer.id
Rails.logger.info "Updating ticket customer from #{ticket.customer_id} to #{customer.id}"
ticket.customer_id = customer.id
end
else
Rails.logger.info "=== NO MATCHING TICKET BY GROUP ID - CHECKING BY PHONE NUMBER ==="
end
rescue => e
Rails.logger.error "Error during group ticket lookup: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
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
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 ? receiver_phone_number : (sender_phone_number.presence || sender_user_id)
# Build preferences with group_id included if needed
cdr_signal_prefs = {
bot_token: channel.options[:bot_token],
chat_id: chat_id,
user_id: sender_user_id
}
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: cdr_signal_prefs
}
)
end
ticket.save!
article_params = {
from: sender_display,
to: receiver_phone_number,
sender_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: params[:internal] == true,
preferences: {
cdr_signal: {
timestamp: sent_at,
message_id: message_id,
from: sender_phone_number,
user_id: sender_user_id
}
}
}
if attachment_data_base64.present?
article_params[:attachments] = [
# i don't even...
# this is necessary because of what's going on in controllers/concerns/creates_ticket_articles.rb
# we need help from the ruby gods
{
'filename' => attachment_filename,
:filename => attachment_filename,
:data => attachment_data_base64,
'data' => attachment_data_base64,
'mime-type' => attachment_mimetype
}
]
end
ticket.with_lock do
ta = article_create(ticket, article_params)
ta.update!(type_id: Ticket::Article::Type.find_by(name: 'cdr_signal').id)
end
ticket.update!(create_article_type_id: Ticket::Article::Type.find_by(name: 'cdr_signal').id)
result = {
ticket: {
id: ticket.id,
number: ticket.number
}
}
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
# 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]}"
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][: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?
# 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}"
render json: {
success: true,
ticket_id: ticket.id,
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
# Use text search on preferences YAML for efficient lookup (prevents DoS from loading all tickets)
state_ids = Ticket::State.where(name: %w[closed merged removed]).pluck(:id)
ticket = Ticket.where.not(state_id: state_ids)
.where("preferences LIKE ?", "%chat_id: #{params[:group_id]}%")
.order(updated_at: :desc)
.first
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
# Idempotency check: if already marked as joined, skip update and return success
# This prevents unnecessary database writes when the cron job sends duplicate notifications
if ticket.preferences.dig('cdr_signal', 'group_joined') == true
Rails.logger.debug "Signal group member #{params[:member_phone]} already marked as joined for group #{params[:group_id]} ticket #{ticket.id}, skipping update"
render json: {
success: true,
ticket_id: ticket.id,
ticket_number: ticket.number,
group_joined: true,
already_joined: true
}, status: :ok
return
end
# Update group_joined flag
member_phone = params[:member_phone]
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