<%- @T('Health information can be retrieved as JSON using') %>:
+
+
+
+
+
+
<% if _.isEmpty(@data.issues): %><%- @Icon('status', 'ok inline') %><% else: %><%- @Icon('status', 'error inline') %><% end %> <%- @T('Current Status') %>
+
+
+ <% if _.isEmpty(@data.issues): %>
+
<%- @T('no issues') %>
+ <% else: %>
+ <% for issue in @data.issues: %>
+
<%= issue %>
+ <% end %>
+ <% end %>
+
+
+
+
\ No newline at end of file
diff --git a/app/assets/javascripts/app/views/translation/index.jst.eco b/app/assets/javascripts/app/views/translation/index.jst.eco
index 39b5858aa..522ea468a 100644
--- a/app/assets/javascripts/app/views/translation/index.jst.eco
+++ b/app/assets/javascripts/app/views/translation/index.jst.eco
@@ -13,8 +13,8 @@
<%- @T('Inline translation') %>
<%- @T('To do easier translations you can enable and disable inline translation feature by pressing "%s".', 'ctrl+alt+t') %>
-
<%- @T('Text with disabled inline translations looks like') %>
-
<%- @T('Text with enabled inline translations looks like') %>
+
<%- @T('Text with disabled inline translations looks like') %>
+
<%- @T('Text with enabled inline translations looks like') %>
<%- @T('Just click into the marker and update the words just in place. Enjoy!') %>
<%- @T('If you want to translate it via the translation table, just go ahead below.') %>
diff --git a/app/assets/stylesheets/zammad.scss b/app/assets/stylesheets/zammad.scss
index 9974648c3..de71c75dd 100644
--- a/app/assets/stylesheets/zammad.scss
+++ b/app/assets/stylesheets/zammad.scss
@@ -608,7 +608,7 @@ pre code.hljs {
margin-left: 15px;
}
-.vertical > .btn + .btn {
+.vertical > .btn:not(.hidden) + .btn {
margin-left: 0;
margin-top: 10px;
}
@@ -4406,6 +4406,7 @@ footer {
.article-content {
color: hsl(60,1%,34%);
position: relative;
+ z-index: 1;
padding: 0 55px;
}
@@ -4751,6 +4752,10 @@ footer {
border-radius: 0 4px 4px 0;
}
+ .pop-selectable:only-child {
+ border-radius: 4px;
+ }
+
.pop-selectable-icon {
fill: hsl(231,3%,40%);
}
@@ -5384,6 +5389,10 @@ footer {
margin-top: 12px;
}
+ .box ul {
+ padding-left: 10px;
+ }
+
.box .two-columns {
margin-left: -4px;
margin-right: -4px;
@@ -5433,6 +5442,15 @@ footer {
}
}
+.horizontal > .box {
+ margin-top: 4px;
+ margin-bottom: 4px;
+
+ & + .box {
+ border-left-width: 0;
+ }
+}
+
.formset-inset {
margin: 34px -24px 24px;
padding: 19px 24px 24px;
@@ -8434,6 +8452,10 @@ body.fit {
align-self: end;
}
+.span-width {
+ flex-basis: 100%;
+}
+
.two-columns,
.three-columns,
.wrap {
@@ -8468,3 +8490,17 @@ body.fit {
margin-left: auto;
margin-right: auto;
}
+
+.spacer {
+ width: 10px;
+ height: 10px;
+}
+
+.double-spacer {
+ width: 20px;
+ height: 20px;
+}
+
+.flex-spacer {
+ flex: 1;
+}
diff --git a/app/controllers/integration/sipgate_controller.rb b/app/controllers/integration/sipgate_controller.rb
index c45335cee..69ae1c113 100644
--- a/app/controllers/integration/sipgate_controller.rb
+++ b/app/controllers/integration/sipgate_controller.rb
@@ -4,14 +4,11 @@ require 'builder'
class Integration::SipgateController < ApplicationController
+ before_action :check_configured
+
# notify about inbound call / block inbound call
def in
- http_log_config facility: 'sipgate.io'
- return if !configured?
-
if params['event'] == 'newCall'
-
- config = Setting.get('sipgate_config')
config_inbound = config[:inbound] || {}
block_caller_ids = config_inbound[:block_caller_ids] || []
@@ -31,12 +28,12 @@ class Integration::SipgateController < ApplicationController
if params['user']
params['comment'] = "#{params['user']} -> reject, busy"
end
- update_log(params)
+ Cti::Log.process(params)
return true
}
end
- update_log(params)
+ Cti::Log.process(params)
xml = Builder::XmlMarkup.new(indent: 2)
xml.instruct!
@@ -46,10 +43,6 @@ class Integration::SipgateController < ApplicationController
# set caller id of outbound call
def out
- http_log_config facility: 'sipgate.io'
- return if !configured?
-
- config = Setting.get('sipgate_config')
config_outbound = config[:outbound][:routing_table]
default_caller_id = config[:outbound][:default_caller_id]
@@ -84,125 +77,26 @@ class Integration::SipgateController < ApplicationController
if from
params['from'] = from
end
- update_log(params)
+ Cti::Log.process(params)
end
private
- def configured?
+ def check_configured
+ http_log_config facility: 'sipgate.io'
+
if !Setting.get('sipgate_integration')
xml_error('Feature is disable, please contact your admin to enable it!')
- return false
+ return
end
- config = Setting.get('sipgate_config')
if !config || !config[:inbound] || !config[:outbound]
xml_error('Feature not configured, please contact your admin!')
- return false
+ return
end
- true
end
- def update_log(params)
-
- user = params['user']
- if params['user'] && params['user'].class == Array
- user = params['user'].join(', ')
- end
- from_comment = nil
- to_comment = nil
- preferences = nil
- if params['direction'] == 'in'
- to_comment = user
- from_comment, preferences = update_log_item('from')
- else
- from_comment = user
- to_comment, preferences = update_log_item('to')
- end
-
- comment = nil
- if params['cause']
- comment = params['cause']
- end
-
- if params['event'] == 'newCall'
- Cti::Log.create(
- direction: params['direction'],
- from: params['from'],
- from_comment: from_comment,
- to: params['to'],
- to_comment: to_comment,
- call_id: params['callId'],
- comment: comment,
- state: params['event'],
- preferences: preferences,
- )
- elsif params['event'] == 'answer'
- log = Cti::Log.find_by(call_id: params['callId'])
- raise "No such call_id #{params['callId']}" if !log
- log.state = 'answer'
- log.start = Time.zone.now
- if user
- log.to_comment = user
- end
- log.comment = comment
- log.save
- elsif params['event'] == 'hangup'
- log = Cti::Log.find_by(call_id: params['callId'])
- raise "No such call_id #{params['callId']}" if !log
- if params['direction'] == 'in' && log.state == 'newCall'
- log.done = false
- end
- if params['direction'] == 'in' && log.to_comment == 'voicemail'
- log.done = false
- end
- log.state = 'hangup'
- log.end = Time.zone.now
- log.comment = comment
- log.save
- else
- raise "Unknown event #{params['event']}"
- end
-
- end
-
- def update_log_item(direction)
- from_comment_known = ''
- from_comment_maybe = ''
- preferences_known = {}
- preferences_known[direction] = []
- preferences_maybe = {}
- preferences_maybe[direction] = []
- caller_ids = Cti::CallerId.lookup(params[direction])
- caller_ids.each { |record|
- if record.level == 'known'
- preferences_known[direction].push record
- else
- preferences_maybe[direction].push record
- end
- comment = ''
- if record.user_id
- user = User.lookup(id: record.user_id)
- if user
- comment += user.fullname
- end
- elsif !record.comment.empty?
- comment += record.comment
- end
- if record.level == 'known'
- if !from_comment_known.empty?
- from_comment_known += ','
- end
- from_comment_known += comment
- else
- if !from_comment_maybe.empty?
- from_comment_maybe += ','
- end
- from_comment_maybe += comment
- end
- }
- return [from_comment_known, preferences_known] if !from_comment_known.empty?
- return ["maybe #{from_comment_maybe}", preferences_maybe] if !from_comment_maybe.empty?
- nil
+ def config
+ @config ||= Setting.get('sipgate_config')
end
def xml_error(error)
diff --git a/app/controllers/monitoring_controller.rb b/app/controllers/monitoring_controller.rb
new file mode 100644
index 000000000..1fa9c3bdc
--- /dev/null
+++ b/app/controllers/monitoring_controller.rb
@@ -0,0 +1,182 @@
+# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
+
+class MonitoringController < ApplicationController
+ before_action -> { authentication_check(permission: 'admin.monitoring') }, except: [:health_check, :status]
+
+=begin
+
+Resource:
+GET /api/v1/monitoring/health_check?token=XXX
+
+Response:
+{
+ "healthy": true,
+ "message": "success",
+}
+
+{
+ "healthy": false,
+ "message": "authentication of XXX failed; issue #2",
+ "issues": ["authentication of XXX failed", "issue #2"],
+}
+
+Test:
+curl http://localhost/api/v1/monitoring/health_check?token=XXX
+
+=end
+
+ def health_check
+ token_or_permission_check
+
+ issues = []
+
+ # channel check
+ Channel.where(active: true).each { |channel|
+ next if (channel.status_in.empty? || channel.status_in == 'ok') && (channel.status_out.empty? || channel.status_out == 'ok')
+ if channel.status_in == 'error'
+ message = "Channel: #{channel.area} in "
+ %w(host user uid).each { |key|
+ next if !channel.options[key] || channel.options[key].empty?
+ message += "key:#{channel.options[key]};"
+ }
+ issues.push "#{message} #{channel.last_log_in}"
+ end
+ next if channel.status_out != 'error'
+ message = "Channel: #{channel.area} out "
+ %w(host user uid).each { |key|
+ next if !channel.options[key] || channel.options[key].empty?
+ message += "key:#{channel.options[key]};"
+ }
+ issues.push "#{message} #{channel.last_log_out}"
+ }
+
+ # unprocessable mail check
+ directory = "#{Rails.root}/tmp/unprocessable_mail"
+ if File.exist?(directory)
+ count = 0
+ Dir.glob("#{directory}/*.eml") { |_entry|
+ count += 1
+ }
+ if count.nonzero?
+ issues.push "unprocessable mails: #{count}"
+ end
+ end
+
+ # scheduler check
+ Scheduler.where(active: true).where.not(last_run: nil).each { |scheduler|
+ next if scheduler.period <= 300
+ next if scheduler.last_run + scheduler.period.seconds > Time.zone.now - 5.minutes
+ issues.push 'scheduler not running'
+ break
+ }
+ if Scheduler.where(active: true, last_run: nil).count == Scheduler.where(active: true).count
+ issues.push 'scheduler not running'
+ end
+
+ token = Setting.get('monitoring_token')
+
+ if issues.empty?
+ result = {
+ healthy: true,
+ message: 'success',
+ token: token,
+ }
+ render json: result
+ return
+ end
+
+ result = {
+ healthy: false,
+ message: issues.join(';'),
+ issues: issues,
+ token: token,
+ }
+ render json: result
+ end
+
+=begin
+
+Resource:
+GET /api/v1/monitoring/status?token=XXX
+
+Response:
+{
+ "agents": 8123,
+ "last_login": "2016-11-21T14:14:14Z",
+ "counts": {
+ "users": 12313,
+ "tickets": 23123,
+ "ticket_articles": 131451,
+ },
+ "last_created_at": {
+ "users": "2016-11-21T14:14:14Z",
+ "tickets": "2016-11-21T14:14:14Z",
+ "ticket_articles": "2016-11-21T14:14:14Z",
+ },
+}
+
+Test:
+curl http://localhost/api/v1/monitoring/status?token=XXX
+
+=end
+
+ def status
+ token_or_permission_check
+
+ last_login = nil
+ last_login_user = User.where('last_login IS NOT NULL').order(last_login: :desc).limit(1).first
+ if last_login_user
+ last_login = last_login_user.last_login
+ end
+
+ status = {
+ counts: {},
+ last_created_at: {},
+ last_login: last_login,
+ agents: User.with_permissions('ticket.agent').count,
+ }
+
+ map = {
+ users: User,
+ groups: Group,
+ overviews: Overview,
+ tickets: Ticket,
+ ticket_articles: Ticket::Article,
+ }
+ map.each { |key, class_name|
+ status[:counts][key] = class_name.count
+ last = class_name.last
+ status[:last_created_at][key] = if last
+ last.created_at
+ end
+ }
+
+ render json: status
+ end
+
+ def token
+ access_check
+ token = SecureRandom.urlsafe_base64(40)
+ Setting.set('monitoring_token', token)
+
+ result = {
+ token: token,
+ }
+ render json: result, status: :created
+ end
+
+ private
+
+ def token_or_permission_check
+ user = authentication_check_only(permission: 'admin.monitoring')
+ return if user
+ return if Setting.get('monitoring_token') == params[:token]
+ raise Exceptions::NotAuthorized
+ end
+
+ def access_check
+ return if Permission.find_by(name: 'admin.monitoring', active: true)
+ raise Exceptions::NotAuthorized
+ end
+
+end
diff --git a/app/models/channel/driver/imap.rb b/app/models/channel/driver/imap.rb
index ee0efbb87..733efd836 100644
--- a/app/models/channel/driver/imap.rb
+++ b/app/models/channel/driver/imap.rb
@@ -73,7 +73,7 @@ example
Rails.logger.info "fetching imap (#{options[:host]}/#{options[:user]} port=#{port},ssl=#{ssl})"
# on check, reduce open_timeout to have faster probing
- timeout = 12
+ timeout = 24
if check_type == 'check'
timeout = 6
end
diff --git a/app/models/channel/driver/pop3.rb b/app/models/channel/driver/pop3.rb
index cd45ab61e..a227b68eb 100644
--- a/app/models/channel/driver/pop3.rb
+++ b/app/models/channel/driver/pop3.rb
@@ -61,8 +61,8 @@ returns
#@pop.set_debug_output $stderr
# on check, reduce open_timeout to have faster probing
- @pop.open_timeout = 8
- @pop.read_timeout = 12
+ @pop.open_timeout = 12
+ @pop.read_timeout = 24
if check_type == 'check'
@pop.open_timeout = 4
@pop.read_timeout = 6
diff --git a/app/models/channel/email_build.rb b/app/models/channel/email_build.rb
index 540ae4763..8302228e5 100644
--- a/app/models/channel/email_build.rb
+++ b/app/models/channel/email_build.rb
@@ -32,8 +32,8 @@ module Channel::EmailBuild
attr['X-Auto-Response-Suppress'] = 'All'
end
- #attr['X-Powered-BY'] = 'Zammad - Support/Helpdesk (http://www.zammad.org/)'
- attr['X-Mailer'] = 'Zammad Mail Service (1.x)'
+ attr['X-Powered-By'] = 'Zammad - Helpdesk/Support (https://zammad.org/)'
+ attr['X-Mailer'] = 'Zammad Mail Service'
# set headers
attr.each do |key, value|
@@ -126,6 +126,21 @@ module Channel::EmailBuild
mail
end
+=begin
+
+ quoted_in_one_line = Channel::EmailBuild.recipient_line('Somebody @ "Company"', 'some.body@example.com')
+
+returnes
+
+ '"Somebody @ \"Company\"" '
+
+=end
+
+ def self.recipient_line(realname, email)
+ return "#{realname} <#{email}>" if realname =~ /^[A-z]+$/i
+ "\"#{realname.gsub('"', '\"')}\" <#{email}>"
+ end
+
=begin
Check if string is a complete html document. If not, add head and css styles.
diff --git a/app/models/channel/filter/auto_response_check.rb b/app/models/channel/filter/auto_response_check.rb
index c6481beca..588094802 100644
--- a/app/models/channel/filter/auto_response_check.rb
+++ b/app/models/channel/filter/auto_response_check.rb
@@ -14,11 +14,20 @@ module Channel::Filter::AutoResponseCheck
mail[ 'x-zammad-article-preferences'.to_sym ]['send-auto-response'] = false
mail[ 'x-zammad-article-preferences'.to_sym ]['is-auto-response'] = true
+ # do not send an auto respondse if one of the following headers exists
+ return if mail[ 'list-unsubscribe'.to_sym ] && mail[ 'list-unsubscribe'.to_sym ] =~ /.../
return if mail[ 'x-loop'.to_sym ] && mail[ 'x-loop'.to_sym ] =~ /(yes|true)/i
return if mail[ 'precedence'.to_sym ] && mail[ 'precedence'.to_sym ] =~ /(bulk|list|junk)/i
return if mail[ 'auto-submitted'.to_sym ] && mail[ 'auto-submitted'.to_sym ] =~ /auto-(generated|replied)/i
return if mail[ 'x-auto-response-suppress'.to_sym ] && mail[ 'x-auto-response-suppress'.to_sym ] =~ /all/i
+ # do not send an auto respondse if sender is system it self
+ message_id = mail[ 'message_id'.to_sym ]
+ if message_id
+ fqdn = Setting.get('fqdn')
+ return if message_id =~ /@#{Regexp.quote(fqdn)}/i
+ end
+
mail[ 'x-zammad-send-auto-response'.to_sym ] = true
mail[ 'x-zammad-is-auto-response'.to_sym ] = false
diff --git a/app/models/channel/filter/sender_is_system_address.rb b/app/models/channel/filter/sender_is_system_address.rb
index 87be321fe..1321edd3f 100644
--- a/app/models/channel/filter/sender_is_system_address.rb
+++ b/app/models/channel/filter/sender_is_system_address.rb
@@ -8,7 +8,7 @@ module Channel::Filter::SenderIsSystemAddress
return if mail[ 'x-zammad-ticket-create-article-sender'.to_sym ]
return if mail[ 'x-zammad-article-sender'.to_sym ]
- # check if sender addesss is system
+ # check if sender address is system
form = 'raw-from'.to_sym
return if !mail[form]
return if !mail[:to]
diff --git a/app/models/cti/caller_id.rb b/app/models/cti/caller_id.rb
index f016d7d42..4118a58df 100644
--- a/app/models/cti/caller_id.rb
+++ b/app/models/cti/caller_id.rb
@@ -233,5 +233,45 @@ returns
caller_ids
end
+ def self.get_comment_preferences(caller_id, direction)
+ from_comment_known = ''
+ from_comment_maybe = ''
+ preferences_known = {}
+ preferences_known[direction] = []
+ preferences_maybe = {}
+ preferences_maybe[direction] = []
+
+ lookup(caller_id).each { |record|
+ if record.level == 'known'
+ preferences_known[direction].push record
+ else
+ preferences_maybe[direction].push record
+ end
+ comment = ''
+ if record.user_id
+ user = User.lookup(id: record.user_id)
+ if user
+ comment += user.fullname
+ end
+ elsif !record.comment.empty?
+ comment += record.comment
+ end
+ if record.level == 'known'
+ if !from_comment_known.empty?
+ from_comment_known += ','
+ end
+ from_comment_known += comment
+ else
+ if !from_comment_maybe.empty?
+ from_comment_maybe += ','
+ end
+ from_comment_maybe += comment
+ end
+ }
+ return [from_comment_known, preferences_known] if !from_comment_known.empty?
+ return ["maybe #{from_comment_maybe}", preferences_maybe] if !from_comment_maybe.empty?
+ nil
+ end
+
end
end
diff --git a/app/models/cti/log.rb b/app/models/cti/log.rb
index fc6046ae9..37866c3df 100644
--- a/app/models/cti/log.rb
+++ b/app/models/cti/log.rb
@@ -254,6 +254,67 @@ returns
}
end
+ # processes a incoming event
+ def self.process(params)
+ comment = params['cause']
+ event = params['event']
+ user = params['user']
+ if user.class == Array
+ user = user.join(', ')
+ end
+
+ from_comment = nil
+ to_comment = nil
+ preferences = nil
+ if params['direction'] == 'in'
+ to_comment = user
+ from_comment, preferences = CallerId.get_comment_preferences(params['from'], 'from')
+ else
+ from_comment = user
+ to_comment, preferences = CallerId.get_comment_preferences(params['to'], 'to')
+ end
+
+ case event
+ when 'newCall'
+ create(
+ direction: params['direction'],
+ from: params['from'],
+ from_comment: from_comment,
+ to: params['to'],
+ to_comment: to_comment,
+ call_id: params['callId'],
+ comment: comment,
+ state: event,
+ preferences: preferences,
+ )
+ when 'answer'
+ log = find_by(call_id: params['callId'])
+ raise "No such call_id #{params['callId']}" if !log
+ log.state = 'answer'
+ log.start = Time.zone.now
+ if user
+ log.to_comment = user
+ end
+ log.comment = comment
+ log.save
+ when 'hangup'
+ log = find_by(call_id: params['callId'])
+ raise "No such call_id #{params['callId']}" if !log
+ if params['direction'] == 'in' && log.state == 'newCall'
+ log.done = false
+ end
+ if params['direction'] == 'in' && log.to_comment == 'voicemail'
+ log.done = false
+ end
+ log.state = 'hangup'
+ log.end = Time.zone.now
+ log.comment = comment
+ log.save
+ else
+ raise ArgumentError, "Unknown event #{event}"
+ end
+ end
+
def push_event
users = User.with_permissions('cti.agent')
users.each { |user|
diff --git a/app/models/observer/ticket/article/fillup_from_email.rb b/app/models/observer/ticket/article/fillup_from_email.rb
index 1d89764f5..2770e9206 100644
--- a/app/models/observer/ticket/article/fillup_from_email.rb
+++ b/app/models/observer/ticket/article/fillup_from_email.rb
@@ -46,13 +46,13 @@ class Observer::Ticket::Article::FillupFromEmail < ActiveRecord::Observer
if !email_address
raise "No email address found for group '#{ticket.group.name}'"
end
- system_sender = "#{email_address.realname} <#{email_address.email}>"
if record.created_by_id != 1 && Setting.get('ticket_define_email_from') == 'AgentNameSystemAddressName'
- seperator = Setting.get('ticket_define_email_from_seperator')
- sender = User.find(record.created_by_id)
- record.from = "#{sender.firstname} #{sender.lastname} #{seperator} #{system_sender}"
+ seperator = Setting.get('ticket_define_email_from_seperator')
+ sender = User.find(record.created_by_id)
+ realname = "#{sender.firstname} #{sender.lastname} #{seperator} #{email_address.realname}"
+ record.from = Channel::EmailBuild.recipient_line(realname, email_address.email)
else
- record.from = system_sender
+ record.from = Channel::EmailBuild.recipient_line(email_address.realname, email_address.email)
end
end
end
diff --git a/app/models/setting.rb b/app/models/setting.rb
index 5f680f2b8..8dfaeffb0 100644
--- a/app/models/setting.rb
+++ b/app/models/setting.rb
@@ -51,11 +51,7 @@ get config setting
=end
def self.get(name)
- if load
- logger.debug "Setting.get(#{name.inspect}) # no cache"
- else
- logger.debug "Setting.get(#{name.inspect}) # from cache"
- end
+ load
@@current[:settings_config][name]
end
diff --git a/app/views/mailer/password_change/en.html.erb b/app/views/mailer/password_change/en.html.erb
index 80236ad12..72f939a58 100644
--- a/app/views/mailer/password_change/en.html.erb
+++ b/app/views/mailer/password_change/en.html.erb
@@ -1,9 +1,9 @@
-Your #{product_name} password has been changed
+Your #{config.product_name} password has been changed
Hi #{user.firstname},
-
The password for your #{product_name} account #{user.login} has been changed recently.
+
The password for your #{config.product_name} account #{user.login} has been changed recently.
This activity is not known to you? If not, contact your system administrator.
-
Your #{product_name} Team
+
Your #{config.product_name} Team
diff --git a/bin/rspec b/bin/rspec
new file mode 100755
index 000000000..d72fadf39
--- /dev/null
+++ b/bin/rspec
@@ -0,0 +1,3 @@
+#!/usr/bin/env ruby
+require 'bundler/setup'
+load Gem.bin_path('rspec-core', 'rspec')
diff --git a/bin/spring b/bin/spring
new file mode 100755
index 000000000..7fe232c3a
--- /dev/null
+++ b/bin/spring
@@ -0,0 +1,15 @@
+#!/usr/bin/env ruby
+
+# This file loads spring without using Bundler, in order to be fast.
+# It gets overwritten when you run the `spring binstub` command.
+
+unless defined?(Spring)
+ require 'rubygems'
+ require 'bundler'
+
+ if (match = Bundler.default_lockfile.read.match(/^GEM$.*?^ (?: )*spring \((.*?)\)$.*?^$/m))
+ Gem.paths = { 'GEM_PATH' => [Bundler.bundle_path.to_s, *Gem.path].uniq.join(Gem.path_separator) }
+ gem 'spring', match[1]
+ require 'spring/binstub'
+ end
+end
diff --git a/config/environments/development.rb b/config/environments/development.rb
index 6f018e1c5..501ea94e4 100644
--- a/config/environments/development.rb
+++ b/config/environments/development.rb
@@ -35,6 +35,7 @@ Rails.application.configure do
config.middleware.insert_after(
ActionDispatch::Static,
Rack::LiveReload,
+ no_swf: true,
min_delay: 500, # default 1000
max_delay: 10_000, # default 60_000
live_reload_port: 35_738
diff --git a/config/routes/monitoring.rb b/config/routes/monitoring.rb
new file mode 100644
index 000000000..18f2e6c93
--- /dev/null
+++ b/config/routes/monitoring.rb
@@ -0,0 +1,8 @@
+Zammad::Application.routes.draw do
+ api_path = Rails.configuration.api_path
+
+ match api_path + '/monitoring/health_check', to: 'monitoring#health_check', via: :get
+ match api_path + '/monitoring/status', to: 'monitoring#status', via: :get
+ match api_path + '/monitoring/token', to: 'monitoring#token', via: :post
+
+end
diff --git a/db/migrate/20161122000001_monitoring_issue_453.rb b/db/migrate/20161122000001_monitoring_issue_453.rb
new file mode 100644
index 000000000..769ea181f
--- /dev/null
+++ b/db/migrate/20161122000001_monitoring_issue_453.rb
@@ -0,0 +1,37 @@
+class MonitoringIssue453 < ActiveRecord::Migration
+ def up
+ # return if it's a new setup
+ return if !Setting.find_by(name: 'system_init_done')
+
+ Setting.create_if_not_exists(
+ title: 'Monitoring Token',
+ name: 'monitoring_token',
+ area: 'HealthCheck::Base',
+ description: 'Token for Monitoring.',
+ options: {
+ form: [
+ {
+ display: '',
+ null: false,
+ name: 'monitoring_token',
+ tag: 'input',
+ },
+ ],
+ },
+ state: SecureRandom.urlsafe_base64(40),
+ preferences: {
+ permission: ['admin.monitoring'],
+ },
+ frontend: false,
+ )
+
+ Permission.create_if_not_exists(
+ name: 'admin.monitoring',
+ note: 'Manage %s',
+ preferences: {
+ translations: ['Monitoring']
+ },
+ )
+
+ end
+end
diff --git a/db/seeds.rb b/db/seeds.rb
index b584368de..4882d358f 100644
--- a/db/seeds.rb
+++ b/db/seeds.rb
@@ -1575,11 +1575,11 @@ Setting.create_if_not_exists(
frontend: false
)
-Setting.create_or_update(
+Setting.create_if_not_exists(
title: 'API Token Access',
name: 'api_token_access',
area: 'API::Base',
- description: 'Enable REST API using tokens (not username/email addeess and password). Each user need to create own access tokens in user profile.',
+ description: 'Enable REST API using tokens (not username/email address and password). Each user need to create own access tokens in user profile.',
options: {
form: [
{
@@ -1600,7 +1600,7 @@ Setting.create_or_update(
},
frontend: false
)
-Setting.create_or_update(
+Setting.create_if_not_exists(
title: 'API Password Access',
name: 'api_password_access',
area: 'API::Base',
@@ -1626,6 +1626,28 @@ Setting.create_or_update(
frontend: false
)
+Setting.create_if_not_exists(
+ title: 'Monitoring Token',
+ name: 'monitoring_token',
+ area: 'HealthCheck::Base',
+ description: 'Token for Monitoring.',
+ options: {
+ form: [
+ {
+ display: '',
+ null: false,
+ name: 'monitoring_token',
+ tag: 'input',
+ },
+ ],
+ },
+ state: SecureRandom.urlsafe_base64(40),
+ preferences: {
+ permission: ['admin.monitoring'],
+ },
+ frontend: false
+)
+
Setting.create_if_not_exists(
title: 'Enable Chat',
name: 'chat',
@@ -2765,6 +2787,13 @@ Permission.create_if_not_exists(
translations: ['Translations']
},
)
+Permission.create_if_not_exists(
+ name: 'admin.monitoring',
+ note: 'Manage %s',
+ preferences: {
+ translations: ['Monitoring']
+ },
+)
Permission.create_if_not_exists(
name: 'admin.maintenance',
note: 'Manage %s',
@@ -5418,11 +5447,7 @@ Karma::Activity.create_or_update(
)
# reset primary key sequences
-if ActiveRecord::Base.connection_config[:adapter] == 'postgresql'
- ActiveRecord::Base.connection.tables.each do |t|
- ActiveRecord::Base.connection.reset_pk_sequence!(t)
- end
-end
+DbHelper.import_post
# install locales and translations
Locale.create_if_not_exists(
diff --git a/lib/auto_wizard.rb b/lib/auto_wizard.rb
index 509409a04..9e71fbeed 100644
--- a/lib/auto_wizard.rb
+++ b/lib/auto_wizard.rb
@@ -146,6 +146,9 @@ returns
}
}
+ # reset primary key sequences
+ DbHelper.import_post
+
# remove auto wizard file
FileUtils.rm auto_wizard_file_location
diff --git a/lib/db_helper.rb b/lib/db_helper.rb
new file mode 100644
index 000000000..27b61f699
--- /dev/null
+++ b/lib/db_helper.rb
@@ -0,0 +1,31 @@
+# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
+
+class DbHelper
+
+=begin
+
+execute post database statements after import (e. g. reset primary key sequences for postgresql)
+
+ DbHelper.import_post
+
+or only for certan tables
+
+ DbHelper.import_post(table_name)
+
+=end
+
+ def self.import_post(table = nil)
+ return if ActiveRecord::Base.connection_config[:adapter] != 'postgresql'
+
+ tables = if table
+ [table]
+ else
+ ActiveRecord::Base.connection.tables
+ end
+
+ tables.each do |t|
+ ActiveRecord::Base.connection.reset_pk_sequence!(t)
+ end
+ end
+
+end
diff --git a/lib/import/base_factory.rb b/lib/import/base_factory.rb
new file mode 100644
index 000000000..550d5ebd6
--- /dev/null
+++ b/lib/import/base_factory.rb
@@ -0,0 +1,28 @@
+module Import
+ module BaseFactory
+
+ # rubocop:disable Style/ModuleFunction
+ extend self
+
+ def import(_records)
+ raise 'Missing implementation for import method for this factory'
+ end
+
+ def pre_import_hook(_records)
+ end
+
+ def backend_class(_record)
+ "Import::#{module_name}".constantize
+ end
+
+ def skip?(_record)
+ false
+ end
+
+ private
+
+ def module_name
+ name.to_s.sub(/Import::/, '').sub(/Factory/, '')
+ end
+ end
+end
diff --git a/lib/import/factory.rb b/lib/import/factory.rb
new file mode 100644
index 000000000..baf6c35ed
--- /dev/null
+++ b/lib/import/factory.rb
@@ -0,0 +1,16 @@
+module Import
+ module Factory
+ include Import::BaseFactory
+
+ # rubocop:disable Style/ModuleFunction
+ extend self
+
+ def import(records)
+ pre_import_hook(records)
+ records.each do |record|
+ next if skip?(record)
+ backend_class(record).new(record)
+ end
+ end
+ end
+end
diff --git a/lib/import/helper.rb b/lib/import/helper.rb
new file mode 100644
index 000000000..696f1c279
--- /dev/null
+++ b/lib/import/helper.rb
@@ -0,0 +1,31 @@
+module Import
+ module Helper
+ # rubocop:disable Style/ModuleFunction
+ extend self
+
+ def check_import_mode
+ # check if system is in import mode
+ return true if Setting.get('import_mode')
+ raise 'System is not in import mode!'
+ end
+
+ # log
+ def log(message)
+ thread_no = Thread.current[:thread_no] || '-'
+ Rails.logger.info "thread##{thread_no}: #{message}"
+ end
+
+ # utf8 convert
+ def utf8_encode(data)
+ data.each { |key, value|
+ next if !value
+ next if value.class != String
+ data[key] = Encode.conv('utf8', value)
+ }
+ end
+
+ def reset_primary_key_sequence(table)
+ DbHelper.import_post(table)
+ end
+ end
+end
diff --git a/lib/import/otrs.rb b/lib/import/otrs.rb
index eb5a6971e..b471eff30 100644
--- a/lib/import/otrs.rb
+++ b/lib/import/otrs.rb
@@ -1,1610 +1,166 @@
-require 'base64'
+# Rails autoload has some issues with same namend sub-classes
+# in the importer folder require AND simultaniuos requiring
+# of the same file in different threads so we need to
+# require them ourself
+require 'import/otrs/ticket'
+require 'import/otrs/ticket_factory'
+require 'import/otrs/article_customer'
+require 'import/otrs/article_customer_factory'
+require 'import/otrs/article'
+require 'import/otrs/article_factory'
+require 'import/otrs/article/attachment_factory'
+require 'import/otrs/history'
+require 'import/otrs/history_factory'
+require 'import/otrs/history/article'
+require 'import/otrs/history/move'
+require 'import/otrs/history/new_ticket'
+require 'import/otrs/history/priority_update'
+require 'import/otrs/history/state_update'
+require 'store'
+require 'store/object'
+require 'store/provider/db'
+require 'store/provider/file'
module Import
-end
-module Import::OTRS
+ module OTRS
+ extend Import::Helper
+ extend Import::OTRS::ImportStats
+ extend Import::OTRS::Async
+ extend Import::OTRS::Diff
-=begin
+ # rubocop:disable Style/ModuleFunction
+ extend self
- result = request_json(Subaction: 'List', 1)
+ def start
+ log 'Start import...'
- return
+ checks
- { some json structure }
+ prerequisites
- result = request_json(Subaction: 'List')
+ base_objects
- return
+ updateable_objects
- "some data string"
+ customer_user
-=end
+ threaded_import('Ticket')
- def self.request_json(data, data_only = false)
- response = post(data)
- if !response
- raise "Can't connect to Zammad Migrator"
- end
- if !response.success?
- raise "Can't connect to Zammad Migrator"
- end
- result = json(response)
- if !result
- raise 'Invalid response'
- end
- if data_only
- result['Result']
- else
- result
- end
- end
-
-=begin
-
- start get request to backend, add auth data automatically
-
- result = request('Subaction=List')
-
- return
-
- "some data string"
-
-=end
-
- def self.request(part)
- url = Setting.get('import_otrs_endpoint') + part + ';Key=' + Setting.get('import_otrs_endpoint_key')
- log 'GET: ' + url
- response = UserAgent.get(
- url,
- {},
- {
- open_timeout: 10,
- read_timeout: 60,
- total_timeout: 180,
- user: Setting.get('import_otrs_user'),
- password: Setting.get('import_otrs_password'),
- },
- )
- if !response.success?
- log "ERROR: #{response.error}"
- return
- end
- response
- end
-
-=begin
-
- start post request to backend, add auth data automatically
-
- result = request('Subaction=List')
-
- return
-
- "some data string"
-
-=end
-
- def self.post(data, url = nil)
- if !url
- url = Setting.get('import_otrs_endpoint')
- data['Action'] = 'ZammadMigrator'
- end
- data['Key'] = Setting.get('import_otrs_endpoint_key')
- log 'POST: ' + url
- log 'PARAMS: ' + data.inspect
- open_timeout = 10
- read_timeout = 120
- total_timeout = 360
- if data.empty?
- open_timeout = 6
- read_timeout = 20
- total_timeout = 120
- end
- response = UserAgent.post(
- url,
- data,
- {
- open_timeout: open_timeout,
- read_timeout: read_timeout,
- total_timeout: total_timeout,
- user: Setting.get('import_otrs_user'),
- password: Setting.get('import_otrs_password'),
- },
- )
- if !response.success?
- log "ERROR: #{response.error}"
- return
- end
- response
- end
-
-=begin
-
- start post request to backend, add auth data automatically
-
- result = json('some response string')
-
- return
-
- {}
-
-=end
-
- def self.json(response)
- data = Encode.conv('utf8', response.body.to_s)
- JSON.parse(data)
- end
-
-=begin
-
- start auth on OTRS - just for experimental reasons
-
- result = auth(username, password)
-
- return
-
- { ..user structure.. }
-
-=end
-
- def self.auth(username, password)
- url = Setting.get('import_otrs_endpoint')
- url.gsub!('ZammadMigrator', 'ZammadSSO')
- response = post( { Action: 'ZammadSSO', Subaction: 'Auth', User: username, Pw: password }, url )
- return if !response
- return if !response.success?
-
- result = json(response)
- result
- end
-
-=begin
-
- request session data - just for experimental reasons
-
- result = session(session_id)
-
- return
-
- { ..session structure.. }
-
-=end
-
- def self.session(session_id)
- url = Setting.get('import_otrs_endpoint')
- url.gsub!('ZammadMigrator', 'ZammadSSO')
- response = post( { Action: 'ZammadSSO', Subaction: 'SessionCheck', SessionID: session_id }, url )
- return if !response
- return if !response.success?
- result = json(response)
- result
- end
-
-=begin
-
- load objects from otrs
-
- result = load('SysConfig')
-
- return
-
- [
- { ..object1.. },
- { ..object2.. },
- { ..object3.. },
- ]
-
-=end
-
- def self.load( object, limit = '', offset = '', diff = 0 )
- request_json( { Subaction: 'Export', Object: object, Limit: limit, Offset: offset, Diff: diff }, 1 )
- end
-
-=begin
-
- start get request to backend to check connection
-
- result = connection_test
-
- return
-
- true | false
-
-=end
-
- def self.connection_test
- request_json({})
- end
-
-=begin
-
- get object statistic from remote server ans save it in cache
-
- result = statistic('Subaction=List')
-
- return
-
- {
- 'Ticket' => 1234,
- 'User' => 123,
- 'SomeObject' => 999,
- }
-
-=end
-
- def self.statistic
-
- # check cache
- cache = Cache.get('import_otrs_stats')
- if cache
- return cache
+ true
end
- # retrive statistic
- statistic = request_json( { Subaction: 'List' }, 1)
- if statistic
- Cache.write('import_otrs_stats', statistic)
- end
- statistic
- end
-
-=begin
-
- return current import state
-
- result = current_state
-
- return
-
- {
- Ticket: {
- total: 1234,
- done: 13,
- },
- Base: {
- total: 1234,
- done: 13,
- },
- }
-
-=end
-
- def self.current_state
- data = statistic
- base = Group.count + Ticket::State.count + Ticket::Priority.count
- base_total = data['Queue'] + data['State'] + data['Priority']
- user = User.count
- user_total = data['User'] + data['CustomerUser']
- data = {
- Base: {
- done: base,
- total: base_total || 0,
- },
- User: {
- done: user,
- total: user_total || 0,
- },
- Ticket: {
- done: Ticket.count,
- total: data['Ticket'] || 0,
- },
- }
- data
- end
-
- #
- # start import
- #
- # Import::OTRS.start
- #
-
- def self.start
- log 'Start import...'
-
- # check if system is in import mode
- if !Setting.get('import_mode')
- raise 'System is not in import mode!'
+ def connection_test
+ Import::OTRS::Requester.connection_test
end
- result = request_json({})
- if !result['Success']
- raise 'API key not valid!'
+ private
+
+ def checks
+ check_import_mode
+ connection_test
end
- # set settings
- settings = load('SysConfig')
- setting(settings)
-
- # dynamic fields
- dynamic_fields = load('DynamicField')
- object_manager(dynamic_fields)
-
- # email accounts
- #accounts = load('PostMasterAccount')
- #account(accounts)
-
- # email filter
- #filters = load('PostMasterFilter')
- #filter(filters)
-
- # create states
- states = load('State')
- ActiveRecord::Base.transaction do
- state(states)
+ def prerequisites
+ # make sure to create store type otherwise
+ # it might lead to race conditions while
+ # creating it in different import threads
+ Store::Object.create_if_not_exists(name: 'Ticket::Article')
end
- # create priorities
- priorities = load('Priority')
- ActiveRecord::Base.transaction do
- priority(priorities)
+ def import(remote_object, args = {})
+ log "loading #{remote_object}..."
+ import_action(remote_object, args)
end
- # create groups
- queues = load('Queue')
- ActiveRecord::Base.transaction do
- ticket_group(queues)
- end
+ def threaded_import(remote_object, args = {})
+ thread_count = args[:threads] || 8
+ limit = args[:limit] || 20
- # get agents groups
- groups = load('Group')
+ Thread.abort_on_exception = true
+ threads = {}
+ (1..thread_count).each { |thread|
- # get agents roles
- roles = load('Role')
+ threads[thread] = Thread.new {
- # create agents
- users = load('User')
- ActiveRecord::Base.transaction do
- user(users, groups, roles, queues)
- end
+ Thread.current[:thread_no] = thread
+ Thread.current[:loop_count] = 0
- # create organizations
- organizations = load('Customer')
- ActiveRecord::Base.transaction do
- organization(organizations)
- end
+ log "Importing #{remote_object} in steps of #{limit}"
+ loop do
+ # get the offset for the current thread and loop count
+ thread_offset_base = (Thread.current[:thread_no] - 1) * limit
+ thread_step = thread_count * limit
+ offset = Thread.current[:loop_count] * thread_step + thread_offset_base
- # create customers
- count = 0
- steps = 50
- run = true
- while run
- count += steps
- records = load('CustomerUser', steps, count - steps)
- if !records || !records[0]
- log 'all customers imported.'
- run = false
- next
- end
- customer(records, organizations)
- end
+ break if !imported?(
+ remote_object: remote_object,
+ limit: limit,
+ offset: offset,
+ diff: args[:diff]
+ )
- Thread.abort_on_exception = true
- thread_count = 8
- threads = {}
- steps = 20
- (1..thread_count).each { |thread|
-
- threads[thread] = Thread.new {
-
- log "Started import thread# #{thread} ..."
- Thread.current[:thread_no] = thread
- Thread.current[:loop_count] = 0
-
- loop do
- # get the offset for the current thread and loop count
- thread_offset_base = (Thread.current[:thread_no] - 1) * steps
- thread_step = thread_count * steps
- offset = Thread.current[:loop_count] * thread_step + thread_offset_base
-
- log "loading... thread# #{thread} ..."
- records = load( 'Ticket', steps, offset)
- if !records || !records[0]
- log "... thread# #{thread}, no more work."
- break
+ Thread.current[:loop_count] += 1
end
- _ticket_result(records, thread)
-
- Thread.current[:loop_count] += 1
- end
- ActiveRecord::Base.connection.close
+ ActiveRecord::Base.connection.close
+ }
}
- }
- (1..thread_count).each { |thread|
- threads[thread].join
- }
+ (1..thread_count).each { |thread|
+ threads[thread].join
+ }
+ end
- true
- end
-
-=begin
- start import in background
-
- Import::OTRS.start_bg
-=end
-
- def self.start_bg
- Setting.reload
-
- Import::OTRS.connection_test
-
- # start thread to observe current state
- status_update_thread = Thread.new {
+ def limit_import(remote_object, args = {})
+ offset = 0
+ limit = args[:limit] || 20
+ log "Importing #{remote_object} in steps of #{limit}"
loop do
- result = {
- data: current_state,
- result: 'in_progress',
- }
- Cache.write('import:state', result, expires_in: 10.minutes)
- sleep 8
- end
- }
- sleep 2
- # start import data
- begin
- Import::OTRS.start
- rescue => e
- status_update_thread.exit
- status_update_thread.join
- Rails.logger.error e.message
- Rails.logger.error e.backtrace.inspect
- result = {
- message: e.message,
- result: 'error',
- }
- Cache.write('import:state', result, expires_in: 10.hours)
- return false
- end
- sleep 16 # wait until new finished import state is on client
- status_update_thread.exit
- status_update_thread.join
-
- result = {
- result: 'import_done',
- }
- Cache.write('import:state', result, expires_in: 10.hours)
-
- Setting.set('system_init_done', true)
- Setting.set('import_mode', false)
- end
-
-=begin
-
- get import state from background process
-
- result = Import::OTRS.status_bg
-
-=end
-
- def self.status_bg
- state = Cache.get('import:state')
- return state if state
- {
- message: 'not running',
- }
- end
-
- def self.diff_worker
- return if !Setting.get('import_mode')
- return if Setting.get('import_otrs_endpoint') == 'http://otrs_host/otrs'
- diff
- end
-
- def self.diff
- log 'Start diff...'
-
- # check if system is in import mode
- if !Setting.get('import_mode')
- raise 'System is not in import mode!'
- end
-
- # create states
- states = load('State')
- state(states)
-
- # create priorities
- priorities = load('Priority')
- priority(priorities)
-
- # create groups
- queues = load('Queue')
- ticket_group(queues)
-
- # get agents groups
- groups = load('Group')
-
- # get agents roles
- roles = load('Role')
-
- # create agents
- users = load('User')
- user(users, groups, roles, queues)
-
- # create organizations
- organizations = load('Customer')
- organization(organizations)
-
- # get changed tickets
- ticket_diff
-
- end
-
- def self.ticket_diff
- count = 0
- run = true
- steps = 20
- while run
- count += steps
- log 'loading... diff ...'
- records = load( 'Ticket', steps, count - steps, 1 )
- if !records || !records[0]
- log '... no more work.'
- run = false
- next
- end
- _ticket_result(records)
- end
-
- end
-
- def self._ticket_result(result, _thread = '-')
- map = {
- Ticket: {
- Changed: :updated_at,
- Created: :created_at,
- CreateBy: :created_by_id,
- TicketNumber: :number,
- QueueID: :group_id,
- StateID: :state_id,
- PriorityID: :priority_id,
- Owner: :owner,
- CustomerUserID: :customer,
- Title: :title,
- TicketID: :id,
- FirstResponse: :first_response_at,
- #FirstResponseTimeDestinationDate: :first_response_escalation_at,
- #FirstResponseInMin: :first_response_in_min,
- #FirstResponseDiffInMin: :first_response_diff_in_min,
- Closed: :close_at,
- #SoltutionTimeDestinationDate: :close_escalation_at,
- #CloseTimeInMin: :close_in_min,
- #CloseTimeDiffInMin: :close_diff_in_min,
- },
- Article: {
- SenderType: :sender,
- ArticleType: :type,
- TicketID: :ticket_id,
- ArticleID: :id,
- Body: :body,
- From: :from,
- To: :to,
- Cc: :cc,
- Subject: :subject,
- InReplyTo: :in_reply_to,
- MessageID: :message_id,
- #ReplyTo: :reply_to,
- References: :references,
- Changed: :updated_at,
- Created: :created_at,
- ChangedBy: :updated_by_id,
- CreatedBy: :created_by_id,
- },
- }
-
- result.each { |record|
-
- # cleanup values
- _cleanup(record)
-
- _utf8_encode(record)
-
- ticket_new = {
- title: '',
- created_by_id: 1,
- updated_by_id: 1,
- }
- map[:Ticket].each { |key, value|
- next if !record.key?(key.to_s)
- ticket_new[value] = record[key.to_s]
- }
-
- record.keys.each { |key|
-
- key_string = key.to_s
-
- next if !key_string.start_with?('DynamicField_')
- dynamic_field_name = key_string[13, key_string.length]
-
- next if skip_fields.include?( dynamic_field_name )
- dynamic_field_name = convert_df_name(dynamic_field_name)
-
- ticket_new[dynamic_field_name.to_sym] = record[key_string]
- }
-
- # find owner
- if ticket_new[:owner]
- user = User.find_by(login: ticket_new[:owner].downcase)
- ticket_new[:owner_id] = if user
- user.id
- else
- 1
- end
- ticket_new.delete(:owner)
- end
-
- record['Articles'].each { |article|
- # utf8 encode
- _utf8_encode(article)
- # lookup customers to create first
- _article_based_customers(article)
- }
-
- # find customer
- if ticket_new[:customer]
- user = User.lookup(login: ticket_new[:customer].downcase)
- ticket_new[:customer_id] = if user
- user.id
- else
- _first_customer_id(record['Articles'])
- end
- ticket_new.delete(:customer)
- else
- ticket_new[:customer_id] = 1
- end
-
- # update or create ticket
- ticket_old = Ticket.find_by(id: ticket_new[:id])
- if ticket_old
- log "update Ticket.find(#{ticket_new[:id]})"
- ticket_old.update_attributes(ticket_new)
- else
- log "add Ticket.find(#{ticket_new[:id]})"
-
- begin
- ticket = Ticket.new(ticket_new)
- ticket.id = ticket_new[:id]
- ticket.save
- _reset_pk('tickets')
- rescue ActiveRecord::RecordNotUnique
- log "Ticket #{ticket_new[:id]} is handled by another thead, skipping."
- next
- end
- end
-
- record['Articles'].each do |article|
-
- retries = 3
- begin
-
- ActiveRecord::Base.transaction do
-
- # get article values
- article_new = {
- created_by_id: 1,
- updated_by_id: 1,
- }
-
- map[:Article].each { |key, value|
- next if !article.key?(key.to_s)
- article_new[value] = article[key.to_s]
- }
-
- if article_new[:sender] == 'customer'
- article_new[:sender_id] = Ticket::Article::Sender.lookup(name: 'Customer').id
- article_new.delete(:sender)
- end
- if article_new[:sender] == 'agent'
- article_new[:sender_id] = Ticket::Article::Sender.lookup(name: 'Agent').id
- article_new.delete(:sender)
- end
- if article_new[:sender] == 'system'
- article_new[:sender_id] = Ticket::Article::Sender.lookup(name: 'System').id
- article_new.delete(:sender)
- end
-
- if article_new[:type] == 'email-external'
- article_new[:type_id] = Ticket::Article::Type.lookup(name: 'email').id
- article_new[:internal] = false
- elsif article_new[:type] == 'email-internal'
- article_new[:type_id] = Ticket::Article::Type.lookup(name: 'email').id
- article_new[:internal] = true
- elsif article_new[:type] == 'note-external'
- article_new[:type_id] = Ticket::Article::Type.lookup(name: 'note').id
- article_new[:internal] = false
- elsif article_new[:type] == 'note-internal'
- article_new[:type_id] = Ticket::Article::Type.lookup(name: 'note').id
- article_new[:internal] = true
- elsif article_new[:type] == 'phone'
- article_new[:type_id] = Ticket::Article::Type.lookup(name: 'phone').id
- article_new[:internal] = false
- elsif article_new[:type] == 'webrequest'
- article_new[:type_id] = Ticket::Article::Type.lookup(name: 'web').id
- article_new[:internal] = false
- else
- article_new[:type_id] = 9
- end
- article_new.delete(:type)
- article_object = Ticket::Article.find_by(id: article_new[:id])
-
- # set state types
- if article_object
- log "update Ticket::Article.find(#{article_new[:id]})"
- article_object.update_attributes(article_new)
- else
- log "add Ticket::Article.find(#{article_new[:id]})"
- begin
- article_object = Ticket::Article.new(article_new)
- article_object.id = article_new[:id]
- article_object.save
- _reset_pk('ticket_articles')
- rescue ActiveRecord::RecordNotUnique
- log "Ticket #{ticket_new[:id]} (article #{article_new[:id]}) is handled by another thead, skipping."
- next
- end
- end
-
- next if !article['Attachments']
- next if article['Attachments'].empty?
-
- # TODO: refactor
- # check if there are attachments present
- if !article_object.attachments.empty?
-
- # skip attachments if count is equal
- next if article_object.attachments.count == article['Attachments'].count
-
- # if the count differs delete all so we
- # can have a fresh start
- article_object.attachments.each(&:delete)
- end
-
- # import article attachments
- article['Attachments'].each { |attachment|
-
- filename = Base64.decode64(attachment['Filename'])
-
- Store.add(
- object: 'Ticket::Article',
- o_id: article_object.id,
- filename: filename,
- data: Base64.decode64(attachment['Content']),
- preferences: {
- 'Mime-Type' => attachment['ContentType'],
- 'Content-ID' => attachment['ContentID'],
- 'content-alternative' => attachment['ContentAlternative'],
- },
- created_by_id: 1,
- )
- }
- end
- rescue ActiveRecord::RecordNotUnique => e
- log "Ticket #{ticket_new[:id]} - RecordNotUnique: #{e}"
- sleep rand 3
- retry if !(retries -= 1).zero?
- raise
- end
- end
-
- #puts "HS: #{record['History'].inspect}"
- record['History'].each { |history|
-
- begin
- if history['HistoryType'] == 'NewTicket'
- History.add(
- id: history['HistoryID'],
- o_id: history['TicketID'],
- history_type: 'created',
- history_object: 'Ticket',
- created_at: history['CreateTime'],
- created_by_id: history['CreateBy']
- )
- elsif history['HistoryType'] == 'StateUpdate'
- data = history['Name']
- # "%%new%%open%%"
- from = nil
- to = nil
- if data =~ /%%(.+?)%%(.+?)%%/
- from = $1
- to = $2
- state_from = Ticket::State.lookup(name: from)
- state_to = Ticket::State.lookup(name: to)
- if state_from
- from_id = state_from.id
- end
- if state_to
- to_id = state_to.id
- end
- end
- History.add(
- id: history['HistoryID'],
- o_id: history['TicketID'],
- history_type: 'updated',
- history_object: 'Ticket',
- history_attribute: 'state',
- value_from: from,
- id_from: from_id,
- value_to: to,
- id_to: to_id,
- created_at: history['CreateTime'],
- created_by_id: history['CreateBy']
- )
- elsif history['HistoryType'] == 'Move'
- data = history['Name']
- # "%%Queue1%%5%%Postmaster%%1"
- from = nil
- to = nil
- if data =~ /%%(.+?)%%(.+?)%%(.+?)%%(.+?)$/
- from = $1
- from_id = $2
- to = $3
- to_id = $4
- end
- History.add(
- id: history['HistoryID'],
- o_id: history['TicketID'],
- history_type: 'updated',
- history_object: 'Ticket',
- history_attribute: 'group',
- value_from: from,
- value_to: to,
- id_from: from_id,
- id_to: to_id,
- created_at: history['CreateTime'],
- created_by_id: history['CreateBy']
- )
- elsif history['HistoryType'] == 'PriorityUpdate'
- data = history['Name']
- # "%%3 normal%%3%%5 very high%%5"
- from = nil
- to = nil
- if data =~ /%%(.+?)%%(.+?)%%(.+?)%%(.+?)$/
- from = $1
- from_id = $2
- to = $3
- to_id = $4
- end
- History.add(
- id: history['HistoryID'],
- o_id: history['TicketID'],
- history_type: 'updated',
- history_object: 'Ticket',
- history_attribute: 'priority',
- value_from: from,
- value_to: to,
- id_from: from_id,
- id_to: to_id,
- created_at: history['CreateTime'],
- created_by_id: history['CreateBy']
- )
- elsif history['ArticleID'] && !history['ArticleID'].to_i.zero?
- History.add(
- id: history['HistoryID'],
- o_id: history['ArticleID'],
- history_type: 'created',
- history_object: 'Ticket::Article',
- related_o_id: history['TicketID'],
- related_history_object: 'Ticket',
- created_at: history['CreateTime'],
- created_by_id: history['CreateBy']
- )
- end
-
- rescue ActiveRecord::RecordNotUnique
- log "Ticket #{ticket_new[:id]} (history #{history['HistoryID']}) is handled by another thead, skipping."
- next
- end
- }
- }
- end
-
- # sync ticket states
- def self.state(records)
- map = {
- ChangeTime: :updated_at,
- CreateTime: :created_at,
- CreateBy: :created_by_id,
- ChangeBy: :updated_by_id,
- Name: :name,
- ID: :id,
- ValidID: :active,
- Comment: :note,
- }
-
- # rename states to handle not uniq issues
- Ticket::State.all.each { |state|
- state.name = state.name + '_tmp'
- state.save
- }
-
- records.each { |state|
- _set_valid(state)
-
- # get new attributes
- state_new = {
- created_by_id: 1,
- updated_by_id: 1,
- }
- map.each { |key, value|
- next if !state.key?(key.to_s)
- state_new[value] = state[key.to_s]
- }
-
- # check if state already exists
- state_old = Ticket::State.lookup(id: state_new[:id])
-
- # set state types
- if state['TypeName'] == 'pending auto'
- state['TypeName'] = 'pending action'
- end
- state_type = Ticket::StateType.lookup(name: state['TypeName'])
- state_new[:state_type_id] = state_type.id
- if state_old
- state_old.update_attributes(state_new)
- else
- state = Ticket::State.new(state_new)
- state.id = state_new[:id]
- state.save
- _reset_pk('ticket_states')
- end
- }
- end
-
- # sync ticket priorities
- def self.priority(records)
-
- map = {
- ChangeTime: :updated_at,
- CreateTime: :created_at,
- CreateBy: :created_by_id,
- ChangeBy: :updated_by_id,
- Name: :name,
- ID: :id,
- ValidID: :active,
- Comment: :note,
- }
-
- records.each { |priority|
- _set_valid(priority)
-
- # get new attributes
- priority_new = {
- created_by_id: 1,
- updated_by_id: 1,
- }
- map.each { |key, value|
- next if !priority.key?(key.to_s)
- priority_new[value] = priority[key.to_s]
- }
-
- # check if state already exists
- priority_old = Ticket::Priority.lookup(id: priority_new[:id])
-
- # set state types
- if priority_old
- priority_old.update_attributes(priority_new)
- else
- priority = Ticket::Priority.new(priority_new)
- priority.id = priority_new[:id]
- priority.save
- _reset_pk('ticket_priorities')
- end
- }
- end
-
- # sync ticket groups / queues
- def self.ticket_group(records)
- map = {
- ChangeTime: :updated_at,
- CreateTime: :created_at,
- CreateBy: :created_by_id,
- ChangeBy: :updated_by_id,
- Name: :name,
- QueueID: :id,
- ValidID: :active,
- Comment: :note,
- }
-
- records.each { |group|
- _set_valid(group)
-
- # get new attributes
- group_new = {
- created_by_id: 1,
- updated_by_id: 1,
- }
- map.each { |key, value|
- next if !group.key?(key.to_s)
- group_new[value] = group[key.to_s]
- }
-
- # check if state already exists
- group_old = Group.lookup(id: group_new[:id])
-
- # set state types
- if group_old
- group_old.update_attributes(group_new)
- else
- group = Group.new(group_new)
- group.id = group_new[:id]
- group.save
- _reset_pk('groups')
- end
- }
- end
-
- # sync agents
- def self.user(records, groups, roles, queues)
-
- map = {
- ChangeTime: :updated_at,
- CreateTime: :created_at,
- CreateBy: :created_by_id,
- ChangeBy: :updated_by_id,
- UserID: :id,
- ValidID: :active,
- Comment: :note,
- UserEmail: :email,
- UserFirstname: :firstname,
- UserLastname: :lastname,
- UserLogin: :login,
- UserPw: :password,
- }
-
- records.each { |user|
- _set_valid(user)
-
- # get roles
- role_ids = get_roles_ids(user, groups, roles, queues)
-
- # get groups
- group_ids = get_queue_ids(user, groups, roles, queues)
-
- # get new attributes
- user_new = {
- created_by_id: 1,
- updated_by_id: 1,
- source: 'OTRS Import',
- role_ids: role_ids,
- group_ids: group_ids,
- }
- map.each { |key, value|
- next if !user.key?(key.to_s)
- user_new[value] = user[key.to_s]
- }
-
- # set pw
- if user_new[:password]
- user_new[:password] = "{sha2}#{user_new[:password]}"
- end
-
- # check if agent already exists
- user_old = User.lookup(id: user_new[:id])
-
- # check if login is already used
- login_in_use = User.where( "login = ? AND id != #{user_new[:id]}", user_new[:login].downcase ).count
- if login_in_use.positive?
- user_new[:login] = "#{user_new[:login]}_#{user_new[:id]}"
- end
-
- # create / update agent
- if user_old
- log "update User.find(#{user_old[:id]})"
-
- # only update roles if different (reduce sql statements)
- if user_old.role_ids == user_new[:role_ids]
- user_new.delete(:role_ids)
- end
-
- user_old.update_attributes(user_new)
- else
- log "add User.find(#{user_new[:id]})"
- user = User.new(user_new)
- user.id = user_new[:id]
- user.save
- _reset_pk('users')
- end
- }
- end
-
- def self.get_queue_ids(user, _groups, _roles, queues)
- queue_ids = []
-
- # lookup by groups
- user['GroupIDs'].each { |group_id, permissions|
- queues.each { |queue_lookup|
-
- next if queue_lookup['GroupID'] != group_id
- next if !permissions
- next if !permissions.include?('rw')
-
- queue_ids.push queue_lookup['QueueID']
- }
- }
-
- # lookup by roles
-
- # roles of user
- # groups of roles
- # queues of group
-
- queue_ids
- end
-
- def self.get_roles_ids(user, groups, roles, _queues)
- local_roles = ['Agent']
- local_role_ids = []
-
- # apply group permissions
- user['GroupIDs'].each { |group_id, permissions|
- groups.each { |group_lookup|
-
- next if group_id != group_lookup['ID']
- next if !permissions
-
- if group_lookup['Name'] == 'admin' && permissions.include?('rw')
- local_roles.push 'Admin'
- end
-
- next if group_lookup['Name'] !~ /^(stats|report)/
- next if !( permissions.include?('ro') || permissions.include?('rw') )
-
- local_roles.push 'Report'
- }
- }
-
- # apply role permissions
- user['RoleIDs'].each { |role_id|
-
- # get groups of role
- roles.each { |role|
- next if role['ID'] != role_id
-
- # verify group names
- role['GroupIDs'].each { |group_id, permissions|
- groups.each { |group_lookup|
-
- next if group_id != group_lookup['ID']
- next if !permissions
-
- if group_lookup['Name'] == 'admin' && permissions.include?('rw')
- local_roles.push 'Admin'
- end
-
- next if group_lookup['Name'] !~ /^(stats|report)/
- next if !( permissions.include?('ro') || permissions.include?('rw') )
-
- local_roles.push 'Report'
- }
- }
- }
- }
-
- local_roles.each { |role|
- role_lookup = Role.lookup(name: role)
- next if !role_lookup
- local_role_ids.push role_lookup.id
- }
- local_role_ids
- end
-
- # sync customers
-
- def self.customer(records, organizations)
- map = {
- ChangeTime: :updated_at,
- CreateTime: :created_at,
- CreateBy: :created_by_id,
- ChangeBy: :updated_by_id,
- ValidID: :active,
- UserComment: :note,
- UserEmail: :email,
- UserFirstname: :firstname,
- UserLastname: :lastname,
- UserLogin: :login,
- UserPassword: :password,
- UserPhone: :phone,
- UserFax: :fax,
- UserMobile: :mobile,
- UserStreet: :street,
- UserZip: :zip,
- UserCity: :city,
- UserCountry: :country,
- }
-
- role_agent = Role.lookup(name: 'Agent')
- role_customer = Role.lookup(name: 'Customer')
-
- records.each { |user|
- _set_valid(user)
-
- # get new attributes
- user_new = {
- created_by_id: 1,
- updated_by_id: 1,
- source: 'OTRS Import',
- organization_id: get_organization_id(user, organizations),
- role_ids: [ role_customer.id ],
- }
- map.each { |key, value|
- next if !user.key?(key.to_s)
- user_new[value] = user[key.to_s]
- }
-
- # check if customer already exists
- user_old = User.lookup(login: user_new[:login])
-
- # create / update agent
- if user_old
-
- # do not update user if it is already agent
- if !user_old.role_ids.include?(role_agent.id)
-
- # only update roles if different (reduce sql statements)
- if user_old.role_ids == user_new[:role_ids]
- user_new.delete(:role_ids)
- end
- log "update User.find(#{user_old[:id]})"
- user_old.update_attributes(user_new)
- end
- else
- log "add User.find(#{user_new[:id]})"
- user = User.new(user_new)
- user.save
- _reset_pk('users')
- end
- }
- end
-
- def self.get_organization_id(user, organizations)
- organization_id = nil
- if user['UserCustomerID']
- organizations.each { |organization|
- next if user['UserCustomerID'] != organization['CustomerID']
- organization = Organization.lookup(name: organization['CustomerCompanyName'])
- organization_id = organization.id
- }
- end
- organization_id
- end
-
- # sync organizations
- def self.organization(records)
- map = {
- ChangeTime: :updated_at,
- CreateTime: :created_at,
- CreateBy: :created_by_id,
- ChangeBy: :updated_by_id,
- CustomerCompanyName: :name,
- ValidID: :active,
- CustomerCompanyComment: :note,
- }
-
- records.each { |organization|
- _set_valid(organization)
-
- # get new attributes
- organization_new = {
- created_by_id: 1,
- updated_by_id: 1,
- }
- map.each { |key, value|
- next if !organization.key?(key.to_s)
- organization_new[value] = organization[key.to_s]
- }
-
- # check if state already exists
- organization_old = Organization.lookup(name: organization_new[:name])
-
- # set state types
- if organization_old
- organization_old.update_attributes(organization_new)
- else
- organization = Organization.new(organization_new)
- organization.id = organization_new[:id]
- organization.save
- _reset_pk('organizations')
- end
- }
- end
-
- # sync settings
- def self.setting(records)
-
- records.each { |setting|
-
- # fqdn
- if setting['Key'] == 'FQDN'
- Setting.set('fqdn', setting['Value'])
- end
-
- # http type
- if setting['Key'] == 'HttpType'
- Setting.set('http_type', setting['Value'])
- end
-
- # system id
- if setting['Key'] == 'SystemID'
- Setting.set('system_id', setting['Value'])
- end
-
- # organization
- if setting['Key'] == 'Organization'
- Setting.set('organization', setting['Value'])
- end
-
- # sending emails
- if setting['Key'] == 'SendmailModule'
- # TODO
- end
-
- # number generater
- if setting['Key'] == 'Ticket::NumberGenerator'
- if setting['Value'] == 'Kernel::System::Ticket::Number::DateChecksum'
- Setting.set('ticket_number', 'Ticket::Number::Date')
- Setting.set('ticket_number_date', { checksum: true })
- elsif setting['Value'] == 'Kernel::System::Ticket::Number::Date'
- Setting.set('ticket_number', 'Ticket::Number::Date')
- Setting.set('ticket_number_date', { checksum: false })
- end
- end
-
- # ticket hook
- if setting['Key'] == 'Ticket::Hook'
- Setting.set('ticket_hook', setting['Value'])
- end
- }
- end
-
- # dynamic fields
- def self.object_manager(dynamic_fields)
-
- dynamic_fields.each { |dynamic_field|
-
- if dynamic_field['ObjectType'] != 'Ticket'
- log "ERROR: Unsupported dynamic field object type '#{dynamic_field['ObjectType']}' for dynamic field '#{dynamic_field['Name']}'"
- next
- end
-
- next if skip_fields.include?( dynamic_field['Name'] )
-
- internal_name = convert_df_name(dynamic_field['Name'])
-
- attribute = ObjectManager::Attribute.get(
- object: dynamic_field['ObjectType'],
- name: internal_name,
- )
- next if !attribute.nil?
-
- object_manager_config = {
- object: dynamic_field['ObjectType'],
- name: internal_name,
- display: dynamic_field['Label'],
- screens: {
- view: {
- '-all-' => {
- shown: true,
- },
- },
- },
- active: true,
- editable: dynamic_field['InternalField'] == '0',
- position: dynamic_field['FieldOrder'],
- created_by_id: 1,
- updated_by_id: 1,
- }
-
- if dynamic_field['FieldType'] == 'Text'
-
- object_manager_config[:data_type] = 'input'
- object_manager_config[:data_option] = {
- default: dynamic_field['Config']['DefaultValue'],
- type: 'text',
- maxlength: 255,
- null: false,
- }
- elsif dynamic_field['FieldType'] == 'TextArea'
-
- object_manager_config[:data_type] = 'textarea'
- object_manager_config[:data_option] = {
- default: dynamic_field['Config']['DefaultValue'],
- rows: dynamic_field['Config']['Rows'],
- null: false,
- }
- elsif dynamic_field['FieldType'] == 'Checkbox'
-
- object_manager_config[:data_type] = 'boolean'
- object_manager_config[:data_option] = {
- default: dynamic_field['Config']['DefaultValue'] == '1',
- options: {
- true => 'Yes',
- false => 'No',
- },
- null: false,
- translate: true,
- }
- elsif dynamic_field['FieldType'] == 'DateTime'
-
- object_manager_config[:data_type] = 'datetime'
- object_manager_config[:data_option] = {
- future: dynamic_field['Config']['YearsInFuture'] != '0',
- past: dynamic_field['Config']['YearsInPast'] != '0',
- diff: dynamic_field['Config']['DefaultValue'].to_i / 60 / 60,
- null: false,
- }
- elsif dynamic_field['FieldType'] == 'Date'
-
- object_manager_config[:data_type] = 'date'
- object_manager_config[:data_option] = {
- future: dynamic_field['Config']['YearsInFuture'] != '0',
- past: dynamic_field['Config']['YearsInPast'] != '0',
- diff: dynamic_field['Config']['DefaultValue'].to_i / 60 / 60 / 24,
- null: false,
- }
- elsif dynamic_field['FieldType'] == 'Dropdown'
-
- object_manager_config[:data_type] = 'select'
- object_manager_config[:data_option] = {
- default: '',
- multiple: false,
- options: dynamic_field['Config']['PossibleValues'],
- null: dynamic_field['Config']['PossibleNone'] == '1',
- translate: dynamic_field['Config']['TranslatableValues'] == '1',
- }
- elsif dynamic_field['FieldType'] == 'Multiselect'
-
- object_manager_config[:data_type] = 'select'
- object_manager_config[:data_option] = {
- default: '',
- multiple: true,
- options: dynamic_field['Config']['PossibleValues'],
- null: dynamic_field['Config']['PossibleNone'] == '1',
- translate: dynamic_field['Config']['TranslatableValues'] == '1',
- }
- else
- log "ERROR: Unsupported dynamic field field type '#{dynamic_field['FieldType']}' for dynamic field '#{dynamic_field['Name']}'"
- next
- end
-
- ObjectManager::Attribute.add( object_manager_config )
- ObjectManager::Attribute.migration_execute(false)
- }
-
- end
-
- def self.convert_df_name(dynamic_field_name)
- new_name = dynamic_field_name.underscore
- new_name.sub(/\_id(s)?\z/, "_no#{$1}")
- end
-
- # log
- def self.log(message)
- thread_no = Thread.current[:thread_no] || '-'
- Rails.logger.info "thread##{thread_no}: #{message}"
- end
-
- # set translate valid ids to active = true|false
- def self._set_valid(record)
-
- # map
- record['ValidID'] = if record['ValidID'].to_s == '3'
- false
- elsif record['ValidID'].to_s == '2'
- false
- elsif record['ValidID'].to_s == '1'
- true
- elsif record['ValidID'].to_s == '0'
- false
-
- # fallback
- else
- true
- end
- end
-
- # cleanup invalid values
- def self._cleanup(record)
- record.each { |key, value|
- if value == '0000-00-00 00:00:00'
- record[key] = nil
- end
- }
-
- # fix OTRS 3.1 bug, no close time if ticket is created
- if record['StateType'] == 'closed' && (!record['Closed'] || record['Closed'].empty?)
- record['Closed'] = record['Created']
- end
- end
-
- # utf8 convert
- def self._utf8_encode(data)
- data.each { |key, value|
- next if !value
- next if value.class != String
- data[key] = Encode.conv('utf8', value)
- }
- end
-
- # reset primary key sequences
- def self._reset_pk(table)
- return if ActiveRecord::Base.connection_config[:adapter] != 'postgresql'
- ActiveRecord::Base.connection.reset_pk_sequence!(table)
- end
-
- # create customers for article
- def self._article_based_customers(article)
-
- # create customer/sender if needed
- return if article['sender'] != 'customer'
- return if article['created_by_id'].to_i != 1
- return if article['from'].empty?
-
- email = nil
- begin
- email = Mail::Address.new(article['from']).address
- rescue
- email = article['from']
- if article['from'] =~ /<(.+?)>/
- email = $1
- end
- end
-
- user = User.lookup(email: email)
- if !user
- user = User.lookup(login: email)
- end
- if !user
- begin
- display_name = Mail::Address.new( article['from'] ).display_name ||
- ( Mail::Address.new( article['from'] ).comments && Mail::Address.new( article['from'] ).comments[0] )
- rescue
- display_name = article['from']
- end
-
- # do extra decoding because we needed to use field.value
- display_name = Mail::Field.new('X-From', display_name).to_s
-
- roles = Role.lookup(name: 'Customer')
- begin
- user = User.create(
- login: email,
- firstname: display_name,
- lastname: '',
- email: email,
- password: '',
- active: true,
- role_ids: [roles.id],
- updated_by_id: 1,
- created_by_id: 1,
+ break if !imported?(
+ remote_object: remote_object,
+ limit: limit,
+ offset: offset,
+ diff: args[:diff]
)
- rescue ActiveRecord::RecordNotUnique
- log "User #{email} was handled by another thread, taking this."
- user = User.lookup(login: email)
- if !user
- log "User #{email} wasn't created sleep and retry."
- sleep rand 3
- retry
- end
+
+ offset += limit
end
end
- article['created_by_id'] = user.id
- true
- end
+ def imported?(args)
+ log "loading #{args[:limit]} #{args[:remote_object]} starting at #{args[:offset]}..."
+ return false if !import_action(args[:remote_object], limit: args[:limit], offset: args[:offset], diff: args[:diff])
+ true
+ end
- def self.skip_fields
- %w(ProcessManagementProcessID ProcessManagementActivityID ZammadMigratorChanged ZammadMigratorChangedOld)
- end
+ def import_action(remote_object, args = {})
+ records = Import::OTRS::Requester.load(remote_object, limit: args[:limit], offset: args[:offset], diff: args[:diff])
+ if !records || records.empty?
+ log '... no more work.'
+ return false
+ end
+ factory_class(remote_object).import(records)
+ end
- def self._first_customer_id(articles)
- user_id = 1
- articles.each { |article|
- next if article['sender'] != 'customer'
- next if article['from'].empty?
+ def factory_class(object)
+ "Import::OTRS::#{object}Factory".constantize
+ end
- user_id = article['created_by_id'].to_i
- break
- }
+ # sync settings
+ def base_objects
+ import('SysConfig')
+ import('DynamicField')
+ end
- user_id
+ def updateable_objects
+ import('State')
+ import('Priority')
+ import('Queue')
+ import('User')
+ import('Customer')
+ end
+
+ def customer_user
+ limit_import('CustomerUser', limit: 50)
+ end
end
end
diff --git a/lib/import/otrs/article.rb b/lib/import/otrs/article.rb
new file mode 100644
index 000000000..bef778d82
--- /dev/null
+++ b/lib/import/otrs/article.rb
@@ -0,0 +1,136 @@
+module Import
+ module OTRS
+ class Article
+ include Import::Helper
+ include Import::OTRS::Helper
+
+ MAPPING = {
+ TicketID: :ticket_id,
+ ArticleID: :id,
+ Body: :body,
+ From: :from,
+ To: :to,
+ Cc: :cc,
+ Subject: :subject,
+ InReplyTo: :in_reply_to,
+ MessageID: :message_id,
+ #ReplyTo: :reply_to,
+ References: :references,
+ Changed: :updated_at,
+ Created: :created_at,
+ ChangedBy: :updated_by_id,
+ CreatedBy: :created_by_id,
+ }.freeze
+
+ def initialize(article)
+ initialize_article_sender_types
+ initialize_article_types
+
+ utf8_encode(article)
+ import(article)
+ end
+
+ private
+
+ def import(article)
+ create_or_update(map(article))
+
+ return if !article['Attachments']
+ return if article['Attachments'].empty?
+
+ Import::OTRS::Article::AttachmentFactory.import(
+ attachments: article['Attachments'],
+ local_article: @local_article
+ )
+ end
+
+ def create_or_update(article)
+ return if updated?(article)
+ create(article)
+ end
+
+ def updated?(article)
+ @local_article = ::Ticket::Article.find_by(id: article[:id])
+ return false if !@local_article
+ log "update Ticket::Article.find_by(id: #{article[:id]})"
+ @local_article.update_attributes(article)
+ true
+ end
+
+ def create(article)
+ log "add Ticket::Article.find_by(id: #{article[:id]})"
+ @local_article = ::Ticket::Article.new(article)
+ @local_article.id = article[:id]
+ @local_article.save
+ reset_primary_key_sequence('ticket_articles')
+ rescue ActiveRecord::RecordNotUnique
+ log "Ticket #{article[:ticket_id]} (article #{article[:id]}) is handled by another thead, skipping."
+ end
+
+ def map(article)
+ {
+ created_by_id: 1,
+ updated_by_id: 1,
+ }
+ .merge(from_mapping(article))
+ .merge(article_type(article))
+ .merge(article_sender_type(article))
+ end
+
+ def article_type(article)
+ @article_types[article['ArticleType']] || @article_types['note-internal']
+ end
+
+ def article_sender_type(article)
+ {
+ sender_id: @sender_type_id[article['SenderType']] || @sender_type_id['note-internal']
+ }
+ end
+
+ def initialize_article_sender_types
+ @sender_type_id = {
+ 'customer' => article_sender_type_id_lookup('Customer'),
+ 'agent' => article_sender_type_id_lookup('Agent'),
+ 'system' => article_sender_type_id_lookup('System'),
+ }
+ end
+
+ def article_sender_type_id_lookup(name)
+ ::Ticket::Article::Sender.find_by(name: name).id
+ end
+
+ def initialize_article_types
+ @article_types = {
+ 'email-external' => {
+ type_id: article_type_id_lookup('email'),
+ internal: false
+ },
+ 'email-internal' => {
+ type_id: article_type_id_lookup('email'),
+ internal: true
+ },
+ 'note-external' => {
+ type_id: article_type_id_lookup('note'),
+ internal: false
+ },
+ 'note-internal' => {
+ type_id: article_type_id_lookup('note'),
+ internal: true
+ },
+ 'phone' => {
+ type_id: article_type_id_lookup('phone'),
+ internal: false
+ },
+ 'webrequest' => {
+ type_id: article_type_id_lookup('web'),
+ internal: false
+ },
+ }
+ end
+
+ def article_type_id_lookup(name)
+ ::Ticket::Article::Type.lookup(name: name).id
+ end
+ end
+ end
+end
diff --git a/lib/import/otrs/article/attachment_factory.rb b/lib/import/otrs/article/attachment_factory.rb
new file mode 100644
index 000000000..ad44e663d
--- /dev/null
+++ b/lib/import/otrs/article/attachment_factory.rb
@@ -0,0 +1,104 @@
+module Import
+ module OTRS
+ class Article
+ module AttachmentFactory
+ extend Import::Helper
+
+ # rubocop:disable Style/ModuleFunction
+ extend self
+
+ def import(args)
+ attachments = args[:attachments] || []
+ local_article = args[:local_article]
+
+ return if skip_import?(attachments, local_article)
+ perform_import(attachments, local_article)
+ end
+
+ private
+
+ def perform_import(attachments, local_article)
+ attachments.each { |attachment| import_single(local_article, attachment) }
+ end
+
+ def import_single(local_article, attachment)
+
+ decoded_filename = Base64.decode64(attachment['Filename'])
+ decoded_content = Base64.decode64(attachment['Content'])
+ # TODO: should be done by a/the Storage object
+ # to handle fingerprinting
+ sha = Digest::SHA256.hexdigest(decoded_content)
+
+ retries = 3
+ begin
+ queueing(sha, decoded_filename)
+
+ log "Ticket #{local_article.ticket_id}, Article #{local_article.id} - Starting import for fingerprint #{sha} (#{decoded_filename})... Queue: #{@sha_queue[sha]}."
+ ActiveRecord::Base.transaction do
+ Store.add(
+ object: 'Ticket::Article',
+ o_id: local_article.id,
+ filename: decoded_filename,
+ data: decoded_content,
+ preferences: {
+ 'Mime-Type' => attachment['ContentType'],
+ 'Content-ID' => attachment['ContentID'],
+ 'content-alternative' => attachment['ContentAlternative'],
+ },
+ created_by_id: 1,
+ )
+ end
+ log "Ticket #{local_article.ticket_id}, Article #{local_article.id} - Finished import for fingerprint #{sha} (#{decoded_filename})... Queue: #{@sha_queue[sha]}."
+ rescue ActiveRecord::RecordNotUnique, ActiveRecord::StatementInvalid => e
+ log "Ticket #{local_article.ticket_id} - #{sha} - #{e.class}: #{e}"
+ sleep rand 3
+ retry if !(retries -= 1).zero?
+ raise
+ ensure
+ queue_cleanup(sha)
+ end
+ end
+
+ def skip_import?(attachments, local_article)
+ local_attachments = local_article.attachments
+ return true if local_attachments.count == attachments.count
+ # get a common ground
+ local_attachments.each(&:delete)
+ return true if attachments.empty?
+ false
+ end
+
+ def queueing(sha, decoded_filename)
+ # this is (currently) needed for avoiding
+ # race conditions inserting attachments with
+ # the same fingerprint in the DB in concurrent threads
+ @sha_queue ||= {}
+ @sha_queue[sha] ||= []
+
+ return if !queueing_active?
+ @sha_queue[sha].push(queue_id)
+
+ while @sha_queue[sha].first != queue_id
+ sleep_time = 0.25
+ log "Found active import for fingerprint #{sha} (#{decoded_filename})... sleeping #{sleep_time} seconds. Queue: #{@sha_queue[sha]}."
+ sleep sleep_time
+ end
+ end
+
+ def queue_cleanup(sha)
+ return if !queueing_active?
+ @sha_queue[sha].shift
+ end
+
+ def queueing_active?
+ return if !queue_id
+ true
+ end
+
+ def queue_id
+ Thread.current[:thread_no]
+ end
+ end
+ end
+ end
+end
diff --git a/lib/import/otrs/article_customer.rb b/lib/import/otrs/article_customer.rb
new file mode 100644
index 000000000..330ca164a
--- /dev/null
+++ b/lib/import/otrs/article_customer.rb
@@ -0,0 +1,84 @@
+module Import
+ module OTRS
+ class ArticleCustomer
+ include Import::Helper
+
+ def initialize(article)
+ user = import(article)
+ return if !user
+ article['created_by_id'] = user.id
+ rescue Exceptions::UnprocessableEntity => e
+ log "ERROR: Can't extract customer from Article #{article[:id]}"
+ end
+
+ private
+
+ def import(article)
+ find_user_or_create(article)
+ end
+
+ def extract_email(from)
+ Mail::Address.new(from).address
+ rescue
+ return from if from !~ /<\s*([^\s]+)/
+ $1
+ end
+
+ def find_user_or_create(article)
+ user = user_found?(article)
+ return user if user
+ create_user(article)
+ end
+
+ def user_found?(article)
+ email = extract_email(article['From'])
+ user = ::User.find_by(email: email)
+ user ||= ::User.find_by(login: email)
+ user
+ end
+
+ def create_user(article)
+ email = extract_email(article['From'])
+ ::User.create(
+ login: email,
+ firstname: extract_display_name(article['from']),
+ lastname: '',
+ email: email,
+ password: '',
+ active: true,
+ role_ids: roles,
+ updated_by_id: 1,
+ created_by_id: 1,
+ )
+ rescue ActiveRecord::RecordNotUnique
+ log "User #{email} was handled by another thread, taking this."
+
+ return if user_found?(article)
+
+ log "User #{email} wasn't created sleep and retry."
+ sleep rand 3
+ retry
+ end
+
+ def roles
+ [
+ Role.find_by(name: 'Customer').id
+ ]
+ end
+
+ def extract_display_name(from)
+ # do extra decoding because we needed to use field.value
+ Mail::Field.new('X-From', parsed_display_name(from)).to_s
+ end
+
+ def parsed_display_name(from)
+ parsed_address = Mail::Address.new(from)
+ return parsed_address.display_name if parsed_address.display_name
+ return from if parsed_address.comments.empty?
+ parsed_address.comments[0]
+ rescue
+ from
+ end
+ end
+ end
+end
diff --git a/lib/import/otrs/article_customer_factory.rb b/lib/import/otrs/article_customer_factory.rb
new file mode 100644
index 000000000..1ab64fe58
--- /dev/null
+++ b/lib/import/otrs/article_customer_factory.rb
@@ -0,0 +1,14 @@
+module Import
+ module OTRS
+ module ArticleCustomerFactory
+ extend Import::Factory
+
+ def skip?(record)
+ return true if record['sender'] != 'customer'
+ return true if record['created_by_id'].to_i != 1
+ return true if record['from'].empty?
+ false
+ end
+ end
+ end
+end
diff --git a/lib/import/otrs/article_factory.rb b/lib/import/otrs/article_factory.rb
new file mode 100644
index 000000000..caf2a55bf
--- /dev/null
+++ b/lib/import/otrs/article_factory.rb
@@ -0,0 +1,7 @@
+module Import
+ module OTRS
+ module ArticleFactory
+ extend Import::Factory
+ end
+ end
+end
diff --git a/lib/import/otrs/async.rb b/lib/import/otrs/async.rb
new file mode 100644
index 000000000..fd54b1a3a
--- /dev/null
+++ b/lib/import/otrs/async.rb
@@ -0,0 +1,62 @@
+module Import
+ module OTRS
+ module Async
+ # rubocop:disable Style/ModuleFunction
+ extend self
+
+ def start_bg
+ Setting.reload
+
+ Import::OTRS::Requester.connection_test
+
+ # start thread to observe current state
+ status_update_thread = Thread.new {
+ loop do
+ result = {
+ data: current_state,
+ result: 'in_progress',
+ }
+ Cache.write('import:state', result, expires_in: 10.minutes)
+ sleep 8
+ end
+ }
+ sleep 2
+
+ # start import data
+ begin
+ Import::OTRS.start
+ rescue => e
+ status_update_thread.exit
+ status_update_thread.join
+ Rails.logger.error e.message
+ Rails.logger.error e.backtrace.inspect
+ result = {
+ message: e.message,
+ result: 'error',
+ }
+ Cache.write('import:state', result, expires_in: 10.hours)
+ return false
+ end
+ sleep 16 # wait until new finished import state is on client
+ status_update_thread.exit
+ status_update_thread.join
+
+ result = {
+ result: 'import_done',
+ }
+ Cache.write('import:state', result, expires_in: 10.hours)
+
+ Setting.set('system_init_done', true)
+ Setting.set('import_mode', false)
+ end
+
+ def status_bg
+ state = Cache.get('import:state')
+ return state if state
+ {
+ message: 'not running',
+ }
+ end
+ end
+ end
+end
diff --git a/lib/import/otrs/customer.rb b/lib/import/otrs/customer.rb
new file mode 100644
index 000000000..9a3d31e1a
--- /dev/null
+++ b/lib/import/otrs/customer.rb
@@ -0,0 +1,67 @@
+module Import
+ module OTRS
+ class Customer
+ include Import::Helper
+ include Import::OTRS::Helper
+
+ MAPPING = {
+ ChangeTime: :updated_at,
+ CreateTime: :created_at,
+ CreateBy: :created_by_id,
+ ChangeBy: :updated_by_id,
+ CustomerCompanyName: :name,
+ CustomerCompanyComment: :note,
+ }.freeze
+
+ def initialize(customer)
+ import(customer)
+ end
+
+ def self.by_customer_id(customer_id)
+ organizations = Import::OTRS::Requester.load('Customer')
+
+ result = nil
+ organizations.each do |organization|
+ next if customer_id != organization['CustomerID']
+ result = Organization.find_by(name: organization['CustomerCompanyName'])
+ break
+ end
+ result
+ end
+
+ private
+
+ def import(customer)
+ create_or_update(map(customer))
+ end
+
+ def create_or_update(customer)
+ return if updated?(customer)
+ create(customer)
+ end
+
+ def updated?(customer)
+ @local_customer = Organization.find_by(name: customer[:name])
+ return false if !@local_customer
+ log "update Organization.find_by(name: #{customer[:name]})"
+ @local_customer.update_attributes(customer)
+ true
+ end
+
+ def create(customer)
+ log "add Organization.find_by(name: #{customer[:name]})"
+ @local_customer = Organization.create(customer)
+ reset_primary_key_sequence('organizations')
+ end
+
+ def map(customer)
+ {
+ created_by_id: 1,
+ updated_by_id: 1,
+ active: active?(customer),
+ }
+ .merge(from_mapping(customer))
+ end
+ end
+ end
+end
diff --git a/lib/import/otrs/customer_factory.rb b/lib/import/otrs/customer_factory.rb
new file mode 100644
index 000000000..1c2c36f46
--- /dev/null
+++ b/lib/import/otrs/customer_factory.rb
@@ -0,0 +1,7 @@
+module Import
+ module OTRS
+ module CustomerFactory
+ extend Import::Factory
+ end
+ end
+end
diff --git a/lib/import/otrs/customer_user.rb b/lib/import/otrs/customer_user.rb
new file mode 100644
index 000000000..1d98ba88d
--- /dev/null
+++ b/lib/import/otrs/customer_user.rb
@@ -0,0 +1,92 @@
+module Import
+ module OTRS
+ class CustomerUser
+ include Import::Helper
+ include Import::OTRS::Helper
+
+ MAPPING = {
+ ChangeTime: :updated_at,
+ CreateTime: :created_at,
+ CreateBy: :created_by_id,
+ ChangeBy: :updated_by_id,
+ UserComment: :note,
+ UserEmail: :email,
+ UserFirstname: :firstname,
+ UserLastname: :lastname,
+ UserLogin: :login,
+ UserPassword: :password,
+ UserPhone: :phone,
+ UserFax: :fax,
+ UserMobile: :mobile,
+ UserStreet: :street,
+ UserZip: :zip,
+ UserCity: :city,
+ UserCountry: :country,
+ }.freeze
+
+ def initialize(customer)
+ import(customer)
+ end
+
+ private
+
+ def import(customer)
+ create_or_update(map(customer))
+ end
+
+ def create_or_update(customer)
+ return if updated?(customer)
+ create(customer)
+ end
+
+ def updated?(customer)
+ @local_customer = ::User.find_by(login: customer[:login])
+ return false if !@local_customer
+
+ # do not update user if it is already agent
+ return true if @local_customer.role_ids.include?(Role.find_by(name: 'Agent').id)
+
+ # only update roles if different (reduce sql statements)
+ if @local_customer.role_ids == customer[:role_ids]
+ user.delete(:role_ids)
+ end
+
+ log "update User.find_by(login: #{customer[:login]})"
+ @local_customer.update_attributes(customer)
+ true
+ end
+
+ def create(customer)
+ log "add User.find_by(login: #{customer[:login]})"
+ @local_customer = ::User.new(customer)
+ @local_customer.save
+ reset_primary_key_sequence('users')
+ end
+
+ def map(customer)
+ {
+ created_by_id: 1,
+ updated_by_id: 1,
+ active: active?(customer),
+ source: 'OTRS Import',
+ organization_id: organization_id(customer),
+ role_ids: role_ids,
+ }
+ .merge(from_mapping(customer))
+ end
+
+ def role_ids
+ [
+ Role.find_by(name: 'Customer').id
+ ]
+ end
+
+ def organization_id(customer)
+ return if !customer['UserCustomerID']
+ organization = Import::OTRS::Customer.by_customer_id(customer['UserCustomerID'])
+ return if !organization
+ organization.id
+ end
+ end
+ end
+end
diff --git a/lib/import/otrs/customer_user_factory.rb b/lib/import/otrs/customer_user_factory.rb
new file mode 100644
index 000000000..02ee37daf
--- /dev/null
+++ b/lib/import/otrs/customer_user_factory.rb
@@ -0,0 +1,7 @@
+module Import
+ module OTRS
+ module CustomerUserFactory
+ extend Import::Factory
+ end
+ end
+end
diff --git a/lib/import/otrs/diff.rb b/lib/import/otrs/diff.rb
new file mode 100644
index 000000000..316457953
--- /dev/null
+++ b/lib/import/otrs/diff.rb
@@ -0,0 +1,36 @@
+module Import
+ module OTRS
+ module Diff
+ # rubocop:disable Style/ModuleFunction
+ extend self
+
+ def diff_worker
+ return if !diff_import_possible?
+ diff
+ end
+
+ private
+
+ def diff_import_possible?
+ return if !Setting.get('import_mode')
+ return if Setting.get('import_otrs_endpoint') == 'http://otrs_host/otrs'
+ true
+ end
+
+ def diff
+ log 'Start diff...'
+
+ check_import_mode
+
+ updateable_objects
+
+ # get changed tickets
+ ticket_diff
+ end
+
+ def ticket_diff
+ import('Ticket', diff: true)
+ end
+ end
+ end
+end
diff --git a/lib/import/otrs/dynamic_field.rb b/lib/import/otrs/dynamic_field.rb
new file mode 100644
index 000000000..b108d6aa6
--- /dev/null
+++ b/lib/import/otrs/dynamic_field.rb
@@ -0,0 +1,61 @@
+module Import
+ module OTRS
+ class DynamicField
+
+ def initialize(dynamic_field)
+ @internal_name = self.class.convert_name(dynamic_field['Name'])
+
+ return if already_imported?(dynamic_field)
+
+ initialize_attribute_config(dynamic_field)
+
+ init_callback(dynamic_field)
+ add
+ end
+
+ def self.convert_name(dynamic_field_name)
+ dynamic_field_name.underscore.sub(/\_id(s)?\z/, '_no\1')
+ end
+
+ private
+
+ def init_callback(_)
+ raise 'No init callback defined for this dynamic field!'
+ end
+
+ def already_imported?(dynamic_field)
+ attribute = ObjectManager::Attribute.get(
+ object: dynamic_field['ObjectType'],
+ name: @internal_name,
+ )
+ attribute ? true : false
+ end
+
+ def initialize_attribute_config(dynamic_field)
+
+ @attribute_config = {
+ object: dynamic_field['ObjectType'],
+ name: @internal_name,
+ display: dynamic_field['Label'],
+ screens: {
+ view: {
+ '-all-' => {
+ shown: true,
+ },
+ },
+ },
+ active: true,
+ editable: dynamic_field['InternalField'] == '0',
+ position: dynamic_field['FieldOrder'],
+ created_by_id: 1,
+ updated_by_id: 1,
+ }
+ end
+
+ def add
+ ObjectManager::Attribute.add(@attribute_config)
+ ObjectManager::Attribute.migration_execute(false)
+ end
+ end
+ end
+end
diff --git a/lib/import/otrs/dynamic_field/checkbox.rb b/lib/import/otrs/dynamic_field/checkbox.rb
new file mode 100644
index 000000000..8666986ac
--- /dev/null
+++ b/lib/import/otrs/dynamic_field/checkbox.rb
@@ -0,0 +1,22 @@
+module Import
+ module OTRS
+ class DynamicField
+ class Checkbox < Import::OTRS::DynamicField
+ def init_callback(dynamic_field)
+ @attribute_config.merge!(
+ data_type: 'boolean',
+ data_option: {
+ default: dynamic_field['Config']['DefaultValue'] == '1',
+ options: {
+ true => 'Yes',
+ false => 'No',
+ },
+ null: false,
+ translate: true,
+ }
+ )
+ end
+ end
+ end
+ end
+end
diff --git a/lib/import/otrs/dynamic_field/date.rb b/lib/import/otrs/dynamic_field/date.rb
new file mode 100644
index 000000000..6be4508bd
--- /dev/null
+++ b/lib/import/otrs/dynamic_field/date.rb
@@ -0,0 +1,24 @@
+# this require is required (hehe) because of Rails autoloading
+# which causes strange behavior not inheriting correctly
+# from Import::OTRS::DynamicField
+require 'import/otrs/dynamic_field'
+
+module Import
+ module OTRS
+ class DynamicField
+ class Date < Import::OTRS::DynamicField
+ def init_callback(dynamic_field)
+ @attribute_config.merge!(
+ data_type: 'date',
+ data_option: {
+ future: dynamic_field['Config']['YearsInFuture'] != '0',
+ past: dynamic_field['Config']['YearsInPast'] != '0',
+ diff: dynamic_field['Config']['DefaultValue'].to_i / 60 / 60 / 24,
+ null: false,
+ }
+ )
+ end
+ end
+ end
+ end
+end
diff --git a/lib/import/otrs/dynamic_field/date_time.rb b/lib/import/otrs/dynamic_field/date_time.rb
new file mode 100644
index 000000000..997a4ac49
--- /dev/null
+++ b/lib/import/otrs/dynamic_field/date_time.rb
@@ -0,0 +1,24 @@
+# this require is required (hehe) because of Rails autoloading
+# which causes strange behavior not inheriting correctly
+# from Import::OTRS::DynamicField
+require 'import/otrs/dynamic_field'
+
+module Import
+ module OTRS
+ class DynamicField
+ class DateTime < Import::OTRS::DynamicField
+ def init_callback(dynamic_field)
+ @attribute_config.merge!(
+ data_type: 'datetime',
+ data_option: {
+ future: dynamic_field['Config']['YearsInFuture'] != '0',
+ past: dynamic_field['Config']['YearsInPast'] != '0',
+ diff: dynamic_field['Config']['DefaultValue'].to_i / 60 / 60,
+ null: false,
+ }
+ )
+ end
+ end
+ end
+ end
+end
diff --git a/lib/import/otrs/dynamic_field/dropdown.rb b/lib/import/otrs/dynamic_field/dropdown.rb
new file mode 100644
index 000000000..a6d0d8c5a
--- /dev/null
+++ b/lib/import/otrs/dynamic_field/dropdown.rb
@@ -0,0 +1,20 @@
+module Import
+ module OTRS
+ class DynamicField
+ class Dropdown < Import::OTRS::DynamicField
+ def init_callback(dynamic_field)
+ @attribute_config.merge!(
+ data_type: 'select',
+ data_option: {
+ default: '',
+ multiple: false,
+ options: dynamic_field['Config']['PossibleValues'],
+ null: dynamic_field['Config']['PossibleNone'] == '1',
+ translate: dynamic_field['Config']['TranslatableValues'] == '1',
+ }
+ )
+ end
+ end
+ end
+ end
+end
diff --git a/lib/import/otrs/dynamic_field/multiselect.rb b/lib/import/otrs/dynamic_field/multiselect.rb
new file mode 100644
index 000000000..3710fc7d0
--- /dev/null
+++ b/lib/import/otrs/dynamic_field/multiselect.rb
@@ -0,0 +1,20 @@
+module Import
+ module OTRS
+ class DynamicField
+ class Multiselect < Import::OTRS::DynamicField
+ def init_callback(dynamic_field)
+ @attribute_config.merge!(
+ data_type: 'select',
+ data_option: {
+ default: '',
+ multiple: true,
+ options: dynamic_field['Config']['PossibleValues'],
+ null: dynamic_field['Config']['PossibleNone'] == '1',
+ translate: dynamic_field['Config']['TranslatableValues'] == '1',
+ }
+ )
+ end
+ end
+ end
+ end
+end
diff --git a/lib/import/otrs/dynamic_field/text.rb b/lib/import/otrs/dynamic_field/text.rb
new file mode 100644
index 000000000..cafcb7f41
--- /dev/null
+++ b/lib/import/otrs/dynamic_field/text.rb
@@ -0,0 +1,19 @@
+module Import
+ module OTRS
+ class DynamicField
+ class Text < Import::OTRS::DynamicField
+ def init_callback(dynamic_field)
+ @attribute_config.merge!(
+ data_type: 'input',
+ data_option: {
+ default: dynamic_field['Config']['DefaultValue'],
+ type: 'text',
+ maxlength: 255,
+ null: false,
+ }
+ )
+ end
+ end
+ end
+ end
+end
diff --git a/lib/import/otrs/dynamic_field/text_area.rb b/lib/import/otrs/dynamic_field/text_area.rb
new file mode 100644
index 000000000..e4b71ce72
--- /dev/null
+++ b/lib/import/otrs/dynamic_field/text_area.rb
@@ -0,0 +1,18 @@
+module Import
+ module OTRS
+ class DynamicField
+ class TextArea < Import::OTRS::DynamicField
+ def init_callback(dynamic_field)
+ @attribute_config.merge!(
+ data_type: 'textarea',
+ data_option: {
+ default: dynamic_field['Config']['DefaultValue'],
+ rows: dynamic_field['Config']['Rows'],
+ null: false,
+ }
+ )
+ end
+ end
+ end
+ end
+end
diff --git a/lib/import/otrs/dynamic_field_factory.rb b/lib/import/otrs/dynamic_field_factory.rb
new file mode 100644
index 000000000..f6bbf350e
--- /dev/null
+++ b/lib/import/otrs/dynamic_field_factory.rb
@@ -0,0 +1,52 @@
+module Import
+ module OTRS
+ module DynamicFieldFactory
+ extend Import::Factory
+ extend Import::Helper
+
+ # rubocop:disable Style/ModuleFunction
+ extend self
+
+ def skip?(record)
+ return true if !importable?(record)
+ return true if skip_field?(record['Name'])
+ false
+ end
+
+ def backend_class(record)
+ "Import::OTRS::DynamicField::#{record['FieldType']}".constantize
+ end
+
+ def skip_field?(dynamic_field_name)
+ skip_fields.include?(dynamic_field_name)
+ end
+
+ private
+
+ def importable?(dynamic_field)
+ return false if !supported_object_type?(dynamic_field)
+ supported_field_type?(dynamic_field)
+ end
+
+ def supported_object_type?(dynamic_field)
+ return true if dynamic_field['ObjectType'] == 'Ticket'
+ log "ERROR: Unsupported dynamic field object type '#{dynamic_field['ObjectType']}' for dynamic field '#{dynamic_field['Name']}'"
+ false
+ end
+
+ def supported_field_type?(dynamic_field)
+ return true if supported_field_types.include?(dynamic_field['FieldType'])
+ log "ERROR: Unsupported dynamic field field type '#{dynamic_field['FieldType']}' for dynamic field '#{dynamic_field['Name']}'"
+ false
+ end
+
+ def supported_field_types
+ %w(Text TextArea Checkbox DateTime Date Dropdown Multiselect)
+ end
+
+ def skip_fields
+ %w(ProcessManagementProcessID ProcessManagementActivityID ZammadMigratorChanged ZammadMigratorChangedOld)
+ end
+ end
+ end
+end
diff --git a/lib/import/otrs/helper.rb b/lib/import/otrs/helper.rb
new file mode 100644
index 000000000..107b74956
--- /dev/null
+++ b/lib/import/otrs/helper.rb
@@ -0,0 +1,37 @@
+module Import
+ module OTRS
+ module Helper
+ # rubocop:disable Style/ModuleFunction
+ extend self
+
+ private
+
+ def from_mapping(record)
+ result = {}
+ # use the mapping of the class in which
+ # this module gets extended
+ self.class::MAPPING.each { |key_sym, value|
+ key = key_sym.to_s
+ next if !record.key?(key)
+ result[value] = record[key]
+ }
+ result
+ end
+
+ def active?(record)
+ case record['ValidID'].to_s
+ when '3'
+ false
+ when '2'
+ false
+ when '1'
+ true
+ when '0'
+ false
+ else
+ true
+ end
+ end
+ end
+ end
+end
diff --git a/lib/import/otrs/history.rb b/lib/import/otrs/history.rb
new file mode 100644
index 000000000..d1ef84cef
--- /dev/null
+++ b/lib/import/otrs/history.rb
@@ -0,0 +1,46 @@
+# rubocop:disable Style/ClassVars
+module Import
+ module OTRS
+ class History
+
+ def initialize(history)
+ init_callback(history)
+ ensure_history_attribute
+ add
+ end
+
+ def init_callback(_)
+ raise 'No init callback defined for this history!'
+ end
+
+ private
+
+ def add
+ ::History.add(@history_attributes)
+ end
+
+ # make sure that no other thread is importing just the same
+ # history attribute which causes a ActiveRecord::RecordNotUnique
+ # exception we (currently) can't handle otherwise
+ def ensure_history_attribute
+ history_attribute = @history_attributes[:history_attribute]
+ return if !history_attribute
+ return if history_attribute_exists?(history_attribute)
+ @@created_history_attributes[history_attribute] = true
+ ::History.attribute_lookup(history_attribute)
+ end
+
+ def history_attribute_exists?(name)
+ @@created_history_attributes ||= {}
+ return false if !@@created_history_attributes[name]
+
+ # make sure the history attribute is added before we
+ # we perform further import
+ # otherwise the following import logic (add) will
+ # try to add the history attribute, too
+ sleep 1 until ::History::Attribute.find_by(name: name)
+ true
+ end
+ end
+ end
+end
diff --git a/lib/import/otrs/history/article.rb b/lib/import/otrs/history/article.rb
new file mode 100644
index 000000000..4145f7987
--- /dev/null
+++ b/lib/import/otrs/history/article.rb
@@ -0,0 +1,20 @@
+module Import
+ module OTRS
+ class History
+ class Article < Import::OTRS::History
+ def init_callback(history)
+ @history_attributes = {
+ id: history['HistoryID'],
+ o_id: history['ArticleID'],
+ history_type: 'created',
+ history_object: 'Ticket::Article',
+ related_o_id: history['TicketID'],
+ related_history_object: 'Ticket',
+ created_at: history['CreateTime'],
+ created_by_id: history['CreateBy']
+ }
+ end
+ end
+ end
+ end
+end
diff --git a/lib/import/otrs/history/move.rb b/lib/import/otrs/history/move.rb
new file mode 100644
index 000000000..b328e875e
--- /dev/null
+++ b/lib/import/otrs/history/move.rb
@@ -0,0 +1,33 @@
+module Import
+ module OTRS
+ class History
+ class Move < Import::OTRS::History
+ def init_callback(history)
+ data = history['Name']
+ # "%%Queue1%%5%%Postmaster%%1"
+ from = nil
+ to = nil
+ if data =~ /%%(.+?)%%(.+?)%%(.+?)%%(.+?)$/
+ from = $1
+ from_id = $2
+ to = $3
+ to_id = $4
+ end
+ @history_attributes = {
+ id: history['HistoryID'],
+ o_id: history['TicketID'],
+ history_type: 'updated',
+ history_object: 'Ticket',
+ history_attribute: 'group',
+ value_from: from,
+ value_to: to,
+ id_from: from_id,
+ id_to: to_id,
+ created_at: history['CreateTime'],
+ created_by_id: history['CreateBy']
+ }
+ end
+ end
+ end
+ end
+end
diff --git a/lib/import/otrs/history/new_ticket.rb b/lib/import/otrs/history/new_ticket.rb
new file mode 100644
index 000000000..5459f6ba2
--- /dev/null
+++ b/lib/import/otrs/history/new_ticket.rb
@@ -0,0 +1,18 @@
+module Import
+ module OTRS
+ class History
+ class NewTicket < Import::OTRS::History
+ def init_callback(history)
+ @history_attributes = {
+ id: history['HistoryID'],
+ o_id: history['TicketID'],
+ history_type: 'created',
+ history_object: 'Ticket',
+ created_at: history['CreateTime'],
+ created_by_id: history['CreateBy']
+ }
+ end
+ end
+ end
+ end
+end
diff --git a/lib/import/otrs/history/priority_update.rb b/lib/import/otrs/history/priority_update.rb
new file mode 100644
index 000000000..4659c148a
--- /dev/null
+++ b/lib/import/otrs/history/priority_update.rb
@@ -0,0 +1,33 @@
+module Import
+ module OTRS
+ class History
+ class PriorityUpdate < Import::OTRS::History
+ def init_callback(history)
+ data = history['Name']
+ # "%%3 normal%%3%%5 very high%%5"
+ from = nil
+ to = nil
+ if data =~ /%%(.+?)%%(.+?)%%(.+?)%%(.+?)$/
+ from = $1
+ from_id = $2
+ to = $3
+ to_id = $4
+ end
+ @history_attributes = {
+ id: history['HistoryID'],
+ o_id: history['TicketID'],
+ history_type: 'updated',
+ history_object: 'Ticket',
+ history_attribute: 'priority',
+ value_from: from,
+ value_to: to,
+ id_from: from_id,
+ id_to: to_id,
+ created_at: history['CreateTime'],
+ created_by_id: history['CreateBy']
+ }
+ end
+ end
+ end
+ end
+end
diff --git a/lib/import/otrs/history/state_update.rb b/lib/import/otrs/history/state_update.rb
new file mode 100644
index 000000000..a1b7ce280
--- /dev/null
+++ b/lib/import/otrs/history/state_update.rb
@@ -0,0 +1,39 @@
+module Import
+ module OTRS
+ class History
+ class StateUpdate < Import::OTRS::History
+ def init_callback(history)
+ data = history['Name']
+ # "%%new%%open%%"
+ from = nil
+ to = nil
+ if data =~ /%%(.+?)%%(.+?)%%/
+ from = $1
+ to = $2
+ state_from = ::Ticket::State.lookup(name: from)
+ state_to = ::Ticket::State.lookup(name: to)
+ if state_from
+ from_id = state_from.id
+ end
+ if state_to
+ to_id = state_to.id
+ end
+ end
+ @history_attributes = {
+ id: history['HistoryID'],
+ o_id: history['TicketID'],
+ history_type: 'updated',
+ history_object: 'Ticket',
+ history_attribute: 'state',
+ value_from: from,
+ id_from: from_id,
+ value_to: to,
+ id_to: to_id,
+ created_at: history['CreateTime'],
+ created_by_id: history['CreateBy']
+ }
+ end
+ end
+ end
+ end
+end
diff --git a/lib/import/otrs/history_factory.rb b/lib/import/otrs/history_factory.rb
new file mode 100644
index 000000000..3d91b66e9
--- /dev/null
+++ b/lib/import/otrs/history_factory.rb
@@ -0,0 +1,40 @@
+module Import
+ module OTRS
+ module HistoryFactory
+ extend Import::Factory
+
+ # rubocop:disable Style/ModuleFunction
+ extend self
+
+ def skip?(record)
+ return true if !determine_class(record)
+ false
+ end
+
+ def backend_class(record)
+ "Import::OTRS::History::#{determine_class(record)}".constantize
+ end
+
+ private
+
+ def determine_class(history)
+ check_supported(history) || check_article(history)
+ end
+
+ def supported_types
+ %w(NewTicket StateUpdate Move PriorityUpdate)
+ end
+
+ def check_supported(history)
+ return if !supported_types.include?(history['HistoryType'])
+ history['HistoryType']
+ end
+
+ def check_article(history)
+ return if !history['ArticleID']
+ return if history['ArticleID'].to_i.zero?
+ 'Article'
+ end
+ end
+ end
+end
diff --git a/lib/import/otrs/import_stats.rb b/lib/import/otrs/import_stats.rb
new file mode 100644
index 000000000..e1c437501
--- /dev/null
+++ b/lib/import/otrs/import_stats.rb
@@ -0,0 +1,74 @@
+module Import
+ module OTRS
+ module ImportStats
+ # rubocop:disable Style/ModuleFunction
+ extend self
+
+ def current_state
+ {
+ Base: {
+ done: base_done,
+ total: base_total,
+ },
+ User: {
+ done: user_done,
+ total: user_total,
+ },
+ Ticket: {
+ done: ticket_done,
+ total: ticket_total,
+ },
+ }
+ end
+
+ private
+
+ def statistic
+
+ # check cache
+ cache = Cache.get('import_otrs_stats')
+ return cache if cache
+
+ # retrive statistic
+ statistic = Import::OTRS::Requester.list
+ return statistic if !statistic
+
+ Cache.write('import_otrs_stats', statistic)
+ statistic
+ end
+
+ def base_done
+ ::Group.count + ::Ticket::State.count + ::Ticket::Priority.count
+ end
+
+ def base_total
+ sum_stat(%w(Queue State Priority))
+ end
+
+ def user_done
+ ::User.count
+ end
+
+ def user_total
+ sum_stat(%w(User CustomerUser))
+ end
+
+ def ticket_done
+ ::Ticket.count
+ end
+
+ def ticket_total
+ sum_stat(%w(Ticket))
+ end
+
+ def sum_stat(objects)
+ data = statistic
+ sum = 0
+ objects.each { |object|
+ sum += data[object]
+ }
+ sum
+ end
+ end
+ end
+end
diff --git a/lib/import/otrs/priority.rb b/lib/import/otrs/priority.rb
new file mode 100644
index 000000000..8882a7ad9
--- /dev/null
+++ b/lib/import/otrs/priority.rb
@@ -0,0 +1,58 @@
+module Import
+ module OTRS
+ class Priority
+ include Import::Helper
+ include Import::OTRS::Helper
+
+ MAPPING = {
+ ChangeTime: :updated_at,
+ CreateTime: :created_at,
+ CreateBy: :created_by_id,
+ ChangeBy: :updated_by_id,
+ Name: :name,
+ ID: :id,
+ Comment: :note,
+ }.freeze
+
+ def initialize(priority)
+ import(priority)
+ end
+
+ private
+
+ def import(priority)
+ create_or_update(map(priority))
+ end
+
+ def create_or_update(priority)
+ return if updated?(priority)
+ create(priority)
+ end
+
+ def updated?(priority)
+ @local_priority = ::Ticket::Priority.find_by(id: priority[:id])
+ return false if !@local_priority
+ log "update Ticket::Priority.find_by(id: #{priority[:id]})"
+ @local_priority.update_attributes(priority)
+ true
+ end
+
+ def create(priority)
+ log "add Ticket::Priority.find_by(id: #{priority[:id]})"
+ @local_priority = ::Ticket::Priority.new(priority)
+ @local_priority.id = priority[:id]
+ @local_priority.save
+ reset_primary_key_sequence('ticket_priorities')
+ end
+
+ def map(priority)
+ {
+ created_by_id: 1,
+ updated_by_id: 1,
+ active: active?(priority),
+ }
+ .merge(from_mapping(priority))
+ end
+ end
+ end
+end
diff --git a/lib/import/otrs/priority_factory.rb b/lib/import/otrs/priority_factory.rb
new file mode 100644
index 000000000..ebd405775
--- /dev/null
+++ b/lib/import/otrs/priority_factory.rb
@@ -0,0 +1,7 @@
+module Import
+ module OTRS
+ module PriorityFactory
+ extend Import::Factory
+ end
+ end
+end
diff --git a/lib/import/otrs/queue.rb b/lib/import/otrs/queue.rb
new file mode 100644
index 000000000..a00e4531a
--- /dev/null
+++ b/lib/import/otrs/queue.rb
@@ -0,0 +1,58 @@
+module Import
+ module OTRS
+ class Queue
+ include Import::Helper
+ include Import::OTRS::Helper
+
+ MAPPING = {
+ ChangeTime: :updated_at,
+ CreateTime: :created_at,
+ CreateBy: :created_by_id,
+ ChangeBy: :updated_by_id,
+ Name: :name,
+ QueueID: :id,
+ Comment: :note,
+ }.freeze
+
+ def initialize(queue)
+ import(queue)
+ end
+
+ private
+
+ def import(queue)
+ create_or_update(map(queue))
+ end
+
+ def create_or_update(queue)
+ return if updated?(queue)
+ create(queue)
+ end
+
+ def updated?(queue)
+ @local_queue = Group.find_by(id: queue[:id])
+ return false if !@local_queue
+ log "update Group.find_by(id: #{queue[:id]})"
+ @local_queue.update_attributes(queue)
+ true
+ end
+
+ def create(queue)
+ log "add Group.find_by(id: #{queue[:id]})"
+ @local_queue = Group.new(queue)
+ @local_queue.id = queue[:id]
+ @local_queue.save
+ reset_primary_key_sequence('groups')
+ end
+
+ def map(queue)
+ {
+ created_by_id: 1,
+ updated_by_id: 1,
+ active: active?(queue),
+ }
+ .merge(from_mapping(queue))
+ end
+ end
+ end
+end
diff --git a/lib/import/otrs/queue_factory.rb b/lib/import/otrs/queue_factory.rb
new file mode 100644
index 000000000..b2ec3ba35
--- /dev/null
+++ b/lib/import/otrs/queue_factory.rb
@@ -0,0 +1,7 @@
+module Import
+ module OTRS
+ module QueueFactory
+ extend Import::Factory
+ end
+ end
+end
diff --git a/lib/import/otrs/requester.rb b/lib/import/otrs/requester.rb
new file mode 100644
index 000000000..69b1d4fb2
--- /dev/null
+++ b/lib/import/otrs/requester.rb
@@ -0,0 +1,93 @@
+module Import
+ module OTRS
+ module Requester
+ extend Import::Helper
+
+ # rubocop:disable Style/ModuleFunction
+ extend self
+
+ def load(object, args = {})
+
+ @cache ||= {}
+ if args.empty? && @cache[object]
+ return @cache[object]
+ end
+
+ result = request_result(
+ Subaction: 'Export',
+ Object: object,
+ Limit: args[:limit] || '',
+ Offset: args[:offset] || '',
+ Diff: args[:diff] ? 1 : 0
+ )
+
+ return result if !args.empty?
+ @cache[object] = result
+ @cache[object]
+ end
+
+ def list
+ request_result(Subaction: 'List')
+ end
+
+ # TODO: refactor to something like .connected?
+ def connection_test
+ result = request_json({})
+ return true if result['Success']
+ raise 'API key not valid!'
+ end
+
+ private
+
+ def request_result(params)
+ response = request_json(params)
+ response['Result']
+ end
+
+ def request_json(params)
+ response = post(params)
+ result = handle_response(response)
+
+ return result if result
+
+ raise 'Invalid response'
+ end
+
+ def handle_response(response)
+ encoded_body = Encode.conv('utf8', response.body.to_s)
+ JSON.parse(encoded_body)
+ end
+
+ def post(params)
+ url = Setting.get('import_otrs_endpoint')
+ params[:Action] = 'ZammadMigrator'
+ params[:Key] = Setting.get('import_otrs_endpoint_key')
+
+ log 'POST: ' + url
+ log 'PARAMS: ' + params.inspect
+
+ response = UserAgent.post(
+ url,
+ params,
+ {
+ open_timeout: 10,
+ read_timeout: 120,
+ total_timeout: 360,
+ user: Setting.get('import_otrs_user'),
+ password: Setting.get('import_otrs_password'),
+ },
+ )
+
+ if !response
+ raise "Can't connect to Zammad Migrator"
+ end
+
+ if !response.success?
+ log "ERROR: #{response.error}"
+ raise 'Zammad Migrator returned an error'
+ end
+ response
+ end
+ end
+ end
+end
diff --git a/lib/import/otrs/state.rb b/lib/import/otrs/state.rb
new file mode 100644
index 000000000..3841e441e
--- /dev/null
+++ b/lib/import/otrs/state.rb
@@ -0,0 +1,70 @@
+module Import
+ module OTRS
+ class State
+ include Import::Helper
+ include Import::OTRS::Helper
+
+ MAPPING = {
+ ChangeTime: :updated_at,
+ CreateTime: :created_at,
+ CreateBy: :created_by_id,
+ ChangeBy: :updated_by_id,
+ Name: :name,
+ ID: :id,
+ ValidID: :active,
+ Comment: :note,
+ }.freeze
+
+ def initialize(state)
+ import(state)
+ end
+
+ private
+
+ def import(state)
+ create_or_update(map(state))
+ end
+
+ def create_or_update(state)
+ return if updated?(state)
+ create(state)
+ end
+
+ def updated?(state)
+ @local_state = ::Ticket::State.find_by(id: state[:id])
+ return false if !@local_state
+ log "update Ticket::State.find_by(id: #{state[:id]})"
+ @local_state.update_attributes(state)
+ true
+ end
+
+ def create(state)
+ log "add Ticket::State.find_by(id: #{state[:id]})"
+ @local_state = ::Ticket::State.new(state)
+ @local_state.id = state[:id]
+ @local_state.save
+ reset_primary_key_sequence('ticket_states')
+ end
+
+ def map(state)
+ {
+ created_by_id: 1,
+ updated_by_id: 1,
+ active: active?(state),
+ state_type_id: state_type_id(state)
+ }
+ .merge(from_mapping(state))
+ end
+
+ def state_type_id(state)
+ map_type(state)
+ ::Ticket::StateType.lookup(name: state['TypeName']).id
+ end
+
+ def map_type(state)
+ return if state['TypeName'] != 'pending auto'
+ state['TypeName'] = 'pending action'
+ end
+ end
+ end
+end
diff --git a/lib/import/otrs/state_factory.rb b/lib/import/otrs/state_factory.rb
new file mode 100644
index 000000000..40b85c8b1
--- /dev/null
+++ b/lib/import/otrs/state_factory.rb
@@ -0,0 +1,22 @@
+module Import
+ module OTRS
+ module StateFactory
+ extend Import::TransactionFactory
+
+ # rubocop:disable Style/ModuleFunction
+ extend self
+
+ def pre_import_hook(_records)
+ backup
+ end
+
+ def backup
+ # rename states to handle not uniq issues
+ ::Ticket::State.all.each { |state|
+ state.name = state.name + '_tmp'
+ state.save
+ }
+ end
+ end
+ end
+end
diff --git a/lib/import/otrs/sys_config_factory.rb b/lib/import/otrs/sys_config_factory.rb
new file mode 100644
index 000000000..da118a481
--- /dev/null
+++ b/lib/import/otrs/sys_config_factory.rb
@@ -0,0 +1,51 @@
+module Import
+ module OTRS
+ module SysConfigFactory
+
+ # rubocop:disable Style/ModuleFunction
+ extend self
+
+ def import(settings)
+ settings.each do |setting|
+ next if direct_copy?(setting)
+ next if number_generator?(setting)
+ end
+ end
+
+ private
+
+ def direct_settings
+ %w(HttpType SystemID Organization TicketHook)
+ end
+
+ def direct_copy?(setting)
+ cleaned_name = cleanup_name(setting['Key'])
+ return false if !direct_settings.include?(cleaned_name)
+
+ internal_name = cleaned_name.underscore
+ Setting.set(internal_name, setting['Value'])
+
+ true
+ end
+
+ def cleanup_name(key)
+ key.tr('::', '')
+ end
+
+ def number_generator?(setting)
+ return false if setting['Key'] != 'Ticket::NumberGenerator'
+
+ case setting['Value']
+ when 'Kernel::System::Ticket::Number::DateChecksum'
+ Setting.set('ticket_number', 'Ticket::Number::Date')
+ Setting.set('ticket_number_date', { checksum: true })
+ when 'Kernel::System::Ticket::Number::Date'
+ Setting.set('ticket_number', 'Ticket::Number::Date')
+ Setting.set('ticket_number_date', { checksum: false })
+ end
+
+ true
+ end
+ end
+ end
+end
diff --git a/lib/import/otrs/ticket.rb b/lib/import/otrs/ticket.rb
new file mode 100644
index 000000000..605b89f51
--- /dev/null
+++ b/lib/import/otrs/ticket.rb
@@ -0,0 +1,172 @@
+require 'ticket'
+
+module Import
+ module OTRS
+ class Ticket
+ include Import::Helper
+ include Import::OTRS::Helper
+
+ MAPPING = {
+ Changed: :updated_at,
+ Created: :created_at,
+ CreateBy: :created_by_id,
+ TicketNumber: :number,
+ QueueID: :group_id,
+ StateID: :state_id,
+ PriorityID: :priority_id,
+ Title: :title,
+ TicketID: :id,
+ FirstResponse: :first_response_at,
+ #FirstResponseTimeDestinationDate: :first_response_escalation_at,
+ #FirstResponseInMin: :first_response_in_min,
+ #FirstResponseDiffInMin: :first_response_diff_in_min,
+ Closed: :close_at,
+ #SoltutionTimeDestinationDate: :close_escalation_at,
+ #CloseTimeInMin: :close_in_min,
+ #CloseTimeDiffInMin: :close_diff_in_min,
+ }.freeze
+
+ def initialize(ticket)
+ fix(ticket)
+ import(ticket)
+ end
+
+ private
+
+ def import(ticket)
+ Import::OTRS::ArticleCustomerFactory.import(ticket['Articles'])
+
+ create_or_update(map(ticket))
+
+ Import::OTRS::ArticleFactory.import(ticket['Articles'])
+ Import::OTRS::HistoryFactory.import(ticket['History'])
+ end
+
+ def create_or_update(ticket)
+ return if updated?(ticket)
+ create(ticket)
+ end
+
+ def updated?(ticket)
+ @local_ticket = ::Ticket.find_by(id: ticket[:id])
+ return false if !@local_ticket
+ log "update Ticket.find_by(id: #{ticket[:id]})"
+ @local_ticket.update_attributes(ticket)
+ true
+ end
+
+ def create(ticket)
+ log "add Ticket.find_by(id: #{ticket[:id]})"
+ @local_ticket = ::Ticket.new(ticket)
+ @local_ticket.id = ticket[:id]
+ @local_ticket.save
+ reset_primary_key_sequence('tickets')
+ rescue ActiveRecord::RecordNotUnique
+ log "Ticket #{ticket[:id]} is handled by another thead, skipping."
+ end
+
+ def map(ticket)
+ ensure_map(default_map(ticket))
+ end
+
+ def ensure_map(mapped)
+ return mapped if mapped[:title]
+ mapped[:title] = '**EMPTY**'
+ mapped
+ end
+
+ def default_map(ticket)
+ {
+ owner_id: owner_id(ticket),
+ customer_id: customer_id(ticket),
+ created_by_id: 1,
+ updated_by_id: 1,
+ }
+ .merge(from_mapping(ticket))
+ .merge(dynamic_fields(ticket))
+ end
+
+ def dynamic_fields(ticket)
+ result = {}
+ ticket.keys.each { |key|
+
+ key_string = key.to_s
+
+ next if !key_string.start_with?('DynamicField_')
+ dynamic_field_name = key_string[13, key_string.length]
+
+ next if Import::OTRS::DynamicFieldFactory.skip_field?( dynamic_field_name )
+ dynamic_field_name = Import::OTRS::DynamicField.convert_name(dynamic_field_name)
+
+ result[dynamic_field_name.to_sym] = ticket[key_string]
+ }
+ result
+ end
+
+ def owner_id(ticket)
+ default = 1
+ owner = ticket['Owner']
+
+ return default if !owner
+ user = user_lookup(owner)
+
+ return user.id if user
+ default
+ end
+
+ def customer_id(ticket)
+ default = 1
+ customer = ticket['CustomerUserID']
+
+ return default if !customer
+ user = user_lookup(customer)
+
+ return user.id if user
+
+ first_customer_id = first_customer_id(ticket['Articles'])
+
+ return first_customer_id if first_customer_id
+
+ default
+ end
+
+ def user_lookup(login)
+ ::User.find_by(login: login.downcase)
+ end
+
+ def first_customer_id(articles)
+ user_id = nil
+ articles.each { |article|
+ next if article['sender'] != 'customer'
+ next if article['from'].empty?
+
+ user_id = article['created_by_id'].to_i
+ break
+ }
+ user_id
+ end
+
+ # cleanup invalid values
+ def fix(ticket)
+ utf8_encode(ticket)
+ fix_timestamps(ticket)
+ fix_close_time(ticket)
+ end
+
+ def fix_timestamps(ticket)
+ ticket.each { |key, value|
+ next if value != '0000-00-00 00:00:00'
+ ticket[key] = nil
+ }
+ end
+
+ # fix OTRS 3.1 bug, no close time if ticket is created
+ def fix_close_time(ticket)
+ return if ticket['StateType'] != 'closed'
+ return if ticket['Closed']
+ return if !ticket['Closed'].empty?
+ ticket['Closed'] = ticket['Created']
+ end
+ end
+ end
+end
diff --git a/lib/import/otrs/ticket_factory.rb b/lib/import/otrs/ticket_factory.rb
new file mode 100644
index 000000000..501b3c594
--- /dev/null
+++ b/lib/import/otrs/ticket_factory.rb
@@ -0,0 +1,7 @@
+module Import
+ module OTRS
+ module TicketFactory
+ extend Import::Factory
+ end
+ end
+end
diff --git a/lib/import/otrs/user.rb b/lib/import/otrs/user.rb
new file mode 100644
index 000000000..2ff06d478
--- /dev/null
+++ b/lib/import/otrs/user.rb
@@ -0,0 +1,167 @@
+module Import
+ module OTRS
+ class User
+ include Import::Helper
+ include Import::OTRS::Helper
+
+ MAPPING = {
+ ChangeTime: :updated_at,
+ CreateTime: :created_at,
+ CreateBy: :created_by_id,
+ ChangeBy: :updated_by_id,
+ UserID: :id,
+ Comment: :note,
+ UserEmail: :email,
+ UserFirstname: :firstname,
+ UserLastname: :lastname,
+ UserLogin: :login,
+ }.freeze
+
+ def initialize(user)
+ import(user)
+ end
+
+ private
+
+ def import(user)
+ create_or_update(map(user))
+ end
+
+ def create_or_update(user)
+ ensure_unique_login(user)
+ return if updated?(user)
+ create(user)
+ end
+
+ def updated?(user)
+ @local_user = ::User.find_by(id: user[:id])
+ return false if !@local_user
+
+ # only update roles if different (reduce sql statements)
+ if @local_user.role_ids == user[:role_ids]
+ user.delete(:role_ids)
+ end
+
+ log "update User.find_by(id: #{user[:id]})"
+ @local_user.update_attributes(user)
+ true
+ end
+
+ def create(user)
+ log "add User.find_by(id: #{user[:id]})"
+ @local_user = ::User.new(user)
+ @local_user.id = user[:id]
+ @local_user.save
+ reset_primary_key_sequence('users')
+ end
+
+ def ensure_unique_login(user)
+ user[:login] = unique_login(user)
+ end
+
+ def unique_login(user)
+ login = user[:login]
+ return login if ::User.where('login = ? AND id != ?', login.downcase, user[:id]).count.zero?
+ "#{login}_#{user[:id]}"
+ end
+
+ def map(user)
+ {
+ created_by_id: 1,
+ updated_by_id: 1,
+ active: active?(user),
+ source: 'OTRS Import',
+ role_ids: role_ids(user),
+ group_ids: group_ids(user),
+ password: password(user),
+ }
+ .merge(from_mapping(user))
+ end
+
+ def password(user)
+ return if !user['UserPw']
+ "{sha2}#{user['UserPw']}"
+ end
+
+ def group_ids(user)
+ result = []
+ queues = Import::OTRS::Requester.load('Queue')
+ queues.each { |queue|
+
+ permissions = user['GroupIDs'][ queue['GroupID'] ]
+
+ next if !permissions
+ next if !permissions.include?('rw')
+
+ result.push queue['QueueID']
+ }
+
+ # lookup by roles
+
+ # roles of user
+ # groups of roles
+ # queues of group
+
+ result
+ end
+
+ def role_ids(user)
+ local_role_ids = []
+ roles(user).each { |role|
+ role_lookup = Role.lookup(name: role)
+ next if !role_lookup
+ local_role_ids.push role_lookup.id
+ }
+ local_role_ids
+ end
+
+ def roles(user)
+ local_roles = ['Agent']
+ local_roles += groups_from_otrs_groups(user)
+ local_roles += groups_from_otrs_roles(user)
+ local_roles.uniq
+ end
+
+ def groups_from_otrs_groups(user)
+ groups = Import::OTRS::Requester.load('Group')
+ groups_from_groups(user, groups)
+ end
+
+ def groups_from_groups(user, groups)
+ result = []
+ groups.each { |group|
+ result += groups_from_otrs_group(user, group)
+ }
+ result
+ end
+
+ def groups_from_otrs_group(user, group)
+ result = []
+ return result if user['GroupIDs'].empty?
+ permissions = user['GroupIDs'][ group['ID'] ]
+
+ return result if !permissions
+
+ if group['Name'] == 'admin' && permissions.include?('rw')
+ result.push 'Admin'
+ end
+
+ return result if group['Name'] !~ /^(stats|report)/
+ return result if !( permissions.include?('ro') || permissions.include?('rw') )
+
+ result.push 'Report'
+ result
+ end
+
+ def groups_from_otrs_roles(user)
+ result = []
+ roles = Import::OTRS::Requester.load('Role')
+ roles.each { |role|
+ next if !user['RoleIDs'].include?(role['ID'])
+ result += groups_from_groups(user, role['GroupIDs'])
+ }
+ result
+ end
+ end
+ end
+end
diff --git a/lib/import/otrs/user_factory.rb b/lib/import/otrs/user_factory.rb
new file mode 100644
index 000000000..ea49ccc77
--- /dev/null
+++ b/lib/import/otrs/user_factory.rb
@@ -0,0 +1,7 @@
+module Import
+ module OTRS
+ module UserFactory
+ extend Import::Factory
+ end
+ end
+end
diff --git a/lib/import/transaction_factory.rb b/lib/import/transaction_factory.rb
new file mode 100644
index 000000000..e35771a9a
--- /dev/null
+++ b/lib/import/transaction_factory.rb
@@ -0,0 +1,18 @@
+module Import
+ module TransactionFactory
+ include Import::BaseFactory
+
+ # rubocop:disable Style/ModuleFunction
+ extend self
+
+ def import(records)
+ ActiveRecord::Base.transaction do
+ pre_import_hook(records)
+ records.each do |record|
+ next if skip?(record)
+ backend_class(record).new(record)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/import/zendesk.rb b/lib/import/zendesk.rb
index 4e9bc9ab6..815df8146 100644
--- a/lib/import/zendesk.rb
+++ b/lib/import/zendesk.rb
@@ -5,7 +5,8 @@ module Import
end
module Import::Zendesk
- module_function
+ # rubocop:disable Style/ModuleFunction
+ extend self
def start
Rails.logger.info 'Start import...'
@@ -998,8 +999,7 @@ module Import::Zendesk
# reset primary key sequences
def self._reset_pk(table)
- return if ActiveRecord::Base.connection_config[:adapter] != 'postgresql'
- ActiveRecord::Base.connection.reset_pk_sequence!(table)
+ DbHelper.import_post(table)
end
def get_custom_fields(custom_fields)
diff --git a/public/assets/chat/chat.css b/public/assets/chat/chat.css
index 774230bd4..451ca8b09 100644
--- a/public/assets/chat/chat.css
+++ b/public/assets/chat/chat.css
@@ -342,10 +342,10 @@
float: left;
width: auto;
height: auto;
- min-height: 1.4em !important;
max-height: 6em;
+ min-height: 1.4em !important;
font-family: inherit;
- line-height: inherit;
+ line-height: 1.4em;
font-size: inherit;
-webkit-appearance: none;
-moz-appearance: none;
@@ -353,6 +353,7 @@
border: none !important;
background: none;
box-shadow: none !important;
+ box-sizing: content-box;
outline: none;
resize: none;
-webkit-flex: 1;
diff --git a/public/assets/chat/chat.js b/public/assets/chat/chat.js
index ef4ce3006..dd8812265 100644
--- a/public/assets/chat/chat.js
+++ b/public/assets/chat/chat.js
@@ -1,3 +1,152 @@
+/*!
+ * ----------------------------------------------------------------------------
+ * "THE BEER-WARE LICENSE" (Revision 42):
+ * wrote this file. As long as you retain this notice you
+ * can do whatever you want with this stuff. If we meet some day, and you think
+ * this stuff is worth it, you can buy me a beer in return. Jevin O. Sewaruth
+ * ----------------------------------------------------------------------------
+ *
+ * Autogrow Textarea Plugin Version v3.0
+ * http://www.technoreply.com/autogrow-textarea-plugin-3-0
+ *
+ * THIS PLUGIN IS DELIVERD ON A PAY WHAT YOU WHANT BASIS. IF THE PLUGIN WAS USEFUL TO YOU, PLEASE CONSIDER BUYING THE PLUGIN HERE :
+ * https://sites.fastspring.com/technoreply/instant/autogrowtextareaplugin
+ *
+ * Date: October 15, 2012
+ *
+ * Zammad modification: remove overflow:hidden when maximum height is reached
+ *
+ */
+
+jQuery.fn.autoGrow = function(options) {
+ return this.each(function() {
+ var settings = jQuery.extend({
+ extraLine: true,
+ }, options);
+
+ var createMirror = function(textarea) {
+ jQuery(textarea).after('');
+ return jQuery(textarea).next('.autogrow-textarea-mirror')[0];
+ }
+
+ var sendContentToMirror = function (textarea) {
+ mirror.innerHTML = String(textarea.value)
+ .replace(/&/g, '&')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''')
+ .replace(//g, '>')
+ .replace(/ /g, ' ')
+ .replace(/\n/g, ' ') +
+ (settings.extraLine? '. .' : '')
+ ;
+
+ if (jQuery(textarea).height() != jQuery(mirror).height()) {
+ jQuery(textarea).height(jQuery(mirror).height());
+
+ var maxHeight = parseInt(jQuery(textarea).css('max-height'), 10);
+ var overflow = jQuery(mirror).height() > maxHeight ? '' : 'hidden'
+ jQuery(textarea).css('overflow', overflow);
+ }
+ }
+
+ var growTextarea = function () {
+ sendContentToMirror(this);
+ }
+
+ // Create a mirror
+ var mirror = createMirror(this);
+
+ // Style the mirror
+ mirror.style.display = 'none';
+ mirror.style.wordWrap = 'break-word';
+ mirror.style.whiteSpace = 'normal';
+ mirror.style.padding = jQuery(this).css('paddingTop') + ' ' +
+ jQuery(this).css('paddingRight') + ' ' +
+ jQuery(this).css('paddingBottom') + ' ' +
+ jQuery(this).css('paddingLeft');
+
+ mirror.style.width = jQuery(this).css('width');
+ mirror.style.fontFamily = jQuery(this).css('font-family');
+ mirror.style.fontSize = jQuery(this).css('font-size');
+ mirror.style.lineHeight = jQuery(this).css('line-height');
+ mirror.style.letterSpacing = jQuery(this).css('letter-spacing');
+ mirror.style.boxSizing = jQuery(this).css('boxSizing');
+
+ // Style the textarea
+ this.style.overflow = "hidden";
+ this.style.minHeight = this.rows+"em";
+
+ // Bind the textarea's event
+ this.onkeyup = growTextarea;
+ this.onfocus = growTextarea;
+
+ // Fire the event for text already present
+ sendContentToMirror(this);
+
+ });
+};
+if (!window.zammadChatTemplates) {
+ window.zammadChatTemplates = {};
+}
+window.zammadChatTemplates["agent"] = function (__obj) {
+ if (!__obj) __obj = {};
+ var __out = [], __capture = function(callback) {
+ var out = __out, result;
+ __out = [];
+ callback.call(this);
+ result = __out.join('');
+ __out = out;
+ return __safe(result);
+ }, __sanitize = function(value) {
+ if (value && value.ecoSafe) {
+ return value;
+ } else if (typeof value !== 'undefined' && value != null) {
+ return __escape(value);
+ } else {
+ return '';
+ }
+ }, __safe, __objSafe = __obj.safe, __escape = __obj.escape;
+ __safe = __obj.safe = function(value) {
+ if (value && value.ecoSafe) {
+ return value;
+ } else {
+ if (!(typeof value !== 'undefined' && value != null)) value = '';
+ var result = new String(value);
+ result.ecoSafe = true;
+ return result;
+ }
+ };
+ if (!__escape) {
+ __escape = __obj.escape = function(value) {
+ return ('' + value)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+ };
+ }
+ (function() {
+ (function() {
+ if (this.agent.avatar) {
+ __out.push('\n\n');
+ }
+
+ __out.push('\n\n ');
+
+ __out.push(__sanitize(this.agent.name));
+
+ __out.push('\n');
+
+ }).call(this);
+
+ }).call(__obj);
+ __obj.safe = __objSafe, __obj.escape = __escape;
+ return __out.join('');
+};
+
var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; },
slice = [].slice,
extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; },
@@ -1275,152 +1424,6 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments);
return window.ZammadChat = ZammadChat;
})(window.jQuery, window);
-/*!
- * ----------------------------------------------------------------------------
- * "THE BEER-WARE LICENSE" (Revision 42):
- * wrote this file. As long as you retain this notice you
- * can do whatever you want with this stuff. If we meet some day, and you think
- * this stuff is worth it, you can buy me a beer in return. Jevin O. Sewaruth
- * ----------------------------------------------------------------------------
- *
- * Autogrow Textarea Plugin Version v3.0
- * http://www.technoreply.com/autogrow-textarea-plugin-3-0
- *
- * THIS PLUGIN IS DELIVERD ON A PAY WHAT YOU WHANT BASIS. IF THE PLUGIN WAS USEFUL TO YOU, PLEASE CONSIDER BUYING THE PLUGIN HERE :
- * https://sites.fastspring.com/technoreply/instant/autogrowtextareaplugin
- *
- * Date: October 15, 2012
- *
- * Zammad modification: remove overflow:hidden when maximum height is reached
- *
- */
-
-jQuery.fn.autoGrow = function(options) {
- return this.each(function() {
- var settings = jQuery.extend({
- extraLine: true,
- }, options);
-
- var createMirror = function(textarea) {
- jQuery(textarea).after('');
- return jQuery(textarea).next('.autogrow-textarea-mirror')[0];
- }
-
- var sendContentToMirror = function (textarea) {
- mirror.innerHTML = String(textarea.value)
- .replace(/&/g, '&')
- .replace(/"/g, '"')
- .replace(/'/g, ''')
- .replace(//g, '>')
- .replace(/ /g, ' ')
- .replace(/\n/g, ' ') +
- (settings.extraLine? '. .' : '')
- ;
-
- if (jQuery(textarea).height() != jQuery(mirror).height()) {
- jQuery(textarea).height(jQuery(mirror).height());
-
- var maxHeight = parseInt(jQuery(textarea).css('max-height'), 10);
- var overflow = jQuery(mirror).height() > maxHeight ? '' : 'hidden'
- jQuery(textarea).css('overflow', overflow);
- }
- }
-
- var growTextarea = function () {
- sendContentToMirror(this);
- }
-
- // Create a mirror
- var mirror = createMirror(this);
-
- // Style the mirror
- mirror.style.display = 'none';
- mirror.style.wordWrap = 'break-word';
- mirror.style.whiteSpace = 'normal';
- mirror.style.padding = jQuery(this).css('paddingTop') + ' ' +
- jQuery(this).css('paddingRight') + ' ' +
- jQuery(this).css('paddingBottom') + ' ' +
- jQuery(this).css('paddingLeft');
-
- mirror.style.width = jQuery(this).css('width');
- mirror.style.fontFamily = jQuery(this).css('font-family');
- mirror.style.fontSize = jQuery(this).css('font-size');
- mirror.style.lineHeight = jQuery(this).css('line-height');
-
- // Style the textarea
- this.style.overflow = "hidden";
- this.style.minHeight = this.rows+"em";
-
- // Bind the textarea's event
- this.onkeyup = growTextarea;
-
- // Fire the event for text already present
- sendContentToMirror(this);
-
- });
-};
-if (!window.zammadChatTemplates) {
- window.zammadChatTemplates = {};
-}
-window.zammadChatTemplates["agent"] = function (__obj) {
- if (!__obj) __obj = {};
- var __out = [], __capture = function(callback) {
- var out = __out, result;
- __out = [];
- callback.call(this);
- result = __out.join('');
- __out = out;
- return __safe(result);
- }, __sanitize = function(value) {
- if (value && value.ecoSafe) {
- return value;
- } else if (typeof value !== 'undefined' && value != null) {
- return __escape(value);
- } else {
- return '';
- }
- }, __safe, __objSafe = __obj.safe, __escape = __obj.escape;
- __safe = __obj.safe = function(value) {
- if (value && value.ecoSafe) {
- return value;
- } else {
- if (!(typeof value !== 'undefined' && value != null)) value = '';
- var result = new String(value);
- result.ecoSafe = true;
- return result;
- }
- };
- if (!__escape) {
- __escape = __obj.escape = function(value) {
- return ('' + value)
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"');
- };
- }
- (function() {
- (function() {
- if (this.agent.avatar) {
- __out.push('\n\n');
- }
-
- __out.push('\n\n ');
-
- __out.push(__sanitize(this.agent.name));
-
- __out.push('\n');
-
- }).call(this);
-
- }).call(__obj);
- __obj.safe = __objSafe, __obj.escape = __escape;
- return __out.join('');
-};
-
if (!window.zammadChatTemplates) {
window.zammadChatTemplates = {};
}
diff --git a/public/assets/chat/chat.min.js b/public/assets/chat/chat.min.js
index 29cdcc3ec..9a099e7e6 100644
--- a/public/assets/chat/chat.min.js
+++ b/public/assets/chat/chat.min.js
@@ -1,2 +1,2 @@
-var bind=function(t,e){return function(){return t.apply(e,arguments)}},slice=[].slice,extend=function(t,e){function s(){this.constructor=t}for(var i in e)hasProp.call(e,i)&&(t[i]=e[i]);return s.prototype=e.prototype,t.prototype=new s,t.__super__=e.prototype,t},hasProp={}.hasOwnProperty;!function(t,e){var s,i,n,o,a,r,h,l;return l=document.getElementsByTagName("script"),r=l[l.length-1],h=r.src.match(".*://([^:/]*).*")[1],s=function(){function e(e){this.options=t.extend({},this.defaults,e),this.log=new n({debug:this.options.debug,logPrefix:this.options.logPrefix||this.logPrefix})}return e.prototype.defaults={debug:!1},e}(),n=function(){function e(e){this.log=bind(this.log,this),this.error=bind(this.error,this),this.notice=bind(this.notice,this),this.debug=bind(this.debug,this),this.options=t.extend({},this.defaults,e)}return e.prototype.defaults={debug:!1},e.prototype.debug=function(){var t;return t=1<=arguments.length?slice.call(arguments,0):[],this.options.debug?this.log("debug",t):void 0},e.prototype.notice=function(){var t;return t=1<=arguments.length?slice.call(arguments,0):[],this.log("notice",t)},e.prototype.error=function(){var t;return t=1<=arguments.length?slice.call(arguments,0):[],this.log("error",t)},e.prototype.log=function(e,s){var i,n,o,a;if(s.unshift("||"),s.unshift(e),s.unshift(this.options.logPrefix),console.log.apply(console,s),this.options.debug){for(a="",i=0,o=s.length;o>i;i++)n=s[i],a+=" ",a+="object"==typeof n?JSON.stringify(n):n&&n.toString?n.toString():n;return t(".js-chatLogDisplay").prepend("
"+a+"
")}},e}(),o=function(t){function e(t){this.stop=bind(this.stop,this),this.start=bind(this.start,this),e.__super__.constructor.call(this,t)}return extend(e,t),e.prototype.timeoutStartedAt=null,e.prototype.logPrefix="timeout",e.prototype.defaults={debug:!1,timeout:4,timeoutIntervallCheck:.5},e.prototype.start=function(){var t,e;return this.stop(),e=new Date,t=function(t){return function(){var s;return s=new Date-new Date(e.getTime()+1e3*t.options.timeout*60),t.log.debug("Timeout check for "+t.options.timeout+" minutes (left "+s/1e3+" sec.)"),0>s?void 0:(t.stop(),t.options.callback())}}(this),this.log.debug("Start timeout in "+this.options.timeout+" minutes"),this.intervallId=setInterval(t,1e3*this.options.timeoutIntervallCheck*60)},e.prototype.stop=function(){return this.intervallId?(this.log.debug("Stop timeout of "+this.options.timeout+" minutes"),clearInterval(this.intervallId)):void 0},e}(s),i=function(t){function s(t){this.ping=bind(this.ping,this),this.send=bind(this.send,this),this.reconnect=bind(this.reconnect,this),this.close=bind(this.close,this),this.connect=bind(this.connect,this),this.set=bind(this.set,this),s.__super__.constructor.call(this,t)}return extend(s,t),s.prototype.logPrefix="io",s.prototype.set=function(t){var e,s,i;s=[];for(e in t)i=t[e],s.push(this.options[e]=i);return s},s.prototype.connect=function(){return this.log.debug("Connecting to "+this.options.host),this.ws=new e.WebSocket(""+this.options.host),this.ws.onopen=function(t){return function(e){return t.log.debug("onOpen",e),t.options.onOpen(e),t.ping()}}(this),this.ws.onmessage=function(t){return function(e){var s,i,n,o;for(o=JSON.parse(e.data),t.log.debug("onMessage",e.data),s=0,i=o.length;i>s;s++)n=o[s],"pong"===n.event&&t.ping();return t.options.onMessage?t.options.onMessage(o):void 0}}(this),this.ws.onclose=function(t){return function(e){if(t.log.debug("close websocket connection",e),t.pingDelayId&&clearTimeout(t.pingDelayId),t.manualClose){if(t.log.debug("manual close, onClose callback"),t.manualClose=!1,t.options.onClose)return t.options.onClose(e)}else if(t.log.debug("error close, onError callback"),t.options.onError)return t.options.onError("Connection lost...")}}(this),this.ws.onerror=function(t){return function(e){return t.log.debug("onError",e),t.options.onError?t.options.onError(e):void 0}}(this)},s.prototype.close=function(){return this.log.debug("close websocket manually"),this.manualClose=!0,this.ws.close()},s.prototype.reconnect=function(){return this.log.debug("reconnect"),this.close(),this.connect()},s.prototype.send=function(t,e){var s;return null==e&&(e={}),this.log.debug("send",t,e),s=JSON.stringify({event:t,data:e}),this.ws.send(s)},s.prototype.ping=function(){var t;return t=function(t){return function(){return t.send("ping")}}(this),this.pingDelayId=setTimeout(t,29e3)},s}(s),a=function(s){function n(s){return this.startTimeoutObservers=bind(this.startTimeoutObservers,this),this.onCssLoaded=bind(this.onCssLoaded,this),this.setAgentOnlineState=bind(this.setAgentOnlineState,this),this.onConnectionEstablished=bind(this.onConnectionEstablished,this),this.setSessionId=bind(this.setSessionId,this),this.onConnectionReestablished=bind(this.onConnectionReestablished,this),this.reconnect=bind(this.reconnect,this),this.destroy=bind(this.destroy,this),this.onScrollHintClick=bind(this.onScrollHintClick,this),this.detectScrolledtoBottom=bind(this.detectScrolledtoBottom,this),this.onLeaveTemporary=bind(this.onLeaveTemporary,this),this.onAgentTypingEnd=bind(this.onAgentTypingEnd,this),this.onAgentTypingStart=bind(this.onAgentTypingStart,this),this.onQueue=bind(this.onQueue,this),this.onQueueScreen=bind(this.onQueueScreen,this),this.onWebSocketClose=bind(this.onWebSocketClose,this),this.onCloseAnimationEnd=bind(this.onCloseAnimationEnd,this),this.close=bind(this.close,this),this.toggle=bind(this.toggle,this),this.sessionClose=bind(this.sessionClose,this),this.onOpenAnimationEnd=bind(this.onOpenAnimationEnd,this),this.open=bind(this.open,this),this.renderMessage=bind(this.renderMessage,this),this.receiveMessage=bind(this.receiveMessage,this),this.onSubmit=bind(this.onSubmit,this),this.onFocus=bind(this.onFocus,this),this.onInput=bind(this.onInput,this),this.onReopenSession=bind(this.onReopenSession,this),this.onError=bind(this.onError,this),this.onWebSocketMessage=bind(this.onWebSocketMessage,this),this.send=bind(this.send,this),this.checkForEnter=bind(this.checkForEnter,this),this.render=bind(this.render,this),this.view=bind(this.view,this),this.T=bind(this.T,this),this.options=t.extend({},this.defaults,s),n.__super__.constructor.call(this,this.options),this.isFullscreen=e.matchMedia&&e.matchMedia("(max-width: 768px)").matches,this.scrollRoot=t(this.getScrollRoot()),t?e.WebSocket&&sessionStorage?this.options.chatId?(this.options.lang||(this.options.lang=t("html").attr("lang")),this.options.lang&&(this.options.lang=this.options.lang.replace(/-.+?$/,""),this.log.debug("lang: "+this.options.lang)),this.options.host||this.detectHost(),this.loadCss(),this.io=new i(this.options),this.io.set({onOpen:this.render,onClose:this.onWebSocketClose,onMessage:this.onWebSocketMessage,onError:this.onError}),void this.io.connect()):(this.state="unsupported",void this.log.error("Chat: need chatId as option!")):(this.state="unsupported",void this.log.notice("Chat: Browser not supported!")):(this.state="unsupported",void this.log.notice("Chat: no jquery found!"))}return extend(n,s),n.prototype.defaults={chatId:void 0,show:!0,target:t("body"),host:"",debug:!1,flat:!1,lang:void 0,cssAutoload:!0,cssUrl:void 0,fontSize:void 0,buttonClass:"open-zammad-chat",inactiveClass:"is-inactive",title:"Chat with us!",scrollHint:"Scrolle nach unten um neue Nachrichten zu sehen",idleTimeout:6,idleTimeoutIntervallCheck:.5,inactiveTimeout:8,inactiveTimeoutIntervallCheck:.5,waitingListTimeout:4,waitingListTimeoutIntervallCheck:.5},n.prototype.logPrefix="chat",n.prototype._messageCount=0,n.prototype.isOpen=!1,n.prototype.blinkOnlineInterval=null,n.prototype.stopBlinOnlineStateTimeout=null,n.prototype.showTimeEveryXMinutes=2,n.prototype.lastTimestamp=null,n.prototype.lastAddedType=null,n.prototype.inputTimeout=null,n.prototype.isTyping=!1,n.prototype.state="offline",n.prototype.initialQueueDelay=1e4,n.prototype.translations={de:{"Chat with us!":"Chatte mit uns!","Scroll down to see new messages":"Scrolle nach unten um neue Nachrichten zu sehen",Online:"Online",Online:"Online",Offline:"Offline",Connecting:"Verbinden","Connection re-established":"Verbindung wiederhergestellt",Today:"Heute",Send:"Senden","Compose your message...":"Ihre Nachricht...","All colleagues are busy.":"Alle Kollegen sind belegt.","You are on waiting list position %s.":"Sie sind in der Warteliste an der Position %s.","Start new conversation":"Neue Konversation starten","Since you didn't respond in the last %s minutes your conversation with %s got closed.":"Da Sie in den letzten %s Minuten nichts geschrieben haben wurde Ihre Konversation mit %s geschlossen.","Since you didn't respond in the last %s minutes your conversation got closed.":"Da Sie in den letzten %s Minuten nichts geschrieben haben wurde Ihre Konversation geschlossen."}},n.prototype.sessionId=void 0,n.prototype.scrolledToBottom=!0,n.prototype.scrollSnapTolerance=10,n.prototype.T=function(){var t,e,s,i,n,o;if(n=arguments[0],s=2<=arguments.length?slice.call(arguments,1):[],this.options.lang&&"en"!==this.options.lang&&(this.translations[this.options.lang]?(o=this.translations[this.options.lang],o[n]||this.log.notice("Translation needed for '"+n+"'"),n=o[n]||n):this.log.notice("Translation '"+this.options.lang+"' needed!")),s)for(t=0,i=s.length;i>t;t++)e=s[t],n=n.replace(/%s/,e);return n},n.prototype.view=function(t){return function(s){return function(i){return i||(i={}),i.T=s.T,i.background=s.options.background,i.flat=s.options.flat,i.fontSize=s.options.fontSize,e.zammadChatTemplates[t](i)}}(this)},n.prototype.getScrollRoot=function(){var t,e,s;return"scrollingElement"in document?document.scrollingElement:(e=document.documentElement,s=e.scrollTop,e.scrollTop=s+1,t=e.scrollTop,e.scrollTop=s,t>s?e:document.body)},n.prototype.render=function(){return this.el&&t(".zammad-chat").get(0)||this.renderBase(),t("."+this.options.buttonClass).addClass(this.inactiveClass),this.setAgentOnlineState("online"),this.log.debug("widget rendered"),this.startTimeoutObservers(),this.idleTimeout.start(),this.sessionId=sessionStorage.getItem("sessionId"),this.send("chat_status_customer",{session_id:this.sessionId,url:e.location.href})},n.prototype.renderBase=function(){return this.el=t(this.view("chat")({title:this.options.title,scrollHint:this.options.scrollHint})),this.options.target.append(this.el),this.input=this.el.find(".zammad-chat-input"),this.el.find(".js-chat-open").click(this.open),this.el.find(".js-chat-toggle").click(this.toggle),this.el.find(".zammad-chat-controls").on("submit",this.onSubmit),this.el.find(".zammad-chat-body").on("scroll",this.detectScrolledtoBottom),this.el.find(".zammad-scroll-hint").click(this.onScrollHintClick),this.input.on({keydown:this.checkForEnter,input:this.onInput}),t(e).on("beforeunload",function(t){return function(){return t.onLeaveTemporary()}}(this)),t(e).bind("hashchange",function(t){return function(){return t.isOpen?void(t.sessionId&&t.send("chat_session_notice",{session_id:t.sessionId,message:e.location.href})):t.idleTimeout.start()}}(this)),this.isFullscreen?this.input.on({focus:this.onFocus,focusout:this.onFocusOut}):void 0},n.prototype.checkForEnter=function(t){return t.shiftKey||13!==t.keyCode?void 0:(t.preventDefault(),this.sendMessage())},n.prototype.send=function(t,e){return null==e&&(e={}),e.chat_id=this.options.chatId,this.io.send(t,e)},n.prototype.onWebSocketMessage=function(t){var e,s,i;for(e=0,s=t.length;s>e;e++)switch(i=t[e],this.log.debug("ws:onmessage",i),i.event){case"chat_error":this.log.notice(i.data),i.data&&"chat_disabled"===i.data.state&&this.destroy({remove:!0});break;case"chat_session_message":if(i.data.self_written)return;this.receiveMessage(i.data);break;case"chat_session_typing":if(i.data.self_written)return;this.onAgentTypingStart();break;case"chat_session_start":this.onConnectionEstablished(i.data);break;case"chat_session_queue":this.onQueueScreen(i.data);break;case"chat_session_closed":this.onSessionClosed(i.data);break;case"chat_session_left":this.onSessionClosed(i.data);break;case"chat_status_customer":switch(i.data.state){case"online":this.sessionId=void 0,!this.options.cssAutoload||this.cssLoaded?this.onReady():this.socketReady=!0;break;case"offline":this.onError("Zammad Chat: No agent online");break;case"chat_disabled":this.onError("Zammad Chat: Chat is disabled");break;case"no_seats_available":this.onError("Zammad Chat: Too many clients in queue. Clients in queue: "+i.data.queue);break;case"reconnect":this.onReopenSession(i.data)}}},n.prototype.onReady=function(){return this.log.debug("widget ready for use"),t("."+this.options.buttonClass).click(this.open).removeClass(this.inactiveClass),this.options.show?this.show():void 0},n.prototype.onError=function(e){return this.log.debug(e),this.addStatus(e),t("."+this.options.buttonClass).hide(),this.isOpen?(this.disableInput(),this.destroy({remove:!1})):this.destroy({remove:!0})},n.prototype.onReopenSession=function(t){var e,s,i,n,o;if(this.log.debug("old messages",t.session),this.inactiveTimeout.start(),o=sessionStorage.getItem("unfinished_message"),t.agent){for(this.onConnectionEstablished(t),n=t.session,e=0,s=n.length;s>e;e++)i=n[e],this.renderMessage({message:i.content,id:i.id,from:i.created_by_id?"agent":"customer"});o&&this.input.val(o)}return t.position&&this.onQueue(t),this.show(),this.open(),this.scrollToBottom(),o?this.input.focus():void 0},n.prototype.onInput=function(){return this.el.find(".zammad-chat-message--unread").removeClass("zammad-chat-message--unread"),sessionStorage.setItem("unfinished_message",this.input.val()),this.onTyping()},n.prototype.onFocus=function(){var s;return t(e).scrollTop(10),s=t(e).scrollTop()>0,t(e).scrollTop(0),s?this.log.notice("virtual keyboard shown"):void 0},n.prototype.onFocusOut=function(){},n.prototype.onTyping=function(){return this.isTyping&&this.isTyping>new Date((new Date).getTime()-1500)?void 0:(this.isTyping=new Date,this.send("chat_session_typing",{session_id:this.sessionId}),this.inactiveTimeout.start())},n.prototype.onSubmit=function(t){return t.preventDefault(),this.sendMessage()},n.prototype.sendMessage=function(){var t,e;return(t=this.input.val())?(this.inactiveTimeout.start(),sessionStorage.removeItem("unfinished_message"),e=this.view("message")({message:t,from:"customer",id:this._messageCount++,unreadClass:""}),this.maybeAddTimestamp(),this.el.find(".zammad-chat-message--typing").size()?(this.lastAddedType="typing-placeholder",this.el.find(".zammad-chat-message--typing").before(e)):(this.lastAddedType="message--customer",this.el.find(".zammad-chat-body").append(e)),this.input.val(""),this.scrollToBottom(),this.send("chat_session_message",{content:t,id:this._messageCount,session_id:this.sessionId})):void 0},n.prototype.receiveMessage=function(t){return this.inactiveTimeout.start(),this.onAgentTypingEnd(),this.maybeAddTimestamp(),this.renderMessage({message:t.message.content,id:t.id,from:"agent"}),this.scrollToBottom({showHint:!0})},n.prototype.renderMessage=function(t){return this.lastAddedType="message--"+t.from,t.unreadClass=document.hidden?" zammad-chat-message--unread":"",this.el.find(".zammad-chat-body").append(this.view("message")(t))},n.prototype.open=function(){var t;return this.isOpen?void this.log.debug("widget already open, block"):(this.isOpen=!0,this.log.debug("open widget"),this.sessionId||this.showLoader(),this.el.addClass("zammad-chat-is-open"),this.inputInitialized||(this.inputInitialized=!0,this.input.autoGrow({extraLine:!1})),t=this.el.height()-this.el.find(".zammad-chat-header").outerHeight(),this.el.css("bottom",-t),this.sessionId?(this.el.css("bottom",0),this.onOpenAnimationEnd()):(this.el.animate({bottom:0},500,this.onOpenAnimationEnd),this.send("chat_session_init",{url:e.location.href})))},n.prototype.onOpenAnimationEnd=function(){return this.idleTimeout.stop(),this.isFullscreen?this.disableScrollOnRoot():void 0},n.prototype.sessionClose=function(){return this.send("chat_session_close",{session_id:this.sessionId}),this.inactiveTimeout.stop(),this.waitingListTimeout.stop(),sessionStorage.removeItem("unfinished_message"),this.onInitialQueueDelayId&&clearTimeout(this.onInitialQueueDelayId),this.setSessionId(void 0)},n.prototype.toggle=function(t){return this.isOpen?this.close(t):this.open(t)},n.prototype.close=function(t){var e;return this.isOpen?(this.initDelayId&&clearTimeout(this.initDelayId),this.sessionId?(this.log.debug("close widget"),t&&t.stopPropagation(),this.sessionClose(),this.isFullscreen&&this.enableScrollOnRoot(),e=this.el.height()-this.el.find(".zammad-chat-header").outerHeight(),this.el.animate({bottom:-e},500,this.onCloseAnimationEnd)):void this.log.debug("can't close widget without sessionId")):void this.log.debug("can't close widget, it's not open")},n.prototype.onCloseAnimationEnd=function(){return this.el.css("bottom",""),this.el.removeClass("zammad-chat-is-open"),this.showLoader(),this.el.find(".zammad-chat-welcome").removeClass("zammad-chat-is-hidden"),this.el.find(".zammad-chat-agent").addClass("zammad-chat-is-hidden"),this.el.find(".zammad-chat-agent-status").addClass("zammad-chat-is-hidden"),this.isOpen=!1,this.io.reconnect()},n.prototype.onWebSocketClose=function(){return this.isOpen?void 0:this.el?(this.el.removeClass("zammad-chat-is-shown"),this.el.removeClass("zammad-chat-is-loaded")):void 0},n.prototype.show=function(){return"offline"!==this.state?(this.el.addClass("zammad-chat-is-loaded"),this.el.addClass("zammad-chat-is-shown")):void 0},n.prototype.disableInput=function(){return this.input.prop("disabled",!0),this.el.find(".zammad-chat-send").prop("disabled",!0)},n.prototype.enableInput=function(){return this.input.prop("disabled",!1),this.el.find(".zammad-chat-send").prop("disabled",!1)},n.prototype.hideModal=function(){return this.el.find(".zammad-chat-modal").html("")},n.prototype.onQueueScreen=function(t){var e;return this.setSessionId(t.session_id),e=function(e){return function(){return e.onQueue(t),e.waitingListTimeout.start()}}(this),this.initialQueueDelay&&!this.onInitialQueueDelayId?void(this.onInitialQueueDelayId=setTimeout(e,this.initialQueueDelay)):(this.onInitialQueueDelayId&&clearTimeout(this.onInitialQueueDelayId),e())},n.prototype.onQueue=function(t){return this.log.notice("onQueue",t.position),this.inQueue=!0,this.el.find(".zammad-chat-modal").html(this.view("waiting")({position:t.position}))},n.prototype.onAgentTypingStart=function(){return this.stopTypingId&&clearTimeout(this.stopTypingId),this.stopTypingId=setTimeout(this.onAgentTypingEnd,3e3),!this.el.find(".zammad-chat-message--typing").size()&&(this.maybeAddTimestamp(),this.el.find(".zammad-chat-body").append(this.view("typingIndicator")()),this.isVisible(this.el.find(".zammad-chat-message--typing"),!0))?this.scrollToBottom():void 0},n.prototype.onAgentTypingEnd=function(){return this.el.find(".zammad-chat-message--typing").remove()},n.prototype.onLeaveTemporary=function(){return this.sessionId?this.send("chat_session_leave_temporary",{session_id:this.sessionId}):void 0},n.prototype.maybeAddTimestamp=function(){var t,e,s;return s=Date.now(),!this.lastTimestamp||s-this.lastTimestamp>6e4*this.showTimeEveryXMinutes?(t=this.T("Today"),e=(new Date).toTimeString().substr(0,5),"timestamp"===this.lastAddedType?(this.updateLastTimestamp(t,e),this.lastTimestamp=s):(this.el.find(".zammad-chat-body").append(this.view("timestamp")({label:t,time:e})),this.lastTimestamp=s,this.lastAddedType="timestamp",this.scrollToBottom())):void 0},n.prototype.updateLastTimestamp=function(t,e){return this.el?this.el.find(".zammad-chat-body").find(".zammad-chat-timestamp").last().replaceWith(this.view("timestamp")({label:t,time:e})):void 0},n.prototype.addStatus=function(t){return this.el?(this.maybeAddTimestamp(),this.el.find(".zammad-chat-body").append(this.view("status")({status:t})),this.scrollToBottom()):void 0},n.prototype.detectScrolledtoBottom=function(){var t;return t=this.el.find(".zammad-chat-body").scrollTop()+this.el.find(".zammad-chat-body").outerHeight(),this.scrolledToBottom=Math.abs(t-this.el.find(".zammad-chat-body").prop("scrollHeight"))<=this.scrollSnapTolerance,this.scrolledToBottom?this.el.find(".zammad-scroll-hint").addClass("is-hidden"):void 0},n.prototype.showScrollHint=function(){return this.el.find(".zammad-scroll-hint").removeClass("is-hidden"),this.el.find(".zammad-chat-body").scrollTop(this.el.find(".zammad-chat-body").scrollTop()+this.el.find(".zammad-scroll-hint").outerHeight())},n.prototype.onScrollHintClick=function(){return this.el.find(".zammad-chat-body").animate({scrollTop:this.el.find(".zammad-chat-body").prop("scrollHeight")},300)},n.prototype.scrollToBottom=function(e){var s;return s=(null!=e?e:{showHint:!1}).showHint,this.scrolledToBottom?this.el.find(".zammad-chat-body").scrollTop(t(".zammad-chat-body").prop("scrollHeight")):s?this.showScrollHint():void 0},n.prototype.destroy=function(t){return null==t&&(t={}),this.log.debug("destroy widget",t),this.setAgentOnlineState("offline"),t.remove&&this.el&&this.el.remove(),this.waitingListTimeout&&this.waitingListTimeout.stop(),this.inactiveTimeout&&this.inactiveTimeout.stop(),this.idleTimeout&&this.idleTimeout.stop(),this.io.close()},n.prototype.reconnect=function(){return this.log.notice("reconnecting"),this.disableInput(),this.lastAddedType="status",this.setAgentOnlineState("connecting"),this.addStatus(this.T("Connection lost"))},n.prototype.onConnectionReestablished=function(){return this.lastAddedType="status",this.setAgentOnlineState("online"),this.addStatus(this.T("Connection re-established"))},n.prototype.onSessionClosed=function(t){return this.addStatus(this.T("Chat closed by %s",t.realname)),this.disableInput(),this.setAgentOnlineState("offline"),this.inactiveTimeout.stop()},n.prototype.setSessionId=function(t){return this.sessionId=t,void 0===t?sessionStorage.removeItem("sessionId"):sessionStorage.setItem("sessionId",t)},n.prototype.onConnectionEstablished=function(t){return this.onInitialQueueDelayId&&clearTimeout(this.onInitialQueueDelayId),this.inQueue=!1,t.agent&&(this.agent=t.agent),t.session_id&&this.setSessionId(t.session_id),this.el.find(".zammad-chat-body").html(""),this.el.find(".zammad-chat-agent").html(this.view("agent")({agent:this.agent})),this.enableInput(),this.hideModal(),this.el.find(".zammad-chat-welcome").addClass("zammad-chat-is-hidden"),this.el.find(".zammad-chat-agent").removeClass("zammad-chat-is-hidden"),this.el.find(".zammad-chat-agent-status").removeClass("zammad-chat-is-hidden"),this.isFullscreen||this.input.focus(),this.setAgentOnlineState("online"),this.waitingListTimeout.stop(),this.idleTimeout.stop(),this.inactiveTimeout.start()},n.prototype.showCustomerTimeout=function(){var t;return this.el.find(".zammad-chat-modal").html(this.view("customer_timeout")({agent:this.agent.name,delay:this.options.inactiveTimeout})),t=function(){return location.reload()},this.el.find(".js-restart").click(t),this.sessionClose()},n.prototype.showWaitingListTimeout=function(){var t;return this.el.find(".zammad-chat-modal").html(this.view("waiting_list_timeout")({delay:this.options.watingListTimeout})),t=function(){return location.reload()},this.el.find(".js-restart").click(t),this.sessionClose()},n.prototype.showLoader=function(){return this.el.find(".zammad-chat-modal").html(this.view("loader")())},n.prototype.setAgentOnlineState=function(t){var e;return this.state=t,this.el?(e=t.charAt(0).toUpperCase()+t.slice(1),this.el.find(".zammad-chat-agent-status").attr("data-status",t).text(this.T(e))):void 0},n.prototype.detectHost=function(){var t;return t="ws://","https:"===e.location.protocol&&(t="wss://"),this.options.host=""+t+h+"/ws"},n.prototype.loadCss=function(){var t,e,s;if(this.options.cssAutoload)return s=this.options.cssUrl,s||(s=this.options.host.replace(/^wss/i,"https").replace(/^ws/i,"http").replace(/\/ws/i,""),s+="/assets/chat/chat.css"),this.log.debug("load css from '"+s+"'"),e="@import url('"+s+"');",t=document.createElement("link"),t.onload=this.onCssLoaded,t.rel="stylesheet",t.href="data:text/css,"+escape(e),document.getElementsByTagName("head")[0].appendChild(t)},n.prototype.onCssLoaded=function(){return this.socketReady?this.onReady():this.cssLoaded=!0},n.prototype.startTimeoutObservers=function(){return this.idleTimeout=new o({logPrefix:"idleTimeout",debug:this.options.debug,timeout:this.options.idleTimeout,timeoutIntervallCheck:this.options.idleTimeoutIntervallCheck,callback:function(t){return function(){return t.log.debug("Idle timeout reached, hide widget",new Date),t.destroy({remove:!0})}}(this)}),this.inactiveTimeout=new o({logPrefix:"inactiveTimeout",debug:this.options.debug,timeout:this.options.inactiveTimeout,timeoutIntervallCheck:this.options.inactiveTimeoutIntervallCheck,callback:function(t){return function(){return t.log.debug("Inactive timeout reached, show timeout screen.",new Date),t.showCustomerTimeout(),t.destroy({remove:!1})}}(this)}),this.waitingListTimeout=new o({logPrefix:"waitingListTimeout",debug:this.options.debug,timeout:this.options.waitingListTimeout,timeoutIntervallCheck:this.options.waitingListTimeoutIntervallCheck,callback:function(t){return function(){return t.log.debug("Waiting list timeout reached, show timeout screen.",new Date),t.showWaitingListTimeout(),t.destroy({remove:!1})}}(this)})},n.prototype.disableScrollOnRoot=function(){return this.rootScrollOffset=this.scrollRoot.scrollTop(),this.scrollRoot.css({overflow:"hidden",position:"fixed"})},n.prototype.enableScrollOnRoot=function(){return this.scrollRoot.scrollTop(this.rootScrollOffset),this.scrollRoot.css({overflow:"",position:""})},n.prototype.isVisible=function(s,i,n,o){var a,r,h,l,d,c,u,p,m,g,f,y,v,b,w,T,z,C,S,I,k,_,O,A,E,L;if(!(s.length<1))if(r=t(e),a=s.length>1?s.eq(0):s,C=a.get(0),L=r.width(),E=r.height(),o=o?o:"both",p=n===!0?C.offsetWidth*C.offsetHeight:!0,"function"==typeof C.getBoundingClientRect){if(z=C.getBoundingClientRect(),S=z.top>=0&&z.top0&&z.bottom<=E,b=z.left>=0&&z.left0&&z.right<=L,I=i?S||u:S&&u,v=i?b||T:b&&T,"both"===o)return p&&I&&v;if("vertical"===o)return p&&I;if("horizontal"===o)return p&&v}else{if(A=r.scrollTop(),k=A+E,_=r.scrollLeft(),O=_+L,w=a.offset(),c=w.top,h=c+a.height(),l=w.left,d=l+a.width(),y=i===!0?h:c,m=i===!0?c:h,g=i===!0?d:l,f=i===!0?l:d,"both"===o)return!!p&&k>=m&&y>=A&&O>=f&&g>=_;if("vertical"===o)return!!p&&k>=m&&y>=A;if("horizontal"===o)return!!p&&O>=f&&g>=_}},n}(s),e.ZammadChat=a}(window.jQuery,window),jQuery.fn.autoGrow=function(t){return this.each(function(){var e=jQuery.extend({extraLine:!0},t),s=function(t){return jQuery(t).after(''),jQuery(t).next(".autogrow-textarea-mirror")[0]},i=function(t){if(o.innerHTML=String(t.value).replace(/&/g,"&").replace(/"/g,""").replace(/'/g,"'").replace(//g,">").replace(/ /g," ").replace(/\n/g," ")+(e.extraLine?". .":""),jQuery(t).height()!=jQuery(o).height()){jQuery(t).height(jQuery(o).height());var s=parseInt(jQuery(t).css("max-height"),10),i=jQuery(o).height()>s?"":"hidden";jQuery(t).css("overflow",i)}},n=function(){i(this)},o=s(this);o.style.display="none",o.style.wordWrap="break-word",o.style.whiteSpace="normal",o.style.padding=jQuery(this).css("paddingTop")+" "+jQuery(this).css("paddingRight")+" "+jQuery(this).css("paddingBottom")+" "+jQuery(this).css("paddingLeft"),o.style.width=jQuery(this).css("width"),o.style.fontFamily=jQuery(this).css("font-family"),o.style.fontSize=jQuery(this).css("font-size"),o.style.lineHeight=jQuery(this).css("line-height"),this.style.overflow="hidden",this.style.minHeight=this.rows+"em",this.onkeyup=n,i(this)})},window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.agent=function(t){t||(t={});var e,s=[],i=function(t){return t&&t.ecoSafe?t:"undefined"!=typeof t&&null!=t?o(t):""},n=t.safe,o=t.escape;return e=t.safe=function(t){if(t&&t.ecoSafe)return t;("undefined"==typeof t||null==t)&&(t="");var e=new String(t);return e.ecoSafe=!0,e},o||(o=t.escape=function(t){return(""+t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}),function(){(function(){this.agent.avatar&&(s.push('\n\n')),s.push('\n\n '),s.push(i(this.agent.name)),s.push("\n")}).call(this)}.call(t),t.safe=n,t.escape=o,s.join("")},window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.chat=function(t){t||(t={});var e,s=[],i=function(t){return t&&t.ecoSafe?t:"undefined"!=typeof t&&null!=t?o(t):""},n=t.safe,o=t.escape;return e=t.safe=function(t){if(t&&t.ecoSafe)return t;("undefined"==typeof t||null==t)&&(t="");var e=new String(t);return e.ecoSafe=!0,e},o||(o=t.escape=function(t){return(""+t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}),function(){(function(){s.push('
\n '),
-this.agent?(s.push("\n "),s.push(this.T("Since you didn't respond in the last %s minutes your conversation with %s got closed.",this.delay,this.agent)),s.push("\n ")):(s.push("\n "),s.push(this.T("Since you didn't respond in the last %s minutes your conversation got closed.",this.delay)),s.push("\n ")),s.push('\n \n
"),s.push(this.T("Start new conversation")),s.push("
\n \n \n \n \n \n '),s.push(this.T("All colleagues are busy.")),s.push(" \n "),s.push(this.T("You are on waiting list position %s.",this.position)),s.push("\n
\n '),s.push(this.T("We are sorry, it takes longer as expected to get an empty slot. Please try again later or send us an email. Thank you!")),s.push('\n \n
"),s.push(this.T("Start new conversation")),s.push("
\n
")}).call(this)}.call(t),t.safe=n,t.escape=o,s.join("")};
\ No newline at end of file
+jQuery.fn.autoGrow=function(t){return this.each(function(){var e=jQuery.extend({extraLine:!0},t),s=function(t){return jQuery(t).after(''),jQuery(t).next(".autogrow-textarea-mirror")[0]},i=function(t){if(o.innerHTML=String(t.value).replace(/&/g,"&").replace(/"/g,""").replace(/'/g,"'").replace(//g,">").replace(/ /g," ").replace(/\n/g," ")+(e.extraLine?". .":""),jQuery(t).height()!=jQuery(o).height()){jQuery(t).height(jQuery(o).height());var s=parseInt(jQuery(t).css("max-height"),10),i=jQuery(o).height()>s?"":"hidden";jQuery(t).css("overflow",i)}},n=function(){i(this)},o=s(this);o.style.display="none",o.style.wordWrap="break-word",o.style.whiteSpace="normal",o.style.padding=jQuery(this).css("paddingTop")+" "+jQuery(this).css("paddingRight")+" "+jQuery(this).css("paddingBottom")+" "+jQuery(this).css("paddingLeft"),o.style.width=jQuery(this).css("width"),o.style.fontFamily=jQuery(this).css("font-family"),o.style.fontSize=jQuery(this).css("font-size"),o.style.lineHeight=jQuery(this).css("line-height"),o.style.letterSpacing=jQuery(this).css("letter-spacing"),o.style.boxSizing=jQuery(this).css("boxSizing"),this.style.overflow="hidden",this.style.minHeight=this.rows+"em",this.onkeyup=n,this.onfocus=n,i(this)})},window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.agent=function(t){t||(t={});var e,s=[],i=function(t){return t&&t.ecoSafe?t:"undefined"!=typeof t&&null!=t?o(t):""},n=t.safe,o=t.escape;return e=t.safe=function(t){if(t&&t.ecoSafe)return t;("undefined"==typeof t||null==t)&&(t="");var e=new String(t);return e.ecoSafe=!0,e},o||(o=t.escape=function(t){return(""+t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}),function(){(function(){this.agent.avatar&&(s.push('\n\n')),s.push('\n\n '),s.push(i(this.agent.name)),s.push("\n")}).call(this)}.call(t),t.safe=n,t.escape=o,s.join("")};var bind=function(t,e){return function(){return t.apply(e,arguments)}},slice=[].slice,extend=function(t,e){function s(){this.constructor=t}for(var i in e)hasProp.call(e,i)&&(t[i]=e[i]);return s.prototype=e.prototype,t.prototype=new s,t.__super__=e.prototype,t},hasProp={}.hasOwnProperty;!function(t,e){var s,i,n,o,a,r,h,l;return l=document.getElementsByTagName("script"),r=l[l.length-1],h=r.src.match(".*://([^:/]*).*")[1],s=function(){function e(e){this.options=t.extend({},this.defaults,e),this.log=new n({debug:this.options.debug,logPrefix:this.options.logPrefix||this.logPrefix})}return e.prototype.defaults={debug:!1},e}(),n=function(){function e(e){this.log=bind(this.log,this),this.error=bind(this.error,this),this.notice=bind(this.notice,this),this.debug=bind(this.debug,this),this.options=t.extend({},this.defaults,e)}return e.prototype.defaults={debug:!1},e.prototype.debug=function(){var t;return t=1<=arguments.length?slice.call(arguments,0):[],this.options.debug?this.log("debug",t):void 0},e.prototype.notice=function(){var t;return t=1<=arguments.length?slice.call(arguments,0):[],this.log("notice",t)},e.prototype.error=function(){var t;return t=1<=arguments.length?slice.call(arguments,0):[],this.log("error",t)},e.prototype.log=function(e,s){var i,n,o,a;if(s.unshift("||"),s.unshift(e),s.unshift(this.options.logPrefix),console.log.apply(console,s),this.options.debug){for(a="",i=0,o=s.length;o>i;i++)n=s[i],a+=" ",a+="object"==typeof n?JSON.stringify(n):n&&n.toString?n.toString():n;return t(".js-chatLogDisplay").prepend("
"+a+"
")}},e}(),o=function(t){function e(t){this.stop=bind(this.stop,this),this.start=bind(this.start,this),e.__super__.constructor.call(this,t)}return extend(e,t),e.prototype.timeoutStartedAt=null,e.prototype.logPrefix="timeout",e.prototype.defaults={debug:!1,timeout:4,timeoutIntervallCheck:.5},e.prototype.start=function(){var t,e;return this.stop(),e=new Date,t=function(t){return function(){var s;return s=new Date-new Date(e.getTime()+1e3*t.options.timeout*60),t.log.debug("Timeout check for "+t.options.timeout+" minutes (left "+s/1e3+" sec.)"),0>s?void 0:(t.stop(),t.options.callback())}}(this),this.log.debug("Start timeout in "+this.options.timeout+" minutes"),this.intervallId=setInterval(t,1e3*this.options.timeoutIntervallCheck*60)},e.prototype.stop=function(){return this.intervallId?(this.log.debug("Stop timeout of "+this.options.timeout+" minutes"),clearInterval(this.intervallId)):void 0},e}(s),i=function(t){function s(t){this.ping=bind(this.ping,this),this.send=bind(this.send,this),this.reconnect=bind(this.reconnect,this),this.close=bind(this.close,this),this.connect=bind(this.connect,this),this.set=bind(this.set,this),s.__super__.constructor.call(this,t)}return extend(s,t),s.prototype.logPrefix="io",s.prototype.set=function(t){var e,s,i;s=[];for(e in t)i=t[e],s.push(this.options[e]=i);return s},s.prototype.connect=function(){return this.log.debug("Connecting to "+this.options.host),this.ws=new e.WebSocket(""+this.options.host),this.ws.onopen=function(t){return function(e){return t.log.debug("onOpen",e),t.options.onOpen(e),t.ping()}}(this),this.ws.onmessage=function(t){return function(e){var s,i,n,o;for(o=JSON.parse(e.data),t.log.debug("onMessage",e.data),s=0,i=o.length;i>s;s++)n=o[s],"pong"===n.event&&t.ping();return t.options.onMessage?t.options.onMessage(o):void 0}}(this),this.ws.onclose=function(t){return function(e){if(t.log.debug("close websocket connection",e),t.pingDelayId&&clearTimeout(t.pingDelayId),t.manualClose){if(t.log.debug("manual close, onClose callback"),t.manualClose=!1,t.options.onClose)return t.options.onClose(e)}else if(t.log.debug("error close, onError callback"),t.options.onError)return t.options.onError("Connection lost...")}}(this),this.ws.onerror=function(t){return function(e){return t.log.debug("onError",e),t.options.onError?t.options.onError(e):void 0}}(this)},s.prototype.close=function(){return this.log.debug("close websocket manually"),this.manualClose=!0,this.ws.close()},s.prototype.reconnect=function(){return this.log.debug("reconnect"),this.close(),this.connect()},s.prototype.send=function(t,e){var s;return null==e&&(e={}),this.log.debug("send",t,e),s=JSON.stringify({event:t,data:e}),this.ws.send(s)},s.prototype.ping=function(){var t;return t=function(t){return function(){return t.send("ping")}}(this),this.pingDelayId=setTimeout(t,29e3)},s}(s),a=function(s){function n(s){return this.startTimeoutObservers=bind(this.startTimeoutObservers,this),this.onCssLoaded=bind(this.onCssLoaded,this),this.setAgentOnlineState=bind(this.setAgentOnlineState,this),this.onConnectionEstablished=bind(this.onConnectionEstablished,this),this.setSessionId=bind(this.setSessionId,this),this.onConnectionReestablished=bind(this.onConnectionReestablished,this),this.reconnect=bind(this.reconnect,this),this.destroy=bind(this.destroy,this),this.onScrollHintClick=bind(this.onScrollHintClick,this),this.detectScrolledtoBottom=bind(this.detectScrolledtoBottom,this),this.onLeaveTemporary=bind(this.onLeaveTemporary,this),this.onAgentTypingEnd=bind(this.onAgentTypingEnd,this),this.onAgentTypingStart=bind(this.onAgentTypingStart,this),this.onQueue=bind(this.onQueue,this),this.onQueueScreen=bind(this.onQueueScreen,this),this.onWebSocketClose=bind(this.onWebSocketClose,this),this.onCloseAnimationEnd=bind(this.onCloseAnimationEnd,this),this.close=bind(this.close,this),this.toggle=bind(this.toggle,this),this.sessionClose=bind(this.sessionClose,this),this.onOpenAnimationEnd=bind(this.onOpenAnimationEnd,this),this.open=bind(this.open,this),this.renderMessage=bind(this.renderMessage,this),this.receiveMessage=bind(this.receiveMessage,this),this.onSubmit=bind(this.onSubmit,this),this.onFocus=bind(this.onFocus,this),this.onInput=bind(this.onInput,this),this.onReopenSession=bind(this.onReopenSession,this),this.onError=bind(this.onError,this),this.onWebSocketMessage=bind(this.onWebSocketMessage,this),this.send=bind(this.send,this),this.checkForEnter=bind(this.checkForEnter,this),this.render=bind(this.render,this),this.view=bind(this.view,this),this.T=bind(this.T,this),this.options=t.extend({},this.defaults,s),n.__super__.constructor.call(this,this.options),this.isFullscreen=e.matchMedia&&e.matchMedia("(max-width: 768px)").matches,this.scrollRoot=t(this.getScrollRoot()),t?e.WebSocket&&sessionStorage?this.options.chatId?(this.options.lang||(this.options.lang=t("html").attr("lang")),this.options.lang&&(this.options.lang=this.options.lang.replace(/-.+?$/,""),this.log.debug("lang: "+this.options.lang)),this.options.host||this.detectHost(),this.loadCss(),this.io=new i(this.options),this.io.set({onOpen:this.render,onClose:this.onWebSocketClose,onMessage:this.onWebSocketMessage,onError:this.onError}),void this.io.connect()):(this.state="unsupported",void this.log.error("Chat: need chatId as option!")):(this.state="unsupported",void this.log.notice("Chat: Browser not supported!")):(this.state="unsupported",void this.log.notice("Chat: no jquery found!"))}return extend(n,s),n.prototype.defaults={chatId:void 0,show:!0,target:t("body"),host:"",debug:!1,flat:!1,lang:void 0,cssAutoload:!0,cssUrl:void 0,fontSize:void 0,buttonClass:"open-zammad-chat",inactiveClass:"is-inactive",title:"Chat with us!",scrollHint:"Scrolle nach unten um neue Nachrichten zu sehen",idleTimeout:6,idleTimeoutIntervallCheck:.5,inactiveTimeout:8,inactiveTimeoutIntervallCheck:.5,waitingListTimeout:4,waitingListTimeoutIntervallCheck:.5},n.prototype.logPrefix="chat",n.prototype._messageCount=0,n.prototype.isOpen=!1,n.prototype.blinkOnlineInterval=null,n.prototype.stopBlinOnlineStateTimeout=null,n.prototype.showTimeEveryXMinutes=2,n.prototype.lastTimestamp=null,n.prototype.lastAddedType=null,n.prototype.inputTimeout=null,n.prototype.isTyping=!1,n.prototype.state="offline",n.prototype.initialQueueDelay=1e4,n.prototype.translations={de:{"Chat with us!":"Chatte mit uns!","Scroll down to see new messages":"Scrolle nach unten um neue Nachrichten zu sehen",Online:"Online",Online:"Online",Offline:"Offline",Connecting:"Verbinden","Connection re-established":"Verbindung wiederhergestellt",Today:"Heute",Send:"Senden","Compose your message...":"Ihre Nachricht...","All colleagues are busy.":"Alle Kollegen sind belegt.","You are on waiting list position %s.":"Sie sind in der Warteliste an der Position %s.","Start new conversation":"Neue Konversation starten","Since you didn't respond in the last %s minutes your conversation with %s got closed.":"Da Sie in den letzten %s Minuten nichts geschrieben haben wurde Ihre Konversation mit %s geschlossen.","Since you didn't respond in the last %s minutes your conversation got closed.":"Da Sie in den letzten %s Minuten nichts geschrieben haben wurde Ihre Konversation geschlossen."}},n.prototype.sessionId=void 0,n.prototype.scrolledToBottom=!0,n.prototype.scrollSnapTolerance=10,n.prototype.T=function(){var t,e,s,i,n,o;if(n=arguments[0],s=2<=arguments.length?slice.call(arguments,1):[],this.options.lang&&"en"!==this.options.lang&&(this.translations[this.options.lang]?(o=this.translations[this.options.lang],o[n]||this.log.notice("Translation needed for '"+n+"'"),n=o[n]||n):this.log.notice("Translation '"+this.options.lang+"' needed!")),s)for(t=0,i=s.length;i>t;t++)e=s[t],n=n.replace(/%s/,e);return n},n.prototype.view=function(t){return function(s){return function(i){return i||(i={}),i.T=s.T,i.background=s.options.background,i.flat=s.options.flat,i.fontSize=s.options.fontSize,e.zammadChatTemplates[t](i)}}(this)},n.prototype.getScrollRoot=function(){var t,e,s;return"scrollingElement"in document?document.scrollingElement:(e=document.documentElement,s=e.scrollTop,e.scrollTop=s+1,t=e.scrollTop,e.scrollTop=s,t>s?e:document.body)},n.prototype.render=function(){return this.el&&t(".zammad-chat").get(0)||this.renderBase(),t("."+this.options.buttonClass).addClass(this.inactiveClass),this.setAgentOnlineState("online"),this.log.debug("widget rendered"),this.startTimeoutObservers(),this.idleTimeout.start(),this.sessionId=sessionStorage.getItem("sessionId"),this.send("chat_status_customer",{session_id:this.sessionId,url:e.location.href})},n.prototype.renderBase=function(){return this.el=t(this.view("chat")({title:this.options.title,scrollHint:this.options.scrollHint})),this.options.target.append(this.el),this.input=this.el.find(".zammad-chat-input"),this.el.find(".js-chat-open").click(this.open),this.el.find(".js-chat-toggle").click(this.toggle),this.el.find(".zammad-chat-controls").on("submit",this.onSubmit),this.el.find(".zammad-chat-body").on("scroll",this.detectScrolledtoBottom),this.el.find(".zammad-scroll-hint").click(this.onScrollHintClick),this.input.on({keydown:this.checkForEnter,input:this.onInput}),t(e).on("beforeunload",function(t){return function(){return t.onLeaveTemporary()}}(this)),t(e).bind("hashchange",function(t){return function(){return t.isOpen?void(t.sessionId&&t.send("chat_session_notice",{session_id:t.sessionId,message:e.location.href})):t.idleTimeout.start()}}(this)),this.isFullscreen?this.input.on({focus:this.onFocus,focusout:this.onFocusOut}):void 0},n.prototype.checkForEnter=function(t){return t.shiftKey||13!==t.keyCode?void 0:(t.preventDefault(),this.sendMessage())},n.prototype.send=function(t,e){return null==e&&(e={}),e.chat_id=this.options.chatId,this.io.send(t,e)},n.prototype.onWebSocketMessage=function(t){var e,s,i;for(e=0,s=t.length;s>e;e++)switch(i=t[e],this.log.debug("ws:onmessage",i),i.event){case"chat_error":this.log.notice(i.data),i.data&&"chat_disabled"===i.data.state&&this.destroy({remove:!0});break;case"chat_session_message":if(i.data.self_written)return;this.receiveMessage(i.data);break;case"chat_session_typing":if(i.data.self_written)return;this.onAgentTypingStart();break;case"chat_session_start":this.onConnectionEstablished(i.data);break;case"chat_session_queue":this.onQueueScreen(i.data);break;case"chat_session_closed":this.onSessionClosed(i.data);break;case"chat_session_left":this.onSessionClosed(i.data);break;case"chat_status_customer":switch(i.data.state){case"online":this.sessionId=void 0,!this.options.cssAutoload||this.cssLoaded?this.onReady():this.socketReady=!0;break;case"offline":this.onError("Zammad Chat: No agent online");break;case"chat_disabled":this.onError("Zammad Chat: Chat is disabled");break;case"no_seats_available":this.onError("Zammad Chat: Too many clients in queue. Clients in queue: "+i.data.queue);break;case"reconnect":this.onReopenSession(i.data)}}},n.prototype.onReady=function(){return this.log.debug("widget ready for use"),t("."+this.options.buttonClass).click(this.open).removeClass(this.inactiveClass),this.options.show?this.show():void 0},n.prototype.onError=function(e){return this.log.debug(e),this.addStatus(e),t("."+this.options.buttonClass).hide(),this.isOpen?(this.disableInput(),this.destroy({remove:!1})):this.destroy({remove:!0})},n.prototype.onReopenSession=function(t){var e,s,i,n,o;if(this.log.debug("old messages",t.session),this.inactiveTimeout.start(),o=sessionStorage.getItem("unfinished_message"),t.agent){for(this.onConnectionEstablished(t),n=t.session,e=0,s=n.length;s>e;e++)i=n[e],this.renderMessage({message:i.content,id:i.id,from:i.created_by_id?"agent":"customer"});o&&this.input.val(o)}return t.position&&this.onQueue(t),this.show(),this.open(),this.scrollToBottom(),o?this.input.focus():void 0},n.prototype.onInput=function(){return this.el.find(".zammad-chat-message--unread").removeClass("zammad-chat-message--unread"),sessionStorage.setItem("unfinished_message",this.input.val()),this.onTyping()},n.prototype.onFocus=function(){var s;return t(e).scrollTop(10),s=t(e).scrollTop()>0,t(e).scrollTop(0),s?this.log.notice("virtual keyboard shown"):void 0},n.prototype.onFocusOut=function(){},n.prototype.onTyping=function(){return this.isTyping&&this.isTyping>new Date((new Date).getTime()-1500)?void 0:(this.isTyping=new Date,this.send("chat_session_typing",{session_id:this.sessionId}),this.inactiveTimeout.start())},n.prototype.onSubmit=function(t){return t.preventDefault(),this.sendMessage()},n.prototype.sendMessage=function(){var t,e;return(t=this.input.val())?(this.inactiveTimeout.start(),sessionStorage.removeItem("unfinished_message"),e=this.view("message")({message:t,from:"customer",id:this._messageCount++,unreadClass:""}),this.maybeAddTimestamp(),this.el.find(".zammad-chat-message--typing").size()?(this.lastAddedType="typing-placeholder",this.el.find(".zammad-chat-message--typing").before(e)):(this.lastAddedType="message--customer",this.el.find(".zammad-chat-body").append(e)),this.input.val(""),this.scrollToBottom(),this.send("chat_session_message",{content:t,id:this._messageCount,session_id:this.sessionId})):void 0},n.prototype.receiveMessage=function(t){return this.inactiveTimeout.start(),this.onAgentTypingEnd(),this.maybeAddTimestamp(),this.renderMessage({message:t.message.content,id:t.id,from:"agent"}),this.scrollToBottom({showHint:!0})},n.prototype.renderMessage=function(t){return this.lastAddedType="message--"+t.from,t.unreadClass=document.hidden?" zammad-chat-message--unread":"",this.el.find(".zammad-chat-body").append(this.view("message")(t))},n.prototype.open=function(){var t;return this.isOpen?void this.log.debug("widget already open, block"):(this.isOpen=!0,this.log.debug("open widget"),this.sessionId||this.showLoader(),this.el.addClass("zammad-chat-is-open"),this.inputInitialized||(this.inputInitialized=!0,this.input.autoGrow({extraLine:!1})),t=this.el.height()-this.el.find(".zammad-chat-header").outerHeight(),this.el.css("bottom",-t),this.sessionId?(this.el.css("bottom",0),this.onOpenAnimationEnd()):(this.el.animate({bottom:0},500,this.onOpenAnimationEnd),this.send("chat_session_init",{url:e.location.href})))},n.prototype.onOpenAnimationEnd=function(){return this.idleTimeout.stop(),this.isFullscreen?this.disableScrollOnRoot():void 0},n.prototype.sessionClose=function(){return this.send("chat_session_close",{session_id:this.sessionId}),this.inactiveTimeout.stop(),this.waitingListTimeout.stop(),sessionStorage.removeItem("unfinished_message"),this.onInitialQueueDelayId&&clearTimeout(this.onInitialQueueDelayId),this.setSessionId(void 0)},n.prototype.toggle=function(t){return this.isOpen?this.close(t):this.open(t)},n.prototype.close=function(t){var e;return this.isOpen?(this.initDelayId&&clearTimeout(this.initDelayId),this.sessionId?(this.log.debug("close widget"),t&&t.stopPropagation(),this.sessionClose(),this.isFullscreen&&this.enableScrollOnRoot(),e=this.el.height()-this.el.find(".zammad-chat-header").outerHeight(),this.el.animate({bottom:-e},500,this.onCloseAnimationEnd)):void this.log.debug("can't close widget without sessionId")):void this.log.debug("can't close widget, it's not open")},n.prototype.onCloseAnimationEnd=function(){return this.el.css("bottom",""),this.el.removeClass("zammad-chat-is-open"),this.showLoader(),this.el.find(".zammad-chat-welcome").removeClass("zammad-chat-is-hidden"),this.el.find(".zammad-chat-agent").addClass("zammad-chat-is-hidden"),this.el.find(".zammad-chat-agent-status").addClass("zammad-chat-is-hidden"),this.isOpen=!1,this.io.reconnect()},n.prototype.onWebSocketClose=function(){return this.isOpen?void 0:this.el?(this.el.removeClass("zammad-chat-is-shown"),this.el.removeClass("zammad-chat-is-loaded")):void 0},n.prototype.show=function(){return"offline"!==this.state?(this.el.addClass("zammad-chat-is-loaded"),this.el.addClass("zammad-chat-is-shown")):void 0},n.prototype.disableInput=function(){return this.input.prop("disabled",!0),this.el.find(".zammad-chat-send").prop("disabled",!0)},n.prototype.enableInput=function(){return this.input.prop("disabled",!1),this.el.find(".zammad-chat-send").prop("disabled",!1)},n.prototype.hideModal=function(){return this.el.find(".zammad-chat-modal").html("")},n.prototype.onQueueScreen=function(t){var e;return this.setSessionId(t.session_id),e=function(e){return function(){return e.onQueue(t),e.waitingListTimeout.start()}}(this),this.initialQueueDelay&&!this.onInitialQueueDelayId?void(this.onInitialQueueDelayId=setTimeout(e,this.initialQueueDelay)):(this.onInitialQueueDelayId&&clearTimeout(this.onInitialQueueDelayId),e())},n.prototype.onQueue=function(t){return this.log.notice("onQueue",t.position),this.inQueue=!0,this.el.find(".zammad-chat-modal").html(this.view("waiting")({position:t.position}))},n.prototype.onAgentTypingStart=function(){return this.stopTypingId&&clearTimeout(this.stopTypingId),this.stopTypingId=setTimeout(this.onAgentTypingEnd,3e3),!this.el.find(".zammad-chat-message--typing").size()&&(this.maybeAddTimestamp(),this.el.find(".zammad-chat-body").append(this.view("typingIndicator")()),this.isVisible(this.el.find(".zammad-chat-message--typing"),!0))?this.scrollToBottom():void 0},n.prototype.onAgentTypingEnd=function(){return this.el.find(".zammad-chat-message--typing").remove()},n.prototype.onLeaveTemporary=function(){return this.sessionId?this.send("chat_session_leave_temporary",{session_id:this.sessionId}):void 0},n.prototype.maybeAddTimestamp=function(){var t,e,s;return s=Date.now(),!this.lastTimestamp||s-this.lastTimestamp>6e4*this.showTimeEveryXMinutes?(t=this.T("Today"),e=(new Date).toTimeString().substr(0,5),"timestamp"===this.lastAddedType?(this.updateLastTimestamp(t,e),this.lastTimestamp=s):(this.el.find(".zammad-chat-body").append(this.view("timestamp")({label:t,time:e})),this.lastTimestamp=s,this.lastAddedType="timestamp",this.scrollToBottom())):void 0},n.prototype.updateLastTimestamp=function(t,e){return this.el?this.el.find(".zammad-chat-body").find(".zammad-chat-timestamp").last().replaceWith(this.view("timestamp")({label:t,time:e})):void 0},n.prototype.addStatus=function(t){return this.el?(this.maybeAddTimestamp(),this.el.find(".zammad-chat-body").append(this.view("status")({status:t})),this.scrollToBottom()):void 0},n.prototype.detectScrolledtoBottom=function(){var t;return t=this.el.find(".zammad-chat-body").scrollTop()+this.el.find(".zammad-chat-body").outerHeight(),this.scrolledToBottom=Math.abs(t-this.el.find(".zammad-chat-body").prop("scrollHeight"))<=this.scrollSnapTolerance,this.scrolledToBottom?this.el.find(".zammad-scroll-hint").addClass("is-hidden"):void 0},n.prototype.showScrollHint=function(){return this.el.find(".zammad-scroll-hint").removeClass("is-hidden"),this.el.find(".zammad-chat-body").scrollTop(this.el.find(".zammad-chat-body").scrollTop()+this.el.find(".zammad-scroll-hint").outerHeight())},n.prototype.onScrollHintClick=function(){return this.el.find(".zammad-chat-body").animate({scrollTop:this.el.find(".zammad-chat-body").prop("scrollHeight")},300)},n.prototype.scrollToBottom=function(e){var s;return s=(null!=e?e:{showHint:!1}).showHint,this.scrolledToBottom?this.el.find(".zammad-chat-body").scrollTop(t(".zammad-chat-body").prop("scrollHeight")):s?this.showScrollHint():void 0},n.prototype.destroy=function(t){return null==t&&(t={}),this.log.debug("destroy widget",t),this.setAgentOnlineState("offline"),t.remove&&this.el&&this.el.remove(),this.waitingListTimeout&&this.waitingListTimeout.stop(),this.inactiveTimeout&&this.inactiveTimeout.stop(),this.idleTimeout&&this.idleTimeout.stop(),this.io.close()},n.prototype.reconnect=function(){return this.log.notice("reconnecting"),this.disableInput(),this.lastAddedType="status",this.setAgentOnlineState("connecting"),this.addStatus(this.T("Connection lost"))},n.prototype.onConnectionReestablished=function(){return this.lastAddedType="status",this.setAgentOnlineState("online"),this.addStatus(this.T("Connection re-established"))},n.prototype.onSessionClosed=function(t){return this.addStatus(this.T("Chat closed by %s",t.realname)),this.disableInput(),this.setAgentOnlineState("offline"),this.inactiveTimeout.stop()},n.prototype.setSessionId=function(t){return this.sessionId=t,void 0===t?sessionStorage.removeItem("sessionId"):sessionStorage.setItem("sessionId",t)},n.prototype.onConnectionEstablished=function(t){return this.onInitialQueueDelayId&&clearTimeout(this.onInitialQueueDelayId),this.inQueue=!1,t.agent&&(this.agent=t.agent),t.session_id&&this.setSessionId(t.session_id),this.el.find(".zammad-chat-body").html(""),this.el.find(".zammad-chat-agent").html(this.view("agent")({agent:this.agent})),this.enableInput(),this.hideModal(),this.el.find(".zammad-chat-welcome").addClass("zammad-chat-is-hidden"),this.el.find(".zammad-chat-agent").removeClass("zammad-chat-is-hidden"),this.el.find(".zammad-chat-agent-status").removeClass("zammad-chat-is-hidden"),this.isFullscreen||this.input.focus(),this.setAgentOnlineState("online"),this.waitingListTimeout.stop(),this.idleTimeout.stop(),this.inactiveTimeout.start()},n.prototype.showCustomerTimeout=function(){var t;return this.el.find(".zammad-chat-modal").html(this.view("customer_timeout")({agent:this.agent.name,delay:this.options.inactiveTimeout})),t=function(){return location.reload()},this.el.find(".js-restart").click(t),this.sessionClose()},n.prototype.showWaitingListTimeout=function(){var t;return this.el.find(".zammad-chat-modal").html(this.view("waiting_list_timeout")({delay:this.options.watingListTimeout})),t=function(){return location.reload()},this.el.find(".js-restart").click(t),this.sessionClose()},n.prototype.showLoader=function(){return this.el.find(".zammad-chat-modal").html(this.view("loader")())},n.prototype.setAgentOnlineState=function(t){var e;return this.state=t,this.el?(e=t.charAt(0).toUpperCase()+t.slice(1),this.el.find(".zammad-chat-agent-status").attr("data-status",t).text(this.T(e))):void 0},n.prototype.detectHost=function(){var t;return t="ws://","https:"===e.location.protocol&&(t="wss://"),this.options.host=""+t+h+"/ws"},n.prototype.loadCss=function(){var t,e,s;if(this.options.cssAutoload)return s=this.options.cssUrl,s||(s=this.options.host.replace(/^wss/i,"https").replace(/^ws/i,"http").replace(/\/ws/i,""),s+="/assets/chat/chat.css"),this.log.debug("load css from '"+s+"'"),e="@import url('"+s+"');",t=document.createElement("link"),t.onload=this.onCssLoaded,t.rel="stylesheet",t.href="data:text/css,"+escape(e),document.getElementsByTagName("head")[0].appendChild(t)},n.prototype.onCssLoaded=function(){return this.socketReady?this.onReady():this.cssLoaded=!0},n.prototype.startTimeoutObservers=function(){return this.idleTimeout=new o({logPrefix:"idleTimeout",debug:this.options.debug,timeout:this.options.idleTimeout,timeoutIntervallCheck:this.options.idleTimeoutIntervallCheck,callback:function(t){return function(){return t.log.debug("Idle timeout reached, hide widget",new Date),t.destroy({remove:!0})}}(this)}),this.inactiveTimeout=new o({logPrefix:"inactiveTimeout",debug:this.options.debug,timeout:this.options.inactiveTimeout,timeoutIntervallCheck:this.options.inactiveTimeoutIntervallCheck,callback:function(t){return function(){return t.log.debug("Inactive timeout reached, show timeout screen.",new Date),t.showCustomerTimeout(),t.destroy({remove:!1})}}(this)}),this.waitingListTimeout=new o({logPrefix:"waitingListTimeout",debug:this.options.debug,timeout:this.options.waitingListTimeout,timeoutIntervallCheck:this.options.waitingListTimeoutIntervallCheck,callback:function(t){return function(){return t.log.debug("Waiting list timeout reached, show timeout screen.",new Date),t.showWaitingListTimeout(),t.destroy({remove:!1})}}(this)})},n.prototype.disableScrollOnRoot=function(){return this.rootScrollOffset=this.scrollRoot.scrollTop(),this.scrollRoot.css({overflow:"hidden",position:"fixed"})},n.prototype.enableScrollOnRoot=function(){return this.scrollRoot.scrollTop(this.rootScrollOffset),this.scrollRoot.css({overflow:"",position:""})},n.prototype.isVisible=function(s,i,n,o){var a,r,h,l,d,c,u,p,m,g,f,y,v,b,w,T,z,C,S,I,k,_,O,A,E,L;if(!(s.length<1))if(r=t(e),a=s.length>1?s.eq(0):s,C=a.get(0),L=r.width(),E=r.height(),o=o?o:"both",p=n===!0?C.offsetWidth*C.offsetHeight:!0,"function"==typeof C.getBoundingClientRect){if(z=C.getBoundingClientRect(),S=z.top>=0&&z.top0&&z.bottom<=E,b=z.left>=0&&z.left0&&z.right<=L,I=i?S||u:S&&u,v=i?b||T:b&&T,"both"===o)return p&&I&&v;if("vertical"===o)return p&&I;if("horizontal"===o)return p&&v}else{if(A=r.scrollTop(),k=A+E,_=r.scrollLeft(),O=_+L,w=a.offset(),c=w.top,h=c+a.height(),l=w.left,d=l+a.width(),y=i===!0?h:c,m=i===!0?c:h,g=i===!0?d:l,f=i===!0?l:d,"both"===o)return!!p&&k>=m&&y>=A&&O>=f&&g>=_;if("vertical"===o)return!!p&&k>=m&&y>=A;if("horizontal"===o)return!!p&&O>=f&&g>=_}},n}(s),e.ZammadChat=a}(window.jQuery,window),window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.chat=function(t){t||(t={});var e,s=[],i=function(t){return t&&t.ecoSafe?t:"undefined"!=typeof t&&null!=t?o(t):""},n=t.safe,o=t.escape;return e=t.safe=function(t){if(t&&t.ecoSafe)return t;("undefined"==typeof t||null==t)&&(t="");var e=new String(t);return e.ecoSafe=!0,e},o||(o=t.escape=function(t){return(""+t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}),function(){(function(){s.push('
\n '),this.agent?(s.push("\n "),s.push(this.T("Since you didn't respond in the last %s minutes your conversation with %s got closed.",this.delay,this.agent)),s.push("\n ")):(s.push("\n "),s.push(this.T("Since you didn't respond in the last %s minutes your conversation got closed.",this.delay)),s.push("\n ")),s.push('\n \n
"),s.push(this.T("Start new conversation")),s.push("
\n \n \n \n \n \n '),s.push(this.T("All colleagues are busy.")),s.push(" \n "),s.push(this.T("You are on waiting list position %s.",this.position)),s.push("\n
\n '),s.push(this.T("We are sorry, it takes longer as expected to get an empty slot. Please try again later or send us an email. Thank you!")),s.push('\n \n
"),s.push(this.T("Start new conversation")),s.push("