497 lines
16 KiB
Ruby
497 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_uid 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_uid: 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_uid: sender_user_id,
|
|
note: 'CDR Signal',
|
|
active: true,
|
|
role_ids: role_ids,
|
|
updated_by_id: 1,
|
|
created_by_id: 1
|
|
)
|
|
end
|
|
|
|
# Update signal_uid if we have it and customer doesn't
|
|
if sender_user_id.present? && customer.signal_uid.blank?
|
|
customer.update(signal_uid: 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
|
|
# For direct messages, prefer UUID (more stable than phone numbers which can change)
|
|
chat_id = is_group_message ? receiver_phone_number : (sender_user_id.presence || sender_phone_number)
|
|
|
|
# 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
|