First version of customer chat.

This commit is contained in:
Martin Edenhofer 2015-11-10 15:01:04 +01:00
parent 92264a55e2
commit 791c0f934c
19 changed files with 1256 additions and 159 deletions

View file

@ -0,0 +1,383 @@
class App.CustomerChat extends App.Controller
@extend Spine.Events
events:
'click .js-acceptChat': 'acceptChat'
sounds:
chat_new: new Audio('assets/sounds/chat_new.mp3')
constructor: ->
super
@i = 0
@chatWindows = {}
@totalQuestions = 7
@answered = 0
@correct = 0
@wrong = 0
@maxChats = 4
@messageCounter = 0
@meta =
active: false
waiting_chat_count: 0
running_chat_count: 0
active_agents: 0
@render()
App.Event.bind(
'chat_status_agent'
(data) =>
@meta = data
@updateMeta()
)
App.Event.bind(
'chat_session_start'
(data) =>
App.WebSocket.send(event:'chat_status_agent')
if data.session
@addChat(data.session)
)
App.WebSocket.send(event:'chat_status_agent')
@interval(@pushState, 16000)
pushState: =>
App.WebSocket.send(
event:'chat_agent_state'
data:
active: @meta.active
)
render: ->
@html App.view('customer_chat/index')()
show: (params) =>
@navupdate '#customer_chat'
counter: =>
if @meta.waiting_chat_count
if @waitingChatCountLast isnt @meta.waiting_chat_count
@sounds.chat_new.play()
@waitingChatCountLast = @meta.waiting_chat_count
return @messageCounter + @meta.waiting_chat_count
@messageCounter
switch: (state = undefined) =>
# read state
if state is undefined
return @meta.active
@meta.active = state
# write state
App.WebSocket.send(
event:'chat_agent_state'
data:
active: @meta.active
)
updateNavMenu: =>
delay = ->
App.Event.trigger('menu:render')
@delay(delay, 200)
updateMeta: =>
if @meta.waiting_chat_count
@$('.js-acceptChat').addClass('is-clickable is-blinking')
else
@$('.js-acceptChat').removeClass('is-clickable is-blinking')
@$('.js-badgeWaitingCustomers').text(@meta.waiting_chat_count)
@$('.js-badgeChattingCustomers').text(@meta.running_chat_count)
@$('.js-badgeActiveAgents').text(@meta.active_agents)
if @meta.active_sessions
for session in @meta.active_sessions
@addChat(session)
@updateNavMenu()
addChat: (session) ->
return if @chatWindows[session.session_id]
chat = new chatWindow
name: "#{session.created_at}"
session: session
callback: @removeChat
@on 'layout-has-changed', @propagateLayoutChange
@$('.chat-workspace').append(chat.el)
@chatWindows[session.session_id] = chat
removeChat: (session_id) =>
console.log('removeChat', session_id, @chatWindows[session_id])
delete @chatWindows[session_id]
propagateLayoutChange: (event) =>
# adjust scroll position on layoutChange
for session_id, chat of @chatWindows
chat.trigger 'layout-changed'
acceptChat: =>
currentChats = 0
for key, value of @chatWindows
if @chatWindows[key]
currentChats += 1
return if currentChats >= @maxChats
App.WebSocket.send(event:'chat_session_start')
class CustomerChatRouter extends App.ControllerPermanent
constructor: (params) ->
super
# check authentication
return if !@authenticate()
App.TaskManager.execute(
key: 'CustomerChat'
controller: 'CustomerChat'
params: {}
show: true
persistent: true
)
class chatWindow extends App.Controller
@extend Spine.Events
className: 'chat-window'
events:
'keydown .js-customerChatInput': 'onKeydown'
'focus .js-customerChatInput': 'clearUnread'
'click': 'clearUnread'
'click .js-send': 'sendMessage'
'click .js-close': 'close'
elements:
'.js-customerChatInput': 'input'
'.js-status': 'status'
'.js-body': 'body'
'.js-scrollHolder': 'scrollHolder'
sounds:
message: new Audio('assets/sounds/chat_message.mp3')
constructor: ->
super
@showTimeEveryXMinutes = 1
@lastTimestamp
@lastAddedType
@isTyping = false
@render()
@on 'layout-change', @scrollToBottom
App.Event.bind(
'chat_session_typing'
(data) =>
return if data.session_id isnt @session.session_id
return if data.self_written
@showWritingLoader()
)
App.Event.bind(
'chat_session_message'
(data) =>
return if data.session_id isnt @session.session_id
return if data.self_written
@receiveMessage(data.message.content)
)
render: ->
@html App.view('layout_ref/customer_chat_window')
name: @options.name
@el.one 'transitionend', @onTransitionend
# make sure animation will run
setTimeout (=> @el.addClass('is-open')), 0
# @addMessage 'Hello. My name is Roger, how can I help you?', 'agent'
if @session && @session.messages
for message in @session.messages
if message.created_by_id
@addMessage message.content, 'agent'
else
@addMessage message.content, 'customer'
# set focus
@input.get(0).focus()
onTransitionend: (event) =>
# chat window is done with animation - adjust scroll-bars
# of sibling chat windows
@trigger 'layout-has-changed'
if event.data and event.data.callback
event.data.callback()
close: =>
@el.one 'transitionend', { callback: @release }, @onTransitionend
@el.removeClass('is-open')
App.WebSocket.send(
event:'chat_session_close'
data:
session_id: @session.session_id
)
if @callback
@callback(@session.session_id)
release: =>
@trigger 'closed'
super
clearUnread: =>
@$('.chat-message--new').removeClass('chat-message--new')
@updateModified(false)
onKeydown: (event) =>
TABKEY = 9;
ENTERKEY = 13;
if event.keyCode isnt TABKEY && event.keyCode isnt ENTERKEY
App.WebSocket.send(
event:'chat_session_typing'
data:
session_id: @session.session_id
)
switch event.keyCode
when TABKEY
allChatInputs = $('.js-customerChatInput').not('[disabled="disabled"]')
chatCount = allChatInputs.size()
index = allChatInputs.index(@input)
if chatCount > 1
switch index
when chatCount-1
if !event.shiftKey
# State: tab without shift on last input
# Jump to first input
event.preventDefault()
allChatInputs.eq(0).focus()
when 0
if event.shiftKey
# State: tab with shift on first input
# Jump to last input
event.preventDefault()
allChatInputs.eq(chatCount-1).focus()
when ENTERKEY
if !event.shiftKey
event.preventDefault()
@sendMessage()
sendMessage: =>
content = @input.html()
return if !content
#@trigger "answer", @input.html()
App.WebSocket.send(
event:'chat_session_message'
data:
content: content
session_id: @session.session_id
)
@addMessage content, 'agent'
@input.html('')
updateModified: (state) =>
@status.toggleClass('is-modified', state)
receiveMessage: (message) =>
isFocused = @input.is(':focus')
@removeWritingLoader()
@addMessage(message, 'customer', !isFocused)
if !isFocused
@updateModified(true)
@sounds.message.play()
addMessage: (message, sender, isNew) =>
@maybeAddTimestamp()
@lastAddedType = sender
@body.append App.view('layout_ref/customer_chat_message')
message: message
sender: sender
isNew: isNew
timestamp: Date.now()
@scrollToBottom()
showWritingLoader: =>
if !@isTyping
@isTyping = true
@maybeAddTimestamp()
@body.append App.view('layout_ref/customer_chat_loader')()
@scrollToBottom()
# clear old delay, set new
@delay(@removeWritingLoader, 1800, 'typing')
removeWritingLoader: =>
@isTyping = false
@$('.js-loader').remove()
goOffline: =>
@addStatusMessage("<strong>#{ @options.name }</strong>'s connection got closed")
@status.attr('data-status', 'offline')
@el.addClass('is-offline')
@input.attr('disabled', true)
maybeAddTimestamp: ->
timestamp = Date.now()
if !@lastTimestamp or timestamp - @lastTimestamp > @showTimeEveryXMinutes * 60000
label = 'Today'
time = new Date().toTimeString().substr(0,5)
if @lastAddedType is 'timestamp'
# update last time
@updateLastTimestamp label, time
@lastTimestamp = timestamp
else
@addTimestamp label, time
@lastTimestamp = timestamp
@lastAddedType = 'timestamp'
addTimestamp: (label, time) =>
@body.append App.view('layout_ref/customer_chat_timestamp')
label: label
time: time
updateLastTimestamp: (label, time) ->
@body
.find('.js-timestamp')
.last()
.replaceWith App.view('layout_ref/customer_chat_timestamp')
label: label
time: time
addStatusMessage: (message) ->
@body.append App.view('layout_ref/customer_chat_status_message')
message: message
@scrollToBottom()
scrollToBottom: ->
@scrollHolder.scrollTop(@scrollHolder.prop('scrollHeight'))
App.Config.set( 'customer_chat', CustomerChatRouter, 'Routes' )
App.Config.set( 'CustomerChat', { controller: 'CustomerChat', authentication: true }, 'permanentTask' )
App.Config.set( 'CustomerChat', { prio: 1200, parent: '', name: 'Customer Chat', target: '#customer_chat', key: 'CustomerChat', role: ['Chat'], class: 'chat' }, 'NavBar' )

