tray/src/messenger/webim/libs/classes/thread.php
2013-04-11 10:33:05 +00:00

877 lines
23 KiB
PHP

<?php
/*
* Copyright 2005-2013 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Represents a chat thread
*
* Events triggered by the class
* - threadChanged
*
* Full description of triggered events:
*
* 1. "threadChanged" - triggers just after thread saved and only if some thread
* fields were changed before.
*
* An associative array passed to event handler has following keys:
* - 'thread': Thread object that was chanded.
* - 'changed_fields': list of changed fields. Names of the fields correspond
* to class properties (see Thread::propertyMap for details) NOT to fields
* names in database.
*
* @todo Think about STATE_* and KIND_* constant systems and may be simplifies
* them.
*/
Class Thread {
/**
* User in the users queue
*/
const STATE_QUEUE = 0;
/**
* User waiting for operator
*/
const STATE_WAITING = 1;
/**
* Conversation in progress
*/
const STATE_CHATTING = 2;
/**
* Thread closed
*/
const STATE_CLOSED = 3;
/**
* Thread just created
*/
const STATE_LOADING = 4;
/**
* User left message without starting a conversation
*/
const STATE_LEFT = 5;
/**
* Message sent by user
*/
const KIND_USER = 1;
/**
* Message sent by operator
*/
const KIND_AGENT = 2;
/**
* Hidden system message to operator
*/
const KIND_FOR_AGENT = 3;
/**
* System messages for user and operator
*/
const KIND_INFO = 4;
/**
* Message for user if operator have connection problems
*/
const KIND_CONN = 5;
/**
* System message about some events (like rename).
*/
const KIND_EVENTS = 6;
/**
* Messaging window connection timeout.
*/
const CONNECTION_TIMEOUT = 30;
/**
* Contain mapping of thread object properties to fields in database.
*
* Keys are object properties and vlues are {chatthread} table fields. Properties are available via magic __get
* and __set methods. Real values are stored in the Thread::$threadInfo array.
*
* Thread object have following properties:
* - 'id': id of the thread
* - 'lastRevision': last revision number
* - 'state': state of the thread. See Thread::STATE_*
* - 'lastToken': last chat token
* - 'nextAgent': id of the next agent(agent that change current agent in the chat)
* - 'groupId': id of the group related to the thread
* - 'shownMessageId': last id of shown message
* - 'messageCount': count of user's messages related to the thread
* - 'created': unix timestamp of the thread creation
* - 'modified': unix timestamp of the thread's last modification
* - 'chatStarted': unix timestamp of related to thread chat started
* - 'agentId': id of an operator who take part in the chat
* - 'agentName': name of an operator who take part in the chat
* - 'agentTyping': "1" if operator typing at last ping time and "0" otherwise
* - 'lastPingAgent': unix timestamp of last operator ping
* - 'locale': locale code of the chat related to thread
* - 'userId': id of an user who take part in the chat
* - 'userName': name of an user who take part in the chat
* - 'userTyping': "1" if user typing at last ping time and "0" otherwise
* - 'lastPingUser': unix timestamp of last user ping
* - 'remote': user's IP
* - 'referer': content of HTTP Referer header for user
* - 'userAgent': content of HTTP User-agent header for user
*
* @var array
*
* @see Thread::__get()
* @see Thread::__set()
* @see Thread::$threadInfo
*/
protected $propertyMap = array(
'id' => 'threadid',
'lastRevision' => 'lrevision',
'state' => 'istate',
'lastToken' => 'ltoken',
'nextAgent' => 'nextagent',
'groupId' => 'groupid',
'shownMessageId' => 'shownmessageid',
'messageCount' => 'messageCount',
'created' => 'dtmcreated',
'modified' => 'dtmmodified',
'chatStarted' => 'dtmchatstarted',
'agentId' => 'agentId',
'agentName' => 'agentName',
'agentTyping' => 'agentTyping',
'lastPingAgent' => 'lastpingagent',
'locale' => 'locale',
'userId' => 'userid',
'userName' => 'userName',
'userTyping' => 'userTyping',
'lastPingUser' => 'lastpinguser',
'remote' => 'remote',
'referer' => 'referer',
'userAgent' => 'userAgent'
);
/**
* Contain loaded from database information about thread
*
* Do not use this property manually!
* @var array
*/
protected $threadInfo;
/**
* List of modified fields.
*
* Do not use this property manually!
* @var array
*/
protected $changedFields = array();
/**
* Forbid create instance from outside of the class
*/
protected function __construct() {}
/**
* Create new empty thread in database
*
* @return boolean|Thread Returns an object of the Thread class or boolean
* false on failure
*/
public static function create() {
// Get database object
$db = Database::getInstance();
// Create new empty thread
$thread = new self();
// Create thread
$db->query("insert into {chatthread} (threadid) values (NULL)");
// Set thread Id
// In this case Thread::$threadInfo array use because id of a thread
// should not be update
$thread->threadInfo['threadid'] = $db->insertedId();
// Check if something went wrong
if (empty($thread->id)) {
return false;
}
// Set initial values
$thread->lastToken = self::nextToken();
$thread->created = time();
return $thread;
}
/**
* Create thread object from database info.
*
* @param array $thread_info Associative array of Thread info from database.
* It must contains ALL thread table's
* FIELDS from the database.
* @return boolean|Thread Returns an object of the Thread class or boolean
* false on failure
*/
public static function createFromDbInfo($thread_info) {
// Create new empty thread
$thread = new self();
// Check thread fields
$obligatory_fields = array_values($thread->propertyMap);
foreach($obligatory_fields as $field) {
if (!array_key_exists($field, $thread_info)) {
// Obligatory field is missing
unset($thread);
return false;
}
// Copy field to Thread object
$thread->threadInfo[$field] = $thread_info[$field];
}
return $thread;
}
/**
* Load thread from database
*
* @param int $id ID of the thread to load
* @return boolean|Thread Returns an object of the Thread class or boolean
* false on failure
*/
public static function load($id, $last_token = null) {
// Check $id
if (empty($id)) {
return false;
}
// Get database object
$db = Database::getInstance();
// Create new empty thread
$thread = new self();
// Load thread
$thread_info = $db->query(
"select * from {chatthread} where threadid = :threadid",
array(
':threadid' => $id
),
array('return_rows' => Database::RETURN_ONE_ROW)
);
// There is no thread with such id in database
if (! $thread_info) {
return;
}
// Store thread properties
$thread->threadInfo = $thread_info;
// Check if something went wrong
if ($thread->id != $id) {
return false;
}
// Check last token
if (! is_null($last_token)) {
if ($thread->lastToken != $last_token) {
return false;
}
}
return $thread;
}
/**
* Reopen thread and send message about it
*
* @return boolean|Thread Boolean FALSE on failure or thread object on success
*/
public static function reopen($id) {
// Load thread
$thread = self::load($id);
// Check if user and agent gone
if (Settings::get('thread_lifetime') != 0 &&
abs($thread->lastPingUser - time()) > Settings::get('thread_lifetime') &&
abs($thread->lastPingAgent - time()) > Settings::get('thread_lifetime')) {
unset($thread);
return false;
}
// Check if thread closed
if ($thread->state == self::STATE_CLOSED || $thread->state == self::STATE_LEFT) {
unset($thread);
return false;
}
// Reopen thread
if ($thread->state == self::STATE_WAITING) {
$thread->nextAgent = 0;
$thread->save();
}
// Send message
$thread->postMessage(self::KIND_EVENTS, getstring_("chat.status.user.reopenedthread", $thread->locale));
return $thread;
}
/**
* Close all old threads that were not closed by some reasons
*/
public static function closeOldThreads() {
if (Settings::get('thread_lifetime') == 0) {
return;
}
$db = Database::getInstance();
$query = "update {chatthread} set lrevision = :next_revision, " .
"dtmmodified = :now, istate = :state_closed " .
"where istate <> :state_closed and istate <> :state_left " .
"and ((lastpingagent <> 0 and lastpinguser <> 0 and " .
"(ABS(:now - lastpinguser) > :thread_lifetime and " .
"ABS(:now - lastpingagent) > :thread_lifetime)) or " .
"(lastpingagent = 0 and lastpinguser <> 0 and " .
"ABS(:now - lastpinguser) > :thread_lifetime))";
$db->query(
$query,
array(
':next_revision' => self::nextRevision(),
':now' => time(),
':state_closed' => self::STATE_CLOSED,
':state_left' => self::STATE_LEFT,
':thread_lifetime' => Settings::get('thread_lifetime')
)
);
}
/**
* Check if connection limit reached
*
* @param string $remote User IP
* @return boolean TRUE if connection limit reached and FALSE otherwise
*/
public static function connectionLimitReached($remote) {
if (Settings::get('max_connections_from_one_host') == 0) {
return false;
}
$db = Database::getInstance();
$result = $db->query(
"select count(*) as opened from {chatthread} " .
"where remote = ? AND istate <> ? AND istate <> ?",
array($remote, Thread::STATE_CLOSED, Thread::STATE_LEFT),
array('return_rows' => Database::RETURN_ONE_ROW)
);
if ($result && isset($result['opened'])) {
return $result['opened'] >= Settings::get('max_connections_from_one_host');
}
return false;
}
/**
* Return next revision number (last revision number plus one)
*
* @return int revision number
*/
protected static function nextRevision() {
$db = Database::getInstance();
$db->query("update {chatrevision} set id=LAST_INSERT_ID(id+1)");
$val = $db->insertedId();
return $val;
}
/**
* Create thread token
*
* @return int Thread token
*/
protected static function nextToken() {
return rand(99999, 99999999);
}
/**
* Implementation of the magic __get method
*
* Check if variable with name $name exists in the Thread::$propertyMap array.
* If it does not exist triggers an error with E_USER_NOTICE level and returns false.
*
* @param string $name property name
* @return mixed
* @see Thread::$propertyMap
*/
public function __get($name) {
// Check property existance
if (! array_key_exists($name, $this->propertyMap)) {
trigger_error("Undefined property '{$name}'", E_USER_NOTICE);
return NULL;
}
$field_name = $this->propertyMap[$name];
return $this->threadInfo[$field_name];
}
/**
* Implementation of the magic __set method
*
* Check if variable with name $name exists in the Thread::$propertyMap
* array before setting. If it does not exist triggers an error
* with E_USER_NOTICE level and value will NOT set. If previous value is
* equal to new value the property will NOT be update and NOT update in
* database when Thread::save method call.
*
* @param string $name Property name
* @param mixed $value Property value
* @return mixed
* @see Thread::$propertyMap
*/
public function __set($name, $value) {
if (empty($this->propertyMap[$name])) {
trigger_error("Undefined property '{$name}'", E_USER_NOTICE);
return;
}
$field_name = $this->propertyMap[$name];
if (array_key_exists($field_name, $this->threadInfo)
&& ($this->threadInfo[$field_name] === $value)) {
return;
}
$this->threadInfo[$field_name] = $value;
if (! in_array($name, $this->changedFields)) {
$this->changedFields[] = $name;
}
}
/**
* Implementation of the magic __isset method
*
* Check if variable with $name exists.
*
* param string $name Variable name
* return boolean True if variable exists and false otherwise
*/
public function __isset($name) {
if (!array_key_exists($name, $this->propertyMap)) {
return false;
}
$property_name = $this->propertyMap[$name];
return isset($this->threadInfo[$property_name]);
}
/**
* Remove thread from database
*/
public function delete() {
$db = Database::getInstance();
$db->query(
"DELETE FROM {chatthread} WHERE threadid = :id LIMIT 1",
array(':id' => $this->id)
);
}
/**
* Ping the thread.
*
* Updates ping time for conversation members and sends messages about connection problems.
*
* @param boolean $is_user Indicates user or operator pings thread. Boolean true for user and boolean false
* otherwise.
* @param boolean $is_typing Indicates if user or operator is typing a message.
*/
public function ping($is_user, $is_typing) {
// Last ping time of other side
$last_ping_other_side = 0;
// Update last ping time
if ($is_user) {
$last_ping_other_side = $this->lastPingAgent;
$this->lastPingUser = time();
$this->userTyping = $is_typing ? "1" : "0";
} else {
$last_ping_other_side = $this->lastPingUser;
$this->lastPingAgent = time();
$this->agentTyping = $is_typing ? "1" : "0";
}
// Update thread state for the first user ping
if ($this->state == self::STATE_LOADING && $is_user) {
$this->state = self::STATE_QUEUE;
$this->save();
return;
}
// Check if other side of the conversation have connection problems
if ($last_ping_other_side > 0 && abs(time() - $last_ping_other_side) > self::CONNECTION_TIMEOUT) {
// Connection problems detected
if ($is_user) {
// _Other_ side is operator
// Update operator's last ping time
$this->lastPingAgent = 0;
// Check if user chatting at the moment
if ($this->state == self::STATE_CHATTING) {
// Send message to user
$message_to_post = getstring_("chat.status.operator.dead", $this->locale);
$this->postMessage(
self::KIND_CONN,
$message_to_post,
null,
null,
$last_ping_other_side + self::CONNECTION_TIMEOUT
);
// And update thread
$this->state = self::STATE_WAITING;
$this->nextAgent = 0;
}
} else {
// _Other_ side is user
// Update user's last ping time
$this->lastPingUser = 0;
// And send a message to operator
$message_to_post = getstring_("chat.status.user.dead", $this->locale);
$this->postMessage(
self::KIND_FOR_AGENT,
$message_to_post,
null,
null,
$last_ping_other_side + self::CONNECTION_TIMEOUT
);
}
}
$this->save(false);
}
/**
* Save the thread to the database
*
* @param boolean $update_revision Indicates if last modified time and last revision should be updated
*/
public function save($update_revision = true){
$db = Database::getInstance();
// Update modified time and last revision if need
if ($update_revision) {
$this->lastRevision = $this->nextRevision();
$this->modified = time();
}
// Do not save thread if nothing changed
if (empty($this->changedFields)) {
return;
}
$values = array();
$set_clause = array();
foreach ($this->changedFields as $field_name) {
$field_db_name = $this->propertyMap[$field_name];
$set_clause[] = "{$field_db_name} = ?";
$values[] = $this->threadInfo[$field_db_name];
}
$query = "update {chatthread} t set " . implode(', ', $set_clause) . " where threadid = ?";
$values[] = $this->id;
$db->query($query, $values);
// Trigger thread changed event
$args = array(
'thread' => $this,
'changed_fields' => $this->changedFields
);
$dispatcher = EventDispatcher::getInstance();
$dispatcher->triggerEvent('threadChanged', $args);
// Clear updated fields
$this->changedFields = array();
}
/**
* Check if thread is reassigned for another operator
*
* Updates thread info, send events messages and avatar message to user
* @global string $home_locale
* @param array $operator Operator for test
*/
public function checkForReassign($operator) {
global $home_locale;
$operator_name = ($this->locale == $home_locale) ? $operator['vclocalename'] : $operator['vccommonname'];
if ($this->state == self::STATE_WAITING &&
($this->nextAgent == $operator['operatorid'] || $this->agentId == $operator['operatorid'])) {
// Prepare message
if ($this->nextAgent == $operator['operatorid']) {
$message_to_post = getstring2_(
"chat.status.operator.changed",
array($operator_name, $this->agentName),
$this->locale
);
} else {
$message_to_post = getstring2_("chat.status.operator.returned", array($operator_name), $this->locale);
}
// Update thread info
$this->state = self::STATE_CHATTING;
$this->nextAgent = 0;
$this->agentId = $operator['operatorid'];
$this->agentName = $operator_name;
$this->save();
// Send messages
$this->postMessage(self::KIND_EVENTS, $message_to_post);
$this->setupAvatar(
$operator['vcavatar'] ? $operator['vcavatar'] : ""
);
}
}
/**
* Load messages from database corresponding to the thread those ID's more than $lastid
*
* @global $webim_encoding
* @param boolean $is_user Boolean TRUE if messages loads for user and boolean FALSE if they loads for operator.
* @param int $lastid ID of the last loaded message.
* @return array Array of messages
* @see Thread::postMessage()
*/
public function getMessages($is_user, &$last_id) {
global $webim_encoding;
$db = Database::getInstance();
// Load messages
$messages = $db->query(
"select messageid as id, ikind as kind, dtmcreated as created, tname as name, tmessage as message " .
"from {chatmessage} " .
"where threadid = :threadid and messageid > :lastid " .
($is_user ? "and ikind <> " . self::KIND_FOR_AGENT : "") .
" order by messageid",
array(
':threadid' => $this->id,
':lastid' => $last_id
),
array('return_rows' => Database::RETURN_ALL_ROWS)
);
foreach ($messages as $key => $msg) {
// Change message fields encoding
$messages[$key]['name'] = myiconv($webim_encoding, "utf-8", $msg['name']);
$messages[$key]['message'] = myiconv($webim_encoding, "utf-8", $msg['message']);
// Get last message ID
if ($msg['id'] > $last_id) {
$last_id = $msg['id'];
}
}
return $messages;
}
/**
* Send the messsage
*
* @param int $kind Message kind. One of the Thread::KIND_*
* @param string $message Message body
* @param string|null $from Sender name
* @param int|null $opid operator id. Use NULL for system messages
* @param int|null $time unix timestamp of the send time. Use NULL for current time.
* @return int Message ID
*
* @see Thread::KIND_USER
* @see Thread::KIND_AGENT
* @see Thread::KIND_FOR_AGENT
* @see Thread::KIND_INFO
* @see Thread::KIND_CONN
* @see Thread::KIND_EVENTS
* @see Thread::getMessages()
*/
public function postMessage($kind, $message, $from = null, $opid = null, $time = null) {
$db = Database::getInstance();
$query = "INSERT INTO {chatmessage} " .
"(threadid,ikind,tmessage,tname,agentId,dtmcreated) " .
"VALUES (:threadid,:kind,:message,:name,:agentid,:created)";
$values = array(
':threadid' => $this->id,
':kind' => $kind,
':message' => $message,
':name' => ($from ? $from : NULL),
':agentid' => ($opid ? $opid : 0),
':created' => ($time ? $time : time())
);
$db->query($query, $values);
return $db->insertedId();
}
/**
* Close thread and send closing messages to the conversation members
*
* @param boolean $is_user Boolean TRUE if user initiate thread closing or boolean FALSE otherwise
*/
public function close($is_user) {
$db = Database::getInstance();
// Get messages count
list($message_count) = $db->query(
"SELECT COUNT(*) FROM {chatmessage} WHERE {chatmessage}.threadid = :threadid AND ikind = :kind_user",
array(
':threadid' => $this->id,
':kind_user' => Thread::KIND_USER
),
array(
'return_rows' => Database::RETURN_ONE_ROW,
'fetch_type' => Database::FETCH_NUM
)
);
// Close thread if it's not already closed
if ($this->state != self::STATE_CLOSED) {
$this->state = self::STATE_CLOSED;
$this->messageCount = $message_count;
$this->save();
}
// Send message about closing
$message = '';
if ($is_user) {
$message = getstring2_("chat.status.user.left", array($this->userName), $this->locale);
} else {
$message = getstring2_("chat.status.operator.left", array($this->agentName), $this->locale);
}
$this->postMessage(self::KIND_EVENTS, $message);
}
/**
* Assign operator to thread
*
* @global string $home_locale
* @param array $operator Operator who try to take thread
* @return boolean Boolean TRUE on success or FALSE on failure
*/
public function take($operator) {
global $home_locale;
$take_thread = false;
$message = '';
$operator_name = ($this->locale == $home_locale) ? $operator['vclocalename'] : $operator['vccommonname'];
if ($this->state == self::STATE_QUEUE || $this->state == self::STATE_WAITING || $this->state == self::STATE_LOADING) {
// User waiting
$take_thread = true;
if ($this->state == self::STATE_WAITING) {
if ($operator['operatorid'] != $this->agentId) {
$message = getstring2_(
"chat.status.operator.changed",
array($operator_name, $this->agentName),
$this->locale
);
} else {
$message = getstring2_("chat.status.operator.returned", array($operator_name), $this->locale);
}
} else {
$message = getstring2_("chat.status.operator.joined", array($operator_name), $this->locale);
}
} elseif ($this->state == self::STATE_CHATTING) {
// User chatting
if ($operator['operatorid'] != $this->agentId) {
$take_thread = true;
$message = getstring2_(
"chat.status.operator.changed",
array($operator_name, $this->agentName),
$this->locale
);
}
} else {
// Thread closed
return false;
}
// Change operator and update chat info
if ($take_thread) {
$this->state = self::STATE_CHATTING;
$this->nextAgent = 0;
$this->agentId = $operator['operatorid'];
$this->agentName = $operator_name;
if (empty($this->chatStarted)) {
$this->chatStarted = time();
}
$this->save();
}
// Send message
if ($message) {
$this->postMessage(self::KIND_EVENTS, $message);
$this->setupAvatar(
$operator['vcavatar'] ? $operator['vcavatar'] : ""
);
}
return true;
}
/**
* Change user name in the conversation
*
* @param string $new_name New user name
*/
public function renameUser($new_name) {
// Rename only if a new name is realy new
if ($this->userName != $new_name) {
// Save old name
$old_name = $this->userName;
// Rename user
$this->userName = $new_name;
$this->save();
// Send message about renaming
$message = getstring2_(
"chat.status.user.changedname",
array($old_name, $new_name),
$this->locale
);
$this->postMessage(self::KIND_EVENTS, $message);
}
}
/**
* Set operator avatar in the user's chat window
* @param string $link URL of the new operator avatar
*/
protected function setupAvatar($link) {
$processor = ThreadProcessor::getInstance();
$processor->call(array(
array(
'function' => 'setupAvatar',
'arguments' => array(
'threadId' => $this->id,
'token' => $this->lastToken,
'return' => array(),
'references' => array(),
'recipient' => 'user',
'imageLink' => $link
)
)
));
}
}
?>