Repo cleanup
This commit is contained in:
parent
59872f579a
commit
e941353b64
444 changed files with 1485 additions and 21978 deletions
408
packages/zammad-addon-link/src/lib/cdr_signal.rb
Normal file
408
packages/zammad-addon-link/src/lib/cdr_signal.rb
Normal file
|
|
@ -0,0 +1,408 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'cdr_signal_api'
|
||||
|
||||
class CdrSignal
|
||||
attr_accessor :client
|
||||
|
||||
#
|
||||
# check token and return bot attributes of token
|
||||
#
|
||||
# bot = CdrSignal.check_token('token')
|
||||
#
|
||||
|
||||
def self.check_token(phone_number)
|
||||
api = CdrSignalApi.new
|
||||
unless api.check_number(phone_number)
|
||||
raise "Phone number #{phone_number} is not registered with Signal CLI"
|
||||
end
|
||||
{ 'id' => phone_number, 'number' => phone_number }
|
||||
end
|
||||
|
||||
#
|
||||
# create or update channel, store bot attributes and verify token
|
||||
#
|
||||
# channel = CdrSignal.create_or_update_channel('token', params)
|
||||
#
|
||||
# returns
|
||||
#
|
||||
# channel # instance of Channel
|
||||
#
|
||||
|
||||
def self.create_or_update_channel(phone_number, params, channel = nil)
|
||||
# verify phone number is registered with Signal CLI
|
||||
bot = CdrSignal.check_token(phone_number)
|
||||
|
||||
raise 'Bot already exists!' unless channel && CdrSignal.bot_duplicate?(bot['id'])
|
||||
|
||||
raise 'Group needed!' if params[:group_id].blank?
|
||||
|
||||
group = Group.find_by(id: params[:group_id])
|
||||
raise 'Group invalid!' unless group
|
||||
|
||||
unless channel
|
||||
channel = CdrSignal.bot_by_bot_id(bot['id'])
|
||||
channel ||= Channel.new
|
||||
end
|
||||
channel.area = 'Signal::Account'
|
||||
channel.options = {
|
||||
adapter: 'cdr_signal',
|
||||
phone_number: phone_number,
|
||||
welcome: params[:welcome]
|
||||
}
|
||||
channel.group_id = group.id
|
||||
channel.active = true
|
||||
channel.save!
|
||||
channel
|
||||
end
|
||||
|
||||
#
|
||||
# check if bot already exists as channel
|
||||
#
|
||||
# success = CdrSignal.bot_duplicate?(bot_id)
|
||||
#
|
||||
# returns
|
||||
#
|
||||
# channel # instance of Channel
|
||||
#
|
||||
|
||||
def self.bot_duplicate?(bot_id, channel_id = nil)
|
||||
Channel.where(area: 'Signal::Account').each do |channel|
|
||||
next unless channel.options
|
||||
next unless channel.options[:bot]
|
||||
next unless channel.options[:bot][:id]
|
||||
next if channel.options[:bot][:id] != bot_id
|
||||
next if channel.id.to_s == channel_id.to_s
|
||||
|
||||
return true
|
||||
end
|
||||
false
|
||||
end
|
||||
|
||||
#
|
||||
# get channel by bot_id
|
||||
#
|
||||
# channel = CdrSignal.bot_by_bot_id(bot_id)
|
||||
#
|
||||
# returns
|
||||
#
|
||||
# true|false
|
||||
#
|
||||
|
||||
def self.bot_by_bot_token(bot_token)
|
||||
Channel.where(area: 'Signal::Account').each do |channel|
|
||||
next unless channel.options
|
||||
next unless channel.options[:bot_token]
|
||||
return channel if channel.options[:bot_token].to_s == bot_token.to_s
|
||||
end
|
||||
nil
|
||||
end
|
||||
|
||||
#
|
||||
# date = CdrSignal.timestamp_to_date('1543414973285')
|
||||
#
|
||||
# returns
|
||||
#
|
||||
# 2018-11-28T14:22:53.285Z
|
||||
#
|
||||
|
||||
def self.timestamp_to_date(timestamp_str)
|
||||
Time.at(timestamp_str.to_i).utc.to_datetime
|
||||
end
|
||||
|
||||
def self.message_id(message_raw)
|
||||
format('%<from>s@%<timestamp>s', from: message_raw['from'], timestamp: message_raw['timestamp'])
|
||||
end
|
||||
|
||||
#
|
||||
# client = CdrSignal.new('token')
|
||||
#
|
||||
|
||||
def initialize(phone_number)
|
||||
@phone_number = phone_number
|
||||
@api = CdrSignalApi.new
|
||||
end
|
||||
|
||||
#
|
||||
# client.send_message(chat_id, 'some message')
|
||||
#
|
||||
|
||||
def send_message(recipient, message)
|
||||
return if Rails.env.test?
|
||||
|
||||
@api.send_message(@phone_number, [recipient], message)
|
||||
end
|
||||
|
||||
def user(number)
|
||||
{
|
||||
# id: params[:message][:from][:id],
|
||||
id: number,
|
||||
username: number
|
||||
# first_name: params[:message][:from][:first_name],
|
||||
# last_name: params[:message][:from][:last_name]
|
||||
}
|
||||
end
|
||||
|
||||
def to_user(message)
|
||||
Rails.logger.debug { 'Create user from message...' }
|
||||
Rails.logger.debug { message.inspect }
|
||||
|
||||
# do message_user lookup
|
||||
message_user = user(message[:source])
|
||||
|
||||
# create or update user
|
||||
login = message_user[:username] || message_user[:id]
|
||||
|
||||
auth = Authorization.find_by(uid: message[:source], provider: 'cdr_signal')
|
||||
|
||||
user_data = {
|
||||
login: login,
|
||||
mobile: message[:source]
|
||||
}
|
||||
|
||||
user = if auth
|
||||
User.find(auth.user_id)
|
||||
else
|
||||
User.where(mobile: message[:source]).order(:updated_at).first
|
||||
end
|
||||
if user
|
||||
user.update!(user_data)
|
||||
else
|
||||
user = User.create!(
|
||||
firstname: message[:source],
|
||||
mobile: message[:source],
|
||||
note: "Signal #{message_user[:username]}",
|
||||
active: true,
|
||||
role_ids: Role.signup_role_ids
|
||||
)
|
||||
end
|
||||
|
||||
# create or update authorization
|
||||
auth_data = {
|
||||
uid: message_user[:id],
|
||||
username: login,
|
||||
user_id: user.id,
|
||||
provider: 'cdr_signal'
|
||||
}
|
||||
if auth
|
||||
auth.update!(auth_data)
|
||||
else
|
||||
Authorization.create(auth_data)
|
||||
end
|
||||
|
||||
user
|
||||
end
|
||||
|
||||
def to_ticket(message, user, group_id, channel)
|
||||
UserInfo.current_user_id = user.id
|
||||
|
||||
Rails.logger.debug { 'Create ticket from message...' }
|
||||
Rails.logger.debug { message.inspect }
|
||||
Rails.logger.debug { user.inspect }
|
||||
Rails.logger.debug { group_id.inspect }
|
||||
|
||||
# prepare title
|
||||
title = '-'
|
||||
title = message[:message][:body] unless message[:message][:body].nil?
|
||||
title = "#{title[0, 60]}..." if title.length > 60
|
||||
|
||||
# find ticket or create one
|
||||
state_ids = Ticket::State.where(name: %w[closed merged removed]).pluck(:id)
|
||||
ticket = Ticket.where(customer_id: user.id).where.not(state_id: state_ids).order(:updated_at).first
|
||||
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
|
||||
ticket.save!
|
||||
return ticket
|
||||
end
|
||||
|
||||
ticket = Ticket.new(
|
||||
group_id: group_id,
|
||||
title: title,
|
||||
state_id: Ticket::State.find_by(default_create: true).id,
|
||||
priority_id: Ticket::Priority.find_by(default_create: true).id,
|
||||
customer_id: user.id,
|
||||
preferences: {
|
||||
channel_id: channel.id,
|
||||
cdr_signal: {
|
||||
bot_token: channel.options[:bot_token],
|
||||
chat_id: message[:source]
|
||||
}
|
||||
}
|
||||
)
|
||||
ticket.save!
|
||||
ticket
|
||||
end
|
||||
|
||||
def to_article(message, user, ticket, channel)
|
||||
Rails.logger.debug { 'Create article from message...' }
|
||||
Rails.logger.debug { message.inspect }
|
||||
Rails.logger.debug { user.inspect }
|
||||
Rails.logger.debug { ticket.inspect }
|
||||
|
||||
UserInfo.current_user_id = user.id
|
||||
|
||||
article = Ticket::Article.new(
|
||||
from: message[:source],
|
||||
to: channel[:options][:bot][:number],
|
||||
body: message[:message][:body],
|
||||
content_type: 'text/plain',
|
||||
message_id: "cdr_signal.#{message[:id]}",
|
||||
ticket_id: ticket.id,
|
||||
type_id: Ticket::Article::Type.find_by(name: 'cdr_signal').id,
|
||||
sender_id: Ticket::Article::Sender.find_by(name: 'Customer').id,
|
||||
internal: false,
|
||||
preferences: {
|
||||
cdr_signal: {
|
||||
timestamp: message[:timestamp],
|
||||
message_id: message[:id],
|
||||
from: message[:source]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# TODO: attachments
|
||||
# TODO voice
|
||||
# TODO emojis
|
||||
#
|
||||
if message[:message][:body]
|
||||
Rails.logger.debug { article.inspect }
|
||||
article.save!
|
||||
|
||||
Store.remove(
|
||||
object: 'Ticket::Article',
|
||||
o_id: article.id
|
||||
)
|
||||
|
||||
return article
|
||||
end
|
||||
raise 'invalid action'
|
||||
end
|
||||
|
||||
def to_group(message, group_id, channel)
|
||||
# begin import
|
||||
Rails.logger.debug { 'signal import message' }
|
||||
|
||||
# TODO: handle messages in group chats
|
||||
|
||||
return if Ticket::Article.find_by(message_id: message[:id])
|
||||
|
||||
ticket = nil
|
||||
# use transaction
|
||||
Transaction.execute(reset_user_id: true) do
|
||||
user = to_user(message)
|
||||
ticket = to_ticket(message, user, group_id, channel)
|
||||
to_article(message, user, ticket, channel)
|
||||
end
|
||||
|
||||
ticket
|
||||
end
|
||||
|
||||
def from_article(article)
|
||||
# sends a message from a zammad article using direct Signal CLI API
|
||||
|
||||
Rails.logger.debug { "Create signal message from article..." }
|
||||
|
||||
# Get the recipient from ticket preferences
|
||||
ticket = Ticket.find_by(id: article.ticket_id)
|
||||
raise "No ticket found for article #{article.id}" unless ticket
|
||||
|
||||
# Get channel to find the bot phone number
|
||||
channel = Channel.find_by(id: ticket.preferences[:channel_id])
|
||||
raise "No channel found for ticket #{ticket.id}" unless channel
|
||||
|
||||
bot_phone_number = channel.options[:phone_number]
|
||||
raise "No phone number configured for channel #{channel.id}" unless bot_phone_number
|
||||
|
||||
recipient = ticket.preferences.dig('cdr_signal', 'chat_id')
|
||||
enable_auto_groups = ENV['BRIDGE_SIGNAL_AUTO_GROUPS'].to_s.downcase == 'true'
|
||||
|
||||
# If auto-groups is enabled and no chat_id, use original_recipient
|
||||
if recipient.blank? && enable_auto_groups
|
||||
recipient = ticket.preferences.dig('cdr_signal', 'original_recipient')
|
||||
raise "No Signal chat_id or original_recipient found in ticket preferences" unless recipient
|
||||
elsif recipient.blank?
|
||||
raise "No Signal chat_id found in ticket preferences"
|
||||
end
|
||||
|
||||
Rails.logger.debug { "Sending to recipient: '#{recipient}'" }
|
||||
|
||||
# Use Signal CLI API
|
||||
api = CdrSignalApi.new
|
||||
|
||||
# Check if we need to create a group (auto-groups enabled, recipient is a phone number)
|
||||
is_group_id = recipient.start_with?('group.')
|
||||
final_recipient = recipient
|
||||
|
||||
if enable_auto_groups && !is_group_id && recipient.present?
|
||||
# Create a group for this conversation
|
||||
begin
|
||||
group_name = "Support Request: #{ticket.number}"
|
||||
|
||||
Rails.logger.info "Creating Signal group '#{group_name}' for ticket ##{ticket.number}"
|
||||
|
||||
create_result = api.create_group(
|
||||
bot_phone_number,
|
||||
name: group_name,
|
||||
members: [recipient],
|
||||
description: 'Private support conversation'
|
||||
)
|
||||
|
||||
if create_result['id'].present?
|
||||
final_recipient = create_result['id']
|
||||
|
||||
# Update ticket preferences with the new group ID
|
||||
ticket.preferences[:cdr_signal] ||= {}
|
||||
ticket.preferences[:cdr_signal][:chat_id] = final_recipient
|
||||
ticket.preferences[:cdr_signal][:original_recipient] = recipient
|
||||
ticket.preferences[:cdr_signal][:group_joined] = false
|
||||
ticket.preferences[:cdr_signal][:group_created_at] = Time.current.iso8601
|
||||
ticket.save!
|
||||
|
||||
Rails.logger.info "Created Signal group #{final_recipient} for ticket ##{ticket.number}"
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Failed to create Signal group: #{e.message}"
|
||||
# Continue with original recipient if group creation fails
|
||||
end
|
||||
end
|
||||
|
||||
# Get attachments from the article
|
||||
options = {}
|
||||
attachments = Store.list(object: 'Ticket::Article', o_id: article.id)
|
||||
if attachments.any?
|
||||
attachment_data = attachments.map do |attachment|
|
||||
{
|
||||
data: Base64.strict_encode64(attachment.content),
|
||||
filename: attachment.filename,
|
||||
mime_type: attachment.preferences['Mime-Type'] || attachment.preferences['Content-Type'] || 'application/octet-stream'
|
||||
}
|
||||
end
|
||||
options[:attachments] = attachment_data
|
||||
Rails.logger.debug { "Sending #{attachment_data.length} attachment(s) with message" }
|
||||
end
|
||||
|
||||
# Send the message via direct Signal CLI API
|
||||
result = api.send_message(bot_phone_number, [final_recipient], article[:body], options)
|
||||
|
||||
Rails.logger.info "Sent Signal message to #{final_recipient}"
|
||||
|
||||
# Update group name if needed (for consistency)
|
||||
if final_recipient.start_with?('group.')
|
||||
expected_name = "Support Request: #{ticket.number}"
|
||||
api.update_group(bot_phone_number, final_recipient, name: expected_name)
|
||||
end
|
||||
|
||||
# Return result in expected format
|
||||
{
|
||||
'result' => {
|
||||
'to' => final_recipient,
|
||||
'from' => bot_phone_number,
|
||||
'timestamp' => result['timestamp'] || Time.current.to_i * 1000
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
156
packages/zammad-addon-link/src/lib/cdr_signal_api.rb
Normal file
156
packages/zammad-addon-link/src/lib/cdr_signal_api.rb
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'json'
|
||||
require 'net/http'
|
||||
require 'net/https'
|
||||
require 'uri'
|
||||
|
||||
# Direct Signal CLI API client for communicating with signal-cli-rest-api
|
||||
# All Signal operations go through this single class
|
||||
class CdrSignalApi
|
||||
def initialize(base_url = nil)
|
||||
@base_url = base_url || ENV.fetch('SIGNAL_CLI_URL', 'http://signal-cli-rest-api:8080')
|
||||
end
|
||||
|
||||
# Fetch pending messages for a phone number
|
||||
# GET /v1/receive/{number}
|
||||
def fetch_messages(phone_number)
|
||||
url = "#{@base_url}/v1/receive/#{CGI.escape(phone_number)}"
|
||||
response = Faraday.get(url, nil, { 'Accept' => 'application/json' })
|
||||
return [] unless response.success?
|
||||
|
||||
JSON.parse(response.body)
|
||||
rescue JSON::ParserError, Faraday::Error => e
|
||||
Rails.logger.error "CdrSignalApi: Failed to fetch messages for #{phone_number}: #{e.message}"
|
||||
[]
|
||||
end
|
||||
|
||||
# Fetch an attachment by ID
|
||||
# GET /v1/attachments/{id}
|
||||
def fetch_attachment(attachment_id)
|
||||
url = "#{@base_url}/v1/attachments/#{CGI.escape(attachment_id)}"
|
||||
response = Faraday.get(url)
|
||||
return nil unless response.success?
|
||||
|
||||
response.body
|
||||
rescue Faraday::Error => e
|
||||
Rails.logger.error "CdrSignalApi: Failed to fetch attachment #{attachment_id}: #{e.message}"
|
||||
nil
|
||||
end
|
||||
|
||||
# List all groups for a phone number
|
||||
# GET /v1/groups/{number}
|
||||
def list_groups(phone_number)
|
||||
url = "#{@base_url}/v1/groups/#{CGI.escape(phone_number)}"
|
||||
response = Faraday.get(url, nil, { 'Accept' => 'application/json' })
|
||||
return [] unless response.success?
|
||||
|
||||
JSON.parse(response.body)
|
||||
rescue JSON::ParserError, Faraday::Error => e
|
||||
Rails.logger.error "CdrSignalApi: Failed to list groups for #{phone_number}: #{e.message}"
|
||||
[]
|
||||
end
|
||||
|
||||
# Check if a phone number is registered with signal-cli
|
||||
# GET /v1/about
|
||||
def check_number(phone_number)
|
||||
# Verify we can connect to signal-cli-rest-api
|
||||
url = "#{@base_url}/v1/about"
|
||||
response = Faraday.get(url, nil, { 'Accept' => 'application/json' })
|
||||
return false unless response.success?
|
||||
|
||||
# Try to list groups for this number to verify it's registered
|
||||
groups_url = "#{@base_url}/v1/groups/#{CGI.escape(phone_number)}"
|
||||
groups_response = Faraday.get(groups_url, nil, { 'Accept' => 'application/json' })
|
||||
groups_response.success?
|
||||
rescue Faraday::Error => e
|
||||
Rails.logger.error "CdrSignalApi: Failed to check number #{phone_number}: #{e.message}"
|
||||
false
|
||||
end
|
||||
|
||||
# Send a message via Signal CLI
|
||||
# POST /v2/send
|
||||
def send_message(from_number, recipients, message, options = {})
|
||||
url = "#{@base_url}/v2/send"
|
||||
|
||||
data = {
|
||||
number: from_number,
|
||||
recipients: Array(recipients),
|
||||
message: message
|
||||
}
|
||||
|
||||
# Add base64 attachments if provided
|
||||
if options[:attachments].present?
|
||||
data[:base64Attachments] = options[:attachments].map { |a| a[:data] }
|
||||
end
|
||||
|
||||
# Add quote parameters if provided
|
||||
if options[:quote_timestamp] && options[:quote_author] && options[:quote_message]
|
||||
data[:quoteTimestamp] = options[:quote_timestamp]
|
||||
data[:quoteAuthor] = options[:quote_author]
|
||||
data[:quoteMessage] = options[:quote_message]
|
||||
end
|
||||
|
||||
response = Faraday.post(url, data.to_json, {
|
||||
'Content-Type' => 'application/json',
|
||||
'Accept' => 'application/json'
|
||||
})
|
||||
|
||||
unless response.success?
|
||||
Rails.logger.error "CdrSignalApi: Failed to send message: #{response.status} #{response.body}"
|
||||
raise "Failed to send Signal message: #{response.status}"
|
||||
end
|
||||
|
||||
JSON.parse(response.body)
|
||||
rescue Faraday::Error => e
|
||||
Rails.logger.error "CdrSignalApi: Failed to send message: #{e.message}"
|
||||
raise "Failed to send Signal message: #{e.message}"
|
||||
end
|
||||
|
||||
# Create a new Signal group
|
||||
# POST /v1/groups/{number}
|
||||
def create_group(phone_number, name:, members:, description: nil)
|
||||
url = "#{@base_url}/v1/groups/#{CGI.escape(phone_number)}"
|
||||
|
||||
data = {
|
||||
name: name,
|
||||
members: Array(members),
|
||||
description: description
|
||||
}.compact
|
||||
|
||||
response = Faraday.post(url, data.to_json, {
|
||||
'Content-Type' => 'application/json',
|
||||
'Accept' => 'application/json'
|
||||
})
|
||||
|
||||
unless response.success?
|
||||
Rails.logger.error "CdrSignalApi: Failed to create group: #{response.status} #{response.body}"
|
||||
raise "Failed to create Signal group: #{response.status}"
|
||||
end
|
||||
|
||||
JSON.parse(response.body)
|
||||
rescue Faraday::Error => e
|
||||
Rails.logger.error "CdrSignalApi: Failed to create group: #{e.message}"
|
||||
raise "Failed to create Signal group: #{e.message}"
|
||||
end
|
||||
|
||||
# Update a Signal group
|
||||
# PUT /v1/groups/{number}/{groupId}
|
||||
def update_group(phone_number, group_id, name: nil, description: nil)
|
||||
url = "#{@base_url}/v1/groups/#{CGI.escape(phone_number)}/#{CGI.escape(group_id)}"
|
||||
|
||||
data = {}
|
||||
data[:name] = name if name.present?
|
||||
data[:description] = description if description.present?
|
||||
|
||||
response = Faraday.put(url, data.to_json, {
|
||||
'Content-Type' => 'application/json',
|
||||
'Accept' => 'application/json'
|
||||
})
|
||||
|
||||
response.success?
|
||||
rescue Faraday::Error => e
|
||||
Rails.logger.error "CdrSignalApi: Failed to update group: #{e.message}"
|
||||
false
|
||||
end
|
||||
end
|
||||
428
packages/zammad-addon-link/src/lib/cdr_signal_poller.rb
Normal file
428
packages/zammad-addon-link/src/lib/cdr_signal_poller.rb
Normal file
|
|
@ -0,0 +1,428 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# CdrSignalPoller handles polling Signal CLI for incoming messages and group membership changes.
|
||||
# This replaces the bridge-worker tasks:
|
||||
# - fetch-signal-messages.ts
|
||||
# - check-group-membership.ts
|
||||
#
|
||||
# It runs via Zammad schedulers to poll at regular intervals.
|
||||
|
||||
class CdrSignalPoller
|
||||
class << self
|
||||
# Fetch messages from all active Signal channels
|
||||
# This is called by the scheduler every 30 seconds
|
||||
def fetch_messages
|
||||
api = CdrSignalApi.new
|
||||
channels = Channel.where(area: 'Signal::Number', active: true)
|
||||
|
||||
channels.each do |channel|
|
||||
phone_number = channel.options[:phone_number]
|
||||
bot_token = channel.options[:bot_token]
|
||||
next unless phone_number.present?
|
||||
|
||||
Rails.logger.debug { "CdrSignalPoller: Fetching messages for #{phone_number}" }
|
||||
|
||||
messages = api.fetch_messages(phone_number)
|
||||
process_messages(channel, messages, api)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "CdrSignalPoller: Error fetching messages for #{phone_number}: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
# Check group membership for all active Signal channels
|
||||
# This is called by the scheduler every 2 minutes
|
||||
def check_group_membership
|
||||
api = CdrSignalApi.new
|
||||
channels = Channel.where(area: 'Signal::Number', active: true)
|
||||
|
||||
channels.each do |channel|
|
||||
phone_number = channel.options[:phone_number]
|
||||
next unless phone_number.present?
|
||||
|
||||
Rails.logger.debug { "CdrSignalPoller: Checking groups for #{phone_number}" }
|
||||
|
||||
groups = api.list_groups(phone_number)
|
||||
process_group_membership(channel, groups)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "CdrSignalPoller: Error checking groups for #{phone_number}: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_messages(channel, messages, api)
|
||||
messages.each do |msg|
|
||||
envelope = msg['envelope']
|
||||
next unless envelope
|
||||
|
||||
source = envelope['source']
|
||||
source_uuid = envelope['sourceUuid']
|
||||
data_message = envelope['dataMessage']
|
||||
sync_message = envelope['syncMessage']
|
||||
|
||||
# Log envelope types for debugging
|
||||
Rails.logger.debug do
|
||||
"CdrSignalPoller: Received envelope - source: #{source}, uuid: #{source_uuid}, " \
|
||||
"dataMessage: #{data_message.present?}, syncMessage: #{sync_message.present?}"
|
||||
end
|
||||
|
||||
# Handle group join events from groupInfo
|
||||
if data_message && data_message['groupInfo']
|
||||
handle_group_info_event(channel, data_message['groupInfo'], source)
|
||||
end
|
||||
|
||||
# Process data messages with content
|
||||
next unless data_message
|
||||
|
||||
process_data_message(channel, data_message, source, source_uuid, api)
|
||||
end
|
||||
end
|
||||
|
||||
def handle_group_info_event(channel, group_info, source)
|
||||
type = group_info['type']
|
||||
return unless %w[JOIN JOINED].include?(type)
|
||||
|
||||
group_id_raw = group_info['groupId']
|
||||
return unless group_id_raw
|
||||
|
||||
group_id = "group.#{Base64.strict_encode64(group_id_raw.pack('c*'))}"
|
||||
|
||||
Rails.logger.info "CdrSignalPoller: User #{source} joined group #{group_id}"
|
||||
notify_group_member_joined(channel, group_id, source)
|
||||
end
|
||||
|
||||
def process_data_message(channel, data_message, source, source_uuid, api)
|
||||
# Determine if this is a group message
|
||||
is_group = data_message['groupV2'].present? ||
|
||||
data_message['groupContext'].present? ||
|
||||
data_message['groupInfo'].present?
|
||||
|
||||
# Get group ID if applicable
|
||||
group_id_raw = data_message.dig('groupV2', 'id') ||
|
||||
data_message.dig('groupContext', 'id') ||
|
||||
data_message.dig('groupInfo', 'groupId')
|
||||
|
||||
phone_number = channel.options[:phone_number]
|
||||
to_recipient = if group_id_raw
|
||||
"group.#{Base64.strict_encode64(group_id_raw.is_a?(Array) ? group_id_raw.pack('c*') : group_id_raw)}"
|
||||
else
|
||||
phone_number
|
||||
end
|
||||
|
||||
# Skip if message is from self
|
||||
return if source == phone_number
|
||||
|
||||
message_text = data_message['message']
|
||||
raw_timestamp = data_message['timestamp']
|
||||
attachments = data_message['attachments']
|
||||
|
||||
# Generate unique message ID
|
||||
message_id = "#{source_uuid}-#{raw_timestamp}"
|
||||
|
||||
# Check for duplicate
|
||||
return if Ticket::Article.exists?(message_id: "cdr_signal.#{message_id}")
|
||||
|
||||
# Fetch and encode attachments
|
||||
attachment_data = fetch_attachments(attachments, api)
|
||||
|
||||
# Process the message through the webhook handler
|
||||
process_incoming_message(
|
||||
channel: channel,
|
||||
to: to_recipient,
|
||||
from: source,
|
||||
user_id: source_uuid,
|
||||
message_id: message_id,
|
||||
message: message_text,
|
||||
sent_at: raw_timestamp ? Time.at(raw_timestamp / 1000).iso8601 : Time.current.iso8601,
|
||||
attachments: attachment_data,
|
||||
is_group: is_group
|
||||
)
|
||||
end
|
||||
|
||||
def fetch_attachments(attachments, api)
|
||||
return [] unless attachments.is_a?(Array)
|
||||
|
||||
attachments.filter_map do |att|
|
||||
id = att['id']
|
||||
content_type = att['contentType']
|
||||
filename = att['filename']
|
||||
|
||||
blob = api.fetch_attachment(id)
|
||||
next unless blob
|
||||
|
||||
# Generate filename if not provided
|
||||
default_filename = filename
|
||||
unless default_filename
|
||||
extension = content_type&.split('/')&.last || 'bin'
|
||||
default_filename = id.include?('.') ? id : "#{id}.#{extension}"
|
||||
end
|
||||
|
||||
{
|
||||
filename: default_filename,
|
||||
mime_type: content_type,
|
||||
data: Base64.strict_encode64(blob)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def process_incoming_message(channel:, to:, from:, user_id:, message_id:, message:, sent_at:, attachments:, is_group:)
|
||||
# Find or create customer
|
||||
customer = find_or_create_customer(from, user_id)
|
||||
return unless customer
|
||||
|
||||
# Set current user context
|
||||
UserInfo.current_user_id = customer.id
|
||||
|
||||
# Find or create ticket
|
||||
ticket = find_or_create_ticket(
|
||||
channel: channel,
|
||||
customer: customer,
|
||||
to: to,
|
||||
from: from,
|
||||
user_id: user_id,
|
||||
is_group: is_group,
|
||||
sent_at: sent_at
|
||||
)
|
||||
|
||||
# Create article
|
||||
create_article(
|
||||
ticket: ticket,
|
||||
from: from,
|
||||
to: to,
|
||||
user_id: user_id,
|
||||
message_id: message_id,
|
||||
message: message || 'No text content',
|
||||
sent_at: sent_at,
|
||||
attachments: attachments
|
||||
)
|
||||
|
||||
Rails.logger.info "CdrSignalPoller: Created article for ticket ##{ticket.number} from #{from}"
|
||||
end
|
||||
|
||||
def find_or_create_customer(phone_number, user_id)
|
||||
# Try phone number first
|
||||
customer = User.find_by(phone: phone_number) if phone_number.present?
|
||||
customer ||= User.find_by(mobile: phone_number) if phone_number.present?
|
||||
|
||||
# Try user ID
|
||||
if customer.nil? && user_id.present?
|
||||
customer = User.find_by(signal_uid: user_id)
|
||||
customer ||= User.find_by(phone: user_id)
|
||||
customer ||= User.find_by(mobile: user_id)
|
||||
end
|
||||
|
||||
# Create new customer if not found
|
||||
unless customer
|
||||
role_ids = Role.signup_role_ids
|
||||
customer = User.create!(
|
||||
firstname: '',
|
||||
lastname: '',
|
||||
email: '',
|
||||
password: '',
|
||||
phone: phone_number.presence || user_id,
|
||||
signal_uid: user_id,
|
||||
note: 'CDR Signal',
|
||||
active: true,
|
||||
role_ids: role_ids,
|
||||
updated_by_id: 1,
|
||||
created_by_id: 1
|
||||
)
|
||||
end
|
||||
|
||||
# Update signal_uid if needed
|
||||
customer.update!(signal_uid: user_id) if user_id.present? && customer.signal_uid.blank?
|
||||
|
||||
# Update phone if customer only has user_id
|
||||
customer.update!(phone: phone_number) if phone_number.present? && customer.phone == user_id
|
||||
|
||||
customer
|
||||
end
|
||||
|
||||
def find_or_create_ticket(channel:, customer:, to:, from:, user_id:, is_group:, sent_at:)
|
||||
state_ids = Ticket::State.where(name: %w[closed merged removed]).pluck(:id)
|
||||
sender_display = from.presence || user_id
|
||||
|
||||
if is_group
|
||||
# Find ticket by group ID
|
||||
ticket = Ticket.where.not(state_id: state_ids)
|
||||
.where('preferences LIKE ?', "%channel_id: #{channel.id}%")
|
||||
.where('preferences LIKE ?', "%chat_id: #{to}%")
|
||||
.order(updated_at: :desc)
|
||||
.first
|
||||
else
|
||||
# Find ticket by customer
|
||||
ticket = Ticket.where(customer_id: customer.id)
|
||||
.where.not(state_id: state_ids)
|
||||
.order(:updated_at)
|
||||
.first
|
||||
end
|
||||
|
||||
if ticket
|
||||
ticket.title = "Message from #{sender_display} at #{sent_at}" 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
|
||||
chat_id = is_group ? to : (user_id.presence || from)
|
||||
|
||||
cdr_signal_prefs = {
|
||||
bot_token: channel.options[:bot_token],
|
||||
chat_id: chat_id,
|
||||
user_id: user_id
|
||||
}
|
||||
cdr_signal_prefs[:original_recipient] = from if is_group
|
||||
|
||||
ticket = Ticket.new(
|
||||
group_id: channel.group_id,
|
||||
title: "Message from #{sender_display} at #{sent_at}",
|
||||
customer_id: customer.id,
|
||||
preferences: {
|
||||
channel_id: channel.id,
|
||||
cdr_signal: cdr_signal_prefs
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
ticket.save!
|
||||
ticket.update!(create_article_type_id: Ticket::Article::Type.find_by(name: 'cdr_signal').id)
|
||||
ticket
|
||||
end
|
||||
|
||||
def create_article(ticket:, from:, to:, user_id:, message_id:, message:, sent_at:, attachments:)
|
||||
sender_display = from.presence || user_id
|
||||
|
||||
article_params = {
|
||||
ticket_id: ticket.id,
|
||||
type_id: Ticket::Article::Type.find_by(name: 'cdr_signal').id,
|
||||
sender_id: Ticket::Article::Sender.find_by(name: 'Customer').id,
|
||||
from: sender_display,
|
||||
to: to,
|
||||
subject: "Message from #{sender_display} at #{sent_at}",
|
||||
body: message,
|
||||
content_type: 'text/plain',
|
||||
message_id: "cdr_signal.#{message_id}",
|
||||
internal: false,
|
||||
preferences: {
|
||||
cdr_signal: {
|
||||
timestamp: sent_at,
|
||||
message_id: message_id,
|
||||
from: from,
|
||||
user_id: user_id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Add primary attachment if present
|
||||
if attachments.present? && attachments.first
|
||||
primary = attachments.first
|
||||
article_params[:attachments] = [{
|
||||
'filename' => primary[:filename],
|
||||
filename: primary[:filename],
|
||||
data: primary[:data],
|
||||
'data' => primary[:data],
|
||||
'mime-type' => primary[:mime_type]
|
||||
}]
|
||||
end
|
||||
|
||||
ticket.with_lock do
|
||||
article = Ticket::Article.create!(article_params)
|
||||
|
||||
# Create additional articles for extra attachments
|
||||
attachments[1..].each_with_index do |att, index|
|
||||
Ticket::Article.create!(
|
||||
article_params.merge(
|
||||
message_id: "cdr_signal.#{message_id}-#{index + 1}",
|
||||
subject: att[:filename],
|
||||
body: att[:filename],
|
||||
attachments: [{
|
||||
'filename' => att[:filename],
|
||||
filename: att[:filename],
|
||||
data: att[:data],
|
||||
'data' => att[:data],
|
||||
'mime-type' => att[:mime_type]
|
||||
}]
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
article
|
||||
end
|
||||
end
|
||||
|
||||
def process_group_membership(channel, groups)
|
||||
groups.each do |group|
|
||||
group_id = group['id']
|
||||
internal_id = group['internalId']
|
||||
members = group['members'] || []
|
||||
next unless group_id && internal_id
|
||||
|
||||
Rails.logger.debug do
|
||||
"CdrSignalPoller: Group #{group['name']} - #{members.length} members, " \
|
||||
"#{(group['pendingInvites'] || []).length} pending"
|
||||
end
|
||||
|
||||
members.each do |member_phone|
|
||||
notify_group_member_joined(channel, group_id, member_phone)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def notify_group_member_joined(channel, group_id, member_phone)
|
||||
# Find ticket with this group_id
|
||||
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: #{group_id}%")
|
||||
.order(updated_at: :desc)
|
||||
.first
|
||||
|
||||
return unless ticket
|
||||
|
||||
# Idempotency check
|
||||
return if ticket.preferences.dig('cdr_signal', 'group_joined') == true
|
||||
|
||||
# Update group_joined flag
|
||||
ticket.preferences[:cdr_signal] ||= {}
|
||||
ticket.preferences[:cdr_signal][:group_joined] = true
|
||||
ticket.preferences[:cdr_signal][:group_joined_at] = Time.current.iso8601
|
||||
ticket.preferences[:cdr_signal][:group_joined_by] = member_phone
|
||||
ticket.save!
|
||||
|
||||
Rails.logger.info "CdrSignalPoller: Member #{member_phone} joined group #{group_id} for ticket ##{ticket.number}"
|
||||
|
||||
# Add resolution note if there were pending notifications
|
||||
add_group_join_resolution_note(ticket)
|
||||
end
|
||||
|
||||
def add_group_join_resolution_note(ticket)
|
||||
# Check if any articles had a group_not_joined notification
|
||||
articles_with_pending = Ticket::Article.where(ticket_id: ticket.id)
|
||||
.where('preferences LIKE ?', '%group_not_joined_note_added: true%')
|
||||
|
||||
return unless articles_with_pending.exists?
|
||||
|
||||
# Check if resolution note already exists
|
||||
resolution_exists = Ticket::Article.where(ticket_id: ticket.id)
|
||||
.where('preferences LIKE ?', '%group_joined_resolution: true%')
|
||||
.exists?
|
||||
|
||||
return if resolution_exists
|
||||
|
||||
Ticket::Article.create!(
|
||||
ticket_id: ticket.id,
|
||||
content_type: 'text/plain',
|
||||
body: 'Recipient has now joined the Signal group. Pending messages will be delivered shortly.',
|
||||
internal: true,
|
||||
sender: Ticket::Article::Sender.find_by(name: 'System'),
|
||||
type: Ticket::Article::Type.find_by(name: 'note'),
|
||||
preferences: {
|
||||
delivery_message: true,
|
||||
group_joined_resolution: true
|
||||
},
|
||||
updated_by_id: 1,
|
||||
created_by_id: 1
|
||||
)
|
||||
|
||||
Rails.logger.info "CdrSignalPoller: Added resolution note for ticket ##{ticket.number}"
|
||||
end
|
||||
end
|
||||
end
|
||||
344
packages/zammad-addon-link/src/lib/cdr_whatsapp.rb
Normal file
344
packages/zammad-addon-link/src/lib/cdr_whatsapp.rb
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'cdr_whatsapp_api'
|
||||
|
||||
class CdrWhatsapp
|
||||
attr_accessor :client
|
||||
|
||||
#
|
||||
# check token and return bot attributes of token
|
||||
#
|
||||
# bot = CdrWhatsapp.check_token('token')
|
||||
#
|
||||
|
||||
def self.check_token(api_url, token)
|
||||
api = CdrWhatsappApi.new(api_url, token)
|
||||
begin
|
||||
bot = api.fetch_self
|
||||
rescue StandardError => e
|
||||
raise "invalid api token: #{e.message}"
|
||||
end
|
||||
bot
|
||||
end
|
||||
|
||||
#
|
||||
# create or update channel, store bot attributes and verify token
|
||||
#
|
||||
# channel = CdrWhatsapp.create_or_update_channel('token', params)
|
||||
#
|
||||
# returns
|
||||
#
|
||||
# channel # instance of Channel
|
||||
#
|
||||
|
||||
def self.create_or_update_channel(api_url, token, params, channel = nil)
|
||||
# verify token
|
||||
bot = CdrWhatsapp.check_token(api_url, token)
|
||||
|
||||
raise 'Bot already exists!' unless channel && CdrWhatsapp.bot_duplicate?(bot['id'])
|
||||
|
||||
raise 'Group needed!' if params[:group_id].blank?
|
||||
|
||||
group = Group.find_by(id: params[:group_id])
|
||||
raise 'Group invalid!' unless group
|
||||
|
||||
unless channel
|
||||
channel = CdrWhatsapp.bot_by_bot_id(bot['id'])
|
||||
channel ||= Channel.new
|
||||
end
|
||||
channel.area = 'Whatsapp::Account'
|
||||
channel.options = {
|
||||
adapter: 'cdr_whatsapp',
|
||||
bot: {
|
||||
id: bot['id'],
|
||||
number: bot['number']
|
||||
},
|
||||
api_token: token,
|
||||
api_url: api_url,
|
||||
welcome: params[:welcome]
|
||||
}
|
||||
channel.group_id = group.id
|
||||
channel.active = true
|
||||
channel.save!
|
||||
channel
|
||||
end
|
||||
|
||||
#
|
||||
# check if bot already exists as channel
|
||||
#
|
||||
# success = CdrWhatsapp.bot_duplicate?(bot_id)
|
||||
#
|
||||
# returns
|
||||
#
|
||||
# channel # instance of Channel
|
||||
#
|
||||
|
||||
def self.bot_duplicate?(bot_id, channel_id = nil)
|
||||
Channel.where(area: 'Whatsapp::Account').each do |channel|
|
||||
next unless channel.options
|
||||
next unless channel.options[:bot]
|
||||
next unless channel.options[:bot][:id]
|
||||
next if channel.options[:bot][:id] != bot_id
|
||||
next if channel.id.to_s == channel_id.to_s
|
||||
|
||||
return true
|
||||
end
|
||||
false
|
||||
end
|
||||
|
||||
#
|
||||
# get channel by bot_id
|
||||
#
|
||||
# channel = CdrWhatsapp.bot_by_bot_id(bot_id)
|
||||
#
|
||||
# returns
|
||||
#
|
||||
# true|false
|
||||
#
|
||||
|
||||
def self.bot_by_bot_token(bot_token)
|
||||
Channel.where(area: 'Whatsapp::Account').each do |channel|
|
||||
next unless channel.options
|
||||
next unless channel.options[:bot_token]
|
||||
return channel if channel.options[:bot_token].to_s == bot_token.to_s
|
||||
end
|
||||
nil
|
||||
end
|
||||
|
||||
#
|
||||
# date = CdrWhatsapp.timestamp_to_date('1543414973285')
|
||||
#
|
||||
# returns
|
||||
#
|
||||
# 2018-11-28T14:22:53.285Z
|
||||
#
|
||||
|
||||
def self.timestamp_to_date(timestamp_str)
|
||||
Time.at(timestamp_str.to_i).utc.to_datetime
|
||||
end
|
||||
|
||||
def self.message_id(message_raw)
|
||||
format('%<from>s@%<timestamp>s', from: message_raw['from'], timestamp: message_raw['timestamp'])
|
||||
end
|
||||
|
||||
#
|
||||
# client = CdrWhatsapp.new('token')
|
||||
#
|
||||
|
||||
def initialize(api_url, token)
|
||||
@token = token
|
||||
@api_url = api_url
|
||||
@api = CdrWhatsappApi.new(api_url, token)
|
||||
end
|
||||
|
||||
#
|
||||
# client.send_message(chat_id, 'some message')
|
||||
#
|
||||
|
||||
def send_message(recipient, message)
|
||||
return if Rails.env.test?
|
||||
|
||||
@api.send_message(recipient, message)
|
||||
end
|
||||
|
||||
def user(number)
|
||||
{
|
||||
# id: params[:message][:from][:id],
|
||||
id: number,
|
||||
username: number
|
||||
# first_name: params[:message][:from][:first_name],
|
||||
# last_name: params[:message][:from][:last_name]
|
||||
}
|
||||
end
|
||||
|
||||
def to_user(message)
|
||||
Rails.logger.debug { 'Create user from message...' }
|
||||
Rails.logger.debug { message.inspect }
|
||||
|
||||
# do message_user lookup
|
||||
message_user = user(message[:source])
|
||||
|
||||
# create or update user
|
||||
login = message_user[:username] || message_user[:id]
|
||||
|
||||
auth = Authorization.find_by(uid: message[:source], provider: 'whatsapp')
|
||||
|
||||
user_data = {
|
||||
login: login,
|
||||
mobile: message[:source]
|
||||
}
|
||||
|
||||
user = if auth
|
||||
User.find(auth.user_id)
|
||||
else
|
||||
User.where(mobile: message[:source]).order(:updated_at).first
|
||||
end
|
||||
if user
|
||||
user.update!(user_data)
|
||||
else
|
||||
user = User.create!(
|
||||
firstname: message[:source],
|
||||
mobile: message[:source],
|
||||
note: "Whatsapp #{message_user[:username]}",
|
||||
active: true,
|
||||
role_ids: Role.signup_role_ids
|
||||
)
|
||||
end
|
||||
|
||||
# create or update authorization
|
||||
auth_data = {
|
||||
uid: message_user[:id],
|
||||
username: login,
|
||||
user_id: user.id,
|
||||
provider: 'cdr_whatsapp'
|
||||
}
|
||||
if auth
|
||||
auth.update!(auth_data)
|
||||
else
|
||||
Authorization.create(auth_data)
|
||||
end
|
||||
|
||||
user
|
||||
end
|
||||
|
||||
def to_ticket(message, user, group_id, channel)
|
||||
UserInfo.current_user_id = user.id
|
||||
|
||||
Rails.logger.debug { 'Create ticket from message...' }
|
||||
Rails.logger.debug { message.inspect }
|
||||
Rails.logger.debug { user.inspect }
|
||||
Rails.logger.debug { group_id.inspect }
|
||||
|
||||
# prepare title
|
||||
title = '-'
|
||||
title = message[:message][:body] unless message[:message][:body].nil?
|
||||
title = "#{title[0, 60]}..." if title.length > 60
|
||||
|
||||
# find ticket or create one
|
||||
state_ids = Ticket::State.where(name: %w[closed merged removed]).pluck(:id)
|
||||
ticket = Ticket.where(customer_id: user.id).where.not(state_id: state_ids).order(:updated_at).first
|
||||
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
|
||||
ticket.save!
|
||||
return ticket
|
||||
end
|
||||
|
||||
ticket = Ticket.new(
|
||||
group_id: group_id,
|
||||
title: title,
|
||||
state_id: Ticket::State.find_by(default_create: true).id,
|
||||
priority_id: Ticket::Priority.find_by(default_create: true).id,
|
||||
customer_id: user.id,
|
||||
preferences: {
|
||||
channel_id: channel.id,
|
||||
cdr_whatsapp: {
|
||||
bot_id: channel.options[:bot][:id],
|
||||
chat_id: message[:source]
|
||||
}
|
||||
}
|
||||
)
|
||||
ticket.save!
|
||||
ticket
|
||||
end
|
||||
|
||||
def to_article(message, user, ticket, channel)
|
||||
Rails.logger.debug { 'Create article from message...' }
|
||||
Rails.logger.debug { message.inspect }
|
||||
Rails.logger.debug { user.inspect }
|
||||
Rails.logger.debug { ticket.inspect }
|
||||
|
||||
UserInfo.current_user_id = user.id
|
||||
|
||||
article = Ticket::Article.new(
|
||||
from: message[:source],
|
||||
to: channel[:options][:bot][:number],
|
||||
body: message[:message][:body],
|
||||
content_type: 'text/plain',
|
||||
message_id: "cdr_whatsapp.#{message[:id]}",
|
||||
ticket_id: ticket.id,
|
||||
type_id: Ticket::Article::Type.find_by(name: 'cdr_whatsapp').id,
|
||||
sender_id: Ticket::Article::Sender.find_by(name: 'Customer').id,
|
||||
internal: false,
|
||||
preferences: {
|
||||
cdr_whatsapp: {
|
||||
timestamp: message[:timestamp],
|
||||
message_id: message[:id],
|
||||
from: message[:source]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# TODO: attachments
|
||||
# TODO voice
|
||||
# TODO emojis
|
||||
#
|
||||
if message[:message][:body]
|
||||
Rails.logger.debug { article.inspect }
|
||||
article.save!
|
||||
|
||||
Store.remove(
|
||||
object: 'Ticket::Article',
|
||||
o_id: article.id
|
||||
)
|
||||
|
||||
return article
|
||||
end
|
||||
raise 'invalid action'
|
||||
end
|
||||
|
||||
def to_group(message, group_id, channel)
|
||||
# begin import
|
||||
Rails.logger.debug { 'whatsapp import message' }
|
||||
|
||||
# TODO: handle messages in group chats
|
||||
|
||||
return if Ticket::Article.find_by(message_id: message[:id])
|
||||
|
||||
ticket = nil
|
||||
# use transaction
|
||||
Transaction.execute(reset_user_id: true) do
|
||||
user = to_user(message)
|
||||
ticket = to_ticket(message, user, group_id, channel)
|
||||
to_article(message, user, ticket, channel)
|
||||
end
|
||||
|
||||
ticket
|
||||
end
|
||||
|
||||
def from_article(article)
|
||||
# sends a message from a zammad article
|
||||
|
||||
Rails.logger.debug { "Create whatsapp message from article..." }
|
||||
|
||||
# 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}'" }
|
||||
|
||||
options = {}
|
||||
|
||||
# Get attachments from the article
|
||||
attachments = Store.list(object: 'Ticket::Article', o_id: article.id)
|
||||
if attachments.any?
|
||||
attachment_data = attachments.map do |attachment|
|
||||
{
|
||||
data: Base64.strict_encode64(attachment.content),
|
||||
filename: attachment.filename,
|
||||
mime_type: attachment.preferences['Mime-Type'] || attachment.preferences['Content-Type'] || 'application/octet-stream'
|
||||
}
|
||||
end
|
||||
options[:attachments] = attachment_data
|
||||
Rails.logger.debug { "Sending #{attachment_data.length} attachment(s) with message" }
|
||||
end
|
||||
|
||||
@api.send_message(recipient, article[:body], options)
|
||||
end
|
||||
end
|
||||
84
packages/zammad-addon-link/src/lib/cdr_whatsapp_api.rb
Normal file
84
packages/zammad-addon-link/src/lib/cdr_whatsapp_api.rb
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'json'
|
||||
require 'net/http'
|
||||
require 'net/https'
|
||||
require 'uri'
|
||||
|
||||
# Direct WhatsApp API client that calls bridge-whatsapp container directly
|
||||
# Bypasses bridge-worker to communicate with bridge-whatsapp at BRIDGE_WHATSAPP_URL
|
||||
class CdrWhatsappApi
|
||||
def initialize(api_url, token)
|
||||
@token = token
|
||||
@last_update = 0
|
||||
# Use direct bridge-whatsapp URL instead of bridge-worker
|
||||
@api_url = ENV.fetch('BRIDGE_WHATSAPP_URL', api_url || 'http://bridge-whatsapp:5000')
|
||||
end
|
||||
|
||||
def get(api)
|
||||
url = "#{@api_url}/api/bots/#{@token}/#{api}"
|
||||
response = Faraday.get(url, nil, { 'Accept' => 'application/json' })
|
||||
return {} unless response.success?
|
||||
|
||||
JSON.parse(response.body)
|
||||
rescue JSON::ParserError, Faraday::Error => e
|
||||
Rails.logger.error "CdrWhatsappApi: GET #{api} failed: #{e.message}"
|
||||
{}
|
||||
end
|
||||
|
||||
def post(api, params = {})
|
||||
url = "#{@api_url}/api/bots/#{@token}/#{api}"
|
||||
response = Faraday.post(url, params.to_json, {
|
||||
'Content-Type' => 'application/json',
|
||||
'Accept' => 'application/json'
|
||||
})
|
||||
|
||||
unless response.success?
|
||||
Rails.logger.error "CdrWhatsappApi: POST #{api} failed: #{response.status} #{response.body}"
|
||||
raise "Failed to call WhatsApp API: #{response.status}"
|
||||
end
|
||||
|
||||
JSON.parse(response.body)
|
||||
rescue JSON::ParserError => e
|
||||
Rails.logger.error "CdrWhatsappApi: Failed to parse response: #{e.message}"
|
||||
{}
|
||||
rescue Faraday::Error => e
|
||||
Rails.logger.error "CdrWhatsappApi: POST #{api} failed: #{e.message}"
|
||||
raise "Failed to call WhatsApp API: #{e.message}"
|
||||
end
|
||||
|
||||
def fetch_self
|
||||
get('')
|
||||
end
|
||||
|
||||
# Send a message via bridge-whatsapp
|
||||
# POST /api/bots/{id}/send with { phoneNumber, message, attachments }
|
||||
def send_message(recipient, text, options = {})
|
||||
params = {
|
||||
phoneNumber: recipient.to_s,
|
||||
message: text
|
||||
}
|
||||
|
||||
# Convert attachments to bridge-whatsapp format
|
||||
if options[:attachments].present?
|
||||
params[:attachments] = options[:attachments].map do |att|
|
||||
{
|
||||
data: att[:data],
|
||||
filename: att[:filename],
|
||||
mime_type: att[:mime_type]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
result = post('send', params)
|
||||
|
||||
# Return in expected format for compatibility
|
||||
{
|
||||
'result' => {
|
||||
'to' => result.dig('result', 'recipient') || recipient,
|
||||
'from' => result.dig('result', 'source') || @token,
|
||||
'timestamp' => result.dig('result', 'timestamp') || Time.current.iso8601
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
142
packages/zammad-addon-link/src/lib/signal_notification_sender.rb
Normal file
142
packages/zammad-addon-link/src/lib/signal_notification_sender.rb
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'erb'
|
||||
require 'cdr_signal_api'
|
||||
|
||||
class SignalNotificationSender
|
||||
TEMPLATE_DIR = Rails.root.join('app', 'views', 'signal_notification')
|
||||
|
||||
class << self
|
||||
def build_message(ticket:, article:, user:, type:, changes:)
|
||||
template_name = template_for_type(type)
|
||||
return if template_name.blank?
|
||||
|
||||
locale = user.locale || Setting.get('locale_default') || 'en'
|
||||
template_path = find_template(template_name, locale)
|
||||
return if template_path.blank?
|
||||
|
||||
render_template(template_path, binding_for(ticket, article, user, changes))
|
||||
end
|
||||
|
||||
def send_message(channel:, recipient:, message:)
|
||||
return if Rails.env.test?
|
||||
return if channel.blank?
|
||||
return if recipient.blank?
|
||||
return if message.blank?
|
||||
|
||||
# Get the phone number from channel options
|
||||
phone_number = channel.options['phone_number'] || channel.options[:phone_number] ||
|
||||
channel.options.dig('bot', 'number') || channel.options.dig(:bot, :number)
|
||||
|
||||
return if phone_number.blank?
|
||||
|
||||
# Use direct Signal CLI API
|
||||
api = CdrSignalApi.new
|
||||
api.send_message(phone_number, [recipient], message)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def template_for_type(type)
|
||||
case type
|
||||
when 'create'
|
||||
'ticket_create'
|
||||
when 'update', 'update.merged_into', 'update.received_merge', 'update.reaction'
|
||||
'ticket_update'
|
||||
when 'reminder_reached'
|
||||
'ticket_reminder_reached'
|
||||
when 'escalation', 'escalation_warning'
|
||||
'ticket_escalation'
|
||||
end
|
||||
end
|
||||
|
||||
def find_template(template_name, locale)
|
||||
base_locale = locale.split('-').first
|
||||
|
||||
[locale, base_locale, 'en'].uniq.each do |try_locale|
|
||||
path = TEMPLATE_DIR.join(template_name, "#{try_locale}.txt.erb")
|
||||
return path if File.exist?(path)
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def binding_for(ticket, article, user, changes)
|
||||
TemplateContext.new(
|
||||
ticket: ticket,
|
||||
article: article,
|
||||
user: user,
|
||||
changes: changes,
|
||||
config: {
|
||||
http_type: Setting.get('http_type'),
|
||||
fqdn: Setting.get('fqdn'),
|
||||
product_name: Setting.get('product_name')
|
||||
}
|
||||
).get_binding
|
||||
end
|
||||
|
||||
def render_template(template_path, binding)
|
||||
template = File.read(template_path)
|
||||
erb = ERB.new(template, trim_mode: '-')
|
||||
erb.result(binding).strip
|
||||
end
|
||||
end
|
||||
|
||||
class TemplateContext
|
||||
attr_reader :ticket, :article, :recipient, :changes, :config
|
||||
|
||||
def initialize(ticket:, article:, user:, changes:, config:)
|
||||
@ticket = ticket
|
||||
@article = article
|
||||
@recipient = user
|
||||
@changes = changes
|
||||
@config = OpenStruct.new(config)
|
||||
end
|
||||
|
||||
def get_binding
|
||||
binding
|
||||
end
|
||||
|
||||
def ticket_url
|
||||
"#{config.http_type}://#{config.fqdn}/#ticket/zoom/#{ticket.id}"
|
||||
end
|
||||
|
||||
def ticket_url_with_article
|
||||
if article
|
||||
"#{ticket_url}/#{article.id}"
|
||||
else
|
||||
ticket_url
|
||||
end
|
||||
end
|
||||
|
||||
def current_user
|
||||
@current_user ||= User.lookup(id: ticket.updated_by_id) || User.lookup(id: 1)
|
||||
end
|
||||
|
||||
def changes_summary
|
||||
return '' if changes.blank?
|
||||
|
||||
changes.map { |key, values| "#{key}: #{values[0]} -> #{values[1]}" }.join("\n")
|
||||
end
|
||||
|
||||
def article_body_preview(max_length = 500)
|
||||
return '' unless article
|
||||
return '' if article.body.blank?
|
||||
|
||||
body = article.body.to_s
|
||||
body = ActionController::Base.helpers.strip_tags(body) if article.content_type&.include?('html')
|
||||
body = body.gsub(/\s+/, ' ').strip
|
||||
|
||||
if body.length > max_length
|
||||
"#{body[0, max_length]}..."
|
||||
else
|
||||
body
|
||||
end
|
||||
end
|
||||
|
||||
def t(text)
|
||||
locale = recipient.locale || Setting.get('locale_default') || 'en'
|
||||
Translation.translate(locale, text)
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Add a link
Reference in a new issue