Make APIs more similar

This commit is contained in:
Darren Clarke 2026-02-15 10:29:52 +01:00
parent 9f0e1f8b61
commit c40d7d056e
57 changed files with 3994 additions and 1801 deletions

View file

@ -0,0 +1,52 @@
import tseslint from "@typescript-eslint/eslint-plugin";
import tsparser from "@typescript-eslint/parser";
import prettier from "eslint-config-prettier";
import unicorn from "eslint-plugin-unicorn";
import importX from "eslint-plugin-import-x";
export default [
{
files: ["**/*.ts"],
languageOptions: {
parser: tsparser,
parserOptions: {
ecmaVersion: 2022,
sourceType: "module",
},
},
plugins: {
"@typescript-eslint": tseslint,
unicorn,
"import-x": importX,
},
rules: {
...tseslint.configs.recommended.rules,
...unicorn.configs.recommended.rules,
// Relax unicorn rules
"unicorn/no-null": "off",
"unicorn/prevent-abbreviations": "off",
"unicorn/no-process-exit": "off",
"unicorn/prefer-top-level-await": "off",
// Import rules
"import-x/no-duplicates": "error",
"import-x/order": [
"error",
{
groups: ["builtin", "external", "internal", "parent", "sibling", "index"],
"newlines-between": "always",
alphabetize: { order: "asc" },
},
],
// TypeScript rules
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
"@typescript-eslint/no-explicit-any": "warn",
},
},
prettier,
];

View file

@ -0,0 +1,20 @@
{
"name": "@link-stack/eslint-config",
"version": "3.5.0-beta.1",
"private": true,
"type": "module",
"exports": {
"./node": "./node.js"
},
"dependencies": {
"@typescript-eslint/eslint-plugin": "^8.29.0",
"@typescript-eslint/parser": "^8.29.0",
"eslint-config-prettier": "^10.1.1",
"eslint-plugin-unicorn": "^58.0.0",
"eslint-plugin-import-x": "^4.12.2"
},
"peerDependencies": {
"eslint": "^9",
"typescript": "^5"
}
}

View file

@ -0,0 +1,27 @@
{
"name": "@link-stack/logger",
"version": "3.5.0-beta.1",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
}
},
"scripts": {
"build": "tsup src/index.ts --format cjs,esm --dts --clean"
},
"dependencies": {
"pino": "^9.6.0",
"pino-pretty": "^13.0.0"
},
"devDependencies": {
"@link-stack/typescript-config": "workspace:*",
"@types/node": "*",
"tsup": "^8.5.0",
"typescript": "^5.9.3"
}
}

View file

@ -0,0 +1,86 @@
import pino, { type Logger as PinoLogger, type LoggerOptions } from "pino";
export type Logger = PinoLogger;
const getLogLevel = (): string => {
return process.env.LOG_LEVEL || (process.env.NODE_ENV === "production" ? "info" : "debug");
};
const getPinoConfig = (): LoggerOptions => {
const isDevelopment = process.env.NODE_ENV !== "production";
const baseConfig: LoggerOptions = {
level: getLogLevel(),
formatters: {
level: (label) => {
return { level: label.toUpperCase() };
},
},
timestamp: () => `,"timestamp":"${new Date(Date.now()).toISOString()}"`,
redact: {
paths: [
"password",
"token",
"secret",
"api_key",
"apiKey",
"authorization",
"cookie",
"HandshakeKey",
"receivedSecret",
"access_token",
"refresh_token",
"zammadCsrfToken",
"clientSecret",
"*.password",
"*.token",
"*.secret",
"*.api_key",
"*.apiKey",
"*.authorization",
"*.cookie",
"*.access_token",
"*.refresh_token",
"*.zammadCsrfToken",
"*.HandshakeKey",
"*.receivedSecret",
"*.clientSecret",
"payload.HandshakeKey",
"headers.authorization",
"headers.cookie",
"headers.Authorization",
"headers.Cookie",
"credentials.password",
"credentials.secret",
"credentials.token",
],
censor: "[REDACTED]",
},
};
if (isDevelopment) {
return {
...baseConfig,
transport: {
target: "pino-pretty",
options: {
colorize: true,
translateTime: "SYS:standard",
ignore: "pid,hostname",
singleLine: false,
messageFormat: "{msg}",
},
},
};
}
return baseConfig;
};
export const logger: Logger = pino(getPinoConfig());
export const createLogger = (name: string, context?: Record<string, unknown>): Logger => {
return logger.child({ name, ...context });
};
export default logger;

