diff --git a/packages/zammad-addon-bridge/src/app/assets/javascripts/app/controllers/_profile/signal_notifications.coffee b/packages/zammad-addon-bridge/src/app/assets/javascripts/app/controllers/_profile/signal_notifications.coffee new file mode 100644 index 0000000..23d0df7 --- /dev/null +++ b/packages/zammad-addon-bridge/src/app/assets/javascripts/app/controllers/_profile/signal_notifications.coffee @@ -0,0 +1,89 @@ +class ProfileSignalNotifications extends App.ControllerSubContent + @requiredPermission: 'user_preferences.signal_notifications+ticket.agent' + header: __('Signal Notifications') + events: + 'submit form': 'update' + + constructor: -> + super + App.User.full(App.Session.get().id, @render, true, true) + + render: => + config = + enabled: false + events: + create: true + update: true + escalation: true + reminder_reached: true + + user = App.User.find(App.Session.get().id) + user_config = user.preferences?.signal_notifications + if user_config + config = $.extend(true, {}, config, user_config) + + @html App.view('profile/signal_notifications') + config: config + signal_uid: user.signal_uid || '' + signal_notification_enabled: App.Config.get('signal_notification_enabled') + + update: (e) => + e.preventDefault() + params = @formParam(e.target) + + preferences = {} + preferences.signal_notifications = + enabled: params.enabled == 'true' + events: + create: params.event_create == 'true' + update: params.event_update == 'true' + escalation: params.event_escalation == 'true' + reminder_reached: params.event_reminder_reached == 'true' + + @formDisable(e) + + @ajax( + id: 'preferences_signal_notifications' + type: 'PUT' + url: @apiPath + '/users/preferences' + data: JSON.stringify(preferences) + processData: true + success: @successPreferences + error: @error + ) + + if params.signal_uid? + user = App.User.find(App.Session.get().id) + user.signal_uid = params.signal_uid + user.save( + done: => + # User saved successfully + fail: (settings, details) => + @notify( + type: 'error' + msg: details.error || __('Failed to save Signal phone number') + ) + ) + + successPreferences: (data, status, xhr) => + App.User.full( + App.Session.get('id'), + => + App.Event.trigger('ui:rerender') + @notify( + type: 'success' + msg: __('Update successful.') + ) + , + true + ) + + error: (xhr, status, error) => + @render() + data = JSON.parse(xhr.responseText) + @notify( + type: 'error' + msg: data.message + ) + +App.Config.set('SignalNotifications', { prio: 2650, name: __('Signal Notifications'), parent: '#profile', target: '#profile/signal_notifications', permission: ['user_preferences.signal_notifications+ticket.agent'], controller: ProfileSignalNotifications }, 'NavBarProfile') diff --git a/packages/zammad-addon-bridge/src/app/assets/javascripts/app/views/profile/signal_notifications.jst.eco b/packages/zammad-addon-bridge/src/app/assets/javascripts/app/views/profile/signal_notifications.jst.eco new file mode 100644 index 0000000..4899934 --- /dev/null +++ b/packages/zammad-addon-bridge/src/app/assets/javascripts/app/views/profile/signal_notifications.jst.eco @@ -0,0 +1,86 @@ + + +<% if !@signal_notification_enabled: %> + +<% end %> + +
+

<%- @T('Signal Phone Number') %>

+

+ <%- @T('Enter your Signal phone number to receive ticket notifications via Signal.') %> +

+ +
+ + +

<%- @T('Use international format with country code (e.g., +1234567890)') %>

+
+ +

<%- @T('Notification Settings') %>

+ +
+ +
+ +

<%- @T('Notification Events') %>

+

+ <%- @T('Select which events should trigger Signal notifications.') %> +

+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ + +
diff --git a/packages/zammad-addon-bridge/src/app/jobs/signal_notification_job.rb b/packages/zammad-addon-bridge/src/app/jobs/signal_notification_job.rb new file mode 100644 index 0000000..1da1012 --- /dev/null +++ b/packages/zammad-addon-bridge/src/app/jobs/signal_notification_job.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +class SignalNotificationJob < ApplicationJob + retry_on StandardError, attempts: 3, wait: lambda { |executions| + executions * 60.seconds + } + + def perform(ticket_id:, article_id:, user_id:, type:, changes:) + ticket = Ticket.find_by(id: ticket_id) + return if !ticket + + user = User.find_by(id: user_id) + return if !user + return if user.signal_uid.blank? + + article = article_id ? Ticket::Article.find_by(id: article_id) : nil + + channel = signal_channel + return if !channel + + message = SignalNotificationSender.build_message( + ticket: ticket, + article: article, + user: user, + type: type, + changes: changes + ) + + return if message.blank? + + SignalNotificationSender.send_message( + channel: channel, + recipient: user.signal_uid, + message: message + ) + + add_history(ticket, user, type) + + Rails.logger.info "Sent Signal notification to #{user.signal_uid} for ticket ##{ticket.number} (#{type})" + end + + private + + def signal_channel + channel_id = Setting.get('signal_notification_channel_id') + return unless channel_id + + Channel.find_by(id: channel_id, area: 'Signal::Account', active: true) + end + + def add_history(ticket, user, type) + identifier = user.signal_uid.presence || user.login + recipient_list = "#{identifier}(#{type}:signal)" + + History.add( + o_id: ticket.id, + history_type: 'notification', + history_object: 'Ticket', + value_to: recipient_list, + created_by_id: 1 + ) + end +end diff --git a/packages/zammad-addon-bridge/src/app/models/transaction/signal_notification.rb b/packages/zammad-addon-bridge/src/app/models/transaction/signal_notification.rb new file mode 100644 index 0000000..a7b9f29 --- /dev/null +++ b/packages/zammad-addon-bridge/src/app/models/transaction/signal_notification.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true + +class Transaction::SignalNotification + include ChecksHumanChanges + + def initialize(item, params = {}) + @item = item + @params = params + end + + def perform + return if Setting.get('import_mode') + return if %w[Ticket Ticket::Article].exclude?(@item[:object]) + return if @params[:disable_notification] + return if !ticket + return if !signal_notifications_enabled? + return if !signal_channel + + collect_signal_recipients.each do |user| + SignalNotificationJob.perform_later( + ticket_id: ticket.id, + article_id: @item[:article_id], + user_id: user.id, + type: @item[:type], + changes: human_changes(@item[:changes], ticket, user) + ) + end + end + + private + + def ticket + @ticket ||= Ticket.find_by(id: @item[:object_id]) + end + + def article + return if !@item[:article_id] + + @article ||= begin + art = Ticket::Article.find_by(id: @item[:article_id]) + return unless art + + sender = Ticket::Article::Sender.lookup(id: art.sender_id) + if sender&.name == 'System' + return if @item[:changes].blank? && art.preferences[:notification] != true + return if art.preferences[:notification] != true + end + + art + end + end + + def current_user + @current_user ||= User.lookup(id: @item[:user_id]) || User.lookup(id: 1) + end + + def signal_notifications_enabled? + Setting.get('signal_notification_enabled') == true + end + + def signal_channel + @signal_channel ||= begin + channel_id = Setting.get('signal_notification_channel_id') + return unless channel_id + + Channel.find_by(id: channel_id, area: 'Signal::Account', active: true) + end + end + + def collect_signal_recipients + recipients = [] + + possible_recipients = possible_recipients_of_group(ticket.group_id) + + mention_users = Mention.where(mentionable_type: @item[:object], mentionable_id: @item[:object_id]).map(&:user) + mention_users.each do |user| + next if !user.group_access?(ticket.group_id, 'read') + + possible_recipients.push(user) + end + + if ticket.owner_id != 1 + possible_recipients.push(ticket.owner) + end + + possible_recipients_with_ooo = Set.new(possible_recipients) + possible_recipients.each do |user| + add_out_of_office_replacement(user, possible_recipients_with_ooo) + end + + possible_recipients_with_ooo.each do |user| + next if recipient_is_current_user?(user) + next if !user.active? + next if !user_has_signal_notifications_enabled?(user) + next if user.signal_uid.blank? + next if !should_notify_for_event?(user) + + recipients.push(user) + end + + recipients.uniq(&:id) + end + + def possible_recipients_of_group(group_id) + Rails.cache.fetch("User/signal_notification/possible_recipients_of_group/#{group_id}/#{User.latest_change}", expires_in: 20.seconds) do + User.group_access(group_id, 'full').sort_by(&:login) + end + end + + def add_out_of_office_replacement(user, recipients) + replacement = user.out_of_office_agent + return unless replacement + return unless TicketPolicy.new(replacement, ticket).agent_read_access? + + recipients.add(replacement) + end + + def recipient_is_current_user?(user) + return false if @params[:interface_handle] != 'application_server' + return true if article&.updated_by_id == user.id + return true if !article && @item[:user_id] == user.id + + false + end + + def user_has_signal_notifications_enabled?(user) + user.preferences.dig('signal_notifications', 'enabled') == true + end + + def should_notify_for_event?(user) + event_type = @item[:type] + return false if event_type.blank? + + event_key = case event_type + when 'create' then 'create' + when 'update', 'update.merged_into', 'update.received_merge', 'update.reaction' then 'update' + when 'reminder_reached' then 'reminder_reached' + when 'escalation', 'escalation_warning' then 'escalation' + else return false + end + + user.preferences.dig('signal_notifications', 'events', event_key) == true + end +end diff --git a/packages/zammad-addon-bridge/src/app/views/signal_notification/ticket_create/en.txt.erb b/packages/zammad-addon-bridge/src/app/views/signal_notification/ticket_create/en.txt.erb new file mode 100644 index 0000000..9a12210 --- /dev/null +++ b/packages/zammad-addon-bridge/src/app/views/signal_notification/ticket_create/en.txt.erb @@ -0,0 +1,16 @@ +[Ticket #<%= ticket.number %>] <%= ticket.title %> + +NEW TICKET + +Group: <%= ticket.group.name %> +Owner: <%= ticket.owner.fullname %> +State: <%= t(ticket.state.name) %> +Priority: <%= t(ticket.priority.name) %> +Customer: <%= ticket.customer.fullname %> +Created by: <%= current_user.fullname %> +<% if article -%> + +<%= article_body_preview(500) %> +<% end -%> + +<%= ticket_url %> diff --git a/packages/zammad-addon-bridge/src/app/views/signal_notification/ticket_escalation/en.txt.erb b/packages/zammad-addon-bridge/src/app/views/signal_notification/ticket_escalation/en.txt.erb new file mode 100644 index 0000000..a3fdbaa --- /dev/null +++ b/packages/zammad-addon-bridge/src/app/views/signal_notification/ticket_escalation/en.txt.erb @@ -0,0 +1,11 @@ +[Ticket #<%= ticket.number %>] <%= ticket.title %> + +ESCALATION + +Group: <%= ticket.group.name %> +Owner: <%= ticket.owner.fullname %> +State: <%= t(ticket.state.name) %> +Priority: <%= t(ticket.priority.name) %> +Customer: <%= ticket.customer.fullname %> + +<%= ticket_url %> diff --git a/packages/zammad-addon-bridge/src/app/views/signal_notification/ticket_reminder_reached/en.txt.erb b/packages/zammad-addon-bridge/src/app/views/signal_notification/ticket_reminder_reached/en.txt.erb new file mode 100644 index 0000000..e718346 --- /dev/null +++ b/packages/zammad-addon-bridge/src/app/views/signal_notification/ticket_reminder_reached/en.txt.erb @@ -0,0 +1,10 @@ +[Ticket #<%= ticket.number %>] <%= ticket.title %> + +REMINDER REACHED + +Group: <%= ticket.group.name %> +Owner: <%= ticket.owner.fullname %> +State: <%= t(ticket.state.name) %> +Pending till: <%= ticket.pending_time&.strftime('%Y-%m-%d %H:%M') %> + +<%= ticket_url %> diff --git a/packages/zammad-addon-bridge/src/app/views/signal_notification/ticket_update/en.txt.erb b/packages/zammad-addon-bridge/src/app/views/signal_notification/ticket_update/en.txt.erb new file mode 100644 index 0000000..3e87f33 --- /dev/null +++ b/packages/zammad-addon-bridge/src/app/views/signal_notification/ticket_update/en.txt.erb @@ -0,0 +1,14 @@ +[Ticket #<%= ticket.number %>] <%= ticket.title %> + +TICKET UPDATED by <%= current_user.fullname %> +<% if changes.present? -%> + +Changes: +<%= changes_summary %> +<% end -%> +<% if article -%> + +<%= article_body_preview(500) %> +<% end -%> + +<%= ticket_url_with_article %> diff --git a/packages/zammad-addon-bridge/src/db/addon/bridge/20260120000001_add_signal_notification_settings.rb b/packages/zammad-addon-bridge/src/db/addon/bridge/20260120000001_add_signal_notification_settings.rb new file mode 100644 index 0000000..9e1bf23 --- /dev/null +++ b/packages/zammad-addon-bridge/src/db/addon/bridge/20260120000001_add_signal_notification_settings.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +class AddSignalNotificationSettings < ActiveRecord::Migration[5.2] + def self.up + # Register Signal notification transaction backend + # Using 0105 to run after email notifications (0100) + Setting.create_if_not_exists( + title: 'Defines transaction backend.', + name: '0105_signal_notification', + area: 'Transaction::Backend::Async', + description: 'Defines the transaction backend to send Signal notifications.', + options: {}, + state: 'Transaction::SignalNotification', + frontend: false + ) + + # Global enable/disable for Signal notifications + Setting.create_if_not_exists( + title: 'Signal Notifications', + name: 'signal_notification_enabled', + area: 'Integration::Switch', + description: 'Enable or disable Signal notifications for agents.', + options: { + form: [ + { + display: '', + null: true, + name: 'signal_notification_enabled', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: false, + preferences: { + prio: 1, + permission: ['admin.integration'], + }, + frontend: true + ) + + # Which Signal channel/bot to use for sending notifications + Setting.create_if_not_exists( + title: 'Signal Notification Channel', + name: 'signal_notification_channel_id', + area: 'Integration::SignalNotification', + description: 'The Signal channel (bot) used to send notifications to agents.', + options: {}, + state: nil, + preferences: { + prio: 2, + permission: ['admin.integration'], + }, + frontend: false + ) + + # Permission for Signal notifications profile page + Permission.create_if_not_exists( + name: 'user_preferences.signal_notifications', + description: 'Manage Signal notification preferences', + preferences: { + translations: ['Profile - Signal Notifications'] + } + ) + end + + def self.down + Setting.find_by(name: '0105_signal_notification')&.destroy + Setting.find_by(name: 'signal_notification_enabled')&.destroy + Setting.find_by(name: 'signal_notification_channel_id')&.destroy + Permission.find_by(name: 'user_preferences.signal_notifications')&.destroy + end +end diff --git a/packages/zammad-addon-bridge/src/lib/signal_notification_sender.rb b/packages/zammad-addon-bridge/src/lib/signal_notification_sender.rb new file mode 100644 index 0000000..8528892 --- /dev/null +++ b/packages/zammad-addon-bridge/src/lib/signal_notification_sender.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +require 'erb' + +class SignalNotificationSender + TEMPLATE_DIR = Rails.root.join('app', 'views', 'signal_notification') + + class << self + def build_message(ticket:, article:, user:, type:, changes:) + template_name = template_for_type(type) + return if template_name.blank? + + locale = user.locale || Setting.get('locale_default') || 'en' + template_path = find_template(template_name, locale) + return if template_path.blank? + + render_template(template_path, binding_for(ticket, article, user, changes)) + end + + def send_message(channel:, recipient:, message:) + return if Rails.env.test? + return if channel.blank? + return if recipient.blank? + return if message.blank? + + api_url = channel.options[:api_url] + api_token = channel.options[:api_token] + + return if api_url.blank? || api_token.blank? + + api = CdrSignalApi.new(api_url, api_token) + api.send_message(recipient, message) + end + + private + + def template_for_type(type) + case type + when 'create' + 'ticket_create' + when 'update', 'update.merged_into', 'update.received_merge', 'update.reaction' + 'ticket_update' + when 'reminder_reached' + 'ticket_reminder_reached' + when 'escalation', 'escalation_warning' + 'ticket_escalation' + end + end + + def find_template(template_name, locale) + base_locale = locale.split('-').first + + [locale, base_locale, 'en'].uniq.each do |try_locale| + path = TEMPLATE_DIR.join(template_name, "#{try_locale}.txt.erb") + return path if File.exist?(path) + end + + nil + end + + def binding_for(ticket, article, user, changes) + TemplateContext.new( + ticket: ticket, + article: article, + user: user, + changes: changes, + config: { + http_type: Setting.get('http_type'), + fqdn: Setting.get('fqdn'), + product_name: Setting.get('product_name') + } + ).get_binding + end + + def render_template(template_path, binding) + template = File.read(template_path) + erb = ERB.new(template, trim_mode: '-') + erb.result(binding).strip + end + end + + class TemplateContext + attr_reader :ticket, :article, :recipient, :changes, :config + + def initialize(ticket:, article:, user:, changes:, config:) + @ticket = ticket + @article = article + @recipient = user + @changes = changes + @config = OpenStruct.new(config) + end + + def get_binding + binding + end + + def ticket_url + "#{config.http_type}://#{config.fqdn}/#ticket/zoom/#{ticket.id}" + end + + def ticket_url_with_article + if article + "#{ticket_url}/#{article.id}" + else + ticket_url + end + end + + def current_user + @current_user ||= User.lookup(id: ticket.updated_by_id) || User.lookup(id: 1) + end + + def changes_summary + return '' if changes.blank? + + changes.map { |key, values| "#{key}: #{values[0]} -> #{values[1]}" }.join("\n") + end + + def article_body_preview(max_length = 500) + return '' unless article + return '' if article.body.blank? + + body = article.body.to_s + body = ActionController::Base.helpers.strip_tags(body) if article.content_type&.include?('html') + body = body.gsub(/\s+/, ' ').strip + + if body.length > max_length + "#{body[0, max_length]}..." + else + body + end + end + + def t(text) + locale = recipient.locale || Setting.get('locale_default') || 'en' + Translation.translate(locale, text) + end + end +end