Initial SMS integration for trigger notifications and additional channel. Thanks to sys4 AG!

This commit is contained in:
Martin Edenhofer 2018-10-16 10:45:15 +02:00
parent 271102057d
commit 22b2f44ba0
60 changed files with 2336 additions and 335 deletions

View file

@ -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'

View file

@ -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

View file

@ -25,7 +25,8 @@ class App.ControllerForm extends App.Controller
@form = @formGen()
# add alert placeholder
@form.prepend('<div class="alert alert--danger js-alert hide" role="alert"></div>')
@form.prepend('<div class="alert alert--danger js-danger js-alert hide" role="alert"></div>')
@form.prepend('<div class="alert alert--success js-success hide" role="alert"></div>')
# if element is given, prepend form to it
if @el

View file

@ -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

View file

@ -67,7 +67,7 @@ class App.ChannelEmailFilter extends App.Controller
container: @el.closest('.content')
callback: @load
)
edit: (id, e) =>
e.preventDefault()
new App.ControllerGenericEdit(

View file

@ -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 = $('<div><div class="js-channelAdapterSelector"></div><div class="js-channelWebhook"></div><div class="js-channelAdapterOptions"></div></div>')
# 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 = $('<div><div class="js-channelAdapterSelector"></div><div class="js-channelAdapterOptions"></div></div>')
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')

View file

@ -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()

View file

@ -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

View file

@ -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')

View file

@ -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')

View file

@ -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 = ''

View file

@ -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

View file

@ -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'

View file

@ -0,0 +1,11 @@
<div class="page-header">
<div class="page-header-title">
<% if @params.hasSwitch: %>
<div class="zammad-switch zammad-switch--small js-channelActive">
<input name="sms_channel_active" type="checkbox" id="sms_channel_active">
<label for="sms_channel_active"></label>
</div>
<% end %>
<h1><%- @T(@params.header) %> <small></small></h1>
</div>
</div>

View file

@ -0,0 +1,130 @@
<h2><%- @T('SMS Accounts') %></h2>
<% if _.isEmpty(@account_channels): %>
<p><%- @T('You have no configured account right now.') %></p>
<% else: %>
<% for channel in @account_channels: %>
<div class="action <% if channel.active isnt true: %>is-inactive<% end %>" data-id="<%- channel.id %>">
<div class="action-flow" style="width: 100%;">
<div class="action-block action-block--flex">
<div class="horizontal">
<h3><%- @Icon('status', channel.status_in + " inline") %> <%- @T('Inbound') %></h3>
</div>
<% if !_.isEmpty(channel.last_log_in): %>
<div class="alert alert--danger">
<%= channel.last_log_in %>
</div>
<% end %>
</div>
<div class="action-block action-block--flex">
<div class="horizontal">
<h3><%- @Icon('status', channel.status_out + " inline") %> <%- @T('Outbound') %></h3>
</div>
<% if !_.isEmpty(channel.last_log_out): %>
<div class="alert alert--danger">
<%= channel.last_log_out %>
</div>
<% end %>
</div>
</div>
<div class="action-flow" style="width: 100%;">
<div class="action-block action-block--flex">
<table class="key-value">
<tr>
<td><%- @T('Adapter') %>
<td><%= channel.options.adapter %>
<tr>
<td><%- @T('Webhook') %>
<td><%= @C('http_type') %>://<%= @C('fqdn') %>/api/v1/sms_webhook/<%= channel.options?.webhook_token || '?' %>
<tr>
<td><%- @T('Account') %>
<td><%= channel.options.account_id %>
<tr>
<td><%- @T('Token') %>
<td><%= @M(channel.options.token, 1, 2) %>
<tr>
<td><%- @T('Sender') %>
<td><%= channel.options.sender %>
<tr>
<td><%- @T('Group') %>
<td>
<div href="#" class="js-channelEdit <% if channel.group.active is false: %>alert alert--danger<% end %>">
<% 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 %>
</div>
</table>
</div>
</div>
<div class="action-controls">
<div class="btn btn--secondary js-channelEdit"><%- @T('Edit') %></div>
<% if channel.active is true: %>
<div class="btn btn--secondary js-channelDisable"><%- @T('Disable') %></div>
<% else: %>
<div class="btn btn--secondary js-channelEnable"><%- @T('Enable') %></div>
<% end %>
<div class="btn btn--danger btn--secondary js-channelDelete"><%- @T('Delete') %></div>
</div>
</div>
<% end %>
<% end %>
<a class="btn btn--success js-channelEdit"><%- @T('New') %></a>
<h2><%- @T('SMS Notification') %></h2>
<% if _.isEmpty(@notification_channels): %>
<p><%- @T('You have no configured account right now.') %></p>
<a class="btn btn--success js-editNotification"><%- @T('New') %></a>
<% else: %>
<% for channel in @notification_channels: %>
<div class="action <% if channel.active isnt true: %>is-inactive<% end %>" data-id="<%- channel.id %>">
<div class="action-flow action-flow--row">
<div class="action-block action-block--flex">
<div class="horizontal">
<h3><%- @Icon('status', channel.status_out + " inline") %> <%- @T('Outbound') %></h3>
</div>
<% if channel.status_in is 'error': %>
<div class="alert alert--danger"><%= channel.last_log_in %></div>
<% end %>
<% if channel.status_out is 'error': %>
<div class="alert alert--danger"><%= channel.last_log_out %></div>
<% end %>
<table class="key-value">
<% if channel.options: %>
<tr>
<td><%- @T('Adapter') %>
<td><%= channel.options.adapter %>
<tr>
<td><%- @T('Token') %>
<td><%= @M(channel.options.token, 1, 2) %>
<% end %>
<tr>
<td><%- @T('Sender') %>
<td><%= channel.options.sender %>
</table>
</div>
</div>
<div class="action-controls">
<div class="btn btn--secondary js-editNotification"><%- @T('Edit') %></div>
<% if channel.active is true: %>
<div class="btn btn--secondary js-channelDisable"><%- @T('Disable') %></div>
<% else: %>
<div class="btn btn--secondary js-channelEnable"><%- @T('Enable') %></div>
<% end %>
<div class="btn btn--danger btn--secondary js-channelDelete"><%- @T('Delete') %></div>
</div>
</div>
<% end %>
<% end %>

View file

@ -3,6 +3,8 @@
<select></select>
<%- @Icon('arrow-down', 'dropdown-arrow') %>
</div>
<div class="controls js-subject"><input type="text" name="<%= @name %>::subject" value="<%= @meta.subject %>" style="width: 100%;" placeholder="<%- @T('Subject') %>"></div>
<div class="controls js-body"><div class="richtext form-control"><div contenteditable="true" data-name="<%= @name %>::body"><%- @meta.body %></div></div></div>
<% if @notificationType is 'email': %>
<div class="controls js-subject"><input type="text" name="<%= @name %>::subject" value="<%= @meta.subject %>" style="width: 100%;" placeholder="<%- @T('Subject') %>"></div>
<% end %>
<div class="controls js-body js-body-<%- @notificationType %>"><div class="richtext form-control"><div contenteditable="true" data-name="<%= @name %>::body"><%- @meta.body %></div></div></div>
</div>

View file

@ -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; }

