-
Notifications
You must be signed in to change notification settings - Fork 23
/
class.CloserPlugin.php
544 lines (482 loc) · 19.3 KB
/
class.CloserPlugin.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
<?php
/**
* @file class.CloserPlugin.php ::
* @ requires osTicket 1.17+ & PHP8.0+
* @ multi-instance: yes
*
* @author Grizly <[email protected]>
* @see https://github.com/clonemeagain/plugin-autocloser
* @fork by Cartmega <www.cartmega.com>
* @see https://github.com/Cartmega/plugin-autocloser
*/
foreach ([
'canned',
'format',
'list',
'orm',
'misc',
'plugin',
'ticket',
'signal',
'staff'
] as $c) {
require_once INCLUDE_DIR . "class.$c.php";
}
require_once 'config.php';
/**
* The goal of this Plugin is to close tickets when they get old. Logans Run
* style.
*/
class CloserPlugin extends Plugin {
var $config_class = 'CloserPluginConfig';
/**
* Set to TRUE to enable extra logging.
*
* @var boolean
*/
const DEBUG = FALSE;
/**
* Keeps all log entries for each run
* for output to syslog
*
* @var array
*/
private $LOG = array();
/**
* The name that appears in threads as: Closer Plugin.
*
* @var string
*/
const PLUGIN_NAME = 'Closer Plugin';
/**
* Hook the bootstrap process Run on every instantiation, so needs to be
* concise.
*
* {@inheritdoc}
*
* @see Plugin::bootstrap()
*/
public function bootstrap() {
// ---------------------------------------------------------------------
// Fetch the config
// ---------------------------------------------------------------------
// Save config and instance for use later in the signal, when it is called
$config = $this->config;
$instance = $this->config->instance;
// Listen for cron Signal, which only happens at end of class.cron.php:
Signal::connect('cron', function ($ignored, $data) use (&$config, $instance) {
// Autocron is an admin option, we can filter out Autocron Signals
// to ensure changing state for potentially hundreds/thousands
// of tickets doesn't affect interactive Agent/User experience.
$use_autocron = $config->get('use_autocron');
// Autocron Cron Signals are sent with this array key set to TRUE
$is_autocron = (isset($data['autocron']) && $data['autocron']);
// Normal cron isn't Autocron:
if (!$is_autocron || ($use_autocron && $is_autocron))
$this->logans_run_mode($config);
});
}
/**
* Closes old tickets.. with extreme prejudice.. or, regular prejudice..
* whatever. = Welcome to the 23rd Century. The perfect world of total
* pleasure. ... there's just one catch.
*/
private function logans_run_mode(&$config) {
global $ost;
if ($this->is_time_to_run($config)) {
try {
$open_ticket_ids = $this->find_ticket_ids($config);
if (self::DEBUG) {
$this->LOG[]=count($open_ticket_ids) . " open tickets.";
}
// Bail if there is no work to do
if (!count($open_ticket_ids)) {
return true;
}
// Find the new TicketStatus from the Setting config:
$new_status = TicketStatus::lookup(
array(
'id' => (int) $config->get('to-status')
));
// Admin note is just text
$admin_note = $config->get('admin-note') ?: FALSE;
// Fetch the actual content of the reply, "html" means load with images,
// I don't think it works with attachments though.
$admin_reply = $config->get('admin-reply');
if (is_numeric($admin_reply) && $admin_reply) {
// We have a valid Canned_Response ID, fetch the actual Canned:
$admin_reply = Canned::lookup($admin_reply);
if ($admin_reply instanceof Canned) {
// Got a real Canned object, let's pull the body/string:
$admin_reply = $admin_reply->getFormattedResponse('html');
}
}
if (self::DEBUG) {
$this->LOG[]="Found the following details:\nAdmin Note: $admin_note\n\nAdmin Reply: $admin_reply\n";
}
// Get the robot for this config
$robot = $config->get('robot-account');
$robot = ($robot>0)? $robot = Staff::lookup($robot) : null;
// Go through each ticket ID:
foreach ($open_ticket_ids as $ticket_id) {
// Fetch ticket as an Object
$ticket = Ticket::lookup($ticket_id);
if (!$ticket instanceof Ticket) {
$this->LOG[]="Ticket $ticket_id was not instatiable. :-(";
continue;
}
// Some tickets aren't closeable.. either because of open tasks, or missing fields.
// we can therefore only work on closeable tickets.
// This won't close it, nor will it send a response, so it will likely trigger again
// on the next run.. TRUE means send an alert.
if (!$ticket->isCloseable()) {
$ticket->LogNote(__('Error auto-changing status'), __(
'Unable to change this ticket\'s status to ' .
$new_status->getState()), self::PLUGIN_NAME, TRUE);
continue;
}
// Add a Note to the thread indicating it was closed by us, don't send an alert.
if ($admin_note) {
$ticket->LogNote(
__('Changing status to: ' . $new_status->getState()), $admin_note, self::PLUGIN_NAME, FALSE);
}
// Post a Reply to the user, telling them the ticket is closed, relates to issue #2
if ($admin_reply) {
$this->post_reply($ticket, $new_status, $admin_reply, $robot);
}
// Actually change the ticket status
$this->change_ticket_status($ticket, $new_status);
}
$this->print2log();
} catch (Exception $e) {
// Well, something borked
$this->LOG[]="Exception encountered, we'll soldier on, but something is broken!";
$this->LOG[]=$e->getMessage();
if (self::DEBUG) {$this->LOG[]='<pre>'.print_r($e->getTrace(),2).'</pre>';}
$this->print2log();
}
}
}
/**
* Calculates when it's time to run the plugin, based on the config. Uses
* things like: How long the admin defined the cycle to be? When it was last
* run
*
* @param PluginConfig $config
* @return boolean
*/
private function is_time_to_run(PluginConfig &$config) {
// We can store arbitrary things in the config, like, when we ran this last:
$last_run = $config->get('last-run');
$now = Misc::dbtime(); // Never assume about time..
$config->set('last-run', $now);
// assume a freqency of "Every Cron" means it is always overdue
$next_run = 0;
// Convert purge frequency to a comparable format to timestamps:
$fr=($config->get('frequency') > 0) ? $config->get('frequency') : 0;
if ($freq_in_config = (int) $fr) {
// Calculate when we want to run next, config hours into seconds,
// plus the last run is the timestamp of the next scheduled run
$next_run = $last_run + ($freq_in_config * 3600);
}
// See if it's time to check old tickets
// Always run when in DEBUG mode.. because waiting for the scheduler is slow
// If we don't have a next_run, it's because we want it to run
// If the next run is in the past, then we are overdue, so, lets go!
if (self::DEBUG || !$next_run || $now > $next_run) {
return TRUE;
}
return FALSE;
}
/**
* This is the part that actually "Closes" the tickets Well, depending on the
* admin settings I mean. Could use $ticket->setStatus($closed_status)
* function however, this gives us control over _how_ it is closed. preventing
* accidentally making any logged-in staff associated with the closure, which
* is an issue with AutoCron
*
* @param Ticket $ticket
* @param TicketStatus $new_status
*/
private function change_ticket_status(Ticket $ticket, TicketStatus $new_status) {
global $ost;
if (self::DEBUG) {
$this->LOG[]=
"Setting status " . $new_status->getState() .
" for ticket {$ticket->getId()}::{$ticket->getSubject()}";
}
// Start by setting the last update and closed timestamps to now
$ticket->closed = $ticket->lastupdate = SqlFunction::NOW();
// Remove any duedate or overdue flags
$ticket->duedate = null;
$ticket->clearOverdue(FALSE); // flag prevents saving, we'll do that
// Post an Event with the current timestamp.
$ticket->logEvent($new_status->getState(), [
'status' => [
$new_status->getId(),
$new_status->getName()
]
]);
// Actually apply the new "TicketStatus" to the Ticket.
$ticket->status = $new_status;
// Save it, flag prevents it refetching the ticket data straight away (inefficient)
$ticket->save(FALSE);
}
/**
* Retrieves an array of ticket_id's from the database
*
* @param PluginConfig $config
* @return array of integers that are Ticket::lookup compatible ID's of Open
* Tickets
* @throws Exception so you have something interesting to read in your cron
* logs..
*/
private function find_ticket_ids(PluginConfig &$config) {
global $ost;
$from_status = (int) $config->get('from-status');
if (!$from_status) {
throw new \Exception("Invalid parameter (int) from_status needs to be > 0");
}
$age_days = (int) $config->get('purge-age');
if ($age_days < 1) {
throw new \Exception("Invalid parameter (int) age_days needs to be > 0");
}
$max = (int) $config->get('purge-num');
if ($max < 1) {
throw new \Exception("Invalid parameter (int) max needs to be > 0");
}
$whereFilter = ($config->get('close-only-answered')) ? ' AND isanswered=1' : '';
$whereFilter .= ($config->get('close-only-overdue')) ? ' AND isoverdue=1' : '';
// Ticket query, note MySQL is doing all the date maths:
// Sidebar: Why haven't we moved to PDO yet?
/*
* Attempt to do this with ORM $tickets = Ticket::objects()->filter( array(
* 'lastupdate' => SqlFunction::DATEDIFF(SqlFunction::NOW(),
* SqlInterval($age_days, 'DAY')), 'status_id' => $from_status, 'isanswered'
* => 1, 'isoverdue' => 1 ))->all(); print_r($tickets);
*/
$sql = sprintf(
"
SELECT ticket_id
FROM %s WHERE lastupdate < DATE_SUB(NOW(), INTERVAL %d DAY)
AND status_id=%d %s
ORDER BY ticket_id ASC
LIMIT %d", TICKET_TABLE, $age_days, $from_status, $whereFilter, $max);
if (self::DEBUG) {
$this->LOG[]="Looking for tickets with query: $sql";
}
$r = db_query($sql);
// Fill an array with just the ID's of the tickets:
$ids = array();
while ($i = db_fetch_array($r, MYSQLI_ASSOC)) {
$ids[] = $i['ticket_id'];
}
return $ids;
}
/**
* Sends a reply to the ticket creator Wrapper/customizer around the
* Ticket::postReply method.
*
* @param Ticket $ticket
* @param TicketStatus $new_status
* @param string $admin_reply
*/
function post_reply(Ticket $ticket, TicketStatus $new_status, $admin_reply, Staff $robot = null) {
// We need to override this for the notifications
global $ost, $thisstaff;
if ($robot) {
$assignee = $robot;
} else {
$assignee = $ticket->getAssignee();
if (!$assignee instanceof Staff) {
// Nobody, or a Team was assigned, and we haven't been told to use a Robot account.
$ticket->logNote(__('AutoCloser Error'), __(
'Unable to send reply, no assigned Agent on ticket, and no Robot account specified in config.'), self::PLUGIN_NAME, FALSE);
return;
}
}
// This actually bypasses any authentication/validation checks..
$thisstaff = $assignee;
// Replace any ticket variables in the message:
$variables = [
'recipient' => $ticket->getOwner()
];
// Provide extra variables.. because. :-)
$options = [
'wholethread' => 'fetch_whole_thread',
'firstresponse' => 'fetch_first_response',
'lastresponse' => 'fetch_last_response'
];
// See if they've been used, if so, call the function
foreach ($options as $option => $method) {
if (strpos($admin_reply, $option) !== FALSE) {
$variables[$option] = $this->{$method}($ticket);
}
}
// Use the Ticket objects own replaceVars method, which replace
// any other Ticket variables.
$custom_reply = $ticket->replaceVars($admin_reply, $variables);
// Build an array of values to send to the ticket's postReply function
// 'emailcollab' => FALSE // don't send notification to all collaborators.. maybe.. dunno.
$vars = [
'reply-to' => 'all',
'response' => $custom_reply
];
$errors = [];
// Send the alert without claiming the ticket on our assignee's behalf.
if (!$sent = $ticket->postReply($vars, $errors, TRUE, FALSE)) {
$ticket->LogNote(__('Error Notification'), __('We were unable to post a reply to the ticket creator.'), self::PLUGIN_NAME, FALSE);
}
}
/**
* Fetches the first response sent to the ticket Owner
*
* @param Ticket $ticket
* @return string
*/
private function fetch_first_response(Ticket $ticket) {
// Apparently the ORM is fighting me.. it doesn't like me
$thread = $ticket->getThread();
if (!$thread instanceof Thread) {
return '';
}
foreach ($thread->getEntries()->all() as $entry) {
if ($this->is_valid_thread_entry($entry, FALSE, TRUE)) {
// this is actually a Response. yes..
return $this->render_thread_entry($entry);
}
}
return ''; // the empty string overwrites the template
}
/**
* Fetches the last response sent to the ticket Owner.
*
* @param Ticket $ticket
* @return string
*/
private function fetch_last_response(Ticket $ticket) {
$thread = $ticket->getThread();
if (!$thread instanceof Thread) {
return '';
}
$last = '';
// Can't seem to get this sorted in reverse.. thought I had it, but nope.
foreach ($thread->getEntries()->all() as $entry) {
if ($this->is_valid_thread_entry($entry, FALSE, TRUE)) {
// We'll just render each response, overwriting the previous one..
// screw it.
$last = $this->render_thread_entry($entry);
}
}
return $last; // the empty string overwrites the template
}
/**
* Fetches the whole thread that the client can see. As an HTML message.
*
* @param Ticket $ticket
* @return string
*/
private function fetch_whole_thread(Ticket $ticket) {
$msg = '';
$thread = $ticket->getThread();
if (!$thread instanceof Thread) {
return $msg;
}
// Iterate through all the thread entries (in order),
// Not sure the ->order_by() thing even does anything.
foreach ($thread->getEntries()
->order_by('created', QuerySet::ASC)
->all() as $entry) {
// Test each entries data-model, and the type of entry from it's model
if ($this->is_valid_thread_entry($entry, TRUE, TRUE)) {
// this is actually a Response or Message yes..
$msg .= $this->render_thread_entry($entry);
}
}
return $msg;
}
/**
* Renders a ThreadEntry as HTML.
*
* @param AnnotatedModel $entry
* @return string
*/
private function render_thread_entry(AnnotatedModel $entry) {
$from = ($entry->get('type') == 'R') ? 'Sent' : 'Received';
$tag = ($entry->get('format') == 'text') ? 'pre' : 'p';
$when = Format::datetime(strtotime($entry->get('created')));
// TODO: Maybe make this a CannedResponse or admin template?
return <<<PIECE
<hr />
<p class="thread">
<h3>{$entry->get('title')}</h3>
<p>$from Date: $when</p>
<$tag>{$entry->get('body')}</$tag>
</p>
PIECE;
}
/**
* $entry should be an AnnotatedModel object, however, we need to check that
* it's actually a type of ThreadEntry, therefore we need to interrogate the
* Object inside it. Would be good if the $ticket->getResponses() method
* worked..
*
* @param AnnotatedModel $entry
* @param bool $message
* @param bool $response
* @return boolean
*/
private function is_valid_thread_entry(AnnotatedModel $entry, $message = FALSE, $response = FALSE) {
if (!$entry->model instanceof ThreadEntry) {
return FALSE;
}
if (!$message && !$response) {
// you gotta pick one ..
return FALSE;
}
if (self::DEBUG) {
$this->LOG[]=printf("Testing thread entry: %s : %s\n", $entry->get('type'), $entry->get('title'));
}
if (isset($entry->model->ht['type'])) {
if ($response && $entry->get('type') == 'R') {
// this is actually a Response
return TRUE;
} elseif ($message && $entry->get('type') == 'M') {
// this is actually a Message
return TRUE;
}
}
return FALSE;
}
/**
* Required stub.
*
* {@inheritdoc}
*
* @see Plugin::uninstall()
*/
function uninstall(&$errors) {
$errors = array();
global $ost;
// Send an alert to the system admin:
$ost->alertAdmin(self::PLUGIN_NAME . ' has been uninstalled', "You wanted that right?", true);
parent::uninstall($errors);
}
/**
* Plugins seem to want this.
*/
public function getForm() {
return array();
}
/**
* Outputs all log entries to the syslog
*
*/
private function print2log() {
global $ost;
if (empty($this->LOG)) {return false;}
$msg='';
foreach($this->LOG as $key=>$value) {$msg.=$value.'<br>';}
$ost->logWarning(self::PLUGIN_NAME, $msg, false);
}
}