From dc299709f74ee2fbb5f81b96fc71eaff68d1f6b7 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 24 May 2026 12:30:02 +0300 Subject: [PATCH] Add f7push webhook for support replies and ticket deep links. OCS endpoint for support.f7cloud.ru, notification notifier, and ?ticket= URL opening in the client UI. --- appinfo/info.xml | 2 +- appinfo/routes.php | 15 +++-- js/main.js | 4 ++ lib/AppInfo/Application.php | 26 +++++++++ lib/Application.php | 16 ----- lib/Controller/PageController.php | 4 ++ lib/Controller/PushApiController.php | 87 ++++++++++++++++++++++++++++ lib/Notification/Notifier.php | 74 +++++++++++++++++++++++ lib/Service/ConfigService.php | 42 ++++++++++++++ lib/Service/PushBridgeService.php | 83 ++++++++++++++++++++++++++ 10 files changed, 332 insertions(+), 21 deletions(-) create mode 100644 lib/AppInfo/Application.php delete mode 100644 lib/Application.php create mode 100644 lib/Controller/PushApiController.php create mode 100644 lib/Notification/Notifier.php create mode 100644 lib/Service/ConfigService.php create mode 100644 lib/Service/PushBridgeService.php diff --git a/appinfo/info.xml b/appinfo/info.xml index 41f0e37..5aac8a2 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -4,7 +4,7 @@ f7support Support ticket client for F7cloud (F7cloud-compatible) f7support client app for creating and viewing support tickets. - 0.1.8 + 0.1.9 AGPL f7support team F7Support diff --git a/appinfo/routes.php b/appinfo/routes.php index e210b44..0517f1b 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -1,8 +1,15 @@ [ - ['name' => 'page#index', 'url' => '/', 'verb' => 'GET'], - ], + 'routes' => [ + ['name' => 'page#index', 'url' => '/', 'verb' => 'GET'], + ], + 'ocs' => [ + [ + 'name' => 'push_api#notify', + 'url' => '/api/v{version}/push', + 'verb' => 'POST', + 'requirements' => ['version' => '1'], + ], + ], ]; - diff --git a/js/main.js b/js/main.js index 3f9f426..1e160fe 100644 --- a/js/main.js +++ b/js/main.js @@ -1589,6 +1589,10 @@ .catch((e) => showError(e.message)) .finally(() => { scheduleTicketsBoardPolling(); + const ticketFromUrl = new URLSearchParams(window.location.search).get("ticket"); + if (ticketFromUrl) { + void tryOpenTicket(ticketFromUrl.trim()); + } }); }); })(); diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php new file mode 100644 index 0000000..b04de1e --- /dev/null +++ b/lib/AppInfo/Application.php @@ -0,0 +1,26 @@ +registerNotifierService(Notifier::class); + } + + public function boot(IBootContext $context): void { + } +} diff --git a/lib/Application.php b/lib/Application.php deleted file mode 100644 index 4d534a4..0000000 --- a/lib/Application.php +++ /dev/null @@ -1,16 +0,0 @@ -config->getAppValue('f7support', 'client_read_receipts', '0'); + // Push webhook (support.f7cloud.ru → f7push): POST /ocs/v2.php/apps/f7support/api/v1/push + // Secret: occ config:app:get f7push api_secret (or f7support push_webhook_secret) + // occ config:app:set f7support push_enabled --value=yes + // Периодический GET /api/client/tickets (мс): has_unread и отпечаток карточки; WebSocket только у открытого тикета. // По умолчанию 3000 (3 с). Пустая строка в конфиге трактуется как 3000. Явное «0» отключает опрос. Иначе 3000…120000. occ: f7support tickets_poll_ms $rawPoll = trim((string) $this->config->getAppValue('f7support', 'tickets_poll_ms', '3000')); diff --git a/lib/Controller/PushApiController.php b/lib/Controller/PushApiController.php new file mode 100644 index 0000000..25ec3c4 --- /dev/null +++ b/lib/Controller/PushApiController.php @@ -0,0 +1,87 @@ +authorize()) { + return new DataResponse(['error' => 'forbidden'], Http::STATUS_FORBIDDEN); + } + + $params = $this->request->getParams(); + $userId = trim((string)($params['userId'] ?? $params['user'] ?? '')); + if ($userId === '') { + $email = trim((string)($params['email'] ?? $params['clientEmail'] ?? '')); + if ($email !== '') { + $user = $this->userManager->getByEmail($email); + if ($user !== null) { + $userId = $user->getUID(); + } + } + } + + $ticketNumber = trim((string)($params['ticketNumber'] ?? $params['ticket'] ?? '')); + $preview = trim((string)($params['body'] ?? $params['messagePreview'] ?? $params['preview'] ?? '')); + $ticketSubject = trim((string)($params['ticketSubject'] ?? $params['subject'] ?? '')); + + if ($userId === '' || $ticketNumber === '') { + return new DataResponse( + ['error' => 'userId (or email) and ticketNumber required'], + Http::STATUS_BAD_REQUEST + ); + } + + if ($this->userManager->get($userId) === null) { + return new DataResponse(['error' => 'user not found'], Http::STATUS_NOT_FOUND); + } + + $queued = $this->pushBridge->notifyNewSupportMessage( + $userId, + $ticketNumber, + $preview, + $ticketSubject !== '' ? $ticketSubject : null, + ); + + return new DataResponse([ + 'success' => $queued, + 'queued' => $queued, + ]); + } + + private function authorize(): bool { + $header = $this->request->getHeader('X-F7-Support-Push-Secret'); + if ($header === '') { + $header = $this->request->getHeader('X-F7-Push-Secret'); + } + if ($header === '') { + $header = (string)$this->request->getParam('secret', ''); + } + return $this->config->verifyPushWebhookSecret($header); + } +} diff --git a/lib/Notification/Notifier.php b/lib/Notification/Notifier.php new file mode 100644 index 0000000..affd00f --- /dev/null +++ b/lib/Notification/Notifier.php @@ -0,0 +1,74 @@ +l10nFactory->get('f7support')->t('Support'); + } + + public function prepare(INotification $notification, string $languageCode): INotification { + if ($notification->getApp() !== 'f7support') { + throw new UnknownNotificationException(); + } + + if ($notification->getSubject() !== 'support_reply') { + throw new UnknownNotificationException(); + } + + $params = $notification->getSubjectParameters(); + $ticketNumber = (string)($params['ticketNumber'] ?? ''); + $ticketSubject = trim((string)($params['ticketSubject'] ?? '')); + $preview = trim((string)($params['preview'] ?? '')); + + if ($preview === '') { + $msgParams = $notification->getMessageParameters(); + $preview = trim((string)($msgParams['preview'] ?? '')); + } + + if ($ticketSubject !== '') { + $title = 'Заявка ' . $ticketNumber . ': ' . $ticketSubject; + } else { + $title = 'Заявка ' . $ticketNumber; + } + + if ($preview === '') { + $preview = 'Новый ответ от поддержки'; + } + + $icon = $this->urlGenerator->getAbsoluteURL( + $this->urlGenerator->imagePath('f7support', 'app.svg') + ); + + if ($this->notificationManager->isPreparingPushNotification()) { + $notification->setParsedSubject($title . "\n" . $preview); + } else { + $notification->setParsedSubject($title); + $notification->setParsedMessage($preview); + } + + $notification->setIcon($icon); + + return $notification; + } +} diff --git a/lib/Service/ConfigService.php b/lib/Service/ConfigService.php new file mode 100644 index 0000000..be50b87 --- /dev/null +++ b/lib/Service/ConfigService.php @@ -0,0 +1,42 @@ +config->getAppValue(self::APP_ID, 'push_enabled', 'yes') === 'yes'; + } + + public function getPushWebhookSecret(): string { + return trim($this->config->getAppValue(self::APP_ID, 'push_webhook_secret', '')); + } + + public function verifyPushWebhookSecret(?string $provided): bool { + if ($provided === null || $provided === '') { + return false; + } + + $own = $this->getPushWebhookSecret(); + if ($own !== '' && hash_equals($own, $provided)) { + return true; + } + + $f7pushSecret = trim($this->config->getAppValue('f7push', 'api_secret', '')); + if ($f7pushSecret !== '' && hash_equals($f7pushSecret, $provided)) { + return true; + } + + return false; + } +} diff --git a/lib/Service/PushBridgeService.php b/lib/Service/PushBridgeService.php new file mode 100644 index 0000000..3299294 --- /dev/null +++ b/lib/Service/PushBridgeService.php @@ -0,0 +1,83 @@ +config->isPushEnabled()) { + return false; + } + + $preview = $this->truncate($messagePreview); + if ($preview === '') { + $preview = 'Новый ответ от поддержки'; + } + + $subject = trim((string)$ticketSubject); + $link = $this->urlGenerator->linkToRouteAbsolute('f7support.page.index') + . '?ticket=' . rawurlencode($ticketNumber); + + $notification = $this->notificationManager->createNotification(); + $notification + ->setApp('f7support') + ->setUser($userId) + ->setDateTime(new \DateTime()) + ->setObject('ticket', $ticketNumber) + ->setSubject('support_reply', [ + 'ticketNumber' => $ticketNumber, + 'ticketSubject' => $subject, + 'preview' => $preview, + ]) + ->setMessage('support_reply', [ + 'preview' => $preview, + ]) + ->setLink($link); + + try { + $this->notificationManager->notify($notification); + $this->logger->debug('f7support: push notification queued', [ + 'app' => 'f7support', + 'user' => $userId, + 'ticket' => $ticketNumber, + ]); + return true; + } catch (\Throwable $e) { + $this->logger->warning('f7support: failed to queue push notification', [ + 'app' => 'f7support', + 'user' => $userId, + 'ticket' => $ticketNumber, + 'exception' => $e, + ]); + return false; + } + } + + private function truncate(string $text): string { + $text = trim(preg_replace('/\s+/u', ' ', $text) ?? $text); + if ($text === '' || mb_strlen($text) <= self::PREVIEW_MAX) { + return $text; + } + return mb_substr($text, 0, self::PREVIEW_MAX - 1) . '…'; + } +}