View file

@ -0,0 +1,11 @@
{
"extends": "@link-stack/typescript-config/tsconfig.node.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"incremental": false,
"composite": false
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}

View file

@ -0,0 +1,8 @@
{
"printWidth": 120,
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": false,
"trailingComma": "es5"
}

View file

@ -0,0 +1,6 @@
{
"name": "@link-stack/prettier-config",
"version": "3.5.0-beta.1",
"private": true,
"main": "index.json"
}

View file

@ -0,0 +1,6 @@
{
"name": "@link-stack/typescript-config",
"version": "3.5.0-beta.1",
"private": true,
"files": ["tsconfig.node.json"]
}

View file

@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "es2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"inlineSources": true,
"skipLibCheck": true,
"strict": true,
"allowJs": true,
"forceConsistentCasingInFileNames": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"isolatedModules": true,
"incremental": true,
"composite": true,
"rewriteRelativeImportExtensions": true,
"types": ["node"],
"lib": ["es2022", "DOM"]
}
}

View file

@ -13,7 +13,6 @@ class ChannelCdrSignal extends App.ControllerSubContent
constructor: ->
super
#@interval(@load, 60000)
@load()
load: =>
@ -154,7 +153,7 @@ class ChannelCdrSignal extends App.ControllerSubContent
)
class FormAdd extends App.ControllerModal
head: 'Add Web Form'
head: 'Add Signal Bot'
shown: true
button: 'Add'
buttonCancel: true
@ -199,24 +198,101 @@ class FormAdd extends App.ControllerModal
onSubmit: (e) =>
@formDisable(e)
params = @formParams()
# Auto-generate bot_token if not provided
if !params.bot_token || params.bot_token.trim() == ''
params.bot_token = @generateToken()
@ajax(
id: 'cdr_signal_app_verify'
type: 'POST'
url: "#{@apiPath}/channels_cdr_signal"
data: JSON.stringify(@formParams())
data: JSON.stringify(params)
processData: true
success: =>
success: (data) =>
@isChanged = true
@close()
channelId = data.id
# Start device linking if phone number provided
if params.phone_number && params.phone_number.trim() != ''
@startLinking(channelId, params.phone_number)
else
@close()
error: (xhr) =>
data = JSON.parse(xhr.responseText)
@formEnable(e)
error_message = App.i18n.translateContent(data.error || 'Unable to save Web Form.')
error_message = App.i18n.translateContent(data.error || 'Unable to save.')
@el.find('.alert').removeClass('hidden').text(error_message)
)
startLinking: (channelId, phoneNumber) =>
@ajax(
id: 'cdr_signal_register'
type: 'POST'
url: "#{@apiPath}/channels_cdr_signal_register/#{channelId}"
data: JSON.stringify(phone_number: phoneNumber)
processData: true
success: (data) =>
if data.linkUri
@showLinkingStep(channelId, data.linkUri)
else
@close()
error: (xhr) =>
data = JSON.parse(xhr.responseText)
error_message = App.i18n.translateContent(data.error || 'Failed to start device linking.')
@el.find('.alert').removeClass('hidden').text(error_message)
@el.find('.js-submit').removeAttr('disabled')
)
showLinkingStep: (channelId, linkUri) =>
@el.find('.modal-body').html(App.view('cdr_signal/form_link')(
linkUri: linkUri
))
@el.find('.js-submit').text(App.i18n.translateContent('Done'))
# Generate QR code using an API service
qrImg = @el.find('.js-qr-image')
encodedUri = encodeURIComponent(linkUri)
qrImg.attr('src', "https://api.qrserver.com/v1/create-qr-code/?size=250x250&data=#{encodedUri}")
# Poll for registration status
@pollingInterval = setInterval(=>
@checkRegistration(channelId)
, 3000)
checkRegistration: (channelId) =>
# Check bot status by reading channel and querying bridge
channel = App.Channel.find(channelId)
return unless channel
@ajax(
id: 'cdr_signal_check_status'
type: 'GET'
url: "#{@apiPath}/channels_cdr_signal"
processData: true
success: (data) =>
# Linking is complete when bridge confirms registration
# For now we rely on manual "Done" click
)
onClosed: =>
if @pollingInterval
clearInterval(@pollingInterval)
@pollingInterval = null
return if !@isChanged
@isChanged = false
@load()
generateToken: ->
chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-'
result = ''
for i in [0...32]
result += chars.charAt(Math.floor(Math.random() * chars.length))
result
class FormEdit extends App.ControllerModal
head: 'Web Form Info'
head: 'Signal Bot Info'
shown: true
buttonCancel: true

