Implemented CTI ticket create screen popup on answering call.

This commit is contained in:
Martin Edenhofer 2019-07-10 00:07:32 +02:00
parent 9503ff20ce
commit 7faa4c310d
31 changed files with 1840 additions and 463 deletions

View File

@ -57,7 +57,7 @@ License: MIT license
highlight.pack.js
Source: https://highlightjs.org
Copyright: 2006 Ivan Sagalaev (https://github.com/isagalaev)
License: BSD License
License: BSD License (BSD-3-Clause)
-----------------------------------------------------------------------------
jquery.flot.js
Source: https://github.com/dnschnur/flot
@ -74,7 +74,7 @@ jquery.fineuploader-3.0.js
Source: http://github.com/Valums-File-Uploader/file-uploader
Copyright: 2010 Andrew Valums <andrew(at)valums.com>
2012 Ray Nicholus <fineuploader(at)garstasio.com>
License: MIT license, GNU GPL 2 or later, GNU LGPL 2 or later
License: GNU GPL 2 or later, GNU LGPL 2 or later
-----------------------------------------------------------------------------
jquery.noty.js
Source: https://github.com/needim/noty/
@ -171,7 +171,7 @@ License: MIT license
Font Awesome icon font
Source: http://fontawesome.io/
Copyright: Font Awesome by Dave Gandy - http://fontawesome.io
License: SIL OFL 1.1
License: MIT License
-----------------------------------------------------------------------------
Simple line icons font
Source: https://github.com/thesabbir/simple-line-icons

View File

@ -26,8 +26,10 @@ class Form extends App.Controller
'submit form': 'update'
'click .js-inboundBlockCallerId .js-add': 'addInboundBlockCallerId'
'click .js-outboundRouting .js-add': 'addOutboundRouting'
'click .js-notifyMap .js-addMap': 'addNotifyMap'
'click .js-inboundBlockCallerId .js-remove': 'removeInboundBlockCallerId'
'click .js-outboundRouting .js-remove': 'removeOutboundRouting'
'click .js-notifyMap .js-removeMap': 'removeNotifyMap'
constructor: ->
super
@ -43,6 +45,8 @@ class Form extends App.Controller
config.inbound = {}
if !config.inbound.block_caller_ids
config.inbound.block_caller_ids = []
if !config.notify_map
config.notify_map = []
config
setConfig: (value) ->
@ -56,6 +60,32 @@ class Form extends App.Controller
cti_token: App.Setting.get('cti_token')
)
# placeholder
configure_attributes = [
{ name: 'user_ids', display: '', tag: 'column_select', multiple: true, null: true, relation: 'User', sortBy: 'firstname' },
]
new App.ControllerForm(
el: @$('.js-userSelectorBlank')
model:
configure_attributes: configure_attributes,
params:
user_ids: []
autofocus: false
)
for row in @config.notify_map
configure_attributes = [
{ name: 'user_ids', display: '', tag: 'column_select', multiple: true, null: true, relation: 'User', sortBy: 'firstname' },
]
new App.ControllerForm(
el: @$("[name=queue][value='#{row.queue}']").closest('tr').find('.js-userSelector')
model:
configure_attributes: configure_attributes,
params:
user_ids: row.user_ids
autofocus: false
)
updateCurrentConfig: =>
config = @config
cleanupInput = @cleanupInput
@ -88,6 +118,17 @@ class Form extends App.Controller
}
)
# notify map
config.notify_map = []
@$('.js-notifyMap .js-row').each(->
queue = $(@).find('input[name="queue"]').val()
user_ids = $(@).find('select[name="user_ids"]').val()
config.notify_map.push {
queue: cleanupInput(queue)
user_ids: user_ids
}
)
@config = config
update: (e) =>
@ -127,6 +168,41 @@ class Form extends App.Controller
}
@render()
addNotifyMap: (e) =>
e.preventDefault()
@updateCurrentConfig()
element = $(e.currentTarget).closest('tr')
queue = @cleanupInput(element.find('input[name="queue"]').val())
user_ids = element.find('select[name="user_ids"]').val()
if _.isEmpty(queue)
@notify(
type: 'error'
msg: App.i18n.translateContent('A queue is required!')
timeout: 6000
)
return
if _.isEmpty(user_ids)
@notify(
type: 'error'
msg: App.i18n.translateContent('A user is required!')
timeout: 6000
)
return
for row in @config.notify_map
if row.queue is queue
@notify(
type: 'error'
msg: App.i18n.translateContent('Queue already exists!')
timeout: 6000
)
return
@config.notify_map.push {
queue: queue
user_ids: user_ids
}
@render()
removeInboundBlockCallerId: (e) =>
e.preventDefault()
@updateCurrentConfig()
@ -141,6 +217,13 @@ class Form extends App.Controller
element.remove()
@updateCurrentConfig()
removeNotifyMap: (e) =>
e.preventDefault()
@updateCurrentConfig()
element = $(e.currentTarget).closest('tr')
element.remove()
@updateCurrentConfig()
class State
@current: ->
App.Setting.get('cti_integration')

View File

@ -25,9 +25,11 @@ class Form extends App.Controller
events:
'submit form': 'update'
'click .js-inboundBlockCallerId .js-add': 'addInboundBlockCallerId'
'click .js-outboundRouting .js-add': 'addOutboundRouting'
'click .js-inboundBlockCallerId .js-remove': 'removeInboundBlockCallerId'
'click .js-outboundRouting .js-add': 'addOutboundRouting'
'click .js-outboundRouting .js-remove': 'removeOutboundRouting'
'click .js-userDeviceMap .js-add': 'addUserDeviceMap'
'click .js-userDeviceMap .js-remove': 'removeUserDeviceMap'
constructor: ->
super
@ -43,6 +45,8 @@ class Form extends App.Controller
config.inbound = {}
if !config.inbound.block_caller_ids
config.inbound.block_caller_ids = []
if !config.user_device_map
config.user_device_map = []
config
setConfig: (value) ->
@ -60,7 +64,7 @@ class Form extends App.Controller
config = @config
cleanupInput = @cleanupInput
config.api_token = @$('input[name=api_token]').val()
config.api_token = cleanupInput(@$('input[name=api_token]').val())
# default caller_id
default_caller_id = @$('input[name=default_caller_id]').val()
@ -90,6 +94,17 @@ class Form extends App.Controller
}
)
# user device map
config.user_device_map = []
@$('.js-userDeviceMap .js-row').each(->
device_id = $(@).find('input[name="device_id"]').val()
user_id = $(@).find('input[name="user_id"]').val()
config.user_device_map.push {
device_id: device_id
user_id: user_id
}
)
@config = config
update: (e) =>
@ -114,6 +129,13 @@ class Form extends App.Controller
}
@render()
removeInboundBlockCallerId: (e) =>
e.preventDefault()
@updateCurrentConfig()
element = $(e.currentTarget).closest('tr')
element.remove()
@updateCurrentConfig()
addOutboundRouting: (e) =>
e.preventDefault()
@updateCurrentConfig()
@ -129,14 +151,27 @@ class Form extends App.Controller
}
@render()
removeInboundBlockCallerId: (e) =>
removeOutboundRouting: (e) =>
e.preventDefault()
@updateCurrentConfig()
element = $(e.currentTarget).closest('tr')
element.remove()
@updateCurrentConfig()
removeOutboundRouting: (e) =>
addUserDeviceMap: (e) =>
e.preventDefault()
@updateCurrentConfig()
element = $(e.currentTarget).closest('tr')
user_id = @cleanupInput(element.find('input[name="user_id"]').val())
device_id = @cleanupInput(element.find('input[name="device_id"]').val())
return if _.isEmpty(user_id) || _.isEmpty(device_id)
@config.user_device_map.push {
user_id: user_id
device_id: device_id
}
@render()
removeUserDeviceMap: (e) =>
e.preventDefault()
@updateCurrentConfig()
element = $(e.currentTarget).closest('tr')

View File

@ -28,6 +28,8 @@ class Form extends App.Controller
'click .js-outboundRouting .js-add': 'addOutboundRouting'
'click .js-inboundBlockCallerId .js-remove': 'removeInboundBlockCallerId'
'click .js-outboundRouting .js-remove': 'removeOutboundRouting'
'click .js-userRemoteMap .js-add': 'addUserRemoteMap'
'click .js-userRemoteMap .js-remove': 'removeUserRemoteMap'
constructor: ->
super
@ -43,6 +45,8 @@ class Form extends App.Controller
config.inbound = {}
if !config.inbound.block_caller_ids
config.inbound.block_caller_ids = []
if !config.user_remote_map
config.user_remote_map = []
config
setConfig: (value) ->
@ -59,6 +63,9 @@ class Form extends App.Controller
config = @config
cleanupInput = @cleanupInput
config.api_user = cleanupInput(@$('input[name=api_user]').val())
config.api_password = cleanupInput(@$('input[name=api_password]').val())
# default caller_id
default_caller_id = @$('input[name=default_caller_id]').val()
config.outbound.default_caller_id = cleanupInput(default_caller_id)
@ -87,6 +94,17 @@ class Form extends App.Controller
}
)
# user device map
config.user_remote_map = []
@$('.js-userRemoteMap .js-row').each(->
remote_user_id = $(@).find('input[name="remote_user_id"]').val()
user_id = $(@).find('input[name="user_id"]').val()
config.user_remote_map.push {
remote_user_id: remote_user_id
user_id: user_id
}
)
@config = config
update: (e) =>
@ -140,6 +158,26 @@ class Form extends App.Controller
element.remove()
@updateCurrentConfig()
addUserRemoteMap: (e) =>
e.preventDefault()
@updateCurrentConfig()
element = $(e.currentTarget).closest('tr')
user_id = @cleanupInput(element.find('input[name="user_id"]').val())
remote_user_id = @cleanupInput(element.find('input[name="remote_user_id"]').val())
return if _.isEmpty(user_id) || _.isEmpty(remote_user_id)
@config.user_remote_map.push {
user_id: user_id
remote_user_id: remote_user_id
}
@render()
removeUserRemoteMap: (e) =>
e.preventDefault()
@updateCurrentConfig()
element = $(e.currentTarget).closest('tr')
element.remove()
@updateCurrentConfig()
class State
@current: ->
App.Setting.get('sipgate_integration')

View File

@ -199,7 +199,7 @@ class App.TicketCreate extends App.Controller
if _.isEmpty(params.ticket_id) && _.isEmpty(params.article_id)
if !_.isEmpty(params.customer_id)
@renderQueue(options: { customer_id: params.customer_id })
@renderQueue(options: params)
return
@renderQueue()
return

View File

@ -6,7 +6,7 @@ class App.CTI extends App.Controller
'.js-callerLog': 'callerLog'
events:
'click .js-check': 'done'
'click .js-userNew': 'userNew'
'click .js-newUser': 'newUser'
list: []
backends: []
meta:
@ -32,9 +32,30 @@ class App.CTI extends App.Controller
return if data.state isnt 'newCall'
return if data.direction isnt 'in'
return if @switch() isnt true
@notify(data)
if !document.hasFocus()
@notify(data)
'cti_event'
)
@bind('menu:render', (data) =>
return if @switch() isnt true
localHtml = ''
for item in @ringingCalls()
localHtml += App.view('navigation/menu_cti_ringing')(
item: item
)
$('.js-phoneMenuItem').after(localHtml)
$('.call-widget').find('.js-newUser').bind('click', (e) =>
@newUser(e)
)
$('.call-widget').find('.js-newTicket').bind('click', (e) =>
user = undefined
user_id = $(e.currentTarget).data('user-id')
if user_id
user = App.User.find(user_id)
console.log('user_id', user_id, user)
@newTicket(user)
)
)
@bind('auth', (data) =>
@meta.counter = 0
)
@ -57,6 +78,13 @@ class App.CTI extends App.Controller
@initSpoolSent = true
)
ringingCalls: =>
ringing = []
for row in @list
if row.state is 'newCall' && row.done is false
ringing.push row
ringing
# fetch data, render view
load: ->
@ajax(
@ -148,8 +176,18 @@ class App.CTI extends App.Controller
item.disabled = false
@removePopovers()
@callerLog.html(App.view('cti/caller_log')(list: @list))
@renderPopovers()
list = $(App.view('cti/caller_log')(list: @list))
list.find('.js-avatar').each( ->
$element = $(@)
new WidgetAvatar(
el: $element
object_id: $element.attr('data-id')
level: $element.attr('data-level')
size: 40
)
)
@callerLog.html(list)
@updateNavMenu()
@ -163,9 +201,15 @@ class App.CTI extends App.Controller
data: JSON.stringify(done: done)
)
userNew: (e) ->
newTicket: (user) =>
if user
@navigate("ticket/create/customer/#{user.id}")
return
@navigate('ticket/create')
newUser: (e) ->
e.preventDefault()
phone = $(e.currentTarget).text()
phone = $(e.currentTarget).data('phone')
new App.ControllerGenericNew(
pageData:
title: 'Users'
@ -176,7 +220,7 @@ class App.CTI extends App.Controller
genericObject: 'User'
item:
phone: phone
container: @el.closest('.content')
#container: @el.closest('.content')
callback: @ticketNew
)
@ -221,6 +265,33 @@ class App.CTI extends App.Controller
currentPosition: =>
@$('.main').scrollTop()
class WidgetAvatar extends App.ObserverController
@extend App.PopoverProvidable
@registerPopovers 'User'
model: 'User'
observe:
login: true
firstname: true
lastname: true
organization_id: true
email: true
image: true
vip: true
out_of_office: true,
out_of_office_start_at: true,
out_of_office_end_at: true,
out_of_office_replacement_id: true,
active: true
globalRerender: false
render: (user) =>
classes = ['user-popover', 'u-textTruncate']
classes.push('is-inactive') if !user.active
@html(App.view('cti/caller_log_avatar')(user: user, classes: classes, level: @level))
@renderPopovers()
class CTIRouter extends App.ControllerPermanent
requiredPermission: 'cti.agent'
constructor: (params) ->

