Newer
Older
<?php
/*********************************************************************
class.thread.php
XXX: Please DO NOT add any ticket related logic! use ticket class.
Peter Rotich <peter@osticket.com>
http://www.osticket.com
Released under the GNU General Public License WITHOUT ANY WARRANTY.
See LICENSE.TXT for details.
vim: expandtab sw=4 ts=4 sts=4:
**********************************************************************/
include_once(INCLUDE_DIR.'class.ticket.php');
include_once(INCLUDE_DIR.'class.draft.php');
include_once(INCLUDE_DIR.'class.role.php');
class Thread extends VerySimpleModel
implements Searchable {
static $meta = array(
'table' => THREAD_TABLE,
'pk' => array('id'),
'joins' => array(
'ticket' => array(
'constraint' => array(
'object_type' => "'T'",
'object_id' => 'Ticket.ticket_id',
'task' => array(
'constraint' => array(
'object_type' => "'A'",
'object_id' => 'Task.id',
),
),
'collaborators' => array(
'reverse' => 'Collaborator.thread',
),
'referrals' => array(
'reverse' => 'ThreadReferral.thread',
),
'entries' => array(
'reverse' => 'ThreadEntry.thread',
'events' => array(
'reverse' => 'ThreadEvent.thread',
'broker' => 'ThreadEvents',
const MODE_STAFF = 1;
const MODE_CLIENT = 2;
var $_object;
var $_collaborators; // Cache for collabs
return $this->id;
return $this->object_id;
return $this->object_type;
if (!$this->_object)
$this->_object = ObjectModel::lookup(
$this->getObjectId(), $this->getObjectType());
return Attachment::objects()->filter(array(
'thread_entry__thread' => $this
))->count();
return $this->entries->count();
}
function getEntries($criteria=false) {
if (!isset($this->_entries)) {
$this->_entries = $this->entries->annotate(array(
'has_attachments' => SqlAggregate::COUNT(SqlCase::N()
->when(array('attachments__inline'=>0), 1)
->otherwise(null)
),
));
$this->_entries->exclude(array('flags__hasbit'=>ThreadEntry::FLAG_HIDDEN));
if ($criteria)
$this->_entries->filter($criteria);
}
return $this->_entries;
// Referrals
function getNumReferrals() {
return $this->referrals->count();
}
function getReferrals() {
return $this->referrals;
}
// Collaborators
function getNumCollaborators() {
return $this->collaborators->count();
if (!isset($this->ht['active_collaborators']))
$this->ht['active_collaborators'] = count($this->getActiveCollaborators());
return $this->ht['active_collaborators'];
$collaborators = $this->getCollaborators();
$active = array();
foreach ($collaborators as $c) {
$active[] = $c;
}
return $active;
function getCollaborators($criteria=array()) {
if ($this->_collaborators && !$criteria)
return $this->_collaborators;
$collaborators = $this->collaborators
->filter(array('thread_id' => $this->getId()));
if (isset($criteria['isactive']))
$collaborators->filter(array('flags__hasbit'=>Collaborator::FLAG_ACTIVE));
// TODO: sort by name of the user
$collaborators->order_by('user__name');
if (!$criteria)
$this->_collaborators = $collaborators;
return $collaborators;
function addCollaborator($user, $vars, &$errors, $event=true) {
if (!$user)
return null;
$vars = array_merge(array(
'threadId' => $this->getId(),
'userId' => $user->getId()), $vars);
if (!($c=Collaborator::add($vars, $errors)))
return null;
$this->_collaborators = null;
if ($event)
$this->getEvents()->log($this->getObject(),
'collab',
array('add' => array($user->getId() => array(
'name' => $user->getName()->getOriginal(),
'src' => @$vars['source'],
))
)
);
function updateCollaborators($vars, &$errors) {
global $thisstaff;
if (!$thisstaff) return;
//Deletes
if($vars['del'] && ($ids=array_filter($vars['del']))) {
$collabs = array();
foreach ($ids as $k => $cid) {
if (($c=Collaborator::lookup($cid))
&& ($c->getThreadId() == $this->getId())
$this->getEvents()->log($this->getObject(), 'collab', array(
'del' => array($c->user_id => array('name' => $c->getName()->getOriginal()))
));
//statuses
$cids = null;
if($vars['cid'] && ($cids=array_filter($vars['cid']))) {
$this->collaborators->filter(array(
'thread_id' => $this->getId(),
'id__in' => $cids
))->update(array(
'updated' => SqlFunction::NOW(),
));
foreach ($vars['cid'] as $c) {
$collab = Collaborator::lookup($c);
if(get_class($collab) == 'Collaborator') {
$collab->setFlag(Collaborator::FLAG_ACTIVE, true);
$collab->save();
}
}
$inactive = $this->collaborators->filter(array(
'thread_id' => $this->getId(),
Q::not(array('id__in' => $cids ?: array(0)))
));
if($inactive) {
foreach ($inactive as $i) {
$i->setFlag(Collaborator::FLAG_ACTIVE, false);
$i->save();
}
$inactive->update(array(
'updated' => SqlFunction::NOW(),
));
}
unset($this->ht['active_collaborators']);
$this->_collaborators = null;
return true;
//UserList of participants (collaborators)
function getParticipants() {
if (!isset($this->_participants)) {
$list = new UserList();
if ($collabs = $this->getActiveCollaborators()) {
foreach ($collabs as $c)
$list->add($c);
}
$this->_participants = $list;
}
function getReferral($id, $type) {
return $this->referrals->findFirst(array(
'object_id' => $id,
'object_type' => $type));
}
function isReferred($to=null, $strict=false) {
if (is_null($to) || !$this->referrals)
return ($this->referrals && $this->referrals->count());
switch (true) {
case $to instanceof Staff:
// Referred to the staff
if ($this->getReferral($to->getId(),
ObjectModel::OBJECT_TYPE_STAFF))
return true;
// Strict check only checks the Agent
if ($strict)
return false;
// Referred to staff's department
if ($this->referrals->findFirst(array(
'object_id__in' => $to->getDepts(),
'object_type' => ObjectModel::OBJECT_TYPE_DEPT)))
// Referred to staff's teams
if ($to->getTeams() && $this->referrals->findFirst(array(
'object_type' => ObjectModel::OBJECT_TYPE_TEAM
)))
break;
case $to instanceof Dept:
// Refered to the dept
return ($this->getReferral($to->getId(),
ObjectModel::OBJECT_TYPE_DEPT));
$vars = array('thread_id' => $this->getId());
switch (true) {
case $to instanceof Staff:
$vars['object_id'] = $to->getId();
$vars['object_type'] = ObjectModel::OBJECT_TYPE_STAFF;
break;
case $to instanceof Team:
$vars['object_id'] = $to->getId();
$vars['object_type'] = ObjectModel::OBJECT_TYPE_TEAM;
break;
case $to instanceof Dept:
$vars['object_id'] = $to->getId();
$vars['object_type'] = ObjectModel::OBJECT_TYPE_DEPT;
break;
default:
return false;
}
return ThreadReferral::create($vars);
}
function render($type=false, $options=array()) {
$mode = $options['mode'] ?: self::MODE_STAFF;
// Register thread actions prior to rendering the thread.
if (!class_exists('tea_showemailheaders'))
include_once INCLUDE_DIR . 'class.thread_actions.php';
$entries = $this->getEntries();
if ($type && is_array($type)) {
$visibility = Q::all(array('type__in' => $type));
if ($type['user_id']) {
$visibility->add(array('user_id' => $type['user_id']));
$visibility->ored = true;
}
$entries->filter($visibility);
}
if ($options['sort'] && !strcasecmp($options['sort'], 'DESC'))
$entries->order_by('-id');
// Precache all the attachments on this thread
AttachmentFile::objects()->filter(array(
'attachments__thread_entry__thread__id' => $this->id
))->all();
$inc = ($mode == self::MODE_STAFF) ? STAFFINC_DIR : CLIENTINC_DIR;
include $inc . 'templates/thread-entries.tmpl.php';
function getEvents() {
return $this->events;
}
/**
* postEmail
*
* After some security and sanity checks, attaches the body and subject
* of the message in reply to this thread item
*
* Parameters:
* mailinfo - (array) of information about the email, with at least the
* following keys
* - mid - (string) email message-id
* - name - (string) personal name of email originator
* - email - (string<email>) originating email address
* - subject - (string) email subject line (decoded)
* - body - (string) email message body (decoded)
*/
function postEmail($mailinfo, $entry=null) {
// +==================+===================+=============+
// | Orig Thread-Type | Reply Thread-Type | Requires |
// +==================+===================+=============+
// | * | Message (M) | From: Owner |
// | * | Note (N) | From: Staff |
// | Response (R) | Message (M) | |
// | Message (M) | Response (R) | From: Staff |
// +------------------+-------------------+-------------+
if (!$object = $this->getObject()) {
// How should someone find this thread?
}
elseif ($object instanceof Ticket && (
!$mailinfo['staffId']
&& $object->isClosed()
&& !$object->isReopenable()
)) {
// Ticket is closed, not reopenable, and email was not submitted
// by an agent. Email cannot be submitted
return false;
}
$vars = array(
'mid' => $mailinfo['mid'],
'header' => $mailinfo['header'],
'poster' => $mailinfo['name'],
'origin' => 'Email',
'source' => 'Email',
'ip' => '',
'recipients' => $mailinfo['recipients'],
'thread_entry_recipients' => $mailinfo['thread_entry_recipients'],
'to-email-id' => $mailinfo['to-email-id'],
// XXX: Is this necessary?
if ($object instanceof Ticket)
$vars['ticketId'] = $object->getId();
if ($object instanceof Task)
$vars['taskId'] = $object->getId();
if (isset($mailinfo['attachments']))
$vars['attachments'] = $mailinfo['attachments'];
// extra handling for determining Cc collabs
if ($mailinfo['email']) {
$staffSenderId = Staff::getIdByEmail($mailinfo['email']);
if (!$staffSenderId) {
$senderId = UserEmailModel::getIdByEmail($mailinfo['email']);
if ($senderId) {
$mailinfo['userId'] = $senderId;
if ($object instanceof Ticket && $senderId != $object->user_id && $senderId != $object->staff_id) {
$mailinfo['userClass'] = 'C';
$collaboratorId = Collaborator::getIdByUserId($senderId, $this->getId());
$collaborator = Collaborator::lookup($collaboratorId);
if ($collaborator && ($collaborator->isCc()))
$vars['thread-type'] = 'M';
}
}
}
}
// Attempt to determine the user posting the entry and the
// corresponding entry type by the information determined by the
// mail parser (via the In-Reply-To header)
switch ($mailinfo['userClass']) {
case 'C': # Thread collaborator
$vars['flags'] = ThreadEntry::FLAG_COLLABORATOR;
case 'U': # Ticket owner
$vars['thread-type'] = 'M';
$vars['userId'] = $mailinfo['userId'];
break;
case 'A': # System administrator
case 'S': # Staff member (agent)
$vars['thread-type'] = 'N';
$vars['staffId'] = $mailinfo['staffId'];
if ($vars['staffId'])
$vars['poster'] = Staff::lookup($mailinfo['staffId']);
// The user type was not identified by the mail parsing system. It
// is likely that the In-Reply-To and References headers were not
// properly brokered by the user's mail client. Use the old logic to
// determine the post type.
default:
// Disambiguate if the user happens also to be a staff member of
// the system. The current ticket owner should _always_ post
// messages instead of notes or responses
if ($object instanceof Ticket
&& strcasecmp($mailinfo['email'], $object->getEmail()) == 0
) {
$vars['thread-type'] = 'M';
$vars['userId'] = $object->getUserId();
}
// Consider collaborator role (disambiguate staff members as
// collaborators). Normally, the block above should match based
// on the Referenced message-id header
elseif ($C = $this->collaborators->filter(array(
'user__emails__address' => $mailinfo['email']
))->first()) {
$vars['thread-type'] = 'M';
// XXX: There's no way that mailinfo[userId] would be set
$vars['userId'] = $mailinfo['userId'] ?: $C->getUserId();
$vars['flags'] = ThreadEntry::FLAG_COLLABORATOR;
}
// Don't process the email -- it came FROM this system
elseif (Email::getIdByEmail($mailinfo['email'])) {
return false;
// Ensure we record the name of the person posting
$vars['poster'] = $vars['poster']
?: $mailinfo['name'] ?: $mailinfo['email'];
// TODO: Consider security constraints
//XXX: Are we potentially leaking the email address to
// collaborators?
// Try not to destroy the format of the body
$header = sprintf(
_S('Received From: %1$s <%2$s>') . "\n\n",
$mailinfo['name'], $mailinfo['email']);
if ($body instanceof HtmlThreadEntryBody)
$header = nl2br(Format::htmlchars($header));
// Add the banner to the top of the message
if ($body instanceof ThreadEntryBody)
$body->prepend($header);
$vars['userId'] = 0; //Unknown user! //XXX: Assume ticket owner?
$vars['thread-type'] = 'M';
}
switch ($vars['thread-type']) {
case 'M':
$vars['message'] = $body;
if ($object instanceof Threadable)
return $object->postThreadEntry('M', $vars);
elseif ($this instanceof ObjectThread)
return $this->addMessage($vars, $errors);
break;
case 'N':
$vars['note'] = $body;
if ($object instanceof Threadable)
return $object->postThreadEntry('N', $vars);
elseif ($this instanceof ObjectThread)
return $this->addNote($vars, $errors);
throw new Exception('Unable to continue thread via email.');
// Currently impossible, but indicate that this thread object could
// not append the incoming email.
return false;
$deleted = Attachment::objects()->filter(array(
'thread_entry__thread' => $this,
))->delete();
if ($deleted)
function removeCollaborators() {
return Collaborator::objects()
->filter(array('thread_id'=>$this->getId()))
->delete();
/**
* Function: lookupByEmailHeaders
*
* Attempt to locate a thread by the email headers. It should be
* considered a secondary lookup to ThreadEntry::lookupByEmailHeaders(),
* which should find an actual thread entry, which should be possible
* for all email communcation which is associated with a thread entry.
* The only time where this is useful is for threads which triggered
* email communication without a thread entry, for instance, like
* tickets created without an initial message.
*/
function lookupByEmailHeaders(&$mailinfo) {
$possibles = array();
foreach (array('mid', 'in-reply-to', 'references') as $header) {
$matches = array();
if (!isset($mailinfo[$header]) || !$mailinfo[$header])
continue;
// Header may have multiple entries (usually separated by
// spaces ( )
elseif (!preg_match_all('/<([^>@]+@[^>]+)>/', $mailinfo[$header],
$matches))
continue;
// The References header will have the most recent message-id
// (parent) on the far right.
// @see rfc 1036, section 2.2.5
// @see http://www.jwz.org/doc/threading.html
$possibles = array_merge($possibles, array_reverse($matches[1]));
// Add the message id if it is embedded in the body
$match = array();
if (preg_match('`(?:class="mid-|Ref-Mid: )([^"\s]*)(?:$|")`',
$mailinfo['message'], $match)
&& !in_array($match[1], $possibles)
) {
$possibles[] = $match[1];
}
foreach ($possibles as $mid) {
// Attempt to detect the ticket and user ids from the
// message-id header. If the message originated from
// osTicket, the Mailer class can break it apart. If it came
// from this help desk, the 'loopback' property will be set
// to true.
$mid_info = Mailer::decodeMessageId($mid);
if (!$mid_info || !$mid_info['loopback'])
continue;
if (isset($mid_info['uid'])
&& @$mid_info['threadId']
&& ($t = Thread::lookup($mid_info['threadId']))
) {
if (@$mid_info['userId']) {
$mailinfo['userId'] = $mid_info['userId'];
}
elseif (@$mid_info['staffId']) {
$mailinfo['staffId'] = $mid_info['staffId'];
}
// ThreadEntry was positively identified
return $t;
}
}
static function getSearchableFields() {
return array(
'lastmessage' => new DatetimeField(array(
'label' => __('Last Message'),
)),
'lastresponse' => new DatetimeField(array(
'label' => __('Last Response'),
)),
);
}
static function supportsCustomData() {
false;
}
if (!parent::delete())
return false;
ThreadEntryEmailInfo::objects()
->filter(array('thread_entry__thread' => $this))
->update(array('headers' => null));
$this->removeCollaborators();
$this->entries->delete();
// Null out the events
$this->events->update(array('thread_id' => 0));
static function create($vars=false) {
$inst = new static($vars);
$inst->created = SqlFunction::NOW();
return $inst;
class ThreadEntryEmailInfo extends VerySimpleModel {
static $meta = array(
'table' => THREAD_ENTRY_EMAIL_TABLE,
'pk' => array('id'),
'joins' => array(
'thread_entry' => array(
'constraint' => array('thread_entry_id' => 'ThreadEntry.id'),
),
),
);
class ThreadEntry extends VerySimpleModel
implements TemplateVariable {
static $meta = array(
'table' => THREAD_ENTRY_TABLE,
'pk' => array('id'),
'select_related' => array('staff', 'user', 'email_info'),
'ordering' => array('created', 'id'),
'joins' => array(
'thread' => array(
'constraint' => array('thread_id' => 'Thread.id'),
'parent' => array(
'constraint' => array('pid' => 'ThreadEntry.id'),
'null' => true,
),
'children' => array(
'reverse' => 'ThreadEntry.parent',
),
'email_info' => array(
'reverse' => 'ThreadEntryEmailInfo.thread_entry',
'list' => false,
),
'attachments' => array(
'reverse' => 'Attachment.thread_entry',
'null' => true,
),
'staff' => array(
'constraint' => array('staff_id' => 'Staff.staff_id'),
'null' => true,
),
'user' => array(
'constraint' => array('user_id' => 'User.id'),
'null' => true,
),
),
);
const FLAG_ORIGINAL_MESSAGE = 0x0001;
const FLAG_EDITED = 0x0002;
const FLAG_HIDDEN = 0x0004;
const FLAG_GUARDED = 0x0008; // No replace on edit
const FLAG_COLLABORATOR = 0x0020; // Message from collaborator
const FLAG_BALANCED = 0x0040; // HTML does not need to be balanced on ::display()
const FLAG_SYSTEM = 0x0080; // Entry is a system note.
const FLAG_REPLY_ALL = 0x00100; // Agent response, reply all
const FLAG_REPLY_USER = 0x00200; // Agent response, reply to User
const PERM_EDIT = 'thread.edit';
var $_headers;
var $_thread;
static protected $perms = array(
self::PERM_EDIT => array(
'title' => /* @trans */ 'Edit Thread',
'desc' => /* @trans */ 'Ability to edit thread items of other agents',
),
);
// Thread entry types
static protected $types = array(
'M' => 'message',
'R' => 'response',
'N' => 'note',
);
function getTypeName() {
return self::$types[$this->type];
}
if (!($thread = $this->getThread()))
// Kind of hard to continue a discussion without a thread ...
return false;
elseif ($this->getEmailMessageId() == $mailinfo['mid'])
// Reporting success so the email can be moved or deleted.
return true;
// Mail sent by this system will have a predictable message-id
// If this incoming mail matches the code, then it very likely
// originated from this system and looped
$info = Mailer::decodeMessageId($mailinfo['mid']);
if ($info && $info['loopback']) {
// This mail was sent by this system. It was received due to
// some kind of mail delivery loop. It should not be considered
// a response to an existing thread entry
if ($ost)
$ost->log(LOG_ERR, _S('Email loop detected'), sprintf(
_S('It appears as though <%s> is being used as a forwarded or fetched email account and is also being used as a user / system account. Please correct the loop or seek technical assistance.'),
$mailinfo['email']),
// This is quite intentional -- don't continue the loop
false,
// Force the message, even if logging is disabled
true);
return $this;
}
return $thread->postEmail($mailinfo, $this);
}
function getId() {
return $this->id;
}
function getPid() {
return $this->get('pid', 0);
function getParent() {
function getType() {
return $this->type;
}
function getSource() {
return $this->source;
}
function getPoster() {
return $this->poster;
}
function getTitle() {
return $this->title;
}
function getBody() {
if (!isset($this->_body)) {
$body = $this->body;
if ($body == null && $this->getNumAttachments()) {
foreach ($this->attachments as $a)
if ($a->inline && ($f=$a->getFile()))
$body .= $f->getData();
}
$this->_body = ThreadEntryBody::fromFormattedText($body, $this->format,
array('balanced' => $this->hasFlag(self::FLAG_BALANCED))
);
}
return $this->_body;
function setBody($body) {
global $cfg;
if ($cfg->isRichTextEnabled())
$this->format = $body->getType();
$this->body = (string) $body;
return $this->save();
function getMessage() {
return $this->getBody();
}
function getCreateDate() {
return $this->created;
}
function getUpdateDate() {
return $this->updated;
}
function getNumAttachments() {
return $this->attachments->count();
function getEmailMessageId() {
if ($this->email_info)
return $this->email_info->mid;
function getEmailHeaderArray() {
require_once(INCLUDE_DIR.'class.mailparse.php');
if (!isset($this->_headers) && $this->email_info
&& isset($this->email_info->headers)
) {
$this->_headers = Mail_Parse::splitHeaders($this->email_info->headers);
}
return $this->_headers;
function getEmailReferences($include_mid=true) {
$references = '';
$headers = self::getEmailHeaderArray();
if (isset($headers['References']) && $headers['References'])
$references = $headers['References']." ";
if ($include_mid && ($mid = $this->getEmailMessageId()))
$references .= $mid;
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
/**
* Retrieve a list of all the recients of this message if the message
* was received via email.
*
* Returns:
* (array<RFC_822>) list of recipients parsed with the Mail/RFC822
* address parsing utility. Returns an empty array if the message was
* not received via email.
*/
function getAllEmailRecipients() {
$headers = self::getEmailHeaderArray();
$recipients = array();
if (!$headers)
return $recipients;
foreach (array('To', 'Cc') as $H) {
if (!isset($headers[$H]))
continue;
if (!($all = Mail_Parse::parseAddressList($headers[$H])))
continue;
$recipients = array_merge($recipients, $all);
}
return $recipients;
}
/**
* Recurse through the ancestry of this thread entry to find the first
* thread entry which cites a email Message-ID field.
*
* Returns:
* <ThreadEntry> or null if neither this thread entry nor any of its
* ancestry contains an email header with an email Message-ID header.
*/
function findOriginalEmailMessage() {
$P = $this;
while (!$P->getEmailMessageId()
&& ($P = $P->getParent()));
return $P;
}
function getUIDFromEmailReference($ref) {
$info = unpack('Vtid/Vuid',
Base32::decode(strtolower(substr($ref, -13))));
if ($info && $info['tid'] == $this->getId())
return $info['uid'];
}
return $this->thread_id;
if (!isset($this->_thread) && $this->thread_id)
// TODO: Consider typing the thread based on its type field
$this->_thread = ObjectThread::lookup($this->getThreadId());
return $this->_thread;
}
function getStaffId() {
return isset($this->staff_id) ? $this->staff_id : 0;
}
function getStaff() {
return $this->staff;
}
function getUserId() {
return isset($this->user_id) ? $this->user_id : 0;
}
function getUser() {
return $this->user;
}
function getEditor() {
static $types = array(
'U' => 'User',
'S' => 'Staff',
);
if (!isset($types[$this->editor_type]))
return null;
return $types[$this->editor_type]::lookup($this->editor);
}
function getName() {
if ($this->staff_id)
return $this->staff->getName();
if ($this->user_id)
return $this->user->getName();
return $this->poster;
if ($this->email_info)
return $this->email_info->headers;
function isAutoReply() {
if (!isset($this->is_autoreply))
$this->is_autoreply = $this->getEmailHeaderArray()
? TicketFilter::isAutoReply($this->getEmailHeaderArray()) : false;
return $this->is_autoreply;
function isBounce() {
if (!isset($this->is_bounce))
$this->is_bounce = $this->getEmailHeaderArray()
? TicketFilter::isBounce($this->getEmailHeaderArray()) : false;
return $this->is_bounce;
function isBounceOrAutoReply() {
return ($this->isAutoReply() || $this->isBounce());
function hasFlag($flag) {
return ($this->get('flags', 0) & $flag) != 0;
}
function clearFlag($flag) {
return $this->set('flags', $this->get('flags') & ~$flag);
}
function setFlag($flag) {
return $this->set('flags', $this->get('flags') | $flag);
}
function isSystem() {
return $this->hasFlag(self::FLAG_SYSTEM);
}
protected function normalizeFileInfo($files, $add_error=true) {
static $error_descriptions = array(
UPLOAD_ERR_INI_SIZE => /* @trans */ 'File is too large',
UPLOAD_ERR_FORM_SIZE => /* @trans */ 'File is too large',
UPLOAD_ERR_PARTIAL => 'The uploaded file was only partially uploaded.',
UPLOAD_ERR_NO_TMP_DIR => 'Missing a temporary folder.',
UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk.',
UPLOAD_ERR_EXTENSION => 'A PHP extension stopped the file upload.',
);
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
if (!is_array($files))
$files = array($files);
$ids = array();
foreach ($files as $name => $file) {
$F = array('inline' => is_array($file) && @$file['inline']);
if (is_numeric($file))
$fileId = $file;
elseif ($file instanceof AttachmentFile)
$fileId = $file->getId();
elseif (is_array($file) && isset($file['id']))
$fileId = $file['id'];
elseif ($AF = AttachmentFile::create($file))
$fileId = $AF->getId();
elseif ($add_error) {
$error = $file['error']
?: sprintf(_S('Unable to import attachment - %s'),
$name ?: $file['name']);
if (is_numeric($error) && isset($error_descriptions[$error])) {
$error = sprintf(_S('Error #%1$d: %2$s'), $error,
_S($error_descriptions[$error]));
}
// No need to log the missing-file error number
if ($error != UPLOAD_ERR_NO_FILE
&& ($thread = $this->getThread())
) {
// Log to the thread directly, since alerts should be
// suppressed and this is defintely a system message
$thread->addNote(array(
'title' => _S('File Import Error'),
'note' => new TextThreadEntryBody($error),
'poster' => 'SYSTEM',
'staffId' => 0,
));
}
$F['id'] = $fileId;
if (is_string($name))
$F['name'] = $name;
if (isset($AF))
$F['file'] = $AF;
// Add things like the `key` field, but don't change current
// keys of the file array
if (is_array($file))
$F += $file;
// Key is required for CID rewriting in the body
if (!isset($F['key']) && ($AF = AttachmentFile::lookup($F['id'])))
$F['key'] = $AF->key;
}
/*
Save attachment to the DB.
@file is a mixed var - can be ID or file hashtable.
*/
function createAttachment($file, $name=false) {
$att = new Attachment(array(
'type' => 'H',
'object_id' => $this->getId(),
'file_id' => $file['id'],
'inline' => $file['inline'] ? 1 : 0,
// Record varying file names in the attachment record
if (is_array($file) && isset($file['name'])) {
$filename = $file['name'];
}
elseif (is_string($name)) {
$filename = $name;
}
if ($filename) {
// This should be a noop since the ORM caches on PK
$F = @$file['file'] ?: AttachmentFile::lookup($file['id']);
// XXX: This is not Unicode safe
if ($F && 0 !== strcasecmp($F->name, $filename))
$att->name = $filename;
}
if (!$att->save())
return false;
return $att;
function createAttachments(array $files) {
$attachments = array();
foreach ($files as $info) {
if ($A = $this->createAttachment($info, @$info['name'] ?: false))
$attachments[] = $A;
return $attachments;
}
function getAttachments() {
return $this->attachments;
}
foreach ($this->attachments as $att) {
$json[$att->file->getKey()] = array(
'download_url' => $att->file->getDownloadUrl(),
'filename' => $att->getFilename(),
return $json;
}
function getAttachmentsLinks($file='attachment.php', $target='_blank', $separator=' ') {
// TODO: Move this to the respective UI templates
foreach ($this->attachments as $att ) {
if ($att->inline) continue;
if ($att->file->size)
$size=sprintf('<em>(%s)</em>', Format::file_size($att->file->size));
$str .= sprintf(
'<a class="Icon file no-pjax" href="%s" target="%s">%s</a>%s %s',
$att->file->getDownloadUrl(), $target,
Format::htmlchars($att->file->name), $size, $separator);
}
return $str;
}
/* save email info
* TODO: Refactor it to include outgoing emails on responses.
*/
function saveEmailInfo($vars) {
// Don't save empty message ID
if (!$vars || !$vars['mid'])
$this->ht['email_mid'] = $vars['mid'];
$header = false;
if (isset($vars['header']))
$header = $vars['header'];
self::logEmailHeaders($this->getId(), $vars['mid'], $header);
/* static */
function logEmailHeaders($id, $mid, $header=false) {
$this->email_info = new ThreadEntryEmailInfo(array(
'thread_entry_id' => $id,
'mid' => $mid,
));
$this->email_info->headers = trim($header);
return $this->email_info->save();
function getActivity() {
return new ThreadActivity('', '');
/* variables */
function __toString() {
return (string) $this->getBody();
// TemplateVariable interface
return (string) $this->getBody()->display('email');
function getVar($tag) {
switch(strtolower($tag)) {
case 'create_date':
return new FormattedDate($this->getCreateDate());
case 'update_date':
return new FormattedDate($this->getUpdateDate());
case 'files':
throw new OOBContent(OOBContent::FILES, $this->attachments->all());
static function getVarScope() {
return array(
'files' => __('Attached files'),
'body' => __('Message body'),
'create_date' => array(
'class' => 'FormattedDate', 'desc' => __('Date created'),
),
'ip_address' => __('IP address of remote user, for web submissions'),
'poster' => __('Submitter of the thread item'),
'class' => 'Staff', 'desc' => __('Agent posting the note or response'),
'title' => __('Subject, if any'),
'class' => 'User', 'desc' => __('User posting the message'),
/**
* Parameters:
* mailinfo (hash<String>) email header information. Must include keys
* - "mid" => Message-Id header of incoming mail
* - "in-reply-to" => Message-Id the email is a direct response to
* - "references" => List of Message-Id's the email is in response
* - "subject" => Find external ticket number in the subject line
*
* seen (by-ref:bool) a flag that will be set if the message-id was
* positively found, indicating that the message-id has been
* previously seen. This is useful if no thread-id is associated
* with the email (if it was rejected for instance).
function lookupByEmailHeaders(&$mailinfo, &$seen=false) {
// Search for messages using the References header, then the
// in-reply-to header
if ($mailinfo['mid'] &&
($entry = ThreadEntry::objects()
->filter(array('email_info__mid' => $mailinfo['mid']))
->order_by(false)
->first()
)
) {
if ($mailinfo['system_emails']
&& ($t = $entry->getThread()->getObject())
&& $t instanceof Ticket)
$t->systemReferral($mailinfo['system_emails']);
return $entry;
foreach (array('mid', 'in-reply-to', 'references') as $header) {
$matches = array();
if (!isset($mailinfo[$header]) || !$mailinfo[$header])
continue;
// Header may have multiple entries (usually separated by
elseif (!preg_match_all('/<([^>@]+@[^>]+)>/', $mailinfo[$header],
$matches))
continue;
// The References header will have the most recent message-id
// (parent) on the far right.
// @see rfc 1036, section 2.2.5
// @see http://www.jwz.org/doc/threading.html
$possibles = array_merge($possibles, array_reverse($matches[1]));
}
// Add the message id if it is embedded in the body
$match = array();
if (preg_match('`(?:class="mid-|Ref-Mid: )([^"\s]*)(?:$|")`',
(string) $mailinfo['message'], $match)
&& !in_array($match[1], $possibles)
) {
$possibles[] = $match[1];
}
$thread = null;
foreach ($possibles as $mid) {
// Attempt to detect the ticket and user ids from the
// message-id header. If the message originated from
// osTicket, the Mailer class can break it apart. If it came
// from this help desk, the 'loopback' property will be set
// to true.
$mid_info = Mailer::decodeMessageId($mid);
if (!$mid_info || !$mid_info['loopback'])
continue;
if (isset($mid_info['uid'])
&& @$mid_info['entryId']
&& ($t = ThreadEntry::lookup($mid_info['entryId']))
&& ($t->thread_id == $mid_info['threadId'])
) {
if (@$mid_info['userId']) {
$mailinfo['userId'] = $mid_info['userId'];
$user = User::lookupByEmail($mailinfo['email']);
if ($user && $mailinfo['userId'] != $user->getId())
$mailinfo['userId'] = $user->getId();
elseif (@$mid_info['staffId']) {
$mailinfo['staffId'] = $mid_info['staffId'];
$staffId = Staff::getIdByEmail($mailinfo['email']);
if ($staffId && $mailinfo['staffId'] != $staffId)
$mailinfo['staffId'] = $staffId;
// Capture the user type
if (@$mid_info['userClass'])
$mailinfo['userClass'] = $mid_info['userClass'];
// ThreadEntry was positively identified
return $t;
}
if (count($possibles)
&& ($entry = ThreadEntry::objects()
->filter(array('email_info__mid__in' => array_map(
function ($a) { return "<$a>"; },
$possibles)))
->first()
)
) {
$mailinfo['passive'] = true;
return $entry;
}
// Search for ticket by the [#123456] in the subject line
// This is the last resort - emails must match to avoid message
// injection by third-party.
$subject = $mailinfo['subject'];
$match = array();
if ($subject
&& $mailinfo['email']
// Required `#` followed by one or more of
// punctuation (-) then letters, numbers, and symbols
// (Try not to match closing punctuation (`]`) in [#12345])
&& preg_match("/#((\p{P}*[^\p{C}\p{Z}\p{P}]+)+)/u", $subject, $match)
//Lookup by ticket number
&& ($ticket = Ticket::lookupByNumber($match[1]))
//Lookup the user using the email address
&& ($user = User::lookup(array('emails__address' => $mailinfo['email'])))) {
//We have a valid ticket and user
if ($ticket->getUserId() == $user->getId() //owner
|| ($c = Collaborator::lookup( // check if collaborator
array('user_id' => $user->getId(),
'thread_id' => $ticket->getThreadId())))) {
$mailinfo['userId'] = $user->getId();
return $ticket->getLastMessage();
}
}
return null;
}
/**
* Find a thread entry from a message-id created from the
* ::asMessageId() method.
*
* *DEPRECATED* use Mailer::decodeMessageId() instead
function lookupByRefMessageId($mid, $from) {
$mid = trim($mid, '<>');
list($ver, $ids, $mails) = explode('$', $mid, 3);
// Current version is <null>
if ($ver !== '')
return false;
$ids = @unpack('Vthread', base64_decode($ids));
if (!$ids || !$ids['thread'])
return false;
$entry = ThreadEntry::lookup($ids['thread']);
if (!$entry)
// Compute the value to be compared from $mails (which used to be in
// ThreadEntry::asMessageId() (#nolint)
$domain = md5($ost->getConfig()->getURL());
$ticket = $entry->getThread()->getObject();
if (!$ticket instanceof Ticket)
return false;
$check = sprintf('%s@%s',
substr(md5($from . $ticket->getNumber() . $ticket->getId()), -10),
substr($domain, -10)
);
if ($check != $mails)
//new entry ... we're trusting the caller to check validity of the data.
static function create($vars=false) {
if ($cfg->isRichTextEnabled())
$vars['body'] = new HtmlThreadEntryBody($vars['body']);
else
$vars['body'] = new TextThreadEntryBody($vars['body']);
if (!($body = $vars['body']->getClean()))
$body = '-'; //Special tag used to signify empty message as stored.
$poster = $vars['poster'];
if ($poster && is_object($poster))
$entry = new static(array(
'created' => SqlFunction::NOW(),
'type' => $vars['type'],
'thread_id' => $vars['threadId'],
'title' => Format::sanitize($vars['title'], true),
'format' => $vars['body']->getType(),
'staff_id' => $vars['staffId'],
'user_id' => $vars['userId'],
'poster' => $poster,
'source' => $vars['source'],
'flags' => $vars['flags'] ?: 0,
//add recipients to thread entry
if ($vars['thread_entry_recipients']) {
$count = 0;
foreach ($vars['thread_entry_recipients'] as $key => $value)
$count = $count + count($value);
if ($count > 1)
$entry->flags |= ThreadEntry::FLAG_REPLY_ALL;
else
$entry->flags |= ThreadEntry::FLAG_REPLY_USER;
$entry->recipients = json_encode($vars['thread_entry_recipients']);
if (Collaborator::getIdByUserId($vars['userId'], $vars['threadId']))
$entry->flags |= ThreadEntry::FLAG_COLLABORATOR;
if ($entry->format == 'html')
// The current codebase properly balances html
$entry->flags |= self::FLAG_BALANCED;
// Flag system messages
if (!($vars['staffId'] || $vars['userId']))
$entry->flags |= self::FLAG_SYSTEM;
$entry->pid = $vars['pid'];
// Check if 'reply_to' is in the $vars as the previous ThreadEntry
// instance. If the body of the previous message is found in the new
// body, strip it out.
elseif (isset($vars['reply_to'])
&& $vars['reply_to'] instanceof ThreadEntry)
$entry->pid = $vars['reply_to']->getId();
$entry->ip_address = $vars['ip_address'];
/************* ATTACHMENTS *****************/
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
// Drop stripped email inline images
if ($vars['attachments']) {
foreach ($vars['body']->getStrippedImages() as $cid) {
foreach ($vars['attachments'] as $i=>$a) {
if (@$a['cid'] && $a['cid'] == $cid) {
// Inline referenced attachment was stripped
unset($vars['attachments'][$i]);
}
}
}
}
// Handle extracted embedded images (<img src="data:base64,..." />).
// The extraction has already been performed in the ThreadEntryBody
// class. Here they should simply be added to the attachments list
if ($atts = $vars['body']->getEmbeddedHtmlImages()) {
if (!is_array($vars['attachments']))
$vars['attachments'] = array();
foreach ($atts as $info) {
$vars['attachments'][] = $info;
}
}
$attached_files = array();
foreach (array(
// Web uploads and canned attachments
$vars['files'], $vars['cannedattachments'],
// Emailed or API attachments
$vars['attachments'],
// Inline images (attached to the draft)
Draft::getAttachmentIds($body),
) as $files
) {
if (is_array($files)) {
// Detect *inline* email attachments
foreach ($files as $i=>$a) {
if (isset($a['cid']) && $a['cid']
&& strpos($body, 'cid:'.$a['cid']) !== false)
$files[$i]['inline'] = true;
}
foreach ($entry->normalizeFileInfo($files) as $F) {
// Deduplicate on the `key` attribute. The key is
// necessary for the CID rewrite below
$attached_files[$F['key']] = $F;
// Change <img src="cid:"> inside the message to point to a unique
// hash-code for the attachment. Since the content-id will be
// discarded, only the unique hash-code (key) will be available to
// retrieve the image later
foreach ($attached_files as $key => $a) {
if (isset($a['cid']) && $a['cid']) {
$body = preg_replace('/src=("|\'|\b)(?:cid:)?'
. preg_quote($a['cid'], '/').'\1/i',
'src="cid:'.$key.'"', $body);
}
// Set body here after it was rewritten to capture the stored file
// keys (above)
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
// Store body as an attachment if bigger than allowed packet size
if (mb_strlen($body) >= 65000) { // 65,535 chars in text field.
$entry->body = NULL;
$file = array(
'type' => 'text/html',
'name' => md5($body).'.txt',
'data' => $body,
);
if (($AF = AttachmentFile::create($file))) {
$attached_files[$file['key']] = array(
'id' => $AF->getId(),
'inline' => true,
'file' => $AF);
} else {
$entry->body = $body;
}
} else {
$entry->body = $body;
}
if (!$entry->save(true))
return false;
// Associate the attached files with this new entry
$entry->createAttachments($attached_files);
// Save mail message id, if available
$entry->saveEmailInfo($vars);
Signal::send('threadentry.created', $entry);
static function add($vars, &$errors=array()) {
return self::create($vars);
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
// Extensible thread entry actions ------------------------
/**
* getActions
*
* Retrieve a list of possible actions. This list is shown to the agent
* via drop-down list at the top-right of the thread entry when rendered
* in the UI.
*/
function getActions() {
if (!isset($this->_actions)) {
$this->_actions = array();
foreach (self::$action_registry as $group=>$list) {
$T = array();
$this->_actions[__($group)] = &$T;
foreach ($list as $id=>$action) {
$A = new $action($this);
if ($A->isVisible()) {
$T[$id] = $A;
}
}
unset($T);
}
}
return $this->_actions;
function hasActions() {
foreach ($this->getActions() as $group => $list) {
if (count($list))
return true;
}
return false;
function triggerAction($name) {
foreach ($this->getActions() as $group=>$list) {
foreach ($list as $id=>$action) {
if (0 === strcasecmp($id, $name)) {
if (!$action->isEnabled())
return false;
$action->trigger();
return true;
}
}
}
return false;
static $action_registry = array();
static function registerAction($group, $action) {
if (!isset(self::$action_registry[$group]))
self::$action_registry[$group] = array();
self::$action_registry[$group][$action::getId()] = $action;
static function getPermissions() {
return self::$perms;
static function getTypes() {
return self::$types;
}
RolePermission::register(/* @trans */ 'Tickets', ThreadEntry::getPermissions());
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
class ThreadReferral extends VerySimpleModel {
static $meta = array(
'table' => THREAD_REFERRAL_TABLE,
'pk' => array('id'),
'joins' => array(
'thread' => array(
'constraint' => array('thread_id' => 'Thread.id'),
),
'agent' => array(
'constraint' => array(
'object_type' => "'S'",
'object_id' => 'Staff.staff_id',
),
),
'team' => array(
'constraint' => array(
'object_type' => "'E'",
'object_id' => 'Team.team_id',
),
),
'dept' => array(
'constraint' => array(
'object_type' => "'D'",
'object_id' => 'Dept.id',
),
),
)
);
var $icons = array(
'E' => 'group',
'D' => 'sitemap',
'S' => 'user'
);
var $_object = null;
function getId() {
return $this->id;
}
function getName() {
return (string) $this->getObject();
}
function getObject() {
if (!isset($this->_object)) {
$this->_object = ObjectModel::lookup(
$this->object_id, $this->object_type);
}
return $this->_object;
}
function getIcon() {
return $this->icons[$this->object_type];
}
function display() {
return sprintf('<i class="icon-%s"></i> %s',
$this->getIcon(), $this->getName());
}
static function create($vars) {
$new = new self($vars);
$new->created = SqlFunction::NOW();
return $new->save();
}
}
class ThreadEvent extends VerySimpleModel {
static $meta = array(
'pk' => array('id'),
'joins' => array(
// Originator of activity
'agent' => array(
'constraint' => array(
'uid' => 'Staff.staff_id',
),
'null' => true,
'staff' => array(
'constraint' => array(
'staff_id' => 'Staff.staff_id',
),
'null' => true,
),
'team' => array(
'constraint' => array(
'team_id' => 'Team.team_id',
'thread' => array(
'constraint' => array('thread_id' => 'Thread.id'),
),
'user' => array(
'constraint' => array(
'uid' => 'User.id',
),
'null' => true,
),
'dept' => array(
'constraint' => array(
'dept_id' => 'Dept.id',
),
'null' => true,
),
'topic' => array(
'constraint' => array(
'topic_id' => 'Topic.topic_id',
),
'null' => true,
),
// Valid events for database storage
const ASSIGNED = 'assigned';
const CLOSED = 'closed';
const CREATED = 'created';
const COLLAB = 'collab';
const EDITED = 'edited';
const ERROR = 'error';
const OVERDUE = 'overdue';
const REOPENED = 'reopened';
const STATUS = 'status';
const VIEWED = 'viewed';
const MODE_STAFF = 1;
const MODE_CLIENT = 2;
var $_data;
function getAvatar($size=null) {
if ($this->uid && $this->uid_type == 'S')
return $this->agent ? $this->agent->getAvatar($size) : '';
if ($this->uid && $this->uid_type == 'U')
return $this->user ? $this->user->getAvatar($size) : '';
function getUserName() {
if ($this->uid && $this->uid_type == 'S')
return $this->agent ? $this->agent->getName() : $this->username;
return $this->user ? $this->user->getName() : $this->username;
return $this->username;
}
function getIcon() {
$icons = array(
'collab' => 'group',
'created' => 'magic',
'overdue' => 'time',
'transferred' => 'share-alt',
'closed' => 'thumbs-up-alt',
'reopened' => 'rotate-right',
'resent' => 'reply-all icon-flip-horizontal',
);
return @$icons[$this->state] ?: 'chevron-sign-right';
}
function getDescription($mode=self::MODE_STAFF) {
// Abstract description
return $this->template(sprintf(
__('%s by {somebody} {timestamp}'),
$this->state
function template($description, $mode=self::MODE_STAFF) {
return preg_replace_callback('/\{(<(?P<type>([^>]+))>)?(?P<key>[^}.]+)(\.(?P<data>[^}]+))?\}/',
function ($m) use ($self, $thisstaff, $cfg, $hideName, $mode) {
case 'assignees':
$assignees = array();
$avatar = '';
if ($cfg->isAvatarsEnabled())
$avatar = $S->getAvatar();
$avatar.$S->getName();
$assignees[] = $T->getLocalName();
}
return implode('/', $assignees);
if ($hideName && $self->agent && $mode == self::MODE_CLIENT)
$name = __('Staff');
else
$name = $self->getUserName();
if ($cfg->isAvatarsEnabled()
&& ($avatar = $self->getAvatar()))
$name = $avatar.$name;
$timeFormat = null;
if ($thisstaff
&& !strcasecmp($thisstaff->datetime_format,
'relative')) {
$timeFormat = function ($timestamp) {
return Format::relativeTime(Misc::db2gmtime($timestamp));
};
}
return sprintf('<time %s datetime="%s"
data-toggle="tooltip" title="%s">%s</time>',
$timeFormat ? 'class="relative"' : '',
date(DateTime::W3C, Misc::db2gmtime($self->timestamp)),
Format::daydatetime($self->timestamp),
$timeFormat ? $timeFormat($self->timestamp) :
Format::datetime($self->timestamp)
if ($cfg->isAvatarsEnabled()
&& ($avatar = $self->getAvatar()))
$name = $avatar.$name;
case 'data':
$val = $self->getData($m['data']);
if (is_array($val))
list($val, $fallback) = $val;
if ($m['type'] && class_exists($m['type']))
$val = $m['type']::lookup($val);
if (!$val && $fallback)
$val = $fallback;
return Format::htmlchars((string) $val);
}
return $m[0];
},
$description
);
function getDept() {
return $this->dept;
function getData($key=false) {
if (!isset($this->_data))
$this->_data = JsonDataParser::decode($this->data);
return ($key) ? @$this->_data[$key] : $this->_data;
$inc = ($mode == self::MODE_STAFF) ? STAFFINC_DIR : CLIENTINC_DIR;
$event = $this->getTypedEvent();
include $inc . 'templates/thread-event.tmpl.php';
static function create($ht=false, $user=false) {
$inst = new static($ht);
$user = is_object($user) ? $user : $thisstaff ?: $thisclient;
if ($user instanceof Staff) {
static function forTicket($ticket, $state, $user=false) {
global $thisstaff;
if($thisstaff && !$ticket->getStaffId())
$staff = $thisstaff->getId();
else
$staff = $ticket->getStaffId();
$inst = self::create(array(
'team_id' => $ticket->getTeamId(),
'dept_id' => $ticket->getDeptId(),
'topic_id' => $ticket->getTopicId(),
static function forTask($task, $state, $user=false) {
$inst = self::create(array(
'staff_id' => $task->getStaffId(),
'team_id' => $task->getTeamId(),
'dept_id' => $task->getDeptId(),
), $user);
return $inst;
}
function getTypedEvent() {
static $subclasses;
if (!isset($subclasses)) {
$parent = get_class($this);
$subclasses = array();
foreach (get_declared_classes() as $class) {
if (is_subclass_of($class, $parent))
$subclasses[$class::$state] = $class;
}
}
if (!($class = $subclasses[$this->state]))
return $this;
return new $class($this->ht);
class ThreadEvents extends InstrumentedList {
function annul($event) {
$this->queryset
->filter(array('state' => $event))
->update(array('annulled' => 1));
/**
* Add an event to the thread activity log.
*
* Parameters:
* $object - Object to log activity for
* $state - State name of the activity (one of 'created', 'edited',
* 'deleted', 'closed', 'reopened', 'error', 'collab', 'resent',
* $data - (array?) Details about the state change
* $user - (string|User|Staff) user triggering the state change
* $annul - (state) a corresponding state change that is annulled by
* this event
*/
function log($object, $state, $data=null, $user=null, $annul=null) {
// TODO: Use $object->createEvent() (nolint)
$event = ThreadEvent::forTicket($object, $state, $user);
$event = ThreadEvent::forTask($object, $state, $user);
# Annul previous entries if requested (for instance, reopening a
# ticket will annul an 'closed' entry). This will be useful to
# easily prevent repeated statistics.
if ($annul) {
$this->annul($annul);
}
$username = $user;
$user = is_object($user) ? $user : $thisclient ?: $thisstaff;
if (!is_string($username)) {
if ($user instanceof Staff) {
$username = $user->getUserName();
// XXX: Use $user here
elseif ($thisclient) {
if ($thisclient->hasAccount())
$username = $thisclient->getFullName();
if (!$username)
$username = $thisclient->getEmail();
}
else {
# XXX: Security Violation ?
$username = 'SYSTEM';
}
}
$event->username = $username;
$event->state = $state;
if ($data) {
if (is_array($data))
$data = JsonDataEncoder::encode($data);
if (!is_string($data))
throw new InvalidArgumentException('Data must be string or array');
$event->data = $data;
}
$this->add($event);
// Save event immediately
return $event->save();
}
}
class AssignmentEvent extends ThreadEvent {
static $icon = 'hand-right';
static $state = 'assigned';
function getDescription($mode=self::MODE_STAFF) {
$data = $this->getData();
switch (true) {
case !is_array($data):
default:
$desc = __('Assignee changed by <b>{somebody}</b> to <strong>{assignees}</strong> {timestamp}');
break;
case isset($data['staff']):
$desc = __('<b>{somebody}</b> assigned this to <strong>{<Staff>data.staff}</strong> {timestamp}');
break;
case isset($data['team']):
$desc = __('<b>{somebody}</b> assigned this to <strong>{<Team>data.team}</strong> {timestamp}');
break;
case isset($data['claim']):
$desc = __('<b>{somebody}</b> claimed this {timestamp}');
break;
}
return $this->template($desc);
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
class ReleaseEvent extends ThreadEvent {
static $icon = 'unlock';
static $state = 'released';
function getDescription($mode=self::MODE_STAFF) {
$data = $this->getData();
switch (true) {
case isset($data['staff'], $data['team']):
$desc = __('Ticket released from <strong>{<Team>data.team}</strong> and <strong>{<Staff>data.staff}</strong> by <b>{somebody}</b> {timestamp}');
break;
case isset($data['staff']):
$desc = __('Ticket released from <strong>{<Staff>data.staff}</strong> by <b>{somebody}</b> {timestamp}');
break;
case isset($data['team']):
$desc = __('Ticket released from <strong>{<Team>data.team}</strong> by <b>{somebody}</b> {timestamp}');
break;
default:
$desc = __('<b>{somebody}</b> released ticket assignment {timestamp}');
break;
}
return $this->template($desc);
}
}
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
class ReferralEvent extends ThreadEvent {
static $icon = 'exchange';
static $state = 'referred';
function getDescription($mode=self::MODE_STAFF) {
$data = $this->getData();
switch (true) {
case isset($data['staff']):
$desc = __('<b>{somebody}</b> referred this to <strong>{<Staff>data.staff}</strong> {timestamp}');
break;
case isset($data['team']):
$desc = __('<b>{somebody}</b> referred this to <strong>{<Team>data.team}</strong> {timestamp}');
break;
case isset($data['dept']):
$desc = __('<b>{somebody}</b> referred this to <strong>{<Dept>data.dept}</strong> {timestamp}');
break;
}
return $this->template($desc);
}
}
class CloseEvent extends ThreadEvent {
static $icon = 'thumbs-up-alt';
static $state = 'closed';
Loading
Loading full blame...