View file

@ -0,0 +1,24 @@
<div class="chat main">
<div class="page-header">
<div class="page-header-title">
<h1><%- @T('Customer Chat') %></h1>
</div>
<div class="page-header-center">
<div class="status-fields">
<div class="status-field js-acceptChat">
<span class="badge js-badgeWaitingCustomers"></span> <%- @T('Waiting Customers') %>
</div>
<div class="status-field">
<span class="badge js-badgeChattingCustomers"></span> <%- @T('Chatting Customers') %>
</div>
<div class="status-field">
<span class="badge js-badgeActiveAgents"></span> <%- @T('Active Agents') %>
</div>
</div>
</div>
<div class="page-header-meta">
<div class="btn btn--action" data-type="settings"><%= @T('Settings') %></div>
</div>
</div>
<div class="chat-workspace"></div>
</div>

View file

@ -26,7 +26,7 @@
<span class="menu-item-name">
<%- @T(item.name) %>
</span>
<% if item.counter isnt undefined: %>
<% if item.counter isnt undefined && item.counter isnt 0: %>
<span class="counter badge badge--big"><%= item.counter %></span>
<% end %>
<% if item.switch isnt undefined: %>

133
app/models/chat.rb Normal file
View file

@ -0,0 +1,133 @@
# Copyright (C) 2012-2014 Zammad Foundation, http://zammad-foundation.org/
class Chat < ApplicationModel
has_many :chat_topics
validates :name, presence: true
def state
return { state: 'chat_disabled' } if !Setting.get('chat')
if Chat::Agent.where(active: true).where('updated_at > ?', Time.zone.now - 2.minutes).count > 0
if active_chat_count >= max_queue
return {
state: 'no_seats_available',
queue: seads_available,
}
else
return { state: 'online' }
end
end
{ state: 'offline' }
end
def self.agent_state(user_id)
return { state: 'chat_disabled' } if !Setting.get('chat')
actice_sessions = []
Chat::Session.where(state: 'running').order('created_at ASC').each {|session|
session_attributes = session.attributes
session_attributes['messages'] = []
Chat::Message.where(chat_session_id: session.id).each { |message|
session_attributes['messages'].push message.attributes
}
actice_sessions.push session_attributes
}
{
waiting_chat_count: waiting_chat_count,
running_chat_count: running_chat_count,
#available_agents: available_agents,
active_sessions: actice_sessions,
active: Chat::Agent.state(user_id)
}
end
def self.waiting_chat_count
Chat::Session.where(state: ['waiting']).count
end
def self.running_chat_count
Chat::Session.where(state: ['waiting']).count
end
def active_chat_count
Chat::Session.where(state: %w(waiting running)).count
end
def available_agents(diff = 2.minutes)
agents = {}
Chat::Agent.where(active: true).where('updated_at > ?', Time.zone.now - diff).each {|record|
agents[record.updated_by_id] = record.concurrent
}
agents
end
def seads_total(diff = 2.minutes)
total = 0
available_agents(diff).each {|_record, concurrent|
total += concurrent
}
total
end
def seads_available(diff = 2.minutes)
seads_total(diff) - active_chat_count
end
end
class Chat::Topic < ApplicationModel
end
class Chat::Agent < ApplicationModel
def seads_available
concurrent - active_chat_count
end
def active_chat_count
Chat::Session.where(state: %w(waiting running), user_id: updated_by_id).count
end
def self.state(user_id, state = nil)
chat_agent = Chat::Agent.find_by(
updated_by_id: user_id
)
if state.nil?
return false if !chat_agent
return chat_agent.active
end
if chat_agent
chat_agent.active = state
chat_agent.updated_at = Time.zone.now
chat_agent.save
else
Chat::Agent.create(
active: state,
updated_by_id: user_id,
created_by_id: user_id,
)
end
end
def self.create_or_update(params)
chat_agent = Chat::Agent.find_by(
updated_by_id: params[:updated_by_id]
)
if chat_agent
chat_agent.update_attributes(params)
else
Chat::Agent.create(params)
end
end
end
class Chat::Session < ApplicationModel
before_create :generate_session_id
store :preferences
def generate_session_id
self.session_id = Digest::MD5.hexdigest(Time.zone.now.to_s + rand(99_999_999_999_999).to_s)
end
end
class Chat::Message < ApplicationModel
end

