Browse Source

Fixes #5010 - WhatsApp Business Channel (first iteration)

Co-authored-by: Benjamin Scharf <bs@zammad.com>
Co-authored-by: Dominik Klein <dk@zammad.com>
Co-authored-by: Dusan Vuckovic <dv@zammad.com>
Co-authored-by: Mantas Masalskis <mm@zammad.com>
Co-authored-by: Martin Gruner <mg@zammad.com>
Co-authored-by: Tobias Schäfer <ts@zammad.com>
Florian Liebe 1 year ago
parent
commit
1ef8c23d72

+ 1 - 0
Gemfile

@@ -120,6 +120,7 @@ gem 'rack-attack'
 gem 'koala'
 gem 'telegram-bot-ruby'
 gem 'twitter'
+gem 'whatsapp_sdk'
 
 # channels - email additions
 gem 'email_address'

+ 7 - 0
Gemfile.lock

@@ -616,6 +616,7 @@ GEM
     snaky_hash (2.0.1)
       hashie
       version_gem (~> 1.1, >= 1.1.1)
+    sorbet-runtime (0.5.11255)
     sprockets (3.7.2)
       concurrent-ruby (~> 1.0)
       rack (> 1, < 3)
@@ -699,6 +700,11 @@ GEM
     websocket-driver (0.7.6)
       websocket-extensions (>= 0.1.0)
     websocket-extensions (0.1.5)
+    whatsapp_sdk (0.11.0)
+      faraday (~> 2)
+      faraday-multipart (~> 1)
+      sorbet-runtime (~> 0.5)
+      zeitwerk (~> 2)
     write_xlsx (1.11.2)
       nkf
       rubyzip (>= 1.0.0)
@@ -838,6 +844,7 @@ DEPENDENCIES
   vite_rails
   webauthn
   webmock
+  whatsapp_sdk
   write_xlsx
   zendesk_api
 

+ 5 - 0
LICENSE-ICONS-3RD-PARTY.json

@@ -834,6 +834,11 @@
         "url": "https:\/\/thenounproject.com\/search\/?q=user&i=10314",
         "license": "CC 3.0 Attribution"
     },