View File

@ -0,0 +1,15 @@
class Widget extends App.Controller
serverRestarted: false
constructor: ->
super
App.Event.bind(
'remote_task'
(data) =>
console.log('remote_task', data)
App.TaskManager.execute(data)
@navigate(data.url)
'remote_task'
)
App.Config.set('remote_task', Widget, 'Widgets')

View File

@ -200,6 +200,20 @@ App.ViewHelpers =
return true if contentType.match(/image\/(png|jpg|jpeg|gif)/i)
false
unique_avatar: (seed, text, size = 40) ->
baseSize = 40
width = 300 * size/baseSize
height = 226 * size/baseSize
rng = new Math.seedrandom(seed)
x = rng() * (width - size)
y = rng() * (height - size)
return App.view('avatar_unique')
x: x
y: y
initials: text
# icon with modifier based on visibility state
# params: className, iconset, addStateClass
iconWithModifier: (item, params) ->

View File

@ -13,9 +13,9 @@
</thead>
<tbody>
<% for item in @list: %>
<tr <% if item.done: %>class="is-grayed-out"<% end %> data-id="<%- item.id %>">
<td class="table-checkbox" style="vertical-align: middle">
<label class="checkbox-replacement<% if item.disabled is true: %> is-disabled<% end %>">
<tr class="u-center<% if item.done: %> is-grayed-out<% end %>" data-id="<%- item.id %>">
<td class="table-checkbox u-positionOrigin">
<label class="checkbox-replacement checkbox-replacement--fullscreen<% if item.disabled is true: %> is-disabled<% end %>">
<input type="checkbox" class="js-check"<% if item.done: %> checked<% end %><% if item.disabled is true: %> disabled<% end %>>
<%- @Icon('checkbox', 'icon-unchecked') %>
<%- @Icon('checkbox-checked', 'icon-checked') %>
@ -26,31 +26,42 @@
<% if item.preferences.from && !_.isEmpty(item.preferences.from): %>
<% for caller_id in item.preferences.from: %>
<% if caller_id.user_id && App.User.exists(caller_id.user_id): %>
<% if shown: %><div class="spacer"></div><% end %>
<div class="user-card">
<div class="js-avatar" data-id="<%- caller_id.user_id %>" data-level="<%= caller_id.level %>"></div>
<a class="text-muted" href="<%- App.Utils.phoneify(item.from_pretty) %>"><%= item.from_pretty %></a>
</div>
<% shown = true %>
<% user = App.User.fullLocal(caller_id.user_id) %>
<% classes = ['user-popover'] %>
<% classes.push('is-inactive') if !user.active %>
<% if caller_id.level isnt 'known': %><%- @T('maybe') %> <% end %>
<span class="<%= classes.join(' ') %>" data-id="<%- user.id %>"><%= user.displayNameLong() %></span><br>
<% else if !_.isEmpty(caller_id.comment): %>
<% shown = true %>
<%- @T('maybe') %> <%= caller_id.comment %><br>
<div class="user-card">
<%- @unique_avatar(caller_id.comment, caller_id.comment.split(" ").map((name) -> name[0]).join("")) %>
<%- @T('maybe') %>: <%= caller_id.comment %><br>
<a class="text-muted" href="<%- App.Utils.phoneify(item.from_pretty) %>"><%= item.from_pretty %></a>
</div>
<% end %>
<% end %>
<% end %>
<% if !shown && !_.isEmpty(item.from_comment): %>
<% shown = true %>
<%= item.from_comment %>
<br>
<div class="user-card">
<%- @unique_avatar(item.from_comment, item.from_comment.split(" ").map((name) -> name[0]).join("")) %>
<%= item.from_comment %><br>
<a class="text-muted" href="<%- App.Utils.phoneify(item.from_pretty) %>"><%= item.from_pretty %></a>
</div>
<% end %>
<% if shown: %>
<small><%= item.from_pretty %></small>
<% else: %>
<% if !shown: %>
<div class="user-card">
<%- @unique_avatar(item.from_pretty || item.from, '??') %>
<% if !_.isEmpty(item.from_pretty): %>
<span class="js-userNew u-clickable" href="#"><%= item.from_pretty %></span>
<a class="inherit-color" href="<%- App.Utils.phoneify(item.from_pretty) %>"><%= item.from_pretty %></a>
<% if item.direction is 'in': %>
<div class="btn btn--text btn--create no-padding js-newUser" href="#" data-phone="<%= item.from_pretty %>"><%- @Icon('plus-small') %> <span><%- @T('Create User') %></div>
<% end %>
<% else: %>
<span><%= item.from %></span>
<a class="inherit-color" href="<%- App.Utils.phoneify(item.from) %>"><%= item.from %></a>
<% end %>
</div>
<% end %>
</td>
<td>
@ -59,41 +70,53 @@
<% for caller_id in item.preferences.to: %>
<% if caller_id.user_id && App.User.exists(caller_id.user_id): %>
<% shown = true %>
<% user = App.User.fullLocal(caller_id.user_id) %>
<% classes = ['user-popover'] %>
<% classes.push('is-inactive') if !user.active %>
<% if caller_id.level isnt 'known': %><%- @T('maybe') %> <% end %>
<span class="<%= classes.join(' ') %>" data-id="<%- user.id %>"><%= user.displayNameLong() %></span><br>
<div class="user-card">
<div class="js-avatar" data-id="<%- caller_id.user_id %>" data-level="<%= caller_id.level %>"></div>
<a class="text-muted" href="<%- App.Utils.phoneify(item.to_pretty) %>"><%= item.to_pretty %></a>
</div>
<% else if !_.isEmpty(caller_id.comment): %>
<% shown = true %>
<%- @T('maybe') %> <%= caller_id.comment %><br>
<div class="user-card">
<%- @unique_avatar(caller_id.comment, caller_id.comment.split(" ").map((name) -> name[0]).join("")) %>
<%- @T('maybe') %>: <%= caller_id.comment %><br>
<a class="text-muted" href="<%- App.Utils.phoneify(item.to_pretty) %>"><%= item.to_pretty %></a>
</div>
<% end %>
<% end %>
<% end %>
<% if !shown && !_.isEmpty(item.to_comment): %>
<% shown = true %>
<%= item.to_comment %>
<br>
<div class="user-card">
<%- @unique_avatar(item.to_comment, item.to_comment.split(" ").map((name) -> name[0]).join("")) %>
<%= item.to_comment %><br>
<a class="text-muted" href="<%- App.Utils.phoneify(item.to_pretty) %>"><%= item.to_pretty %></a>
</div>
<% end %>
<% if shown: %>
<small><%= item.to_pretty %></small>
<% else: %>
<% if !shown: %>
<div class="user-card">
<% if item.direction isnt 'in': %>
<%- @unique_avatar(item.to_pretty || item.to, '??') %>
<% end %>
<% if !_.isEmpty(item.to_pretty): %>
<%= item.to_pretty %>
<a class="inherit-color" href="<%- App.Utils.phoneify(item.to_pretty) %>"><%= item.to_pretty %></a>
<% if item.direction is 'out': %>
<div class="btn btn--text btn--create no-padding js-newUser" href="#" data-phone="<%= item.to_pretty %>"><%- @Icon('plus-small') %> <span><%- @T('Create User') %></div>
<% end %>
<% else: %>
<%= item.to %>
<a class="inherit-color" href="<%- App.Utils.phoneify(item.to) %>"><%= item.to %></a>
<% end %>
</div>
<% end %>
</td>
<!--<td style="vertical-align: middle"><%= item.queue %></td>-->
<td style="vertical-align: middle">
<td>
<% if item.state_human: %>
<%- @Icon('status', "#{item.status_class} inline") %> <%- @T(item.state_human) %>
<% end %>
</td>
<td style="vertical-align: middle"><%= @time_duration(item.duration_waiting_time) %></td>
<td style="vertical-align: middle"><%= @time_duration(item.duration_talking_time) %></td>
<td style="vertical-align: middle"><%- @humanTime(item.created_at) %></td>
<td><%= @time_duration(item.duration_waiting_time) %></td>
<td><%= @time_duration(item.duration_talking_time) %></td>
<td><%- @humanTime(item.created_at) %></td>
</tr>
<% end %>
</tbody>

View File

@ -0,0 +1,2 @@
<%- @user.avatar() %>
<div class="<%= @classes.join(' ') %>" data-id="<%- @user.id %>"><% if @level isnt 'known': %><%- @T('maybe') %>: <% end %><%= @user.displayNameLong() %></div>

View File

@ -93,5 +93,32 @@
</table>
</div>
<h2><%- @T('Notify Map') %></h2>
<p><%- @T('Notify certain users by matching caller id.') %><%- @T('If no mapping is defined, all user get notified about any event.') %>
<div class="settings-entry">
<table class="settings-list js-notifyMap" style="width: 100%;">
<thead>
<tr>
<th width="30%"><%- @T('Destination caller id or queue') %>
<th width="60%"><%- @T('Agents') %>
<th width="10%"><%- @T('Action') %>
</thead>
<tbody>
<% for row in @config.notify_map: %>
<tr class="js-row">
<td class="settings-list-control-cell"><input name="queue" value="<%= row.queue %>" class="form-control form-control--small js-summary">
<td class="settings-list-control-cell js-userSelector">
<td class="settings-list-row-control"><div class="btn btn--text js-removeMap"><%- @Icon('trash') %> <%- @T('Remove') %></div>
<% end %>
<tr>
<td class="settings-list-control-cell"><input name="queue" value="" placeholder="4930609854189" class="form-control form-control--small js-summary">
<td class="settings-list-control-cell js-userSelectorBlank">
<td class="settings-list-row-control"><div class="btn btn--text btn--create js-addMap"><%- @Icon('plus-small') %> <%- @T('Add') %></div>
</tbody>
</table>
</div>
<button type="submit" class="btn btn--primary js-submit"><%- @T('Save') %></button>
</form>

View File

@ -2,7 +2,7 @@
<h2>Placetel <%- @T('Settings') %></h2>
<p><%- @T('You need to configure the Zammad endpoints in the %s', 'Placetel') %>:<p>
<p><%- @T('You need to configure the Zammad endpoints in the %s web interface', 'Placetel') %>:<p>
<div class="settings-entry">
<table class="settings-list" style="width: 100%;">
@ -109,5 +109,32 @@
</table>
</div>
-->
<h2><%- @T('User assignment to telephones') %></h2>
<p><%- @T('User assignment to telephones to be able to offer extended like open new ticket screen on answering a call.') %>
<div class="settings-entry">
<table class="settings-list js-userDeviceMap" style="width: 100%;">
<thead>
<tr>
<th width="45%"><%- @T('Placetel') %>/<%- @T('Device') %>
<th width="45%"><%- @T('Zammad') %>/<%- @T('User') %>
<th width="10%"><%- @T('Action') %>
</thead>
<tbody>
<% for row in @config.user_device_map: %>
<tr class="js-row">
<td class="settings-list-control-cell"><input name="device_id" value="<%= row.device_id %>" class="form-control form-control--small js-summary">
<td class="settings-list-control-cell"><input name="user_id" value="<%= row.user_id %>" class="form-control form-control--small js-summary">
<td class="settings-list-row-control"><div class="btn btn--text js-remove"><%- @Icon('trash') %> <%- @T('Remove') %></div>
<% end %>
<tr>
<td class="settings-list-control-cell"><input name="device_id" value="" placeholder="e. g. 777042617425@fpbx.de" class="form-control form-control--small js-summary">
<td class="settings-list-control-cell"><input name="user_id" value="" placeholder="<%- @Ti('e. g. user@example.com') %>" class="form-control form-control--small js-summary">
<td class="settings-list-row-control"><div class="btn btn--text btn--create js-add"><%- @Icon('plus-small') %> <%- @T('Add') %></div>
</tbody>
</table>
</div>
<button type="submit" class="btn btn--primary js-submit"><%- @T('Save') %></button>
</form>