View file

@ -2,34 +2,27 @@
<fieldset>
<div class="input form-group">
<div class="formGroup-label">
<label for="form_name"><%- @T('Phone number') %> <span>*</span></label>
<label for="phone_number"><%- @T('Phone number') %> <span>*</span></label>
</div>
<div class="controls">
<input id="phone_number" type="text" name="phone_number" value="" class="form-control" required autocomplete="off">
<input id="phone_number" type="text" name="phone_number" value="" class="form-control" required autocomplete="off" placeholder="+1234567890">
<p class="help-text"><%- @T('The phone number linked to your Signal account. A QR code will be shown to link the device.') %></p>
</div>
</div>
<div class="input form-group">
<div class="formGroup-label">
<label for="form_name"><%- @T('Bot Token') %> <span>*</span></label>
<label for="bot_token"><%- @T('Bot Token') %></label>
</div>
<div class="controls">
<input id="bot_token" type="text" name="bot_token" value="" class="form-control" required autocomplete="off">
<input id="bot_token" type="text" name="bot_token" value="" class="form-control" autocomplete="off">
<p class="help-text"><%- @T('Leave blank to auto-generate.') %></p>
</div>
</div>
<div class="input form-group">
<div class="formGroup-label">
<label for="form_name"><%- @T('Bot Endpoint') %> <span>*</span></label>
</div>
<div class="controls">
<input id="bot_endpoint" type="text" name="bot_endpoint" value="" class="form-control" required autocomplete="off">
</div>
</div>
<div class="input form-group">
<div class="formGroup-label">
<label for=""><%- @T('Choose the group in which form submissions will get added to.') %> <span>*</span></label>
<label for=""><%- @T('Choose the group in which incoming messages will be added.') %> <span>*</span></label>
</div>
<div class="controls">
<div class="js-messagesGroup"></div>
@ -38,7 +31,7 @@
<div class="input form-group">
<div class="formGroup-label">
<label for=""><%- @T('Choose the organization to which submitters will be added to when they submit via this form.') %> <span>*</span></label>
<label for=""><%- @T('Choose the organization to which users will be added to when they send a message to this number.') %></label>
</div>
<div class="controls">
<div class="profile-organization js-organization"></div>

View file