+    "whatsapp.svg": {
+        "author": "Meta",
+        "url": "https://about.meta.com/uk/brand/resources/whatsapp/whatsapp-brand/",
+        "license": ""
+    },
     "web.svg": {
         "author": "Zammad",
         "url": "",

+ 270 - 0
app/assets/javascripts/app/controllers/_channel/whatsapp.coffee

@@ -0,0 +1,270 @@
+class ChannelWhatsapp extends App.ControllerSubContent
+  @requiredPermission: 'admin.channel_whatsapp'
+  events:
+    'click .js-new':     'new'
+    'click .js-edit':    'edit'
+    'click .js-delete':  'delete'
+    'click .js-disable': 'disable'
+    'click .js-enable':  'enable'
+
+  constructor: ->
+    super
+
+    @load()
+
+  load: =>
+    @startLoading()
+    @ajax(
+      id: 'whatsapp_index'
+      type: 'GET'
+      url: "#{@apiPath}/channels/admin/whatsapp"
+      processData: true
+      success: (data) =>
+        @stopLoading()
+        App.Collection.loadAssets(data.assets)
+        @render(data)
+    )
+
+  render: (data) =>
+    channels = data.channel_ids.map (elem) -> App.Channel.find(elem)
+
+    @html App.view('whatsapp/index')(
+      channels: channels
+    )
+
+  new: (e) =>
+    e.preventDefault()
+
+    new WhatsappAccountCloudAPIModal(
+      container: @el.parents('.content')
+      load: @load
+      headPrefix: __('New')
+    )
+
+  edit: (e) =>
+    e.preventDefault()
+
+    id = $(e.target).closest('.action').data('id')
+    channel = App.Channel.find(id)
+
+    new WhatsappAccountCloudAPIModal(
+      container: @el.parents('.content')
+      channel: channel
+      load: @load
+      headPrefix: __('Edit')
+    )
+
+  delete: (e) =>
+    e.preventDefault()
+    id = $(e.target).closest('.action').data('id')
+
+    new App.ControllerConfirm(
+      message:     __('Are you sure?')
+      buttonClass: 'btn--danger'
+      callback: =>
+        @ajax(
+          id:   'whatsapp_delete'
+          type: 'DELETE'
+          url:  "#{@apiPath}/channels/admin/whatsapp/#{id}"
+          processData: true
+          success: =>
+            @load()
+        )
+      container: @el.closest('.content')
+    )
+
+  disable: (e) =>
+    e.preventDefault()
+
+    id = $(e.target).closest('.action').data('id')
+
+    @ajax(
+      id:   'whatsapp_disable'
+      type: 'POST'
+      url:  "#{@apiPath}/channels/admin/whatsapp/#{id}/disable"
+      data: JSON.stringify(id: id)
+      processData: true
+      success: =>
+        @load()
+    )
+
+  enable: (e) =>
+    e.preventDefault()
+
+    id = $(e.target).closest('.action').data('id')
+
+    @ajax(
+      id:   'whatsapp_enable'
+      type: 'POST'
+      url:  "#{@apiPath}/channels/admin/whatsapp/#{id}/enable"
+      processData: true
+      success: =>
+        @load()
+    )
+
+class WhatsappAccountCloudAPIModal extends App.ControllerModal
+  head: __('WhatsApp Account')
+  shown: true
+  buttonSubmit: __('Next')
+  buttonClass: 'btn--primary'
+  buttonCancel: true
+  small: true
+
+  content: =>
+    $(App.view('whatsapp/account_cloud_api')(
+      channel: @channel
+      params:  @params
+    ))
+
+  onSubmit: (e) =>
+    element = $(e.target).closest('form').get(0)
+    if element && element.reportValidity && !element.reportValidity()
+      return false
+
+    @clearAlerts()
+    @formDisable(e)
+
+    params = if @params then _.extend(@params, @formParams()) else @formParams()
+
+    @ajax(
+      id: 'whatsapp_initial'
+      type: 'POST'
+      url: "#{@apiPath}/channels/admin/whatsapp/preload"
+      data: JSON.stringify(params)
+      processData: true
+      success: (data) =>
+        @el.removeClass('fade')
+        @close()
+
+        params.available_phone_numbers = data.data.phone_numbers
+
+        new WhatsappAccountPhoneNumberModal(
+          params:     params
+          channel:    @channel
+          container:  @container
+          load:       @load
+          headPrefix: @headPrefix
+        )
+      error: (xhr) =>
+        data = JSON.parse(xhr.responseText)
+        @formEnable(e)
+        error_message = App.i18n.translateContent(data.error || __('The WhatsApp connection could not be saved.'))
+        @showAlert(error_message)
+    )
+
+class WhatsappAccountPhoneNumberModal extends App.ControllerModal
+  head: __('WhatsApp Account')
+  shown: true
+  buttonCancel: true
+  small: true
+
+  content: =>
+    content = $(App.view('whatsapp/account_phone_number')(
+      channel: @channel
+      params:  @params
+    ))
+
+    preselected_group_id = if @channel then @channel.group_id else 1
+
+    content.find('.js-messagesGroup').replaceWith App.UiElement.tree_select.render(
+      name: 'group_id'
+      multiple: false
+      limit: 100
+      null: false
+      relation: 'Group'
+      nulloption: true
+      value: preselected_group_id
+    )
+
+    content.find('.js-phoneNumbers').replaceWith App.UiElement.select.render(
+      name: 'phone_number_id'
+      multiple: false
+      value: @channel?.options?.phone_number_id || @params.available_phone_numbers?[0]?.value
+      options: @params.available_phone_numbers?.map (elem) -> { name: elem.label, value: elem.value }
+    )
+
+    content
+
+  onClosed: =>
+    return if !@isChanged
+    @isChanged = false
+    @load()
+
+  onSubmit: (e) =>
+    element = $(e.target).closest('form').get(0)
+    if element && element.reportValidity && !element.reportValidity()
+      return false
+
+    @clearAlerts()
+
+    if @channel
+      url    =  "#{@apiPath}/channels/admin/whatsapp/#{@channel.id}"
+      method = 'PUT'
+    else
+      url    = "#{@apiPath}/channels/admin/whatsapp"
+      method = 'POST'
+
+    @formDisable(e)
+
+    params = @formParams()
+
+    @ajax(
+      id: 'whatsapp_save'
+      type: method
+      url: url
+      data: JSON.stringify(params)
+      processData: true
+      success: (data) =>
+        @isChanged = true
+        @el.removeClass('fade')
+        @close()
+
+        new WhatsappAccountWebhookModal(
+          channel:    data
+          container:  @container
+          headPrefix: @headPrefix
+        )
+      error: (xhr) =>
+        data = JSON.parse(xhr.responseText)
+        @formEnable(e)
+        error_message = App.i18n.translateContent(data.error || __('The WhatsApp connection could not be saved.'))
+        @showAlert(error_message)
+    )
+
+class WhatsappAccountWebhookModal extends App.ControllerModal
+  head: __('WhatsApp Account')
+  shown: true
+  buttonSubmit: __('Finish')
+  buttonClass: 'btn--primary'
+  small: true
+  events:
+    'click .js-copy': 'copyToClipboard'
+
+  content: =>
+    content = $(App.view('whatsapp/account_webhook')(
+      channel: @channel
+      callback_url: "#{@Config.get('http_type')}://#{@Config.get('fqdn')}/#{@apiPath}/channels_whatsapp_webhook/#{@channel.options?.callback_url_uuid}"
+    ))
+
+    content
+
+  onSubmit: (e) =>
+    @close()
+
+  copyToClipboard: (e) =>
+    e.preventDefault()
+
+    button = $(e.target).parents('[role="button"]')
+    field_name = button.data('targetField')
+    value = $(@container).find("input[name='#{jQuery.escapeSelector(field_name)}']").val()
+
+    @copyToClipboardWithTooltip(value, e.target,'.modal-body', true)
+
+App.Config.set('Whatsapp', {
+  prio: 5100,
+  name: __('WhatsApp'),
+  parent: '#channels',
+  target: '#channels/whatsapp',
+  controller: ChannelWhatsapp,
+  permission: ['admin.channel_whatsapp']
+}, 'NavBarAdmin')

+ 34 - 0
app/assets/javascripts/app/views/whatsapp/account_cloud_api.jst.eco

@@ -0,0 +1,34 @@
+<div class="alert alert--danger hidden" role="alert"></div>
+<p>
+  <%- @T('You can find a tutorial on how to manage a %s in our online documentation %l.', 'WhatsApp Business Account', 'https://admin-docs.zammad.org/en/latest/channels/whatsapp.html') %>
+</p>
+<fieldset>
+  <h2><%- @T('Step 1 of 3: WhatsApp Business Cloud API') %></h2>
+
+  <div class="input form-group">
+    <div class="formGroup-label">
+      <label for="business_id"><%- @T('WhatsApp Business Account ID') %><% if !@channel: %> <span>*</span><% end %></label>
+    </div>
+    <div class="controls">
+      <input id="business_id" type="text" name="business_id" value="<%= @params?.business_id || @channel?.options?.business_id %>" class="form-control" <% if @channel: %>disabled<% else: %>required autocomplete="off"<% end %>>
+    </div>
+  </div>
+
+  <div class="input form-group">
+    <div class="formGroup-label">
+      <label for="access_token"><%- @T('Access token') %> <span>*</span></label>
+    </div>
+    <div class="controls">
+      <input id="access_token" type="text" name="access_token" value="<%= @params?.access_token || @channel?.options?.access_token %>" class="form-control" required autocomplete="off">
+    </div>
+  </div>
+
+  <div class="input form-group">
+    <div class="formGroup-label">
+      <label for="app_secret"><%- @T('App secret') %> <span>*</span></label>
+    </div>
+    <div class="controls">
+      <input id="app_secret" type="text" name="app_secret" value="<%= @params?.app_secret || @channel?.options?.app_secret %>" class="form-control" required autocomplete="off">
+    </div>
+  </div>
+</fieldset>

+ 50 - 0
app/assets/javascripts/app/views/whatsapp/account_phone_number.jst.eco

@@ -0,0 +1,50 @@
+<div class="alert alert--danger hidden" role="alert"></div>
+<p>
+  <%- @T('You can find a tutorial on how to manage a %s in our online documentation %l.', 'WhatsApp Business Account', 'https://admin-docs.zammad.org/en/latest/channels/whatsapp.html') %>
+</p>
+<fieldset>
+  <h2 id="name" for="name"><%- @T('Step 2 of 3: WhatsApp Business Phone Number') %></h2>
+
+  <input id="business_id" type="hidden" name="business_id" value="<%= @params?.business_id || @channel?.options?.business_id%>">
+  <input id="access_token" type="hidden" name="access_token" value="<%= @params?.access_token || @channel?.options?.access_token %>">
+  <input id="app_secret" type="hidden" name="app_secret" value="<%= @params?.app_secret || @channel?.options?.app_secret%>">
+
+  <div class="input form-group">
+    <div class="formGroup-label">
+      <label for=""><%- @T('Phone number') %> <span>*</span></label>
+    </div>
+    <div class="controls">
+      <div class="js-phoneNumbers"></div>
+    </div>
+  </div>
+
+  <h2><%- @T('Ticket Settings') %></h2>
+
+  <div class="input form-group">
+    <div class="formGroup-label">
+      <label for="welcome"><%- @T('Welcome message') %> <span>*</span></label>
+    </div>
+    <div class="controls">
+      <input id="welcome" type="text" name="welcome" value="<%= @params?.welcome || @channel?.options?.welcome %>" placeholder="<%- @Ti('How can we help?') %>" class="form-control" required autocomplete="off">
+    </div>
+  </div>
+
+  <div class="input form-group">
+    <div class="formGroup-label">
+      <label for="goodbye"><%- @T('Goodbye message') %> <span>*</span></label>
+    </div>
+    <div class="controls">
+      <input id="goodbye" type="text" name="goodbye" value="<%= @params?.goodbye || @channel?.options?.goodbye %>" placeholder="<%- @Ti('Have a nice day!') %>" class="form-control" required autocomplete="off">
+    </div>
+  </div>
+
+  <div class="input form-group">
+    <div class="formGroup-label">
+      <label for=""><%- @T('Target Group') %> <span>*</span></label>
+    </div>
+    <div class="controls">
+      <div class="js-messagesGroup"></div>
+      <p class="help-text"><%- @T('Choose the group to which messages will get added.') %></p>
+    </div>
+  </div>
+</fieldset>

+ 35 - 0
app/assets/javascripts/app/views/whatsapp/account_webhook.jst.eco

@@ -0,0 +1,35 @@
+<div class="alert alert--danger hidden" role="alert"></div>
+<p>
+  <%- @T('You can find a tutorial on how to manage a %s in our online documentation %l.', 'WhatsApp Business Account', 'https://admin-docs.zammad.org/en/latest/channels/whatsapp.html') %>
+</p>
+<fieldset>
+  <h2 id="name" for="name"><%- @T('Step 3 of 3: WhatsApp Business Webhook') %></h2>
+
+  <div class="input form-group">
+    <div class="formGroup-label">
+      <label for="callback_url"><%- @T('Callback URL') %></label>
+    </div>
+    <div class="controls controls--button ignore-readonly">
+      <input readonly id="callback_url" name="callback_url" name="callback_url" type="text" value="<%= @callback_url %>" class="form-control">
+      <div class="controls-button u-clickable js-copy" role="button" data-target-field="callback_url" aria-label="<%= @T('Copy to clipboard') %>">
+        <span class="controls-button-inner">
+          <%- @Icon('clipboard') %>
+        </span>
+      </div>
+    </div>
+  </div>
+
+  <div class="input form-group">
+    <div class="formGroup-label">
+      <label for="verify_token"><%- @T('Verify Token') %></label>
+    </div>
+    <div class="controls controls--button ignore-readonly">
+      <input readonly id="verify_token" type="text" name="verify_token" value="<%= @channel?.options?.verify_token %>" class="form-control">
+      <div class="controls-button u-clickable js-copy" role="button" data-target-field="verify_token" aria-label="<%= @T('Copy to clipboard') %>">
+        <span class="controls-button-inner">
+          <%- @Icon('clipboard') %>
+        </span>
+      </div>
+    </div>
+  </div>
+</fieldset>

+ 49 - 0
app/assets/javascripts/app/views/whatsapp/index.jst.eco

@@ -0,0 +1,49 @@
+<div class="page-header">
+  <div class="page-header-title">
+      <h1><%- @T('WhatsApp') %> <small><%- @T('Accounts') %></small></h1>
+   </div>
+
+    <div class="page-header-meta">
+      <button class="btn btn--success js-new"><%- @T('Add Account') %></button>
+    </div>
+</div>
+
+<div class="page-content">
+  <% if _.isEmpty(@channels): %>
+    <div class="page-description">
+      <p><%- @T('You can connect WhatsApp Business Accounts with Zammad.') %></p>
+    </div>
+  <% else: %>
+    <% for channel in @channels: %>
+      <div class="action <% if channel.active isnt true: %>is-inactive<% end %>" data-id="<%= channel.id %>">
+        <div class="action-block action-row">
+          <h2>
+            <%- @Icon('status', 'supergood-color inline') %>
+            <%= channel.options.name %>
+            <span class="text-muted"><%= channel.options.phone_number %></span>
+         </h2>
+        </div>
+        <div class="action-flow action-flow--row">
+          <div class="action-block">
+            <h3><%- @T('Messages') %></h3>
+            <%= channel.options.phone_number %>
+          </div>
+          <%- @Icon('arrow-right', 'action-flow-icon') %>
+          <div class="action-block">
+            <h3><%- @T('Group') %></h3>
+            <%= App.Group.find(channel.group_id)?.displayName() || '-' %>
+          </div>
+        </div>
+        <div class="action-controls">
+          <div class="btn btn--danger btn--secondary js-delete"><%- @T('Delete') %></div>
+          <% if channel.active is true: %>
+            <div class="btn btn--secondary js-disable"><%- @T('Disable') %></div>
+          <% else: %>
+            <div class="btn btn--secondary js-enable"><%- @T('Enable') %></div>
+          <% end %>
+          <div class="btn js-edit"><%- @T('Edit') %></div>
+        </div>
+      </div>
+    <% end %>
+  <% end %>
+</div>

+ 1 - 0
app/assets/stylesheets/svg-dimensions.css

@@ -156,5 +156,6 @@
 .icon-user { width: 16px; height: 16px; }
 .icon-web { width: 17px; height: 17px; }
 .icon-weibo-button { width: 29px; height: 24px; }
+.icon-whatsapp { width: 15px; height: 15px; }
 .icon-zoom-in { width: 20px; height: 20px; }
 .icon-zoom-out { width: 20px; height: 20px; }

+ 9 - 0
app/assets/stylesheets/zammad.scss

@@ -3858,6 +3858,11 @@ ol.tabs li {
 
 .icon {
   fill: currentColor;
+
+  &--md {
+    width: 2rem;
+    height: 2rem;
+  }
 }
 
 [class*='icon-file-'] {
@@ -11722,6 +11727,10 @@ output {
     &.action-flow--row {
       flex-basis: 100%;
     }
+
+    &.action-flow--vertically-centered {
+      align-items: center;
+    }
   }
 
   &-separator {

Some files were not shown because too many files changed in this diff