View File

@ -2,7 +2,7 @@
<h2>sipgate.io <%- @T('Settings') %></h2>
<p><%- @T('You need to configure the Zammad endpoints in the Sipgate web interface') %>:<p>
<p><%- @T('You need to configure the Zammad endpoints in the %s web interface', 'Sipgate') %>:<p>
<div class="settings-entry">
<table class="settings-list" style="width: 100%;">
@ -96,5 +96,51 @@
</table>
</div>
<p><%- @T('In order for Zammad to access %s, a %s API user and password must be stored here', 'Sipgate', 'Sipgate') %>:<p>
<div class="settings-entry">
<table class="settings-list" style="width: 100%;">
<thead>
<tr>
<th width="20%"><%- @T('Type') %>
<th width="80%"><%- @T('Content') %>
</thead>
<tbody>
<tr>
<td class="settings-list-row-control"><%- @T('API User') %>
<td class="settings-list-control-cell"><input type="input" class="form-control form-control--small js-select" value="<%= @config.api_user %>" name="api_user" placeholder="someuser@example.com">
<tr>
<td class="settings-list-row-control"><%- @T('API Password') %>
<td class="settings-list-control-cell"><input type="password" class="form-control form-control--small js-select" value="<%= @config.api_password %>" name="api_password" placeholder="">
</tbody>
</table>
</div>
<h2><%- @T('User assignment to Sipgate users') %></h2>
<p><%- @T('User assignment to Sipgate users to be able to offer extended like open new ticket screen on answering a call.') %>
<div class="settings-entry">
<table class="settings-list js-userRemoteMap" style="width: 100%;">
<thead>
<tr>
<th width="45%"><%- @T('Sipgate') %>/<%- @T('User') %>
<th width="45%"><%- @T('Zammad') %>/<%- @T('User') %>
<th width="10%"><%- @T('Action') %>
</thead>
<tbody>
<% for row in @config.user_remote_map: %>
<tr class="js-row">
<td class="settings-list-control-cell"><input name="remote_user_id" value="<%= row.remote_user_id %>" class="form-control form-control--small js-summary">
<td class="settings-list-control-cell"><input name="user_id" value="<%= row.user_id %>" class="form-control form-control--small js-summary">
<td class="settings-list-row-control"><div class="btn btn--text js-remove"><%- @Icon('trash') %> <%- @T('Remove') %></div>
<% end %>
<tr>
<td class="settings-list-control-cell"><input name="remote_user_id" value="" placeholder="e. g. W123" class="form-control form-control--small js-summary">
<td class="settings-list-control-cell"><input name="user_id" value="" placeholder="<%- @Ti('e. g. user@example.com') %>" class="form-control form-control--small js-summary">
<td class="settings-list-row-control"><div class="btn btn--text btn--create js-add"><%- @Icon('plus-small') %> <%- @T('Add') %></div>
</tbody>
</table>
</div>
<button type="submit" class="btn btn--primary js-submit"><%- @T('Save') %></button>
</form>

View File

@ -14,7 +14,7 @@
<li class="divider"></li>
<% end %>
<% if item.navheader: %>
<li class="dropdown-header"><%- @T( item.navheader ) %></li>
<li class="dropdown-header"><%- @T(item.navheader) %></li>
<% end %>
<li><a href="<%= item.target %>"><%- @T( item.name ) %><% if item['count'] isnt undefined: %><span class="badge badge--text count"><%= item['count'] %></span><% end %></a></li>
<% end %>

View File

@ -0,0 +1,60 @@
<div class="call-widget">
<div class="call-widget-header">
<%- @Icon('status', "neutral") %>
<div class="label"><%- @T('Inbound Call') %></div>
<!--
<div class="flex-spacer"></div>
<div class="btn btn--text js-remove" title="<%- @Ti('Remove') %>"><%- @Icon('diagonal-cross') %></div>
-->
</div>
<div class="horizontal center">
<% user = undefined %>
<% shown = false %>
<% if @item.preferences.from && !_.isEmpty(@item.preferences.from): %>
<% for caller_id in @item.preferences.from: %>
<% if caller_id.user_id && App.User.exists(caller_id.user_id): %>
<% user = App.User.fullLocal(caller_id.user_id) %>
<% classes = ['user-popover', 'u-textTruncate'] %>
<% classes.push('is-inactive') if !user.active %>
<% if shown: %><div class="spacer"></div><% end %>
<div class="user-card">
<a href="<%- user.uiUrl() %>"><%- user.avatar() %></a>
<div class="<%= classes.join(' ') %>" data-id="<%- user.id %>"><% if caller_id.level isnt 'known': %><%- @T('maybe') %>: <% end %><%= user.displayNameLong() %></div>
<span class="text-muted"><%= @item.from_pretty %></span>
</div>
<% shown = true %>
<% else if !_.isEmpty(caller_id.comment): %>
<% shown = true %>
<div class="user-card">
<%- @unique_avatar(caller_id.comment, caller_id.comment.split(" ").map((name) -> name[0]).join("")) %>
<%- @T('maybe') %>: <%= caller_id.comment %><br>
<span class="text-muted"><%= @item.from_pretty %></span>
</div>
<% end %>
<% end %>
<% end %>
<% if !shown && !_.isEmpty(@item.from_comment): %>
<% shown = true %>
<div class="user-card">
<%- @unique_avatar(@item.from_comment, @item.from_comment.split(" ").map((name) -> name[0]).join("")) %>
<%= @item.from_comment %><br>
<span class="text-muted"><%= @item.from_pretty %></span>
</div>
<% end %>
<% if !shown: %>
<div class="user-card">
<%- @unique_avatar(@item.from_pretty || @item.from, '??') %>
<% if !_.isEmpty(@item.from_pretty): %>
<a class="inherit-color" href="<%- App.Utils.phoneify(@item.from_pretty) %>"><%= @item.from_pretty %></a>
<% if @item.direction is 'in': %>
<div class="btn btn--text btn--create no-padding js-newUser" href="#" data-phone="<%= @item.from_pretty %>"><%- @Icon('plus-small') %> <span><%- @T('Create User') %></div>
<% end %>
<% else: %>
<a class="inherit-color" href="<%- App.Utils.phoneify(@item.from) %>"><%= @item.from %></a>
<% end %>
</div>
<% end %>
<div class="flex-spacer"></div>
<div class="btn btn--small btn--quad btn--create space-left js-newTicket" title="<%- @Ti('New Ticket') %>" data-user-id="<% if user: %><%- user.id %><% end %>"><%- @Icon('plus') %></div>
</div>
</div>

View File

