WhatsApp/Signal/Formstack/admin updates
This commit is contained in:
parent
bcecf61a46
commit
d0cc5a21de
451 changed files with 16139 additions and 39623 deletions
|
|
@ -1,63 +1,150 @@
|
|||
# zammad-addon-bridge
|
||||
# CDR Bridge Zammad Addon
|
||||
|
||||
An addon that adds [bridge](https://gitlab.com/digiresilience/link/link-stack) channels to Zammad.
|
||||
## Overview
|
||||
|
||||
## Channels
|
||||
The CDR Bridge addon integrates external communication channels (Signal, WhatsApp, Voice) into Zammad, supporting both the classic UI and the new Vue-based desktop/mobile interfaces.
|
||||
|
||||
This channel creates a three channels: "Voice", "Signal" and "Whatsapp".
|
||||
## Features
|
||||
|
||||
To submit a ticket: make a POST to the Submission Endpoint with the header
|
||||
`Authorization: SUBMISSION_TOKEN`.
|
||||
### Signal Channel Integration
|
||||
|
||||
The payload for the Voice channel must be a json object with the keys:
|
||||
- Reply button on customer Signal messages
|
||||
- "Add Signal message" button in ticket reply area
|
||||
- 10,000 character limit with warning at 5,000
|
||||
- Plain text format with attachment support
|
||||
- Full integration with both classic and new Vue-based UI
|
||||
|
||||
- `startTime` - string containing ISO date
|
||||
- `endTime` - string containing ISO date
|
||||
- `to` - fully qualified phone number
|
||||
- `from` - fully qualified phone number
|
||||
- `duration` - string containing the recording duration
|
||||
- `callSid` - the unique identifier for the call
|
||||
- `recording` - string base64 encoded binary of the recording
|
||||
- `mimeType` - string of the binary mime-type
|
||||
### WhatsApp Channel Integration
|
||||
|
||||
The payload for the Signal channel must be a json object with the keys:
|
||||
- Reply button on customer WhatsApp messages
|
||||
- "Add WhatsApp message" button in ticket reply area
|
||||
- 4,096 character limit with warning at 3,000
|
||||
- Plain text format with attachment support
|
||||
- Full integration with both classic and new Vue-based UI
|
||||
|
||||
- TBD
|
||||
### Voice Channel Support
|
||||
|
||||
The payload for the Whatsapp channel must be a json object with the keys:
|
||||
- Classic UI implementation maintained
|
||||
- New UI support ready for future implementation
|
||||
|
||||
- TBD
|
||||
### Channel Restriction Settings (NEW)
|
||||
|
||||
- Control which reply channels appear in the UI
|
||||
- Configurable via `cdr_link_allowed_channels` setting
|
||||
- Acts as a whitelist while preserving contextual logic
|
||||
- Empty setting falls back to default Zammad behavior
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Zammad 6.0+ (for new UI support)
|
||||
- CDR Bridge backend services configured
|
||||
- Signal/WhatsApp/Voice services running
|
||||
|
||||
### Installation Steps
|
||||
|
||||
1. Build the addon package:
|
||||
|
||||
```bash
|
||||
cd packages/zammad-addon-bridge
|
||||
npm run build
|
||||
```
|
||||
|
||||
2. Install in Zammad:
|
||||
|
||||
```bash
|
||||
# Copy the generated .zpm file to your Zammad installation
|
||||
cp dist/bridge-vX.X.X.zpm /opt/zammad/
|
||||
|
||||
# Install using Zammad package manager
|
||||
zammad run rails r "Package.install(file: '/opt/zammad/bridge-vX.X.X.zpm')"
|
||||
|
||||
# Restart Zammad
|
||||
systemctl restart zammad
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Channel Restriction Settings
|
||||
|
||||
Control which reply channels are available in the ticket interface:
|
||||
|
||||
```ruby
|
||||
# Rails console
|
||||
Setting.set('cdr_link_allowed_channels', 'note,signal message') # Signal only
|
||||
Setting.set('cdr_link_allowed_channels', 'note,whatsapp message') # WhatsApp only
|
||||
Setting.set('cdr_link_allowed_channels', 'note,signal message,whatsapp message') # Both
|
||||
Setting.set('cdr_link_allowed_channels', '') # Default behavior (all channels)
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
|
||||
- The setting acts as a whitelist of allowed channels
|
||||
- Channels must be both in the whitelist AND contextually appropriate
|
||||
- For example, Signal replies only appear for tickets that originated from Signal
|
||||
- Empty or unset falls back to default Zammad behavior
|
||||
- Changes take effect immediately (browser refresh required)
|
||||
|
||||
## Development
|
||||
|
||||
1. Edit the files in `src/`
|
||||
### Adding New Channels
|
||||
|
||||
Migration files should go in `src/db/addon/CHANNEL_NAME` ([see this post](https://community.zammad.org/t/automating-creation-of-custom-object-attributes/3831/2?u=abelxluck))
|
||||
1. Create TypeScript plugin in `app/frontend/shared/entities/ticket-article/action/plugins/`
|
||||
2. Add desktop UI plugin in `app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/article-type/plugins/`
|
||||
3. Add corresponding backend implementation
|
||||
4. Create database migrations in `src/db/addon/bridge/`
|
||||
|
||||
2. Update version and changelog in `bridge-skeleton.szpm`
|
||||
3. Build a new package `make`
|
||||
|
||||
This outputs `dist/bridge-vXXX.szpm`
|
||||
|
||||
4. Install the szpm using the zammad package manager.
|
||||
|
||||
5. Repeat
|
||||
|
||||
### Create a new migration
|
||||
|
||||
Included is a helper script to create new migrations. You must have the python
|
||||
`inflection` library installed.
|
||||
|
||||
- debian/ubuntu: `apt install python3-inflection`
|
||||
- pip: `pip install --user inflection`
|
||||
- or create your own venv
|
||||
|
||||
To make a new migration simply run:
|
||||
### Building the Package
|
||||
|
||||
```bash
|
||||
# Update version and changelog in bridge-skeleton.szpm
|
||||
# Build the package
|
||||
make
|
||||
# Output: dist/bridge-vX.X.X.szpm
|
||||
```
|
||||
|
||||
### Create a New Migration
|
||||
|
||||
Helper script to create new migrations (requires python `inflection` library):
|
||||
|
||||
```bash
|
||||
# Install dependency
|
||||
apt install python3-inflection # Debian/Ubuntu
|
||||
# Or: pip install --user inflection
|
||||
|
||||
# Create migration
|
||||
make new-migration
|
||||
```
|
||||
|
||||
## Compatibility
|
||||
|
||||
- **Zammad 6.0+**: Both Classic and New UI
|
||||
- **Browser Support**: All modern browsers
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Voice Channel
|
||||
|
||||
POST to submission endpoint with `Authorization: SUBMISSION_TOKEN` header:
|
||||
|
||||
```json
|
||||
{
|
||||
"startTime": "ISO date string",
|
||||
"endTime": "ISO date string",
|
||||
"to": "fully qualified phone number",
|
||||
"from": "fully qualified phone number",
|
||||
"duration": "recording duration string",
|
||||
"callSid": "unique call identifier",
|
||||
"recording": "base64 encoded binary",
|
||||
"mimeType": "binary mime-type string"
|
||||
}
|
||||
```
|
||||
|
||||
### Signal/WhatsApp Channels
|
||||
|
||||
Handled via CDR Bridge backend services - see bridge documentation for API details.
|
||||
|
||||
## License
|
||||
|
||||
[](https://gitlab.com/digiresilience/link/zamamd-addon-bridge/blob/master/LICENSE.md)
|
||||
|
|
@ -66,5 +153,3 @@ This is a free software project licensed under the GNU Affero General
|
|||
Public License v3.0 (GNU AGPLv3) by [The Center for Digital
|
||||
Resilience](https://digiresilience.org) and [Guardian
|
||||
Project](https://guardianproject.info).
|
||||
|
||||
🐻
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
{
|
||||
"name": "@link-stack/zammad-addon-bridge",
|
||||
"displayName": "Bridge",
|
||||
"version": "2.2.0",
|
||||
"version": "3.3.0",
|
||||
"description": "An addon that adds CDR Bridge channels to Zammad.",
|
||||
"scripts": {
|
||||
"build": "node '../../node_modules/@link-stack/zammad-addon-common/dist/build.js'",
|
||||
"migrate": "node '../../node_modules/@link-stack/zammad-addon-common/dist/migrate.js'"
|
||||
"build": "node '../zammad-addon-common/dist/build.js'",
|
||||
"migrate": "node '../zammad-addon-common/dist/migrate.js'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@link-stack/zammad-addon-common": "*"
|
||||
"@link-stack/zammad-addon-common": "workspace:*"
|
||||
},
|
||||
"author": "",
|
||||
"license": "AGPL-3.0-or-later"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
class Index extends App.ControllerSubContent
|
||||
class ChannelCdrSignal extends App.ControllerSubContent
|
||||
requiredPermission: 'admin.channel_cdr_signal'
|
||||
events:
|
||||
'click .js-new': 'new'
|
||||
|
|
@ -246,4 +246,4 @@ class FormEdit extends App.ControllerModal
|
|||
@el.find('.alert').removeClass('hidden').text(error_message)
|
||||
)
|
||||
|
||||
App.Config.set('cdr_signal', { prio: 5100, name: 'Signal', parent: '#channels', target: '#channels/cdr_signal', controller: Index, permission: ['admin.channel_cdr_signal'] }, 'NavBarAdmin')
|
||||
App.Config.set('cdr_signal', { prio: 5100, name: 'Signal', parent: '#channels', target: '#channels/cdr_signal', controller: ChannelCdrSignal, permission: ['admin.channel_cdr_signal'] }, 'NavBarAdmin')
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
class Index extends App.ControllerSubContent
|
||||
class ChannelCdrVoice extends App.ControllerSubContent
|
||||
requiredPermission: 'admin.channel_cdr_voice'
|
||||
events:
|
||||
'click .js-new': 'new'
|
||||
|
|
@ -246,4 +246,4 @@ class FormEdit extends App.ControllerModal
|
|||
@el.find('.alert').removeClass('hidden').text(error_message)
|
||||
)
|
||||
|
||||
App.Config.set('cdr_voice', { prio: 5100, name: 'Voice', parent: '#channels', target: '#channels/cdr_voice', controller: Index, permission: ['admin.channel_cdr_voice'] }, 'NavBarAdmin')
|
||||
App.Config.set('cdr_voice', { prio: 5100, name: 'Voice', parent: '#channels', target: '#channels/cdr_voice', controller: ChannelCdrVoice, permission: ['admin.channel_cdr_voice'] }, 'NavBarAdmin')
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
class Index extends App.ControllerSubContent
|
||||
class ChannelCdrWhatsapp extends App.ControllerSubContent
|
||||
requiredPermission: 'admin.channel_cdr_whatsapp'
|
||||
events:
|
||||
'click .js-new': 'new'
|
||||
|
|
@ -246,4 +246,4 @@ class FormEdit extends App.ControllerModal
|
|||
@el.find('.alert').removeClass('hidden').text(error_message)
|
||||
)
|
||||
|
||||
App.Config.set('cdr_whatsapp', { prio: 5100, name: 'Whatsapp', parent: '#channels', target: '#channels/cdr_whatsapp', controller: Index, permission: ['admin.channel_cdr_whatsapp'] }, 'NavBarAdmin')
|
||||
App.Config.set('cdr_whatsapp', { prio: 5100, name: 'Whatsapp', parent: '#channels', target: '#channels/cdr_whatsapp', controller: ChannelCdrWhatsapp, permission: ['admin.channel_cdr_whatsapp'] }, 'NavBarAdmin')
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
class CdrLinkChannelFilter
|
||||
# Required stub - we don't add any actions, just pass through
|
||||
@action: (actions, ticket, article, ui) ->
|
||||
actions
|
||||
|
||||
@articleTypes: (articleTypes, ticket, ui) ->
|
||||
return articleTypes if !ui.permissionCheck('ticket.agent')
|
||||
|
||||
# Check CDR Link allowed channels setting
|
||||
allowedChannels = ui.Config.get('cdr_link_allowed_channels')
|
||||
|
||||
# If no whitelist is configured, allow all types
|
||||
if !allowedChannels || !allowedChannels.trim()
|
||||
return articleTypes
|
||||
|
||||
# Parse the comma-separated whitelist
|
||||
whitelist = (channel.trim() for channel in allowedChannels.split(','))
|
||||
|
||||
# Filter article types to only those in the whitelist
|
||||
# Always keep 'note' for internal notes regardless of whitelist
|
||||
filteredTypes = articleTypes.filter (type) ->
|
||||
type.name is 'note' or type.name in whitelist
|
||||
|
||||
# Add email if it's in the whitelist but not in the array
|
||||
# (Email is only added by Zammad core for email tickets, not Signal tickets)
|
||||
if 'email' in whitelist
|
||||
hasEmail = filteredTypes.some (type) -> type.name is 'email'
|
||||
if !hasEmail
|
||||
# Add email with all the standard email attributes
|
||||
filteredTypes.push {
|
||||
name: 'email'
|
||||
icon: 'email'
|
||||
attributes: ['to', 'cc', 'subject']
|
||||
internal: false
|
||||
features: ['attachment']
|
||||
}
|
||||
|
||||
filteredTypes
|
||||
|
||||
App.Config.set('900-CdrLinkChannelFilter', CdrLinkChannelFilter, 'TicketZoomArticleAction')
|
||||
|
|
@ -45,11 +45,16 @@ class CdrSignalReply
|
|||
@articleTypes: (articleTypes, ticket, ui) ->
|
||||
return articleTypes if !ui.permissionCheck('ticket.agent')
|
||||
|
||||
# Check if this ticket was created via Signal
|
||||
return articleTypes if !ticket || !ticket.create_article_type_id
|
||||
|
||||
articleTypeCreate = App.TicketArticleType.find(ticket.create_article_type_id).name
|
||||
|
||||
return articleTypes if articleTypeCreate isnt 'cdr_signal'
|
||||
# Only add cdr_signal type if ticket was created via Signal
|
||||
if articleTypeCreate isnt 'cdr_signal'
|
||||
return articleTypes
|
||||
|
||||
# Add the cdr_signal article type for Signal replies
|
||||
articleTypes.push {
|
||||
name: 'cdr_signal'
|
||||
icon: 'cdr-signal'
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CdrSignalChannelsController < ApplicationController
|
||||
prepend_before_action -> { authentication_check && authorize! }
|
||||
|
||||
def index
|
||||
channels = Channel.where(area: 'Signal::Number', active: true).map do |channel|
|
||||
{
|
||||
id: channel.id,
|
||||
phone_number: channel.options['phone_number'],
|
||||
bot_endpoint: channel.options['bot_endpoint']
|
||||
# bot_token intentionally excluded - bridge-worker should look it up from cdr database
|
||||
}
|
||||
end
|
||||
|
||||
render json: channels
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CdrTicketArticleTypesController < ApplicationController
|
||||
prepend_before_action -> { authentication_check && authorize! }
|
||||
|
||||
def index
|
||||
types = Ticket::Article::Type.all.map do |type|
|
||||
{
|
||||
id: type.id,
|
||||
name: type.name,
|
||||
communication: type.communication
|
||||
}
|
||||
end
|
||||
|
||||
render json: types
|
||||
end
|
||||
end
|
||||
|
|
@ -115,7 +115,21 @@ class ChannelsCdrSignalController < ApplicationController
|
|||
|
||||
channel = channel_for_token(token)
|
||||
return render json: {}, status: 401 if !channel || !channel.active
|
||||
return render json: {}, status: 401 if channel.options[:token] != token
|
||||
# Use constant-time comparison to prevent timing attacks
|
||||
return render json: {}, status: 401 unless ActiveSupport::SecurityUtils.secure_compare(
|
||||
channel.options[:token].to_s,
|
||||
token.to_s
|
||||
)
|
||||
|
||||
# Handle group creation events
|
||||
if params[:event] == 'group_created'
|
||||
return update_group
|
||||
end
|
||||
|
||||
# Handle group member joined events
|
||||
if params[:event] == 'group_member_joined'
|
||||
return handle_group_member_joined
|
||||
end
|
||||
|
||||
channel_id = channel.id
|
||||
|
||||
|
|
@ -141,6 +155,13 @@ class ChannelsCdrSignalController < ApplicationController
|
|||
|
||||
receiver_phone_number = params[:to].strip
|
||||
sender_phone_number = params[:from].strip
|
||||
|
||||
# Check if this is a group message using the is_group flag from bridge-worker
|
||||
# This flag is set when:
|
||||
# 1. The original message came from a Signal group
|
||||
# 2. Bridge-worker created a new group for the conversation
|
||||
is_group_message = params[:is_group].to_s == 'true' || params[:is_group].to_s == 'true'
|
||||
|
||||
customer = User.find_by(phone: sender_phone_number)
|
||||
customer ||= User.find_by(mobile: sender_phone_number)
|
||||
unless customer
|
||||
|
|
@ -192,23 +213,69 @@ class ChannelsCdrSignalController < ApplicationController
|
|||
|
||||
# find ticket or create one
|
||||
state_ids = Ticket::State.where(name: %w[closed merged removed]).pluck(:id)
|
||||
ticket = Ticket.where(customer_id: customer.id).where.not(state_id: state_ids).order(:updated_at).first
|
||||
|
||||
if is_group_message
|
||||
Rails.logger.info "=== SIGNAL GROUP TICKET LOOKUP ==="
|
||||
Rails.logger.info "Looking for ticket with group_id: #{receiver_phone_number}"
|
||||
Rails.logger.info "Customer ID: #{customer.id}"
|
||||
Rails.logger.info "Customer Phone: #{sender_phone_number}"
|
||||
Rails.logger.info "Channel ID: #{channel.id}"
|
||||
|
||||
begin
|
||||
# Use text search on preferences YAML to efficiently find tickets without loading all into memory
|
||||
# This prevents DoS attacks from memory exhaustion
|
||||
ticket = Ticket.where.not(state_id: state_ids)
|
||||
.where("preferences LIKE ?", "%channel_id: #{channel.id}%")
|
||||
.where("preferences LIKE ?", "%chat_id: #{receiver_phone_number}%")
|
||||
.order(updated_at: :desc)
|
||||
.first
|
||||
|
||||
if ticket
|
||||
Rails.logger.info "=== FOUND MATCHING TICKET BY GROUP ID: ##{ticket.number} ==="
|
||||
# Update customer if different (handles duplicate phone numbers)
|
||||
if ticket.customer_id != customer.id
|
||||
Rails.logger.info "Updating ticket customer from #{ticket.customer_id} to #{customer.id}"
|
||||
ticket.customer_id = customer.id
|
||||
end
|
||||
else
|
||||
Rails.logger.info "=== NO MATCHING TICKET BY GROUP ID - CHECKING BY PHONE NUMBER ==="
|
||||
end
|
||||
rescue => e
|
||||
Rails.logger.error "Error during group ticket lookup: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n")
|
||||
end
|
||||
else
|
||||
Rails.logger.info "Not a group message or no group_id, finding most recent ticket"
|
||||
ticket = Ticket.where(customer_id: customer.id).where.not(state_id: state_ids).order(:updated_at).first
|
||||
end
|
||||
|
||||
if ticket
|
||||
# check if title need to be updated
|
||||
ticket.title = title if ticket.title == '-'
|
||||
new_state = Ticket::State.find_by(default_create: true)
|
||||
ticket.state = Ticket::State.find_by(default_follow_up: true) if ticket.state_id != new_state.id
|
||||
else
|
||||
# Set up chat_id based on whether this is a group message
|
||||
chat_id = is_group_message ? receiver_phone_number : sender_phone_number
|
||||
|
||||
# Build preferences with group_id included if needed
|
||||
cdr_signal_prefs = {
|
||||
bot_token: channel.options[:bot_token], # change to bot id
|
||||
chat_id: chat_id
|
||||
}
|
||||
|
||||
Rails.logger.info "=== CREATING NEW TICKET ==="
|
||||
Rails.logger.info "Preferences to be stored:"
|
||||
Rails.logger.info " - channel_id: #{channel.id}"
|
||||
Rails.logger.info " - cdr_signal: #{cdr_signal_prefs.inspect}"
|
||||
|
||||
ticket = Ticket.new(
|
||||
group_id: channel.group_id,
|
||||
title: title,
|
||||
customer_id: customer.id,
|
||||
preferences: {
|
||||
channel_id: channel.id,
|
||||
cdr_signal: {
|
||||
bot_token: channel.options[:bot_token], # change to bot id
|
||||
chat_id: sender_phone_number
|
||||
}
|
||||
cdr_signal: cdr_signal_prefs
|
||||
}
|
||||
)
|
||||
end
|
||||
|
|
@ -224,7 +291,7 @@ class ChannelsCdrSignalController < ApplicationController
|
|||
content_type: 'text/plain',
|
||||
message_id: "cdr_signal.#{message_id}",
|
||||
ticket_id: ticket.id,
|
||||
internal: false,
|
||||
internal: params[:internal] == true,
|
||||
preferences: {
|
||||
cdr_signal: {
|
||||
timestamp: sent_at,
|
||||
|
|
@ -265,4 +332,137 @@ 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
|
||||
|
||||
# Update ticket preferences with the group information
|
||||
ticket.preferences ||= {}
|
||||
ticket.preferences[:cdr_signal] ||= {}
|
||||
ticket.preferences[:cdr_signal][:chat_id] = params[:group_id]
|
||||
ticket.preferences[:cdr_signal][:original_recipient] = params[:original_recipient] if params[:original_recipient].present?
|
||||
ticket.preferences[:cdr_signal][:group_created_at] = params[:timestamp] if params[:timestamp].present?
|
||||
|
||||
# Track whether user has joined the group (initially false)
|
||||
# This will be updated to true when we receive a group join event from Signal
|
||||
ticket.preferences[:cdr_signal][:group_joined] = params[:group_joined] if params.key?(:group_joined)
|
||||
|
||||
ticket.save!
|
||||
|
||||
Rails.logger.info "Signal group #{params[:group_id]} associated with ticket #{ticket.id}"
|
||||
|
||||
render json: {
|
||||
success: true,
|
||||
ticket_id: ticket.id,
|
||||
ticket_number: ticket.number
|
||||
}, status: :ok
|
||||
end
|
||||
|
||||
# Webhook endpoint for receiving group member joined notifications from bridge-worker
|
||||
# This is called when a user accepts the Signal group invitation
|
||||
# Expected payload:
|
||||
# {
|
||||
# "event": "group_member_joined",
|
||||
# "group_id": "group.base64encodedid",
|
||||
# "member_phone": "+1234567890",
|
||||
# "timestamp": "ISO8601 timestamp"
|
||||
# }
|
||||
def handle_group_member_joined
|
||||
# Validate required parameters
|
||||
errors = {}
|
||||
errors['event'] = 'required' unless params[:event].present?
|
||||
errors['group_id'] = 'required' unless params[:group_id].present?
|
||||
errors['member_phone'] = 'required' unless params[:member_phone].present?
|
||||
|
||||
if errors.present?
|
||||
render json: {
|
||||
errors: errors
|
||||
}, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
# Find ticket(s) with this group_id in preferences
|
||||
# Use text search on preferences YAML for efficient lookup (prevents DoS from loading all tickets)
|
||||
state_ids = Ticket::State.where(name: %w[closed merged removed]).pluck(:id)
|
||||
|
||||
ticket = Ticket.where.not(state_id: state_ids)
|
||||
.where("preferences LIKE ?", "%chat_id: #{params[:group_id]}%")
|
||||
.order(updated_at: :desc)
|
||||
.first
|
||||
|
||||
unless ticket
|
||||
Rails.logger.warn "Signal group member joined: Ticket not found for group_id #{params[:group_id]}"
|
||||
render json: { error: 'Ticket not found for this group' }, status: :not_found
|
||||
return
|
||||
end
|
||||
|
||||
# Idempotency check: if already marked as joined, skip update and return success
|
||||
# This prevents unnecessary database writes when the cron job sends duplicate notifications
|
||||
if ticket.preferences.dig('cdr_signal', 'group_joined') == true
|
||||
Rails.logger.debug "Signal group member #{params[:member_phone]} already marked as joined for group #{params[:group_id]} ticket #{ticket.id}, skipping update"
|
||||
render json: {
|
||||
success: true,
|
||||
ticket_id: ticket.id,
|
||||
ticket_number: ticket.number,
|
||||
group_joined: true,
|
||||
already_joined: true
|
||||
}, status: :ok
|
||||
return
|
||||
end
|
||||
|
||||
# Update group_joined flag
|
||||
member_phone = params[:member_phone]
|
||||
ticket.preferences[:cdr_signal][:group_joined] = true
|
||||
ticket.preferences[:cdr_signal][:group_joined_at] = params[:timestamp] if params[:timestamp].present?
|
||||
ticket.preferences[:cdr_signal][:group_joined_by] = member_phone
|
||||
|
||||
ticket.save!
|
||||
|
||||
Rails.logger.info "Signal group member #{member_phone} joined group #{params[:group_id]} for ticket #{ticket.id}"
|
||||
|
||||
render json: {
|
||||
success: true,
|
||||
ticket_id: ticket.id,
|
||||
ticket_number: ticket.number,
|
||||
group_joined: true
|
||||
}, status: :ok
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -0,0 +1,355 @@
|
|||
<!-- Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/ -->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useActiveElement, useLocalStorage, useWindowSize } from '@vueuse/core'
|
||||
import { computed, nextTick, ref, watch, type MaybeRef } from 'vue'
|
||||
|
||||
import type { TicketById } from '#shared/entities/ticket/types'
|
||||
import type { AppSpecificTicketArticleType } from '#shared/entities/ticket-article/action/plugins/types.ts'
|
||||
import { useApplicationStore } from '#shared/stores/application.ts'
|
||||
import { useSessionStore } from '#shared/stores/session.ts'
|
||||
import type { ButtonVariant } from '#shared/types/button.ts'
|
||||
|
||||
import CommonButton from '#desktop/components/CommonButton/CommonButton.vue'
|
||||
import ResizeLine from '#desktop/components/ResizeLine/ResizeLine.vue'
|
||||
import { useResizeLine } from '#desktop/components/ResizeLine/useResizeLine.ts'
|
||||
import { useElementScroll } from '#desktop/composables/useElementScroll.ts'
|
||||
|
||||
interface Props {
|
||||
ticket: TicketById
|
||||
newArticlePresent?: boolean
|
||||
createArticleType?: string | null
|
||||
ticketArticleTypes: AppSpecificTicketArticleType[]
|
||||
isTicketCustomer?: boolean
|
||||
hasInternalArticle?: boolean
|
||||
parentReachedBottomScroll: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
defineEmits<{
|
||||
'show-article-form': [
|
||||
articleType: string,
|
||||
performReply: AppSpecificTicketArticleType['performReply'],
|
||||
]
|
||||
'discard-form': []
|
||||
}>()
|
||||
|
||||
const currentTicketArticleType = computed(() => {
|
||||
if (props.isTicketCustomer) return 'web'
|
||||
if (props.createArticleType && ['phone', 'web'].includes(props.createArticleType)) {
|
||||
return 'email'
|
||||
}
|
||||
|
||||
return props.createArticleType
|
||||
})
|
||||
|
||||
const allowedArticleTypes = computed(() => {
|
||||
return ['note', 'phone', currentTicketArticleType.value]
|
||||
})
|
||||
|
||||
const availableArticleTypes = computed(() => {
|
||||
// Get the channels that would normally be available
|
||||
let availableArticleTypes = props.ticketArticleTypes.filter((type) =>
|
||||
allowedArticleTypes.value.includes(type.value),
|
||||
)
|
||||
|
||||
// Check for CDR Link channel whitelist
|
||||
const application = useApplicationStore()
|
||||
const cdrAllowedChannels = application.config.cdr_link_allowed_channels as string | undefined
|
||||
|
||||
if (cdrAllowedChannels && cdrAllowedChannels.trim()) {
|
||||
// Parse the whitelist
|
||||
const whitelist = cdrAllowedChannels.split(',').map(c => c.trim())
|
||||
|
||||
// Filter to only channels in the whitelist
|
||||
availableArticleTypes = availableArticleTypes.filter(type => whitelist.includes(type.value))
|
||||
}
|
||||
|
||||
const hasEmail = availableArticleTypes.some((type) => type.value === 'email')
|
||||
|
||||
let primaryTicketArticleType = currentTicketArticleType.value
|
||||
if (availableArticleTypes.length === 2) {
|
||||
primaryTicketArticleType = props.createArticleType
|
||||
}
|
||||
|
||||
return availableArticleTypes.map((type) => {
|
||||
return {
|
||||
articleType: type.value,
|
||||
label:
|
||||
primaryTicketArticleType === type.value && hasEmail ? __('Add reply') : type.buttonLabel,
|
||||
icon: type.icon,
|
||||
variant:
|
||||
primaryTicketArticleType === type.value ||
|
||||
(type.value === 'phone' && !hasEmail && availableArticleTypes.length === 2)
|
||||
? 'primary'
|
||||
: 'secondary',
|
||||
performReply: (() =>
|
||||
type.performReply?.(props.ticket)) as AppSpecificTicketArticleType['performReply'],
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const pinned = defineModel<boolean>('pinned')
|
||||
|
||||
const togglePinned = () => {
|
||||
pinned.value = !pinned.value
|
||||
}
|
||||
|
||||
const articlePanel = ref<HTMLElement>()
|
||||
|
||||
// Scroll the new article panel into view whenever:
|
||||
// - an article is being added
|
||||
// - the panel is being unpinned
|
||||
watch(
|
||||
() => [props.newArticlePresent, pinned.value],
|
||||
([newArticlePresent, newPinned]) => {
|
||||
if (!newArticlePresent || newPinned) return
|
||||
|
||||
nextTick(() => {
|
||||
// NB: Give editor a chance to initialize its height.
|
||||
setTimeout(() => {
|
||||
articlePanel.value?.scrollIntoView?.(true)
|
||||
}, 300)
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
// Reset the pinned state whenever the article is removed.
|
||||
watch(
|
||||
() => props.newArticlePresent,
|
||||
(newArticlePresent) => {
|
||||
if (newArticlePresent) return
|
||||
|
||||
pinned.value = false
|
||||
},
|
||||
)
|
||||
|
||||
const DEFAULT_ARTICLE_PANEL_HEIGHT = 290
|
||||
const MINIMUM_ARTICLE_PANEL_HEIGHT = 150
|
||||
|
||||
const { userId } = useSessionStore()
|
||||
|
||||
const articlePanelHeight = useLocalStorage(
|
||||
`${userId}-article-reply-height`,
|
||||
DEFAULT_ARTICLE_PANEL_HEIGHT,
|
||||
)
|
||||
|
||||
const { height: screenHeight } = useWindowSize()
|
||||
|
||||
const articlePanelMaxHeight = computed(() => screenHeight.value / 2)
|
||||
|
||||
const resizeLine = ref<InstanceType<typeof ResizeLine>>()
|
||||
|
||||
const resizeCallback = (valueY: number) => {
|
||||
if (valueY >= articlePanelMaxHeight.value || valueY < MINIMUM_ARTICLE_PANEL_HEIGHT) return
|
||||
|
||||
articlePanelHeight.value = valueY
|
||||
}
|
||||
|
||||
// a11y keyboard navigation
|
||||
const activeElement = useActiveElement()
|
||||
|
||||
const handleKeyStroke = (e: KeyboardEvent, adjustment: number) => {
|
||||
if (!articlePanelHeight.value || activeElement.value !== resizeLine.value?.resizeLine) return
|
||||
|
||||
e.preventDefault()
|
||||
|
||||
const newHeight = articlePanelHeight.value + adjustment
|
||||
|
||||
if (newHeight >= articlePanelMaxHeight.value) return
|
||||
|
||||
resizeCallback(newHeight)
|
||||
}
|
||||
|
||||
const { startResizing } = useResizeLine(
|
||||
resizeCallback,
|
||||
resizeLine.value?.resizeLine,
|
||||
handleKeyStroke,
|
||||
{ orientation: 'horizontal', offsetThreshold: 56 }, // bottom bar height in px
|
||||
)
|
||||
|
||||
const resetHeight = () => {
|
||||
articlePanelHeight.value = DEFAULT_ARTICLE_PANEL_HEIGHT
|
||||
}
|
||||
|
||||
const articleForm = ref<HTMLElement>()
|
||||
|
||||
const { reachedTop: articleFormReachedTop } = useElementScroll(articleForm as MaybeRef<HTMLElement>)
|
||||
|
||||
defineExpose({
|
||||
articlePanel,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="newArticlePresent"
|
||||
ref="articlePanel"
|
||||
class="relative mx-auto flex w-full flex-col"
|
||||
:class="{
|
||||
'max-w-6xl px-12 py-4': !pinned,
|
||||
'sticky bottom-0 z-20 overflow-hidden border-t border-t-neutral-300 bg-neutral-50 dark:border-t-gray-900 dark:bg-gray-500':
|
||||
pinned,
|
||||
}"
|
||||
:style="{
|
||||
height: pinned ? `${articlePanelHeight}px` : 'auto',
|
||||
}"
|
||||
aria-labelledby="article-reply-form-title"
|
||||
role="complementary"
|
||||
:aria-expanded="!pinned"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<ResizeLine
|
||||
v-if="pinned"
|
||||
ref="resizeLine"
|
||||
class="group absolute top-0 z-10 h-3 w-full"
|
||||
:label="$t('Resize article panel')"
|
||||
orientation="horizontal"
|
||||
:values="{
|
||||
max: articlePanelMaxHeight,
|
||||
min: MINIMUM_ARTICLE_PANEL_HEIGHT,
|
||||
current: articlePanelHeight,
|
||||
}"
|
||||
@mousedown-event="startResizing"
|
||||
@touchstart-event="startResizing"
|
||||
@dblclick="resetHeight"
|
||||
/>
|
||||
<div
|
||||
class="flex h-full grow flex-col"
|
||||
data-test-id="article-reply-stripes-panel"
|
||||
:class="{
|
||||
'bg-stripes relative z-0 rounded-xl outline-1 outline-blue-700 before:rounded-2xl':
|
||||
hasInternalArticle && !pinned,
|
||||
'border-stripes': hasInternalArticle && pinned,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="isolate flex h-full grow flex-col"
|
||||
:class="{
|
||||
'rounded-xl border border-neutral-300 bg-neutral-50 dark:border-gray-900 dark:bg-gray-500':
|
||||
!pinned,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="flex h-10 items-center p-3"
|
||||
:class="{
|
||||
'bg-neutral-50 dark:bg-gray-500': pinned,
|
||||
'border-b border-b-transparent': pinned && articleFormReachedTop,
|
||||
'border-b border-b-neutral-300 dark:border-b-gray-900':
|
||||
pinned && !articleFormReachedTop,
|
||||
}"
|
||||
>
|
||||
<CommonLabel
|
||||
id="article-reply-form-title"
|
||||
class="text-stone-200 ltr:mr-auto rtl:ml-auto dark:text-neutral-500"
|
||||
tag="h2"
|
||||
size="small"
|
||||
>
|
||||
{{ $t('Reply') }}
|
||||
</CommonLabel>
|
||||
<CommonButton
|
||||
v-tooltip="$t('Discard unsaved reply')"
|
||||
class="text-red-500 ltr:mr-2 rtl:ml-2"
|
||||
variant="none"
|
||||
icon="trash"
|
||||
@click="$emit('discard-form')"
|
||||
/>
|
||||
<CommonButton
|
||||
v-tooltip="pinned ? $t('Unpin this panel') : $t('Pin this panel')"
|
||||
:icon="pinned ? 'pin' : 'pin-angle'"
|
||||
variant="neutral"
|
||||
size="small"
|
||||
@click="togglePinned"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
id="ticketArticleReplyForm"
|
||||
ref="articleForm"
|
||||
class="grow px-3 pb-3"
|
||||
:class="{
|
||||
'overflow-y-auto': pinned,
|
||||
'my-[5px] px-4 pt-2': hasInternalArticle && pinned,
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="newArticlePresent !== undefined"
|
||||
class="sticky bottom-0 z-20 flex w-full justify-center gap-2.5 border-t py-1.5"
|
||||
:class="{
|
||||
'border-t-neutral-100 bg-neutral-50 dark:border-t-gray-900 dark:bg-gray-500':
|
||||
parentReachedBottomScroll,
|
||||
'border-t-transparent': !parentReachedBottomScroll,
|
||||
}"
|
||||
>
|
||||
<CommonButton
|
||||
v-for="button in availableArticleTypes"
|
||||
:key="button.articleType"
|
||||
:prefix-icon="button.icon"
|
||||
:variant="button.variant as ButtonVariant"
|
||||
size="large"
|
||||
@click="$emit('show-article-form', button.articleType, button.performReply)"
|
||||
>
|
||||
{{ $t(button.label) }}
|
||||
</CommonButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.border-stripes {
|
||||
position: relative;
|
||||
z-index: -10;
|
||||
background-color: var(--color-neutral-50);
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 40px;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
border: 5px solid transparent;
|
||||
background-image: repeating-linear-gradient(
|
||||
45deg,
|
||||
var(--color-blue-400),
|
||||
var(--color-blue-400) 5px,
|
||||
var(--color-blue-700) 5px,
|
||||
var(--color-blue-700) 10px
|
||||
);
|
||||
background-position: -1px;
|
||||
background-attachment: fixed;
|
||||
mask:
|
||||
linear-gradient(white, white) padding-box,
|
||||
linear-gradient(white, white);
|
||||
mask-composite: exclude;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 40px;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
outline: 1px solid var(--color-blue-700);
|
||||
outline-offset: -5px;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-theme='dark'] .border-stripes {
|
||||
background-color: var(--color-gray-500);
|
||||
|
||||
&::before {
|
||||
background-image: repeating-linear-gradient(
|
||||
45deg,
|
||||
var(--color-blue-700),
|
||||
var(--color-blue-700) 5px,
|
||||
var(--color-blue-900) 5px,
|
||||
var(--color-blue-900) 10px
|
||||
);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import type { ChannelModule } from "#desktop/pages/ticket/components/TicketDetailView/article-type/types.ts";
|
||||
|
||||
export default <ChannelModule>{
|
||||
name: "signal message",
|
||||
label: __("Signal Message"),
|
||||
icon: "cdr-signal",
|
||||
};
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import type { ChannelModule } from "#desktop/pages/ticket/components/TicketDetailView/article-type/types.ts";
|
||||
|
||||
export default <ChannelModule>{
|
||||
name: "whatsapp message",
|
||||
label: __("WhatsApp Message"),
|
||||
icon: "whatsapp",
|
||||
};
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import { EnumTicketArticleSenderName } from '#shared/graphql/types.ts'
|
||||
|
||||
import type { TicketArticleAction, TicketArticleActionPlugin, TicketArticleType } from './types.ts'
|
||||
|
||||
const actionPlugin: TicketArticleActionPlugin = {
|
||||
order: 350,
|
||||
|
||||
addActions(ticket, article) {
|
||||
const sender = article.sender?.name
|
||||
const type = article.type?.name
|
||||
|
||||
if (sender !== EnumTicketArticleSenderName.Customer || type !== 'signal message')
|
||||
return []
|
||||
|
||||
const action: TicketArticleAction = {
|
||||
apps: ['mobile', 'desktop'],
|
||||
label: __('Reply'),
|
||||
name: 'signal message',
|
||||
icon: 'cdr-signal',
|
||||
view: {
|
||||
agent: ['change'],
|
||||
},
|
||||
perform(ticket, article, { openReplyForm }) {
|
||||
const articleData = {
|
||||
articleType: type,
|
||||
inReplyTo: article.messageId,
|
||||
}
|
||||
|
||||
openReplyForm(articleData)
|
||||
},
|
||||
}
|
||||
return [action]
|
||||
},
|
||||
|
||||
addTypes(ticket) {
|
||||
const descriptionType = ticket.createArticleType?.name
|
||||
|
||||
if (descriptionType !== 'signal message') return []
|
||||
|
||||
const type: TicketArticleType = {
|
||||
apps: ['mobile', 'desktop'],
|
||||
value: 'signal message',
|
||||
label: __('Signal'),
|
||||
buttonLabel: __('Add Signal message'),
|
||||
icon: 'cdr-signal',
|
||||
view: {
|
||||
agent: ['change'],
|
||||
},
|
||||
internal: false,
|
||||
contentType: 'text/plain',
|
||||
fields: {
|
||||
body: {
|
||||
required: true,
|
||||
validation: 'length:1,10000',
|
||||
},
|
||||
attachments: {},
|
||||
},
|
||||
editorMeta: {
|
||||
footer: {
|
||||
maxlength: 10000,
|
||||
warningLength: 5000,
|
||||
},
|
||||
},
|
||||
}
|
||||
return [type]
|
||||
},
|
||||
}
|
||||
|
||||
export default actionPlugin
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import { EnumTicketArticleSenderName } from "#shared/graphql/types.ts";
|
||||
|
||||
import type {
|
||||
TicketArticleAction,
|
||||
TicketArticleActionPlugin,
|
||||
TicketArticleType,
|
||||
} from "./types.ts";
|
||||
|
||||
const actionPlugin: TicketArticleActionPlugin = {
|
||||
order: 360,
|
||||
|
||||
addActions(ticket, article) {
|
||||
const sender = article.sender?.name;
|
||||
const type = article.type?.name;
|
||||
|
||||
if (
|
||||
sender !== EnumTicketArticleSenderName.Customer ||
|
||||
type !== "whatsapp message"
|
||||
)
|
||||
return [];
|
||||
|
||||
const action: TicketArticleAction = {
|
||||
apps: ["mobile", "desktop"],
|
||||
label: __("Reply"),
|
||||
name: "whatsapp message",
|
||||
icon: "cdr-whatsapp",
|
||||
view: {
|
||||
agent: ["change"],
|
||||
},
|
||||
perform(ticket, article, { openReplyForm }) {
|
||||
const articleData = {
|
||||
articleType: type,
|
||||
inReplyTo: article.messageId,
|
||||
};
|
||||
|
||||
openReplyForm(articleData);
|
||||
},
|
||||
};
|
||||
return [action];
|
||||
},
|
||||
|
||||
addTypes(ticket) {
|
||||
const descriptionType = ticket.createArticleType?.name;
|
||||
|
||||
if (descriptionType !== "whatsapp message") return [];
|
||||
|
||||
const type: TicketArticleType = {
|
||||
apps: ["mobile", "desktop"],
|
||||
value: "whatsapp message",
|
||||
label: __("WhatsApp"),
|
||||
buttonLabel: __("Add WhatsApp message"),
|
||||
icon: "cdr-whatsapp",
|
||||
view: {
|
||||
agent: ["change"],
|
||||
},
|
||||
internal: false,
|
||||
contentType: "text/plain",
|
||||
fields: {
|
||||
body: {
|
||||
required: true,
|
||||
validation: "length:1,4096",
|
||||
},
|
||||
attachments: {},
|
||||
},
|
||||
editorMeta: {
|
||||
footer: {
|
||||
maxlength: 4096,
|
||||
warningLength: 3000,
|
||||
},
|
||||
},
|
||||
};
|
||||
return [type];
|
||||
},
|
||||
};
|
||||
|
||||
export default actionPlugin;
|
||||
|
|
@ -25,10 +25,30 @@ class CommunicateCdrSignalJob < ApplicationJob
|
|||
log_error(article,
|
||||
"Can't find ticket.preferences['cdr_signal']['bot_token'] for Ticket.find(#{article.ticket_id})")
|
||||
end
|
||||
unless ticket.preferences['cdr_signal']['chat_id']
|
||||
# Only require chat_id if auto-groups is not enabled
|
||||
if ENV['BRIDGE_SIGNAL_AUTO_GROUPS'].to_s.downcase != 'true' && ticket.preferences['cdr_signal']['chat_id'].blank?
|
||||
log_error(article,
|
||||
"Can't find ticket.preferences['cdr_signal']['chat_id'] for Ticket.find(#{article.ticket_id})")
|
||||
end
|
||||
|
||||
# Check if this is a group chat and if the user has joined
|
||||
chat_id = ticket.preferences['cdr_signal']['chat_id']
|
||||
is_group_chat = chat_id&.start_with?('group.')
|
||||
group_joined = ticket.preferences.dig('cdr_signal', 'group_joined')
|
||||
|
||||
# If this is a group chat and user hasn't joined yet, don't send the message
|
||||
if is_group_chat && group_joined == false
|
||||
Rails.logger.info "Ticket ##{ticket.number}: User hasn't joined Signal group yet, skipping message delivery"
|
||||
|
||||
# Mark article as pending delivery
|
||||
article.preferences['delivery_status'] = 'pending'
|
||||
article.preferences['delivery_status_message'] = 'Waiting for user to join Signal group'
|
||||
article.preferences['delivery_status_date'] = Time.zone.now
|
||||
article.save!
|
||||
|
||||
# Retry later when user might have joined
|
||||
raise 'User has not joined Signal group yet'
|
||||
end
|
||||
channel = ::CdrSignal.bot_by_bot_token(ticket.preferences['cdr_signal']['bot_token'])
|
||||
channel ||= ::Channel.lookup(id: ticket.preferences['channel_id'])
|
||||
unless channel
|
||||
|
|
@ -43,10 +63,7 @@ class CommunicateCdrSignalJob < ApplicationJob
|
|||
has_error = false
|
||||
|
||||
begin
|
||||
result = channel.deliver(
|
||||
to: ticket.preferences[:cdr_signal][:chat_id],
|
||||
body: article.body
|
||||
)
|
||||
result = channel.deliver(article)
|
||||
rescue StandardError => e
|
||||
log_error(article, e.message)
|
||||
has_error = true
|
||||
|
|
|
|||
|
|
@ -43,10 +43,7 @@ class CommunicateCdrWhatsappJob < ApplicationJob
|
|||
has_error = false
|
||||
|
||||
begin
|
||||
result = channel.deliver(
|
||||
to: ticket.preferences[:cdr_whatsapp][:chat_id],
|
||||
body: article.body
|
||||
)
|
||||
result = channel.deliver(article)
|
||||
rescue StandardError => e
|
||||
log_error(article, e.message)
|
||||
has_error = true
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Controllers
|
||||
class CdrSignalChannelsControllerPolicy < Controllers::ApplicationControllerPolicy
|
||||
def index?
|
||||
user.permissions?('admin.channel')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Controllers
|
||||
class CdrTicketArticleTypesControllerPolicy < Controllers::ApplicationControllerPolicy
|
||||
def index?
|
||||
true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
Zammad::Application.routes.draw do
|
||||
api_path = Rails.configuration.api_path
|
||||
|
||||
match api_path + '/cdr_signal_channels', to: 'cdr_signal_channels#index', via: :get
|
||||
end
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
Zammad::Application.routes.draw do
|
||||
api_path = Rails.configuration.api_path
|
||||
|
||||
match api_path + '/ticket_article_types', to: 'cdr_ticket_article_types#index', via: :get
|
||||
end
|
||||
|
|
@ -7,6 +7,7 @@ Zammad::Application.routes.draw do
|
|||
match "#{api_path}/channels_cdr_signal", to: 'channels_cdr_signal#add', via: :post
|
||||
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_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
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ class CdrSignalChannel < ActiveRecord::Migration[5.2]
|
|||
)
|
||||
Permission.create_if_not_exists(
|
||||
name: 'admin.channel_cdr_signal',
|
||||
note: 'Manage %s',
|
||||
description: 'Manage %s',
|
||||
preferences: {
|
||||
translations: ['Channel - Signal']
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ class CdrVoiceChannel < ActiveRecord::Migration[5.2]
|
|||
)
|
||||
Permission.create_if_not_exists(
|
||||
name: 'admin.channel_cdr_voice',
|
||||
note: 'Manage %s',
|
||||
description: 'Manage %s',
|
||||
preferences: {
|
||||
translations: ['Channel - Voice']
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ class CdrWhatsappChannel < ActiveRecord::Migration[5.2]
|
|||
)
|
||||
Permission.create_if_not_exists(
|
||||
name: 'admin.channel_cdr_whatsapp',
|
||||
note: 'Manage %s',
|
||||
description: 'Manage %s',
|
||||
preferences: {
|
||||
translations: ['Channel - Whatsapp']
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddChannelRestrictionSetting < ActiveRecord::Migration[5.2]
|
||||
def self.up
|
||||
Setting.create_if_not_exists(
|
||||
title: 'CDR Link - Allowed Reply Channels',
|
||||
name: 'cdr_link_allowed_channels',
|
||||
area: 'Integration::CDRLink',
|
||||
description: 'Comma-separated whitelist of allowed reply channels (e.g., "note,signal message,email"). Leave empty to allow all channels.',
|
||||
options: {
|
||||
form: [
|
||||
{
|
||||
display: 'Allowed Channels',
|
||||
null: true,
|
||||
name: 'cdr_link_allowed_channels',
|
||||
tag: 'input',
|
||||
}
|
||||
],
|
||||
},
|
||||
state: '', # Empty by default (allows all)
|
||||
frontend: true, # Available to frontend
|
||||
preferences: {
|
||||
permission: ['admin'],
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def self.down
|
||||
Setting.find_by(name: 'cdr_link_allowed_channels')&.destroy
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
class EnableDesktopBetaSwitch < ActiveRecord::Migration[6.1]
|
||||
def change
|
||||
# Enable the desktop beta switch to allow users to toggle between old and new UI
|
||||
Setting.set('ui_desktop_beta_switch', true)
|
||||
|
||||
# Also ensure the beta UI switch permission exists and is active
|
||||
permission = Permission.find_by(name: 'user_preferences.beta_ui_switch')
|
||||
if permission
|
||||
permission.update(active: true)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddCdrLinkConfig < ActiveRecord::Migration[5.2]
|
||||
def self.up
|
||||
Setting.create_if_not_exists(
|
||||
title: 'CDR Link',
|
||||
name: 'cdr_link_config',
|
||||
area: 'Integration::CDRLink',
|
||||
description: 'Defines the CDR Link integration config.',
|
||||
options: {},
|
||||
state: { items: [] },
|
||||
frontend: false,
|
||||
preferences: {
|
||||
prio: 2,
|
||||
permission: ['admin.integration'],
|
||||
}
|
||||
)
|
||||
|
||||
# Update the existing allowed_channels setting to use admin.integration permission
|
||||
setting = Setting.find_by(name: 'cdr_link_allowed_channels')
|
||||
if setting
|
||||
setting.preferences = {
|
||||
permission: ['admin.integration'],
|
||||
}
|
||||
setting.save!
|
||||
end
|
||||
end
|
||||
|
||||
def self.down
|
||||
Setting.find_by(name: 'cdr_link_config')&.destroy
|
||||
|
||||
# Restore original permission if rolling back
|
||||
setting = Setting.find_by(name: 'cdr_link_allowed_channels')
|
||||
if setting
|
||||
setting.preferences = {
|
||||
permission: ['admin'],
|
||||
}
|
||||
setting.save!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -312,8 +312,42 @@ class CdrSignal
|
|||
def from_article(article)
|
||||
# sends a message from a zammad article
|
||||
|
||||
Rails.logger.debug { "Create signal message from article to '#{article[:to]}'..." }
|
||||
Rails.logger.debug { "Create signal message from article..." }
|
||||
|
||||
@api.send_message(article[:to], article[:body])
|
||||
# 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_signal', 'chat_id')
|
||||
|
||||
# If auto-groups is enabled and no chat_id, use original_recipient
|
||||
if recipient.blank? && ENV['BRIDGE_SIGNAL_AUTO_GROUPS'].to_s.downcase == 'true'
|
||||
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}'" }
|
||||
|
||||
# Include ticket ID as conversationId for group creation callback
|
||||
options = {}
|
||||
options[:conversationId] = ticket.number if ticket
|
||||
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -35,6 +35,16 @@ class CdrSignalApi
|
|||
end
|
||||
|
||||
def send_message(recipient, text, options = {})
|
||||
post('send', { to: recipient.to_s, message: text }.merge(parse_hash(options)))
|
||||
# Don't encode conversationId with CGI
|
||||
params = { to: recipient.to_s, message: text }
|
||||
if options[:conversationId]
|
||||
params[:conversationId] = options[:conversationId]
|
||||
options.delete(:conversationId)
|
||||
end
|
||||
if options[:attachments]
|
||||
params[:attachments] = options[:attachments]
|
||||
options.delete(:attachments)
|
||||
end
|
||||
post('send', params.merge(parse_hash(options)))
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -312,8 +312,33 @@ class CdrWhatsapp
|
|||
def from_article(article)
|
||||
# sends a message from a zammad article
|
||||
|
||||
Rails.logger.debug { "Create whatsapp message from article to '#{article[:to]}'..." }
|
||||
Rails.logger.debug { "Create whatsapp message from article..." }
|
||||
|
||||
@api.send_message(article[:to], article[:body])
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -35,6 +35,9 @@ class CdrWhatsappApi
|
|||
end
|
||||
|
||||
def send_message(recipient, text, options = {})
|
||||
post('send', { to: recipient.to_s, message: text }.merge(parse_hash(options)))
|
||||
params = { to: recipient.to_s, message: text }
|
||||
params[:attachments] = options[:attachments] if options[:attachments]
|
||||
options.delete(:attachments) if options[:attachments]
|
||||
post('send', params.merge(parse_hash(options)))
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue