diff --git a/Gemfile b/Gemfile index 630b9f01f..8ef71c43f 100644 --- a/Gemfile +++ b/Gemfile @@ -105,6 +105,9 @@ gem 'icalendar-recurrence' # feature - phone number formatting gem 'telephone_number' +# feature - SMS +gem 'twilio-ruby' + # integrations gem 'clearbit' gem 'net-ldap' diff --git a/Gemfile.lock b/Gemfile.lock index b0941b974..a7b24e420 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -447,6 +447,10 @@ GEM thread_safe (0.3.6) tilt (2.0.8) tins (1.15.1) + twilio-ruby (5.10.2) + faraday (~> 0.9) + jwt (>= 1.5, <= 2.5) + nokogiri (>= 1.6, < 2.0) twitter (6.2.0) addressable (~> 2.3) buftok (~> 0.2.0) @@ -578,6 +582,7 @@ DEPENDENCIES telephone_number test-unit therubyracer + twilio-ruby twitter uglifier unicorn diff --git a/app/assets/javascripts/app/controllers/_application_controller_form.coffee b/app/assets/javascripts/app/controllers/_application_controller_form.coffee index 79f6ed2e8..81651486f 100644 --- a/app/assets/javascripts/app/controllers/_application_controller_form.coffee +++ b/app/assets/javascripts/app/controllers/_application_controller_form.coffee @@ -25,7 +25,8 @@ class App.ControllerForm extends App.Controller @form = @formGen() # add alert placeholder - @form.prepend('') + @form.prepend('') + @form.prepend('') # if element is given, prepend form to it if @el diff --git a/app/assets/javascripts/app/controllers/_application_controller_generic.coffee b/app/assets/javascripts/app/controllers/_application_controller_generic.coffee index 2f9bff2fc..c7c2b49da 100644 --- a/app/assets/javascripts/app/controllers/_application_controller_generic.coffee +++ b/app/assets/javascripts/app/controllers/_application_controller_generic.coffee @@ -295,15 +295,15 @@ class App.ControllerGenericDestroyConfirm extends App.ControllerModal App.i18n.translateContent('Sure to delete this object?') onSubmit: => - @item.destroy( - done: => - @close() - if @callback - @callback() - fail: => - @log 'errors' - @close() - ) + options = @options || {} + options.done = => + @close() + if @callback + @callback() + options.fail = => + @log 'errors' + @close() + @item.destroy(options) class App.ControllerConfirm extends App.ControllerModal buttonClose: true diff --git a/app/assets/javascripts/app/controllers/_channel/email.coffee b/app/assets/javascripts/app/controllers/_channel/email.coffee index 6889e4c14..4d7f54740 100644 --- a/app/assets/javascripts/app/controllers/_channel/email.coffee +++ b/app/assets/javascripts/app/controllers/_channel/email.coffee @@ -67,7 +67,7 @@ class App.ChannelEmailFilter extends App.Controller container: @el.closest('.content') callback: @load ) - + edit: (id, e) => e.preventDefault() new App.ControllerGenericEdit( diff --git a/app/assets/javascripts/app/controllers/_channel/sms.coffee b/app/assets/javascripts/app/controllers/_channel/sms.coffee new file mode 100644 index 000000000..13091c844 --- /dev/null +++ b/app/assets/javascripts/app/controllers/_channel/sms.coffee @@ -0,0 +1,442 @@ +class App.ChannelSms extends App.ControllerTabs + requiredPermission: 'admin.channel_sms' + header: 'SMS' + constructor: -> + super + + @title 'SMS', true + @tabs = [ + { + name: 'Accounts', + target: 'c-account', + controller: App.ChannelSmsAccountOverview, + }, + ] + + @render() + +class App.ChannelSmsAccountOverview extends App.Controller + events: + 'click .js-channelEdit': 'change' + 'click .js-channelDelete': 'delete' + 'click .js-channelDisable': 'disable' + 'click .js-channelEnable': 'enable' + 'click .js-editNotification': 'editNotification' + + constructor: -> + super + @interval(@load, 30000) + #@load() + + load: => + + @startLoading() + + @ajax( + id: 'sms_index' + type: 'GET' + url: "#{@apiPath}/channels_sms" + processData: true + success: (data, status, xhr) => + @config = data.config + @stopLoading() + App.Collection.loadAssets(data.assets) + @render(data) + ) + + render: (data = {}) => + + @channelDriver = data.channel_driver + + # get channels + @account_channels = [] + for channel_id in data.account_channel_ids + account_channel = App.Channel.fullLocal(channel_id) + if account_channel.group_id + account_channel.group = App.Group.find(account_channel.group_id) + else + account_channel.group = '-' + @account_channels.push account_channel + + # get channels + @notification_channels = [] + for channel_id in data.notification_channel_ids + @notification_channels.push App.Channel.find(channel_id) + + @html App.view('channel/sms_account_overview')( + account_channels: @account_channels + notification_channels: @notification_channels + config: @config + ) + + change: (e) => + e.preventDefault() + id = $(e.target).closest('.action').data('id') + if !id + channel = new App.Channel(active: true) + else + channel = App.Channel.find(id) + new App.ChannelSmsAccount( + container: @el.closest('.content') + channel: channel + callback: @load + channelDriver: @channelDriver + config: @config + ) + + delete: (e) => + e.preventDefault() + id = $(e.target).closest('.action').data('id') + channel = App.Channel.find(id) + new App.ControllerGenericDestroyConfirm( + item: channel + options: + url: "/api/v1/channels_sms/#{channel.id}" + container: @el.closest('.content') + callback: => + @load() + ) + + disable: (e) => + e.preventDefault() + id = $(e.target).closest('.action').data('id') + @ajax( + id: 'sms_disable' + type: 'POST' + url: "#{@apiPath}/channels_sms_disable" + data: JSON.stringify(id: id) + processData: true + success: => + @load() + ) + + enable: (e) => + e.preventDefault() + id = $(e.target).closest('.action').data('id') + @ajax( + id: 'sms_enable' + type: 'POST' + url: "#{@apiPath}/channels_sms_enable" + data: JSON.stringify(id: id) + processData: true + success: => + @load() + ) + + editNotification: (e) => + e.preventDefault() + id = $(e.target).closest('.action').data('id') + channel = App.Channel.find(id) + new App.ChannelSmsNotification( + container: @el.closest('.content') + channel: channel + callback: @load + channelDriver: @channelDriver + config: @config + ) + +class App.ChannelSmsAccount extends App.ControllerModal + head: 'SMS Account' + buttonCancel: true + centerButtons: [ + { + text: 'Test' + className: 'js-test' + } + ] + elements: + 'form': 'form' + 'select[name="options::adapter"]': 'adapterSelect' + events: + 'click .js-test': 'onTest' + + content: -> + el = $('
') + + # form + options = {} + currentConfig = {} + for config in @config + if config.account + options[config.adapter] = config.name + + form = new App.ControllerForm( + el: el.find('.js-channelAdapterSelector') + model: + configure_attributes: [ + { name: 'options::adapter', display: 'Provider', tag: 'select', null: false, options: options, nulloption: true } + ] + className: '' + params: @channel + ) + @renderAdapterOptions(@channel.options?.adapter, el) + el.find('[name="options::adapter"]').bind('change', (e) => + @renderAdapterOptions(e.target.value, el) + ) + el + + renderAdapterOptions: (adapter, el) -> + el.find('.js-channelWebhook').html('') + el.find('.js-channelAdapterOptions').html('') + + currentConfig = {} + for configuration in @config + if configuration.adapter is adapter + if configuration.account + currentConfig = configuration.account + return if _.isEmpty(currentConfig) + + if _.isEmpty(@channel.options) || _.isEmpty(@channel.options.webhook_token) + @channel.options ||= {} + @channel.options.webhook_token = '?' + for localCurrentConfig in currentConfig + if localCurrentConfig.name is 'options::webhook_token' + @channel.options.webhook_token = localCurrentConfig.default + + webhook = "#{@Config.get('http_type')}://#{@Config.get('fqdn')}/api/v1/sms_webhook/#{@channel.options?.webhook_token}" + new App.ControllerForm( + el: el.find('.js-channelWebhook') + model: + configure_attributes: [ + { name: 'options::webhook', display: 'Webhook', tag: 'input', type: 'text', limit: 200, null: false, default: webhook, disabled: true }, + ] + className: '' + params: @channel + ) + + new App.ControllerForm( + el: el.find('.js-channelAdapterOptions') + model: + configure_attributes: currentConfig + className: '' + params: @channel + ) + + onDelete: => + if @channel.isNew() is true + @close() + @callback() + return + + new App.ControllerGenericDestroyConfirm( + item: @channel + options: + url: "/api/v1/channels_sms/#{@channel.id}" + container: @el.closest('.content') + callback: => + @close() + @callback() + ) + + onSubmit: (e) -> + e.preventDefault() + + if @adapterSelect.val() is '' + @onDelete() + return + + @formDisable(e) + + @channel.options ||= {} + for key, value of @formParam(@el) + if key is 'options' + for optionsKey, optionsValue of value + @channel.options ||= {} + @channel.options[optionsKey] = optionsValue + else + @channel[key] = value + @channel.area = 'Sms::Account' + + url = '/api/v1/channels_sms' + if !@channel.isNew() + url = "/api/v1/channels_sms/#{@channel.id}" + + ui = @ + @channel.save( + url: url + done: -> + ui.formEnable(e) + ui.channel = App.Channel.find(@id) + ui.close() + ui.callback() + fail: (settings, details) -> + ui.log 'errors', details + ui.formEnable(e) + ui.showAlert(details.error_human || details.error || 'Unable to update object!') + ) + + onTest: (e) -> + e.preventDefault() + new TestModal( + channel: @formParam(@el) + container: @el.closest('.content') + ) + +class App.ChannelSmsNotification extends App.ControllerModal + head: 'SMS Notification' + buttonCancel: true + centerButtons: [ + { + text: 'Test' + className: 'js-test' + } + ] + elements: + 'form': 'form' + 'select[name="options::adapter"]': 'adapterSelect' + events: + 'click .js-test': 'onTest' + + content: -> + el = $('
') + if !@channel + @channel = new App.Channel(active: true) + + # form + options = {} + currentConfig = {} + for config in @config + if config.notification + options[config.adapter] = config.name + + form = new App.ControllerForm( + el: el.find('.js-channelAdapterSelector') + model: + configure_attributes: [ + { name: 'options::adapter', display: 'Provider', tag: 'select', null: false, options: options, nulloption: true } + ] + className: '' + params: @channel + ) + @renderAdapterOptions(@channel.options?.adapter, el) + el.find('[name="options::adapter"]').bind('change', (e) => + @renderAdapterOptions(e.target.value, el) + ) + el + + renderAdapterOptions: (adapter, el) -> + el.find('.js-channelAdapterOptions').html('') + + currentConfig = {} + for configuration in @config + if configuration.adapter is adapter + if configuration.notification + currentConfig = configuration.notification + return if _.isEmpty(currentConfig) + + new App.ControllerForm( + el: el.find('.js-channelAdapterOptions') + model: + configure_attributes: currentConfig + className: '' + params: @channel + ) + + onDelete: => + if @channel.isNew() is true + @close() + @callback() + return + + new App.ControllerGenericDestroyConfirm( + item: @channel + options: + url: "/api/v1/channels_sms/#{@channel.id}" + container: @el.closest('.content') + callback: => + @close() + @callback() + ) + + onSubmit: (e) -> + e.preventDefault() + + if @adapterSelect.val() is '' + @onDelete() + return + + @formDisable(e) + + @channel.options ||= {} + for key, value of @formParam(@el) + @channel[key] = value + @channel.area = 'Sms::Notification' + + url = '/api/v1/channels_sms' + if !@channel.isNew() + url = "/api/v1/channels_sms/#{@channel.id}" + ui = @ + @channel.save( + url: url + done: -> + ui.formEnable(e) + ui.channel = App.Channel.find(@id) + ui.close() + ui.callback() + fail: (settings, details) -> + ui.log 'errors', details + ui.formEnable(e) + ui.showAlert(details.error_human || details.error || 'Unable to update object!') + ) + + onTest: (e) -> + e.preventDefault() + new TestModal( + channel: @formParam(@el) + container: @el.closest('.content') + ) + +class TestModal extends App.ControllerModal + head: 'Test SMS provider' + buttonCancel: true + + content: -> + form = new App.ControllerForm( + model: + configure_attributes: [ + { name: 'recipient', display: 'Recipient', tag: 'input', null: false } + { name: 'message', display: 'Message', tag: 'input', null: false, default: 'Test message from Zammad' } + ] + className: '' + ) + form.form + + T: (name) -> + App.i18n.translateInline(name) + + submit: (e) -> + super(e) + + @el.find('.js-danger').addClass('hide') + @el.find('.js-success').addClass('hide') + @formDisable(@el) + + testData = _.extend( + @formParam(e.currentTarget), + options: @channel.options + ) + + @ajax( + type: 'POST' + url: "#{@apiPath}/channels_sms/test" + data: JSON.stringify(testData) + processData: true + success: (data) => + @formEnable(@el) + if error_text = (data.error || data.error_human) + @el.find('.js-danger') + .text(@T(error_text)) + .removeClass('hide') + else + @el.find('.js-success') + .text(@T('SMS successfully sent')) + .removeClass('hide') + error: (xhr) => + data = JSON.parse(xhr.responseText) + @formEnable(@el) + @el.find('.js-danger') + .text(@T(data.error || 'Unable to perform test')) + .removeClass('hide') + ) + +App.Config.set('SMS', { prio: 3100, name: 'SMS', parent: '#channels', target: '#channels/sms', controller: App.ChannelSms, permission: ['admin.channel_sms'] }, 'NavBarAdmin') diff --git a/app/assets/javascripts/app/controllers/_ui_element/ticket_perform_action.coffee b/app/assets/javascripts/app/controllers/_ui_element/ticket_perform_action.coffee index f9ac78d3e..b1ff84895 100644 --- a/app/assets/javascripts/app/controllers/_ui_element/ticket_perform_action.coffee +++ b/app/assets/javascripts/app/controllers/_ui_element/ticket_perform_action.coffee @@ -18,6 +18,7 @@ class App.UiElement.ticket_perform_action for groupKey, groupMeta of groups if !groupMeta.model || !App[groupMeta.model] elements["#{groupKey}.email"] = { name: 'email', display: 'Email' } + elements["#{groupKey}.sms"] = { name: 'sms', display: 'SMS' } else for row in App[groupMeta.model].configure_attributes @@ -161,9 +162,11 @@ class App.UiElement.ticket_perform_action if groupAndAttribute elementRow.find('.js-attributeSelector select').val(groupAndAttribute) - if groupAndAttribute is 'notification.email' + notificationTypeMatch = groupAndAttribute.match(/^notification.([\w]+)$/) + + if _.isArray(notificationTypeMatch) && notificationType = notificationTypeMatch[1] elementRow.find('.js-setAttribute').html('') - @buildRecipientList(elementFull, elementRow, groupAndAttribute, elements, meta, attribute) + @buildRecipientList(notificationType, elementFull, elementRow, groupAndAttribute, elements, meta, attribute) else elementRow.find('.js-setNotification').html('') if !elementRow.find('.js-setAttribute div').get(0) @@ -304,9 +307,11 @@ class App.UiElement.ticket_perform_action elementRow.find('.js-value').removeClass('hide').html(item) - @buildRecipientList: (elementFull, elementRow, groupAndAttribute, elements, meta, attribute) -> + @buildRecipientList: (notificationType, elementFull, elementRow, groupAndAttribute, elements, meta, attribute) -> - return if elementRow.find('.js-setNotification .js-body').get(0) + return if elementRow.find(".js-setNotification .js-body-#{notificationType}").get(0) + + elementRow.find('.js-setNotification').empty() options = 'article_last_sender': 'Article Last Sender' @@ -314,7 +319,11 @@ class App.UiElement.ticket_perform_action 'ticket_customer': 'Customer' 'ticket_agents': 'All Agents' - name = "#{attribute.name}::notification.email" + name = "#{attribute.name}::notification.#{notificationType}" + + messageLength = switch notificationType + when 'sms' then 160 + else 200000 # meta.recipient was a string in the past (single-select) so we convert it to array if needed if !_.isArray(meta.recipient) @@ -338,13 +347,14 @@ class App.UiElement.ticket_perform_action notificationElement = $( App.view('generic/ticket_perform_action/notification_email')( attribute: attribute name: name + notificationType: notificationType meta: meta || {} )) notificationElement.find('.js-recipient select').replaceWith(selection) notificationElement.find('.js-body div[contenteditable="true"]').ce( mode: 'richtext' placeholder: 'message' - maxlength: 200000 + maxlength: messageLength ) new App.WidgetPlaceholder( el: notificationElement.find('.js-body div[contenteditable="true"]').parent() diff --git a/app/assets/javascripts/app/controllers/agent_ticket_create.coffee b/app/assets/javascripts/app/controllers/agent_ticket_create.coffee index d16d113d1..f6257e467 100644 --- a/app/assets/javascripts/app/controllers/agent_ticket_create.coffee +++ b/app/assets/javascripts/app/controllers/agent_ticket_create.coffee @@ -380,7 +380,7 @@ class App.TicketCreate extends App.Controller @tokanice() tokanice: -> - App.Utils.tokaniceEmails('.content.active input[name=cc]') + App.Utils.tokanice('.content.active input[name=cc]', 'email') localUserInfo: (e) => return if !@sidebarWidget diff --git a/app/assets/javascripts/app/controllers/ticket_zoom/article_action/sms_reply.coffee b/app/assets/javascripts/app/controllers/ticket_zoom/article_action/sms_reply.coffee new file mode 100644 index 000000000..016bc8af5 --- /dev/null +++ b/app/assets/javascripts/app/controllers/ticket_zoom/article_action/sms_reply.coffee @@ -0,0 +1,79 @@ +class SmsReply + @action: (actions, ticket, article, ui) -> + return actions if ui.permissionCheck('ticket.customer') + + if article.sender.name is 'Customer' && article.type.name is 'sms' + actions.push { + name: 'reply' + type: 'smsMessageReply' + icon: 'reply' + href: '#' + } + + actions + + @perform: (articleContainer, type, ticket, article, ui) -> + return true if type isnt 'smsMessageReply' + + ui.scrollToCompose() + + # get reference article + type = App.TicketArticleType.find(article.type_id) + + articleNew = { + to: article.from + cc: '' + body: '' + in_reply_to: '' + } + + if article.message_id + articleNew.in_reply_to = article.message_id + + # get current body + articleNew.body = ui.el.closest('.ticketZoom').find('.article-add [data-name="body"]').html().trim() || '' + + App.Event.trigger('ui::ticket::setArticleType', { + ticket: ticket + type: type + article: articleNew + position: 'end' + }) + + true + + @articleTypes: (articleTypes, ticket, ui) -> + return articleTypes if !ui.permissionCheck('ticket.agent') + + return articleTypes if !ticket || !ticket.create_article_type_id + + articleTypeCreate = App.TicketArticleType.find(ticket.create_article_type_id).name + + return articleTypes if articleTypeCreate isnt 'sms' + articleTypes.push { + name: 'sms' + icon: 'sms' + attributes: ['to'] + internal: false, + features: ['body:limit'] + maxTextLength: 160 + warningTextLength: 30 + } + articleTypes + + @setArticleTypePost: (type, ticket, ui) -> + return if type isnt 'telegram personal-message' + rawHTML = ui.$('[data-name=body]').html() + cleanHTML = App.Utils.htmlRemoveRichtext(rawHTML) + if cleanHTML && cleanHTML.html() != rawHTML + ui.$('[data-name=body]').html(cleanHTML) + + @params: (type, params, ui) -> + if type is 'sms' + App.Utils.htmlRemoveRichtext(ui.$('[data-name=body]'), false) + params.content_type = 'text/plain' + params.body = App.Utils.html2text(params.body, true) + + params + +App.Config.set('300-SmsReply', SmsReply, 'TicketZoomArticleAction') diff --git a/app/assets/javascripts/app/controllers/ticket_zoom/article_new.coffee b/app/assets/javascripts/app/controllers/ticket_zoom/article_new.coffee index d24dc366d..50805f372 100644 --- a/app/assets/javascripts/app/controllers/ticket_zoom/article_new.coffee +++ b/app/assets/javascripts/app/controllers/ticket_zoom/article_new.coffee @@ -98,7 +98,7 @@ class App.TicketZoomArticleNew extends App.Controller # set expand of text area only once @bind('ui::ticket::shown', (data) => return if data.ticket_id.toString() isnt @ticket.id.toString() - @tokanice() + @tokanice(@type) ) # rerender, e. g. on language change @@ -106,8 +106,8 @@ class App.TicketZoomArticleNew extends App.Controller @render() ) - tokanice: -> - App.Utils.tokaniceEmails('.content.active .js-to, .js-cc, js-bcc') + tokanice: (type = 'email') -> + App.Utils.tokanice('.content.active .js-to, .js-cc, js-bcc', type) setPossibleArticleTypes: => @articleTypes = [] @@ -163,7 +163,7 @@ class App.TicketZoomArticleNew extends App.Controller position: 'right' ) - @tokanice() + @tokanice(@type) @$('[data-name="body"]').ce({ mode: 'richtext' @@ -346,7 +346,7 @@ class App.TicketZoomArticleNew extends App.Controller @setArticleTypePost(articleTypeToSet) $(window).off('click.ticket-zoom-select-type') - @tokanice() + @tokanice(articleTypeToSet) hideSelectableArticleType: => @el.find('.js-articleTypes').addClass('is-hidden') diff --git a/app/assets/javascripts/app/index.coffee b/app/assets/javascripts/app/index.coffee index 64e60859c..5395640dd 100644 --- a/app/assets/javascripts/app/index.coffee +++ b/app/assets/javascripts/app/index.coffee @@ -44,6 +44,7 @@ class App extends Spine.Controller App.Utils.decimal(data, positions) # define mask helper + # mask an value like 'a***********yz' M: (item, start = 1, end = 2) -> return '' if !item string = '' diff --git a/app/assets/javascripts/app/lib/app_post/utils.coffee b/app/assets/javascripts/app/lib/app_post/utils.coffee index 62353dd1c..e7392119b 100644 --- a/app/assets/javascripts/app/lib/app_post/utils.coffee +++ b/app/assets/javascripts/app/lib/app_post/utils.coffee @@ -1121,7 +1121,7 @@ class App.Utils articleNew # apply email token field with autocompletion - @tokaniceEmails: (selector) -> + @tokanice: (selector, type) -> source = "#{App.Config.get('api_path')}/users/search" a = -> $(selector).tokenfield( @@ -1131,7 +1131,7 @@ class App.Utils minLength: 2 }, ).on('tokenfield:createtoken', (e) -> - if !e.attrs.value.match(/@/) || e.attrs.value.match(/\s/) + if type is 'email' && !e.attrs.value.match(/@/) || e.attrs.value.match(/\s/) e.preventDefault() return false e.attrs.label = e.attrs.value diff --git a/app/assets/javascripts/app/models/channel.coffee b/app/assets/javascripts/app/models/channel.coffee index 301f00451..3a53e5118 100644 --- a/app/assets/javascripts/app/models/channel.coffee +++ b/app/assets/javascripts/app/models/channel.coffee @@ -1,5 +1,5 @@ class App.Channel extends App.Model - @configure 'Channel', 'adapter', 'area', 'options', 'group_id', 'active', 'updated_at' + @configure 'Channel', 'adapter', 'area', 'options', 'group_id', 'active' @extend Spine.Model.Ajax @url: @apiPath + '/channels' diff --git a/app/assets/javascripts/app/views/channel/_header.jst.eco b/app/assets/javascripts/app/views/channel/_header.jst.eco new file mode 100644 index 000000000..64cb11e75 --- /dev/null +++ b/app/assets/javascripts/app/views/channel/_header.jst.eco @@ -0,0 +1,11 @@ + diff --git a/app/assets/javascripts/app/views/channel/sms_account_overview.jst.eco b/app/assets/javascripts/app/views/channel/sms_account_overview.jst.eco new file mode 100644 index 000000000..b0b4630a1 --- /dev/null +++ b/app/assets/javascripts/app/views/channel/sms_account_overview.jst.eco @@ -0,0 +1,130 @@ +

