false, 'http_code' => null, 'error' => $curlError]; } if ($httpCode < 200 || $httpCode >= 300) { return ['success' => false, 'http_code' => $httpCode, 'error' => "HTTP {$httpCode}: {$response}"]; } return ['success' => true, 'http_code' => $httpCode, 'error' => null]; } /** * Persist a failed payload for later retry by * cron/retry_failed_notifications.php. Best-effort: a DB failure here * must not throw back into the original (already-failed) notification * attempt — it just means this particular failure isn't retried, no * worse than the pre-existing behavior. */ private static function queueForRetry(array $payload, string $error): void { try { require_once dirname(__DIR__) . '/helpers/Database.php'; $conn = Database::getConnection(); $stmt = $conn->prepare( "INSERT INTO notification_retry_queue (payload, last_error) VALUES (?, ?)" ); $payloadJson = json_encode($payload); $stmt->bind_param("ss", $payloadJson, $error); $stmt->execute(); $stmt->close(); } catch (Throwable $e) { error_log('NotificationHelper: failed to queue notification for retry: ' . $e->getMessage()); } } private static function fire(array $payload): void { $webhookUrl = $GLOBALS['config']['MATRIX_WEBHOOK_URL'] ?? null; if (empty($webhookUrl)) { return; } $result = self::attemptDelivery($webhookUrl, $payload); if ($result['success']) { return; } $id = $payload['ticket_id'] ?? '?'; error_log("Matrix webhook failed for ticket #{$id}: {$result['error']}"); self::queueForRetry($payload, $result['error']); } private static function notifyUsers(): array { $raw = $GLOBALS['config']['MATRIX_NOTIFY_USERS'] ?? ''; return array_values(array_filter(array_map('trim', explode(',', $raw)))); } /** * Redact a ticket title for the shared Matrix notify list when the * ticket isn't public, matching how sendCommentNotification() and * notifyWatchers() already redact comment/activity previews for the * same list. */ private static function redactedTitle(string $title, string $visibility): string { return $visibility === 'public' ? $title : '(restricted ticket — title hidden)'; } // ─── Public event methods ───────────────────────────────────────────────── /** * Generic operational alert with no associated ticket (e.g. a recurring * schedule whose ticket creation failed after its next_run_at was * already advanced, so the missed occurrence has no other record an * admin would normally see). Always sent to the shared * MATRIX_NOTIFY_USERS list, regardless of any per-event notify toggle. */ public static function sendSystemAlert(string $message, array $context = []): void { self::fire(array_merge([ 'event' => 'system_alert', 'message' => $message, ], $context, [ 'notify_users' => self::notifyUsers(), ])); } /** * New ticket created (manual or automated/API). * * $ticketData['visibility'] ('public', 'internal', or 'confidential') is * used to redact the title sent to the shared MATRIX_NOTIFY_USERS list * for non-public tickets, same as sendCommentNotification()'s preview * redaction. Defaults to 'public' for callers (e.g. the hwmonDaemon * Bearer-API paths) that never set a non-default visibility. */ public static function sendTicketNotification($ticketId, array $ticketData, string $trigger = 'manual'): void { $visibility = $ticketData['visibility'] ?? 'public'; $title = $ticketData['title'] ?? 'Untitled'; preg_match('/^\[([^\]]+)\]/', $title, $m); $source = $m[1] ?? ($trigger === 'automated' ? 'Automated' : 'Manual'); self::fire([ 'event' => 'ticket_created', 'ticket_id' => $ticketId, 'title' => self::redactedTitle($title, $visibility), 'priority' => (int)($ticketData['priority'] ?? 4), 'category' => $ticketData['category'] ?? 'General', 'type' => $ticketData['type'] ?? 'Issue', 'status' => $ticketData['status'] ?? 'Open', 'source' => $source, 'url' => UrlHelper::ticketUrl($ticketId), 'trigger' => $trigger, 'notify_users' => self::notifyUsers(), ]); } /** * Ticket status changed. * * @param string|int $ticketId * @param string $oldStatus * @param string $newStatus * @param string $ticketTitle * @param string|null $changedByDisplay Display name of the user who changed status * @param string $visibility Ticket visibility; non-public titles are * redacted before being sent to the shared * notify list, same as sendTicketNotification(). */ public static function sendStatusChangeNotification($ticketId, string $oldStatus, string $newStatus, string $ticketTitle, ?string $changedByDisplay = null, string $visibility = 'public'): void { self::fire([ 'event' => 'status_changed', 'ticket_id' => $ticketId, 'title' => self::redactedTitle($ticketTitle, $visibility), 'old_status' => $oldStatus, 'new_status' => $newStatus, 'changed_by' => $changedByDisplay, 'url' => UrlHelper::ticketUrl($ticketId), 'notify_users' => self::notifyUsers(), ]); } /** * New comment posted (non-mention; use sendMentionNotification for @mentions). * * @param string|int $ticketId * @param string $ticketTitle * @param string $commentText Plain text (first 200 chars will be sent) * @param string|null $authorDisplay Display name of commenter * @param bool $isInternal True if the comment is internal-only * @param string $visibility Ticket visibility: 'public', 'internal', or * 'confidential'. For non-public tickets the * comment text preview is redacted so it is * never leaked to the shared notify list. */ public static function sendCommentNotification($ticketId, string $ticketTitle, string $commentText, ?string $authorDisplay = null, bool $isInternal = false, string $visibility = 'public'): void { $notifyUsers = self::notifyUsers(); if (empty($notifyUsers)) { return; } // The shared notify list may include users without access to non-public // tickets, so never post the comment body for internal/confidential // tickets — only that activity occurred. $preview = $visibility === 'public' ? mb_strimwidth($commentText, 0, 200, '…') : null; self::fire([ 'event' => 'comment_added', 'ticket_id' => $ticketId, 'title' => $ticketTitle, 'author' => $authorDisplay, 'preview' => $preview, 'is_internal' => $isInternal, 'url' => UrlHelper::ticketUrl($ticketId), 'notify_users' => $notifyUsers, ]); } /** * @mention detected in a comment. * * @param string|int $ticketId * @param string $ticketTitle * @param string $commentText * @param string|null $authorDisplay * @param array $mentionedMatrixIds Matrix user IDs derived from @usernames */ public static function sendMentionNotification($ticketId, string $ticketTitle, string $commentText, ?string $authorDisplay, array $mentionedMatrixIds): void { if (empty($mentionedMatrixIds)) { return; } self::fire([ 'event' => 'mention', 'ticket_id' => $ticketId, 'title' => $ticketTitle, 'author' => $authorDisplay, 'preview' => mb_strimwidth($commentText, 0, 200, '…'), 'url' => UrlHelper::ticketUrl($ticketId), 'notify_users' => $mentionedMatrixIds, ]); } /** * Notify all watchers of a ticket about an update event. * * Fetches watchers from the DB, resolves their Matrix IDs via Synapse, * and fires the appropriate event notification with them in notify_users. * * @param \mysqli $conn * @param string|int $ticketId * @param string $ticketTitle * @param string $event One of: status_changed, comment_added, assigned * @param array $extraData Merged into the payload (old_status/new_status, author, etc.) * @param int|null $excludeUserId Don't notify the actor themselves * @param string $visibility Ticket visibility: 'public', 'internal', or * 'confidential'. The shared notify list may * contain users without access to non-public * tickets, so for those tickets it's excluded * entirely (only actual watchers are notified) * and both the title and any comment/body * preview in $extraData are redacted. */ public static function notifyWatchers(\mysqli $conn, $ticketId, string $ticketTitle, string $event, array $extraData = [], ?int $excludeUserId = null, string $visibility = 'public'): void { $webhookUrl = $GLOBALS['config']['MATRIX_WEBHOOK_URL'] ?? null; $domain = $GLOBALS['config']['MATRIX_DOMAIN'] ?? null; if (!$webhookUrl || !$domain) { return; } // Don't leak comment/body content to the shared notify list for // non-public tickets — keep only the fact that activity occurred. if ($visibility !== 'public' && isset($extraData['preview'])) { $extraData['preview'] = null; } // Fetch watcher usernames, excluding the actor so they don't notify // themselves. Notifications are best-effort: if the watchers table is // absent or the query fails, skip silently rather than fataling the // request that already committed its DB change. mysqli may either throw // (default exception mode) or return false, so handle both. $usernames = []; try { if ($excludeUserId !== null) { $sql = "SELECT u.username FROM ticket_watchers tw JOIN users u ON tw.user_id = u.user_id WHERE tw.ticket_id = ? AND tw.user_id != ?"; $stmt = $conn->prepare($sql); } else { $sql = "SELECT u.username FROM ticket_watchers tw JOIN users u ON tw.user_id = u.user_id WHERE tw.ticket_id = ?"; $stmt = $conn->prepare($sql); } if (!$stmt) { return; } if ($excludeUserId !== null) { $stmt->bind_param("si", $ticketId, $excludeUserId); } else { $stmt->bind_param("s", $ticketId); } $stmt->execute(); $result = $stmt->get_result(); $stmt->close(); while ($row = $result->fetch_assoc()) { $usernames[] = $row['username']; } } catch (\Throwable $e) { error_log('NotificationHelper::notifyWatchers watcher lookup failed: ' . $e->getMessage()); return; } if (empty($usernames)) { return; } // Resolve to Matrix IDs — skip users without Synapse accounts $matrixIds = SynapseHelper::resolveUsernames($usernames); if (empty($matrixIds)) { return; } // The shared notify list may include users without access to // non-public tickets, so only mix it in for public tickets — for // internal/confidential tickets, notify actual watchers only. $allNotify = $visibility === 'public' ? array_unique(array_merge($matrixIds, self::notifyUsers())) : $matrixIds; $payload = array_merge($extraData, [ 'event' => $event, 'ticket_id' => $ticketId, 'title' => self::redactedTitle($ticketTitle, $visibility), 'url' => UrlHelper::ticketUrl($ticketId), 'notify_users' => array_values($allNotify), ]); self::fire($payload); } /** * Ticket assigned (or reassigned) to a user. * * @param string|int $ticketId * @param string $ticketTitle * @param string|null $assigneeName Display name of new assignee * @param string|null $assigneeMatrix Matrix user ID of new assignee (to DM) * @param string|null $changedByDisplay * @param string $visibility Ticket visibility; non-public titles are * redacted before being sent to the shared * notify list, same as sendTicketNotification(). * The assignee is DMed directly regardless, * since they now have standing access to the * ticket by virtue of being assigned to it. */ public static function sendAssignmentNotification($ticketId, string $ticketTitle, ?string $assigneeName, ?string $assigneeMatrix, ?string $changedByDisplay = null, string $visibility = 'public'): void { $notifyUsers = self::notifyUsers(); // Also notify the assignee directly if we know their Matrix ID if ($assigneeMatrix && !in_array($assigneeMatrix, $notifyUsers, true)) { $notifyUsers[] = $assigneeMatrix; } if (empty($notifyUsers)) { return; } self::fire([ 'event' => 'assigned', 'ticket_id' => $ticketId, 'title' => self::redactedTitle($ticketTitle, $visibility), 'assignee' => $assigneeName, 'changed_by' => $changedByDisplay, 'url' => UrlHelper::ticketUrl($ticketId), 'notify_users' => $notifyUsers, ]); } }