Просмотр исходного кода

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

Martin Edenhofer 6 лет назад
Родитель
Сommit
22b2f44ba0

+ 3 - 0
Gemfile

@@ -105,6 +105,9 @@ gem 'icalendar-recurrence'
 # feature - phone number formatting
 gem 'telephone_number'
 
+# feature - SMS
+gem 'twilio-ruby'
+
 # integrations
 gem 'clearbit'
 gem 'net-ldap'

+ 5 - 0
Gemfile.lock

@@ -447,6 +447,10 @@ GEM
     thread_safe (0.3.6)
     tilt (2.0.8)
     tins (1.15.1)
+    twilio-ruby (5.10.2)
+      faraday (~> 0.9)
+      jwt (>= 1.5, <= 2.5)
+      nokogiri (>= 1.6, < 2.0)
     twitter (6.2.0)
       addressable (~> 2.3)
       buftok (~> 0.2.0)
@@ -578,6 +582,7 @@ DEPENDENCIES
   telephone_number
   test-unit
   therubyracer
+  twilio-ruby
   twitter
   uglifier
   unicorn

+ 2 - 1
app/assets/javascripts/app/controllers/_application_controller_form.coffee

@@ -25,7 +25,8 @@ class App.ControllerForm extends App.Controller
       @form = @formGen()
 
     # add alert placeholder
