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';
function getDescription($mode=self::MODE_STAFF) {
return $this->template(__('Closed by <b>{somebody}</b> with status of {<TicketStatus>data.status} {timestamp}'), $mode);
return $this->template(__('Closed by <b>{somebody}</b> {timestamp}'), $mode);
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
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;
$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;
}
}
}
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);
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
}
}
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')),
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
'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;
}
}
}
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}'));
}
}
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);
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
}
}
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}'));
}
}
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}'));
}
}
class ViewEvent extends ThreadEvent {
static $state = 'viewed';
}
static $types = array('text', 'html');
var $body;
var $type;
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");
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) . ' ... '
$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,
// Strip the quoted part of the body
if ((list($msg) = explode($tag, $this->body, 2)) && trim($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');
static function fromFormattedText($text, $format=false, $options=array()) {
switch ($format) {
case 'text':
return new HtmlThreadEntryBody($text, array('strip-embedded'=>false) + $options);
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>';
return '<div style="white-space:pre-wrap">'
return nl2br($escaped);
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() {
// 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);
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;
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;
}
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;
$this->getCounts();
return $this->counts[MessageThreadEntry::ENTRY_TYPE];
$this->getCounts();
return $this->counts[ResponseThreadEntry::ENTRY_TYPE];
$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);
2803
2804
2805
2806
2807
2808
2809
2810
2811
2812
2813
2814
2815
2816
2817
2818
2819
2820
2821
2822
2823
2824
2825
$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;
// 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
));
$entries = clone $this->getEntries();
return $entries->filter(array(
'type' => ResponseThreadEntry::ENTRY_TYPE
));
$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);
}
function addResponse($vars, &$errors) {
$vars['threadId'] = $this->getId();
$vars['userId'] = 0;
if ($message = $this->getLastMessage())
$vars['pid'] = $message->getId();
if (!($resp = ResponseThreadEntry::add($vars, $errors)))
return $resp;
$this->lastresponse = SqlFunction::NOW();
$this->save(true);
}
function getVar($name) {
switch ($name) {
$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();
static function getVarScope() {
return array(
'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);
if (isset(self::$types[$type]))
$class = self::$types[$type];
if (!class_exists($class))
$class = get_called_class();
return $class::lookup($criteria);
}
}
// 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_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';
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) {
2986
2987
2988
2989
2990
2991
2992
2993
2994
2995
2996
2997
2998
2999
3000
3001
3002
3003
3004
3005
3006
}
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) {
$this->entry->getThread()->getObjectType() == 'T' ? 'tickets' : 'tasks',
$this->entry->getThread()->getObjectId(),
$this->entry->getId(),
function getTicketsAPI() {
return new TicketsAjaxAPI();
}
function getTasksAPI() {
return new TasksAjaxAPI();
}
function getThreadId();
function getThread();
function postThreadEntry($type, $vars, $options=array());
3042
3043
3044
3045
3046
3047
3048
3049
3050
3051
3052
3053
3054
3055
3056
3057
3058
3059
3060
3061
3062
3063
3064
3065
3066
3067
3068
3069
3070
3071
3072
3073
3074
3075
3076
3077
3078
3079
3080
3081
3082
3083
/**
* 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'),
);
}
}