View file

@ -0,0 +1,93 @@
class CreateChat < ActiveRecord::Migration
def up
=begin
ActiveRecord::Migration.drop_table :chats
ActiveRecord::Migration.drop_table :chat_topics
ActiveRecord::Migration.drop_table :chat_sessions
ActiveRecord::Migration.drop_table :chat_messages
ActiveRecord::Migration.drop_table :chat_agents
=end
create_table :chats do |t|
t.string :name, limit: 250, null: true
t.integer :max_queue, null: false, default: 5
t.string :note, limit: 250, null: true
t.boolean :active, null: false, default: true
t.integer :updated_by_id, null: false
t.integer :created_by_id, null: false
t.timestamps null: false
end
add_index :chats, [:name], unique: true
create_table :chat_topics do |t|
t.integer :chat_id, null: false
t.string :name, limit: 250, null: false
t.string :note, limit: 250, null: true
t.integer :updated_by_id, null: false
t.integer :created_by_id, null: false
t.timestamps null: false
end
add_index :chat_topics, [:name], unique: true
create_table :chat_sessions do |t|
t.integer :chat_id, null: false
t.string :session_id, null: false
t.string :name, limit: 250, null: true
t.string :state, limit: 50, null: false, default: 'waiting' # running, closed
t.integer :user_id, null: true
t.text :preferences, limit: 100.kilobytes + 1, null: true
t.integer :updated_by_id, null: true
t.integer :created_by_id, null: true
t.timestamps null: false
end
add_index :chat_sessions, [:state]
add_index :chat_sessions, [:user_id]
create_table :chat_messages do |t|
t.integer :chat_session_id, null: false
t.string :content, limit: 5000, null: false
t.integer :created_by_id, null: true
t.timestamps null: false
end
create_table :chat_agents do |t|
t.boolean :active, null: false, default: true
t.integer :concurrent, null: false, default: 5
t.integer :updated_by_id, null: false
t.integer :created_by_id, null: false
t.timestamps null: false
end
add_index :chat_agents, [:created_by_id], unique: true
Role.create_if_not_exists(
name: 'Chat',
note: 'Access to chat feature.',
updated_by_id: 1,
created_by_id: 1
)
chat = Chat.create(
name: 'default',
max_queue: 5,
note: '',
active: true,
updated_by_id: 1,
created_by_id: 1,
)
chat_topic = Chat::Topic.create(
chat_id: chat.id,
name: 'default',
updated_by_id: 1,
created_by_id: 1,
)
end
def down
drop_table :chat_topics
drop_table :chat_sessions
drop_table :chat_messages
drop_table :chat_agents
drop_table :chats
end
end

