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') %>
-
-
+ <% if @notificationType is 'email': %>
+
+ <% end %>
+
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 @@
+
+
\ 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