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
496 lines
16 KiB
Ruby
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
|