18
lib/sessions/event.rb Normal file
View file

@ -0,0 +1,18 @@
class Sessions::Event
include ApplicationLib
def self.run(event, data, session, client_id)
adapter = "Sessions::Event::#{event.to_classname}"
begin
backend = load_adapter(adapter)
rescue => e
return { error: "No such event #{event}" }
end
ActiveRecord::Base.establish_connection
result = backend.run(data, session, client_id)
ActiveRecord::Base.remove_connection
result
end
end

View file

@ -0,0 +1,38 @@
class Sessions::Event::ChatAgentState
def self.run(data, session, _client_id)
# check if feature is enabled
if !Setting.get('chat')
return {
event: 'chat_agent_state',
data: {
state: 'chat_disabled',
},
}
end
# only agents can do this
chat_id = 1
chat = Chat.find_by(id: chat_id)
if !session['id']
return {
event: 'chat_agent_state',
data: {
state: 'failed',
message: 'No such user_id.'
},
}
end
Chat::Agent.state(session['id'], data['data']['active'])
{
event: 'chat_agent_state',
data: {
state: 'ok',
active: data['data']['active'],
},
}
end
end

View file

@ -0,0 +1,46 @@
class Sessions::Event::ChatSessionClose
def self.run(data, _session, _client_id)
# check if feature is enabled
if !Setting.get('chat')
return {
event: 'chat_status_close',
data: {
state: 'chat_disabled',
},
}
end
if !data['data'] || !data['data']['session_id']
return {
event: 'chat_status_close',
data: {
state: 'Need session_id.',
},
}
end
chat_session = Chat::Session.find_by(session_id: data['data']['session_id'])
if !chat_session
return {
event: 'chat_status_close',
data: {
state: "No such session id #{data['data']['session_id']}",
},
}
end
chat_session.state = 'closed'
chat_session.save
# return new session
{
event: 'chat_status_close',
data: {
state: 'ok',
session_id: chat_session.session_id,
},
}
end
end