<%- @T('SMS Accounts') %>

+ +<% if _.isEmpty(@account_channels): %> +

<%- @T('You have no configured account right now.') %>

+<% else: %> + <% for channel in @account_channels: %> +
+
+
+
+

<%- @Icon('status', channel.status_in + " inline") %> <%- @T('Inbound') %>

+
+ <% if !_.isEmpty(channel.last_log_in): %> +
+ <%= channel.last_log_in %> +
+ <% end %> +
+ +
+
+

<%- @Icon('status', channel.status_out + " inline") %> <%- @T('Outbound') %>

+
+ <% if !_.isEmpty(channel.last_log_out): %> +
+ <%= channel.last_log_out %> +
+ <% end %> + +
+
+ +
+
+ + + + + + + +
<%- @T('Adapter') %> + <%= channel.options.adapter %> +
<%- @T('Webhook') %> + <%= @C('http_type') %>://<%= @C('fqdn') %>/api/v1/sms_webhook/<%= channel.options?.webhook_token || '?' %> +
<%- @T('Account') %> + <%= channel.options.account_id %> +
<%- @T('Token') %> + <%= @M(channel.options.token, 1, 2) %> +
<%- @T('Sender') %> + <%= channel.options.sender %> +
<%- @T('Group') %> + +
+ <% groupName = '' %> + <% if channel.group.displayName: %> + <% groupName = channel.group.displayName() %> + <% else: %> + <% groupName = channel.group %> + <% end %> + <% if channel.group.active is false: %> + <%- @T('%s is inactive, please select a active one.', groupName) %> + <% else: %> + <%= groupName %> + <% end %> +
+
+
+
+ +
+
<%- @T('Edit') %>
+ <% if channel.active is true: %> +
<%- @T('Disable') %>
+ <% else: %> +
<%- @T('Enable') %>
+ <% end %> +
<%- @T('Delete') %>
+
+
+ <% end %> +<% end %> + +<%- @T('New') %> + +

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

+<% if _.isEmpty(@notification_channels): %> +

<%- @T('You have no configured account right now.') %>

+ <%- @T('New') %> +<% else: %> + <% for channel in @notification_channels: %> +
+
+
+
+

<%- @Icon('status', channel.status_out + " inline") %> <%- @T('Outbound') %>

