Lint / PHP (phpcs PSR-12) (push) Successful in 26s
Lint / JS (eslint) (push) Successful in 7s
Lint / PHP requirements (version + extensions) (push) Successful in 21s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m8s
Lint / Deploy (push) Successful in 5s
NotificationHelper::fire() logged a failed webhook post via error_log() only, with no retry and no persistent record — once a notification failed, it was gone with no trace beyond the log line, even though the underlying DB write (audit_log entry, status change, etc.) it was reporting on had already committed. Extracted the curl POST into attemptDelivery(), shared between fire() (unchanged best-effort caller-facing behavior) and the new cron/retry_failed_notifications.php. On failure, fire() now also queues the payload to notification_retry_queue (migration 007) via Database::getConnection() — most fire() call sites don't have a $conn handy, and threading one through every caller would be a much larger, more invasive change than reusing the existing connection singleton. The cron script processes due rows with exponential backoff (2, 4, 8... capped at 60 minutes) up to each row's max_attempts (default 6), then leaves an exhausted row in place — not deleted — so it stays visible for manual investigation instead of disappearing a second time. Verified against real MariaDB and a local HTTP server standing in for the Matrix webhook, toggled between failing and succeeding: confirmed a real failure via fire() is correctly queued; the retry script reschedules a still-failing row with the expected backoff delay; flipping the fake webhook to succeed lets the same row's next retry delete it; a row that exhausts all attempts is left in place and correctly excluded from the next run's due-row query; and a success via fire() queues nothing (no regression on the common case). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
381 lines
16 KiB
PHP
381 lines
16 KiB
PHP
<?php
|
|
|
|
require_once dirname(__DIR__) . '/helpers/UrlHelper.php';
|
|
require_once dirname(__DIR__) . '/helpers/SynapseHelper.php';
|
|
|
|
class NotificationHelper
|
|
{
|
|
// ─── Internal: fire a webhook ─────────────────────────────────────────────
|
|
|
|
/**
|
|
* POST a payload to the configured Matrix webhook and report the raw
|
|
* result. Shared by fire() (best-effort, queues on failure) and
|
|
* cron/retry_failed_notifications.php (retries a previously-queued
|
|
* payload) so both use identical request handling.
|
|
*
|
|
* @return array{success: bool, http_code: ?int, error: ?string}
|
|
*/
|
|
public static function attemptDelivery(string $webhookUrl, array $payload): array
|
|
{
|
|
$ch = curl_init($webhookUrl);
|
|
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
|
curl_setopt($ch, CURLOPT_POST, 1);
|
|
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
|
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
|
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
|
// A slow-but-not-fully-hung hookshot endpoint could otherwise add up
|
|
// to the full CURLOPT_TIMEOUT per call, and a single request can
|
|
// trigger more than one notification sequentially (via
|
|
// notifyWatchers/sendCommentNotification/etc.) — capping just the
|
|
// connect phase keeps that from stacking into tens of seconds of
|
|
// added latency.
|
|
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 3);
|
|
|
|
$response = curl_exec($ch);
|
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
$curlError = curl_error($ch);
|
|
curl_close($ch);
|
|
|
|
if ($curlError) {
|
|
return ['success' => 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,
|
|
]);
|
|
}
|
|
}
|