View file

@ -0,0 +1,55 @@
class Sessions::Event::ChatSessionInit
def self.run(data, _session, client_id)
# check if feature is enabled
if !Setting.get('chat')
return {
event: 'chat_session_init',
data: {
state: 'chat_disabled',
},
}
end
chat_id = 1
chat = Chat.find_by(id: chat_id)
if !chat
return {
event: 'chat_session_init',
data: {
state: 'no_such_chat',
},
}
end
# create chat session
chat_session = Chat::Session.create(
chat_id: chat_id,
name: '',
state: 'waiting',
preferences: {
participants: [client_id],
},
)
# send update to agents
User.where(active: true).each {|user|
data = {
event: 'chat_status_agent',
data: Chat.agent_state(user.id),
}
Sessions.send_to(user.id, data)
}
# return new session
{
event: 'chat_session_init',
data: {
state: 'queue',
position: Chat.waiting_chat_count,
session_id: chat_session.session_id,
},
}
end
end

View file

@ -0,0 +1,68 @@
class Sessions::Event::ChatSessionMessage
def self.run(data, session, client_id)
# check if feature is enabled
if !Setting.get('chat')
return {
event: 'chat_session_message',
data: {
state: 'chat_disabled',
},
}
end
if !data['data'] || !data['data']['session_id']
return {
event: 'chat_session_message',
data: {
state: 'Need session_id.',
},
}
end
chat_session = Chat::Session.find_by(session_id: data['data']['session_id'])
if !chat_session
return {
event: 'chat_session_message',
data: {
state: "No such session id #{data['data']['session_id']}",
},
}
end
user_id = nil
if session
user_id = session['id']
end
chat_message = Chat::Message.create(
chat_session_id: chat_session.id,
content: data['data']['content'],
created_by_id: user_id,
)
message = {
event: 'chat_session_message',
data: {
session_id: chat_session.session_id,
message: chat_message,
},
}
# send to participents
chat_session.preferences[:participants].each {|local_client_id|
next if local_client_id == client_id
Sessions.send(local_client_id, message)
}
# send chat_session_init to agent
{
event: 'chat_session_message',
data: {
session_id: chat_session.session_id,
message: chat_message,
self_written: true,
},
}
end
end

View file

@ -0,0 +1,60 @@
class Sessions::Event::ChatSessionStart
def self.run(data, session, client_id)
# check if feature is enabled
if !Setting.get('chat')
return {
event: 'chat_session_start',
data: {
state: 'chat_disabled',
},
}
end
# find first in waiting list
chat_session = Chat::Session.where(state: 'waiting').order('created_at ASC').first
if !chat_session
return {
event: 'chat_session_start',
data: {
state: 'failed',
message: 'No session available.',
},
}
end
chat_session.user_id = session['id']
chat_session.state = 'running'
chat_session.preferences[:participants].push client_id
chat_session.save
# send chat_session_init to client
chat_user = User.find(chat_session.user_id)
user = {
name: chat_user.fullname,
avatar: chat_user.image,
}
data = {
event: 'chat_session_start',
data: {
state: 'ok',
agent: user,
session_id: chat_session.session_id,
},
}
chat_session.preferences[:participants].each {|local_client_id|
next if local_client_id == client_id
Sessions.send(local_client_id, data)
}
# send chat_session_init to agent
{
event: 'chat_session_start',
data: {
state: 'ok',
session: chat_session,
},
}
end
end

View file

@ -0,0 +1,61 @@
class Sessions::Event::ChatSessionTyping
def self.run(data, session, client_id)
# check if feature is enabled
if !Setting.get('chat')
return {
event: 'chat_session_typing',
data: {
state: 'chat_disabled',
},
}
end
if !data['data'] || !data['data']['session_id']
return {
event: 'chat_session_typing',
data: {
state: 'Need session_id.',
},
}
end
chat_session = Chat::Session.find_by(session_id: data['data']['session_id'])
if !chat_session
return {
event: 'chat_session_typing',
data: {
state: "No such session id #{data['data']['session_id']}",
},
}
end
user_id = nil
if session
user_id = session['id']
end
message = {
event: 'chat_session_typing',
data: {
session_id: chat_session.session_id,
user_id: user_id,
},
}
# send to participents
chat_session.preferences[:participants].each {|local_client_id|
next if local_client_id == client_id
Sessions.send(local_client_id, message)
}
# send chat_session_init to agent
{
event: 'chat_session_typing',
data: {
session_id: chat_session.session_id,
self_written: true,
},
}
end
end