@ -3,31 +3,22 @@
<fieldset>
<div class="input form-group">
<div class="formGroup-label">
<label for="form_name"><%- @T('Voice Line Number') %> <span>*</span></label>
<label for="phone_number"><%- @T('Phone number') %> <span>*</span></label>
</div>
<div class="controls">
<input id="phone_number" type="text" name="phone_number" value="<%= @channel.options.phone_number %>" class="form-control" required autocomplete="off">
</div>
</div>
<div class="input form-group">
<div class="input form-group">
<div class="formGroup-label">
<label for="form_name"><%- @T('Bot Token') %> <span>*</span></label>
<label for="bot_token"><%- @T('Bot Token') %> <span>*</span></label>
</div>
<div class="controls">
<input id="bot_token" type="text" name="bot_token" value="<%= @channel.options.bot_token %>" class="form-control" required autocomplete="off">
</div>
</div>
<div class="input form-group">
<div class="formGroup-label">
<label for="form_name"><%- @T('Bot Endpoint') %> <span>*</span></label>
</div>
<div class="controls">
<input id="bot_endpoint" type="text" name="bot_endpoint" value="<%= @channel.options.bot_endpoint %>" class="form-control" required autocomplete="off">
</div>
</div>
<div class="input form-group">
<div class="formGroup-label">
<label for=""><%- @T('Choose the group in which incoming messages will be added.') %> <span>*</span></label>
@ -38,7 +29,7 @@
</div>
<div class="input form-group">
<div class="formGroup-label">
<label for=""><%- @T('Choose the organization to which users will be added to when they send a message to this number.') %> <span>*</span></label>
<label for=""><%- @T('Choose the organization to which users will be added to when they send a message to this number.') %></label>
</div>
<div class="controls">
<div class="profile-organization js-organization"></div>
@ -46,7 +37,7 @@
</div>
<div class="input form-group">
<div class="formGroup-label">
<label for="token"><%- @T('Endpoint URL') %> <span>*</span></label>
<label for="token"><%- @T('Webhook URL') %></label>
</div>
<div class="controls">
<input id="token" type="text" value="<%= "#{App.Config.get('http_type')}://#{App.Config.get('fqdn')}/api/v1/channels_cdr_signal_webhook/#{@channel.options.token}" %>" class="form-control input js-select" readonly>

View file

@ -0,0 +1,10 @@
<div class="alert alert--danger hidden" role="alert"></div>
<div style="text-align: center; padding: 20px;">
<h3><%- @T('Link Signal Device') %></h3>
<p><%- @T('Scan this QR code with your Signal app to link this device.') %></p>
<p style="margin-bottom: 5px;"><%- @T('In Signal, go to Settings > Linked Devices > Link New Device.') %></p>
<div style="margin: 20px 0;">
<img class="js-qr-image" src="" alt="QR Code" style="width: 250px; height: 250px; border: 1px solid #ddd;" />
</div>
<p class="text-muted" style="font-size: 12px; word-break: break-all;"><%= @linkUri %></p>
</div>

View file

