diff --git a/runtime/history/SUPPORT-260918-GRAM-SUPPORT-STAFF-EMAIL-SOURCE-READONLY-516R1R4.json b/runtime/history/SUPPORT-260918-GRAM-SUPPORT-STAFF-EMAIL-SOURCE-READONLY-516R1R4.json new file mode 100644 index 00000000..696ffb7c --- /dev/null +++ b/runtime/history/SUPPORT-260918-GRAM-SUPPORT-STAFF-EMAIL-SOURCE-READONLY-516R1R4.json @@ -0,0 +1,38 @@ +{ + "schema_version": 1, + "channel": "homelab-runtime", + "command_id": "SUPPORT-260918-GRAM-SUPPORT-STAFF-EMAIL-SOURCE-READONLY-516R1R4", + "status": "OK", + "rc": 0, + "host": "pve01", + "mode": "read-only", + "component": "gram-support-staff-email-source-readonly", + "started_at_utc": "2026-09-18T05:14:57Z", + "finished_at_utc": "2026-09-18T05:15:08Z", + "reference_register_checked": true, + "reference_sha256": "5a3d8e5154c41cb582a4e0aca68090be1f0138918bf82131a948df326f9d8d66", + "error_register_checked": true, + "error_register_sha256": "3b09a553ec0f527ed3afeed4753f52a74ee3036045bbfb3c685e9f8af4ba7ba0", + "command_sha256": "08ff6acd78dea5c4a5621dcca2fedad234c67817f25ff1f3b4ee9b9b2d14c5f0", + "duplicate_failed_command_blocked": false, + "block_reason": null, + "execution_started": true, + "change_declared": false, + "result_contract_valid": true, + "result_contract_status": null, + "result_contract_error": null, + "command_rc": 0, + "changes_made": false, + "rollback_started": false, + "rollback_restored": null, + "mutation_outcome": "NO_MUTATION", + "sanitized": true, + "secrets_included": false, + "private_addresses_included": false, + "raw_evidence_retained_locally": true, + "raw_evidence_sha256": "3375e95bfa30e7b6adf9c22d4a03b7407be9113f1d2ed450ec96bed61add1e9a", + "sanitized_output_sha256": "3375e95bfa30e7b6adf9c22d4a03b7407be9113f1d2ed450ec96bed61add1e9a", + "output_truncated_in_json": false, + "full_sanitized_output_url": "https://git.gram1.ru/.well-known/homelab-runtime/latest.txt", + "output": "GRAM516R1R4_SAFE_REPORT={\"classification\":{\"all_sites_eligible_staff_match_target\":true,\"changes_made\":false,\"eligible_staff_target_match_total\":7,\"eligible_staff_total\":7,\"email_consumer_present\":true,\"event_dispatcher_present\":true,\"runtime_probe_7of7\":true,\"send_attempts\":0,\"source_probe_ok\":true},\"schema_version\":1,\"scope\":\"READ_ONLY_GRAM_SUPPORT_STAFF_EMAIL_SOURCE\",\"secret_policy\":\"NO_NON_TARGET_EMAIL_ADDRESSES_EXPORTED\",\"sites\":[{\"all_eligible_match_target\":true,\"eligible_staff_count\":1,\"eligible_target_match_count\":1,\"slug\":\"codevipe\",\"staff\":[{\"email_matches_target\":true,\"email_nonempty\":true,\"gram_manage\":true,\"gram_viewAll\":true,\"is_admin\":true,\"is_moderator\":true,\"primary_group_id\":2,\"secondary_group_ids\":[3,4],\"staff_eligible\":true,\"user_id_sha256\":\"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b\"}]},{\"all_eligible_match_target\":true,\"eligible_staff_count\":1,\"eligible_target_match_count\":1,\"slug\":\"dsmods\",\"staff\":[{\"email_matches_target\":true,\"email_nonempty\":true,\"gram_manage\":true,\"gram_viewAll\":true,\"is_admin\":true,\"is_moderator\":true,\"primary_group_id\":2,\"secondary_group_ids\":[3,4],\"staff_eligible\":true,\"user_id_sha256\":\"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b\"}]},{\"all_eligible_match_target\":true,\"eligible_staff_count\":1,\"eligible_target_match_count\":1,\"slug\":\"gamevipe\",\"staff\":[{\"email_matches_target\":true,\"email_nonempty\":true,\"gram_manage\":true,\"gram_viewAll\":true,\"is_admin\":true,\"is_moderator\":true,\"primary_group_id\":2,\"secondary_group_ids\":[3,4],\"staff_eligible\":true,\"user_id_sha256\":\"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b\"}]},{\"all_eligible_match_target\":true,\"eligible_staff_count\":1,\"eligible_target_match_count\":1,\"slug\":\"hkmods\",\"staff\":[{\"email_matches_target\":true,\"email_nonempty\":true,\"gram_manage\":true,\"gram_viewAll\":true,\"is_admin\":true,\"is_moderator\":true,\"primary_group_id\":2,\"secondary_group_ids\":[3,4],\"staff_eligible\":true,\"user_id_sha256\":\"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b\"}]},{\"all_eligible_match_target\":true,\"eligible_staff_count\":1,\"eligible_target_match_count\":1,\"slug\":\"kingofwolk\",\"staff\":[{\"email_matches_target\":true,\"email_nonempty\":true,\"gram_manage\":true,\"gram_viewAll\":true,\"is_admin\":true,\"is_moderator\":true,\"primary_group_id\":2,\"secondary_group_ids\":[3,4],\"staff_eligible\":true,\"user_id_sha256\":\"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b\"}]},{\"all_eligible_match_target\":true,\"eligible_staff_count\":1,\"eligible_target_match_count\":1,\"slug\":\"zakrutim\",\"staff\":[{\"email_matches_target\":true,\"email_nonempty\":true,\"gram_manage\":true,\"gram_viewAll\":true,\"is_admin\":true,\"is_moderator\":true,\"primary_group_id\":2,\"secondary_group_ids\":[3,4],\"staff_eligible\":true,\"user_id_sha256\":\"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b\"}]},{\"all_eligible_match_target\":true,\"eligible_staff_count\":1,\"eligible_target_match_count\":1,\"slug\":\"newfi\",\"staff\":[{\"email_matches_target\":true,\"email_nonempty\":true,\"gram_manage\":true,\"gram_viewAll\":true,\"is_admin\":true,\"is_moderator\":true,\"primary_group_id\":2,\"secondary_group_ids\":[3,4],\"staff_eligible\":true,\"user_id_sha256\":\"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b\"}]}],\"source\":{\"files\":{\"Notification/DeliveryGate.php\":{\"line_count\":16,\"present\":true,\"text\":\"alerts = $alerts;\\n $this->email = $email;\\n }\\n\\n /**\\n * Owner-facing delivery. Staff alert fan-out is intentionally provided by\\n * dispatchStaff() so staff recipients must still pass Gram Support ACL.\\n */\\n public function dispatchOwner(SupportEvent $event, Ticket $ticket, ?User $owner): void\\n {\\n if (!Policy::mayEmitExternal($event))\\n {\\n return;\\n }\\n\\n if ($owner && $owner->user_id)\\n {\\n $this->alerts->consumeForUser($event, $ticket, $owner);\\n $this->email->consumeForRegistered($event, $ticket, $owner);\\n return;\\n }\\n\\n $this->email->consumeForGuest($event, $ticket);\\n }\\n\\n /**\\n * @param User[] $staffUsers\\n */\\n public function dispatchStaff(SupportEvent $event, Ticket $ticket, array $staffUsers): void\\n {\\n if (!Policy::mayEmitExternal($event))\\n {\\n return;\\n }\\n\\n foreach ($staffUsers as $staff)\\n {\\n if (!$staff instanceof User || !$staff->user_id)\\n {\\n continue;\\n }\\n\\n if (!$staff->hasPermission('gramSupport', 'viewAll')\\n && !$staff->hasPermission('gramSupport', 'manage'))\\n {\\n continue;\\n }\\n\\n $this->alerts->consumeForUser($event, $ticket, $staff);\\n }\\n }\\n}\\n\"},\"Notification/EmailConsumer.php\":{\"line_count\":107,\"present\":true,\"text\":\"gate = $gate;\\n }\\n\\n public function consumeForRegistered(SupportEvent $event, Ticket $ticket, User $recipient): bool\\n {\\n if (!Policy::mayEmitExternal($event) || !$recipient->user_id)\\n {\\n return false;\\n }\\n\\n if (!$ticket->user_id || (int)$ticket->user_id !== (int)$recipient->user_id)\\n {\\n return false;\\n }\\n\\n $recipientKey = RecipientKey::user((int)$recipient->user_id);\\n return $this->send(\\n $event,\\n $recipientKey,\\n function () use ($event, $ticket, $recipient)\\n {\\n \\\\XF::app()->mailer()->newMail()\\n ->setToUser($recipient)\\n ->setTemplate('gram_support_ticket_event', [\\n 'ticket' => $ticket,\\n 'event' => $event,\\n 'accessUrl' => null\\n ])\\n ->queue();\\n }\\n );\\n }\\n\\n public function consumeForGuest(SupportEvent $event, Ticket $ticket): bool\\n {\\n if (!Policy::mayEmitExternal($event))\\n {\\n return false;\\n }\\n\\n $email = trim((string)$ticket->guest_email);\\n if ($email === '')\\n {\\n return false;\\n }\\n\\n $recipientKey = RecipientKey::email($email);\\n\\n return $this->send(\\n $event,\\n $recipientKey,\\n function () use ($event, $ticket, $email)\\n {\\n // Raw guest access token is intentionally unavailable here:\\n // only its hash is persisted. Email links to the manual access\\n // page; the guest uses the one-time/saved access code.\\n \\\\XF::app()->mailer()->newMail()\\n ->setTo($email)\\n ->setTemplate('gram_support_ticket_event_guest', [\\n 'ticket' => $ticket,\\n 'event' => $event,\\n 'accessUrl' => \\\\XF::app()->router('public')->buildLink('canonical:support/access')\\n ])\\n ->queue();\\n }\\n );\\n }\\n\\n private function send(SupportEvent $event, string $recipientKey, callable $sender): bool\\n {\\n if (!$this->gate->acquire((int)$event->event_id, 'email', $recipientKey))\\n {\\n return false;\\n }\\n\\n try\\n {\\n $sender();\\n $this->gate->markSent((int)$event->event_id, 'email', $recipientKey);\\n return true;\\n }\\n catch (\\\\Throwable $e)\\n {\\n $this->gate->markFailed(\\n (int)$event->event_id,\\n 'email',\\n $recipientKey,\\n get_class($e)\\n );\\n throw $e;\\n }\\n }\\n}\\n\"},\"Notification/EntityDeliveryGate.php\":{\"line_count\":118,\"present\":true,\"text\":\"deliveryKey($eventId, $consumer, $recipientKey);\\n $lockName = 'gram_support_delivery_' . substr($deliveryKey, 0, 32);\\n $db = \\\\XF::db();\\n\\n if ((int)$db->fetchOne('SELECT GET_LOCK(?, 2)', [$lockName]) !== 1)\\n {\\n return false;\\n }\\n\\n try\\n {\\n $now = \\\\XF::$time;\\n $delivery = \\\\XF::em()->find('Gram\\\\Support:Delivery', $deliveryKey);\\n\\n if (!$delivery)\\n {\\n $delivery = \\\\XF::em()->create('Gram\\\\Support:Delivery');\\n $delivery->delivery_key = $deliveryKey;\\n $delivery->event_id = $eventId;\\n $delivery->consumer = $consumer;\\n $delivery->recipient_key = $recipientKey;\\n $delivery->state = 'pending';\\n $delivery->lease_expires = $now + self::LEASE_SECONDS;\\n $delivery->attempt_count = 1;\\n $delivery->created_date = $now;\\n $delivery->updated_date = $now;\\n $delivery->save();\\n return true;\\n }\\n\\n if ((string)$delivery->state === 'sent')\\n {\\n return false;\\n }\\n\\n if ((string)$delivery->state === 'pending' && (int)$delivery->lease_expires > $now)\\n {\\n return false;\\n }\\n\\n $delivery->state = 'pending';\\n $delivery->lease_expires = $now + self::LEASE_SECONDS;\\n $delivery->attempt_count = (int)$delivery->attempt_count + 1;\\n $delivery->updated_date = $now;\\n $delivery->save();\\n\\n return true;\\n }\\n finally\\n {\\n try\\n {\\n $db->fetchOne('SELECT RELEASE_LOCK(?)', [$lockName]);\\n }\\n catch (\\\\Throwable $ignored)\\n {\\n }\\n }\\n }\\n\\n public function markSent(int $eventId, string $consumer, string $recipientKey): void\\n {\\n $delivery = \\\\XF::em()->find(\\n 'Gram\\\\Support:Delivery',\\n $this->deliveryKey($eventId, $consumer, $recipientKey)\\n );\\n\\n if (!$delivery)\\n {\\n return;\\n }\\n\\n $delivery->state = 'sent';\\n $delivery->lease_expires = 0;\\n $delivery->updated_date = \\\\XF::$time;\\n $delivery->sent_date = \\\\XF::$time;\\n $delivery->save();\\n }\\n\\n public function markFailed(int $eventId, string $consumer, string $recipientKey, string $failureClass): void\\n {\\n $delivery = \\\\XF::em()->find(\\n 'Gram\\\\Support:Delivery',\\n $this->deliveryKey($eventId, $consumer, $recipientKey)\\n );\\n\\n if (!$delivery)\\n {\\n return;\\n }\\n\\n $delivery->state = 'failed';\\n $delivery->lease_expires = 0;\\n $delivery->last_failure_class = substr($failureClass, 0, 191);\\n $delivery->updated_date = \\\\XF::$time;\\n $delivery->save();\\n }\\n\\n private function deliveryKey(int $eventId, string $consumer, string $recipientKey): string\\n {\\n return hash('sha256', $eventId . \\\"\\\\0\\\" . $consumer . \\\"\\\\0\\\" . $recipientKey);\\n }\\n}\\n\"},\"Notification/EventDispatcher.php\":{\"line_count\":42,\"present\":true,\"text\":\"user_id > 0)\\n {\\n $owner = \\\\XF::em()->find('XF:User', (int)$ticket->user_id);\\n }\\n\\n $dispatcher->dispatchOwner($event, $ticket, $owner);\\n\\n if ((int)$ticket->assigned_user_id > 0)\\n {\\n $staff = \\\\XF::em()->find('XF:User', (int)$ticket->assigned_user_id);\\n if ($staff)\\n {\\n $dispatcher->dispatchStaff($event, $ticket, [$staff]);\\n }\\n }\\n }\\n catch (\\\\Throwable $e)\\n {\\n // Event/Ticket/Message are already persisted truth.\\n \\\\XF::logException($e, false, 'Gram Support notification consumer: ');\\n }\\n }\\n}\\n\"},\"Notification/Policy.php\":{\"line_count\":61,\"present\":true,\"text\":\"event_type, self::INTERNAL_EVENT_TYPES, true))\\n {\\n return false;\\n }\\n\\n $payload = self::safePayload($event);\\n\\n if (!empty($payload['is_internal']))\\n {\\n return false;\\n }\\n\\n return true;\\n }\\n\\n public static function safePayload(SupportEvent $event): array\\n {\\n $payload = $event->safe_payload_json;\\n\\n if (is_array($payload))\\n {\\n return $payload;\\n }\\n\\n if (!is_string($payload) || $payload === '')\\n {\\n return [];\\n }\\n\\n $decoded = json_decode($payload, true);\\n return is_array($decoded) ? $decoded : [];\\n }\\n\\n public static function alertExtraData(SupportEvent $event): array\\n {\\n $payload = self::safePayload($event);\\n\\n return [\\n 'support_event_id' => (int)$event->event_id,\\n 'ticket_id' => (int)$event->ticket_id,\\n 'event_type' => (string)$event->event_type,\\n 'public_ref' => isset($payload['public_ref']) ? (string)$payload['public_ref'] : ''\\n ];\\n }\\n}\\n\"},\"Notification/RecipientKey.php\":{\"line_count\":28,\"present\":true,\"text\":\"alerts = $alerts;\n $this->email = $email;\n }\n\n /**\n * Owner-facing delivery. Staff alert fan-out is intentionally provided by\n * dispatchStaff() so staff recipients must still pass Gram Support ACL.\n */\n public function dispatchOwner(SupportEvent $event, Ticket $ticket, ?User $owner): void\n {\n if (!Policy::mayEmitExternal($event))\n {\n return;\n }\n\n if ($owner && $owner->user_id)\n {\n $this->alerts->consumeForUser($event, $ticket, $owner);\n $this->email->consumeForRegistered($event, $ticket, $owner);\n return;\n }\n\n $this->email->consumeForGuest($event, $ticket);\n }\n\n /**\n * @param User[] $staffUsers\n */\n public function dispatchStaff(SupportEvent $event, Ticket $ticket, array $staffUsers): void\n {\n if (!Policy::mayEmitExternal($event))\n {\n return;\n }\n\n foreach ($staffUsers as $staff)\n {\n if (!$staff instanceof User || !$staff->user_id)\n {\n continue;\n }\n\n if (!$staff->hasPermission('gramSupport', 'viewAll')\n && !$staff->hasPermission('gramSupport', 'manage'))\n {\n continue;\n }\n\n $this->alerts->consumeForUser($event, $ticket, $staff);\n }\n }\n}\n"},"Notification/EmailConsumer.php":{"line_count":107,"present":true,"text":"gate = $gate;\n }\n\n public function consumeForRegistered(SupportEvent $event, Ticket $ticket, User $recipient): bool\n {\n if (!Policy::mayEmitExternal($event) || !$recipient->user_id)\n {\n return false;\n }\n\n if (!$ticket->user_id || (int)$ticket->user_id !== (int)$recipient->user_id)\n {\n return false;\n }\n\n $recipientKey = RecipientKey::user((int)$recipient->user_id);\n return $this->send(\n $event,\n $recipientKey,\n function () use ($event, $ticket, $recipient)\n {\n \\XF::app()->mailer()->newMail()\n ->setToUser($recipient)\n ->setTemplate('gram_support_ticket_event', [\n 'ticket' => $ticket,\n 'event' => $event,\n 'accessUrl' => null\n ])\n ->queue();\n }\n );\n }\n\n public function consumeForGuest(SupportEvent $event, Ticket $ticket): bool\n {\n if (!Policy::mayEmitExternal($event))\n {\n return false;\n }\n\n $email = trim((string)$ticket->guest_email);\n if ($email === '')\n {\n return false;\n }\n\n $recipientKey = RecipientKey::email($email);\n\n return $this->send(\n $event,\n $recipientKey,\n function () use ($event, $ticket, $email)\n {\n // Raw guest access token is intentionally unavailable here:\n // only its hash is persisted. Email links to the manual access\n // page; the guest uses the one-time/saved access code.\n \\XF::app()->mailer()->newMail()\n ->setTo($email)\n ->setTemplate('gram_support_ticket_event_guest', [\n 'ticket' => $ticket,\n 'event' => $event,\n 'accessUrl' => \\XF::app()->router('public')->buildLink('canonical:support/access')\n ])\n ->queue();\n }\n );\n }\n\n private function send(SupportEvent $event, string $recipientKey, callable $sender): bool\n {\n if (!$this->gate->acquire((int)$event->event_id, 'email', $recipientKey))\n {\n return false;\n }\n\n try\n {\n $sender();\n $this->gate->markSent((int)$event->event_id, 'email', $recipientKey);\n return true;\n }\n catch (\\Throwable $e)\n {\n $this->gate->markFailed(\n (int)$event->event_id,\n 'email',\n $recipientKey,\n get_class($e)\n );\n throw $e;\n }\n }\n}\n"},"Notification/EntityDeliveryGate.php":{"line_count":118,"present":true,"text":"deliveryKey($eventId, $consumer, $recipientKey);\n $lockName = 'gram_support_delivery_' . substr($deliveryKey, 0, 32);\n $db = \\XF::db();\n\n if ((int)$db->fetchOne('SELECT GET_LOCK(?, 2)', [$lockName]) !== 1)\n {\n return false;\n }\n\n try\n {\n $now = \\XF::$time;\n $delivery = \\XF::em()->find('Gram\\Support:Delivery', $deliveryKey);\n\n if (!$delivery)\n {\n $delivery = \\XF::em()->create('Gram\\Support:Delivery');\n $delivery->delivery_key = $deliveryKey;\n $delivery->event_id = $eventId;\n $delivery->consumer = $consumer;\n $delivery->recipient_key = $recipientKey;\n $delivery->state = 'pending';\n $delivery->lease_expires = $now + self::LEASE_SECONDS;\n $delivery->attempt_count = 1;\n $delivery->created_date = $now;\n $delivery->updated_date = $now;\n $delivery->save();\n return true;\n }\n\n if ((string)$delivery->state === 'sent')\n {\n return false;\n }\n\n if ((string)$delivery->state === 'pending' && (int)$delivery->lease_expires > $now)\n {\n return false;\n }\n\n $delivery->state = 'pending';\n $delivery->lease_expires = $now + self::LEASE_SECONDS;\n $delivery->attempt_count = (int)$delivery->attempt_count + 1;\n $delivery->updated_date = $now;\n $delivery->save();\n\n return true;\n }\n finally\n {\n try\n {\n $db->fetchOne('SELECT RELEASE_LOCK(?)', [$lockName]);\n }\n catch (\\Throwable $ignored)\n {\n }\n }\n }\n\n public function markSent(int $eventId, string $consumer, string $recipientKey): void\n {\n $delivery = \\XF::em()->find(\n 'Gram\\Support:Delivery',\n $this->deliveryKey($eventId, $consumer, $recipientKey)\n );\n\n if (!$delivery)\n {\n return;\n }\n\n $delivery->state = 'sent';\n $delivery->lease_expires = 0;\n $delivery->updated_date = \\XF::$time;\n $delivery->sent_date = \\XF::$time;\n $delivery->save();\n }\n\n public function markFailed(int $eventId, string $consumer, string $recipientKey, string $failureClass): void\n {\n $delivery = \\XF::em()->find(\n 'Gram\\Support:Delivery',\n $this->deliveryKey($eventId, $consumer, $recipientKey)\n );\n\n if (!$delivery)\n {\n return;\n }\n\n $delivery->state = 'failed';\n $delivery->lease_expires = 0;\n $delivery->last_failure_class = substr($failureClass, 0, 191);\n $delivery->updated_date = \\XF::$time;\n $delivery->save();\n }\n\n private function deliveryKey(int $eventId, string $consumer, string $recipientKey): string\n {\n return hash('sha256', $eventId . \"\\0\" . $consumer . \"\\0\" . $recipientKey);\n }\n}\n"},"Notification/EventDispatcher.php":{"line_count":42,"present":true,"text":"user_id > 0)\n {\n $owner = \\XF::em()->find('XF:User', (int)$ticket->user_id);\n }\n\n $dispatcher->dispatchOwner($event, $ticket, $owner);\n\n if ((int)$ticket->assigned_user_id > 0)\n {\n $staff = \\XF::em()->find('XF:User', (int)$ticket->assigned_user_id);\n if ($staff)\n {\n $dispatcher->dispatchStaff($event, $ticket, [$staff]);\n }\n }\n }\n catch (\\Throwable $e)\n {\n // Event/Ticket/Message are already persisted truth.\n \\XF::logException($e, false, 'Gram Support notification consumer: ');\n }\n }\n}\n"},"Notification/Policy.php":{"line_count":61,"present":true,"text":"event_type, self::INTERNAL_EVENT_TYPES, true))\n {\n return false;\n }\n\n $payload = self::safePayload($event);\n\n if (!empty($payload['is_internal']))\n {\n return false;\n }\n\n return true;\n }\n\n public static function safePayload(SupportEvent $event): array\n {\n $payload = $event->safe_payload_json;\n\n if (is_array($payload))\n {\n return $payload;\n }\n\n if (!is_string($payload) || $payload === '')\n {\n return [];\n }\n\n $decoded = json_decode($payload, true);\n return is_array($decoded) ? $decoded : [];\n }\n\n public static function alertExtraData(SupportEvent $event): array\n {\n $payload = self::safePayload($event);\n\n return [\n 'support_event_id' => (int)$event->event_id,\n 'ticket_id' => (int)$event->ticket_id,\n 'event_type' => (string)$event->event_type,\n 'public_ref' => isset($payload['public_ref']) ? (string)$payload['public_ref'] : ''\n ];\n }\n}\n"},"Notification/RecipientKey.php":{"line_count":28,"present":true,"text":"gate = $gate;\"},{\"line\":16,\"text\":\" }\"},{\"line\":17,\"text\":\"\"},{\"line\":18,\"text\":\" public function consumeForUser(SupportEvent $event, Ticket $ticket, User $recipient): bool\"},{\"line\":19,\"text\":\" {\"},{\"line\":20,\"text\":\" if (!Policy::mayEmitExternal($event) || !$recipient->user_id)\"},{\"line\":21,\"text\":\" {\"},{\"line\":22,\"text\":\" return false;\"},{\"line\":23,\"text\":\" }\"},{\"line\":24,\"text\":\"\"},{\"line\":25,\"text\":\" // Never alert a user about a ticket they cannot access.\"},{\"line\":26,\"text\":\" $isOwner = $ticket->user_id && (int)$ticket->user_id === (int)$recipient->user_id;\"},{\"line\":27,\"text\":\" $isStaff = $recipient->hasPermission('gramSupport', 'viewAll')\"},{\"line\":28,\"text\":\" || $recipient->hasPermission('gramSupport', 'manage');\"},{\"line\":29,\"text\":\"\"},{\"line\":30,\"text\":\" if (!$isOwner && !$isStaff)\"},{\"line\":31,\"text\":\" {\"},{\"line\":32,\"text\":\" return false;\"},{\"line\":33,\"text\":\" }\"},{\"line\":34,\"text\":\"\"},{\"line\":35,\"text\":\" $recipientKey = RecipientKey::user((int)$recipient->user_id);\"},{\"line\":36,\"text\":\" if (!$this->gate->acquire((int)$event->event_id, 'xf_alert', $recipientKey))\"},{\"line\":37,\"text\":\" {\"},{\"line\":38,\"text\":\" return false;\"},{\"line\":39,\"text\":\" }\"},{\"line\":40,\"text\":\"\"},{\"line\":41,\"text\":\" try\"},{\"line\":42,\"text\":\" {\"},{\"line\":43,\"text\":\" /** @var \\\\XF\\\\Repository\\\\UserAlertRepository $alerts */\"},{\"line\":44,\"text\":\" $alerts = \\\\XF::repository('XF:UserAlert');\"},{\"line\":45,\"text\":\"\"},{\"line\":46,\"text\":\" // Back the alert with the recipient's own User content so the\"},{\"line\":47,\"text\":\" // core alert handler remains valid/viewable. Ticket access is\"},{\"line\":48,\"text\":\" // separately enforced before emission and again on ticket route.\"},{\"line\":49,\"text\":\" $alerts->alert(\"},{\"line\":50,\"text\":\" $recipient,\"},{\"line\":51,\"text\":\" 0,\"},{\"line\":52,\"text\":\" 'Support',\"},{\"line\":53,\"text\":\" 'user',\"},{\"line\":54,\"text\":\" (int)$recipient->user_id,\"},{\"line\":55,\"text\":\" 'gram_support_evt',\"},{\"line\":56,\"text\":\" Policy::alertExtraData($event),\"},{\"line\":57,\"text\":\" ['usePush' => false]\"},{\"line\":58,\"text\":\" );\"},{\"line\":59,\"text\":\"\"},{\"line\":60,\"text\":\" $this->gate->markSent((int)$event->event_id, 'xf_alert', $recipientKey);\"},{\"line\":61,\"text\":\" return true;\"},{\"line\":62,\"text\":\" }\"},{\"line\":63,\"text\":\" catch (\\\\Throwable $e)\"},{\"line\":64,\"text\":\" {\"},{\"line\":65,\"text\":\" $this->gate->markFailed(\"},{\"line\":66,\"text\":\" (int)$event->event_id,\"},{\"line\":67,\"text\":\" 'xf_alert',\"},{\"line\":68,\"text\":\" $recipientKey,\"},{\"line\":69,\"text\":\" get_class($e)\"},{\"line\":70,\"text\":\" );\"},{\"line\":71,\"text\":\" throw $e;\"},{\"line\":72,\"text\":\" }\"}],\"present\":true,\"sha256\":\"e22d0cdb0a0806c6a231504409bd17cd781caab0f28181aff7246b0295d8cd98\"},\"Notification/Dispatcher.php\":{\"line_count\":67,\"matched_context\":[{\"sep\":true},{\"line\":1,\"text\":\"alerts = $alerts;\"},{\"line\":17,\"text\":\" $this->email = $email;\"},{\"line\":18,\"text\":\" }\"},{\"line\":19,\"text\":\"\"},{\"line\":20,\"text\":\" /**\"},{\"line\":21,\"text\":\" * Owner-facing delivery. Staff alert fan-out is intentionally provided by\"},{\"line\":22,\"text\":\" * dispatchStaff() so staff recipients must still pass Gram Support ACL.\"},{\"line\":23,\"text\":\" */\"},{\"line\":24,\"text\":\" public function dispatchOwner(SupportEvent $event, Ticket $ticket, ?User $owner): void\"},{\"line\":25,\"text\":\" {\"},{\"line\":26,\"text\":\" if (!Policy::mayEmitExternal($event))\"},{\"line\":27,\"text\":\" {\"},{\"line\":28,\"text\":\" return;\"},{\"line\":29,\"text\":\" }\"},{\"line\":30,\"text\":\"\"},{\"line\":31,\"text\":\" if ($owner && $owner->user_id)\"},{\"line\":32,\"text\":\" {\"},{\"line\":33,\"text\":\" $this->alerts->consumeForUser($event, $ticket, $owner);\"},{\"line\":34,\"text\":\" $this->email->consumeForRegistered($event, $ticket, $owner);\"},{\"line\":35,\"text\":\" return;\"},{\"line\":36,\"text\":\" }\"},{\"line\":37,\"text\":\"\"},{\"line\":38,\"text\":\" $this->email->consumeForGuest($event, $ticket);\"},{\"line\":39,\"text\":\" }\"},{\"line\":40,\"text\":\"\"},{\"line\":41,\"text\":\" /**\"},{\"line\":42,\"text\":\" * @param User[] $staffUsers\"},{\"line\":43,\"text\":\" */\"},{\"line\":44,\"text\":\" public function dispatchStaff(SupportEvent $event, Ticket $ticket, array $staffUsers): void\"},{\"line\":45,\"text\":\" {\"},{\"line\":46,\"text\":\" if (!Policy::mayEmitExternal($event))\"},{\"line\":47,\"text\":\" {\"},{\"line\":48,\"text\":\" return;\"},{\"line\":49,\"text\":\" }\"},{\"line\":50,\"text\":\"\"},{\"line\":51,\"text\":\" foreach ($staffUsers as $staff)\"},{\"line\":52,\"text\":\" {\"},{\"line\":53,\"text\":\" if (!$staff instanceof User || !$staff->user_id)\"},{\"line\":54,\"text\":\" {\"},{\"line\":55,\"text\":\" continue;\"},{\"line\":56,\"text\":\" }\"},{\"line\":57,\"text\":\"\"},{\"line\":58,\"text\":\" if (!$staff->hasPermission('gramSupport', 'viewAll')\"},{\"line\":59,\"text\":\" && !$staff->hasPermission('gramSupport', 'manage'))\"},{\"line\":60,\"text\":\" {\"},{\"line\":61,\"text\":\" continue;\"},{\"line\":62,\"text\":\" }\"},{\"line\":63,\"text\":\"\"},{\"line\":64,\"text\":\" $this->alerts->consumeForUser($event, $ticket, $staff);\"},{\"line\":65,\"text\":\" }\"},{\"line\":66,\"text\":\" }\"},{\"line\":67,\"text\":\"}\"}],\"present\":true,\"sha256\":\"a043d94bc91feeed0969ad218566cebac76b2d804e7c69cc2b87b94d42f5bb85\"},\"Pub/Controller/Support.php\":{\"line_count\":236,\"matched_context\":[{\"sep\":true},{\"line\":1,\"text\":\"isPost())\"},{\"line\":37,\"text\":\" {\"},{\"line\":38,\"text\":\" $this->assertPostOnly();\"},{\"line\":39,\"text\":\"\"},{\"line\":40,\"text\":\" $email = $this->filter('email', 'str');\"},{\"line\":41,\"text\":\" $category = $this->filter('category', 'str');\"},{\"line\":42,\"text\":\" $subject = $this->filter('subject', 'str');\"},{\"line\":43,\"text\":\" $message = $this->filter('message', 'str');\"},{\"line\":44,\"text\":\"\"},{\"line\":45,\"text\":\" if (!$visitor->user_id)\"},{\"line\":46,\"text\":\" {\"},{\"line\":47,\"text\":\" if (!$this->captchaIsValid(true))\"},{\"line\":48,\"text\":\" {\"},{\"line\":49,\"text\":\" return $this->error(\"},{\"line\":50,\"text\":\" \\\\XF::phrase('did_not_complete_the_captcha_verification_properly')\"},{\"line\":51,\"text\":\" );\"},{\"line\":52,\"text\":\" }\"},{\"line\":53,\"text\":\"\"},{\"line\":54,\"text\":\" /** @var RateLimiter $rateLimiter */\"},{\"line\":55,\"text\":\" $rateLimiter = $this->service(RateLimiter::class);\"},{\"line\":56,\"text\":\" $allowed = $rateLimiter->consumeGuest(\"},{\"line\":57,\"text\":\" SiteConfig::siteId(),\"},{\"line\":58,\"text\":\" $email,\"},{\"line\":59,\"text\":\" SiteConfig::guestRateLimitCount(),\"},{\"line\":60,\"text\":\" SiteConfig::guestRateLimitWindowSeconds()\"},{\"line\":61,\"text\":\" );\"},{\"line\":62,\"text\":\"\"},{\"line\":63,\"text\":\" if (!$allowed)\"},{\"sep\":true},{\"line\":74,\"text\":\" if ($upload)\"},{\"line\":75,\"text\":\" {\"},{\"line\":76,\"text\":\" $attachmentId = $attachmentFlow->prepare($upload, $tempHash);\"},{\"line\":77,\"text\":\" }\"},{\"line\":78,\"text\":\"\"},{\"line\":79,\"text\":\" /** @var Creator $creator */\"},{\"line\":80,\"text\":\" $creator = $this->service(Creator::class);\"},{\"line\":81,\"text\":\" $created = $creator->create([\"},{\"line\":82,\"text\":\" 'email' => $email,\"},{\"line\":83,\"text\":\" 'category' => $category,\"},{\"line\":84,\"text\":\" 'subject' => $subject,\"},{\"line\":85,\"text\":\" 'message' => $message\"},{\"line\":86,\"text\":\" ]);\"},{\"line\":87,\"text\":\"\"},{\"sep\":true},{\"line\":127,\"text\":\" }\"},{\"line\":128,\"text\":\"\"},{\"line\":129,\"text\":\" return $this->view(\"},{\"line\":130,\"text\":\" 'Gram\\\\Support:Create',\"},{\"line\":131,\"text\":\" 'gram_support_create',\"},{\"line\":132,\"text\":\" ['forceCaptcha' => !$visitor->user_id]\"},{\"line\":133,\"text\":\" );\"},{\"line\":134,\"text\":\" }\"},{\"line\":135,\"text\":\"\"},{\"line\":136,\"text\":\" public function actionAccess()\"},{\"line\":137,\"text\":\" {\"},{\"sep\":true},{\"line\":183,\"text\":\" 'messages' => $messages\"},{\"line\":184,\"text\":\" ]\"},{\"line\":185,\"text\":\" );\"},{\"line\":186,\"text\":\" }\"},{\"line\":187,\"text\":\"\"},{\"line\":188,\"text\":\" public function actionReply()\"},{\"line\":189,\"text\":\" {\"},{\"line\":190,\"text\":\" $this->assertPostOnly();\"},{\"line\":191,\"text\":\"\"},{\"line\":192,\"text\":\" $ticket = $this->assertViewableTicketByRef($this->filter('ref', 'str'));\"},{\"line\":193,\"text\":\" $visitor = \\\\XF::visitor();\"},{\"line\":194,\"text\":\"\"},{\"line\":195,\"text\":\" if ($visitor->user_id)\"},{\"line\":196,\"text\":\" {\"},{\"line\":197,\"text\":\" if ((int)$ticket->user_id !== (int)$visitor->user_id\"},{\"line\":198,\"text\":\" || !$visitor->hasPermission('gramSupport', 'replyOwn'))\"},{\"line\":199,\"text\":\" {\"},{\"line\":200,\"text\":\" return $this->noPermission();\"},{\"line\":201,\"text\":\" }\"},{\"line\":202,\"text\":\" }\"},{\"line\":203,\"text\":\" elseif (!GuestTicketSession::hasGrant($ticket))\"},{\"sep\":true},{\"line\":205,\"text\":\" return $this->noPermission();\"},{\"line\":206,\"text\":\" }\"},{\"line\":207,\"text\":\"\"},{\"line\":208,\"text\":\" $message = $this->filter('message', 'str');\"},{\"line\":209,\"text\":\"\"},{\"line\":210,\"text\":\" /** @var Reply $reply */\"},{\"line\":211,\"text\":\" $reply = $this->service(Reply::class);\"},{\"line\":212,\"text\":\" $reply->add($ticket, $message, false, false);\"},{\"line\":213,\"text\":\"\"},{\"line\":214,\"text\":\" return $this->redirect(\"},{\"line\":215,\"text\":\" $this->buildLink('support/ticket', null, ['ref' => $ticket->public_ref])\"},{\"line\":216,\"text\":\" );\"},{\"line\":217,\"text\":\" }\"}],\"present\":true,\"sha256\":\"6cb34b1d27e9f0132263637cefa62fe0e998a9a86620b7ac33199f41ee86f9d4\"},\"Service/Ticket/Creator.php\":{\"line_count\":139,\"matched_context\":[{\"sep\":true},{\"line\":8,\"text\":\"use Gram\\\\Support\\\\Util\\\\PublicRef;\"},{\"line\":9,\"text\":\"use Gram\\\\Support\\\\Util\\\\SafeContext;\"},{\"line\":10,\"text\":\"use Gram\\\\Support\\\\Util\\\\SiteConfig;\"},{\"line\":11,\"text\":\"use Gram\\\\Support\\\\Util\\\\StatusPolicy;\"},{\"line\":12,\"text\":\"use XF\\\\Service\\\\AbstractService;\"},{\"line\":13,\"text\":\"use Gram\\\\Support\\\\Notification\\\\EventDispatcher;\"},{\"line\":14,\"text\":\"\"},{\"line\":15,\"text\":\"class Creator extends AbstractService\"},{\"line\":16,\"text\":\"{\"},{\"line\":17,\"text\":\" public function create(array $input, ?int $now = null): array\"},{\"line\":18,\"text\":\" {\"},{\"line\":19,\"text\":\" $now = $now ?? \\\\XF::$time;\"},{\"line\":20,\"text\":\" $visitor = \\\\XF::visitor();\"},{\"line\":21,\"text\":\" $siteId = SiteConfig::siteId();\"},{\"line\":22,\"text\":\"\"},{\"line\":23,\"text\":\" $email = strtolower(trim((string)($input['email'] ?? '')));\"},{\"line\":24,\"text\":\" $category = trim((string)($input['category'] ?? ''));\"},{\"line\":25,\"text\":\" $subject = trim((string)($input['subject'] ?? ''));\"},{\"line\":26,\"text\":\" $messageText = trim((string)($input['message'] ?? ''));\"},{\"line\":27,\"text\":\"\"},{\"line\":28,\"text\":\" if ($category === '' || $messageText === '')\"},{\"line\":29,\"text\":\" {\"},{\"line\":30,\"text\":\" throw new \\\\InvalidArgumentException('GRAM_SUPPORT_REQUIRED_FIELDS');\"},{\"line\":31,\"text\":\" }\"},{\"line\":32,\"text\":\"\"},{\"line\":33,\"text\":\" if (!$visitor->user_id && !filter_var($email, FILTER_VALIDATE_EMAIL))\"},{\"line\":34,\"text\":\" {\"},{\"line\":35,\"text\":\" throw new \\\\InvalidArgumentException('GRAM_SUPPORT_VALID_EMAIL_REQUIRED');\"},{\"line\":36,\"text\":\" }\"},{\"line\":37,\"text\":\"\"},{\"line\":38,\"text\":\" if ($subject === '')\"},{\"line\":39,\"text\":\" {\"},{\"line\":40,\"text\":\" $subject = function_exists('mb_substr')\"},{\"sep\":true},{\"line\":68,\"text\":\" /** @var Ticket $ticket */\"},{\"line\":69,\"text\":\" $ticket = $this->em()->create('Gram\\\\Support:Ticket');\"},{\"line\":70,\"text\":\" $ticket->public_ref = 'TMP-' . bin2hex(random_bytes(12));\"},{\"line\":71,\"text\":\" $ticket->site_id = $siteId;\"},{\"line\":72,\"text\":\" $ticket->user_id = (int)$visitor->user_id;\"},{\"line\":73,\"text\":\" $ticket->guest_email = $visitor->user_id ? '' : $email;\"},{\"line\":74,\"text\":\" $ticket->guest_access_token_hash = $guest ? $guest['hash'] : '';\"},{\"line\":75,\"text\":\" $ticket->guest_access_expires = $guest\"},{\"line\":76,\"text\":\" ? $now + SiteConfig::guestTokenTtlSeconds()\"},{\"line\":77,\"text\":\" : 0;\"},{\"line\":78,\"text\":\" $ticket->node_id = (int)($context['node_id'] ?? 0);\"},{\"sep\":true},{\"line\":96,\"text\":\" $ticket->save();\"},{\"line\":97,\"text\":\"\"},{\"line\":98,\"text\":\" $message = $this->em()->create('Gram\\\\Support:Message');\"},{\"line\":99,\"text\":\" $message->ticket_id = (int)$ticket->ticket_id;\"},{\"line\":100,\"text\":\" $message->user_id = (int)$visitor->user_id;\"},{\"line\":101,\"text\":\" $message->is_staff = 0;\"},{\"line\":102,\"text\":\" $message->is_internal = 0;\"},{\"line\":103,\"text\":\" $message->message = $messageText;\"},{\"line\":104,\"text\":\" $message->message_date = $now;\"},{\"line\":105,\"text\":\" $message->save();\"},{\"line\":106,\"text\":\"\"},{\"line\":107,\"text\":\" $ticket->last_message_id = (int)$message->message_id;\"},{\"line\":108,\"text\":\" $ticket->save();\"},{\"line\":109,\"text\":\"\"},{\"line\":110,\"text\":\" $event = $this->em()->create('Gram\\\\Support:SupportEvent');\"},{\"line\":111,\"text\":\" $event->ticket_id = (int)$ticket->ticket_id;\"},{\"line\":112,\"text\":\" $event->site_id = $siteId;\"},{\"line\":113,\"text\":\" $event->actor_user_id = (int)$visitor->user_id;\"},{\"line\":114,\"text\":\" $event->event_type = 'ticket_created';\"},{\"line\":115,\"text\":\" $event->event_date = $now;\"},{\"line\":116,\"text\":\" $event->safe_payload_json = json_encode([\"},{\"line\":117,\"text\":\" 'public_ref' => (string)$ticket->public_ref,\"},{\"line\":118,\"text\":\" 'category' => (string)$ticket->category,\"},{\"line\":119,\"text\":\" 'is_internal' => false\"},{\"sep\":true},{\"line\":126,\"text\":\" {\"},{\"line\":127,\"text\":\" $db->rollback();\"},{\"line\":128,\"text\":\" throw $e;\"},{\"line\":129,\"text\":\" }\"},{\"line\":130,\"text\":\"\"},{\"line\":131,\"text\":\" (new EventDispatcher())->dispatchSafely($event, $ticket);\"},{\"line\":132,\"text\":\"\"},{\"line\":133,\"text\":\" return [\"},{\"line\":134,\"text\":\" 'ticket' => $ticket,\"},{\"line\":135,\"text\":\" 'event' => $event,\"},{\"line\":136,\"text\":\" 'guest_secret' => $guest ? $guest['secret'] : null\"}],\"present\":true,\"sha256\":\"7a84cd77761a7943495f9d3ef11269987f85265170ad6cf927a1661b8bf0a99a\"},\"Service/Ticket/Reply.php\":{\"line_count\":68,\"matched_context\":[{\"sep\":true},{\"line\":3,\"text\":\"namespace Gram\\\\Support\\\\Service\\\\Ticket;\"},{\"line\":4,\"text\":\"\"},{\"line\":5,\"text\":\"use Gram\\\\Support\\\\Entity\\\\Ticket;\"},{\"line\":6,\"text\":\"use Gram\\\\Support\\\\Util\\\\SiteConfig;\"},{\"line\":7,\"text\":\"use XF\\\\Service\\\\AbstractService;\"},{\"line\":8,\"text\":\"use Gram\\\\Support\\\\Notification\\\\EventDispatcher;\"},{\"line\":9,\"text\":\"\"},{\"line\":10,\"text\":\"class Reply extends AbstractService\"},{\"line\":11,\"text\":\"{\"},{\"line\":12,\"text\":\" public function add(Ticket $ticket, string $messageText, bool $isStaff, bool $isInternal = false): array\"},{\"line\":13,\"text\":\" {\"},{\"line\":14,\"text\":\" $messageText = trim($messageText);\"},{\"line\":15,\"text\":\" if ($messageText === '')\"},{\"line\":16,\"text\":\" {\"},{\"line\":17,\"text\":\" throw new \\\\InvalidArgumentException('GRAM_SUPPORT_EMPTY_REPLY');\"},{\"line\":18,\"text\":\" }\"},{\"line\":19,\"text\":\" if ($isInternal && !$isStaff)\"},{\"line\":20,\"text\":\" {\"},{\"line\":21,\"text\":\" throw new \\\\LogicException('INTERNAL_REQUIRES_STAFF');\"},{\"line\":22,\"text\":\" }\"},{\"line\":23,\"text\":\"\"},{\"line\":24,\"text\":\" $visitor = \\\\XF::visitor();\"},{\"line\":25,\"text\":\" $now = \\\\XF::$time;\"},{\"line\":26,\"text\":\" $db = \\\\XF::db();\"},{\"sep\":true},{\"line\":29,\"text\":\" try\"},{\"line\":30,\"text\":\" {\"},{\"line\":31,\"text\":\" $message = $this->em()->create('Gram\\\\Support:Message');\"},{\"line\":32,\"text\":\" $message->ticket_id = (int)$ticket->ticket_id;\"},{\"line\":33,\"text\":\" $message->user_id = (int)$visitor->user_id;\"},{\"line\":34,\"text\":\" $message->is_staff = $isStaff ? 1 : 0;\"},{\"line\":35,\"text\":\" $message->is_internal = $isInternal ? 1 : 0;\"},{\"line\":36,\"text\":\" $message->message = $messageText;\"},{\"line\":37,\"text\":\" $message->message_date = $now;\"},{\"line\":38,\"text\":\" $message->save();\"},{\"line\":39,\"text\":\"\"},{\"line\":40,\"text\":\" $ticket->last_message_id = (int)$message->message_id;\"},{\"line\":41,\"text\":\" $ticket->updated_date = $now;\"},{\"line\":42,\"text\":\" $ticket->save();\"},{\"line\":43,\"text\":\"\"},{\"line\":44,\"text\":\" $event = $this->em()->create('Gram\\\\Support:SupportEvent');\"},{\"line\":45,\"text\":\" $event->ticket_id = (int)$ticket->ticket_id;\"},{\"line\":46,\"text\":\" $event->site_id = (string)$ticket->site_id;\"},{\"line\":47,\"text\":\" $event->actor_user_id = (int)$visitor->user_id;\"},{\"line\":48,\"text\":\" $event->event_type = $isInternal ? 'internal_note_created' : 'ticket_reply_created';\"},{\"line\":49,\"text\":\" $event->event_date = $now;\"},{\"line\":50,\"text\":\" $event->safe_payload_json = json_encode([\"},{\"line\":51,\"text\":\" 'public_ref' => (string)$ticket->public_ref,\"},{\"line\":52,\"text\":\" 'is_internal' => $isInternal\"},{\"line\":53,\"text\":\" ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);\"},{\"sep\":true},{\"line\":59,\"text\":\" {\"},{\"line\":60,\"text\":\" $db->rollback();\"},{\"line\":61,\"text\":\" throw $e;\"},{\"line\":62,\"text\":\" }\"},{\"line\":63,\"text\":\"\"},{\"line\":64,\"text\":\" (new EventDispatcher())->dispatchSafely($event, $ticket);\"},{\"line\":65,\"text\":\"\"},{\"line\":66,\"text\":\" return ['message' => $message, 'event' => $event];\"},{\"line\":67,\"text\":\" }\"},{\"line\":68,\"text\":\"}\"}],\"present\":true,\"sha256\":\"482571a2ccf07a3c8672f34940243264a8271ef8c3cbc4fadbb46112afd36830\"},\"_data/templates.xml\":{\"line_count\":781,\"matched_context\":[{\"sep\":true},{\"line\":1,\"text\":\"\"},{\"line\":2,\"text\":\"\"},{\"line\":3,\"text\":\" \"},{\"line\":9,\"text\":\" \"},{\"line\":553,\"text\":\" \"},{\"line\":575,\"text\":\" \"},{\"line\":647,\"text\":\" \"},{\"line\":764,\"text\":\" \"},{\"line\":772,\"text\":\" \"},{\"line\":781,\"text\":\"\"}],\"present\":true,\"sha256\":\"1a344bd306135486d2019bf0a5cafff5eb472882f7b3433ef13af4556a4c791e\"}}},\"transport_rc\":0,\"transport_stderr_bytes\":0,\"transport_stderr_sha256\":\"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}\nGRAM516R1R3_NOTIFICATION_FILES=[\"Notification/AlertConsumer.php\",\"Notification/DeliveryGate.php\",\"Notification/Dispatcher.php\",\"Notification/EmailConsumer.php\",\"Notification/EntityDeliveryGate.php\",\"Notification/EventDispatcher.php\",\"Notification/Policy.php\",\"Notification/RecipientKey.php\"]\nGRAM516R1R3_EMAIL_CONSUMER_FILE_PRESENT=true\nGRAM516R1R3_DECISION=PASS_NOTIFY_CAPTCHA_SOURCE_EVIDENCE_CAPTURED\nGRAM516R1R3_NEXT_GATE=BUILD_EXACT_TURNSTILE_AND_EMAIL_NOTIFY_CHANGE\nHOMELAB_RESULT_CONTRACT={\"version\":1,\"command_id\":\"SUPPORT-260918-GRAM-SUPPORT-NOTIFY-CAPTCHA-SOURCE-READONLY-516R1R3\",\"status\":\"OK\",\"changes_made\":false,\"rollback_started\":false,\"rollback_restored\":null}\n" + "output": "GRAM516R1R4_SAFE_REPORT={\"classification\":{\"all_sites_eligible_staff_match_target\":true,\"changes_made\":false,\"eligible_staff_target_match_total\":7,\"eligible_staff_total\":7,\"email_consumer_present\":true,\"event_dispatcher_present\":true,\"runtime_probe_7of7\":true,\"send_attempts\":0,\"source_probe_ok\":true},\"schema_version\":1,\"scope\":\"READ_ONLY_GRAM_SUPPORT_STAFF_EMAIL_SOURCE\",\"secret_policy\":\"NO_NON_TARGET_EMAIL_ADDRESSES_EXPORTED\",\"sites\":[{\"all_eligible_match_target\":true,\"eligible_staff_count\":1,\"eligible_target_match_count\":1,\"slug\":\"codevipe\",\"staff\":[{\"email_matches_target\":true,\"email_nonempty\":true,\"gram_manage\":true,\"gram_viewAll\":true,\"is_admin\":true,\"is_moderator\":true,\"primary_group_id\":2,\"secondary_group_ids\":[3,4],\"staff_eligible\":true,\"user_id_sha256\":\"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b\"}]},{\"all_eligible_match_target\":true,\"eligible_staff_count\":1,\"eligible_target_match_count\":1,\"slug\":\"dsmods\",\"staff\":[{\"email_matches_target\":true,\"email_nonempty\":true,\"gram_manage\":true,\"gram_viewAll\":true,\"is_admin\":true,\"is_moderator\":true,\"primary_group_id\":2,\"secondary_group_ids\":[3,4],\"staff_eligible\":true,\"user_id_sha256\":\"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b\"}]},{\"all_eligible_match_target\":true,\"eligible_staff_count\":1,\"eligible_target_match_count\":1,\"slug\":\"gamevipe\",\"staff\":[{\"email_matches_target\":true,\"email_nonempty\":true,\"gram_manage\":true,\"gram_viewAll\":true,\"is_admin\":true,\"is_moderator\":true,\"primary_group_id\":2,\"secondary_group_ids\":[3,4],\"staff_eligible\":true,\"user_id_sha256\":\"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b\"}]},{\"all_eligible_match_target\":true,\"eligible_staff_count\":1,\"eligible_target_match_count\":1,\"slug\":\"hkmods\",\"staff\":[{\"email_matches_target\":true,\"email_nonempty\":true,\"gram_manage\":true,\"gram_viewAll\":true,\"is_admin\":true,\"is_moderator\":true,\"primary_group_id\":2,\"secondary_group_ids\":[3,4],\"staff_eligible\":true,\"user_id_sha256\":\"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b\"}]},{\"all_eligible_match_target\":true,\"eligible_staff_count\":1,\"eligible_target_match_count\":1,\"slug\":\"kingofwolk\",\"staff\":[{\"email_matches_target\":true,\"email_nonempty\":true,\"gram_manage\":true,\"gram_viewAll\":true,\"is_admin\":true,\"is_moderator\":true,\"primary_group_id\":2,\"secondary_group_ids\":[3,4],\"staff_eligible\":true,\"user_id_sha256\":\"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b\"}]},{\"all_eligible_match_target\":true,\"eligible_staff_count\":1,\"eligible_target_match_count\":1,\"slug\":\"zakrutim\",\"staff\":[{\"email_matches_target\":true,\"email_nonempty\":true,\"gram_manage\":true,\"gram_viewAll\":true,\"is_admin\":true,\"is_moderator\":true,\"primary_group_id\":2,\"secondary_group_ids\":[3,4],\"staff_eligible\":true,\"user_id_sha256\":\"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b\"}]},{\"all_eligible_match_target\":true,\"eligible_staff_count\":1,\"eligible_target_match_count\":1,\"slug\":\"newfi\",\"staff\":[{\"email_matches_target\":true,\"email_nonempty\":true,\"gram_manage\":true,\"gram_viewAll\":true,\"is_admin\":true,\"is_moderator\":true,\"primary_group_id\":2,\"secondary_group_ids\":[3,4],\"staff_eligible\":true,\"user_id_sha256\":\"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b\"}]}],\"source\":{\"files\":{\"Notification/DeliveryGate.php\":{\"line_count\":16,\"present\":true,\"text\":\"alerts = $alerts;\\n $this->email = $email;\\n }\\n\\n /**\\n * Owner-facing delivery. Staff alert fan-out is intentionally provided by\\n * dispatchStaff() so staff recipients must still pass Gram Support ACL.\\n */\\n public function dispatchOwner(SupportEvent $event, Ticket $ticket, ?User $owner): void\\n {\\n if (!Policy::mayEmitExternal($event))\\n {\\n return;\\n }\\n\\n if ($owner && $owner->user_id)\\n {\\n $this->alerts->consumeForUser($event, $ticket, $owner);\\n $this->email->consumeForRegistered($event, $ticket, $owner);\\n return;\\n }\\n\\n $this->email->consumeForGuest($event, $ticket);\\n }\\n\\n /**\\n * @param User[] $staffUsers\\n */\\n public function dispatchStaff(SupportEvent $event, Ticket $ticket, array $staffUsers): void\\n {\\n if (!Policy::mayEmitExternal($event))\\n {\\n return;\\n }\\n\\n foreach ($staffUsers as $staff)\\n {\\n if (!$staff instanceof User || !$staff->user_id)\\n {\\n continue;\\n }\\n\\n if (!$staff->hasPermission('gramSupport', 'viewAll')\\n && !$staff->hasPermission('gramSupport', 'manage'))\\n {\\n continue;\\n }\\n\\n $this->alerts->consumeForUser($event, $ticket, $staff);\\n }\\n }\\n}\\n\"},\"Notification/EmailConsumer.php\":{\"line_count\":107,\"present\":true,\"text\":\"gate = $gate;\\n }\\n\\n public function consumeForRegistered(SupportEvent $event, Ticket $ticket, User $recipient): bool\\n {\\n if (!Policy::mayEmitExternal($event) || !$recipient->user_id)\\n {\\n return false;\\n }\\n\\n if (!$ticket->user_id || (int)$ticket->user_id !== (int)$recipient->user_id)\\n {\\n return false;\\n }\\n\\n $recipientKey = RecipientKey::user((int)$recipient->user_id);\\n return $this->send(\\n $event,\\n $recipientKey,\\n function () use ($event, $ticket, $recipient)\\n {\\n \\\\XF::app()->mailer()->newMail()\\n ->setToUser($recipient)\\n ->setTemplate('gram_support_ticket_event', [\\n 'ticket' => $ticket,\\n 'event' => $event,\\n 'accessUrl' => null\\n ])\\n ->queue();\\n }\\n );\\n }\\n\\n public function consumeForGuest(SupportEvent $event, Ticket $ticket): bool\\n {\\n if (!Policy::mayEmitExternal($event))\\n {\\n return false;\\n }\\n\\n $email = trim((string)$ticket->guest_email);\\n if ($email === '')\\n {\\n return false;\\n }\\n\\n $recipientKey = RecipientKey::email($email);\\n\\n return $this->send(\\n $event,\\n $recipientKey,\\n function () use ($event, $ticket, $email)\\n {\\n // Raw guest access token is intentionally unavailable here:\\n // only its hash is persisted. Email links to the manual access\\n // page; the guest uses the one-time/saved access code.\\n \\\\XF::app()->mailer()->newMail()\\n ->setTo($email)\\n ->setTemplate('gram_support_ticket_event_guest', [\\n 'ticket' => $ticket,\\n 'event' => $event,\\n 'accessUrl' => \\\\XF::app()->router('public')->buildLink('canonical:support/access')\\n ])\\n ->queue();\\n }\\n );\\n }\\n\\n private function send(SupportEvent $event, string $recipientKey, callable $sender): bool\\n {\\n if (!$this->gate->acquire((int)$event->event_id, 'email', $recipientKey))\\n {\\n return false;\\n }\\n\\n try\\n {\\n $sender();\\n $this->gate->markSent((int)$event->event_id, 'email', $recipientKey);\\n return true;\\n }\\n catch (\\\\Throwable $e)\\n {\\n $this->gate->markFailed(\\n (int)$event->event_id,\\n 'email',\\n $recipientKey,\\n get_class($e)\\n );\\n throw $e;\\n }\\n }\\n}\\n\"},\"Notification/EntityDeliveryGate.php\":{\"line_count\":118,\"present\":true,\"text\":\"deliveryKey($eventId, $consumer, $recipientKey);\\n $lockName = 'gram_support_delivery_' . substr($deliveryKey, 0, 32);\\n $db = \\\\XF::db();\\n\\n if ((int)$db->fetchOne('SELECT GET_LOCK(?, 2)', [$lockName]) !== 1)\\n {\\n return false;\\n }\\n\\n try\\n {\\n $now = \\\\XF::$time;\\n $delivery = \\\\XF::em()->find('Gram\\\\Support:Delivery', $deliveryKey);\\n\\n if (!$delivery)\\n {\\n $delivery = \\\\XF::em()->create('Gram\\\\Support:Delivery');\\n $delivery->delivery_key = $deliveryKey;\\n $delivery->event_id = $eventId;\\n $delivery->consumer = $consumer;\\n $delivery->recipient_key = $recipientKey;\\n $delivery->state = 'pending';\\n $delivery->lease_expires = $now + self::LEASE_SECONDS;\\n $delivery->attempt_count = 1;\\n $delivery->created_date = $now;\\n $delivery->updated_date = $now;\\n $delivery->save();\\n return true;\\n }\\n\\n if ((string)$delivery->state === 'sent')\\n {\\n return false;\\n }\\n\\n if ((string)$delivery->state === 'pending' && (int)$delivery->lease_expires > $now)\\n {\\n return false;\\n }\\n\\n $delivery->state = 'pending';\\n $delivery->lease_expires = $now + self::LEASE_SECONDS;\\n $delivery->attempt_count = (int)$delivery->attempt_count + 1;\\n $delivery->updated_date = $now;\\n $delivery->save();\\n\\n return true;\\n }\\n finally\\n {\\n try\\n {\\n $db->fetchOne('SELECT RELEASE_LOCK(?)', [$lockName]);\\n }\\n catch (\\\\Throwable $ignored)\\n {\\n }\\n }\\n }\\n\\n public function markSent(int $eventId, string $consumer, string $recipientKey): void\\n {\\n $delivery = \\\\XF::em()->find(\\n 'Gram\\\\Support:Delivery',\\n $this->deliveryKey($eventId, $consumer, $recipientKey)\\n );\\n\\n if (!$delivery)\\n {\\n return;\\n }\\n\\n $delivery->state = 'sent';\\n $delivery->lease_expires = 0;\\n $delivery->updated_date = \\\\XF::$time;\\n $delivery->sent_date = \\\\XF::$time;\\n $delivery->save();\\n }\\n\\n public function markFailed(int $eventId, string $consumer, string $recipientKey, string $failureClass): void\\n {\\n $delivery = \\\\XF::em()->find(\\n 'Gram\\\\Support:Delivery',\\n $this->deliveryKey($eventId, $consumer, $recipientKey)\\n );\\n\\n if (!$delivery)\\n {\\n return;\\n }\\n\\n $delivery->state = 'failed';\\n $delivery->lease_expires = 0;\\n $delivery->last_failure_class = substr($failureClass, 0, 191);\\n $delivery->updated_date = \\\\XF::$time;\\n $delivery->save();\\n }\\n\\n private function deliveryKey(int $eventId, string $consumer, string $recipientKey): string\\n {\\n return hash('sha256', $eventId . \\\"\\\\0\\\" . $consumer . \\\"\\\\0\\\" . $recipientKey);\\n }\\n}\\n\"},\"Notification/EventDispatcher.php\":{\"line_count\":42,\"present\":true,\"text\":\"user_id > 0)\\n {\\n $owner = \\\\XF::em()->find('XF:User', (int)$ticket->user_id);\\n }\\n\\n $dispatcher->dispatchOwner($event, $ticket, $owner);\\n\\n if ((int)$ticket->assigned_user_id > 0)\\n {\\n $staff = \\\\XF::em()->find('XF:User', (int)$ticket->assigned_user_id);\\n if ($staff)\\n {\\n $dispatcher->dispatchStaff($event, $ticket, [$staff]);\\n }\\n }\\n }\\n catch (\\\\Throwable $e)\\n {\\n // Event/Ticket/Message are already persisted truth.\\n \\\\XF::logException($e, false, 'Gram Support notification consumer: ');\\n }\\n }\\n}\\n\"},\"Notification/Policy.php\":{\"line_count\":61,\"present\":true,\"text\":\"event_type, self::INTERNAL_EVENT_TYPES, true))\\n {\\n return false;\\n }\\n\\n $payload = self::safePayload($event);\\n\\n if (!empty($payload['is_internal']))\\n {\\n return false;\\n }\\n\\n return true;\\n }\\n\\n public static function safePayload(SupportEvent $event): array\\n {\\n $payload = $event->safe_payload_json;\\n\\n if (is_array($payload))\\n {\\n return $payload;\\n }\\n\\n if (!is_string($payload) || $payload === '')\\n {\\n return [];\\n }\\n\\n $decoded = json_decode($payload, true);\\n return is_array($decoded) ? $decoded : [];\\n }\\n\\n public static function alertExtraData(SupportEvent $event): array\\n {\\n $payload = self::safePayload($event);\\n\\n return [\\n 'support_event_id' => (int)$event->event_id,\\n 'ticket_id' => (int)$event->ticket_id,\\n 'event_type' => (string)$event->event_type,\\n 'public_ref' => isset($payload['public_ref']) ? (string)$payload['public_ref'] : ''\\n ];\\n }\\n}\\n\"},\"Notification/RecipientKey.php\":{\"line_count\":28,\"present\":true,\"text\":\"gate = $gate;"},{"line":16,"text":" }"},{"line":17,"text":""},{"line":18,"text":" public function consumeForUser(SupportEvent $event, Ticket $ticket, User $recipient): bool"},{"line":19,"text":" {"},{"line":20,"text":" if (!Policy::mayEmitExternal($event) || !$recipient->user_id)"},{"line":21,"text":" {"},{"line":22,"text":" return false;"},{"line":23,"text":" }"},{"line":24,"text":""},{"line":25,"text":" // Never alert a user about a ticket they cannot access."},{"line":26,"text":" $isOwner = $ticket->user_id && (int)$ticket->user_id === (int)$recipient->user_id;"},{"line":27,"text":" $isStaff = $recipient->hasPermission('gramSupport', 'viewAll')"},{"line":28,"text":" || $recipient->hasPermission('gramSupport', 'manage');"},{"line":29,"text":""},{"line":30,"text":" if (!$isOwner && !$isStaff)"},{"line":31,"text":" {"},{"line":32,"text":" return false;"},{"line":33,"text":" }"},{"line":34,"text":""},{"line":35,"text":" $recipientKey = RecipientKey::user((int)$recipient->user_id);"},{"line":36,"text":" if (!$this->gate->acquire((int)$event->event_id, 'xf_alert', $recipientKey))"},{"line":37,"text":" {"},{"line":38,"text":" return false;"},{"line":39,"text":" }"},{"line":40,"text":""},{"line":41,"text":" try"},{"line":42,"text":" {"},{"line":43,"text":" /** @var \\XF\\Repository\\UserAlertRepository $alerts */"},{"line":44,"text":" $alerts = \\XF::repository('XF:UserAlert');"},{"line":45,"text":""},{"line":46,"text":" // Back the alert with the recipient's own User content so the"},{"line":47,"text":" // core alert handler remains valid/viewable. Ticket access is"},{"line":48,"text":" // separately enforced before emission and again on ticket route."},{"line":49,"text":" $alerts->alert("},{"line":50,"text":" $recipient,"},{"line":51,"text":" 0,"},{"line":52,"text":" 'Support',"},{"line":53,"text":" 'user',"},{"line":54,"text":" (int)$recipient->user_id,"},{"line":55,"text":" 'gram_support_evt',"},{"line":56,"text":" Policy::alertExtraData($event),"},{"line":57,"text":" ['usePush' => false]"},{"line":58,"text":" );"},{"line":59,"text":""},{"line":60,"text":" $this->gate->markSent((int)$event->event_id, 'xf_alert', $recipientKey);"},{"line":61,"text":" return true;"},{"line":62,"text":" }"},{"line":63,"text":" catch (\\Throwable $e)"},{"line":64,"text":" {"},{"line":65,"text":" $this->gate->markFailed("},{"line":66,"text":" (int)$event->event_id,"},{"line":67,"text":" 'xf_alert',"},{"line":68,"text":" $recipientKey,"},{"line":69,"text":" get_class($e)"},{"line":70,"text":" );"},{"line":71,"text":" throw $e;"},{"line":72,"text":" }"}],"present":true,"sha256":"e22d0cdb0a0806c6a231504409bd17cd781caab0f28181aff7246b0295d8cd98"},"Notification/Dispatcher.php":{"line_count":67,"matched_context":[{"sep":true},{"line":1,"text":"alerts = $alerts;"},{"line":17,"text":" $this->email = $email;"},{"line":18,"text":" }"},{"line":19,"text":""},{"line":20,"text":" /**"},{"line":21,"text":" * Owner-facing delivery. Staff alert fan-out is intentionally provided by"},{"line":22,"text":" * dispatchStaff() so staff recipients must still pass Gram Support ACL."},{"line":23,"text":" */"},{"line":24,"text":" public function dispatchOwner(SupportEvent $event, Ticket $ticket, ?User $owner): void"},{"line":25,"text":" {"},{"line":26,"text":" if (!Policy::mayEmitExternal($event))"},{"line":27,"text":" {"},{"line":28,"text":" return;"},{"line":29,"text":" }"},{"line":30,"text":""},{"line":31,"text":" if ($owner && $owner->user_id)"},{"line":32,"text":" {"},{"line":33,"text":" $this->alerts->consumeForUser($event, $ticket, $owner);"},{"line":34,"text":" $this->email->consumeForRegistered($event, $ticket, $owner);"},{"line":35,"text":" return;"},{"line":36,"text":" }"},{"line":37,"text":""},{"line":38,"text":" $this->email->consumeForGuest($event, $ticket);"},{"line":39,"text":" }"},{"line":40,"text":""},{"line":41,"text":" /**"},{"line":42,"text":" * @param User[] $staffUsers"},{"line":43,"text":" */"},{"line":44,"text":" public function dispatchStaff(SupportEvent $event, Ticket $ticket, array $staffUsers): void"},{"line":45,"text":" {"},{"line":46,"text":" if (!Policy::mayEmitExternal($event))"},{"line":47,"text":" {"},{"line":48,"text":" return;"},{"line":49,"text":" }"},{"line":50,"text":""},{"line":51,"text":" foreach ($staffUsers as $staff)"},{"line":52,"text":" {"},{"line":53,"text":" if (!$staff instanceof User || !$staff->user_id)"},{"line":54,"text":" {"},{"line":55,"text":" continue;"},{"line":56,"text":" }"},{"line":57,"text":""},{"line":58,"text":" if (!$staff->hasPermission('gramSupport', 'viewAll')"},{"line":59,"text":" && !$staff->hasPermission('gramSupport', 'manage'))"},{"line":60,"text":" {"},{"line":61,"text":" continue;"},{"line":62,"text":" }"},{"line":63,"text":""},{"line":64,"text":" $this->alerts->consumeForUser($event, $ticket, $staff);"},{"line":65,"text":" }"},{"line":66,"text":" }"},{"line":67,"text":"}"}],"present":true,"sha256":"a043d94bc91feeed0969ad218566cebac76b2d804e7c69cc2b87b94d42f5bb85"},"Pub/Controller/Support.php":{"line_count":236,"matched_context":[{"sep":true},{"line":1,"text":"isPost())"},{"line":37,"text":" {"},{"line":38,"text":" $this->assertPostOnly();"},{"line":39,"text":""},{"line":40,"text":" $email = $this->filter('email', 'str');"},{"line":41,"text":" $category = $this->filter('category', 'str');"},{"line":42,"text":" $subject = $this->filter('subject', 'str');"},{"line":43,"text":" $message = $this->filter('message', 'str');"},{"line":44,"text":""},{"line":45,"text":" if (!$visitor->user_id)"},{"line":46,"text":" {"},{"line":47,"text":" if (!$this->captchaIsValid(true))"},{"line":48,"text":" {"},{"line":49,"text":" return $this->error("},{"line":50,"text":" \\XF::phrase('did_not_complete_the_captcha_verification_properly')"},{"line":51,"text":" );"},{"line":52,"text":" }"},{"line":53,"text":""},{"line":54,"text":" /** @var RateLimiter $rateLimiter */"},{"line":55,"text":" $rateLimiter = $this->service(RateLimiter::class);"},{"line":56,"text":" $allowed = $rateLimiter->consumeGuest("},{"line":57,"text":" SiteConfig::siteId(),"},{"line":58,"text":" $email,"},{"line":59,"text":" SiteConfig::guestRateLimitCount(),"},{"line":60,"text":" SiteConfig::guestRateLimitWindowSeconds()"},{"line":61,"text":" );"},{"line":62,"text":""},{"line":63,"text":" if (!$allowed)"},{"sep":true},{"line":74,"text":" if ($upload)"},{"line":75,"text":" {"},{"line":76,"text":" $attachmentId = $attachmentFlow->prepare($upload, $tempHash);"},{"line":77,"text":" }"},{"line":78,"text":""},{"line":79,"text":" /** @var Creator $creator */"},{"line":80,"text":" $creator = $this->service(Creator::class);"},{"line":81,"text":" $created = $creator->create(["},{"line":82,"text":" 'email' => $email,"},{"line":83,"text":" 'category' => $category,"},{"line":84,"text":" 'subject' => $subject,"},{"line":85,"text":" 'message' => $message"},{"line":86,"text":" ]);"},{"line":87,"text":""},{"sep":true},{"line":127,"text":" }"},{"line":128,"text":""},{"line":129,"text":" return $this->view("},{"line":130,"text":" 'Gram\\Support:Create',"},{"line":131,"text":" 'gram_support_create',"},{"line":132,"text":" ['forceCaptcha' => !$visitor->user_id]"},{"line":133,"text":" );"},{"line":134,"text":" }"},{"line":135,"text":""},{"line":136,"text":" public function actionAccess()"},{"line":137,"text":" {"},{"sep":true},{"line":183,"text":" 'messages' => $messages"},{"line":184,"text":" ]"},{"line":185,"text":" );"},{"line":186,"text":" }"},{"line":187,"text":""},{"line":188,"text":" public function actionReply()"},{"line":189,"text":" {"},{"line":190,"text":" $this->assertPostOnly();"},{"line":191,"text":""},{"line":192,"text":" $ticket = $this->assertViewableTicketByRef($this->filter('ref', 'str'));"},{"line":193,"text":" $visitor = \\XF::visitor();"},{"line":194,"text":""},{"line":195,"text":" if ($visitor->user_id)"},{"line":196,"text":" {"},{"line":197,"text":" if ((int)$ticket->user_id !== (int)$visitor->user_id"},{"line":198,"text":" || !$visitor->hasPermission('gramSupport', 'replyOwn'))"},{"line":199,"text":" {"},{"line":200,"text":" return $this->noPermission();"},{"line":201,"text":" }"},{"line":202,"text":" }"},{"line":203,"text":" elseif (!GuestTicketSession::hasGrant($ticket))"},{"sep":true},{"line":205,"text":" return $this->noPermission();"},{"line":206,"text":" }"},{"line":207,"text":""},{"line":208,"text":" $message = $this->filter('message', 'str');"},{"line":209,"text":""},{"line":210,"text":" /** @var Reply $reply */"},{"line":211,"text":" $reply = $this->service(Reply::class);"},{"line":212,"text":" $reply->add($ticket, $message, false, false);"},{"line":213,"text":""},{"line":214,"text":" return $this->redirect("},{"line":215,"text":" $this->buildLink('support/ticket', null, ['ref' => $ticket->public_ref])"},{"line":216,"text":" );"},{"line":217,"text":" }"}],"present":true,"sha256":"6cb34b1d27e9f0132263637cefa62fe0e998a9a86620b7ac33199f41ee86f9d4"},"Service/Ticket/Creator.php":{"line_count":139,"matched_context":[{"sep":true},{"line":8,"text":"use Gram\\Support\\Util\\PublicRef;"},{"line":9,"text":"use Gram\\Support\\Util\\SafeContext;"},{"line":10,"text":"use Gram\\Support\\Util\\SiteConfig;"},{"line":11,"text":"use Gram\\Support\\Util\\StatusPolicy;"},{"line":12,"text":"use XF\\Service\\AbstractService;"},{"line":13,"text":"use Gram\\Support\\Notification\\EventDispatcher;"},{"line":14,"text":""},{"line":15,"text":"class Creator extends AbstractService"},{"line":16,"text":"{"},{"line":17,"text":" public function create(array $input, ?int $now = null): array"},{"line":18,"text":" {"},{"line":19,"text":" $now = $now ?? \\XF::$time;"},{"line":20,"text":" $visitor = \\XF::visitor();"},{"line":21,"text":" $siteId = SiteConfig::siteId();"},{"line":22,"text":""},{"line":23,"text":" $email = strtolower(trim((string)($input['email'] ?? '')));"},{"line":24,"text":" $category = trim((string)($input['category'] ?? ''));"},{"line":25,"text":" $subject = trim((string)($input['subject'] ?? ''));"},{"line":26,"text":" $messageText = trim((string)($input['message'] ?? ''));"},{"line":27,"text":""},{"line":28,"text":" if ($category === '' || $messageText === '')"},{"line":29,"text":" {"},{"line":30,"text":" throw new \\InvalidArgumentException('GRAM_SUPPORT_REQUIRED_FIELDS');"},{"line":31,"text":" }"},{"line":32,"text":""},{"line":33,"text":" if (!$visitor->user_id && !filter_var($email, FILTER_VALIDATE_EMAIL))"},{"line":34,"text":" {"},{"line":35,"text":" throw new \\InvalidArgumentException('GRAM_SUPPORT_VALID_EMAIL_REQUIRED');"},{"line":36,"text":" }"},{"line":37,"text":""},{"line":38,"text":" if ($subject === '')"},{"line":39,"text":" {"},{"line":40,"text":" $subject = function_exists('mb_substr')"},{"sep":true},{"line":68,"text":" /** @var Ticket $ticket */"},{"line":69,"text":" $ticket = $this->em()->create('Gram\\Support:Ticket');"},{"line":70,"text":" $ticket->public_ref = 'TMP-' . bin2hex(random_bytes(12));"},{"line":71,"text":" $ticket->site_id = $siteId;"},{"line":72,"text":" $ticket->user_id = (int)$visitor->user_id;"},{"line":73,"text":" $ticket->guest_email = $visitor->user_id ? '' : $email;"},{"line":74,"text":" $ticket->guest_access_token_hash = $guest ? $guest['hash'] : '';"},{"line":75,"text":" $ticket->guest_access_expires = $guest"},{"line":76,"text":" ? $now + SiteConfig::guestTokenTtlSeconds()"},{"line":77,"text":" : 0;"},{"line":78,"text":" $ticket->node_id = (int)($context['node_id'] ?? 0);"},{"sep":true},{"line":96,"text":" $ticket->save();"},{"line":97,"text":""},{"line":98,"text":" $message = $this->em()->create('Gram\\Support:Message');"},{"line":99,"text":" $message->ticket_id = (int)$ticket->ticket_id;"},{"line":100,"text":" $message->user_id = (int)$visitor->user_id;"},{"line":101,"text":" $message->is_staff = 0;"},{"line":102,"text":" $message->is_internal = 0;"},{"line":103,"text":" $message->message = $messageText;"},{"line":104,"text":" $message->message_date = $now;"},{"line":105,"text":" $message->save();"},{"line":106,"text":""},{"line":107,"text":" $ticket->last_message_id = (int)$message->message_id;"},{"line":108,"text":" $ticket->save();"},{"line":109,"text":""},{"line":110,"text":" $event = $this->em()->create('Gram\\Support:SupportEvent');"},{"line":111,"text":" $event->ticket_id = (int)$ticket->ticket_id;"},{"line":112,"text":" $event->site_id = $siteId;"},{"line":113,"text":" $event->actor_user_id = (int)$visitor->user_id;"},{"line":114,"text":" $event->event_type = 'ticket_created';"},{"line":115,"text":" $event->event_date = $now;"},{"line":116,"text":" $event->safe_payload_json = json_encode(["},{"line":117,"text":" 'public_ref' => (string)$ticket->public_ref,"},{"line":118,"text":" 'category' => (string)$ticket->category,"},{"line":119,"text":" 'is_internal' => false"},{"sep":true},{"line":126,"text":" {"},{"line":127,"text":" $db->rollback();"},{"line":128,"text":" throw $e;"},{"line":129,"text":" }"},{"line":130,"text":""},{"line":131,"text":" (new EventDispatcher())->dispatchSafely($event, $ticket);"},{"line":132,"text":""},{"line":133,"text":" return ["},{"line":134,"text":" 'ticket' => $ticket,"},{"line":135,"text":" 'event' => $event,"},{"line":136,"text":" 'guest_secret' => $guest ? $guest['secret'] : null"}],"present":true,"sha256":"7a84cd77761a7943495f9d3ef11269987f85265170ad6cf927a1661b8bf0a99a"},"Service/Ticket/Reply.php":{"line_count":68,"matched_context":[{"sep":true},{"line":3,"text":"namespace Gram\\Support\\Service\\Ticket;"},{"line":4,"text":""},{"line":5,"text":"use Gram\\Support\\Entity\\Ticket;"},{"line":6,"text":"use Gram\\Support\\Util\\SiteConfig;"},{"line":7,"text":"use XF\\Service\\AbstractService;"},{"line":8,"text":"use Gram\\Support\\Notification\\EventDispatcher;"},{"line":9,"text":""},{"line":10,"text":"class Reply extends AbstractService"},{"line":11,"text":"{"},{"line":12,"text":" public function add(Ticket $ticket, string $messageText, bool $isStaff, bool $isInternal = false): array"},{"line":13,"text":" {"},{"line":14,"text":" $messageText = trim($messageText);"},{"line":15,"text":" if ($messageText === '')"},{"line":16,"text":" {"},{"line":17,"text":" throw new \\InvalidArgumentException('GRAM_SUPPORT_EMPTY_REPLY');"},{"line":18,"text":" }"},{"line":19,"text":" if ($isInternal && !$isStaff)"},{"line":20,"text":" {"},{"line":21,"text":" throw new \\LogicException('INTERNAL_REQUIRES_STAFF');"},{"line":22,"text":" }"},{"line":23,"text":""},{"line":24,"text":" $visitor = \\XF::visitor();"},{"line":25,"text":" $now = \\XF::$time;"},{"line":26,"text":" $db = \\XF::db();"},{"sep":true},{"line":29,"text":" try"},{"line":30,"text":" {"},{"line":31,"text":" $message = $this->em()->create('Gram\\Support:Message');"},{"line":32,"text":" $message->ticket_id = (int)$ticket->ticket_id;"},{"line":33,"text":" $message->user_id = (int)$visitor->user_id;"},{"line":34,"text":" $message->is_staff = $isStaff ? 1 : 0;"},{"line":35,"text":" $message->is_internal = $isInternal ? 1 : 0;"},{"line":36,"text":" $message->message = $messageText;"},{"line":37,"text":" $message->message_date = $now;"},{"line":38,"text":" $message->save();"},{"line":39,"text":""},{"line":40,"text":" $ticket->last_message_id = (int)$message->message_id;"},{"line":41,"text":" $ticket->updated_date = $now;"},{"line":42,"text":" $ticket->save();"},{"line":43,"text":""},{"line":44,"text":" $event = $this->em()->create('Gram\\Support:SupportEvent');"},{"line":45,"text":" $event->ticket_id = (int)$ticket->ticket_id;"},{"line":46,"text":" $event->site_id = (string)$ticket->site_id;"},{"line":47,"text":" $event->actor_user_id = (int)$visitor->user_id;"},{"line":48,"text":" $event->event_type = $isInternal ? 'internal_note_created' : 'ticket_reply_created';"},{"line":49,"text":" $event->event_date = $now;"},{"line":50,"text":" $event->safe_payload_json = json_encode(["},{"line":51,"text":" 'public_ref' => (string)$ticket->public_ref,"},{"line":52,"text":" 'is_internal' => $isInternal"},{"line":53,"text":" ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);"},{"sep":true},{"line":59,"text":" {"},{"line":60,"text":" $db->rollback();"},{"line":61,"text":" throw $e;"},{"line":62,"text":" }"},{"line":63,"text":""},{"line":64,"text":" (new EventDispatcher())->dispatchSafely($event, $ticket);"},{"line":65,"text":""},{"line":66,"text":" return ['message' => $message, 'event' => $event];"},{"line":67,"text":" }"},{"line":68,"text":"}"}],"present":true,"sha256":"482571a2ccf07a3c8672f34940243264a8271ef8c3cbc4fadbb46112afd36830"},"_data/templates.xml":{"line_count":781,"matched_context":[{"sep":true},{"line":1,"text":""},{"line":2,"text":""},{"line":3,"text":" "},{"line":9,"text":" "},{"line":553,"text":" "},{"line":575,"text":" "},{"line":647,"text":" "},{"line":764,"text":" "},{"line":772,"text":" "},{"line":781,"text":""}],"present":true,"sha256":"1a344bd306135486d2019bf0a5cafff5eb472882f7b3433ef13af4556a4c791e"}}},"transport_rc":0,"transport_stderr_bytes":0,"transport_stderr_sha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"} -GRAM516R1R3_NOTIFICATION_FILES=["Notification/AlertConsumer.php","Notification/DeliveryGate.php","Notification/Dispatcher.php","Notification/EmailConsumer.php","Notification/EntityDeliveryGate.php","Notification/EventDispatcher.php","Notification/Policy.php","Notification/RecipientKey.php"] -GRAM516R1R3_EMAIL_CONSUMER_FILE_PRESENT=true -GRAM516R1R3_DECISION=PASS_NOTIFY_CAPTCHA_SOURCE_EVIDENCE_CAPTURED -GRAM516R1R3_NEXT_GATE=BUILD_EXACT_TURNSTILE_AND_EMAIL_NOTIFY_CHANGE -HOMELAB_RESULT_CONTRACT={"version":1,"command_id":"SUPPORT-260918-GRAM-SUPPORT-NOTIFY-CAPTCHA-SOURCE-READONLY-516R1R3","status":"OK","changes_made":false,"rollback_started":false,"rollback_restored":null} +GRAM516R1R4_SAFE_REPORT={"classification":{"all_sites_eligible_staff_match_target":true,"changes_made":false,"eligible_staff_target_match_total":7,"eligible_staff_total":7,"email_consumer_present":true,"event_dispatcher_present":true,"runtime_probe_7of7":true,"send_attempts":0,"source_probe_ok":true},"schema_version":1,"scope":"READ_ONLY_GRAM_SUPPORT_STAFF_EMAIL_SOURCE","secret_policy":"NO_NON_TARGET_EMAIL_ADDRESSES_EXPORTED","sites":[{"all_eligible_match_target":true,"eligible_staff_count":1,"eligible_target_match_count":1,"slug":"codevipe","staff":[{"email_matches_target":true,"email_nonempty":true,"gram_manage":true,"gram_viewAll":true,"is_admin":true,"is_moderator":true,"primary_group_id":2,"secondary_group_ids":[3,4],"staff_eligible":true,"user_id_sha256":"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b"}]},{"all_eligible_match_target":true,"eligible_staff_count":1,"eligible_target_match_count":1,"slug":"dsmods","staff":[{"email_matches_target":true,"email_nonempty":true,"gram_manage":true,"gram_viewAll":true,"is_admin":true,"is_moderator":true,"primary_group_id":2,"secondary_group_ids":[3,4],"staff_eligible":true,"user_id_sha256":"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b"}]},{"all_eligible_match_target":true,"eligible_staff_count":1,"eligible_target_match_count":1,"slug":"gamevipe","staff":[{"email_matches_target":true,"email_nonempty":true,"gram_manage":true,"gram_viewAll":true,"is_admin":true,"is_moderator":true,"primary_group_id":2,"secondary_group_ids":[3,4],"staff_eligible":true,"user_id_sha256":"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b"}]},{"all_eligible_match_target":true,"eligible_staff_count":1,"eligible_target_match_count":1,"slug":"hkmods","staff":[{"email_matches_target":true,"email_nonempty":true,"gram_manage":true,"gram_viewAll":true,"is_admin":true,"is_moderator":true,"primary_group_id":2,"secondary_group_ids":[3,4],"staff_eligible":true,"user_id_sha256":"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b"}]},{"all_eligible_match_target":true,"eligible_staff_count":1,"eligible_target_match_count":1,"slug":"kingofwolk","staff":[{"email_matches_target":true,"email_nonempty":true,"gram_manage":true,"gram_viewAll":true,"is_admin":true,"is_moderator":true,"primary_group_id":2,"secondary_group_ids":[3,4],"staff_eligible":true,"user_id_sha256":"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b"}]},{"all_eligible_match_target":true,"eligible_staff_count":1,"eligible_target_match_count":1,"slug":"zakrutim","staff":[{"email_matches_target":true,"email_nonempty":true,"gram_manage":true,"gram_viewAll":true,"is_admin":true,"is_moderator":true,"primary_group_id":2,"secondary_group_ids":[3,4],"staff_eligible":true,"user_id_sha256":"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b"}]},{"all_eligible_match_target":true,"eligible_staff_count":1,"eligible_target_match_count":1,"slug":"newfi","staff":[{"email_matches_target":true,"email_nonempty":true,"gram_manage":true,"gram_viewAll":true,"is_admin":true,"is_moderator":true,"primary_group_id":2,"secondary_group_ids":[3,4],"staff_eligible":true,"user_id_sha256":"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b"}]}],"source":{"files":{"Notification/DeliveryGate.php":{"line_count":16,"present":true,"text":"alerts = $alerts;\n $this->email = $email;\n }\n\n /**\n * Owner-facing delivery. Staff alert fan-out is intentionally provided by\n * dispatchStaff() so staff recipients must still pass Gram Support ACL.\n */\n public function dispatchOwner(SupportEvent $event, Ticket $ticket, ?User $owner): void\n {\n if (!Policy::mayEmitExternal($event))\n {\n return;\n }\n\n if ($owner && $owner->user_id)\n {\n $this->alerts->consumeForUser($event, $ticket, $owner);\n $this->email->consumeForRegistered($event, $ticket, $owner);\n return;\n }\n\n $this->email->consumeForGuest($event, $ticket);\n }\n\n /**\n * @param User[] $staffUsers\n */\n public function dispatchStaff(SupportEvent $event, Ticket $ticket, array $staffUsers): void\n {\n if (!Policy::mayEmitExternal($event))\n {\n return;\n }\n\n foreach ($staffUsers as $staff)\n {\n if (!$staff instanceof User || !$staff->user_id)\n {\n continue;\n }\n\n if (!$staff->hasPermission('gramSupport', 'viewAll')\n && !$staff->hasPermission('gramSupport', 'manage'))\n {\n continue;\n }\n\n $this->alerts->consumeForUser($event, $ticket, $staff);\n }\n }\n}\n"},"Notification/EmailConsumer.php":{"line_count":107,"present":true,"text":"gate = $gate;\n }\n\n public function consumeForRegistered(SupportEvent $event, Ticket $ticket, User $recipient): bool\n {\n if (!Policy::mayEmitExternal($event) || !$recipient->user_id)\n {\n return false;\n }\n\n if (!$ticket->user_id || (int)$ticket->user_id !== (int)$recipient->user_id)\n {\n return false;\n }\n\n $recipientKey = RecipientKey::user((int)$recipient->user_id);\n return $this->send(\n $event,\n $recipientKey,\n function () use ($event, $ticket, $recipient)\n {\n \\XF::app()->mailer()->newMail()\n ->setToUser($recipient)\n ->setTemplate('gram_support_ticket_event', [\n 'ticket' => $ticket,\n 'event' => $event,\n 'accessUrl' => null\n ])\n ->queue();\n }\n );\n }\n\n public function consumeForGuest(SupportEvent $event, Ticket $ticket): bool\n {\n if (!Policy::mayEmitExternal($event))\n {\n return false;\n }\n\n $email = trim((string)$ticket->guest_email);\n if ($email === '')\n {\n return false;\n }\n\n $recipientKey = RecipientKey::email($email);\n\n return $this->send(\n $event,\n $recipientKey,\n function () use ($event, $ticket, $email)\n {\n // Raw guest access token is intentionally unavailable here:\n // only its hash is persisted. Email links to the manual access\n // page; the guest uses the one-time/saved access code.\n \\XF::app()->mailer()->newMail()\n ->setTo($email)\n ->setTemplate('gram_support_ticket_event_guest', [\n 'ticket' => $ticket,\n 'event' => $event,\n 'accessUrl' => \\XF::app()->router('public')->buildLink('canonical:support/access')\n ])\n ->queue();\n }\n );\n }\n\n private function send(SupportEvent $event, string $recipientKey, callable $sender): bool\n {\n if (!$this->gate->acquire((int)$event->event_id, 'email', $recipientKey))\n {\n return false;\n }\n\n try\n {\n $sender();\n $this->gate->markSent((int)$event->event_id, 'email', $recipientKey);\n return true;\n }\n catch (\\Throwable $e)\n {\n $this->gate->markFailed(\n (int)$event->event_id,\n 'email',\n $recipientKey,\n get_class($e)\n );\n throw $e;\n }\n }\n}\n"},"Notification/EntityDeliveryGate.php":{"line_count":118,"present":true,"text":"deliveryKey($eventId, $consumer, $recipientKey);\n $lockName = 'gram_support_delivery_' . substr($deliveryKey, 0, 32);\n $db = \\XF::db();\n\n if ((int)$db->fetchOne('SELECT GET_LOCK(?, 2)', [$lockName]) !== 1)\n {\n return false;\n }\n\n try\n {\n $now = \\XF::$time;\n $delivery = \\XF::em()->find('Gram\\Support:Delivery', $deliveryKey);\n\n if (!$delivery)\n {\n $delivery = \\XF::em()->create('Gram\\Support:Delivery');\n $delivery->delivery_key = $deliveryKey;\n $delivery->event_id = $eventId;\n $delivery->consumer = $consumer;\n $delivery->recipient_key = $recipientKey;\n $delivery->state = 'pending';\n $delivery->lease_expires = $now + self::LEASE_SECONDS;\n $delivery->attempt_count = 1;\n $delivery->created_date = $now;\n $delivery->updated_date = $now;\n $delivery->save();\n return true;\n }\n\n if ((string)$delivery->state === 'sent')\n {\n return false;\n }\n\n if ((string)$delivery->state === 'pending' && (int)$delivery->lease_expires > $now)\n {\n return false;\n }\n\n $delivery->state = 'pending';\n $delivery->lease_expires = $now + self::LEASE_SECONDS;\n $delivery->attempt_count = (int)$delivery->attempt_count + 1;\n $delivery->updated_date = $now;\n $delivery->save();\n\n return true;\n }\n finally\n {\n try\n {\n $db->fetchOne('SELECT RELEASE_LOCK(?)', [$lockName]);\n }\n catch (\\Throwable $ignored)\n {\n }\n }\n }\n\n public function markSent(int $eventId, string $consumer, string $recipientKey): void\n {\n $delivery = \\XF::em()->find(\n 'Gram\\Support:Delivery',\n $this->deliveryKey($eventId, $consumer, $recipientKey)\n );\n\n if (!$delivery)\n {\n return;\n }\n\n $delivery->state = 'sent';\n $delivery->lease_expires = 0;\n $delivery->updated_date = \\XF::$time;\n $delivery->sent_date = \\XF::$time;\n $delivery->save();\n }\n\n public function markFailed(int $eventId, string $consumer, string $recipientKey, string $failureClass): void\n {\n $delivery = \\XF::em()->find(\n 'Gram\\Support:Delivery',\n $this->deliveryKey($eventId, $consumer, $recipientKey)\n );\n\n if (!$delivery)\n {\n return;\n }\n\n $delivery->state = 'failed';\n $delivery->lease_expires = 0;\n $delivery->last_failure_class = substr($failureClass, 0, 191);\n $delivery->updated_date = \\XF::$time;\n $delivery->save();\n }\n\n private function deliveryKey(int $eventId, string $consumer, string $recipientKey): string\n {\n return hash('sha256', $eventId . \"\\0\" . $consumer . \"\\0\" . $recipientKey);\n }\n}\n"},"Notification/EventDispatcher.php":{"line_count":42,"present":true,"text":"user_id > 0)\n {\n $owner = \\XF::em()->find('XF:User', (int)$ticket->user_id);\n }\n\n $dispatcher->dispatchOwner($event, $ticket, $owner);\n\n if ((int)$ticket->assigned_user_id > 0)\n {\n $staff = \\XF::em()->find('XF:User', (int)$ticket->assigned_user_id);\n if ($staff)\n {\n $dispatcher->dispatchStaff($event, $ticket, [$staff]);\n }\n }\n }\n catch (\\Throwable $e)\n {\n // Event/Ticket/Message are already persisted truth.\n \\XF::logException($e, false, 'Gram Support notification consumer: ');\n }\n }\n}\n"},"Notification/Policy.php":{"line_count":61,"present":true,"text":"event_type, self::INTERNAL_EVENT_TYPES, true))\n {\n return false;\n }\n\n $payload = self::safePayload($event);\n\n if (!empty($payload['is_internal']))\n {\n return false;\n }\n\n return true;\n }\n\n public static function safePayload(SupportEvent $event): array\n {\n $payload = $event->safe_payload_json;\n\n if (is_array($payload))\n {\n return $payload;\n }\n\n if (!is_string($payload) || $payload === '')\n {\n return [];\n }\n\n $decoded = json_decode($payload, true);\n return is_array($decoded) ? $decoded : [];\n }\n\n public static function alertExtraData(SupportEvent $event): array\n {\n $payload = self::safePayload($event);\n\n return [\n 'support_event_id' => (int)$event->event_id,\n 'ticket_id' => (int)$event->ticket_id,\n 'event_type' => (string)$event->event_type,\n 'public_ref' => isset($payload['public_ref']) ? (string)$payload['public_ref'] : ''\n ];\n }\n}\n"},"Notification/RecipientKey.php":{"line_count":28,"present":true,"text":"