@ -115,6 +115,10 @@ strong {
font-weight: 500;
}
.inherit-color {
color: inherit;
}
.text-muted {
color: hsl(60,1%,74%);
}
@ -419,6 +423,10 @@ pre code.hljs {
&--small {
padding-top: 5px;
padding-bottom: 4px;
&.btn--quad {
padding: 4px 5px;
}
}
&--slim {
@ -588,12 +596,18 @@ pre code.hljs {
background: none;
vertical-align: baseline;
text-align: start;
.table & {
margin: 0;
min-height: 38px;
}
&.no-padding {
padding: 0;
margin: 0;
min-height: auto;
}
.icon {
opacity: 1;
margin-right: 6px;
@ -662,7 +676,7 @@ pre code.hljs {
padding: 10px 12px 9px;
.icon {
margin-top: -1px;
margin: -1px;
}
}
@ -1111,6 +1125,10 @@ th.align-right {
}
}
.table > tbody > tr.u-center > td {
vertical-align: middle;
}
.table-hover > tbody > tr:hover,
.table-hover > tbody > tr.is-hover {
background: white;
@ -1161,9 +1179,15 @@ th.align-right {
.table tr.is-grayed-out {
color: hsl(120,1%,77%);
.icon {
.icon,
.btn span {
opacity: 0.33;
}
.avatar {
background: hsl(120,1%,86%);
text-shadow: none;
}
}
td .icon {
@ -1321,6 +1345,11 @@ td .icon-trash {
.table .radio-replacement {
height: 40px;
width: 40px;
&.checkbox-replacement--fullscreen {
width: 100%;
height: 100%;
}
}
.table-overview tbody .icon-checkbox,
@ -3665,6 +3694,45 @@ footer {
margin-top: 2px;
}
.call-widget {
background: hsl(228,17%,91%);
padding: 8px 10px;
& + & {
border-top: 1px solid hsl(228,10%,81%);
}
&-header {
display: flex;
color: inherit;
margin-bottom: 3px;
.label {
color: inherit;
margin: 0;
}
.btn--text {
color: inherit;
opacity: .5;
&:hover {
opacity: 1;
}
}
.icon-diagonal-cross {
width: 9px;
height: 9px;
margin-top: -5px;
}
}
.text-muted {
color: hsl(228,6%,67%);
}
}
.tasks {
background: #2c2d36;
flex: 1;
@ -4810,6 +4878,22 @@ footer {
@extend .u-clickable;
}
.user-card {
padding: 2px 0 0 50px;
position: relative;
min-height: 40px;
max-width: 192px;
display: flex;
flex-direction: column;
justify-content: center;
.avatar {
position: absolute;
left: 0;
top: 0;
}
}
.stat-icon {
position: relative;
}

View File

@ -23,7 +23,7 @@ class CtiController < ApplicationController
}
]
result = Cti::Log.log
result = Cti::Log.log(current_user)
result[:backends] = backends
render json: result
end

View File

@ -6,75 +6,35 @@ class Integration::CtiController < ApplicationController
# notify about inbound call / block inbound call
def event
if params['direction'] == 'in'
if params['event'] == 'newCall'
config_inbound = config_integration[:inbound] || {}
block_caller_ids = config_inbound[:block_caller_ids] || []
local_params = ActiveSupport::HashWithIndifferentAccess.new(params.permit!.to_h)
# check if call need to be blocked
block_caller_ids.each do |item|
next unless item[:caller_id] == params['from']
cti = Cti::Driver::Cti.new(params: local_params, config: config_integration)
render json: { action: 'reject', reason: 'busy' }, status: :ok
result = cti.process
#params['Reject'] = 'busy'
params['comment'] = 'reject, busy'
if params['user']
params['comment'] = "#{params['user']} -> reject, busy"
end
Cti::Log.process(params)
return true
end
end
Cti::Log.process(params)
render json: {}, status: :ok
return true
elsif params['direction'] == 'out'
config_outbound = config_integration[:outbound]
routing_table = nil
default_caller_id = nil
if config_outbound.present?
routing_table = config_outbound[:routing_table]
default_caller_id = config_outbound[:default_caller_id]
end
# set callerId
data = {}
to = params[:to]
from = nil
if to && routing_table.present?
routing_table.each do |row|
dest = row[:dest].gsub(/\*/, '.+?')
next if to !~ /^#{dest}$/
from = row[:caller_id]
data = {
action: 'dial',
caller_id: from,
number: params[:to]
}
break
end
if data.blank? && default_caller_id.present?
from = default_caller_id
data = {
action: 'dial',
caller_id: default_caller_id,
number: params[:to]
}
end
end
render json: data, status: :ok
if from.present?
params['from'] = from
end
Cti::Log.process(params)
# check if inbound call should get rejected
if result[:action] == 'reject'
response_ok(action: 'reject', reason: 'busy')
return true
end
render json: { error: 'Invalid direction!' }, status: :unprocessable_entity
# check if oubound call change the outbound caller_id
if result[:action] == 'set_caller_id'
data = {
action: 'dial',
caller_id: result[:params][:from_caller_id],
number: result[:params][:to_caller_id],
}
response_ok(data)
return true
end
if result[:action] == 'invalid_direction'
response_error('Invalid direction!')
return true
end
response_ok({})
end
private
@ -115,4 +75,8 @@ class Integration::CtiController < ApplicationController
render json: { error: error }, status: :unauthorized
end
def response_ok(data)
render json: data, status: :ok
end
end

View File

@ -10,115 +10,29 @@ class Integration::PlacetelController < ApplicationController
local_params = ActiveSupport::HashWithIndifferentAccess.new(params.permit!.to_h)
# do placetel event mapping
if local_params['event'] == 'IncomingCall'
local_params['direction'] = 'in'
local_params['event'] = 'newCall'
elsif local_params['event'] == 'HungUp'
local_params['event'] = 'hangup'
elsif local_params['event'] == 'CallAccepted'
local_params['event'] = 'answer'
end
cti = Cti::Driver::Placetel.new(params: local_params, config: config_integration)
if local_params['user'].blank? && local_params['peer']
local_params['user'] = get_voip_user_by_peer(local_params['peer'])
end
result = cti.process
if local_params['direction'].blank?
entry = Cti::Log.find_by(call_id: params[:call_id])
if entry
local_params['direction'] = entry.direction
end
end
if local_params['type'] == 'missed'
local_params['cause'] = 'cancel'
elsif local_params['type'] == 'voicemail'
local_params['cause'] = 'voicemail'
elsif local_params['type'] == 'blocked'
local_params['cause'] = 'blocked'
elsif local_params['type'] == 'accepted'
local_params['cause'] = 'normalClearing'
end
if local_params['direction'] == 'in'
if local_params['event'] == 'newCall'
config_inbound = config_integration[:inbound] || {}
block_caller_ids = config_inbound[:block_caller_ids] || []
# check if call need to be blocked
block_caller_ids.each do |item|
next unless item[:caller_id] == local_params['from']
xml = Builder::XmlMarkup.new(indent: 2)
xml.instruct!
content = xml.Response() do
xml.Reject('reason' => 'busy')
end
send_data content, type: 'application/xml; charset=UTF-8;'
#local_params['Reject'] = 'busy'
local_params['comment'] = 'reject, busy'
if local_params['user']
local_params['comment'] = "#{local_params['user']} -> reject, busy"
end
Cti::Log.process(local_params)
return true
end
end
Cti::Log.process(local_params)
xml = Builder::XmlMarkup.new(indent: 2)
xml.instruct!
content = xml.Response()
send_data content, type: 'application/xml; charset=UTF-8;'
return true
elsif local_params['direction'] == 'out'
config_outbound = config_integration[:outbound]
routing_table = nil
default_caller_id = nil
if config_outbound.present?
routing_table = config_outbound[:routing_table]
default_caller_id = config_outbound[:default_caller_id]
end
xml = Builder::XmlMarkup.new(indent: 2)
xml.instruct!
# set callerId
content = nil
to = local_params[:to]
from = nil
if to && routing_table.present?
routing_table.each do |row|
dest = row[:dest].gsub(/\*/, '.+?')
next if to !~ /^#{dest}$/
from = row[:caller_id]
content = xml.Response() do
xml.Dial(callerId: from) { xml.Number(params[:to]) }
end
break
end
if !content && default_caller_id.present?
from = default_caller_id
content = xml.Response() do
xml.Dial(callerId: default_caller_id) { xml.Number(params[:to]) }
end
end
else
content = xml.Response()
end
send_data(content, type: 'application/xml; charset=UTF-8;')
if from.present?
local_params['from'] = from
end
Cti::Log.process(local_params)
# check if inbound call should get rejected
if result[:action] == 'reject'
response_reject(result)
return true
end
response_error('Invalid direction!')
# check if oubound call change the outbound caller_id
if result[:action] == 'set_caller_id'
response_set_caller_id(result)
return true
end
if result[:action] == 'invalid_direction'
response_error('Invalid direction!')
return true
end
response_ok(response)
true
end
private
@ -168,65 +82,29 @@ class Integration::PlacetelController < ApplicationController
xml_error(error, 401)
end
def get_voip_user_by_peer(peer)
load_voip_users[peer]
def response_reject(_result)
xml = Builder::XmlMarkup.new(indent: 2)
xml.instruct!
content = xml.Response() do
xml.Reject({ reason: 'busy' })
end
send_data content, type: 'application/xml; charset=UTF-8;'
end
def load_voip_users
return {} if config_integration.blank? || config_integration[:api_token].blank?
list = Cache.get('placetelGetVoipUsers')
return list if list
response = UserAgent.post(
'https://api.placetel.de/api/getVoIPUsers.json',
{
api_key: config_integration[:api_token],
},
{
log: {
facility: 'placetel',
},
json: true,
open_timeout: 4,
read_timeout: 6,
total_timeout: 6,
},
)
if !response.success?
logger.error "Can't fetch getVoipUsers from '#{url}', http code: #{response.code}"
Cache.write('placetelGetVoipUsers', {}, { expires_in: 1.hour })
return {}
def response_set_caller_id(result)
xml = Builder::XmlMarkup.new(indent: 2)
xml.instruct!
content = xml.Response() do
xml.Dial(callerId: result[:params][:from_caller_id]) { xml.Number(result[:params][:to_caller_id]) }
end
result = response.data
if result.blank?
logger.error "Can't fetch getVoipUsers from '#{url}', result: #{response.inspect}"
Cache.write('placetelGetVoipUsers', {}, { expires_in: 1.hour })
return {}
end
if result.is_a?(Hash) && (result['result'] == '-1' || result['result_code'] == 'error')
logger.error "Can't fetch getVoipUsers from '#{url}', result: #{result.inspect}"
Cache.write('placetelGetVoipUsers', {}, { expires_in: 1.hour })
return {}
end
if !result.is_a?(Array)
logger.error "Can't fetch getVoipUsers from '#{url}', result: #{result.inspect}"
Cache.write('placetelGetVoipUsers', {}, { expires_in: 1.hour })
return {}
end
list = {}
result.each do |entry|
next if entry['name'].blank?
if entry['uid'].present?
list[entry['uid']] = entry['name']
end
next if entry['uid2'].blank?
list[entry['uid2']] = entry['name']
end
Cache.write('placetelGetVoipUsers', list, { expires_in: 24.hours })
list
send_data(content, type: 'application/xml; charset=UTF-8;')
end
def response_ok(_result)
xml = Builder::XmlMarkup.new(indent: 2)
xml.instruct!
content = xml.Response()
send_data content, type: 'application/xml; charset=UTF-8;'
end
end

View File

@ -6,84 +6,33 @@ class Integration::SipgateController < ApplicationController
before_action :check_configured
# notify about inbound call / block inbound call
def in
if params['event'] == 'newCall'
config_inbound = config_integration[:inbound] || {}
block_caller_ids = config_inbound[:block_caller_ids] || []
def event
# check if call need to be blocked
block_caller_ids.each do |item|
next if item[:caller_id] != params['from']
local_params = ActiveSupport::HashWithIndifferentAccess.new(params.permit!.to_h)
xml = Builder::XmlMarkup.new(indent: 2)
xml.instruct!
content = xml.Response(onHangup: url, onAnswer: url) do
xml.Reject('reason' => 'busy')
end
cti = Cti::Driver::SipgateIo.new(params: local_params, config: config_integration)
send_data content, type: 'application/xml; charset=UTF-8;'
result = cti.process
#params['Reject'] = 'busy'
params['comment'] = 'reject, busy'
if params['user']
params['comment'] = "#{params['user']} -> reject, busy"
end
Cti::Log.process(params)
return true
end
# check if inbound call should get rejected
if result[:action] == 'reject'
response_reject(result)
return true
end
Cti::Log.process(params)
xml = Builder::XmlMarkup.new(indent: 2)
xml.instruct!
content = xml.Response(onHangup: url, onAnswer: url)
send_data content, type: 'application/xml; charset=UTF-8;'
end
# set caller id of outbound call
def out
config_outbound = config_integration[:outbound]
routing_table = nil
default_caller_id = nil
if config_outbound.present?
routing_table = config_outbound[:routing_table]
default_caller_id = config_outbound[:default_caller_id]
# check if oubound call change the outbound caller_id
if result[:action] == 'set_caller_id'
response_set_caller_id(result)
return true
end
xml = Builder::XmlMarkup.new(indent: 2)
xml.instruct!
# set callerId
content = nil
to = params[:to]
from = nil
if to && routing_table.present?
routing_table.each do |row|
dest = row[:dest].gsub(/\*/, '.+?')
next if to !~ /^#{dest}$/
from = row[:caller_id]
content = xml.Response(onHangup: url, onAnswer: url) do
xml.Dial(callerId: from) { xml.Number(params[:to]) }
end
break
end
if !content && default_caller_id.present?
from = default_caller_id
content = xml.Response(onHangup: url, onAnswer: url) do
xml.Dial(callerId: default_caller_id) { xml.Number(params[:to]) }
end
end
else
content = xml.Response(onHangup: url, onAnswer: url)
if result[:action] == 'invalid_direction'
response_error('Invalid direction!')
return true
end
send_data(content, type: 'application/xml; charset=UTF-8;')
if from.present?
params['from'] = from
end
Cti::Log.process(params)
response_ok(response)
true
end
private
@ -128,4 +77,30 @@ class Integration::SipgateController < ApplicationController
def url
"#{base_url}/#{params['direction']}"
end
def response_reject(_result)
xml = Builder::XmlMarkup.new(indent: 2)
xml.instruct!
content = xml.Response(onHangup: url, onAnswer: url) do
xml.Reject({ reason: 'busy' })
end
send_data content, type: 'application/xml; charset=UTF-8;'
end
def response_set_caller_id(result)
xml = Builder::XmlMarkup.new(indent: 2)
xml.instruct!
content = xml.Response(onHangup: url, onAnswer: url) do
xml.Dial(callerId: result[:params][:from_caller_id]) { xml.Number(result[:params][:to_caller_id]) }
end
send_data(content, type: 'application/xml; charset=UTF-8;')
end
def response_ok(_result)
xml = Builder::XmlMarkup.new(indent: 2)
xml.instruct!
content = xml.Response(onHangup: url, onAnswer: url)
send_data content, type: 'application/xml; charset=UTF-8;'
end
end

View File

@ -39,6 +39,8 @@ module Cti
=begin
get items (users) for a certain caller id
caller_id_records = Cti::CallerId.lookup('49123456789')
returns
@ -312,6 +314,30 @@ returns
nil
end
=begin
return users by caller_id
[user1, user2] = Cti::CallerId.known_agents_by_number('491234567')
=end
def self.known_agents_by_number(number)
users = []
caller_ids = Cti::CallerId.extract_numbers(number)
caller_id_records = Cti::CallerId.lookup(caller_ids)
caller_id_records.each do |caller_id_record|
next if caller_id_record.level != 'known'
user = User.find_by(id: caller_id_record.user_id)
next if !user
next if !user.permissions?('cti.agent')
users.push user
end
users
end
def update_cti_logs
return if object != 'User'

View File

@ -0,0 +1,223 @@
class Cti::Driver::Base
def initialize(params = {})
@config = params[:config] || config
@params = mapping(params[:params])
end
def mapping(params)
params
end
def process
# validate diections
result = direction_check
return result if result.present?
# reject inbound call
result = reject_check
if result.present? && result[:action] == 'reject'
@params['comment'] = 'reject, busy'
if @params['user'].present?
@params['comment'] = "#{@params['user']} -> reject, busy"
end
Cti::Log.process(@params)
return result
end
# set caller id of outbound call
result = caller_id_rewrite(@params)
if result.present? && result[:action] == 'set_caller_id'
@params['from'] = result[:params][:from_caller_id]
Cti::Log.process(@params)
return result
end
log = Cti::Log.process(@params)
# push new call notifiation
push_incoming_call(log)
# open screen if call got answerd
push_open_ticket_screen(log)
result || {}
end
def direction_check
# check possible diections
if @params['direction'] != 'in' && @params['direction'] != 'out'
return {
action: 'invalid_direction',
params: @params
}
end
nil
end
def reject_check
return nil if @params['direction'] != 'in'
return nil if @params['event'] != 'newCall'
config_inbound = @config[:inbound] || {}
block_caller_ids = config_inbound[:block_caller_ids] || []
# check if call need to be blocked
block_caller_ids.each do |item|
next if item[:caller_id] != @params['from']
return {
action: 'reject'
}
end
nil
end
def caller_id_rewrite(params)
return nil if params['direction'] != 'out'
return nil if params['event'] != 'newCall'
config_outbound = @config[:outbound]
routing_table = nil
default_caller_id = nil
if config_outbound.present?
routing_table = config_outbound[:routing_table]
default_caller_id = config_outbound[:default_caller_id]
end
to = params[:to]
return nil if to.blank?
if routing_table.present?
routing_table.each do |row|
dest = row[:dest].gsub(/\*/, '.+?')
next if to !~ /^#{dest}$/
return {
action: 'set_caller_id',
params: {
from_caller_id: row[:caller_id],
to_caller_id: params[:to],
}
}
end
end
if default_caller_id.present?
return {
action: 'set_caller_id',
params: {
from_caller_id: default_caller_id,
to_caller_id: params[:to],
}
}
end
nil
end
def push_open_ticket_screen(log)
return if log.destroyed?
return if @params[:event] != 'answer'
return if @params[:direction] != 'in'
user = push_open_ticket_screen_recipient
return if !user
return if !user.permissions?('cti.agent')
customer_id = log.best_customer_id_of_log_entry
id = rand(999_999_999)
PushMessages.send_to(user.id, {
event: 'remote_task',
data: {
key: "TicketCreateScreen-#{id}",
controller: 'TicketCreate',
params: { customer_id: customer_id.to_s, title: 'Call', id: id },
show: true,
url: "ticket/create/id/#{id}"
},
})
end
def push_open_ticket_screen_recipient
# try to find answering which answered call
user = nil
# based on answeringNumber
if @params[:answeringNumber].present?
user = Cti::CallerId.known_agents_by_number(@params[:answeringNumber]).first
end
# based on user param
if !user && @params[:user].present?
user = User.find_by(login: @params[:user].downcase)
end
# based on user_id param
if !user && @params[:user_id].present?
user = User.find_by(id: @params[:user_id])
end
user
end
def push_incoming_call(log)
return if log.destroyed?
return if @params[:event] != 'newCall'
return if @params[:direction] != 'in'
# check if only a certain user should get the notification
if @config[:notify_map].present?
user_ids = []
@config[:notify_map].each do |row|
next if row[:user_ids].blank? || row[:queue] != @params[:to]
row[:user_ids].each do |user_id|
user = User.find_by(id: user_id)
next if !user
next if !user.permissions?('cti.agent')
user_ids.push user.id
end
end
# add agents which have this number directly assigned
Cti::CallerId.known_agents_by_number(@params[:to]).each do |user|
next if !user
next if !user.permissions?('cti.agent')
user_ids.push user.id
end
user_ids.uniq.each do |user_id|
PushMessages.send_to(
user_id,
{
event: 'cti_event',
data: log,
},
)
end
return true
end
# send notify about event
users = User.with_permissions('cti.agent')
users.each do |user|
PushMessages.send_to(
user.id,
{
event: 'cti_event',
data: log,
},
)
end
true
end
end

View File

@ -0,0 +1,7 @@
class Cti::Driver::Cti < Cti::Driver::Base
def config
Setting.get('cti_config')
end
end

View File

@ -0,0 +1,139 @@
class Cti::Driver::Placetel < Cti::Driver::Base
def config
Setting.get('placetel_config')
end
def mapping(params)
# do event mapping
if params['event'] == 'IncomingCall'
params['direction'] = 'in'
params['event'] = 'newCall'
elsif params['event'] == 'HungUp'
params['event'] = 'hangup'
elsif params['event'] == 'CallAccepted'
params['event'] = 'answer'
end
if params['user'].blank? && params['peer'].present?
params['user'] = get_voip_user_by_peer(params['peer'])
end
# lookup current direction if not given
if params['direction'].blank?
entry = Cti::Log.find_by(call_id: params[:call_id])
if entry
params['direction'] = entry.direction
end
end
# do case mapping
if params['type'] == 'missed'
params['cause'] = 'cancel'
elsif params['type'] == 'voicemail'
params['cause'] = 'voicemail'
elsif params['type'] == 'blocked'
params['cause'] = 'blocked'
elsif params['type'] == 'accepted'
params['cause'] = 'normalClearing'
end
params
end
def push_open_ticket_screen_recipient
# try to find answering which answered call
user = nil
# based on peer
if @params['peer'].present?
user_id = get_user_id_by_peer(@params['peer'])
if user_id.present?
user = if User.exists?(user_id)
User.find(user_id)
else
User.find_by(email: user_id.downcase)
end
end
end
user
end
def get_voip_user_by_peer(peer)
load_voip_users[peer]
end
def load_voip_users
return {} if @config.blank? || @config[:api_token].blank?
list = Cache.get('placetelGetVoipUsers')
return list if list
response = UserAgent.post(
'https://api.placetel.de/api/getVoIPUsers.json',
{
api_key: @config[:api_token],
},
{
log: {
facility: 'placetel',
},
json: true,
open_timeout: 4,
read_timeout: 6,
total_timeout: 6,
},
)
if !response.success?
Rails.logger.error "Can't fetch getVoipUsers from '#{url}', http code: #{response.code}"
Cache.write('placetelGetVoipUsers', {}, { expires_in: 1.hour })
return {}
end
result = response.data
if result.blank?
Rails.logger.error "Can't fetch getVoipUsers from '#{url}', result: #{response.inspect}"
Cache.write('placetelGetVoipUsers', {}, { expires_in: 1.hour })
return {}
end
if result.is_a?(Hash) && (result['result'] == '-1' || result['result_code'] == 'error')
Rails.logger.error "Can't fetch getVoipUsers from '#{url}', result: #{result.inspect}"
Cache.write('placetelGetVoipUsers', {}, { expires_in: 1.hour })
return {}
end
if !result.is_a?(Array)
Rails.logger.error "Can't fetch getVoipUsers from '#{url}', result: #{result.inspect}"
Cache.write('placetelGetVoipUsers', {}, { expires_in: 1.hour })
return {}
end
list = {}
result.each do |entry|
next if entry['name'].blank?
if entry['uid'].present?
list[entry['uid']] = entry['name']
end
next if entry['uid2'].blank?
list[entry['uid2']] = entry['name']
end
Cache.write('placetelGetVoipUsers', list, { expires_in: 24.hours })
list
end
def get_user_id_by_peer(peer)
return if @config.blank? || @config[:user_device_map].blank?
@config[:user_device_map].each do |row|
next if row[:user_id].blank?
return row[:user_id] if row[:device_id] == peer
end
nil
end
end

View File

@ -0,0 +1,107 @@
class Cti::Driver::SipgateIo < Cti::Driver::Base
def config
Setting.get('sipgate_config')
end
def push_open_ticket_screen_recipient
# try to find answering which answered call
user = nil
# based on peer
if @params['userId'].present?
user_id = get_user_id_by_sipgate_user_id(@params['userId'])
if user_id.present?
user = if User.exists?(user_id)
User.find(user_id)
else
User.find_by(email: user_id.downcase)
end
end
end
user
end
def load_voip_users
return {} if @config.blank? || @config[:api_user].blank? || @config[:api_password].blank?
list = Cache.get('sipgateUserList')
return list if list
url = 'https://api.sipgate.com/v2/users'
response = UserAgent.get(
url,
{},
{
user: @config[:api_user],
password: @config[:api_password],
log: {
facility: 'sipagte.io',
},
json: true,
open_timeout: 4,
read_timeout: 6,
total_timeout: 6,
},
)
if !response.success?
Rails.logger.error "Can't fetch users from '#{url}', http code: #{response.code}"
Cache.write('sipgateUserList', {}, { expires_in: 1.hour })
return {}
end
result = response.data
if result.blank?
Rails.logger.error "Can't fetch users from '#{url}', result: #{response.inspect}"
Cache.write('sipgateUserList', {}, { expires_in: 1.hour })
return {}
end
if result.is_a?(Array) && (result['result'] == '-1' || result['result_code'] == 'error')
Rails.logger.error "Can't fetch users from '#{url}', result: #{result.inspect}"
Cache.write('sipgateUserList', {}, { expires_in: 1.hour })
return {}
end
if !result.is_a?(Hash)
Rails.logger.error "Can't fetch users from '#{url}', result: #{result.inspect}"
Cache.write('sipgateUserList', {}, { expires_in: 1.hour })
return {}
end
if result['items'].blank?
Rails.logger.error "Can't fetch users from '#{url}', no items found, result: #{result.inspect}"
Cache.write('sipgateUserList', {}, { expires_in: 1.hour })
return {}
end
list = {}
result['items'].each do |entry|
next if entry['id'].blank?
name = ''
%w[firstname lastname email].each do |item|
next if entry[item].blank?
name += ' ' if name.present?
name += entry[item]
end
list[entry['id']] = name
end
Cache.write('sipgateUserList', list, { expires_in: 24.hours })
list
end
def get_user_id_by_sipgate_user_id(user_id)
return if @config.blank? || @config[:user_remote_map].blank?
@config[:user_remote_map].each do |row|
next if row[:user_id].blank?
return row[:user_id] if row[:remote_user_id] == user_id
end
nil
end
end

View File

@ -8,19 +8,20 @@ module Cti
validates :state, format: { with: /\A(newCall|answer|hangup)\z/,  message: 'newCall|answer|hangup is allowed' }
after_commit :push_incoming_call, :push_caller_list_update
after_commit :push_caller_list_update
=begin
Cti::Log.create!(
direction: 'in',
from: '007',
from_comment: 'AAA',
from_comment: '',
to: '008',
to_comment: 'BBB',
call_id: '1',
comment: '',
state: 'newCall',
done: true,
)
Cti::Log.create!(
@ -32,6 +33,7 @@ module Cti
call_id: '2',
comment: '',
state: 'answer',
done: true,
)
Cti::Log.create!(
@ -43,6 +45,7 @@ module Cti
call_id: '3',
comment: '',
state: 'hangup',
done: true,
)
example data, can be used for demo
@ -66,7 +69,15 @@ example data, can be used for demo
object: 'User',
o_id: 2,
user_id: 2,
}
},
{
caller_id: '4930726128135',
comment: nil,
level: 'maybe',
object: 'User',
o_id: 2,
user_id: 3,
},
]
},
created_at: Time.zone.now,
@ -81,6 +92,7 @@ example data, can be used for demo
call_id: rand(999_999_999),
comment: '',
state: 'newCall',
done: true,
preferences: {
to: [
{
@ -105,6 +117,7 @@ example data, can be used for demo
call_id: rand(999_999_999),
comment: '',
state: 'answer',
done: true,
preferences: {
from: [
{
@ -163,6 +176,7 @@ example data, can be used for demo
call_id: rand(999_999_999),
comment: '',
state: 'hangup',
done: true,
start_at: Time.zone.now - 15.seconds,
end_at: Time.zone.now,
preferences: {
@ -194,6 +208,7 @@ example data, can be used for demo
call_id: rand(999_999_999),
comment: '',
state: 'hangup',
done: true,
start_at: Time.zone.now - 15.seconds,
end_at: Time.zone.now,
preferences: {
@ -225,6 +240,7 @@ example data, can be used for demo
call_id: rand(999_999_999),
comment: '',
state: 'hangup',
done: true,
start_at: Time.zone.now - 15.seconds,
end_at: Time.zone.now,
preferences: {
@ -254,6 +270,7 @@ example data, can be used for demo
call_id: rand(999_999_999),
comment: '',
state: 'hangup',
done: true,
start_at: Time.zone.now - 20.seconds,
end_at: Time.zone.now,
preferences: {},
@ -269,7 +286,7 @@ example data, can be used for demo
=begin
Cti::Log.log
Cti::Log.log(current_user)
returns
@ -280,8 +297,8 @@ returns
=end
def self.log
list = Cti::Log.log_records
def self.log(current_user)
list = Cti::Log.log_records(current_user)
# add assets
assets = list.map(&:preferences)
@ -299,7 +316,7 @@ returns
=begin
Cti::Log.log_records
Cti::Log.log_records(current_user)
returns
@ -307,7 +324,12 @@ returns
=end
def self.log_records
def self.log_records(current_user)
cti_config = Setting.get('cti_config')
if cti_config[:notify_map].present?
return Cti::Log.where(queue: queues_of_user(current_user, cti_config)).order(created_at: :desc).limit(60)
end
Cti::Log.order(created_at: :desc).limit(60)
end
@ -349,9 +371,15 @@ Cti::Log.process(
to_comment = queue
end
from_comment, preferences = CallerId.get_comment_preferences(params['from'], 'from')
if queue.blank?
queue = params['to']
end
else
from_comment = user
to_comment, preferences = CallerId.get_comment_preferences(params['to'], 'to')
if queue.blank?
queue = params['from']
end
end
log = find_by(call_id: call_id)
@ -363,7 +391,7 @@ Cti::Log.process(
end
raise "call_id #{call_id} already exists!" if log
create(
log = create(
direction: params['direction'],
from: params['from'],
from_comment: from_comment,
@ -417,29 +445,12 @@ Cti::Log.process(
else
raise ArgumentError, "Unknown event #{event.inspect}"
end
end
def push_incoming_call
return true if destroyed?
return true if state != 'newCall'
return true if direction != 'in'
# send notify about event
users = User.with_permissions('cti.agent')
users.each do |user|
Sessions.send_to(
user.id,
{
event: 'cti_event',
data: self,
},
)
end
true
log
end
def self.push_caller_list_update?(record)
list_ids = Cti::Log.log_records.pluck(:id)
list_ids = Cti::Log.order(created_at: :desc).limit(60).pluck(:id)
return true if list_ids.include?(record.id)
false
@ -498,5 +509,54 @@ optional you can put the max oldest chat entries as argument
parsed = TelephoneNumber.parse(to&.sub(/^\+?/, '+'))
parsed.send(parsed.valid? ? :international_number : :original_number)
end
=begin
returnes queues of user
['queue1', 'queue2'] = Cti::Log.queues_of_user(User.find(123), config)
=end
def self.queues_of_user(user, config)
queues = []
config[:notify_map]&.each do |row|
next if row[:user_ids].blank?
next if !row[:user_ids].include?(user.id.to_s) && !row[:user_ids].include?(user.id)
queues.push row[:queue]
end
if user.phone.present?
caller_ids = Cti::CallerId.extract_numbers(user.phone)
queues = queues.concat(caller_ids)
end
queues
end
=begin
return best customer id of caller log
log = Cti::Log.find(123)
customer_id = log.best_customer_id_of_log_entry
=end
def best_customer_id_of_log_entry
customer_id = nil
if preferences[:from].present?
preferences[:from].each do |entry|
if customer_id.blank?
customer_id = entry[:user_id]
end
next if entry[:level] != 'known'
customer_id = entry[:user_id]
break
end
end
customer_id
end
end
end

View File

@ -1,6 +1,6 @@
Zammad::Application.routes.draw do
match '/api/v1/sipgate/in', to: 'integration/sipgate#in', via: :post
match '/api/v1/sipgate/out', to: 'integration/sipgate#out', via: :post
match '/api/v1/sipgate/in', to: 'integration/sipgate#event', via: :post
match '/api/v1/sipgate/out', to: 'integration/sipgate#event', via: :post
end

View File

@ -249,6 +249,39 @@ RSpec.describe Cti::CallerId do
end
end
describe '.known_agents_by_number' do
context 'with known agent caller_id' do
let!(:agent_user1) { create(:agent_user, phone: '0123456') }
let!(:agent_user2) { create(:agent_user, phone: '0123457') }
it 'gives matching agents' do
expect(described_class.known_agents_by_number('49123456'))
.to match_array([agent_user1])
end
end
context 'with known customer caller_id' do
let!(:customer_user1) { create(:customer_user, phone: '0123456') }
it 'returns an empty array' do
expect(described_class.known_agents_by_number('49123456')).to eq([])
end
end
context 'with maybe caller_id' do
let(:ticket1) do
create(:ticket_article, created_by_id: customer_user2.id, body: 'some text 0123457') # create ticket
Observer::Transaction.commit
Scheduler.worker(true)
end
let!(:customer_user2) { create(:customer_user) }
it 'returns an empty array' do
expect(described_class.known_agents_by_number('49123457').count).to eq(0)
end
end
end
describe 'callbacks' do
subject!(:caller_id) { build(:cti_caller_id, caller_id: phone) }

View File

@ -1,11 +1,14 @@
require 'rails_helper'
RSpec.describe Cti::Log do
subject(:log) { create(:'cti/log') }
subject(:user) { create(:user, roles: Role.where(name: 'Agent'), phone: phone) }
let(:phone) { '' }
let(:log) { create(:'cti/log') }
describe '.log' do
it 'returns a hash with :list and :assets keys' do
expect(described_class.log).to be_a(Hash).and include(:list, :assets)
expect(described_class.log(user)).to match(hash_including(:list, :assets))
end
context 'when over 60 Log records exist' do
@ -17,7 +20,7 @@ RSpec.describe Cti::Log do
end
it 'returns the 60 latest ones in the :list key' do
expect(described_class.log[:list]).to match_array(cti_logs.last(60))
expect(described_class.log(user)[:list]).to match_array(cti_logs.last(60))
end
end
@ -25,10 +28,52 @@ RSpec.describe Cti::Log do
subject!(:cti_log) { create(:'cti/log', preferences: { from: [caller_id] }) }
let(:caller_id) { create(:caller_id) }
let(:user) { User.find_by(id: caller_id.user_id) }
let(:caller_user) { User.find_by(id: caller_id.user_id) }
it 'returns a hash of the CallerId Users and their assets in the :assets key' do
expect(described_class.log[:assets]).to eq(user.assets({}))
expect(described_class.log(user)[:assets]).to eq(caller_user.assets({}))
end
end
context 'when a notify map is defined' do
subject!(:cti_logs) do
[create(:'cti/log', queue: 'queue0'),
create(:'cti/log', queue: 'queue2'),
create(:'cti/log', queue: 'queue3'),
create(:'cti/log', queue: 'queue4')]
end
before do
cti_config = Setting.get('cti_config')
cti_config[:notify_map] = [ { queue: 'queue4', user_ids: [user.id.to_s] } ]
Setting.set('cti_config', cti_config)
end
it 'returns one matching log record' do
expect(described_class.log(user)[:list]).to match_array([cti_logs[3]])
end
end
end
describe '.push_caller_list_update?' do
let!(:existing_logs) { create_list(:'cti/log', 60) }
let(:log) { create(:'cti/log') }
context 'when given log is older than existing logs' do
before { travel(-10.seconds) }
it 'return false' do
expect(described_class.push_caller_list_update?(log)).to eq false
end
end
context 'when given log is newer than existing logs' do
before { travel(10.seconds) }
it 'return true' do
expect(described_class.push_caller_list_update?(log)).to eq true
end
end
end
@ -52,12 +97,25 @@ RSpec.describe Cti::Log do
let(:event) { 'newCall' }
context 'with unrecognized "call_id"' do
it 'creates a new Log record (#state: "newCall", #done: false)' do
it 'creates a new Log record' do
expect { described_class.process(attributes) }
.to change(described_class, :count).by(1)
expect(described_class.last.attributes)
.to include('state' => 'newCall', 'done' => false)
.to include(
'call_id' => '1',
'state' => 'newCall',
'done' => false,
'queue' => '49123457',
'from' => '49123456',
'from_comment' => nil,
'from_pretty' => '49123456',
'start_at' => nil,
'end_at' => nil,
'to' => '49123457',
'to_comment' => 'user 1',
'to_pretty' => '49123457'
)
end
context 'for direction "in", with a CallerId record matching the "from" number' do
@ -178,6 +236,93 @@ RSpec.describe Cti::Log do
end
end
end
context 'for preferences.from verification' do
subject(:log) do
described_class.process(attributes)
end
let(:customer_user_of_ticket) { create(:customer_user) }
let(:ticket_sample) do
create(:ticket_article, created_by_id: customer_user_of_ticket.id, body: 'some text 0123457')
Observer::Transaction.commit
Scheduler.worker(true)
end
let(:caller_id) { '0123456' }
let(:attributes) do
{
'cause' => '',
'event' => 'newCall',
'user' => 'user 1',
'from' => caller_id,
'to' => '49123450',
'call_id' => '1',
'direction' => 'in',
}
end
context 'with now related customer' do
it 'gives no caller information' do
expect(log.preferences[:from]).to eq(nil)
end
end
context 'with related known customer' do
let!(:customer_user) { create(:customer_user, phone: '0123456') }
it 'gives caller information' do
expect(log.preferences[:from].count).to eq(1)
expect(log.preferences[:from].first)
.to include(
'level' => 'known',
'user_id' => customer_user.id,
)
end
end
context 'with related known customers' do
let!(:customer_user1) { create(:customer_user, phone: '0123456') }
let!(:customer_user2) { create(:customer_user, phone: '0123456') }
it 'gives caller information' do
expect(log.preferences[:from].count).to eq(2)
expect(log.preferences[:from].first)
.to include(
'level' => 'known',
'user_id' => customer_user2.id,
)
end
end
context 'with related maybe customer' do
let(:caller_id) { '0123457' }
let!(:ticket) { ticket_sample }
it 'gives caller information' do
expect(log.preferences[:from].count).to eq(1)
expect(log.preferences[:from].first)
.to include(
'level' => 'maybe',
'user_id' => customer_user_of_ticket.id,
)
end
end
context 'with related maybe and known customer' do
let(:caller_id) { '0123457' }
let!(:customer) { create(:customer_user, phone: '0123457') }
let!(:ticket) { ticket_sample }
it 'gives caller information' do
expect(log.preferences[:from].count).to eq(1)
expect(log.preferences[:from].first)
.to include(
'level' => 'known',
'user_id' => customer.id,
)
end
end
end
end
describe 'Callbacks -' do
@ -280,6 +425,71 @@ RSpec.describe Cti::Log do
end
end
describe '.queues_of_user' do
context 'without notify_map and no own phone number' do
it 'gives an empty array' do
expect(described_class.queues_of_user(user, Setting.get('cti_config'))).to eq([])
end
end
context 'with notify_map and no own phone number' do
before do
cti_config = Setting.get('cti_config')
cti_config[:notify_map] = [ { queue: 'queue4', user_ids: [user.id.to_s] } ]
Setting.set('cti_config', cti_config)
end
it 'gives an array with queue' do
expect(described_class.queues_of_user(user, Setting.get('cti_config'))).to eq(['queue4'])
end
end
context 'with notify_map and with own phone number' do
let(:phone) { '012345678' }
before do
cti_config = Setting.get('cti_config')
cti_config[:notify_map] = [ { queue: 'queue4', user_ids: [user.id.to_s] } ]
Setting.set('cti_config', cti_config)
end
it 'gives an array with queue and phone number' do
expect(described_class.queues_of_user(user, Setting.get('cti_config'))).to eq(%w[queue4 4912345678])
end
end
end
describe '#best_customer_id_of_log_entry' do
subject(:log1) do
described_class.process(
'event' => 'newCall',
'user' => 'user 1',
'from' => '01234599',
'to' => '49123450',
'call_id' => '1',
'direction' => 'in',
)
end
let!(:agent1) { create(:agent_user, phone: '01234599') }
let!(:customer2) { create(:customer_user, phone: '') }
let!(:ticket_article1) { create(:ticket_article, created_by_id: customer2.id, body: 'some text 01234599') }
context 'with agent1 (known), customer1 (known) and customer2 (maybe)' do
let!(:customer1) { create(:customer_user, phone: '01234599') }
it 'gives customer1' do
expect(log1.best_customer_id_of_log_entry).to eq(customer1.id)
end
end
context 'with agent1 (known) and customer2 (maybe)' do
it 'gives customer2' do
expect(log1.best_customer_id_of_log_entry).to eq(agent1.id)
end
end
end
describe '#to_json' do
it 'includes virtual attributes' do
expect(log.as_json).to include('from_pretty', 'to_pretty')

View File

@ -61,10 +61,6 @@ RSpec.describe 'Integration CTI', type: :request do
note: 'some note',
}
],
notify_user_ids: {
2 => true,
4 => false,
},
}
})
@ -72,29 +68,47 @@ RSpec.describe 'Integration CTI', type: :request do
end
describe 'request handling' do
let!(:token) { Setting.get('cti_token') }
it 'does token check' do
params = 'event=newCall&direction=in&from=4912347114711&to=4930600000000&call_id=4991155921769858278-1&user%5B%5D=user+1&user%5B%5D=user+2'
post '/api/v1/cti/not_existing_token', params: params
post '/api/v1/cti/not_existing_token', params: {
event: 'newCall',
direction: 'in',
from: '4912347114711',
to: '4930600000000',
call_id: '4991155921769858278-1',
user: 'user 1',
}
expect(response).to have_http_status(:unauthorized)
expect(json_response).to be_a_kind_of(Hash)
expect(json_response['error']).to eq('Invalid token, please contact your admin!')
end
it 'does basic call' do
token = Setting.get('cti_token')
# inbound - I
params = 'event=newCall&direction=in&from=4912347114711&to=4930600000000&call_id=4991155921769858278-1&user%5B%5D=user+1&user%5B%5D=user+2'
post "/api/v1/cti/#{token}", params: params
post "/api/v1/cti/#{token}", params: {
event: 'newCall',
direction: 'in',
from: '4912347114711',
to: '4930600000000',
call_id: '4991155921769858278-1',
user: ['user+1', 'user+2'],
}
expect(response).to have_http_status(:ok)
expect(json_response).to be_a_kind_of(Hash)
expect(json_response).to be_blank
# inbound - II - block caller
params = 'event=newCall&direction=in&from=491715000000&to=4930600000000&call_id=4991155921769858278-2&user%5B%5D=user+1&user%5B%5D=user+2'
post "/api/v1/cti/#{token}", params: params
post "/api/v1/cti/#{token}", params: {
event: 'newCall',
direction: 'in',
from: '491715000000',
to: '4930600000000',
call_id: '4991155921769858278-2',
user: ['user+1', 'user+2'],
}
expect(response).to have_http_status(:ok)
expect(json_response).to be_a_kind_of(Hash)
@ -102,8 +116,14 @@ RSpec.describe 'Integration CTI', type: :request do
expect(json_response['reason']).to eq('busy')
# outbound - I - set default_caller_id
params = 'event=newCall&direction=out&from=4930600000000&to=4912347114711&call_id=8621106404543334274-3&user%5B%5D=user+1'
post "/api/v1/cti/#{token}", params: params
post "/api/v1/cti/#{token}", params: {
event: 'newCall',
direction: 'out',
from: '4930600000000',
to: '4912347114711',
call_id: '8621106404543334274-3',
user: 'user 1',
}
expect(response).to have_http_status(:ok)
expect(json_response).to be_a_kind_of(Hash)
expect(json_response['action']).to eq('dial')
@ -111,8 +131,14 @@ RSpec.describe 'Integration CTI', type: :request do
expect(json_response['caller_id']).to eq('4930777000000')
# outbound - II - set caller_id based on routing_table by explicite number
params = 'event=newCall&direction=out&from=4930600000000&to=491714000000&call_id=8621106404543334274-4&user%5B%5D=user+1'
post "/api/v1/cti/#{token}", params: params
post "/api/v1/cti/#{token}", params: {
event: 'newCall',
direction: 'out',
from: '4930600000000',
to: '491714000000',
call_id: '8621106404543334274-4',
user: 'user 1',
}
expect(response).to have_http_status(:ok)
expect(json_response).to be_a_kind_of(Hash)
expect(json_response['action']).to eq('dial')
@ -120,8 +146,14 @@ RSpec.describe 'Integration CTI', type: :request do
expect(json_response['caller_id']).to eq('41715880339000')
# outbound - III - set caller_id based on routing_table by 41*
params = 'event=newCall&direction=out&from=4930600000000&to=4147110000000&call_id=8621106404543334274-5&user%5B%5D=user+1'
post "/api/v1/cti/#{token}", params: params
post "/api/v1/cti/#{token}", params: {
event: 'newCall',
direction: 'out',
from: '4930600000000',
to: '4147110000000',
call_id: '8621106404543334274-5',
user: 'user 1',
}
expect(response).to have_http_status(:ok)
expect(json_response).to be_a_kind_of(Hash)
expect(json_response['action']).to eq('dial')
@ -130,8 +162,15 @@ RSpec.describe 'Integration CTI', type: :request do
# no config
Setting.set('cti_config', {})
params = 'event=newCall&direction=in&from=4912347114711&to=4930600000000&call_id=4991155921769858278-6&user%5B%5D=user+1&user%5B%5D=user+2'
post "/api/v1/cti/#{token}", params: params
post "/api/v1/cti/#{token}", params: {
event: 'newCall',
direction: 'in',
from: '4912347114711',
to: '4930600000000',
call_id: '4991155921769858278-6',
user: ['user+1', 'user+2'],
}
expect(response).to have_http_status(:unprocessable_entity)
expect(json_response).to be_a_kind_of(Hash)
expect(json_response['error']).to eq('Feature not configured, please contact your admin!')
@ -139,11 +178,16 @@ RSpec.describe 'Integration CTI', type: :request do
end
it 'does log call' do
token = Setting.get('cti_token')
# outbound - I - new call
params = 'event=newCall&direction=out&from=4930600000000&to=4912347114711&call_id=1234567890-1&user%5B%5D=user+1'
post "/api/v1/cti/#{token}", params: params
post "/api/v1/cti/#{token}", params: {
event: 'newCall',
direction: 'out',
from: '4930600000000',
to: '4912347114711',
call_id: '1234567890-1',
user: 'user 1',
}
expect(response).to have_http_status(:ok)
log = Cti::Log.find_by(call_id: '1234567890-1')
expect(log).to be_truthy
@ -153,7 +197,7 @@ RSpec.describe 'Integration CTI', type: :request do
expect(log.from_comment).to eq('user 1')
expect(log.to_comment).to eq('CallerId Customer1')
expect(log.comment).to be_nil
expect(log.queue).to be_nil
expect(log.queue).to eq('4930777000000')
expect(log.state).to eq('newCall')
expect(log.done).to eq(true)
expect(log.initialized_at).to be_truthy
@ -165,8 +209,12 @@ RSpec.describe 'Integration CTI', type: :request do
travel 2.seconds
# outbound - I - hangup by agent
params = 'event=hangup&direction=out&call_id=1234567890-1&cause=cancel'
post "/api/v1/cti/#{token}", params: params
post "/api/v1/cti/#{token}", params: {
event: 'hangup',
direction: 'out',
call_id: '1234567890-1',
cause: 'cancel',
}
expect(response).to have_http_status(:ok)
log = Cti::Log.find_by(call_id: '1234567890-1')
expect(log).to be_truthy
@ -176,7 +224,7 @@ RSpec.describe 'Integration CTI', type: :request do
expect(log.from_comment).to eq('user 1')
expect(log.to_comment).to eq('CallerId Customer1')
expect(log.comment).to eq('cancel')
expect(log.queue).to be_nil
expect(log.queue).to eq('4930777000000')
expect(log.state).to eq('hangup')
expect(log.done).to eq(true)
expect(log.initialized_at).to be_truthy
@ -186,8 +234,14 @@ RSpec.describe 'Integration CTI', type: :request do
expect(log.duration_talking_time).to be_nil
# outbound - II - new call
params = 'event=newCall&direction=out&from=4930600000000&to=4912347114711&call_id=1234567890-2&user%5B%5D=user+1'
post "/api/v1/cti/#{token}", params: params
post "/api/v1/cti/#{token}", params: {
event: 'newCall',
direction: 'out',
from: '4930600000000',
to: '4912347114711',
call_id: '1234567890-2',
user: ['user 1'],
}
expect(response).to have_http_status(:ok)
log = Cti::Log.find_by(call_id: '1234567890-2')
expect(log).to be_truthy
@ -197,7 +251,7 @@ RSpec.describe 'Integration CTI', type: :request do
expect(log.from_comment).to eq('user 1')
expect(log.to_comment).to eq('CallerId Customer1')
expect(log.comment).to be_nil
expect(log.queue).to be_nil
expect(log.queue).to eq('4930777000000')
expect(log.state).to eq('newCall')
expect(log.done).to eq(true)
expect(log.initialized_at).to be_truthy
@ -209,8 +263,13 @@ RSpec.describe 'Integration CTI', type: :request do
travel 2.seconds
# outbound - II - answer by customer
params = 'event=answer&direction=out&call_id=1234567890-2&from=4930600000000&to=4912347114711'
post "/api/v1/cti/#{token}", params: params
post "/api/v1/cti/#{token}", params: {
event: 'answer',
direction: 'out',
call_id: '1234567890-2',
from: '4930600000000',
to: '4912347114711',
}
expect(response).to have_http_status(:ok)
log = Cti::Log.find_by(call_id: '1234567890-2')
expect(log).to be_truthy
@ -220,7 +279,7 @@ RSpec.describe 'Integration CTI', type: :request do
expect(log.from_comment).to eq('user 1')
expect(log.to_comment).to eq('CallerId Customer1')
expect(log.comment).to be_nil
expect(log.queue).to be_nil
expect(log.queue).to eq('4930777000000')
expect(log.state).to eq('answer')
expect(log.done).to eq(true)
expect(log.initialized_at).to be_truthy
@ -232,8 +291,14 @@ RSpec.describe 'Integration CTI', type: :request do
travel 2.seconds
# outbound - II - hangup by customer
params = 'event=hangup&direction=out&call_id=1234567890-2&cause=normalClearing&from=4930600000000&to=4912347114711'
post "/api/v1/cti/#{token}", params: params
post "/api/v1/cti/#{token}", params: {
event: 'hangup',
direction: 'out',
call_id: '1234567890-2',
cause: 'normalClearing',
from: '4930600000000',
to: '4912347114711',
}
expect(response).to have_http_status(:ok)
log = Cti::Log.find_by(call_id: '1234567890-2')
expect(log).to be_truthy
@ -243,7 +308,7 @@ RSpec.describe 'Integration CTI', type: :request do
expect(log.from_comment).to eq('user 1')
expect(log.to_comment).to eq('CallerId Customer1')
expect(log.comment).to eq('normalClearing')
expect(log.queue).to be_nil
expect(log.queue).to eq('4930777000000')
expect(log.state).to eq('hangup')
expect(log.done).to eq(true)
expect(log.initialized_at).to be_truthy
@ -255,8 +320,14 @@ RSpec.describe 'Integration CTI', type: :request do
travel 1.second
# inbound - I - new call
params = 'event=newCall&direction=in&to=4930600000000&from=4912347114711&call_id=1234567890-3&user%5B%5D=user+1'
post "/api/v1/cti/#{token}", params: params
post "/api/v1/cti/#{token}", params: {
event: 'newCall',
direction: 'in',
to: '4930600000000',
from: '4912347114711',
call_id: '1234567890-3',
user: 'user 1',
}
expect(response).to have_http_status(:ok)
log = Cti::Log.find_by(call_id: '1234567890-3')
expect(log).to be_truthy
@ -266,7 +337,7 @@ RSpec.describe 'Integration CTI', type: :request do
expect(log.to_comment).to eq('user 1')
expect(log.from_comment).to eq('CallerId Customer1')
expect(log.comment).to be_nil
expect(log.queue).to be_nil
expect(log.queue).to eq('4930600000000')
expect(log.state).to eq('newCall')
expect(log.done).to eq(false)
expect(log.initialized_at).to be_truthy
@ -278,8 +349,13 @@ RSpec.describe 'Integration CTI', type: :request do
travel 1.second
# inbound - I - answer by customer
params = 'event=answer&direction=in&call_id=1234567890-3&to=4930600000000&from=4912347114711'
post "/api/v1/cti/#{token}", params: params
post "/api/v1/cti/#{token}", params: {
event: 'answer',
direction: 'in',
call_id: '1234567890-3',
to: '4930600000000',
from: '4912347114711',
}
expect(response).to have_http_status(:ok)
log = Cti::Log.find_by(call_id: '1234567890-3')
expect(log).to be_truthy
@ -289,7 +365,7 @@ RSpec.describe 'Integration CTI', type: :request do
expect(log.to_comment).to eq('user 1')
expect(log.from_comment).to eq('CallerId Customer1')
expect(log.comment).to be_nil
expect(log.queue).to be_nil
expect(log.queue).to eq('4930600000000')
expect(log.state).to eq('answer')
expect(log.done).to eq(true)
expect(log.initialized_at).to be_truthy
@ -301,8 +377,14 @@ RSpec.describe 'Integration CTI', type: :request do
travel 1.second
# inbound - I - hangup by customer
params = 'event=hangup&direction=in&call_id=1234567890-3&cause=normalClearing&to=4930600000000&from=4912347114711'
post "/api/v1/cti/#{token}", params: params
post "/api/v1/cti/#{token}", params: {
event: 'hangup',
direction: 'in',
call_id: '1234567890-3',
cause: 'normalClearing',
to: '4930600000000',
from: '4912347114711',
}
expect(response).to have_http_status(:ok)
log = Cti::Log.find_by(call_id: '1234567890-3')
expect(log).to be_truthy
@ -312,7 +394,7 @@ RSpec.describe 'Integration CTI', type: :request do
expect(log.to_comment).to eq('user 1')
expect(log.from_comment).to eq('CallerId Customer1')
expect(log.comment).to eq('normalClearing')
expect(log.queue).to be_nil
expect(log.queue).to eq('4930600000000')
expect(log.state).to eq('hangup')
expect(log.done).to eq(true)
expect(log.initialized_at).to be_truthy
@ -324,18 +406,24 @@ RSpec.describe 'Integration CTI', type: :request do
travel 1.second
# inbound - II - new call
params = 'event=newCall&direction=in&to=4930600000000&from=4912347114711&call_id=1234567890-4&user%5B%5D=user+1,user+2'
post "/api/v1/cti/#{token}", params: params
post "/api/v1/cti/#{token}", params: {
event: 'newCall',
direction: 'in',
to: '4930600000000',
from: '4912347114711',
call_id: '1234567890-4',
user: ['user 1', 'user 2'],
}
expect(response).to have_http_status(:ok)
log = Cti::Log.find_by(call_id: '1234567890-4')
expect(log).to be_truthy
expect(log.to).to eq('4930600000000')
expect(log.from).to eq('4912347114711')
expect(log.direction).to eq('in')
expect(log.to_comment).to eq('user 1,user 2')
expect(log.to_comment).to eq('user 1, user 2')
expect(log.from_comment).to eq('CallerId Customer1')
expect(log.comment).to be_nil
expect(log.queue).to be_nil
expect(log.queue).to eq('4930600000000')
expect(log.state).to eq('newCall')
expect(log.done).to eq(false)
expect(log.initialized_at).to be_truthy
@ -347,8 +435,14 @@ RSpec.describe 'Integration CTI', type: :request do
travel 1.second
# inbound - II - answer by voicemail
params = 'event=answer&direction=in&call_id=1234567890-4&to=4930600000000&from=4912347114711&user=voicemail'
post "/api/v1/cti/#{token}", params: params
post "/api/v1/cti/#{token}", params: {
event: 'answer',
direction: 'in',
call_id: '1234567890-4',
to: '4930600000000',
from: '4912347114711',
user: 'voicemail',
}
expect(response).to have_http_status(:ok)
log = Cti::Log.find_by(call_id: '1234567890-4')
expect(log).to be_truthy
@ -358,7 +452,7 @@ RSpec.describe 'Integration CTI', type: :request do
expect(log.to_comment).to eq('voicemail')
expect(log.from_comment).to eq('CallerId Customer1')
expect(log.comment).to be_nil
expect(log.queue).to be_nil
expect(log.queue).to eq('4930600000000')
expect(log.state).to eq('answer')
expect(log.done).to eq(true)
expect(log.initialized_at).to be_truthy
@ -370,8 +464,14 @@ RSpec.describe 'Integration CTI', type: :request do
travel 1.second
# inbound - II - hangup by customer
params = 'event=hangup&direction=in&call_id=1234567890-4&cause=normalClearing&to=4930600000000&from=4912347114711'
post "/api/v1/cti/#{token}", params: params
post "/api/v1/cti/#{token}", params: {
event: 'hangup',
direction: 'in',
call_id: '1234567890-4',
cause: 'normalClearing',
to: '4930600000000',
from: '4912347114711',
}
expect(response).to have_http_status(:ok)
log = Cti::Log.find_by(call_id: '1234567890-4')
expect(log).to be_truthy
@ -381,7 +481,7 @@ RSpec.describe 'Integration CTI', type: :request do
expect(log.to_comment).to eq('voicemail')
expect(log.from_comment).to eq('CallerId Customer1')
expect(log.comment).to eq('normalClearing')
expect(log.queue).to be_nil
expect(log.queue).to eq('4930600000000')
expect(log.state).to eq('hangup')
expect(log.done).to eq(false)
expect(log.initialized_at).to be_truthy
@ -393,8 +493,14 @@ RSpec.describe 'Integration CTI', type: :request do
travel 1.second
# inbound - III - new call
params = 'event=newCall&direction=in&to=4930600000000&from=4912347114711&call_id=1234567890-5&user%5B%5D=user+1,user+2'
post "/api/v1/cti/#{token}", params: params
post "/api/v1/cti/#{token}", params: {
event: 'newCall',
direction: 'in',
to: '4930600000000',
from: '4912347114711',
call_id: '1234567890-5',
user: 'user 1,user 2',
}
expect(response).to have_http_status(:ok)
log = Cti::Log.find_by(call_id: '1234567890-5')
expect(log).to be_truthy
@ -404,7 +510,7 @@ RSpec.describe 'Integration CTI', type: :request do
expect(log.to_comment).to eq('user 1,user 2')
expect(log.from_comment).to eq('CallerId Customer1')
expect(log.comment).to be_nil
expect(log.queue).to be_nil
expect(log.queue).to eq('4930600000000')
expect(log.state).to eq('newCall')
expect(log.done).to eq(false)
expect(log.initialized_at).to be_truthy
@ -416,8 +522,14 @@ RSpec.describe 'Integration CTI', type: :request do
travel 1.second
# inbound - III - hangup by customer
params = 'event=hangup&direction=in&call_id=1234567890-5&cause=normalClearing&to=4930600000000&from=4912347114711'
post "/api/v1/cti/#{token}", params: params
post "/api/v1/cti/#{token}", params: {
event: 'hangup',
direction: 'in',
call_id: '1234567890-5',
cause: 'normalClearing',
to: '4930600000000',
from: '4912347114711',
}
expect(response).to have_http_status(:ok)
log = Cti::Log.find_by(call_id: '1234567890-5')
expect(log).to be_truthy
@ -427,7 +539,7 @@ RSpec.describe 'Integration CTI', type: :request do
expect(log.to_comment).to eq('user 1,user 2')
expect(log.from_comment).to eq('CallerId Customer1')
expect(log.comment).to eq('normalClearing')
expect(log.queue).to be_nil
expect(log.queue).to eq('4930600000000')
expect(log.state).to eq('hangup')
expect(log.done).to eq(false)
expect(log.initialized_at).to be_truthy
@ -439,8 +551,14 @@ RSpec.describe 'Integration CTI', type: :request do
travel 1.second
# inbound - IV - new call
params = 'event=newCall&direction=in&to=4930600000000&from=49999992222222&call_id=1234567890-6&user%5B%5D=user+1,user+2'
post "/api/v1/cti/#{token}", params: params
post "/api/v1/cti/#{token}", params: {
event: 'newCall',
direction: 'in',
to: '4930600000000',
from: '49999992222222',
call_id: '1234567890-6',
user: 'user 1,user 2',
}
expect(response).to have_http_status(:ok)
log = Cti::Log.find_by(call_id: '1234567890-6')
expect(log).to be_truthy
@ -452,7 +570,7 @@ RSpec.describe 'Integration CTI', type: :request do
expect(log.preferences['to']).to be_falsey
expect(log.preferences['from']).to be_truthy
expect(log.comment).to be_nil
expect(log.queue).to be_nil
expect(log.queue).to eq('4930600000000')
expect(log.state).to eq('newCall')
expect(log.done).to eq(false)
expect(log.initialized_at).to be_truthy
@ -464,8 +582,15 @@ RSpec.describe 'Integration CTI', type: :request do
travel 1.second
# inbound - IV - new call
params = 'event=newCall&direction=in&to=4930600000000&from=anonymous&call_id=1234567890-7&user%5B%5D=user+1,user+2&queue=some_queue_name'
post "/api/v1/cti/#{token}", params: params
post "/api/v1/cti/#{token}", params: {
event: 'newCall',
direction: 'in',
to: '4930600000000',
from: 'anonymous',
call_id: '1234567890-7',
user: 'user 1,user 2',
queue: 'some_queue_name',
}
expect(response).to have_http_status(:ok)
log = Cti::Log.find_by(call_id: '1234567890-7')
expect(log).to be_truthy
@ -486,10 +611,10 @@ RSpec.describe 'Integration CTI', type: :request do
expect(log.duration_waiting_time).to be_nil
expect(log.duration_talking_time).to be_nil
# get caller list
get '/api/v1/cti/log'
expect(response).to have_http_status(:unauthorized)
# get caller list
authenticated_as(agent_user)
get '/api/v1/cti/log', as: :json
expect(response).to have_http_status(:ok)
@ -515,12 +640,110 @@ RSpec.describe 'Integration CTI', type: :request do
expect(json_response['list'][6]['call_id']).to eq('1234567890-1')
end
it 'does log call with notify group with two a log entry' do
# outbound - I - new call
post "/api/v1/cti/#{token}", params: {
event: 'newCall',
direction: 'out',
from: '4930600000000',
to: '4912347114711',
call_id: '1234567890-1',
user: 'user 1',
}
expect(response).to have_http_status(:ok)
# outbound - II - new call
post "/api/v1/cti/#{token}", params: {
event: 'newCall',
direction: 'out',
from: '4930600000000',
to: '4912347114711',
call_id: '1234567890-2',
user: 'user 1',
}
# inbound - III - new call
post "/api/v1/cti/#{token}", params: {
event: 'newCall',
direction: 'in',
to: '4930600000000',
from: '4912347114711',
call_id: '1234567890-5',
user: 'user 1,user 2',
}
expect(response).to have_http_status(:ok)
# get caller list (with notify group with 2 log entries)
cti_config = Setting.get('cti_config')
cti_config[:notify_map] = [{ queue: '4930777000000', user_ids: [agent_user.id.to_s] }]
Setting.set('cti_config', cti_config)
authenticated_as(agent_user)
get '/api/v1/cti/log', as: :json
expect(response).to have_http_status(:ok)
expect(json_response.dig('assets', 'User')).not_to be(nil)
expect(json_response['list'].map { |x| x['call_id'] }).to match_array(%w[1234567890-1 1234567890-2])
end
it 'does log call with notify group without a log entry' do
# outbound - I - new call
post "/api/v1/cti/#{token}", params: {
event: 'newCall',
direction: 'out',
from: '4930600000000',
to: '4912347114711',
call_id: '1234567890-1',
user: 'user 1',
}
expect(response).to have_http_status(:ok)
# outbound - II - new call
post "/api/v1/cti/#{token}", params: {
event: 'newCall',
direction: 'out',
from: '4930600000000',
to: '4912347114711',
call_id: '1234567890-2',
user: 'user 1',
}
# inbound - III - new call
post "/api/v1/cti/#{token}", params: {
event: 'newCall',
direction: 'in',
to: '4930600000000',
from: '4912347114711',
call_id: '1234567890-5',
user: 'user 1,user 2',
}
expect(response).to have_http_status(:ok)
# get caller list (with notify group without a log entry)
cti_config = Setting.get('cti_config')
cti_config[:notify_map] = [{ queue: '4912347114711', user_ids: [agent_user.to_s] }]
Setting.set('cti_config', cti_config)
authenticated_as(agent_user)
get '/api/v1/cti/log', as: :json
expect(response).to have_http_status(:ok)
expect(json_response['list']).to eq([])
end
it 'does queue param tests' do
token = Setting.get('cti_token')
# inbound - queue & user
params = 'event=newCall&direction=in&to=4930600000000&from=anonymous&call_id=1234567890-1&user%5B%5D=user+1,user+2&queue=some_queue_name'
post "/api/v1/cti/#{token}", params: params
post "/api/v1/cti/#{token}", params: {
event: 'newCall',
direction: 'in',
to: '4930600000000',
from: 'anonymous',
call_id: '1234567890-1',
user: 'user 1,user 2',
queue: 'some_queue_name',
}
expect(response).to have_http_status(:ok)
log = Cti::Log.find_by(call_id: '1234567890-1')
expect(log).to be_truthy
@ -542,8 +765,15 @@ RSpec.describe 'Integration CTI', type: :request do
expect(log.duration_talking_time).to be_nil
# inbound - queue & no user
params = 'event=newCall&direction=in&to=4930600000000&from=anonymous&call_id=1234567890-2&user%5B%5D=&queue=some_queue_name'
post "/api/v1/cti/#{token}", params: params
post "/api/v1/cti/#{token}", params: {
event: 'newCall',
direction: 'in',
to: '4930600000000',
from: 'anonymous',
call_id: '1234567890-2',
user: '',
queue: 'some_queue_name',
}
expect(response).to have_http_status(:ok)
log = Cti::Log.find_by(call_id: '1234567890-2')
expect(log).to be_truthy

View File

@ -153,7 +153,7 @@ class IntegrationCtiTest < TestCase
end
# Regression test for #2096
def test_inactive_users_displayed_with_strikethrough_in_caller_log
def test_inactive_users_displayed_inactive_in_caller_log
id = rand(99_999_999)
@browser = browser_instance
@ -205,10 +205,10 @@ class IntegrationCtiTest < TestCase
# view caller log
click(css: 'a[href="#cti"]')
# assertion: names appear in strikethrough
# assertion: names appear in inactive
match(
css: 'span.is-inactive',
value: 'John Doe',
css: 'span.avatar--inactive',
value: 'JD',
)
end
@ -279,12 +279,12 @@ class IntegrationCtiTest < TestCase
# assertions: Caller ID includes user organization
match(
css: '.js-callerLog tr:first-of-type span.user-popover',
css: '.js-callerLog tr:first-of-type div.user-popover',
value: 'John Doe (Zammad Foundation)',
)
match(
css: '.js-callerLog tr:last-of-type span.user-popover',
css: '.js-callerLog tr:last-of-type div.user-popover',
value: 'John Doe (Zammad Foundation)',
)
end