@ -1,8 +1,8 @@
# frozen_string_literal: true
class ChannelsCdrSignalController < ApplicationController
prepend_before_action -> { authentication_check && authorize! }, except: [:webhook]
skip_before_action :verify_csrf_token, only: [:webhook]
prepend_before_action -> { authentication_check && authorize! }, except: %i[webhook bot_webhook]
skip_before_action :verify_csrf_token, only: %i[webhook bot_webhook]
include CreatesTicketArticles
@ -60,7 +60,6 @@ class ChannelsCdrSignalController < ApplicationController
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]
},
@ -87,7 +86,6 @@ class ChannelsCdrSignalController < ApplicationController
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!
@ -97,6 +95,20 @@ class ChannelsCdrSignalController < ApplicationController
render json: channel
end
def register
channel = Channel.find_by(id: params[:id], area: 'Signal::Number')
unless channel
render json: { error: 'Channel not found' }, status: :not_found
return
end
api = CdrSignalApi.new
result = api.register(channel.options[:bot_token], params[:phone_number])
render json: result
rescue StandardError => e
render json: { error: e.message }, status: :unprocessable_entity
end
def rotate_token
channel = Channel.find_by(id: params[:id], area: 'Signal::Number')
channel.options[:token] = SecureRandom.urlsafe_base64(48)
@ -133,13 +145,33 @@ class ChannelsCdrSignalController < ApplicationController
false
end
# Webhook endpoint for incoming messages from bridge-signal (bot-centric)
# Bridge POSTs here with incoming messages, group events, etc.
def bot_webhook
bot_id = params[:id]
channel = Channel.where(area: 'Signal::Number', active: true)
.find { |c| c.options[:bot_token] == bot_id }
return render(json: {}, status: 401) unless channel
# Handle events
case params[:event]
when 'group_created'
return update_group
when 'group_member_joined'
return handle_group_member_joined
end
# Process incoming message
process_incoming_message(channel)
end
# Legacy webhook endpoint (token-based)
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
@ -155,16 +187,14 @@ class ChannelsCdrSignalController < ApplicationController
return handle_group_member_joined
end
channel_id = channel.id
process_incoming_message(channel)
end
# validate input
def update_group
errors = {}
# %i[to
# from
# message_id
# sent_at].each | field |
# (errors[field] = 'required' if params[field].blank?)
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: {
@ -173,6 +203,139 @@ class ChannelsCdrSignalController < ApplicationController
return
end
unless params[:event] == 'group_created'
render json: { error: 'Unsupported event type' }, status: :bad_request
return
end
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
existing_chat_id = ticket.preferences&.dig(:cdr_signal, :chat_id) ||
ticket.preferences&.dig('cdr_signal', 'chat_id')
if existing_chat_id&.start_with?('group.')
Rails.logger.info "Signal group update: Ticket #{ticket.id} already has group #{existing_chat_id}, ignoring new group #{params[:group_id]}"
render json: {
success: true,
skipped: true,
reason: 'Ticket already has a group assigned',
existing_group_id: existing_chat_id,
ticket_id: ticket.id,
ticket_number: ticket.number
}, status: :ok
return
end
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?
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
def handle_group_member_joined
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
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
if ticket.preferences.dig('cdr_signal', 'group_joined') == true
render json: {
success: true,
ticket_id: ticket.id,
ticket_number: ticket.number,
group_joined: true,
already_joined: true
}, status: :ok
return
end
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}"
articles_with_pending_notification = Ticket::Article.where(ticket_id: ticket.id)
.where("preferences LIKE ?", "%group_not_joined_note_added: true%")
if articles_with_pending_notification.exists?
resolution_note_exists = Ticket::Article.where(ticket_id: ticket.id)
.where("preferences LIKE ?", "%group_joined_resolution: true%")
.exists?
unless resolution_note_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 "Ticket ##{ticket.number}: Added resolution note about customer joining Signal group"
end
end
render json: {
success: true,
ticket_id: ticket.id,
ticket_number: ticket.number,
group_joined: true
}, status: :ok
end
private
def process_incoming_message(channel)
channel_id = channel.id
message_id = params[:message_id]
return if Ticket::Article.exists?(message_id: "cdr_signal.#{message_id}")
@ -181,16 +344,9 @@ class ChannelsCdrSignalController < ApplicationController
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)
# Lookup customer with fallback chain
customer = nil
if sender_phone_number.present?
customer = User.find_by(phone: sender_phone_number)
@ -198,7 +354,6 @@ class ChannelsCdrSignalController < ApplicationController
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
@ -220,16 +375,13 @@ class ChannelsCdrSignalController < ApplicationController
)
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')
@ -261,71 +413,40 @@ class ChannelsCdrSignalController < ApplicationController
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
}
# Store original recipient phone for group tickets to enable ticket splitting
if is_group_message
cdr_signal_prefs[:original_recipient] = sender_phone_number
end
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,
@ -361,9 +482,6 @@ class ChannelsCdrSignalController < ApplicationController
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,
@ -390,185 +508,4 @@ class ChannelsCdrSignalController < ApplicationController
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
# Idempotency check: if chat_id is already a group ID, don't overwrite it
# This prevents race conditions where multiple group_created webhooks arrive
# (e.g., due to retries after API timeouts during group creation)
existing_chat_id = ticket.preferences&.dig(:cdr_signal, :chat_id) ||
ticket.preferences&.dig('cdr_signal', 'chat_id')
if existing_chat_id&.start_with?('group.')
Rails.logger.info "Signal group update: Ticket #{ticket.id} already has group #{existing_chat_id}, ignoring new group #{params[:group_id]}"
render json: {
success: true,
skipped: true,
reason: 'Ticket already has a group assigned',
existing_group_id: existing_chat_id,
ticket_id: ticket.id,
ticket_number: ticket.number
}, status: :ok
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}"
# Check if any articles had a group_not_joined notification and add resolution note
# Only add resolution note if we previously notified about the delivery issue
articles_with_pending_notification = Ticket::Article.where(ticket_id: ticket.id)
.where("preferences LIKE ?", "%group_not_joined_note_added: true%")
if articles_with_pending_notification.exists?
# Check if we already added a resolution note for this ticket
resolution_note_exists = Ticket::Article.where(ticket_id: ticket.id)
.where("preferences LIKE ?", "%group_joined_resolution: true%")
.exists?
unless resolution_note_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 "Ticket ##{ticket.number}: Added resolution note about customer joining Signal group"
end
end
render json: {
success: true,
ticket_id: ticket.id,
ticket_number: ticket.number,
group_joined: true
}, status: :ok
end
end

