Helpdesk da PluGzOne, baseado no osTicket
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

3428 lines
108 KiB

<?php
/*********************************************************************
class.thread.php
Thread of things!
XXX: Please DO NOT add any ticket related logic! use ticket class.
Peter Rotich <peter@osticket.com>
Copyright (c) 2006-2013 osTicket
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');
//Ticket thread.
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 $_entries;
var $_collaborators; // Cache for collabs
var $_participants;
function getId() {
return $this->id;
}
function getObjectId() {
return $this->object_id;
}
function getObjectType() {
return $this->object_type;
}
function getObject() {
if (!$this->_object)
$this->_object = ObjectModel::lookup(
$this->getObjectId(), $this->getObjectType());
return $this->_object;
}
function getNumAttachments() {
return Attachment::objects()->filter(array(
'thread_entry__thread' => $this
))->count();
}
function getNumEntries() {
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->getCollaborators()->count();
}
function getNumActiveCollaborators() {
if (!isset($this->ht['active_collaborators']))
$this->ht['active_collaborators'] = count($this->getActiveCollaborators());
return $this->ht['active_collaborators'];
}
function getActiveCollaborators() {
$collaborators = $this->getCollaborators();
$active = array();
foreach ($collaborators as $c) {
if ($c->isActive())
$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 isCollaborator($user) {
return $this->collaborators->findFirst(array(
'user_id' => $user->getId(),
'thread_id' => $this->getId()));
}
function addCollaborator($user, $vars, &$errors, $event=true) {
global $cfg, $thisstaff;
if (!$user)
return null;
if ($this->isCollaborator($user))
return false;
$vars = array_merge(array(
'threadId' => $this->getId(),
'userId' => $user->getId()), $vars ?: array());
if (!($c=Collaborator::add($vars, $errors)))
return null;
$c->active = true;
// Disable Agent Collabs (if configured) for User created tickets
if (!$thisstaff && $this->object_type === 'T'
&& $cfg->disableAgentCollaborators()
&& Staff::lookup($user->getDefaultEmailAddress()))
$c->active = false;
$this->_collaborators = null;
if ($event) {
$vars['add'] = true;
$this->logCollaboratorEvents($user, $vars);
}
return $c;
}
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())
&& $c->delete())
$collabs[] = $c;
$this->logCollaboratorEvents($c, $vars);
}
}
//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 (($collab instanceof 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;
}
function logCollaboratorEvents($collaborator, $vars) {
$name = $collaborator->getName()->getOriginal();
$userId = (get_class($collaborator) == 'User')
? $collaborator->getId() : $collaborator->user_id;
$action = $vars['del'] ? 'object.deleted' : 'object.created';
$addDel = $vars['del'] ? 'del' : 'add';
$this->getEvents()->log($this->getObject(), 'collab', array(
$addDel => array($userId => array('name' => $name))
));
$type = array('type' => 'collab', $addDel => array($userId => array(
'name' => $name,
'src' => @$vars['source'],
)));
Signal::send($action, $this->getObject(), $type);
}
//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;
}
return $this->_participants;
}
// MailingList of recipients (collaborators)
function getRecipients() {
$list = new MailingList();
if ($collabs = $this->getActiveCollaborators()) {
foreach ($collabs as $c)
$list->addCc($c);
}
return $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)))
return true;
// Referred to staff's teams
if ($to->getTeams() && $this->referrals->findFirst(array(
'object_id__in' => $to->getTeams(),
'object_type' => ObjectModel::OBJECT_TYPE_TEAM
)))
return true;
return false;
break;
case $to instanceof Team:
//Referred to a Team
return ($this->getReferral($to->getId(),
ObjectModel::OBJECT_TYPE_TEAM));
break;
case $to instanceof Dept:
// Refered to the dept
return ($this->getReferral($to->getId(),
ObjectModel::OBJECT_TYPE_DEPT));
break;
}
return false;
}
function refer($to) {
if ($this->isReferred($to, true))
return false;
$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);
}
// Render thread
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();
$events = $this->getEvents();
$inc = ($mode == self::MODE_STAFF) ? STAFFINC_DIR : CLIENTINC_DIR;
include $inc . 'templates/thread-entries.tmpl.php';
}
function getEntry($id) {
return ThreadEntry::lookup($id, $this->getId());
}
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?
return false;
}
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' => '',
'reply_to' => $entry,
'recipients' => $mailinfo['recipients'],
'thread_entry_recipients' => $mailinfo['thread_entry_recipients'],
'to-email-id' => $mailinfo['to-email-id'],
'autorespond' => !isset($mailinfo['passive']),
);
// XXX: Is this necessary?
if ($object instanceof Ticket)
$vars['ticketId'] = $object->getId();
if ($object instanceof Task)
$vars['taskId'] = $object->getId();
$errors = array();
if (isset($mailinfo['attachments']))
$vars['attachments'] = $mailinfo['attachments'];
$body = $mailinfo['message'];
// 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']);
break;
// 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
if (!$vars['thread-type']) {
//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';
}
if ($mailinfo['system_emails']
&& ($t = $this->getObject())
&& $t instanceof Ticket)
$t->systemReferral($mailinfo['system_emails']);
switch ($vars['thread-type']) {
case 'M':
$vars['message'] = $body;
if ($object instanceof Threadable) {
$entry = $object->postThreadEntry('M', $vars);
if ($this->getObjectType() == 'C') {
if ($object->isChild()) {
$parent = Ticket::lookup($object->getPid());
ThreadEntry::setExtra(array($entry), array('thread' => $this->getId()), $parent->getThread()->getId());
}
}
return $entry;
}
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);
break;
}
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;
}
function deleteAttachments() {
$deleted = Attachment::objects()->filter(array(
'thread_entry__thread' => $this,
))->delete();
if ($deleted)
AttachmentFile::deleteOrphans();
return $deleted;
}
function removeCollaborators() {
return Collaborator::objects()
->filter(array('thread_id'=>$this->getId()))
->delete();
}
function setExtra($mergedThread, $info='') {
if ($info && $info['extra']) {
$extra = json_decode($info['extra'], true);
$entries = ThreadEntry::objects()->filter(array('thread_id' => $info['threadId']));
foreach ($entries as $entry)
$entry->saveExtra($entry, array('thread' => $info['threadId']), $mergedThread->getId());
} else
ThreadEntry::setExtra($this->getEntries(), array('thread' => $this->getId()), $mergedThread->getId());
$this->object_type = 'C';
$number = Ticket::objects()->filter(array('ticket_id'=>$this->getObjectId()))->values_flat('number')->first();
$this->extra = json_encode(array('ticket_id' => $mergedThread->getObjectId(), 'number' => $extra['number'] ?: $number[0]));
$this->save();
}
/**
* 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;
}
}
return null;
}
static function getSearchableFields() {
return array(
'lastmessage' => new DatetimeField(array(
'label' => __('Last Message'),
)),
'lastresponse' => new DatetimeField(array(
'label' => __('Last Response'),
)),
);
}
static function supportsCustomData() {
false;
}
function delete() {
//Self delete
if (!parent::delete())
return false;
// Clear email meta data (header..etc)
ThreadEntryEmailInfo::objects()
->filter(array('thread_entry__thread' => $this))
->update(array('headers' => null));
// Mass delete entries
$this->deleteAttachments();
$this->removeCollaborators();
$this->entries->delete();
// Null out the events
$this->events->update(array('thread_id' => 0));
return true;
}
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 ThreadEntryMergeInfo extends VerySimpleModel {
static $meta = array(
'table' => THREAD_ENTRY_MERGE_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,
),
'merge_info' => array(
'reverse' => 'ThreadEntryMergeInfo.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_RESENT = 0x0010;
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 FLAG_CHILD = 0x00400; // Entry is from a child Ticket
const PERM_EDIT = 'thread.edit';
var $_headers;
var $_body;
var $_thread;
var $_actions;
var $is_autoreply;
var $is_bounce;
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];
}
function postEmail($mailinfo) {
global $ost;
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 &lt;%s&gt; 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() {
return $this->parent;
}
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()) {
$attachments = Attachment::objects()
->filter(array(
'inline' => 1,
'object_id' => $this->getId(),
'type' => ObjectModel::OBJECT_TYPE_THREAD,
'file__type__in' => array('text/html','text/plain'))
);
foreach ($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 (!$body instanceof ThreadEntryBody) {
if ($cfg->isRichTextEnabled())
$body = new HtmlThreadEntryBody($body);
else
$body = new TextThreadEntryBody($body);
}
$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;
return $references;
}
/**
* 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'];
}
function getThreadId() {
return $this->thread_id;
}
function getThread() {
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;
}
function getEmailHeader() {
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.',
);
if (!is_array($files))
$files = array($files);
$ids = array();
foreach ($files as $id => $info) {
$F = array('inline' => is_array($info) && @$info['inline']);
$AF = null;
if ($info instanceof AttachmentFile)
$fileId = $info->getId();
elseif (is_array($info) && isset($info['id']))
$fileId = $info['id'];
elseif ($AF = AttachmentFile::create($info))
$fileId = $AF->getId();
elseif ($add_error) {
$error = $info['error']
?: sprintf(_S('Unable to save attachment - %s'),
$info['name'] ?: $info['id']);
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,
));
}
continue;
}
$F['id'] = $fileId;
if (is_string($info))
$F['name'] = $info;
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($info))
$F += $info;
// Key is required for CID rewriting in the body
if (!isset($F['key']) && ($AF = AttachmentFile::lookup($F['id'])))
$F['key'] = $AF->key;
$ids[] = $F;
}
return $ids;
}
/*
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
// TODO: fix name lookup
if ($F && strcasecmp($F->name, $filename) !== 0)
$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;
}
function getAttachmentUrls() {
$json = array();
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
$str='';
foreach ($this->attachments as $att ) {
if ($att->inline) continue;
$size = '';
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&nbsp;%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'])
return 0;
$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) {
$headerInfo = Mail_Parse::splitHeaders($header);
if (!$id || !$mid)
return false;
$this->email_info = new ThreadEntryEmailInfo(array(
'thread_entry_id' => $id,
'email_id' => Email::getIdByEmail($headerInfo['Delivered-To']),
'mid' => $mid,
));
if ($header)
$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
function asVar() {
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'),
'staff' => array(
'class' => 'Staff', 'desc' => __('Agent posting the note or response'),
),
'title' => __('Subject, if any'),
'user' => array(
'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()
)
) {
$seen = true;
if ($mailinfo['system_emails']
&& ($t = $entry->getThread()->getObject())
&& $t instanceof Ticket)
$t->systemReferral($mailinfo['system_emails']);
return $entry;
}
$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]*)(?:$|")`',
(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;
}
}
// Passive threading - listen mode
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) {
global $ost;
$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)
return false;
// 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)
return false;
return $entry;
}
function setExtra($entries, $info=NULL, $thread_id=NULL) {
foreach ($entries as $entry) {
$mergeInfo = ThreadEntryMergeInfo::objects()
->filter(array('thread_entry_id'=>$entry->getId()))
->values_flat('thread_entry_id')
->first();
if (!$mergeInfo) {
$mergeInfo = new ThreadEntryMergeInfo(array(
'thread_entry_id' => $entry->getId(),
'data' => json_encode($info),
));
$mergeInfo->save();
}
$entry->saveExtra($info, $thread_id);
}
}
function saveExtra($info=NULL, $thread_id=NULL) {
$this->setFlag(ThreadEntry::FLAG_CHILD, true);
$this->thread_id = $thread_id;
$this->save();
}
function getMergeData() {
return $this->merge_info ? $this->merge_info->data : null;
}
function sortEntries($entries, $ticket) {
$buckets = array();
$childEntries = array();
foreach ($entries as $i=>$E) {
if ($ticket) {
$extra = json_decode($E->getMergeData(), true);
//separated entries
if ($ticket->getMergeType() == 'separate') {
if ($extra['thread']) {
$childEntries[$E->getId()] = $E;
if ($childEntries) {
uasort($childEntries, function ($a, $b) { //sort by child ticket
$aExtra = json_decode($a->getMergeData(), true);
$bExtra = json_decode($b->getMergeData(), true);
if ($aExtra['thread'] != $bExtra["thread"])
return $bExtra["thread"] - $aExtra['thread'];
});
uasort($childEntries, function($a, $b) { //sort by child created date
$aExtra = json_decode($a->getMergeData(), true);
$bExtra = json_decode($b->getMergeData(), true);
if ($aExtra['thread'] == $bExtra["thread"])
return strtotime($a->created) - strtotime($b->created);
});
}
} else
$buckets[$E->getId()] = $E;
} else
$buckets[$E->getId()] = $E;
} else //we may be looking at a task
$buckets[$E->getId()] = $E;
}
if ($ticket && $ticket->getMergeType() == 'separate')
$buckets = $buckets + $childEntries;
return $buckets;
}
//new entry ... we're trusting the caller to check validity of the data.
static function create($vars=false) {
global $cfg;
assert(is_array($vars));
//Must have...
if (!$vars['threadId'] || !$vars['type'])
return false;
if (!$vars['body'] instanceof ThreadEntryBody) {
if ($cfg->isRichTextEnabled())
$vars['body'] = new HtmlThreadEntryBody($vars['body']);
else
$vars['body'] = new TextThreadEntryBody($vars['body']);
}
if (!($body = Format::strip_emoticons($vars['body']->getClean())))
$body = '-'; //Special tag used to signify empty message as stored.
// Ensure valid external images
$body = Format::stripExternalImages($body);
$poster = $vars['poster'];
if ($poster && is_object($poster))
$poster = (string) $poster;
$entry = new static(array(
'created' => SqlFunction::NOW(),
'type' => $vars['type'],
'thread_id' => $vars['threadId'],
'title' => Format::strip_emoticons(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;
if (isset($vars['pid']))
$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();
if ($vars['ip_address'])
$entry->ip_address = $vars['ip_address'];
/************* ATTACHMENTS *****************/
// 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'],
// 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)
// 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);
return $entry;
}
static function add($vars, &$errors=array()) {
return self::create($vars);
}
// 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());
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(
'table' => THREAD_EVENT_TABLE,
'pk' => array('id'),
'joins' => array(
// Originator of activity
'agent' => array(
'constraint' => array(
'uid' => 'Staff.staff_id',
),
'null' => true,
),
// Agent assignee
'staff' => array(
'constraint' => array(
'staff_id' => 'Staff.staff_id',
),
'null' => true,
),
'team' => array(
'constraint' => array(
'team_id' => 'Team.team_id',
),
'null' => true,
),
'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,
),
'event' => array(
'constraint' => array(
'event_id' => 'Event.id',
),
'null' => true,
),
),
);
// Valid events for database storage
const ASSIGNED = 'assigned';
const RELEASED = 'released';
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 TRANSFERRED = 'transferred';
const REFERRED = 'referred';
const VIEWED = 'viewed';
const MERGED = 'merged';
const UNLINKED = 'unlinked';
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;
if ($this->uid && $this->uid_type == 'U')
return $this->user ? $this->user->getName() : $this->username;
return $this->username;
}
function getIcon() {
$icons = array(
'assigned' => 'hand-right',
'released' => 'unlock',
'collab' => 'group',
'created' => 'magic',
'overdue' => 'time',
'transferred' => 'share-alt',
'referred' => 'exchange',
'edited' => 'pencil',
'closed' => 'thumbs-up-alt',
'reopened' => 'rotate-right',
'resent' => 'reply-all icon-flip-horizontal',
'merged' => 'code-fork',
'linked' => 'link',
'unlinked' => 'unlink',
);
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
), $mode);
}
function template($description, $mode=self::MODE_STAFF) {
global $thisstaff, $cfg;
$self = $this;
$hideName = $cfg->hideStaffName();
return preg_replace_callback('/\{(<(?P<type>([^>]+))>)?(?P<key>[^}.]+)(\.(?P<data>[^}]+))?\}/',
function ($m) use ($self, $thisstaff, $cfg, $hideName, $mode) {
switch ($m['key']) {
case 'assignees':
$assignees = array();
if ($S = $self->staff) {
$avatar = '';
if ($cfg->isAvatarsEnabled())
$avatar = $S->getAvatar();
$assignees[] =
$avatar.$S->getName();
}
if ($T = $self->team) {
$assignees[] = $T->getLocalName();
}
return implode('/', $assignees);
case 'somebody':
if ($hideName && $self->agent && $mode == self::MODE_CLIENT)
$name = __('Staff');
else
$name = $self->getUserName();
if ($cfg->isAvatarsEnabled()
&& ($avatar = $self->getAvatar()))
$name = $avatar.$name;
return $name;
case 'timestamp':
$timeFormat = null;
if ($mode != self::MODE_CLIENT && $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)
);
case 'agent':
$name = $self->agent->getName();
if ($cfg->isAvatarsEnabled()
&& ($avatar = $self->getAvatar()))
$name = $avatar.$name;
return $name;
case 'dept':
if ($dept = $self->getDept())
return $dept->getLocalName();
return __('None');
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;
}
function render($mode) {
$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);
$inst->timestamp = SqlFunction::NOW();
global $thisstaff, $thisclient;
$user = is_object($user) ? $user : $thisstaff ?: $thisclient;
if ($user instanceof Staff) {
$inst->uid_type = 'S';
$inst->uid = $user->getId();
}
elseif ($user instanceof User) {
$inst->uid_type = 'U';
$inst->uid = $user->getId();
}
return $inst;
}
static function forTicket($ticket, $state, $user=false) {
global $thisstaff;
if($thisstaff && !$ticket->getStaffId())
$staff = $thisstaff->getId();
else
$staff = $ticket->getStaffId();
$inst = self::create(array(
'thread_type' => ObjectModel::OBJECT_TYPE_TICKET,
'staff_id' => $staff,
'team_id' => $ticket->getTeamId(),
'dept_id' => $ticket->getDeptId(),
'topic_id' => $ticket->getTopicId(),
), $user);
return $inst;
}
static function forTask($task, $state, $user=false) {
$inst = self::create(array(
'thread_type' => ObjectModel::OBJECT_TYPE_TASK,
'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;
}
}
$this->state = Event::getNameById($this->event_id);
if (!($class = $subclasses[$this->state]))
return $this;
return new $class($this->ht);
}
}
class Event extends VerySimpleModel {
static $meta = array(
'table' => EVENT_TABLE,
'pk' => array('id'),
);
function getInfo() {
return $this->ht;
}
function getId() {
return $this->id;
}
function getName() {
return $this->name;
}
function getDescription() {
return $this->description;
}
static function getNameById($id) {
return array_search($id, self::getIds());
}
static function getIdByName($name) {
$ids = self::getIds();
return $ids[$name] ?: 0;
}
static function getIds() {
static $ids;
if (!isset($ids)) {
$ids = array();
$events = self::objects()->values_flat('id', 'name');
foreach ($events as $row) {
list($id, $name) = $row;
$ids[$name] = $id;
}
}
return $ids;
}
static function getStates($dropdown=false) {
$names = array();
if ($dropdown)
$names = array(__('All'));
$events = self::objects()->values_flat('name');
foreach ($events as $val)
$names[] = ucfirst($val[0]);
return $names;
}
static function create($vars=false, &$errors=array()) {
$event = new static($vars);
return $event;
}
static function __create($vars, &$errors=array()) {
$event = self::create($vars);
$event->save();
return $event;
}
function save($refetch=false) {
return parent::save($refetch);
}
}
class ThreadEvents extends InstrumentedList {
function annul($event) {
$event_id = Event::getIdByName($event);
$this->queryset
->filter(array('event_id' => $event_id))
->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',
* 'assigned', 'released', 'transferred')
* $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) {
global $thisstaff, $thisclient;
if ($object && ($object instanceof Ticket))
// TODO: Use $object->createEvent() (nolint)
$event = ThreadEvent::forTicket($object, $state, $user);
elseif ($object && ($object instanceof Task))
$event = ThreadEvent::forTask($object, $state, $user);
if (is_null($event))
return;
# 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->event_id = Event::getIdByName($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, $mode);
}
}
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, $mode);
}
}
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, $mode);
}
}
class CloseEvent extends ThreadEvent {
static $icon = 'thumbs-up-alt';
static $state = 'closed';
function getDescription($mode=self::MODE_STAFF) {
if ($this->getData('status'))
return $this->template(__('Closed by <b>{somebody}</b> with status of {<TicketStatus>data.status} {timestamp}'), $mode);
else
return $this->template(__('Closed by <b>{somebody}</b> {timestamp}'), $mode);
}
}
class CollaboratorEvent extends ThreadEvent {
static $icon = 'group';
static $state = 'collab';
function getDescription($mode=self::MODE_STAFF) {
$data = $this->getData();
switch (true) {
case isset($data['org']):
$desc = __('Collaborators for {<Organization>data.org} organization added');
break;
case isset($data['del']):
$base = __('<b>{somebody}</b> removed <strong>%s</strong> from the collaborators {timestamp}');
$collabs = array();
$users = User::objects()->filter(array('id__in' => array_keys($data['del'])));
foreach ($data['del'] as $id=>$c) {
$U = false;
foreach ($users as $user) {
if ($user->id == $id) {
$U = $user;
break;
}
}
$collabs[] = Format::htmlchars($U ? $U->getName() : @$c['name'] ?: $c);
}
$desc = sprintf($base, implode(', ', $collabs));
break;
case isset($data['add']):
$base = __('<b>{somebody}</b> added <strong>%s</strong> as collaborators {timestamp}');
$collabs = array();
if ($data['add']) {
$users = User::objects()->filter(array('id__in' => array_keys($data['add'])));
foreach ($data['add'] as $id=>$c) {
$U = false;
foreach ($users as $user) {
if ($user->id == $id) {
$U = $user;
break;
}
}
$c = sprintf("%s %s",
Format::htmlchars($U ? $U->getName() : @$c['name'] ?: $c),
$c['src'] ? sprintf(__('via %s'
/* e.g. "Added collab "Me <me@company.me>" via Email (to)" */
), $c['src']) : ''
);
$collabs[] = $c;
}
}
$desc = $collabs
? sprintf($base, implode(', ', $collabs))
: 'somebody';
break;
}
return $this->template($desc, $mode);
}
}
class CreationEvent extends ThreadEvent {
static $icon = 'magic';
static $state = 'created';
function getDescription($mode=self::MODE_STAFF) {
return $this->template(__('Created by <b>{somebody}</b> {timestamp}'), $mode);
}
}
class EditEvent extends ThreadEvent {
static $icon = 'pencil';
static $state = 'edited';
function getDescription($mode=self::MODE_STAFF) {
$data = $this->getData();
switch (true) {
case isset($data['owner']):
$desc = __('<b>{somebody}</b> changed ownership to {<User>data.owner} {timestamp}');
break;
case isset($data['status']):
$desc = __('<b>{somebody}</b> changed the status to <strong>{<TicketStatus>data.status}</strong> {timestamp}');
break;
case isset($data['fields']):
$fields = $changes = array();
foreach (DynamicFormField::objects()->filter(array(
'id__in' => array_keys($data['fields'])
)) as $F) {
$fields[$F->id] = $F;
}
foreach ($data['fields'] as $id=>$f) {
if (!($field = $fields[$id]))
continue;
if ($mode == self::MODE_CLIENT && !$field->isVisibleToUsers())
continue;
list($old, $new) = $f;
$impl = $field->getImpl($field);
$before = $impl->to_php($old);
$after = $impl->to_php($new);
$changes[] = sprintf('<strong>%s</strong> %s',
$field->getLocal('label'), $impl->whatChanged($before, $after));
}
// Fallthrough to other editable fields
case isset($data['topic_id']):
case isset($data['sla_id']):
case isset($data['source']):
case isset($data['user_id']):
case isset($data['duedate']):
$base = __('Updated by <b>{somebody}</b> {timestamp} — %s');
foreach (array(
'topic_id' => array(__('Help Topic'), array('Topic', 'getTopicName')),
'sla_id' => array(__('SLA'), array('SLA', 'getSLAName')),
'duedate' => array(__('Due Date'), array('Format', 'date')),
'user_id' => array(__('Ticket Owner'), array('User', 'getNameById')),
'source' => array(__('Source'), null)
) as $f => $info) {
if (isset($data[$f])) {
list($name, $desc) = $info;
list($old, $new) = $data[$f];
if ($desc && is_callable($desc)) {
$new = call_user_func($desc, $new);
if ($old)
$old = call_user_func($desc, $old);
}
if ($old and $new) {
$changes[] = sprintf(
__('<strong>%1$s</strong> changed from <strong>%2$s</strong> to <strong>%3$s</strong>'),
Format::htmlchars($name), Format::htmlchars($old), Format::htmlchars($new)
);
}
elseif ($new) {
$changes[] = sprintf(
__('<strong>%1$s</strong> set to <strong>%2$s</strong>'),
Format::htmlchars($name), Format::htmlchars($new)
);
}
else {
$changes[] = sprintf(
__('unset <strong>%1$s</strong>'),
Format::htmlchars($name)
);
}
}
}
$desc = $changes
? sprintf($base, implode(', ', $changes)) : '';
break;
}
return $this->template($desc, $mode);
}
}
class OverdueEvent extends ThreadEvent {
static $icon = 'time';
static $state = 'overdue';
function getDescription($mode=self::MODE_STAFF) {
return $this->template(__('Flagged as overdue by the system {timestamp}'), $mode);
}
}
class ReopenEvent extends ThreadEvent {
static $icon = 'rotate-right';
static $state = 'reopened';
function getDescription($mode=self::MODE_STAFF) {
return $this->template(__('Reopened by <b>{somebody}</b> {timestamp}'), $mode);
}
}
class ResendEvent extends ThreadEvent {
static $icon = 'reply-all icon-flip-horizontal';
static $state = 'resent';
function getDescription($mode=self::MODE_STAFF) {
return $this->template(__('<b>{somebody}</b> resent <strong><a href="#thread-entry-{data.entry}">a previous response</a></strong> {timestamp}'), $mode);
}
}
class TransferEvent extends ThreadEvent {
static $icon = 'share-alt';
static $state = 'transferred';
function getDescription($mode=self::MODE_STAFF) {
return $this->template(__('<b>{somebody}</b> transferred this to <strong>{dept}</strong> {timestamp}'), $mode);
}
}
class ViewEvent extends ThreadEvent {
static $state = 'viewed';
}
class MergedEvent extends ThreadEvent {
static $icon = 'code-fork';
static $state = 'merged';
function getDescription($mode=self::MODE_STAFF) {
return sprintf($this->template(__('<b>{somebody}</b> merged this ticket with %s{data.id}%s<b>{data.ticket}</b>%s {timestamp}'), $mode),
'<a href="tickets.php?id=', '">', '</a>');
}
}
class LinkedEvent extends ThreadEvent {
static $icon = 'link';
static $state = 'linked';
function getDescription($mode=self::MODE_STAFF) {
return sprintf($this->template(__('<b>{somebody}</b> linked this ticket with %s{data.id}%s<b>{data.ticket}</b>%s {timestamp}'), $mode),
'<a href="tickets.php?id=', '">', '</a>');
}
}
class UnlinkEvent extends ThreadEvent {
static $icon = 'unlink';
static $state = 'unlinked';
function getDescription($mode=self::MODE_STAFF) {
return sprintf($this->template(__('<b>{somebody}</b> unlinked this ticket from %s{data.id}%s<b>{data.ticket}</b>%s {timestamp}'), $mode),
'<a href="tickets.php?id=', '">', '</a>');
}
}
class ThreadEntryBody /* extends SplString */ {
static $types = array('text', 'html');
var $body;
var $type;
var $stripped_images = array();
var $embedded_images = array();
var $options = array(
'strip-embedded' => true
);
function __construct($body, $type='text', $options=array()) {
$type = strtolower($type);
if (!in_array($type, static::$types))
throw new Exception("$type: Unsupported ThreadEntryBody type");
$this->body = (string) $body;
if (strlen($this->body) > 250000) {
$max_packet = db_get_variable('max_allowed_packet', 'global');
// Truncate just short of the max_allowed_packet
$this->body = substr($this->body, 0, $max_packet - 2048) . ' ... '
. _S('(truncated)');
}
$this->type = $type;
$this->options = array_merge($this->options, $options);
}
function isEmpty() {
return !$this->body || $this->body == '-';
}
function convertTo($type) {
if ($type === $this->type)
return $this;
$conv = $this->type . ':' . strtolower($type);
switch ($conv) {
case 'text:html':
return new ThreadEntryBody(sprintf('<pre>%s</pre>',
Format::htmlchars($this->body)), $type);
case 'html:text':
return new ThreadEntryBody(Format::html2text((string) $this), $type);
}
}
function stripQuotedReply($tag) {
//Strip quoted reply...on emailed messages
if (!$tag || strpos($this->body, $tag) === false)
return;
// Capture a list of inline images
$images_before = $images_after = array();
preg_match_all('/src=("|\'|\b)cid:(\S+)\1/', $this->body, $images_before,
PREG_PATTERN_ORDER);
// Strip the quoted part of the body
if ((list($msg) = explode($tag, $this->body, 2)) && trim($msg)) {
$this->body = $msg;
// Capture a list of dropped inline images
if ($images_before) {
preg_match_all('/src=("|\'|\b)cid:(\S+)\1/', $this->body,
$images_after, PREG_PATTERN_ORDER);
$this->stripped_images = array_diff($images_before[2],
$images_after[2]);
}
}
}
function getStrippedImages() {
return $this->stripped_images;
}
function getEmbeddedHtmlImages() {
return $this->embedded_images;
}
function getType() {
return $this->type;
}
function getClean() {
switch ($this->type) {
case 'html':
return trim($this->body, " <>br/\t\n\r") ? $this->body: '';
case 'text':
return trim($this->body) ? $this->body: '';
default:
return trim($this->body);
}
}
function __toString() {
return (string) $this->body;
}
function toHtml() {
return $this->display('html');
}
function prepend($what) {
$this->body = $what . $this->body;
}
function append($what) {
$this->body .= $what;
}
function asVar() {
// Email template, assume HTML
return $this->display('email');
}
function display($format=false) {
throw new Exception('display: Abstract display() method not implemented');
}
function getSearchable() {
return Format::searchable($this->body);
}
static function fromFormattedText($text, $format=false, $options=array()) {
switch ($format) {
case 'text':
return new TextThreadEntryBody($text);
case 'html':
return new HtmlThreadEntryBody($text, array('strip-embedded'=>false) + $options);
default:
return new ThreadEntryBody($text);
}
}
static function clean($text, $format=null) {
global $cfg;
$format = $format ?: ($cfg->isRichTextEnabled() ? 'html' : 'text');
$body = static::fromFormattedText($text, $format);
return $body->getClean();
}
}
class TextThreadEntryBody extends ThreadEntryBody {
function __construct($body, $options=array()) {
parent::__construct($body, 'text', $options);
}
function getClean() {
return Format::htmlchars(Format::html_balance(Format::stripEmptyLines(parent::getClean())));
}
function prepend($what) {
$this->body = $what . "\n\n" . $this->body;
}
function display($output=false) {
if ($this->isEmpty())
return '(empty)';
$escaped = Format::htmlchars($this->body);
switch ($output) {
case 'html':
return '<div style="white-space:pre-wrap">'
.Format::clickableurls($escaped).'</div>';
case 'email':
return '<div style="white-space:pre-wrap">'
.$escaped.'</div>';
case 'pdf':
return nl2br($escaped);
default:
return '<pre>'.$escaped.'</pre>';
}
}
}
class HtmlThreadEntryBody extends ThreadEntryBody {
function __construct($body, $options=array()) {
if (!isset($options['strip-embedded']) || $options['strip-embedded'])
$body = $this->extractEmbeddedHtmlImages($body);
parent::__construct($body, 'html', $options);
}
function extractEmbeddedHtmlImages($body) {
$self = $this;
return preg_replace_callback('/src="(data:[^"]+)"/',
function ($m) use ($self) {
$info = Format::parseRfc2397($m[1], false, false);
$info['cid'] = 'img'.Misc::randCode(12);
list(,$type) = explode('/', $info['type'], 2);
$info['name'] = 'image'.Misc::randCode(4).'.'.$type;
$self->embedded_images[] = $info;
return 'src="cid:'.$info['cid'].'"';
}, $body);
}
function getClean() {
global $thisclient, $thisstaff;
$clean = ($thisstaff || $thisclient)
? Format::editor_spacing(parent::getClean())
: parent::getClean();
return Format::sanitize($clean);
}
function getSearchable() {
// Replace tag chars with spaces (to ensure words are separated)
$body = Format::html($this->body, array('hook_tag' => function($el, $attributes=0) {
static $non_ws = array('wbr' => 1);
return (isset($non_ws[$el])) ? '' : ' ';
}));
// Collapse multiple white-spaces
$body = html_entity_decode($body, ENT_QUOTES);
$body = preg_replace('`\s+`u', ' ', $body);
return Format::searchable($body);
}
function prepend($what) {
$this->body = sprintf('<div>%s<br/><br/></div>%s', $what, $this->body);
}
function display($output=false) {
if ($this->isEmpty())
return '(empty)';
switch ($output) {
case 'email':
return $this->body;
case 'pdf':
return Format::clickableurls(Format::stripExternalImages($this->body, true));
default:
return Format::display($this->body, true, !$this->options['balanced']);
}
}
}
/* Message - Ticket thread entry of type message */
class MessageThreadEntry extends ThreadEntry {
const ENTRY_TYPE = 'M';
function getSubject() {
return $this->getTitle();
}
static function add($vars, &$errors=array()) {
if (!$vars || !is_array($vars) || !$vars['threadId'])
$errors['err'] = __('Missing or invalid data');
elseif (!$vars['message'])
$errors['message'] = __('Message content is required');
if ($errors) return false;
$vars['type'] = self::ENTRY_TYPE;
$vars['body'] = $vars['message'];
if (!$vars['poster']
&& $vars['userId']
&& ($user = User::lookup($vars['userId'])))
$vars['poster'] = (string) $user->getName();
return parent::add($vars);
}
static function getVarScope() {
$base = parent::getVarScope();
unset($base['staff']);
return $base;
}
}
/* thread entry of type response */
class ResponseThreadEntry extends ThreadEntry {
const ENTRY_TYPE = 'R';
function getActivity() {
return new ThreadActivity(
_S('New Response'),
_S('New response posted'));
}
function getSubject() {
return $this->getTitle();
}
function getRespondent() {
return $this->getStaff();
}
static function add($vars, &$errors=array()) {
if (!$vars || !is_array($vars) || !$vars['threadId'])
$errors['err'] = __('Missing or invalid data');
elseif (!$vars['response'])
$errors['response'] = __('Response content is required');
if ($errors) return false;
$vars['type'] = self::ENTRY_TYPE;
$vars['body'] = $vars['response'];
if (!$vars['pid'] && $vars['msgId'])
$vars['pid'] = $vars['msgId'];
if (!$vars['poster']
&& $vars['staffId']
&& ($staff = Staff::lookup($vars['staffId'])))
$vars['poster'] = (string) $staff->getName();
return parent::add($vars);
}
static function getVarScope() {
$base = parent::getVarScope();
unset($base['user']);
return $base;
}
}
/* Thread entry of type note (Internal Note) */
class NoteThreadEntry extends ThreadEntry {
const ENTRY_TYPE = 'N';
function getMessage() {
return $this->getBody();
}
function getActivity() {
return new ThreadActivity(
_S('New Internal Note'),
_S('New internal note posted'));
}
static function add($vars, &$errors=array()) {
//Check required params.
if (!$vars || !is_array($vars) || !$vars['threadId'])
$errors['err'] = __('Missing or invalid data');
elseif (!$vars['note'])
$errors['note'] = __('Note content is required');
if ($errors) return false;
//TODO: use array_intersect_key when we move to php 5 to extract just what we need.
$vars['type'] = self::ENTRY_TYPE;
$vars['body'] = $vars['note'];
return parent::add($vars);
}
static function getVarScope() {
$base = parent::getVarScope();
unset($base['user']);
return $base;
}
}
// Object specific thread utils.
class ObjectThread extends Thread
implements TemplateVariable {
static $types = array(
ObjectModel::OBJECT_TYPE_TASK => 'TaskThread',
ObjectModel::OBJECT_TYPE_TICKET => 'TicketThread',
);
var $counts;
function getCounts() {
if (!isset($this->counts) && $this->getId()) {
$this->counts = array();
$stuff = $this->entries
->values_flat('type')
->annotate(array(
'count' => SqlAggregate::COUNT('id')
));
foreach ($stuff as $row) {
list($type, $count) = $row;
$this->counts[$type] = $count;
}
}
return $this->counts;
}
function getNumMessages() {
$this->getCounts();
return $this->counts[MessageThreadEntry::ENTRY_TYPE];
}
function getNumResponses() {
$this->getCounts();
return $this->counts[ResponseThreadEntry::ENTRY_TYPE];
}
function getNumNotes() {
$this->getCounts();
return $this->counts[NoteThreadEntry::ENTRY_TYPE];
}
function getLastMessage($criteria=false) {
$entries = clone $this->getEntries();
$entries->filter(array(
'type' => MessageThreadEntry::ENTRY_TYPE
));
if ($criteria)
$entries->filter($criteria);
$entries->order_by('-id');
return $entries->first();
}
function getLastEmailMessage($criteria=array()) {
$criteria += array(
'source' => 'Email',
'email_info__headers__isnull' => false);
return $this->getLastMessage($criteria);
}
function getLastEmailMessageByUser($user) {
$uid = is_numeric($user) ? $user : 0;
if (!$uid && ($user instanceof EmailContact))
$uid = $user->getUserId();
return $uid
? $this->getLastEmailMessage(array('user_id' => $uid))
: null;
}
function getEntry($criteria) {
// XXX: PUNT
if (is_numeric($criteria))
return parent::getEntry($criteria);
$entries = clone $this->getEntries();
$entries->filter($criteria);
return $entries->first();
}
function getMessages() {
$entries = clone $this->getEntries();
return $entries->filter(array(
'type' => MessageThreadEntry::ENTRY_TYPE
));
}
function getResponses() {
$entries = clone $this->getEntries();
return $entries->filter(array(
'type' => ResponseThreadEntry::ENTRY_TYPE
));
}
function getNotes() {
$entries = clone $this->getEntries();
return $entries->filter(array(
'type' => NoteThreadEntry::ENTRY_TYPE
));
}
function addNote($vars, &$errors=array()) {
//Add ticket Id.
$vars['threadId'] = $this->getId();
return NoteThreadEntry::add($vars, $errors);
}
function addMessage($vars, &$errors) {
$vars['threadId'] = $this->getId();
$vars['staffId'] = 0;
if (!($message = MessageThreadEntry::add($vars, $errors)))
return $message;
$this->lastmessage = SqlFunction::NOW();
$this->save(true);
return $message;
}
function addResponse($vars, &$errors) {
$vars['threadId'] = $this->getId();
$vars['userId'] = 0;
if ($message = $this->getLastMessage())
$vars['pid'] = $message->getId();
$vars['flags'] = 0;
if (!($resp = ResponseThreadEntry::add($vars, $errors)))
return $resp;
$this->lastresponse = SqlFunction::NOW();
$this->save(true);
return $resp;
}
function __toString() {
return $this->asVar();
}
function asVar() {
return new ThreadEntries($this);
}
function getVar($name) {
switch ($name) {
case 'original':
$entry = $this->entries->filter(array(
'type' => MessageThreadEntry::ENTRY_TYPE,
'flags__hasbit' => ThreadEntry::FLAG_ORIGINAL_MESSAGE,
))
->order_by('id')
->first();
if ($entry)
return $entry->getBody();
break;
case 'last_message':
case 'lastmessage':
$entry = $this->getLastMessage();
if ($entry)
return $entry->getBody();
break;
case 'complete':
return $this->asVar();
break;
}
}
static function getVarScope() {
return array(
'complete' =>array('class' => 'ThreadEntries', 'desc' => __('Thread Correspondence')),
'original' => array('class' => 'MessageThreadEntry', 'desc' => __('Original Message')),
'lastmessage' => array('class' => 'MessageThreadEntry', 'desc' => __('Last Message')),
);
}
static function lookup($criteria, $type=false) {
if (!$type)
return parent::lookup($criteria);
$class = false;
if (isset(self::$types[$type]))
$class = self::$types[$type];
if (!class_exists($class))
$class = get_called_class();
return $class::lookup($criteria);
}
}
class ThreadEntries {
var $thread;
function __construct($thread) {
$this->thread = $thread;
}
function __tostring() {
return (string) $this->getVar();
}
function asVar() {
return $this->getVar();
}
function getVar($name='') {
$order = '';
switch ($name) {
case 'reversed':
$order = '-';
default:
$content = '';
$thread = $this->thread;
ob_start();
include INCLUDE_DIR.'client/templates/thread-export.tmpl.php';
$content = ob_get_contents();
ob_end_clean();
return $content;
break;
}
}
static function getVarScope() {
return array(
'reversed' => sprintf('%s %s', __('Thread Correspondence'),
__('in reversed order')),
);
}
}
// Ticket thread class
class TicketThread extends ObjectThread {
static function create($ticket=false) {
assert($ticket !== false);
$id = is_object($ticket) ? $ticket->getId() : $ticket;
$thread = parent::create(array(
'object_id' => $id,
'object_type' => ObjectModel::OBJECT_TYPE_TICKET
));
if ($thread->save())
return $thread;
}
}
/**
* Class: ThreadEntryAction
*
* Defines a simple action to be performed on a thread entry item, such as
* viewing the raw email headers used to generate the message, resend the
* confirmation emails, etc.
*/
abstract class ThreadEntryAction {
static $name; // Friendly, translatable name
static $id; // Unique identifier used for plumbing
static $icon = 'cog';
var $entry;
function getName() {
$class = get_class($this);
return __($class::$name);
}
static function getId() {
return static::$id;
}
function getIcon() {
$class = get_class($this);
return 'icon-' . $class::$icon;
}
function getObJectId() {
return $this->entry->getThread()->getObjectId();
}
function __construct(ThreadEntry $thread) {
$this->entry = $thread;
}
abstract function trigger();
function isEnabled() {
return $this->isVisible();
}
function isVisible() {
return true;
}
/**
* getJsStub
*
* Retrieves a small JavaScript snippet to insert into the rendered page
* which should, via an AJAX callback, trigger this action to be
* performed. The URL for this sort of activity is already provided for
* you via the ::getAjaxUrl() method in this class.
*/
abstract function getJsStub();
/**
* getAjaxUrl
*
* Generate a URL to be used as an AJAX callback. The URL can be used to
* trigger this thread entry action via the callback.
*
* Parameters:
* $dialog - (bool) used in conjunction with `$.dialog()` javascript
* function which assumes the `ajax.php/` should be replace a leading
* `#` in the url
*/
function getAjaxUrl($dialog=false) {
return sprintf('%s%s/%d/thread/%d/%s',
$dialog ? '#' : 'ajax.php/',
$this->entry->getThread()->getObjectType() == 'T' ? 'tickets' : 'tasks',
$this->entry->getThread()->getObjectId(),
$this->entry->getId(),
static::getId()
);
}
function getTicketsAPI() {
return new TicketsAjaxAPI();
}
function getTasksAPI() {
return new TasksAjaxAPI();
}
}
interface Threadable {
function getThreadId();
function getThread();
function postThreadEntry($type, $vars, $options=array());
function addCollaborator($user, $vars, &$errors, $event=true);
}
/**
* ThreadActivity
*
* Object to thread activity
*
*/
class ThreadActivity implements TemplateVariable {
var $title;
var $desc;
function __construct($title, $desc) {
$this->title = $title;
$this->desc = $desc;
}
function getTitle() {
return $this->title;
}
function getDescription() {
return $this->desc;
}
function asVar() {
return (string) $this->getTitle();
}
function getVar($tag) {
if ($tag && is_callable(array($this, 'get'.ucfirst($tag))))
return call_user_func(array($this, 'get'.ucfirst($tag)));
return false;
}
static function getVarScope() {
return array(
'title' => __('Activity Title'),
'description' => __('Activity Description'),
);
}
}
?>