2023-02-13 12:41:30 +00:00
# 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
2025-11-21 14:55:28 +01:00
# 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
2023-02-13 12:41:30 +00:00
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 ] . strip
2025-11-21 14:55:28 +01:00
# 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' || params [ :is_group ] . to_s == 'true'
2023-02-13 12:41:30 +00:00
customer = User . find_by ( phone : sender_phone_number )
customer || = User . find_by ( mobile : sender_phone_number )
unless customer
role_ids = Role . signup_role_ids
customer = User . create (
firstname : '' ,
lastname : '' ,
email : '' ,
password : '' ,
phone : sender_phone_number ,
note : 'CDR Signal' ,
active : true ,
role_ids : role_ids ,
updated_by_id : 1 ,
created_by_id : 1
)
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 ]
title = " Message from #{ sender_phone_number } at #{ sent_at } "
body = message
# find ticket or create one
state_ids = Ticket :: State . where ( name : %w[ closed merged removed ] ) . pluck ( :id )
2025-11-21 14:55:28 +01:00
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_phone_number } "
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
2023-02-13 12:41:30 +00:00
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
2025-11-21 14:55:28 +01:00
# Set up chat_id based on whether this is a group message
chat_id = is_group_message ? receiver_phone_number : 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
}
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 } "
2023-02-13 12:41:30 +00:00
ticket = Ticket . new (
group_id : channel . group_id ,
title : title ,
customer_id : customer . id ,
preferences : {
channel_id : channel . id ,
2025-11-21 14:55:28 +01:00
cdr_signal : cdr_signal_prefs
2023-02-13 12:41:30 +00:00
}
)
end
ticket . save!
article_params = {
from : sender_phone_number ,
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 ,
2025-11-21 14:55:28 +01:00
internal : params [ :internal ] == true ,
2023-02-13 12:41:30 +00:00
preferences : {
cdr_signal : {
timestamp : sent_at ,
message_id : message_id ,
from : sender_phone_number
}
}
}
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
2025-11-21 14:55:28 +01:00
# 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
2023-02-13 12:41:30 +00:00
end