View file

@ -125,22 +125,8 @@ class ChannelsCdrWhatsappController < ApplicationController
channel = channel_for_bot_token(bot_token)
return render json: { error: 'Channel not found' }, status: :not_found if !channel || !channel.active
# Normalize parameter names from bridge-whatsapp (camelCase) to Zammad (snake_case)
normalized_params = {
to: params[:to],
from: params[:from],
user_id: params[:userId] || params[:user_id],
message_id: params[:messageId] || params[:message_id],
sent_at: params[:sentAt] || params[:sent_at],
message: params[:message],
attachment: params[:attachment],
filename: params[:filename],
mime_type: params[:mimeType] || params[:mime_type]
}
# Use the channel's webhook token to reuse existing logic
params[:token] = channel.options[:token]
normalized_params.each { |k, v| params[k] = v if v.present? }
webhook
end

View file

@ -29,7 +29,7 @@ class CommunicateCdrWhatsappJob < ApplicationJob
log_error(article,
"Can't find ticket.preferences['cdr_whatsapp']['chat_id'] for Ticket.find(#{article.ticket_id})")
end
channel = ::CdrSignal.bot_by_bot_token(ticket.preferences['cdr_whatsapp']['bot_token'])
channel = ::CdrWhatsapp.bot_by_bot_token(ticket.preferences['cdr_whatsapp']['bot_token'])
channel ||= ::Channel.lookup(id: ticket.preferences['channel_id'])
unless channel
log_error(article,

View file

@ -1,12 +1,10 @@
# frozen_string_literal: true
# Configuration for direct Signal CLI REST API access
# The SIGNAL_CLI_URL environment variable points to the signal-cli-rest-api container
# Default: http://signal-cli-rest-api:8080
#
# This enables Zammad to poll for Signal messages directly without going through bridge-worker
# Configuration for bridge-signal API access
# The BRIDGE_SIGNAL_URL environment variable points to the bridge-signal container
# Default: http://bridge-signal:5002
Rails.application.config.after_initialize do
signal_cli_url = ENV.fetch('SIGNAL_CLI_URL', 'http://signal-cli-rest-api:8080')
Rails.logger.info "Signal CLI API URL configured: #{signal_cli_url}"
bridge_signal_url = ENV.fetch('BRIDGE_SIGNAL_URL', 'http://bridge-signal:5002')
Rails.logger.info "Bridge Signal API URL configured: #{bridge_signal_url}"
end

View file

@ -8,6 +8,8 @@ Zammad::Application.routes.draw do
match "#{api_path}/channels_cdr_signal/:id", to: 'channels_cdr_signal#update', via: :put
match "#{api_path}/channels_cdr_signal_webhook/:token", to: 'channels_cdr_signal#webhook', via: :post
match "#{api_path}/channels_cdr_signal_webhook/:token/update_group", to: 'channels_cdr_signal#update_group', via: :post
match "#{api_path}/channels_cdr_signal_bot_webhook/:id", to: 'channels_cdr_signal#bot_webhook', via: :post
match "#{api_path}/channels_cdr_signal_register/:id", to: 'channels_cdr_signal#register', via: :post
match "#{api_path}/channels_cdr_signal_disable", to: 'channels_cdr_signal#disable', via: :post
match "#{api_path}/channels_cdr_signal_enable", to: 'channels_cdr_signal#enable', via: :post
match "#{api_path}/channels_cdr_signal", to: 'channels_cdr_signal#destroy', via: :delete

View file

@ -0,0 +1,13 @@
# frozen_string_literal: true
class RemoveSignalSchedulers < ActiveRecord::Migration[5.2]
def self.up
# Remove polling schedulers -- bridge-signal now pushes messages via webhook
Scheduler.find_by(name: 'Fetch Signal messages')&.destroy
Scheduler.find_by(name: 'Check Signal group membership')&.destroy
end
def self.down
# No-op: schedulers are no longer used
end
end

View file

@ -5,92 +5,8 @@ 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|
Channel.where(area: 'Signal::Number').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
@ -98,14 +14,6 @@ class CdrSignal
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
@ -114,32 +22,14 @@ class CdrSignal
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
@ -147,10 +37,8 @@ class CdrSignal
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')
@ -177,7 +65,6 @@ class CdrSignal
)
end
# create or update authorization
auth_data = {
uid: message_user[:id],
username: login,
@ -196,22 +83,13 @@ class CdrSignal
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
@ -238,16 +116,11 @@ class CdrSignal
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],
to: channel[:options][:phone_number],
body: message[:message][:body],
content_type: 'text/plain',
message_id: "cdr_signal.#{message[:id]}",
@ -264,12 +137,7 @@ class CdrSignal
}
)
# TODO: attachments
# TODO voice
# TODO emojis
#
if message[:message][:body]
Rails.logger.debug { article.inspect }
article.save!
Store.remove(
@ -283,15 +151,11 @@ class CdrSignal
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)
@ -302,25 +166,20 @@ class CdrSignal
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
bot_id = channel.options[:bot_token]
raise "No bot_token configured for channel #{channel.id}" unless bot_id
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
@ -328,80 +187,45 @@ class CdrSignal
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 not already a group)
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 = {}
# Encode attachments
attachments = Store.list(object: 'Ticket::Article', o_id: article.id)
if attachments.any?
attachment_data = attachments.map do |attachment|
options[:attachments] = attachments.map do |a|
{
data: Base64.strict_encode64(attachment.content),
filename: attachment.filename,
mime_type: attachment.preferences['Mime-Type'] || attachment.preferences['Content-Type'] || 'application/octet-stream'
data: Base64.strict_encode64(a.content),
filename: a.filename,
mime_type: a.preferences['Mime-Type'] || a.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)
# Auto-group: let bridge handle it
if enable_auto_groups && !recipient&.start_with?('group.')
recipient = ticket.preferences.dig('cdr_signal', 'original_recipient') || recipient
options[:auto_group] = { ticketNumber: ticket.number.to_s }
end
# Return result in expected format
result = api.send_message(bot_id, recipient, article[:body], options)
send_result = result.is_a?(Hash) ? result['result'] : nil
# If bridge created a group, update ticket preferences
if send_result.is_a?(Hash) && send_result['groupId'].present?
ticket.preferences[:cdr_signal] ||= {}
ticket.preferences[:cdr_signal][:chat_id] = send_result['groupId']
ticket.preferences[:cdr_signal][:group_joined] = false
ticket.preferences[:cdr_signal][:group_created_at] = Time.current.iso8601
ticket.save!
end
# Return in expected format
{
'result' => {
'to' => final_recipient,
'from' => bot_phone_number,
'timestamp' => result['timestamp'] || Time.current.to_i * 1000
'to' => send_result&.dig('recipient') || recipient,
'from' => send_result&.dig('source') || bot_id,
'timestamp' => send_result&.dig('timestamp') || Time.current.to_i * 1000
}
}
end

