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) . '…';
+ }
+}