View file

@ -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%);

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 = ''

View file

@ -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))

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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'

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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'

View file

@ -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

View file

@ -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',

View file

@ -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

Binary file not shown.

View file

@ -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

View file

@ -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',

View file

@ -92,15 +92,33 @@ examples how to use
break
end
arguments = nil
if /\A(?<method_id>[^\(]+)\((?<parameter>[^\)]+)\)\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

View file

@ -405,11 +405,21 @@
logotype
</title>
<path d="M0 14.752h11.657V9.697H8.563v2.167h-3.24a8.08 8.08 0 0 0-.887.062c0-.042.371-.33.66-.743l6.314-8.955V0H.083v4.972h3.094V2.89h2.889c.413 0 .887-.062.887-.062 0 .041-.371.33-.66.742L0 12.483v2.27zm13.081-3.033c0 2.146 1.63 3.281 3.487 3.281 2.27 0 3.136-1.754 3.136-1.754h.042s-.042.145-.042.33v.062c0 .516.392 1.114 1.424 1.114h3.074v-2.579h-.784c-.289 0-.454-.165-.454-.454V8.212c0-2.786-1.65-4.333-4.745-4.333-2.683 0-4.498 1.382-4.498 1.382l1.238 2.414s1.485-1.01 2.93-1.01c.804 0 1.506.309 1.506 1.175v.31h-.64c-1.65 0-5.674.454-5.674 3.57zm3.59-.247c0-.95 1.259-1.32 2.435-1.32h.33v.247c0 .928-.908 2.022-1.816 2.022-.577 0-.949-.392-.949-.95zm8.831 3.28h6.252v-2.579h-1.341v-1.609c0-1.713.412-3.446 2.042-3.446.681 0 .826.557.826 1.341v6.293h4.91v-2.579h-1.34v-1.815c0-1.775.494-3.24 2.042-3.24.68 0 .825.557.825 1.341v6.293h4.91v-2.579h-1.34V7.861c0-2.93-1.527-3.982-3.343-3.982-1.63 0-2.93.867-3.508 2.001h-.04c-.517-1.485-1.61-2.001-2.766-2.001-1.795 0-2.826 1.217-3.363 2.043h-.041s.02-.145.02-.248v-.35c0-.723-.515-1.197-1.485-1.197h-3.363v2.579h.99c.29 0 .454.144.454.433v5.034h-1.34v2.58zm20.447 0h6.252v-2.579H50.86v-1.609c0-1.713.412-3.446 2.042-3.446.681 0 .826.557.826 1.341v6.293h4.91v-2.579h-1.34v-1.815c0-1.775.494-3.24 2.042-3.24.68 0 .825.557.825 1.341v6.293h4.91v-2.579h-1.34V7.861c0-2.93-1.527-3.982-3.343-3.982-1.63 0-2.93.867-3.508 2.001h-.04c-.517-1.485-1.61-2.001-2.766-2.001-1.795 0-2.826 1.217-3.363 2.043h-.041s.02-.145.02-.248v-.35c0-.723-.515-1.197-1.485-1.197h-3.363v2.579h.99c.29 0 .454.144.454.433v5.034h-1.34v2.58zm20.055-3.033c0 2.146 1.63 3.281 3.487 3.281 2.27 0 3.136-1.754 3.136-1.754h.042s-.042.145-.042.33v.062c0 .516.392 1.114 1.424 1.114h3.074v-2.579h-.784c-.289 0-.454-.165-.454-.454V8.212c0-2.786-1.65-4.333-4.745-4.333-2.683 0-4.498 1.382-4.498 1.382l1.238 2.414s1.485-1.01 2.93-1.01c.804 0 1.506.309 1.506 1.175v.31h-.64c-1.65 0-5.674.454-5.674 3.57zm3.59-.247c0-.95 1.259-1.32 2.435-1.32h.33v.247c0 .928-.908 2.022-1.816 2.022-.577 0-.949-.392-.949-.95zm8.522-2.022c0 3.28 1.856 5.55 4.683 5.55 2.476 0 3.28-1.754 3.28-1.754h.042s-.041.145-.041.33v.062c0 .516.392 1.114 1.423 1.114h3.075v-2.579h-.784c-.29 0-.454-.165-.454-.454V0h-5.014v2.58h1.444v1.67c0 .392.02.681.02.681h-.04s-.681-1.052-2.724-1.052c-2.93 0-4.91 2.187-4.91 5.57zm3.59 0c0-1.65.949-2.559 2.125-2.559 1.362 0 2.042 1.238 2.042 2.641 0 1.713-1.031 2.518-2.104 2.518-1.217 0-2.063-1.053-2.063-2.6z" fill-rule="evenodd"/>
</symbol><symbol id="icon-long-arrow-down" viewBox="0 0 11 11">
<title>
long-arrow-down
</title>
<path d="M4.81 8.605c-.24-.33-.576-.66-.96-1.024L1.412 5.27.5 6.375 5.5 11l5-4.625-.913-1.106-2.308 2.196c-.465.43-.833.777-1.09 1.14l-.048-.016V0H4.859v8.589l-.048.016z" fill-rule="evenodd"/>
</symbol><symbol id="icon-long-arrow-right" viewBox="0 0 11 11">
<title>
long-arrow-right
</title>
<path d="M8.347 6.203c-.32.24-.64.577-.993.962L5.11 9.6l1.073.913 4.486-4.998L6.184.516l-1.073.913 2.13 2.307c.417.465.753.833 1.106 1.09l-.016.048H0v1.281h8.33l.017.048z" fill-rule="evenodd"/>
</symbol><symbol id="icon-low-priority" viewBox="0 0 16 16">
<title>
low-priority
</title>
<path d="M7 11.18L3.886 8.031a1 1 0 0 0-1.41 0l.093-.094a.994.994 0 0 0-.008 1.411l4.719 4.733a.988.988 0 0 0 1.404 0l4.714-4.733c.388-.39.393-1.024.005-1.426l.119.124a.973.973 0 0 0-1.399-.015l-3.121 3.15V1.961a.997.997 0 0 0-1-.998c-.553 0-1 .44-1 .998v9.218z" fill-rule="evenodd"/>
</symbol><symbol id="icon-magnifier" viewBox="0 0 15 15">
<title>
magnifier
@ -634,6 +644,11 @@
small-dot
</title>
<circle cx="8" cy="8" r="3" fill-rule="evenodd"/>
</symbol><symbol id="icon-sms" viewBox="0 0 17 17">
<title>
sms
</title>
<path d="M7.616 13.923L3.25 16.75l.714-3.847C1.582 11.69 0 9.546 0 7.105 0 3.319 3.806.25 8.5.25S17 3.319 17 7.105c0 3.786-3.806 6.855-8.5 6.855-.298 0-.593-.013-.884-.037zm-3.71-9.248c-.984 0-1.708.522-1.708 1.338 0 .689.39 1.117 1.448 1.438.69.208.868.361.868.703 0 .354-.287.568-.752.568-.47 0-.847-.167-1.195-.455L2 8.877c.39.367.977.661 1.797.661 1.181 0 1.878-.602 1.878-1.465 0-.856-.512-1.217-1.407-1.498-.738-.228-.915-.348-.915-.642 0-.295.246-.455.635-.455.382 0 .71.127 1.038.388l.52-.596c-.417-.388-.916-.595-1.64-.595zm6.729.114H9.214L8.55 7.933l-.71-3.144H6.427L6.07 9.425h1.093l.082-1.8c.028-.649.028-1.224-.02-1.906l.779 3.217h1.059l.73-3.217c-.034.575-.02 1.244.014 1.893l.082 1.813h1.1l-.355-4.636zm2.596-.114c-.984 0-1.708.522-1.708 1.338 0 .689.39 1.117 1.448 1.438.69.208.868.361.868.703 0 .354-.287.568-.752.568-.471 0-.847-.167-1.195-.455l-.567.61c.39.367.977.661 1.796.661 1.182 0 1.879-.602 1.879-1.465 0-.856-.512-1.217-1.407-1.498-.738-.228-.916-.348-.916-.642 0-.295.246-.455.636-.455.382 0 .71.127 1.038.388l.52-.596c-.418-.388-.916-.595-1.64-.595z" fill-rule="evenodd"/>
</symbol><symbol id="icon-split" viewBox="0 0 16 17">
<title>
split

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 76 KiB

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="17px" height="17px" viewBox="0 0 17 17" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 52.2 (67145) - http://www.bohemiancoding.com/sketch -->
<title>sms</title>
<desc>Created with Sketch.</desc>
<g id="sms" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<path d="M7.61649155,13.9230946 L3.25,16.75 L3.9643919,12.9032744 C1.58206933,11.6887873 0,9.54580983 0,7.10483871 C0,3.31901583 3.80557963,0.25 8.5,0.25 C13.1944204,0.25 17,3.31901583 17,7.10483871 C17,10.8906616 13.1944204,13.9596774 8.5,13.9596774 C8.20165766,13.9596774 7.90690529,13.9472819 7.61649155,13.9230946 Z M3.90593799,4.675 C2.92222806,4.675 2.19810825,5.19679314 2.19810825,6.01293113 C2.19810825,6.70196567 2.58749343,7.13010363 3.64634787,7.4512071 C4.33631109,7.65858643 4.51392538,7.81244851 4.51392538,8.15362095 C4.51392538,8.5081727 4.22700998,8.72224168 3.76248029,8.72224168 C3.29111929,8.72224168 2.91539674,8.55500029 2.56699947,8.2673451 L2,8.87610376 C2.38938518,9.24403482 2.97687861,9.53837967 3.79663689,9.53837967 C4.97845507,9.53837967 5.67524961,8.93631066 5.67524961,8.07334508 C5.67524961,7.21706916 5.16290068,6.85582775 4.2679979,6.57486221 C3.53021545,6.34741392 3.35260116,6.22700012 3.35260116,5.93265527 C3.35260116,5.63831042 3.59852864,5.47775868 3.98791382,5.47775868 C4.37046768,5.47775868 4.69837099,5.60486214 5.0262743,5.86575871 L5.54545455,5.27037935 C5.12874409,4.88237933 4.6300578,4.675 3.90593799,4.675 Z M10.6347872,4.78872415 L9.21387283,4.78872415 L8.55123489,7.93286231 L7.84077772,4.78872415 L6.42669469,4.78872415 L6.07146611,9.42465553 L7.16447714,9.42465553 L7.24645297,7.62513815 C7.27377824,6.97624155 7.27377824,6.40093116 7.22595901,5.71858629 L8.00472937,8.93631066 L9.06358382,8.93631066 L9.79453494,5.71858629 C9.76037835,6.29389667 9.77404099,6.96286224 9.80819758,7.61175884 L9.89017341,9.42465553 L10.9900158,9.42465553 L10.6347872,4.78872415 Z M13.2306884,4.675 C12.2469785,4.675 11.5228586,5.19679314 11.5228586,6.01293113 C11.5228586,6.70196567 11.9122438,7.13010363 12.9710983,7.4512071 C13.6610615,7.65858643 13.8386758,7.81244851 13.8386758,8.15362095 C13.8386758,8.5081727 13.5517604,8.72224168 13.0872307,8.72224168 C12.6158697,8.72224168 12.2401471,8.55500029 11.8917499,8.2673451 L11.3247504,8.87610376 C11.7141356,9.24403482 12.301629,9.53837967 13.1213873,9.53837967 C14.3032055,9.53837967 15,8.93631066 15,8.07334508 C15,7.21706916 14.4876511,6.85582775 13.5927483,6.57486221 C12.8549658,6.34741392 12.6773516,6.22700012 12.6773516,5.93265527 C12.6773516,5.63831042 12.923279,5.47775868 13.3126642,5.47775868 C13.6952181,5.47775868 14.0231214,5.60486214 14.3510247,5.86575871 L14.8702049,5.27037935 C14.4534945,4.88237933 13.9548082,4.675 13.2306884,4.675 Z" id="sms-bubble" fill="#50E3C2"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View file

@ -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

View file

@ -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

View file

@ -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) }

View file

@ -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

View file

@ -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

View file

@ -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&nbsp;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

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

19
test/fixtures/mail67.box vendored Normal file
View file

@ -0,0 +1,19 @@
Return-Path: <info@example.de>
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 <info@example.de>; 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" <info@example.de>
To: info@example.de
Message-Id: <20180704080232.343463D42403@dd38536.example.com>
Date: Wed, 4 Jul 2018 10:02:32 +0200 (CEST)
X-KasLoop: m032b9f7

View file

@ -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: #<Exceptions::UnprocessableEntity: Group needed in channel definition!>')
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

View file

@ -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('<b>'), result)
template = "\#{ticket.title.last(4)}"
result = described_class.new(
{
ticket: ticket,
},
'en-us',
template,
).render
assert_equal(CGI.escapeHTML('</b>'), 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