+
+ <% if channel.status_in is 'error': %> +
<%= channel.last_log_in %>
+ <% end %> + <% if channel.status_out is 'error': %> +
<%= channel.last_log_out %>
+ <% end %> + + <% if channel.options: %> + + + +
<%- @T('Adapter') %> + <%= channel.options.adapter %> +
<%- @T('Token') %> + <%= @M(channel.options.token, 1, 2) %> + <% end %> +
<%- @T('Sender') %> + <%= channel.options.sender %> +
+
+
+
+
<%- @T('Edit') %>
+ <% if channel.active is true: %> +
<%- @T('Disable') %>
+ <% else: %> +
<%- @T('Enable') %>
+ <% end %> +
<%- @T('Delete') %>
+
+
+ <% end %> +<% end %> diff --git a/app/assets/javascripts/app/views/generic/ticket_perform_action/notification_email.jst.eco b/app/assets/javascripts/app/views/generic/ticket_perform_action/notification_email.jst.eco index 72ad0c5c7..d81224181 100644 --- a/app/assets/javascripts/app/views/generic/ticket_perform_action/notification_email.jst.eco +++ b/app/assets/javascripts/app/views/generic/ticket_perform_action/notification_email.jst.eco @@ -3,6 +3,8 @@ <%- @Icon('arrow-down', 'dropdown-arrow') %> -
-
<%- @meta.body %>
+ <% if @notificationType is 'email': %> +
+ <% end %> +
<%- @meta.body %>
diff --git a/app/assets/stylesheets/svg-dimensions.css b/app/assets/stylesheets/svg-dimensions.css index 3e9c6bfd8..0273b7a2d 100644 --- a/app/assets/stylesheets/svg-dimensions.css +++ b/app/assets/stylesheets/svg-dimensions.css @@ -55,7 +55,9 @@ .icon-lock { width: 16px; height: 17px; } .icon-logo { width: 42px; height: 36px; } .icon-logotype { width: 91px; height: 15px; } +.icon-long-arrow-down { width: 11px; height: 11px; } .icon-long-arrow-right { width: 11px; height: 11px; } +.icon-low-priority { width: 16px; height: 16px; } .icon-magnifier { width: 15px; height: 15px; } .icon-marker { width: 17px; height: 19px; } .icon-message { width: 24px; height: 24px; } @@ -94,6 +96,7 @@ .icon-searchdetail { width: 18px; height: 14px; } .icon-signout { width: 15px; height: 19px; } .icon-small-dot { width: 16px; height: 16px; } +.icon-sms { width: 17px; height: 17px; } .icon-split { width: 16px; height: 17px; } .icon-status-modified-outer-circle { width: 16px; height: 16px; } .icon-status { width: 16px; height: 16px; } diff --git a/app/assets/stylesheets/zammad.scss b/app/assets/stylesheets/zammad.scss index 2bb00cbe2..8972b4666 100644 --- a/app/assets/stylesheets/zammad.scss +++ b/app/assets/stylesheets/zammad.scss @@ -2240,6 +2240,10 @@ kbd { .help-block { color: hsl(198,19%,72%); } + .content-controls-align-right { + display: flex; + justify-content: flex-end; + } } .page-description p { @@ -7790,6 +7794,10 @@ output { border-color: hsl(210,10%,85%); } + &[disabled] + label:after { + background: hsl(210,17%,97%); + } + &:focus + label { transition: none; background: hsl(200,71%,59%); diff --git a/app/controllers/application_channel_controller.rb b/app/controllers/application_channel_controller.rb new file mode 100644 index 000000000..927528f9b --- /dev/null +++ b/app/controllers/application_channel_controller.rb @@ -0,0 +1,47 @@ +class ApplicationChannelController < ApplicationController + # Extending controllers has to define following constants: + # PERMISSION = "admin.channel_xyz" + # AREA = "XYZ::Account" + + def index + render json: channels_data + end + + def show + model_show_render(Channel, params) + end + + def create + model_create_render(Channel, channel_params) + end + + def update + model_update_render(Channel, channel_params) + end + + def enable + channel.update!(active: true) + render json: channel + end + + def disable + channel.update!(active: false) + render json: channel + end + + def destroy + channel.destroy! + render json: {} + end + + private + + def channel + @channel ||= Channel.lookup(id: params[:id]) + end + + def channel_params + params = params.permit!.to_s + end + +end diff --git a/app/controllers/channels_sms_controller.rb b/app/controllers/channels_sms_controller.rb new file mode 100644 index 000000000..15fc3e747 --- /dev/null +++ b/app/controllers/channels_sms_controller.rb @@ -0,0 +1,86 @@ +class ChannelsSmsController < ApplicationChannelController + PERMISSION = 'admin.channel_sms'.freeze + AREA = ['Sms::Notification'.freeze, 'Sms::Account'.freeze].freeze + + prepend_before_action -> { authentication_check(permission: self.class::PERMISSION) }, except: [:webhook] + skip_before_action :verify_csrf_token, only: [:webhook] + + def index + assets = {} + render json: { + account_channel_ids: channels_data('Sms::Account', assets), + notification_channel_ids: channels_data('Sms::Notification', assets), + config: channels_config, + assets: assets + } + end + + def test + raise 'Missing parameter options.adapter' if params[:options][:adapter].blank? + + driver = Channel.driver_class(params[:options][:adapter]) + resp = driver.new.send(params[:options], test_options) + + render json: { success: resp } + rescue => e + render json: { error: e.inspect, error_human: e.message } + end + + def webhook + raise Exceptions::UnprocessableEntity, 'token param missing' if params['token'].blank? + + channel = nil + Channel.where(active: true, area: 'Sms::Account').each do |local_channel| + next if local_channel.options[:webhook_token] != params['token'] + + channel = local_channel + end + if !channel + render( + json: { message: 'channel not found' }, + status: :not_found + ) + return + end + + conten_type, content = channel.process(params.permit!.to_h) + send_data content, type: conten_type + end + + private + + def test_options + params.permit(:recipient, :message) + end + + def channel_params + raise 'Missing area params' if params[:area].blank? + if !self.class::AREA.include?(params[:area]) + raise "Invalid area '#{params[:area]}'!" + end + raise 'Missing options params' if params[:options].blank? + raise 'Missing options.adapter params' if params[:options][:adapter].blank? + + params + end + + def channels_config + list = [] + Dir.glob(Rails.root.join('app', 'models', 'channel', 'driver', 'sms', '*.rb')).each do |path| + filename = File.basename(path) + require_dependency "channel/driver/sms/#{filename.sub('.rb', '')}" + list.push Channel.driver_class("sms/#{filename}").definition + end + list + end + + def channels_data(area, assets) + channel_ids = [] + Channel.where(area: area).each do |channel| + assets = channel.assets(assets) + channel_ids.push(channel.id) + end + channel_ids + end + +end diff --git a/app/controllers/monitoring_controller.rb b/app/controllers/monitoring_controller.rb index ac7787a7e..55aa22b0f 100644 --- a/app/controllers/monitoring_controller.rb +++ b/app/controllers/monitoring_controller.rb @@ -216,6 +216,7 @@ curl http://localhost/api/v1/monitoring/status?token=XXX overviews: Overview, tickets: Ticket, ticket_articles: Ticket::Article, + text_modules: TextModule, } map.each do |key, class_name| status[:counts][key] = class_name.count diff --git a/app/models/channel.rb b/app/models/channel.rb index 401e61502..ef0c43e9a 100644 --- a/app/models/channel.rb +++ b/app/models/channel.rb @@ -49,13 +49,7 @@ fetch one account end begin - # we need to require each channel backend individually otherwise we get a - # 'warning: toplevel constant Twitter referenced by Channel::Driver::Twitter' error e.g. - # so we have to convert the channel name to the filename via Rails String.underscore - # http://stem.ps/rails/2015/01/25/ruby-gotcha-toplevel-constant-referenced-by.html - require "channel/driver/#{adapter.to_filename}" - - driver_class = Object.const_get("Channel::Driver::#{adapter.to_classname}") + driver_class = self.class.driver_class(adapter) driver_instance = driver_class.new return if !force && !driver_instance.fetchable?(self) @@ -93,13 +87,7 @@ stream instance of account adapter = options[:adapter] begin - # we need to require each channel backend individually otherwise we get a - # 'warning: toplevel constant Twitter referenced by Channel::Driver::Twitter' error e.g. - # so we have to convert the channel name to the filename via Rails String.underscore - # http://stem.ps/rails/2015/01/25/ruby-gotcha-toplevel-constant-referenced-by.html - require "channel/driver/#{adapter.to_filename}" - - driver_class = Object.const_get("Channel::Driver::#{adapter.to_classname}") + driver_class = self.class.driver_class(adapter) driver_instance = driver_class.new # check is stream exists @@ -145,7 +133,7 @@ stream all accounts adapter = channel.options[:adapter] next if adapter.blank? - driver_class = Object.const_get("Channel::Driver::#{adapter.to_classname}") + driver_class = self.driver_class(adapter) next if !driver_class.respond_to?(:streamable?) next if !driver_class.streamable? @@ -250,31 +238,22 @@ stream all accounts send via account channel = Channel.where(area: 'Email::Account').first - channel.deliver(mail_params, notification) + channel.deliver(params, notification) =end - def deliver(mail_params, notification = false) - + def deliver(params, notification = false) adapter = options[:adapter] adapter_options = options if options[:outbound] && options[:outbound][:adapter] adapter = options[:outbound][:adapter] adapter_options = options[:outbound][:options] end - result = nil - begin - # we need to require each channel backend individually otherwise we get a - # 'warning: toplevel constant Twitter referenced by Channel::Driver::Twitter' error e.g. - # so we have to convert the channel name to the filename via Rails String.underscore - # http://stem.ps/rails/2015/01/25/ruby-gotcha-toplevel-constant-referenced-by.html - require "channel/driver/#{adapter.to_filename}" - - driver_class = Object.const_get("Channel::Driver::#{adapter.to_classname}") + driver_class = self.class.driver_class(adapter) driver_instance = driver_class.new - result = driver_instance.send(adapter_options, mail_params, notification) + result = driver_instance.send(adapter_options, params, notification) self.status_out = 'ok' self.last_log_out = '' save! @@ -290,6 +269,72 @@ send via account result end +=begin + +process via account + + channel = Channel.where(area: 'Email::Account').first + channel.process(params) + +=end + + def process(params) + adapter = options[:adapter] + adapter_options = options + if options[:inbound] && options[:inbound][:adapter] + adapter = options[:inbound][:adapter] + adapter_options = options[:inbound][:options] + end + result = nil + begin + driver_class = self.class.driver_class(adapter) + driver_instance = driver_class.new + result = driver_instance.process(adapter_options, params, self) + self.status_in = 'ok' + self.last_log_in = '' + save! + rescue => e + error = "Can't use Channel::Driver::#{adapter.to_classname}: #{e.inspect}" + logger.error error + logger.error e.backtrace + self.status_in = 'error' + self.last_log_in = error + save! + raise e, error + end + result + end + +=begin + +load channel driver and return class + + klass = Channel.driver_class('Imap') + +=end + + def self.driver_class(adapter) + # we need to require each channel backend individually otherwise we get a + # 'warning: toplevel constant Twitter referenced by Channel::Driver::Twitter' error e.g. + # so we have to convert the channel name to the filename via Rails String.underscore + # http://stem.ps/rails/2015/01/25/ruby-gotcha-toplevel-constant-referenced-by.html + require "channel/driver/#{adapter.to_filename}" + + Object.const_get("::Channel::Driver::#{adapter.to_classname}") + end + +=begin + +get instance of channel driver + + channel.driver_instance + +=end + + def driver_instance + self.class.driver_class(options[:adapter]) + end + private def email_address_check diff --git a/app/models/channel/driver/sms/massenversand.rb b/app/models/channel/driver/sms/massenversand.rb new file mode 100644 index 000000000..ae954bcb1 --- /dev/null +++ b/app/models/channel/driver/sms/massenversand.rb @@ -0,0 +1,51 @@ +class Channel::Driver::Sms::Massenversand + NAME = 'sms/massenversand'.freeze + + def send(options, attr, _notification = false) + Rails.logger.info "Sending SMS to recipient #{attr[:recipient]}" + + return true if Setting.get('import_mode') + + Rails.logger.info "Backend sending Massenversand SMS to #{attr[:recipient]}" + begin + url = build_url(options, attr) + + if Setting.get('developer_mode') != true + response = Faraday.get(url).body + raise response if !response.match?('OK') + end + + true + rescue => e + Rails.logger.debug "Massenversand error: #{e.inspect}" + raise e + end + end + + def self.definition + { + name: 'Massenversand', + adapter: 'sms/massenversand', + notification: [ + { name: 'options::gateway', display: 'Gateway', tag: 'input', type: 'text', limit: 200, null: false, placeholder: 'https://gate1.goyyamobile.com/sms/sendsms.asp', default: 'https://gate1.goyyamobile.com/sms/sendsms.asp' }, + { name: 'options::token', display: 'Token', tag: 'input', type: 'text', limit: 200, null: false, placeholder: 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' }, + { name: 'options::sender', display: 'Sender', tag: 'input', type: 'text', limit: 200, null: false, placeholder: '00491710000000' }, + ] + } + end + + private + + def build_url(options, attr) + params = { + authToken: options[:token], + getID: 1, + msg: attr[:message], + msgtype: 'c', + receiver: attr[:recipient], + sender: options[:sender] + } + + options[:gateway] + '?' + URI.encode_www_form(params) + end +end diff --git a/app/models/channel/driver/sms/twilio.rb b/app/models/channel/driver/sms/twilio.rb new file mode 100644 index 000000000..b302d8875 --- /dev/null +++ b/app/models/channel/driver/sms/twilio.rb @@ -0,0 +1,147 @@ +class Channel::Driver::Sms::Twilio + NAME = 'sms/twilio'.freeze + + def fetchable?(_channel) + false + end + + def send(options, attr, _notification = false) + Rails.logger.info "Sending SMS to recipient #{attr[:recipient]}" + + return true if Setting.get('import_mode') + + Rails.logger.info "Backend sending Twilio SMS to #{attr[:recipient]}" + begin + if Setting.get('developer_mode') != true + result = api(options).messages.create( + from: options[:sender], + to: attr[:recipient], + body: attr[:message], + ) + + raise result.error_message if result.error_code.positive? + end + + true + rescue => e + Rails.logger.debug "Twilio error: #{e.inspect}" + raise e + end + end + + def process(_options, attr, channel) + Rails.logger.info "Receiving SMS frim recipient #{attr[:From]}" + + # prevent already created articles + if Ticket::Article.find_by(message_id: attr[:SmsMessageSid]) + return ['application/xml; charset=UTF-8;', Twilio::TwiML::MessagingResponse.new.to_s] + end + + # find sender + user = User.where(mobile: attr[:From]).order(:updated_at).first + if !user + from_comment, preferences = Cti::CallerId.get_comment_preferences(attr[:From], 'from') + if preferences && preferences['from'] && preferences['from'][0] + if preferences['from'][0]['level'] == 'known' && preferences['from'][0]['object'] == 'User' + user = User.find_by(id: preferences['from'][0]['o_id']) + end + end + end + if !user + user = User.create!( + firstname: attr[:From], + mobile: attr[:From], + ) + end + + UserInfo.current_user_id = user.id + + # find ticket + article_type_sms = Ticket::Article::Type.find_by(name: 'sms') + state_ids = Ticket::State.where(name: %w[closed merged removed]).pluck(:id) + ticket = Ticket.where(customer_id: user.id, create_article_type_id: article_type_sms.id).where.not(state_id: state_ids).order(:updated_at).first + if ticket + new_state = Ticket::State.find_by(default_create: true) + if ticket.state_id != new_state.id + ticket.state = Ticket::State.find_by(default_follow_up: true) + ticket.save! + end + else + if channel.group_id.blank? + raise Exceptions::UnprocessableEntity, 'Group needed in channel definition!' + end + + group = Group.find_by(id: channel.group_id) + if !group + raise Exceptions::UnprocessableEntity, 'Group is invalid!' + end + + title = attr[:Body] + if title.length > 40 + title = "#{title[0, 40]}..." + end + ticket = Ticket.new( + group_id: channel.group_id, + title: title, + state_id: Ticket::State.find_by(default_create: true).id, + priority_id: Ticket::Priority.find_by(default_create: true).id, + customer_id: user.id, + preferences: { + channel_id: channel.id, + sms: { + AccountSid: attr['AccountSid'], + From: attr['From'], + To: attr['To'], + } + } + ) + ticket.save! + end + + Ticket::Article.create!( + ticket_id: ticket.id, + type: article_type_sms, + sender: Ticket::Article::Sender.find_by(name: 'Customer'), + body: attr[:Body], + from: attr[:From], + to: attr[:To], + message_id: attr[:SmsMessageSid], + content_type: 'text/plain', + preferences: { + channel_id: channel.id, + sms: { + AccountSid: attr['AccountSid'], + From: attr['From'], + To: attr['To'], + } + } + ) + + ['application/xml; charset=UTF-8;', Twilio::TwiML::MessagingResponse.new.to_s] + end + + def self.definition + { + name: 'twilio', + adapter: 'sms/twilio', + account: [ + { name: 'options::webhook_token', display: 'Webhook Token', tag: 'input', type: 'text', limit: 200, null: false, default: Digest::MD5.hexdigest(rand(999_999_999_999).to_s), disabled: true, readonly: true }, + { name: 'options::account_id', display: 'Account SID', tag: 'input', type: 'text', limit: 200, null: false, placeholder: 'XXXXXX' }, + { name: 'options::token', display: 'Token', tag: 'input', type: 'text', limit: 200, null: false }, + { name: 'options::sender', display: 'Sender', tag: 'input', type: 'text', limit: 200, null: false, placeholder: '+491710000000' }, + { name: 'group_id', display: 'Destination Group', tag: 'select', null: false, relation: 'Group', nulloption: true, filter: { active: true } }, + ], + notification: [ + { name: 'options::account_id', display: 'Account SID', tag: 'input', type: 'text', limit: 200, null: false, placeholder: 'XXXXXX' }, + { name: 'options::token', display: 'Token', tag: 'input', type: 'text', limit: 200, null: false }, + { name: 'options::sender', display: 'Sender', tag: 'input', type: 'text', limit: 200, null: false, placeholder: '+491710000000' }, + ], + } + end + + private + + def api(options) + @api ||= ::Twilio::REST::Client.new options[:account_id], options[:token] + end +end diff --git a/app/models/cti/caller_id.rb b/app/models/cti/caller_id.rb index c9dd3e353..1f429f133 100644 --- a/app/models/cti/caller_id.rb +++ b/app/models/cti/caller_id.rb @@ -243,6 +243,34 @@ returns end end +=begin + + from_comment, preferences = Cti::CallerId.get_comment_preferences('00491710000000', 'from') + + returns + + [ + "Bob Smith", + { + "from"=>[ + { + "id"=>1961634, + "caller_id"=>"491710000000", + "comment"=>nil, + "level"=>"known", + "object"=>"User", + "o_id"=>3, + "user_id"=>3, + "preferences"=>nil, + "created_at"=>Mon, 24 Sep 2018 15:19:48 UTC +00:00, + "updated_at"=>Mon, 24 Sep 2018 15:19:48 UTC +00:00, + } + ] + } + ] + +=end + def self.get_comment_preferences(caller_id, direction) from_comment_known = '' from_comment_maybe = '' diff --git a/app/models/observer/ticket/article/communicate_email.rb b/app/models/observer/ticket/article/communicate_email.rb index 7c5247c21..4f75e3ac5 100644 --- a/app/models/observer/ticket/article/communicate_email.rb +++ b/app/models/observer/ticket/article/communicate_email.rb @@ -6,24 +6,25 @@ class Observer::Ticket::Article::CommunicateEmail < ActiveRecord::Observer def after_create(record) # return if we run import mode - return if Setting.get('import_mode') + return true if Setting.get('import_mode') # only do send email if article got created via application_server (e. g. not # if article and sender type is set via *.postmaster) - return if ApplicationHandleInfo.postmaster? + return true if ApplicationHandleInfo.postmaster? # if sender is customer, do not communicate - return if !record.sender_id + return true if !record.sender_id sender = Ticket::Article::Sender.lookup(id: record.sender_id) - return 1 if sender.nil? - return 1 if sender['name'] == 'Customer' + return true if sender.nil? + return true if sender.name == 'Customer' # only apply on emails - return if !record.type_id + return true if !record.type_id type = Ticket::Article::Type.lookup(id: record.type_id) - return if type['name'] != 'email' + return true if type.nil? + return true if type.name != 'email' # send background job Delayed::Job.enqueue(Observer::Ticket::Article::CommunicateEmail::BackgroundJob.new(record.id)) diff --git a/app/models/observer/ticket/article/communicate_email/background_job.rb b/app/models/observer/ticket/article/communicate_email/background_job.rb index 1bd150eb4..5835838cb 100644 --- a/app/models/observer/ticket/article/communicate_email/background_job.rb +++ b/app/models/observer/ticket/article/communicate_email/background_job.rb @@ -15,9 +15,7 @@ class Observer::Ticket::Article::CommunicateEmail::BackgroundJob subject = ticket.subject_build(record.subject, subject_prefix_mode) # set retry count - if !record.preferences['delivery_retry'] - record.preferences['delivery_retry'] = 0 - end + record.preferences['delivery_retry'] ||= 0 record.preferences['delivery_retry'] += 1 # send email diff --git a/app/models/observer/ticket/article/communicate_facebook.rb b/app/models/observer/ticket/article/communicate_facebook.rb index 6b1148c62..bbd3bc6cf 100644 --- a/app/models/observer/ticket/article/communicate_facebook.rb +++ b/app/models/observer/ticket/article/communicate_facebook.rb @@ -5,24 +5,25 @@ class Observer::Ticket::Article::CommunicateFacebook < ActiveRecord::Observer def after_create(record) # return if we run import mode - return if Setting.get('import_mode') + return true if Setting.get('import_mode') # only do send email if article got created via application_server (e. g. not # if article and sender type is set via *.postmaster) - return if ApplicationHandleInfo.postmaster? + return true if ApplicationHandleInfo.postmaster? # if sender is customer, do not communicate - return if !record.sender_id + return true if !record.sender_id sender = Ticket::Article::Sender.lookup(id: record.sender_id) - return 1 if sender.nil? - return 1 if sender['name'] == 'Customer' + return true if sender.nil? + return true if sender.name == 'Customer' # only apply for facebook - return if !record.type_id + return true if !record.type_id type = Ticket::Article::Type.lookup(id: record.type_id) - return if type['name'] !~ /\Afacebook/ + return true if type.nil? + return true if type.name !~ /\Afacebook/ Delayed::Job.enqueue(Observer::Ticket::Article::CommunicateFacebook::BackgroundJob.new(record.id)) end diff --git a/app/models/observer/ticket/article/communicate_sms.rb b/app/models/observer/ticket/article/communicate_sms.rb new file mode 100644 index 000000000..10ddcc759 --- /dev/null +++ b/app/models/observer/ticket/article/communicate_sms.rb @@ -0,0 +1,25 @@ +class Observer::Ticket::Article::CommunicateSms < ActiveRecord::Observer + observe 'ticket::_article' + + def after_create(record) + + # return if we run import mode + return true if Setting.get('import_mode') + + # if sender is customer, do not communicate + return true if !record.sender_id + + sender = Ticket::Article::Sender.lookup(id: record.sender_id) + return true if sender.nil? + return true if sender.name == 'Customer' + + # only apply on sms + return true if !record.type_id + + type = Ticket::Article::Type.lookup(id: record.type_id) + return true if type.nil? + return true if type.name != 'sms' + + Delayed::Job.enqueue(Observer::Ticket::Article::CommunicateSms::BackgroundJob.new(record.id)) + end +end diff --git a/app/models/observer/ticket/article/communicate_sms/background_job.rb b/app/models/observer/ticket/article/communicate_sms/background_job.rb new file mode 100644 index 000000000..b5f62defc --- /dev/null +++ b/app/models/observer/ticket/article/communicate_sms/background_job.rb @@ -0,0 +1,121 @@ +class Observer::Ticket::Article::CommunicateSms::BackgroundJob + def initialize(id) + @article_id = id + end + + def perform + article = Ticket::Article.find(@article_id) + + article.preferences['delivery_retry'] ||= 0 + article.preferences['delivery_retry'] += 1 + + ticket = Ticket.lookup(id: article.ticket_id) + log_error(article, "Can't find article.preferences for Ticket::Article.find(#{article.id})") if !article.preferences + + # if sender is system, take artile channel + if article.sender.name == 'System' + log_error(article, "Can't find article.preferences['sms_recipients'] for Ticket::Article.find(#{article.id})") if !article.preferences['sms_recipients'] + log_error(article, "Can't find article.preferences['channel_id'] for Ticket::Article.find(#{article.id})") if !article.preferences['channel_id'] + channel = Channel.lookup(id: article.preferences['channel_id']) + log_error(article, "No such channel id #{article.preferences['channel_id']}") if !channel + + # if sender is agent, take create channel + else + log_error(article, "Can't find ticket.preferences['channel_id'] for Ticket.find(#{ticket.id})") if !ticket.preferences['channel_id'] + channel = Channel.lookup(id: ticket.preferences['channel_id']) + log_error(article, "No such channel id #{ticket.preferences['channel_id']}") if !channel + end + + begin + if article.sender.name == 'System' + article.preferences['sms_recipients'].each do |recipient| + channel.deliver( + recipient: recipient, + message: article.body.first(160), + ) + end + else + channel.deliver( + recipient: article.to, + message: article.body.first(160), + ) + end + rescue => e + log_error(article, e.message) + return + end + + log_success(article) + + return if article.sender.name == 'Agent' + + log_history(article, ticket, 'sms', article.to) + end + + # log successful delivery + def log_success(article) + article.preferences['delivery_status_message'] = nil + article.preferences['delivery_status'] = 'success' + article.preferences['delivery_status_date'] = Time.zone.now + article.save! + end + + def log_error(local_record, message) + local_record.preferences['delivery_status'] = 'fail' + local_record.preferences['delivery_status_message'] = message + local_record.preferences['delivery_status_date'] = Time.zone.now + local_record.save! + Rails.logger.error message + + if local_record.preferences['delivery_retry'] >= max_attempts + Ticket::Article.create( + ticket_id: local_record.ticket_id, + content_type: 'text/plain', + body: "#{log_error_prefix}: #{message}", + internal: true, + sender: Ticket::Article::Sender.find_by(name: 'System'), + type: Ticket::Article::Type.find_by(name: 'note'), + preferences: { + delivery_article_id_related: local_record.id, + delivery_message: true, + }, + updated_by_id: 1, + created_by_id: 1, + ) + end + + raise message + end + + def log_history(article, ticket, history_type, recipient_list) + return if recipient_list.blank? + + History.add( + o_id: article.id, + history_type: history_type, + history_object: 'Ticket::Article', + related_o_id: ticket.id, + related_history_object: 'Ticket', + value_from: article.subject, + value_to: recipient_list, + created_by_id: article.created_by_id, + ) + end + + def log_error_prefix + 'Unable to send sms message' + end + + def max_attempts + 4 + end + + def reschedule_at(current_time, attempts) + if Rails.env.production? + return current_time + attempts * 120.seconds + end + + current_time + 5.seconds + end + +end diff --git a/app/models/observer/ticket/article/communicate_telegram.rb b/app/models/observer/ticket/article/communicate_telegram.rb index 5e43ab0fe..a492d35f1 100644 --- a/app/models/observer/ticket/article/communicate_telegram.rb +++ b/app/models/observer/ticket/article/communicate_telegram.rb @@ -6,20 +6,20 @@ class Observer::Ticket::Article::CommunicateTelegram < ActiveRecord::Observer def after_create(record) # return if we run import mode - return if Setting.get('import_mode') + return true if Setting.get('import_mode') # if sender is customer, do not communicate - return if !record.sender_id + return true if !record.sender_id sender = Ticket::Article::Sender.lookup(id: record.sender_id) - return if sender.nil? - return if sender['name'] == 'Customer' + return true if sender.nil? + return true if sender.name == 'Customer' # only apply on telegram messages - return if !record.type_id + return true if !record.type_id type = Ticket::Article::Type.lookup(id: record.type_id) - return if type['name'] !~ /\Atelegram/i + return true if type.name !~ /\Atelegram/i Delayed::Job.enqueue(Observer::Ticket::Article::CommunicateTelegram::BackgroundJob.new(record.id)) end diff --git a/app/models/observer/ticket/article/communicate_telegram/background_job.rb b/app/models/observer/ticket/article/communicate_telegram/background_job.rb index 18e65bf06..edeb53e51 100644 --- a/app/models/observer/ticket/article/communicate_telegram/background_job.rb +++ b/app/models/observer/ticket/article/communicate_telegram/background_job.rb @@ -7,10 +7,8 @@ class Observer::Ticket::Article::CommunicateTelegram::BackgroundJob article = Ticket::Article.find(@article_id) # set retry count - if !article.preferences['delivery_retry'] - article.preferences['delivery_retry'] = 0 - end - article.preferences['delivery_retry'] += 1 + record.preferences['delivery_retry'] ||= 0 + record.preferences['delivery_retry'] += 1 ticket = Ticket.lookup(id: article.ticket_id) log_error(article, "Can't find ticket.preferences for Ticket.find(#{article.ticket_id})") if !ticket.preferences diff --git a/app/models/observer/ticket/article/communicate_twitter.rb b/app/models/observer/ticket/article/communicate_twitter.rb index d391993fd..b804607fc 100644 --- a/app/models/observer/ticket/article/communicate_twitter.rb +++ b/app/models/observer/ticket/article/communicate_twitter.rb @@ -6,24 +6,25 @@ class Observer::Ticket::Article::CommunicateTwitter < ActiveRecord::Observer def after_create(record) # return if we run import mode - return if Setting.get('import_mode') + return true if Setting.get('import_mode') # only do send email if article got created via application_server (e. g. not # if article and sender type is set via *.postmaster) - return if ApplicationHandleInfo.postmaster? + return true if ApplicationHandleInfo.postmaster? # if sender is customer, do not communicate - return if !record.sender_id + return true if !record.sender_id sender = Ticket::Article::Sender.lookup(id: record.sender_id) - return if sender.nil? - return if sender['name'] == 'Customer' + return true if sender.nil? + return true if sender.name == 'Customer' # only apply on tweets - return if !record.type_id + return true if !record.type_id type = Ticket::Article::Type.lookup(id: record.type_id) - return if type['name'] !~ /\Atwitter/i + return true if type.nil? + return true if type.name !~ /\Atwitter/i raise Exceptions::UnprocessableEntity, 'twitter to: parameter is missing' if record.to.blank? && type['name'] == 'twitter direct-message' diff --git a/app/models/observer/ticket/article/communicate_twitter/background_job.rb b/app/models/observer/ticket/article/communicate_twitter/background_job.rb index ee7b4b9d6..7161fd851 100644 --- a/app/models/observer/ticket/article/communicate_twitter/background_job.rb +++ b/app/models/observer/ticket/article/communicate_twitter/background_job.rb @@ -7,10 +7,8 @@ class Observer::Ticket::Article::CommunicateTwitter::BackgroundJob article = Ticket::Article.find(@article_id) # set retry count - if !article.preferences['delivery_retry'] - article.preferences['delivery_retry'] = 0 - end - article.preferences['delivery_retry'] += 1 + record.preferences['delivery_retry'] ||= 0 + record.preferences['delivery_retry'] += 1 ticket = Ticket.lookup(id: article.ticket_id) log_error(article, "Can't find ticket.preferences for Ticket.find(#{article.ticket_id})") if !ticket.preferences diff --git a/app/models/observer/ticket/article/fillup_from_email.rb b/app/models/observer/ticket/article/fillup_from_email.rb index 59d8095f0..735366556 100644 --- a/app/models/observer/ticket/article/fillup_from_email.rb +++ b/app/models/observer/ticket/article/fillup_from_email.rb @@ -17,13 +17,14 @@ class Observer::Ticket::Article::FillupFromEmail < ActiveRecord::Observer sender = Ticket::Article::Sender.lookup(id: record.sender_id) return true if sender.nil? - return true if sender['name'] == 'Customer' + return true if sender.name == 'Customer' # set email attributes return true if !record.type_id type = Ticket::Article::Type.lookup(id: record.type_id) - return true if type['name'] != 'email' + return true if type.nil? + return true if type.name != 'email' # set subject if empty ticket = record.ticket diff --git a/app/models/observer/ticket/article/fillup_from_general.rb b/app/models/observer/ticket/article/fillup_from_general.rb index fca6080ea..4b1a89315 100644 --- a/app/models/observer/ticket/article/fillup_from_general.rb +++ b/app/models/observer/ticket/article/fillup_from_general.rb @@ -10,19 +10,21 @@ class Observer::Ticket::Article::FillupFromGeneral < ActiveRecord::Observer # only do fill of from if article got created via application_server (e. g. not # if article and sender type is set via *.postmaster) - return if ApplicationHandleInfo.postmaster? + return true if ApplicationHandleInfo.postmaster? # set from on all article types excluding email|twitter status|twitter direct-message|facebook feed post|facebook feed comment return true if record.type_id.blank? type = Ticket::Article::Type.lookup(id: record.type_id) - return true if type['name'] == 'email' # from will be set by channel backend - return true if type['name'] == 'twitter status' - return true if type['name'] == 'twitter direct-message' - return true if type['name'] == 'facebook feed post' - return true if type['name'] == 'facebook feed comment' + return true if type.nil? + return true if type.name == 'email' + return true if type.name == 'twitter status' + return true if type.name == 'twitter direct-message' + return true if type.name == 'facebook feed post' + return true if type.name == 'facebook feed comment' + return true if type.name == 'sms' user_id = record.created_by_id diff --git a/app/models/observer/ticket/article/fillup_from_origin_by_id.rb b/app/models/observer/ticket/article/fillup_from_origin_by_id.rb index 07ebd3fdd..dd1e78b92 100644 --- a/app/models/observer/ticket/article/fillup_from_origin_by_id.rb +++ b/app/models/observer/ticket/article/fillup_from_origin_by_id.rb @@ -6,19 +6,21 @@ class Observer::Ticket::Article::FillupFromOriginById < ActiveRecord::Observer def before_create(record) # return if we run import mode - return if Setting.get('import_mode') + return true if Setting.get('import_mode') # only do fill origin_by_id if article got created via application_server (e. g. not # if article and sender type is set via *.postmaster) - return if ApplicationHandleInfo.postmaster? + return true if ApplicationHandleInfo.postmaster? # check if origin_by_id exists - return if record.origin_by_id.present? - return if record.ticket.customer_id.blank? - return if record.sender.name != 'Customer' + return true if record.origin_by_id.present? + return true if record.ticket.blank? + return true if record.ticket.customer_id.blank? + return true if record.sender_id.blank? + return true if record.sender.name != 'Customer' type_name = record.type.name - return if type_name != 'phone' && type_name != 'note' && type_name != 'web' + return true if type_name != 'phone' && type_name != 'note' && type_name != 'web' record.origin_by_id = record.ticket.customer_id end diff --git a/app/models/ticket.rb b/app/models/ticket.rb index b88198ddd..f27187ac7 100644 --- a/app/models/ticket.rb +++ b/app/models/ticket.rb @@ -918,229 +918,17 @@ perform changes on ticket end perform_notification.each do |key, value| - perform_changes_notification(key, value, perform_origin, article) - end - true - end - - def perform_changes_notification(_key, value, perform_origin, article) - - # value['recipient'] was a string in the past (single-select) so we convert it to array if needed - value_recipient = value['recipient'] - if !value_recipient.is_a?(Array) - value_recipient = [value_recipient] - end - - recipients_raw = [] - value_recipient.each do |recipient| - if recipient == 'article_last_sender' - if article.present? - if article.reply_to.present? - recipients_raw.push(article.reply_to) - elsif article.from.present? - recipients_raw.push(article.from) - elsif article.origin_by_id - email = User.find_by(id: article.origin_by_id).email - recipients_raw.push(email) - elsif article.created_by_id - email = User.find_by(id: article.created_by_id).email - recipients_raw.push(email) - end - end - elsif recipient == 'ticket_customer' - email = User.find_by(id: customer_id).email - recipients_raw.push(email) - elsif recipient == 'ticket_owner' - email = User.find_by(id: owner_id).email - recipients_raw.push(email) - elsif recipient == 'ticket_agents' - User.group_access(group_id, 'full').sort_by(&:login).each do |user| - recipients_raw.push(user.email) - end - else - logger.error "Unknown email notification recipient '#{recipient}'" + # send notification + case key + when 'notification.sms' + send_sms_notification(value, article, perform_origin) next + when 'notification.email' + send_email_notification(value, article, perform_origin) end end - recipients_checked = [] - recipients_raw.each do |recipient_email| - - skip_user = false - users = User.where(email: recipient_email) - users.each do |user| - next if user.preferences[:mail_delivery_failed] != true - next if !user.preferences[:mail_delivery_failed_data] - - till_blocked = ((user.preferences[:mail_delivery_failed_data] - Time.zone.now - 60.days) / 60 / 60 / 24).round - next if till_blocked.positive? - - logger.info "Send no trigger based notification to #{recipient_email} because email is marked as mail_delivery_failed for #{till_blocked} days" - skip_user = true - break - end - next if skip_user - - # send notifications only to email adresses - next if recipient_email.blank? - next if recipient_email !~ /@/ - - # check if address is valid - begin - Mail::AddressList.new(recipient_email).addresses.each do |address| - recipient_email = address.address - break if recipient_email.present? && recipient_email =~ /@/ && !recipient_email.match?(/\s/) - end - rescue - if recipient_email.present? - if recipient_email !~ /^(.+?)<(.+?)@(.+?)>$/ - next # no usable format found - end - - recipient_email = "#{$2}@#{$3}" - end - next if recipient_email.blank? - next if recipient_email !~ /@/ - next if recipient_email.match?(/\s/) - end - - # do not sent notifications to this recipients - send_no_auto_response_reg_exp = Setting.get('send_no_auto_response_reg_exp') - begin - next if recipient_email.match?(/#{send_no_auto_response_reg_exp}/i) - rescue => e - logger.error "ERROR: Invalid regex '#{send_no_auto_response_reg_exp}' in setting send_no_auto_response_reg_exp" - logger.error 'ERROR: ' + e.inspect - next if recipient_email.match?(/(mailer-daemon|postmaster|abuse|root|noreply|noreply.+?|no-reply|no-reply.+?)@.+?/i) - end - - # check if notification should be send because of customer emails - if article.present? && article.preferences.fetch('is-auto-response', false) == true && article.from && article.from =~ /#{Regexp.quote(recipient_email)}/i - logger.info "Send no trigger based notification to #{recipient_email} because of auto response tagged incoming email" - next - end - - # loop protection / check if maximal count of trigger mail has reached - map = { - 10 => 10, - 30 => 15, - 60 => 25, - 180 => 50, - 600 => 100, - } - skip = false - map.each do |minutes, count| - already_sent = Ticket::Article.where( - ticket_id: id, - sender: Ticket::Article::Sender.find_by(name: 'System'), - type: Ticket::Article::Type.find_by(name: 'email'), - ).where('ticket_articles.created_at > ? AND ticket_articles.to LIKE ?', Time.zone.now - minutes.minutes, "%#{recipient_email.strip}%").count - next if already_sent < count - - logger.info "Send no trigger based notification to #{recipient_email} because already sent #{count} for this ticket within last #{minutes} minutes (loop protection)" - skip = true - break - end - next if skip - - map = { - 10 => 30, - 30 => 60, - 60 => 120, - 180 => 240, - 600 => 360, - } - skip = false - map.each do |minutes, count| - already_sent = Ticket::Article.where( - sender: Ticket::Article::Sender.find_by(name: 'System'), - type: Ticket::Article::Type.find_by(name: 'email'), - ).where('ticket_articles.created_at > ? AND ticket_articles.to LIKE ?', Time.zone.now - minutes.minutes, "%#{recipient_email.strip}%").count - next if already_sent < count - - logger.info "Send no trigger based notification to #{recipient_email} because already sent #{count} in total within last #{minutes} minutes (loop protection)" - skip = true - break - end - next if skip - - email = recipient_email.downcase.strip - next if recipients_checked.include?(email) - - recipients_checked.push(email) - end - - return if recipients_checked.blank? - - recipient_string = recipients_checked.join(', ') - - group_id = self.group_id - return if !group_id - - email_address = Group.find(group_id).email_address - if !email_address - logger.info "Unable to send trigger based notification to #{recipient_string} because no email address is set for group '#{group.name}'" - return - end - - if !email_address.channel_id - logger.info "Unable to send trigger based notification to #{recipient_string} because no channel is set for email address '#{email_address.email}' (id: #{email_address.id})" - return - end - - # articles.last breaks (returns the wrong article) - # if another email notification trigger preceded this one - # (see https://github.com/zammad/zammad/issues/1543) - objects = { - ticket: self, - article: article || articles.last - } - - # get subject - subject = NotificationFactory::Mailer.template( - templateInline: value['subject'], - locale: 'en-en', - objects: objects, - quote: false, - ) - subject = subject_build(subject) - - body = NotificationFactory::Mailer.template( - templateInline: value['body'], - locale: 'en-en', - objects: objects, - quote: true, - ) - - (body, attachments_inline) = HtmlSanitizer.replace_inline_images(body, id) - - message = Ticket::Article.create( - ticket_id: id, - to: recipient_string, - subject: subject, - content_type: 'text/html', - body: body, - internal: false, - sender: Ticket::Article::Sender.find_by(name: 'System'), - type: Ticket::Article::Type.find_by(name: 'email'), - preferences: { - perform_origin: perform_origin, - }, - updated_by_id: 1, - created_by_id: 1, - ) - - attachments_inline.each do |attachment| - Store.add( - object: 'Ticket::Article', - o_id: message.id, - data: attachment[:data], - filename: attachment[:filename], - preferences: attachment[:preferences], - ) - end - true end @@ -1485,4 +1273,296 @@ result self.owner_id = 1 true end + + # articles.last breaks (returns the wrong article) + # if another email notification trigger preceded this one + # (see https://github.com/zammad/zammad/issues/1543) + def build_notification_template_objects(article) + { + ticket: self, + article: article || articles.last + } + end + + def send_email_notification(value, article, perform_origin) + # value['recipient'] was a string in the past (single-select) so we convert it to array if needed + value_recipient = Array(value['recipient']) + + recipients_raw = [] + value_recipient.each do |recipient| + if recipient == 'article_last_sender' + if article.present? + if article.reply_to.present? + recipients_raw.push(article.reply_to) + elsif article.from.present? + recipients_raw.push(article.from) + elsif article.origin_by_id + email = User.find_by(id: article.origin_by_id).email + recipients_raw.push(email) + elsif article.created_by_id + email = User.find_by(id: article.created_by_id).email + recipients_raw.push(email) + end + end + elsif recipient == 'ticket_customer' + email = User.find_by(id: customer_id).email + recipients_raw.push(email) + elsif recipient == 'ticket_owner' + email = User.find_by(id: owner_id).email + recipients_raw.push(email) + elsif recipient == 'ticket_agents' + User.group_access(group_id, 'full').sort_by(&:login).each do |user| + recipients_raw.push(user.email) + end + else + logger.error "Unknown email notification recipient '#{recipient}'" + next + end + end + + recipients_checked = [] + recipients_raw.each do |recipient_email| + + skip_user = false + users = User.where(email: recipient_email) + users.each do |user| + next if user.preferences[:mail_delivery_failed] != true + next if !user.preferences[:mail_delivery_failed_data] + + till_blocked = ((user.preferences[:mail_delivery_failed_data] - Time.zone.now - 60.days) / 60 / 60 / 24).round + next if till_blocked.positive? + + logger.info "Send no trigger based notification to #{recipient_email} because email is marked as mail_delivery_failed for #{till_blocked} days" + skip_user = true + break + end + next if skip_user + + # send notifications only to email adresses + next if recipient_email.blank? + next if recipient_email !~ /@/ + + # check if address is valid + begin + Mail::AddressList.new(recipient_email).addresses.each do |address| + recipient_email = address.address + break if recipient_email.present? && recipient_email =~ /@/ && !recipient_email.match?(/\s/) + end + rescue + if recipient_email.present? + if recipient_email !~ /^(.+?)<(.+?)@(.+?)>$/ + next # no usable format found + end + + recipient_email = "#{$2}@#{$3}" + end + next if recipient_email.blank? + next if recipient_email !~ /@/ + next if recipient_email.match?(/\s/) + end + + # do not sent notifications to this recipients + send_no_auto_response_reg_exp = Setting.get('send_no_auto_response_reg_exp') + begin + next if recipient_email.match?(/#{send_no_auto_response_reg_exp}/i) + rescue => e + logger.error "ERROR: Invalid regex '#{send_no_auto_response_reg_exp}' in setting send_no_auto_response_reg_exp" + logger.error 'ERROR: ' + e.inspect + next if recipient_email.match?(/(mailer-daemon|postmaster|abuse|root|noreply|noreply.+?|no-reply|no-reply.+?)@.+?/i) + end + + # check if notification should be send because of customer emails + if article.present? && article.preferences.fetch('is-auto-response', false) == true && article.from && article.from =~ /#{Regexp.quote(recipient_email)}/i + logger.info "Send no trigger based notification to #{recipient_email} because of auto response tagged incoming email" + next + end + + # loop protection / check if maximal count of trigger mail has reached + map = { + 10 => 10, + 30 => 15, + 60 => 25, + 180 => 50, + 600 => 100, + } + skip = false + map.each do |minutes, count| + already_sent = Ticket::Article.where( + ticket_id: id, + sender: Ticket::Article::Sender.find_by(name: 'System'), + type: Ticket::Article::Type.find_by(name: 'email'), + ).where('ticket_articles.created_at > ? AND ticket_articles.to LIKE ?', Time.zone.now - minutes.minutes, "%#{recipient_email.strip}%").count + next if already_sent < count + + logger.info "Send no trigger based notification to #{recipient_email} because already sent #{count} for this ticket within last #{minutes} minutes (loop protection)" + skip = true + break + end + next if skip + + map = { + 10 => 30, + 30 => 60, + 60 => 120, + 180 => 240, + 600 => 360, + } + skip = false + map.each do |minutes, count| + already_sent = Ticket::Article.where( + sender: Ticket::Article::Sender.find_by(name: 'System'), + type: Ticket::Article::Type.find_by(name: 'email'), + ).where('ticket_articles.created_at > ? AND ticket_articles.to LIKE ?', Time.zone.now - minutes.minutes, "%#{recipient_email.strip}%").count + next if already_sent < count + + logger.info "Send no trigger based notification to #{recipient_email} because already sent #{count} in total within last #{minutes} minutes (loop protection)" + skip = true + break + end + next if skip + + email = recipient_email.downcase.strip + next if recipients_checked.include?(email) + + recipients_checked.push(email) + end + + return if recipients_checked.blank? + + recipient_string = recipients_checked.join(', ') + + group_id = self.group_id + return if !group_id + + email_address = Group.find(group_id).email_address + if !email_address + logger.info "Unable to send trigger based notification to #{recipient_string} because no email address is set for group '#{group.name}'" + return + end + + if !email_address.channel_id + logger.info "Unable to send trigger based notification to #{recipient_string} because no channel is set for email address '#{email_address.email}' (id: #{email_address.id})" + return + end + + objects = build_notification_template_objects(article) + + # get subject + subject = NotificationFactory::Mailer.template( + templateInline: value['subject'], + locale: 'en-en', + objects: objects, + quote: false, + ) + subject = subject_build(subject) + + body = NotificationFactory::Mailer.template( + templateInline: value['body'], + locale: 'en-en', + objects: objects, + quote: true, + ) + + (body, attachments_inline) = HtmlSanitizer.replace_inline_images(body, id) + + message = Ticket::Article.create( + ticket_id: id, + to: recipient_string, + subject: subject, + content_type: 'text/html', + body: body, + internal: false, + sender: Ticket::Article::Sender.find_by(name: 'System'), + type: Ticket::Article::Type.find_by(name: 'email'), + preferences: { + perform_origin: perform_origin, + }, + updated_by_id: 1, + created_by_id: 1, + ) + + attachments_inline.each do |attachment| + Store.add( + object: 'Ticket::Article', + o_id: message.id, + data: attachment[:data], + filename: attachment[:filename], + preferences: attachment[:preferences], + ) + end + end + + def sms_recipients_by_type(recipient_type, article) + case recipient_type + when 'article_last_sender' + return nil if article.blank? + + if article.origin_by_id + article.origin_by_id + elsif article.created_by_id + article.created_by_id + end + when 'ticket_customer' + customer_id + when 'ticket_owner' + owner_id + when 'ticket_agents' + User.group_access(group_id, 'full').sort_by(&:login) + else + logger.error "Unknown sms notification recipient '#{recipient}'" + nil + end + end + + def build_sms_recipients_list(value, article) + Array(value['recipient']) + .each_with_object([]) { |recipient_type, sum| sum.concat(Array(sms_recipients_by_type(recipient_type, article))) } + .map { |user_or_id| User.lookup(id: user_or_id) } + .select { |user| user.mobile.present? } + end + + def send_sms_notification(value, article, perform_origin) + sms_recipients = build_sms_recipients_list(value, article) + + if sms_recipients.blank? + logger.debug "No SMS recipients found for Ticket# #{number}" + return + end + + sms_recipients_to = sms_recipients + .map { |recipient| "#{recipient.fullname} (#{recipient.mobile})" } + .join(', ') + + channel = Channel.find_by(area: 'Sms::Notification') + if !channel.active? + # write info message since we have an active trigger + logger.info "Found possible SMS recipient(s) (#{sms_recipients_to}) for Ticket# #{number} but SMS channel is not active." + return + end + + objects = build_notification_template_objects(article) + body = NotificationFactory::Renderer.new(objects, 'en-en', value['body'], false) + .render + .html2text + .tr(' ', ' ') # convert non-breaking space to simple space + + # attributes content_type is not needed for SMS + article = Ticket::Article.create( + ticket_id: id, + subject: 'SMS notification', + to: sms_recipients_to, + body: body, + internal: true, + sender: Ticket::Article::Sender.find_by(name: 'System'), + type: Ticket::Article::Type.find_by(name: 'sms'), + preferences: { + perform_origin: perform_origin, + sms_recipients: sms_recipients.map(&:mobile), + channel_id: channel.id, + }, + updated_by_id: 1, + created_by_id: 1, + ) + + end end diff --git a/app/models/ticket/article.rb b/app/models/ticket/article.rb index 53e5407d1..ef9a9852d 100644 --- a/app/models/ticket/article.rb +++ b/app/models/ticket/article.rb @@ -23,6 +23,10 @@ class Ticket::Article < ApplicationModel store :preferences + validates :ticket_id, presence: true + validates :type_id, presence: true + validates :sender_id, presence: true + sanitized_html :body activity_stream_permission 'ticket.agent' diff --git a/app/models/transaction/trigger.rb b/app/models/transaction/trigger.rb index c16f70067..73dd66883 100644 --- a/app/models/transaction/trigger.rb +++ b/app/models/transaction/trigger.rb @@ -23,7 +23,6 @@ class Transaction::Trigger end def perform - # return if we run import mode return if Setting.get('import_mode') @@ -39,7 +38,6 @@ class Transaction::Trigger original_user_id = UserInfo.current_user_id Ticket.perform_triggers(ticket, article, @item, @params) - UserInfo.current_user_id = original_user_id end diff --git a/config/application.rb b/config/application.rb index 786ab124a..d3f556e1f 100644 --- a/config/application.rb +++ b/config/application.rb @@ -32,6 +32,7 @@ module Zammad 'observer::_ticket::_article::_fillup_from_email', 'observer::_ticket::_article::_communicate_email', 'observer::_ticket::_article::_communicate_facebook', + 'observer::_ticket::_article::_communicate_sms', 'observer::_ticket::_article::_communicate_twitter', 'observer::_ticket::_article::_communicate_telegram', 'observer::_ticket::_reset_new_state', diff --git a/config/routes/channel_sms.rb b/config/routes/channel_sms.rb new file mode 100644 index 000000000..b5af05c26 --- /dev/null +++ b/config/routes/channel_sms.rb @@ -0,0 +1,16 @@ +Zammad::Application.routes.draw do + api_path = Rails.configuration.api_path + + match api_path + '/channels_sms', to: 'channels_sms#index', via: :get + match api_path + '/channels_sms/:id', to: 'channels_sms#show', via: :get + match api_path + '/channels_sms', to: 'channels_sms#create', via: :post + match api_path + '/channels_sms/:id', to: 'channels_sms#update', via: :put + match api_path + '/channels_sms/:id', to: 'channels_sms#destroy', via: :delete + match api_path + '/channels_sms_enable', to: 'channels_sms#enable', via: :post + match api_path + '/channels_sms_disable', to: 'channels_sms#disable', via: :post + match api_path + '/channels_sms', to: 'channels_sms#destroy', via: :delete + match api_path + '/channels_sms/test', to: 'channels_sms#test', via: :post + match api_path + '/sms_webhook/:token', to: 'channels_sms#webhook', via: :get + match api_path + '/sms_webhook/:token', to: 'channels_sms#webhook', via: :post + +end diff --git a/contrib/icon-sprite.sketch b/contrib/icon-sprite.sketch index e6eb16ec3..e338e77a1 100644 Binary files a/contrib/icon-sprite.sketch and b/contrib/icon-sprite.sketch differ diff --git a/db/migrate/20180524182518_sms_support.rb b/db/migrate/20180524182518_sms_support.rb new file mode 100644 index 000000000..e80f6dc89 --- /dev/null +++ b/db/migrate/20180524182518_sms_support.rb @@ -0,0 +1,15 @@ +class SmsSupport < ActiveRecord::Migration[5.1] + def up + + # return if it's a new setup + return if !Setting.find_by(name: 'system_init_done') + + Permission.create_if_not_exists( + name: 'admin.channel_sms', + note: 'Manage %s', + preferences: { + translations: ['Channel - SMS'] + }, + ) + end +end diff --git a/db/seeds/permissions.rb b/db/seeds/permissions.rb index 0c5212e29..7cecf6955 100644 --- a/db/seeds/permissions.rb +++ b/db/seeds/permissions.rb @@ -136,6 +136,13 @@ Permission.create_if_not_exists( translations: ['Channel - Telegram'] }, ) +Permission.create_if_not_exists( + name: 'admin.channel_sms', + note: 'Manage %s', + preferences: { + translations: ['Channel - SMS'] + }, +) Permission.create_if_not_exists( name: 'admin.channel_chat', note: 'Manage %s', diff --git a/lib/notification_factory/renderer.rb b/lib/notification_factory/renderer.rb index 01e9b371e..f95bd26a9 100644 --- a/lib/notification_factory/renderer.rb +++ b/lib/notification_factory/renderer.rb @@ -92,15 +92,33 @@ examples how to use break end + arguments = nil + if /\A(?[^\(]+)\((?[^\)]+)\)\z/ =~ method + + if parameter != parameter.to_i.to_s + value = "\#{#{object_name}.#{object_methods_s} / invalid parameter: #{parameter}}" + break + end + + begin + arguments = Array(parameter.to_i) + method = method_id + rescue + value = "\#{#{object_name}.#{object_methods_s} / #{e.message}}" + break + end + end + # if method exists if !object_refs.respond_to?(method.to_sym) value = "\#{#{object_name}.#{object_methods_s} / no such method}" break end begin - object_refs = object_refs.send(method.to_sym) + object_refs = object_refs.send(method.to_sym, *arguments) rescue => e - object_refs = "\#{#{object_name}.#{object_methods_s} / e.message}" + value = "\#{#{object_name}.#{object_methods_s} / #{e.message}}" + break end end placeholder = if !value diff --git a/public/assets/images/icons.svg b/public/assets/images/icons.svg index 55e7dbe4e..4e8001847 100644 --- a/public/assets/images/icons.svg +++ b/public/assets/images/icons.svg @@ -405,11 +405,21 @@ logotype + + + long-arrow-down + + long-arrow-right + + + low-priority + + magnifier @@ -634,6 +644,11 @@ small-dot + + + sms + + split diff --git a/public/assets/images/icons/sms.svg b/public/assets/images/icons/sms.svg new file mode 100644 index 000000000..1435cc2ef --- /dev/null +++ b/public/assets/images/icons/sms.svg @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg width="17px" height="17px" viewBox="0 0 17 17" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> + <!-- Generator: Sketch 52.2 (67145) - http://www.bohemiancoding.com/sketch --> + <title>sms + Created with Sketch. + + + + \ No newline at end of file diff --git a/spec/controllers/integration/exchange_spec.rb b/spec/controllers/integration/exchange_spec.rb new file mode 100644 index 000000000..56581a250 --- /dev/null +++ b/spec/controllers/integration/exchange_spec.rb @@ -0,0 +1,46 @@ +require 'rails_helper' + +RSpec.describe 'Exchange integration endpoint', type: :request do + before { authenticated_as(admin_with_admin_user_permissions) } + + let(:admin_with_admin_user_permissions) do + create(:user, roles: [role_with_admin_user_permissions]) + end + + let(:role_with_admin_user_permissions) do + create(:role).tap { |role| role.permission_grant('admin.integration') } + end + + describe 'EWS folder retrieval' do + # see https://github.com/zammad/zammad/issues/1802 + context 'when no folders found (#1802)' do + let(:empty_folder_list) { { folders: {} } } + + it 'responds with an error message' do + allow(Sequencer).to receive(:process).with(any_args).and_return(empty_folder_list) + + post api_v1_integration_exchange_folders_path, + params: {}, as: :json + + expect(json_response).to include('result' => 'failed').and include('message') + end + end + end + + describe 'autodiscovery' do + # see https://github.com/zammad/zammad/issues/2065 + context 'when Autodiscover gem raises Errno::EADDRNOTAVAIL (#2065)' do + let(:client) { instance_double('Autodiscover::Client') } + + it 'rescues and responds with an empty hash (to proceed to manual configuration)' do + allow(Autodiscover::Client).to receive(:new).with(any_args).and_return(client) + allow(client).to receive(:autodiscover).and_raise(Errno::EADDRNOTAVAIL) + + post api_v1_integration_exchange_autodiscover_path, + params: {}, as: :json + + expect(json_response).to eq('result' => 'ok') + end + end + end +end diff --git a/spec/factories/channel.rb b/spec/factories/channel.rb index dbbdc33d7..625f09504 100644 --- a/spec/factories/channel.rb +++ b/spec/factories/channel.rb @@ -1,9 +1,11 @@ FactoryBot.define do factory :channel do - area 'Email::Dummy' - group { ::Group.find(1) } - active true - options {} - preferences {} + area 'Email::Dummy' + group { ::Group.find(1) } + active true + options {} + preferences {} + updated_by_id 1 + created_by_id 1 end end diff --git a/spec/lib/import/exchange/folder_spec.rb b/spec/lib/import/exchange/folder_spec.rb index f11949d23..65dead1ad 100644 --- a/spec/lib/import/exchange/folder_spec.rb +++ b/spec/lib/import/exchange/folder_spec.rb @@ -2,6 +2,7 @@ require 'rails_helper' RSpec.describe Import::Exchange::Folder do # see https://github.com/zammad/zammad/issues/2152 + describe '#display_path (#2152)' do let(:subject) { described_class.new(ews_connection) } let(:ews_connection) { Viewpoint::EWSClient.new(endpoint, user, pass) } diff --git a/spec/models/channel/driver/sms/massenversand_spec.rb b/spec/models/channel/driver/sms/massenversand_spec.rb new file mode 100644 index 000000000..6a251cf2f --- /dev/null +++ b/spec/models/channel/driver/sms/massenversand_spec.rb @@ -0,0 +1,72 @@ +require 'rails_helper' + +RSpec.describe Channel::Driver::Sms::Massenversand do + it 'passes' do + channel = create_channel + + stub_request(:get, url_to_mock) + .to_return(body: 'OK') + + api = channel.driver_instance.new + expect(api.send(channel.options, { recipient: receiver_number, message: message_body })).to be true + end + + it 'fails' do + channel = create_channel + + stub_request(:get, url_to_mock) + .to_return(body: 'blocked receiver ()') + + api = channel.driver_instance.new + expect { api.send(channel.options, { recipient: receiver_number, message: message_body }) }.to raise_exception(RuntimeError) + end + + private + + def create_channel + FactoryBot.create(:channel, + options: { + adapter: 'sms/massenversand', + gateway: gateway, + sender: sender_number, + token: token + }, + created_by_id: 1, + updated_by_id: 1) + end + + def url_to_mock + params = { + authToken: token, + getID: 1, + msg: message_body, + msgtype: 'c', + receiver: receiver_number, + sender: sender_number + } + + gateway + '?' + URI.encode_www_form(params) + end + + # api parameters + + def gateway + 'https://gate1.goyyamobile.com/sms/sendsms.asp' + end + + def message_body + 'Test' + end + + def receiver_number + '+37060010000' + end + + def sender_number + '+491000000000' + end + + def token + '00q1234123423r5rwefdfsfsfef' + end +end diff --git a/spec/models/channel/driver/sms/twilio_spec.rb b/spec/models/channel/driver/sms/twilio_spec.rb new file mode 100644 index 000000000..9078c4d94 --- /dev/null +++ b/spec/models/channel/driver/sms/twilio_spec.rb @@ -0,0 +1,71 @@ +require 'rails_helper' + +RSpec.describe Channel::Driver::Sms::Twilio do + it 'passes' do + channel = create_channel + + stub_request(:post, url_to_mock) + .to_return(body: mocked_response_success) + + api = channel.driver_instance.new + expect(api.send(channel.options, { recipient: '+37060010000', message: message_body })).to be true + end + + it 'fails' do + channel = create_channel + + stub_request(:post, url_to_mock) + .to_return(status: 400, body: mocked_response_failure) + + api = channel.driver_instance.new + + expect { api.send(channel.options, { recipient: 'asd', message: message_body }) }.to raise_exception(Twilio::REST::RestError) + expect(a_request(:post, url_to_mock)).to have_been_made + end + + private + + def create_channel + FactoryBot.create(:channel, + options: { + account_id: account_id, + adapter: 'sms/twilio', + sender: sender_number, + token: token + }, + created_by_id: 1, + updated_by_id: 1) + end + + # api parameters + + def url_to_mock + "https://api.twilio.com/2010-04-01/Accounts/#{account_id}/Messages.json" + end + + def account_id + 'ASDASDAS3213424AD' + end + + def message_body + 'Test' + end + + def sender_number + '+15005550006' + end + + def token + '2345r4erfdvc4wedxv3efds' + end + + # mocked responses + + def mocked_response_success + '{"sid": "SM07eab0404df148a4bf3712cb8b72e4c2", "date_created": "Fri, 01 Jun 2018 06:11:19 +0000", "date_updated": "Fri, 01 Jun 2018 06:11:19 +0000", "date_sent": null, "account_sid": "AC5989ff24c08f701b8b1ef09e1b79cbf8", "to": "+37060010000", "from": "+15005550006", "messaging_service_sid": null, "body": "Sent from your Twilio trial account - Test", "status": "queued", "num_segments": "1", "num_media": "0", "direction": "outbound-api", "api_version": "2010-04-01", "price": null, "price_unit": "USD", "error_code": null, "error_message": null, "uri": "/2010-04-01/Accounts/AC5989ff24c08f701b8b1ef09e1b79cbf8/Messages/SM07eab0404df148a4bf3712cb8b72e4c2.json", "subresource_uris": {"media": "/2010-04-01/Accounts/AC5989ff24c08f701b8b1ef09e1b79cbf8/Messages/SM07eab0404df148a4bf3712cb8b72e4c2/Media.json"}}' + end + + def mocked_response_failure + '{"code": 21211, "message": "The \'To\' number asd is not a valid phone number.", "more_info": "https://www.twilio.com/docs/errors/21211", "status": 400}' + end +end diff --git a/spec/models/trigger/sms_spec.rb b/spec/models/trigger/sms_spec.rb new file mode 100644 index 000000000..74defa3ae --- /dev/null +++ b/spec/models/trigger/sms_spec.rb @@ -0,0 +1,32 @@ +require 'rails_helper' + +RSpec.describe Trigger do + + describe 'sms' do + + it 'sends interpolated, html-free SMS' do + customer = create(:customer_user) + agent = create(:agent_user) + another_agent = create(:admin_user, mobile: '+37061010000') + Group.lookup(id: 1).users << another_agent + + channel = create(:channel, area: 'Sms::Notification') + trigger = create(:trigger, + disable_notification: false, + perform: { + 'notification.sms': { + recipient: 'ticket_agents', + body: 'space between #{ticket.title}', # rubocop:disable Lint/InterpolationCheck + } + }) + + ticket = create(:ticket, customer: customer, created_by_id: agent.id) + Observer::Transaction.commit + + triggered_article = Ticket::Article.last + + expect(triggered_article.body.match?(/space between/)).to be_truthy + expect(triggered_article.body.match?(ticket.title)).to be_truthy + end + end +end diff --git a/test/data/twilio/inbound_sms1.json b/test/data/twilio/inbound_sms1.json new file mode 100644 index 000000000..9891c5046 --- /dev/null +++ b/test/data/twilio/inbound_sms1.json @@ -0,0 +1,21 @@ +{ + "ToCountry": "DE", + "ToState": "", + "SmsMessageSid": "SM1bd5330f81336e8b54c2d3d9fc1bcb6a", + "NumMedia": "0", + "ToCity": "", + "FromZip": "", + "SmsSid": "SM1bd5330f81336e8b54c2d3d9fc1bcb6a", + "FromState": "", + "SmsStatus": "received", + "FromCity": "", + "Body": "Ldfhxhcuffufuf. Fifififig. Fifififiif Fifififiif Fifififiif Fifififiif Fifififiif", + "FromCountry": "DE", + "To": "+4915700000000", + "ToZip": "", + "NumSegments": "1", + "MessageSid": "SM1bd5330f81336e8b54c2d3d9fc1bcb6a", + "AccountSid": "AC78349f4b974f5f6907c513a77d96756d", + "From": "+491710000000", + "ApiVersion": "2010-04-01" +} \ No newline at end of file diff --git a/test/data/twilio/inbound_sms2.json b/test/data/twilio/inbound_sms2.json new file mode 100644 index 000000000..7a5d69861 --- /dev/null +++ b/test/data/twilio/inbound_sms2.json @@ -0,0 +1,21 @@ +{ + "ToCountry": "DE", + "ToState": "", + "SmsMessageSid": "SM1bd5330f81336e8b54c2d3d9fc1bcb6b", + "NumMedia": "0", + "ToCity": "", + "FromZip": "", + "SmsSid": "SM1bd5330f81336e8b54c2d3d9fc1bcb6b", + "FromState": "", + "SmsStatus": "received", + "FromCity": "", + "Body": "Follow up", + "FromCountry": "DE", + "To": "+4915700000000", + "ToZip": "", + "NumSegments": "1", + "MessageSid": "SM1bd5330f81336e8b54c2d3d9fc1bcb6b", + "AccountSid": "AC78349f4b974f5f6907c513a77d96756d", + "From": "+491710000000", + "ApiVersion": "2010-04-01" +} \ No newline at end of file diff --git a/test/data/twilio/inbound_sms3.json b/test/data/twilio/inbound_sms3.json new file mode 100644 index 000000000..d7a14893b --- /dev/null +++ b/test/data/twilio/inbound_sms3.json @@ -0,0 +1,21 @@ +{ + "ToCountry": "DE", + "ToState": "", + "SmsMessageSid": "SM1bd5330f81336e8b54c2d3d9fc1bcb6c", + "NumMedia": "0", + "ToCity": "", + "FromZip": "", + "SmsSid": "SM1bd5330f81336e8b54c2d3d9fc1bcb6c", + "FromState": "", + "SmsStatus": "received", + "FromCity": "", + "Body": "new 2", + "FromCountry": "DE", + "To": "+4915700000000", + "ToZip": "", + "NumSegments": "1", + "MessageSid": "SM1bd5330f81336e8b54c2d3d9fc1bcb6c", + "AccountSid": "AC78349f4b974f5f6907c513a77d96756d", + "From": "+491710000000", + "ApiVersion": "2010-04-01" +} \ No newline at end of file diff --git a/test/fixtures/mail67.box b/test/fixtures/mail67.box new file mode 100644 index 000000000..fe7137702 --- /dev/null +++ b/test/fixtures/mail67.box @@ -0,0 +1,19 @@ +Return-Path: +X-Original-To: info@example.de +Delivered-To: m032b9f7@dd38536.example.com +Received: from dd38536.example.com (dd0801.example.com [1.1.1.1]) + by dd38536.example.com (Postfix) with ESMTPSA id 343463D42403 + for ; Wed, 4 Jul 2018 10:02:32 +0200 (CEST) +MIME-Version: 1.0 +Content-Type: text/plain; charset=ISO-8859-1 +Content-Transfer-Encoding: 8bit +X-SenderIP: 1.1.1.1 +User-Agent: ALL-INKL Webmail 2.11 +Subject: Testmail - Alias in info@example.de Gruppe +From: "Bob Smith | deal" +To: info@example.de +Message-Id: <20180704080232.343463D42403@dd38536.example.com> +Date: Wed, 4 Jul 2018 10:02:32 +0200 (CEST) +X-KasLoop: m032b9f7 + + diff --git a/test/integration/twilio_sms_controller_test.rb b/test/integration/twilio_sms_controller_test.rb new file mode 100644 index 000000000..77ab2fdc7 --- /dev/null +++ b/test/integration/twilio_sms_controller_test.rb @@ -0,0 +1,232 @@ +require 'test_helper' +require 'rexml/document' +require 'webmock/minitest' + +class TwilioSmsControllerTest < ActionDispatch::IntegrationTest + setup do + @headers = { 'ACCEPT' => 'application/json', 'CONTENT_TYPE' => 'application/json' } + end + + test 'basic call' do + + # configure twilio channel + bot_id = 123_456_789 + group_id = Group.find_by(name: 'Users').id + + UserInfo.current_user_id = 1 + channel = Channel.create!( + area: 'Sms::Account', + options: { + adapter: 'sms/twilio', + webhook_token: 'f409460e50f76d331fdac8ba7b7963b6', + account_id: '111', + token: '223', + sender: '333', + }, + group_id: nil, + active: true, + ) + + # create agent + agent = User.create!( + login: 'tickets-agent@example.com', + firstname: 'Tickets', + lastname: 'Agent', + email: 'tickets-agent@example.com', + password: 'agentpw', + active: true, + roles: Role.where(name: 'Agent'), + groups: Group.all, + ) + + # process inbound sms + post '/api/v1/sms_webhook', params: read_messaage('inbound_sms1'), headers: @headers + assert_response(404) + result = JSON.parse(@response.body) + + post '/api/v1/sms_webhook/not_existing', params: read_messaage('inbound_sms1'), headers: @headers + assert_response(404) + result = JSON.parse(@response.body) + + post '/api/v1/sms_webhook/f409460e50f76d331fdac8ba7b7963b6', params: read_messaage('inbound_sms1'), headers: @headers + assert_response(422) + result = JSON.parse(@response.body) + assert_equal(result['error'], 'Can\'t use Channel::Driver::Sms::Twilio: #') + + channel.group_id = Group.first.id + channel.save! + + post '/api/v1/sms_webhook/f409460e50f76d331fdac8ba7b7963b6', params: read_messaage('inbound_sms1'), headers: @headers + assert_response(200) + response = REXML::Document.new(@response.body) + assert_equal(response.elements.count, 1) + + ticket = Ticket.last + article = Ticket::Article.last + customer = User.last + assert_equal(1, ticket.articles.count) + assert_equal('Ldfhxhcuffufuf. Fifififig. Fifififiif F...', ticket.title) + assert_equal('new', ticket.state.name) + assert_equal(group_id, ticket.group_id) + assert_equal(customer.id, ticket.customer_id) + assert_equal(customer.id, ticket.created_by_id) + assert_equal('+491710000000', article.from) + assert_equal('+4915700000000', article.to) + assert_nil(article.cc) + assert_nil(article.subject) + assert_equal('Ldfhxhcuffufuf. Fifififig. Fifififiif Fifififiif Fifififiif Fifififiif Fifififiif', article.body) + assert_equal(customer.id, article.created_by_id) + assert_equal('Customer', article.sender.name) + assert_equal('sms', article.type.name) + + post '/api/v1/sms_webhook/f409460e50f76d331fdac8ba7b7963b6', params: read_messaage('inbound_sms2'), headers: @headers + assert_response(200) + response = REXML::Document.new(@response.body) + assert_equal(response.elements.count, 1) + + ticket.reload + assert_equal(2, ticket.articles.count) + assert_equal('new', ticket.state.name) + + article = Ticket::Article.last + assert_equal('+491710000000', article.from) + assert_equal('+4915700000000', article.to) + assert_nil(article.cc) + assert_nil(article.subject) + assert_equal('Follow up', article.body) + assert_equal('Customer', article.sender.name) + assert_equal('sms', article.type.name) + assert_equal(customer.id, article.created_by_id) + + # check duplicate callbacks + post '/api/v1/sms_webhook/f409460e50f76d331fdac8ba7b7963b6', params: read_messaage('inbound_sms2'), headers: @headers + assert_response(200) + response = REXML::Document.new(@response.body) + assert_equal(response.elements.count, 1) + + ticket.reload + assert_equal(2, ticket.articles.count) + assert_equal('new', ticket.state.name) + assert_equal(Ticket::Article.last.id, article.id) + + # new ticket need to be create + ticket.state = Ticket::State.find_by(name: 'closed') + ticket.save! + + post '/api/v1/sms_webhook/f409460e50f76d331fdac8ba7b7963b6', params: read_messaage('inbound_sms3'), headers: @headers + assert_response(200) + response = REXML::Document.new(@response.body) + assert_equal(response.elements.count, 1) + + ticket.reload + assert_equal(2, ticket.articles.count) + assert_not_equal(Ticket.last.id, ticket.id) + assert_equal('closed', ticket.state.name) + + ticket = Ticket.last + article = Ticket::Article.last + customer = User.last + assert_equal(1, ticket.articles.count) + assert_equal('new 2', ticket.title) + assert_equal(group_id, ticket.group_id) + assert_equal(customer.id, ticket.customer_id) + assert_equal(customer.id, ticket.created_by_id) + assert_equal('+491710000000', article.from) + assert_equal('+4915700000000', article.to) + assert_nil(article.cc) + assert_nil(article.subject) + assert_equal('new 2', article.body) + assert_equal(customer.id, article.created_by_id) + assert_equal('Customer', article.sender.name) + assert_equal('sms', article.type.name) + + # reply by agent + credentials = ActionController::HttpAuthentication::Basic.encode_credentials('tickets-agent@example.com', 'agentpw') + params = { + ticket_id: ticket.id, + body: 'some test', + type: 'sms', + } + post '/api/v1/ticket_articles', params: params.to_json, headers: @headers.merge('Authorization' => credentials) + assert_response(201) + result = JSON.parse(@response.body) + assert_equal(Hash, result.class) + assert_nil(result['subject']) + assert_equal('some test', result['body']) + assert_equal('text/plain', result['content_type']) + assert_equal(agent.id, result['updated_by_id']) + assert_equal(agent.id, result['created_by_id']) + + stub_request(:post, 'https://api.twilio.com/2010-04-01/Accounts/111/Messages.json') + .with( + body: { + 'Body' => 'some test', + 'From' => '333', + 'To' => nil, + }, + headers: { + 'Accept' => 'application/json', + 'Accept-Charset' => 'utf-8', + 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', + 'Authorization' => 'Basic MTExOjIyMw==', + 'Content-Type' => 'application/x-www-form-urlencoded', + 'User-Agent' => 'twilio-ruby/5.10.2 (ruby/x86_64-darwin16 2.4.4-p296)' + } + ).to_return(status: 200, body: '', headers: {}) + + assert_nil(article.preferences[:delivery_retry]) + assert_nil(article.preferences[:delivery_status]) + + Observer::Transaction.commit + Scheduler.worker(true) + + article = Ticket::Article.find(result['id']) + assert_equal(1, article.preferences[:delivery_retry]) + assert_equal('success', article.preferences[:delivery_status]) + + end + + test 'customer based on already existing mobile attibute' do + + customer = User.create!( + firstname: '', + lastname: '', + email: 'me@example.com', + mobile: '01710000000', + note: '', + updated_by_id: 1, + created_by_id: 1, + ) + Observer::Transaction.commit + Scheduler.worker(true) + + # configure twilio channel + bot_id = 123_456_789 + group_id = Group.find_by(name: 'Users').id + + UserInfo.current_user_id = 1 + channel = Channel.create!( + area: 'Sms::Account', + options: { + adapter: 'sms/twilio', + webhook_token: 'f409460e50f76d331fdac8ba7b7963b6', + account_id: '111', + token: '223', + sender: '333', + }, + group_id: group_id, + active: true, + ) + + post '/api/v1/sms_webhook/f409460e50f76d331fdac8ba7b7963b6', params: read_messaage('inbound_sms1'), headers: @headers + assert_response(200) + response = REXML::Document.new(@response.body) + assert_equal(response.elements.count, 1) + + assert_equal(User.last.id, customer.id) + end + + def read_messaage(file) + File.read(Rails.root.join('test', 'data', 'twilio', "#{file}.json")) + end +end diff --git a/test/unit/notification_factory_renderer_test.rb b/test/unit/notification_factory_renderer_test.rb index 1bca33044..1eb6cb6c9 100644 --- a/test/unit/notification_factory_renderer_test.rb +++ b/test/unit/notification_factory_renderer_test.rb @@ -583,4 +583,56 @@ class NotificationFactoryRendererTest < ActiveSupport::TestCase end + test 'methods with single Integer parameter' do + + template = "\#{ticket.title.first(3)}" + result = described_class.new( + { + ticket: ticket, + }, + 'en-us', + template, + ).render + assert_equal(CGI.escapeHTML(''), result) + + template = "\#{ticket.title.last(4)}" + result = described_class.new( + { + ticket: ticket, + }, + 'en-us', + template, + ).render + assert_equal(CGI.escapeHTML(''), result) + + template = "\#{ticket.title.slice(3, 4)}" + result = described_class.new( + { + ticket: ticket, + }, + 'en-us', + template, + ).render + assert_equal(CGI.escapeHTML("\#{ticket.title.slice(3,4) / invalid parameter: 3,4}"), result) + + template = "\#{ticket.title.first('some invalid parameter')}" + result = described_class.new( + { + ticket: ticket, + }, + 'en-us', + template, + ).render + assert_equal("\#{ticket.title.first(someinvalidparameter) / invalid parameter: someinvalidparameter}", result) + + template = "\#{ticket.title.chomp(`cat /etc/passwd`)}" + result = described_class.new( + { + ticket: ticket, + }, + 'en-us', + template, + ).render + assert_equal("\#{ticket.title.chomp(`cat/etc/passwd`) / not allowed}", result) + end end