View file

@ -1,148 +1,95 @@
# 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
# Bridge-signal API client
# Communicates with the bridge-signal Node.js service (replaces signal-cli-rest-api)
class CdrSignalApi
def initialize(base_url = nil)
@base_url = base_url || ENV.fetch('SIGNAL_CLI_URL', 'http://signal-cli-rest-api:8080')
@base_url = base_url || ENV.fetch('BRIDGE_SIGNAL_URL', 'http://bridge-signal:5002')
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)}"
# Register/link a phone number
def register(bot_id, phone_number, device_name = 'Zammad')
post("/api/bots/#{bot_id}/register", { phoneNumber: phone_number, deviceName: device_name })
end
# Get bot status
def get_bot(bot_id)
get("/api/bots/#{bot_id}")
end
# Unregister a bot
def unregister(bot_id)
post("/api/bots/#{bot_id}/unregister", {})
end
# Send a message (with optional auto-group)
def send_message(bot_id, recipient, message, options = {})
data = { recipient: recipient, message: message }
data[:attachments] = options[:attachments] if options[:attachments].present?
data[:autoGroup] = options[:auto_group] if options[:auto_group].present?
post("/api/bots/#{bot_id}/send", data)
end
# Group operations
def create_group(bot_id, name:, members:, description: nil)
post("/api/bots/#{bot_id}/groups", { name: name, members: members, description: description }.compact)
end
def update_group(bot_id, group_id, name: nil, description: nil)
put("/api/bots/#{bot_id}/groups/#{CGI.escape(group_id)}", { name: name, description: description }.compact)
end
def list_groups(bot_id)
get("/api/bots/#{bot_id}/groups")
end
# Health check
def health
get('/api/health')
end
private
def get(path)
url = "#{@base_url}#{path}"
response = Faraday.get(url, nil, { 'Accept' => 'application/json' })
return [] unless response.success?
unless response.success?
Rails.logger.error "CdrSignalApi: GET #{path} failed: #{response.status} #{response.body}"
return nil
end
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}"
Rails.logger.error "CdrSignalApi: GET #{path} error: #{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
def post(path, data)
url = "#{@base_url}#{path}"
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}"
Rails.logger.error "CdrSignalApi: POST #{path} failed: #{response.status} #{response.body}"
raise "Bridge-signal request failed: #{response.status}"
end
JSON.parse(response.body)
rescue JSON::ParserError => e
# POST may return empty body on success
nil
rescue Faraday::Error => e
Rails.logger.error "CdrSignalApi: Failed to send message: #{e.message}"
raise "Failed to send Signal message: #{e.message}"
Rails.logger.error "CdrSignalApi: POST #{path} error: #{e.message}"
raise "Bridge-signal request failed: #{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?
def put(path, data)
url = "#{@base_url}#{path}"
response = Faraday.put(url, data.to_json, {
'Content-Type' => 'application/json',
'Accept' => 'application/json'
@ -150,7 +97,7 @@ class CdrSignalApi
response.success?
rescue Faraday::Error => e
Rails.logger.error "CdrSignalApi: Failed to update group: #{e.message}"
Rails.logger.error "CdrSignalApi: PUT #{path} error: #{e.message}"
false
end
end

View file

@ -1,428 +0,0 @@
# 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

View file

@ -24,15 +24,11 @@ class SignalNotificationSender
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)
bot_id = channel.options['bot_token'] || channel.options[:bot_token]
return if bot_id.blank?
return if phone_number.blank?
# Use direct Signal CLI API
api = CdrSignalApi.new
api.send_message(phone_number, [recipient], message)
api.send_message(bot_id, recipient, message)
end
private