View file

@ -0,0 +1,31 @@
class Sessions::Event::ChatStatus
def self.run(_data, _session, _client_id)
# check if feature is enabled
if !Setting.get('chat')
return {
event: 'chat_status',
data: {
state: 'chat_disabled',
},
}
end
chat_id = 1
chat = Chat.find_by(id: chat_id)
if !chat
return {
event: 'chat_status',
data: {
state: 'no_such_chat',
},
}
end
{
event: 'chat_status',
data: chat.state,
}
end
end

View file

@ -0,0 +1,27 @@
class Sessions::Event::ChatStatusAgent
def self.run(_data, session, _client_id)
# check if user has permissions
# check if feature is enabled
if !Setting.get('chat')
return {
event: 'chat_status_agent',
data: {
state: 'chat_disabled',
},
}
end
# renew timestamps
state = Chat::Agent.state(session['id'])
Chat::Agent.state(session['id'], state)
{
event: 'chat_status_agent',
data: Chat.agent_state(session['id']),
}
end
end

View file

@ -51,6 +51,8 @@ do($ = window.jQuery, window) ->
input: @onInput
).autoGrow { extraLine: false }
@session_id = undefined
if !window.WebSocket
console.log('Zammad Chat: Browser not supported')
return
@ -61,7 +63,7 @@ do($ = window.jQuery, window) ->
@ws.onopen = =>
console.log('ws connected')
@send "chat_status"
@send 'chat_status'
@ws.onmessage = @onWebSocketMessage
@ -76,40 +78,52 @@ do($ = window.jQuery, window) ->
event.preventDefault()
@sendMessage()
send: (action, data) =>
send: (event, data) =>
console.log 'debug', 'ws:send', event, data
pipe = JSON.stringify
action: action
event: event
data: data
@ws.send pipe
onWebSocketMessage: (e) =>
pipe = JSON.parse( e.data )
console.log 'debug', 'ws:onmessage', pipe
pipes = JSON.parse( e.data )
console.log 'debug', 'ws:onmessage', pipes
switch pipe.action
when 'chat_message'
@receiveMessage pipe.data
when 'chat_typing_start'
@onAgentTypingStart()
when 'chat_typing_end'
@onAgentTypingEnd()
when 'chat_init'
switch pipe.data.state
when 'ok'
@onConnectionEstablished pipe.data.agent
when 'queue'
@onQueue pipe.data.position
when 'chat_status'
switch pipe.data.state
when 'ok'
@onReady()
when 'offline'
console.log 'Zammad Chat: No agent online'
when 'chat_disabled'
console.log 'Zammad Chat: Chat is disabled'
when 'no_seats_available'
console.log 'Zammad Chat: Too many clients in queue. Clients in queue: ', pipe.data.queue
for pipe in pipes
switch pipe.event
when 'chat_session_message'
return if pipe.data.self_written
@receiveMessage pipe.data
when 'chat_session_typing'
return if pipe.data.self_written
@onAgentTypingStart()
if @stopTypingId
clearTimeout(@stopTypingId)
delay = =>
@onAgentTypingEnd()
@stopTypingId = setTimeout(delay, 3000)
when 'chat_session_start'
switch pipe.data.state
when 'ok'
@onConnectionEstablished pipe.data.agent
when 'chat_session_init'
switch pipe.data.state
when 'ok'
@onConnectionEstablished pipe.data.agent
when 'queue'
@onQueue pipe.data.position
@session_id = pipe.data.session_id
when 'chat_status'
switch pipe.data.state
when 'online'
@onReady()
console.log 'Zammad Chat: ready'
when 'offline'
console.log 'Zammad Chat: No agent online'
when 'chat_disabled'
console.log 'Zammad Chat: Chat is disabled'
when 'no_seats_available'
console.log 'Zammad Chat: Too many clients in queue. Clients in queue: ', pipe.data.queue
onReady: =>
@show() if @options.show
@ -119,22 +133,22 @@ do($ = window.jQuery, window) ->
@el.find('.zammad-chat-message--unread')
.removeClass 'zammad-chat-message--unread'
clearTimeout(@inputTimeout) if @inputTimeout
# fire typingEnd after 5 seconds
@inputTimeout = setTimeout @onTypingEnd, 5000
@onTypingStart() if @isTyping
@onTypingStart()
onTypingStart: ->
clearTimeout(@isTypingTimeout) if @isTypingTimeout
# fire typingEnd after 5 seconds
@isTypingTimeout = setTimeout @onTypingEnd, 1500
# send typing start event
@isTyping = true
@send 'typing_start'
if !@isTyping
@isTyping = true
@send 'chat_session_typing', {session_id: @session_id}
onTypingEnd: =>
# send typing end event
@isTyping = false
@send 'typing_end'
onSubmit: (event) =>
event.preventDefault()
@ -167,9 +181,10 @@ do($ = window.jQuery, window) ->
@isTyping = false
# send message event
@send 'message',
body: message
@send 'chat_session_message',
content: message
id: @_messageCount
session_id: @session_id
receiveMessage: (data) =>
# hide writing indicator
@ -180,7 +195,7 @@ do($ = window.jQuery, window) ->
@lastAddedType = 'message--agent'
unread = document.hidden ? " zammad-chat-message--unread" : ""
@el.find('.zammad-chat-body').append @view('message')
message: data.body
message: data.message.content
id: data.id
from: 'agent'
@scrollToBottom()
@ -275,7 +290,7 @@ do($ = window.jQuery, window) ->
@el.find('.zammad-chat-body').scrollTop($('.zammad-chat-body').prop('scrollHeight'))
connect: ->
@send('chat_init')
@send('chat_session_init')
reconnect: =>
# set status to connecting