-    @form.prepend('<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

+ 9 - 9
app/assets/javascripts/app/controllers/_application_controller_generic.coffee

@@ -295,15 +295,15 @@ class App.ControllerGenericDestroyConfirm extends App.ControllerModal
     App.i18n.translateContent('Sure to delete this object?')
 
   onSubmit: =>
-    @item.destroy(
-      done: =>
-        @close()
-        if @callback
-          @callback()
-      fail: =>
-        @log 'errors'
-        @close()
-    )
+    options = @options || {}
+    options.done = =>
+      @close()
+      if @callback
+        @callback()
+    options.fail = =>
+      @log 'errors'
+      @close()
+    @item.destroy(options)
 
 class App.ControllerConfirm extends App.ControllerModal
   buttonClose: true

+ 1 - 1
app/assets/javascripts/app/controllers/_channel/email.coffee

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

+ 442 - 0
app/assets/javascripts/app/controllers/_channel/sms.coffee

@@ -0,0 +1,442 @@
+class App.ChannelSms extends App.ControllerTabs
+  requiredPermission: 'admin.channel_sms'
+  header: 'SMS'
+  constructor: ->
+    super
+
+    @title 'SMS', true
+    @tabs = [
+      {
+        name:       'Accounts',
+        target:     'c-account',
+        controller: App.ChannelSmsAccountOverview,
+      },
+    ]
+
+    @render()
+
+class App.ChannelSmsAccountOverview extends App.Controller
+  events:
+    'click .js-channelEdit': 'change'
+    'click .js-channelDelete': 'delete'
+    'click .js-channelDisable': 'disable'
+    'click .js-channelEnable': 'enable'
+    'click .js-editNotification': 'editNotification'
+
+  constructor: ->
+    super
+    @interval(@load, 30000)
+    #@load()
+
+  load: =>
+
+    @startLoading()
+
+    @ajax(
+      id:   'sms_index'
+      type: 'GET'
+      url:  "#{@apiPath}/channels_sms"
+      processData: true
+      success: (data, status, xhr) =>
+        @config = data.config
+        @stopLoading()
+        App.Collection.loadAssets(data.assets)
+        @render(data)
+    )
+
+  render: (data = {}) =>
+
+    @channelDriver = data.channel_driver
+
+    # get channels
+    @account_channels = []
+    for channel_id in data.account_channel_ids
+      account_channel = App.Channel.fullLocal(channel_id)
+      if account_channel.group_id
+        account_channel.group = App.Group.find(account_channel.group_id)
+      else
+        account_channel.group = '-'
+      @account_channels.push account_channel
+
+    # get channels
+    @notification_channels = []
+    for channel_id in data.notification_channel_ids
+      @notification_channels.push App.Channel.find(channel_id)
+
+    @html App.view('channel/sms_account_overview')(
+      account_channels:      @account_channels
+      notification_channels: @notification_channels
+      config:                @config
+    )
+
+  change: (e) =>
+    e.preventDefault()
+    id = $(e.target).closest('.action').data('id')
+    if !id
+      channel = new App.Channel(active: true)
+    else
+      channel = App.Channel.find(id)
+    new App.ChannelSmsAccount(
+      container:     @el.closest('.content')
+      channel:       channel
+      callback:      @load
+      channelDriver: @channelDriver
+      config:        @config
+    )
+
+  delete: (e) =>
+    e.preventDefault()
+    id = $(e.target).closest('.action').data('id')
+    channel = App.Channel.find(id)
+    new App.ControllerGenericDestroyConfirm(
+      item: channel
+      options:
+        url: "/api/v1/channels_sms/#{channel.id}"
+      container: @el.closest('.content')
+      callback: =>
+        @load()
+    )
+
+  disable: (e) =>
+    e.preventDefault()
+    id   = $(e.target).closest('.action').data('id')
+    @ajax(
+      id:   'sms_disable'
+      type: 'POST'
+      url:  "#{@apiPath}/channels_sms_disable"
+      data: JSON.stringify(id: id)
+      processData: true
+      success: =>
+        @load()
+    )
+
+  enable: (e) =>
+    e.preventDefault()
+    id   = $(e.target).closest('.action').data('id')
+    @ajax(
+      id:   'sms_enable'
+      type: 'POST'
+      url:  "#{@apiPath}/channels_sms_enable"
+      data: JSON.stringify(id: id)
+      processData: true
+      success: =>
+        @load()
+    )
+
+  editNotification: (e) =>
+    e.preventDefault()
+    id      = $(e.target).closest('.action').data('id')
+    channel = App.Channel.find(id)
+    new App.ChannelSmsNotification(
+      container:     @el.closest('.content')
+      channel:       channel
+      callback:      @load
+      channelDriver: @channelDriver
+      config:        @config
+    )
+
+class App.ChannelSmsAccount extends App.ControllerModal
+  head: 'SMS Account'
+  buttonCancel: true
+  centerButtons: [
+    {
+      text: 'Test'
+      className: 'js-test'
+    }
+  ]
+  elements:
+    'form': 'form'
+    'select[name="options::adapter"]': 'adapterSelect'
+  events:
+    'click .js-test': 'onTest'
+
+  content: ->
+    el = $('<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')

+ 16 - 6
app/assets/javascripts/app/controllers/_ui_element/ticket_perform_action.coffee

@@ -18,6 +18,7 @@ class App.UiElement.ticket_perform_action
     for groupKey, groupMeta of groups
       if !groupMeta.model || !App[groupMeta.model]
         elements["#{groupKey}.email"] = { name: 'email', display: 'Email' }
+        elements["#{groupKey}.sms"] = { name: 'sms', display: 'SMS' }
       else
 
         for row in App[groupMeta.model].configure_attributes
@@ -161,9 +162,11 @@ class App.UiElement.ticket_perform_action
     if groupAndAttribute
       elementRow.find('.js-attributeSelector select').val(groupAndAttribute)
 
-    if groupAndAttribute is 'notification.email'
+    notificationTypeMatch = groupAndAttribute.match(/^notification.([\w]+)$/)
+
+    if _.isArray(notificationTypeMatch) && notificationType = notificationTypeMatch[1]
       elementRow.find('.js-setAttribute').html('')
-      @buildRecipientList(elementFull, elementRow, groupAndAttribute, elements, meta, attribute)
+      @buildRecipientList(notificationType, elementFull, elementRow, groupAndAttribute, elements, meta, attribute)
     else
       elementRow.find('.js-setNotification').html('')
       if !elementRow.find('.js-setAttribute div').get(0)
@@ -304,9 +307,11 @@ class App.UiElement.ticket_perform_action
 
     elementRow.find('.js-value').removeClass('hide').html(item)
 
-  @buildRecipientList: (elementFull, elementRow, groupAndAttribute, elements, meta, attribute) ->
+  @buildRecipientList: (notificationType, elementFull, elementRow, groupAndAttribute, elements, meta, attribute) ->
+
+    return if elementRow.find(".js-setNotification .js-body-#{notificationType}").get(0)
 
-    return if elementRow.find('.js-setNotification .js-body').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()

+ 1 - 1
app/assets/javascripts/app/controllers/agent_ticket_create.coffee

@@ -380,7 +380,7 @@ class App.TicketCreate extends App.Controller
     @tokanice()
 
   tokanice: ->
-    App.Utils.tokaniceEmails('.content.active input[name=cc]')
+    App.Utils.tokanice('.content.active input[name=cc]', 'email')
 
   localUserInfo: (e) =>
     return if !@sidebarWidget

+ 79 - 0
app/assets/javascripts/app/controllers/ticket_zoom/article_action/sms_reply.coffee

@@ -0,0 +1,79 @@
+class SmsReply
+  @action: (actions, ticket, article, ui) ->
+    return actions if ui.permissionCheck('ticket.customer')
+
+    if article.sender.name is 'Customer' && article.type.name is 'sms'
+      actions.push {
+        name: 'reply'
+        type: 'smsMessageReply'
+        icon: 'reply'
+        href: '#'
+      }
+
+    actions
+
+  @perform: (articleContainer, type, ticket, article, ui) ->
+    return true if type isnt 'smsMessageReply'
+
+    ui.scrollToCompose()
+
+    # get reference article
+    type = App.TicketArticleType.find(article.type_id)
+
+    articleNew = {
+      to:          article.from
+      cc:          ''
+      body:        ''
+      in_reply_to: ''
+    }
+
+    if article.message_id
+      articleNew.in_reply_to = article.message_id
+
+    # get current body
+    articleNew.body = ui.el.closest('.ticketZoom').find('.article-add [data-name="body"]').html().trim() || ''
+
+    App.Event.trigger('ui::ticket::setArticleType', {
+      ticket: ticket
+      type: type
+      article: articleNew
+      position: 'end'
+    })
+
+    true
+
+  @articleTypes: (articleTypes, ticket, ui) ->
+    return articleTypes if !ui.permissionCheck('ticket.agent')
+
+    return articleTypes if !ticket || !ticket.create_article_type_id
+
+    articleTypeCreate = App.TicketArticleType.find(ticket.create_article_type_id).name
+
+    return articleTypes if articleTypeCreate isnt 'sms'
+    articleTypes.push {
+      name:              'sms'
+      icon:              'sms'
+      attributes:        ['to']
+      internal:          false,
+      features:          ['body:limit']
+      maxTextLength:     160
+      warningTextLength: 30
+    }
+    articleTypes
+
+  @setArticleTypePost: (type, ticket, ui) ->
+    return if type isnt 'telegram personal-message'
+    rawHTML = ui.$('[data-name=body]').html()
+    cleanHTML = App.Utils.htmlRemoveRichtext(rawHTML)
+    if cleanHTML && cleanHTML.html() != rawHTML
+      ui.$('[data-name=body]').html(cleanHTML)
+
+  @params: (type, params, ui) ->
+    if type is 'sms'
+      App.Utils.htmlRemoveRichtext(ui.$('[data-name=body]'), false)
+      params.content_type = 'text/plain'
+      params.body = App.Utils.html2text(params.body, true)
+
+    params
+
+App.Config.set('300-SmsReply', SmsReply, 'TicketZoomArticleAction')

+ 5 - 5
app/assets/javascripts/app/controllers/ticket_zoom/article_new.coffee

@@ -98,7 +98,7 @@ class App.TicketZoomArticleNew extends App.Controller
     # set expand of text area only once
     @bind('ui::ticket::shown', (data) =>
       return if data.ticket_id.toString() isnt @ticket.id.toString()
-      @tokanice()
+      @tokanice(@type)
     )
 
     # rerender, e. g. on language change
@@ -106,8 +106,8 @@ class App.TicketZoomArticleNew extends App.Controller
       @render()
     )
 
-  tokanice: ->
-    App.Utils.tokaniceEmails('.content.active .js-to, .js-cc, js-bcc')
+  tokanice: (type = 'email') ->
+    App.Utils.tokanice('.content.active .js-to, .js-cc, js-bcc', type)
 
   setPossibleArticleTypes: =>
     @articleTypes = []
@@ -163,7 +163,7 @@ class App.TicketZoomArticleNew extends App.Controller
       position:  'right'
     )
 
-    @tokanice()
+    @tokanice(@type)
 
     @$('[data-name="body"]').ce({
       mode:      'richtext'
@@ -346,7 +346,7 @@ class App.TicketZoomArticleNew extends App.Controller
     @setArticleTypePost(articleTypeToSet)
 
     $(window).off('click.ticket-zoom-select-type')
-    @tokanice()
+    @tokanice(articleTypeToSet)
 
   hideSelectableArticleType: =>
     @el.find('.js-articleTypes').addClass('is-hidden')

Некоторые файлы не были показаны из-за большого количества измененных файлов