View file

@ -1,66 +1,3 @@
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
};
}
(function() {
(function() {
__out.push('<img class="zammad-chat-agent-avatar" src="');
__out.push(__sanitize(this.agent.avatar));
__out.push('">\n<span class="zammad-chat-agent-sentence">\n <span class="zammad-chat-agent-name">');
__out.push(__sanitize(this.agent.name));
__out.push('</span> ');
__out.push(this.agentPhrase);
__out.push('\n</span>');
}).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); }; };
(function($, window) {
@ -151,6 +88,7 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments);
}).autoGrow({
extraLine: false
});
this.session_id = void 0;
if (!window.WebSocket) {
console.log('Zammad Chat: Browser not supported');
return;
@ -161,7 +99,7 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments);
this.ws.onopen = (function(_this) {
return function() {
console.log('ws connected');
return _this.send("chat_status");
return _this.send('chat_status');
};
})(this);
this.ws.onmessage = this.onWebSocketMessage;
@ -184,45 +122,76 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments);
}
};
ZammadChat.prototype.send = function(action, data) {
ZammadChat.prototype.send = function(event, data) {
var pipe;
console.log('debug', 'ws:send', event, data);
pipe = JSON.stringify({
action: action,
event: event,
data: data
});
return this.ws.send(pipe);
};
ZammadChat.prototype.onWebSocketMessage = function(e) {
var pipe;
pipe = JSON.parse(e.data);
console.log('debug', 'ws:onmessage', pipe);
switch (pipe.action) {
case 'chat_message':
return this.receiveMessage(pipe.data);
case 'chat_typing_start':
return this.onAgentTypingStart();
case 'chat_typing_end':
return this.onAgentTypingEnd();
case 'chat_init':
switch (pipe.data.state) {
case 'ok':
return this.onConnectionEstablished(pipe.data.agent);
case 'queue':
return this.onQueue(pipe.data.position);
}
break;
case 'chat_status':
switch (pipe.data.state) {
case 'ok':
return this.onReady();
case 'offline':
return console.log('Zammad Chat: No agent online');
case 'chat_disabled':
return console.log('Zammad Chat: Chat is disabled');
case 'no_seats_available':
return console.log('Zammad Chat: Too many clients in queue. Clients in queue: ', pipe.data.queue);
}
var delay, i, len, pipe, pipes;
pipes = JSON.parse(e.data);
console.log('debug', 'ws:onmessage', pipes);
for (i = 0, len = pipes.length; i < len; i++) {
pipe = pipes[i];
switch (pipe.event) {
case 'chat_session_message':
if (pipe.data.self_written) {
return;
}
this.receiveMessage(pipe.data);
break;
case 'chat_session_typing':
if (pipe.data.self_written) {
return;
}
this.onAgentTypingStart();
if (this.stopTypingId) {
clearTimeout(this.stopTypingId);
}
delay = (function(_this) {
return function() {
return _this.onAgentTypingEnd();
};
})(this);
this.stopTypingId = setTimeout(delay, 3000);
break;
case 'chat_session_start':
switch (pipe.data.state) {
case 'ok':
this.onConnectionEstablished(pipe.data.agent);
}
break;
case 'chat_session_init':
switch (pipe.data.state) {
case 'ok':
this.onConnectionEstablished(pipe.data.agent);
break;
case 'queue':
this.onQueue(pipe.data.position);
this.session_id = pipe.data.session_id;
}
break;
case 'chat_status':
switch (pipe.data.state) {
case 'online':
this.onReady();
console.log('Zammad Chat: ready');
break;
case 'offline':
console.log('Zammad Chat: No agent online');
break;
case 'chat_disabled':
console.log('Zammad Chat: Chat is disabled');
break;
case 'no_seats_available':
console.log('Zammad Chat: Too many clients in queue. Clients in queue: ', pipe.data.queue);
}
}
}
};
@ -234,23 +203,24 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments);
ZammadChat.prototype.onInput = function() {
this.el.find('.zammad-chat-message--unread').removeClass('zammad-chat-message--unread');
if (this.inputTimeout) {
clearTimeout(this.inputTimeout);
}
this.inputTimeout = setTimeout(this.onTypingEnd, 5000);
if (this.isTyping) {
return this.onTypingStart();
}
return this.onTypingStart();
};
ZammadChat.prototype.onTypingStart = function() {
this.isTyping = true;
return this.send('typing_start');
if (this.isTypingTimeout) {
clearTimeout(this.isTypingTimeout);
}
this.isTypingTimeout = setTimeout(this.onTypingEnd, 1500);
if (!this.isTyping) {
this.isTyping = true;
return this.send('chat_session_typing', {
session_id: this.session_id
});
}
};
ZammadChat.prototype.onTypingEnd = function() {
this.isTyping = false;
return this.send('typing_end');
return this.isTyping = false;
};
ZammadChat.prototype.onSubmit = function(event) {
@ -280,9 +250,10 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments);
this.el.find('.zammad-chat-input').val('');
this.scrollToBottom();
this.isTyping = false;
return this.send('message', {
body: message,
id: this._messageCount
return this.send('chat_session_message', {
content: message,
id: this._messageCount,
session_id: this.session_id
});
};
@ -295,7 +266,7 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments);
" zammad-chat-message--unread": ""
};
this.el.find('.zammad-chat-body').append(this.view('message')({
message: data.body,
message: data.message.content,
id: data.id,
from: 'agent'
}));
@ -404,7 +375,7 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments);
};
ZammadChat.prototype.connect = function() {
return this.send('chat_init');
return this.send('chat_session_init');
};
ZammadChat.prototype.reconnect = function() {
@ -456,6 +427,69 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments);
});
})(window.jQuery, window);
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
};
}
(function() {
(function() {
__out.push('<img class="zammad-chat-agent-avatar" src="');
__out.push(__sanitize(this.agent.avatar));
__out.push('">\n<span class="zammad-chat-agent-sentence">\n <span class="zammad-chat-agent-name">');
__out.push(__sanitize(this.agent.name));
__out.push('</span> ');
__out.push(this.agentPhrase);
__out.push('\n</span>');
}).call(this);
}).call(__obj);
__obj.safe = __objSafe, __obj.escape = __escape;
return __out.join('');
};
/*!
* ----------------------------------------------------------------------------
* "THE BEER-WARE LICENSE" (Revision 42):

File diff suppressed because one or more lines are too long

View file

@ -94,8 +94,11 @@
</div>
<script src="jquery-2.1.4.min.js"></script>
<!--
<script src="node_modules/sass.js/dist/sass.js"></script>
-->
<script src="chat.js"></script>
<!--
<script>
var sass = new Sass();
var scss = '';
@ -125,7 +128,8 @@
}
});
}
-->
<script>
$('.settings :input').on({
change: function(){

View file

@ -136,6 +136,9 @@ EventMachine.run {
# check if connection not already exists
next if !@clients[client_id]
Sessions.touch(client_id)
@clients[client_id][:last_ping] = Time.now.utc.to_i
# spool messages for new connects
if data['spool']
Sessions.spool_create(msg)
@ -201,8 +204,6 @@ EventMachine.run {
# remember ping, send pong back
elsif data['action'] == 'ping'
Sessions.touch(client_id)
@clients[client_id][:last_ping] = Time.now.utc.to_i
message = {
action: 'pong',
}
@ -255,6 +256,12 @@ EventMachine.run {
log 'notice', 'do not send broadcast to it self', client_id
end
}
elsif data['event']
message = Sessions::Event.run(data['event'], data, @clients[client_id][:session], client_id)
if message
websocket_send(client_id, message)
end
end
}
end