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.
This commit is contained in:
root
2026-05-24 12:30:02 +03:00
parent 92df667d78
commit dc299709f7
10 changed files with 332 additions and 21 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
<name>f7support</name> <name>f7support</name>
<summary>Support ticket client for F7cloud (F7cloud-compatible)</summary> <summary>Support ticket client for F7cloud (F7cloud-compatible)</summary>
<description>f7support client app for creating and viewing support tickets.</description> <description>f7support client app for creating and viewing support tickets.</description>
<version>0.1.8</version> <version>0.1.9</version>
<licence>AGPL</licence> <licence>AGPL</licence>
<author>f7support team</author> <author>f7support team</author>
<namespace>F7Support</namespace> <namespace>F7Support</namespace>
+8 -1
View File
@@ -4,5 +4,12 @@ return [
'routes' => [ 'routes' => [
['name' => 'page#index', 'url' => '/', 'verb' => 'GET'], ['name' => 'page#index', 'url' => '/', 'verb' => 'GET'],
], ],
'ocs' => [
[
'name' => 'push_api#notify',
'url' => '/api/v{version}/push',
'verb' => 'POST',
'requirements' => ['version' => '1'],
],
],
]; ];
+4
View File
@@ -1589,6 +1589,10 @@
.catch((e) => showError(e.message)) .catch((e) => showError(e.message))
.finally(() => { .finally(() => {
scheduleTicketsBoardPolling(); scheduleTicketsBoardPolling();
const ticketFromUrl = new URLSearchParams(window.location.search).get("ticket");
if (ticketFromUrl) {
void tryOpenTicket(ticketFromUrl.trim());
}
}); });
}); });
})(); })();
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace OCA\F7Support\AppInfo;
use OCA\F7Support\Notification\Notifier;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
class Application extends App implements IBootstrap {
public const APP_ID = 'f7support';
public function __construct(array $urlParams = []) {
parent::__construct(self::APP_ID, $urlParams);
}
public function register(IRegistrationContext $context): void {
$context->registerNotifierService(Notifier::class);
}
public function boot(IBootContext $context): void {
}
}
-16
View File
@@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace OCA\F7Support;
use OCP\AppFramework\App;
class Application extends App {
public const APP_ID = 'f7support';
public function __construct(array $urlParams = []) {
parent::__construct(self::APP_ID, $urlParams);
}
}
+4
View File
@@ -50,6 +50,10 @@ class PageController extends Controller {
// Отключить вызовы с клиента: occ config:app:set f7support client_read_receipts --value=0 // Отключить вызовы с клиента: occ config:app:set f7support client_read_receipts --value=0
$clientReadReceipts = $this->config->getAppValue('f7support', 'client_read_receipts', '0'); $clientReadReceipts = $this->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 только у открытого тикета. // Периодический GET /api/client/tickets (мс): has_unread и отпечаток карточки; WebSocket только у открытого тикета.
// По умолчанию 3000 (3 с). Пустая строка в конфиге трактуется как 3000. Явное «0» отключает опрос. Иначе 3000…120000. occ: f7support tickets_poll_ms // По умолчанию 3000 (3 с). Пустая строка в конфиге трактуется как 3000. Явное «0» отключает опрос. Иначе 3000…120000. occ: f7support tickets_poll_ms
$rawPoll = trim((string) $this->config->getAppValue('f7support', 'tickets_poll_ms', '3000')); $rawPoll = trim((string) $this->config->getAppValue('f7support', 'tickets_poll_ms', '3000'));
+87
View File
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace OCA\F7Support\Controller;
use OCA\F7Support\Service\ConfigService;
use OCA\F7Support\Service\PushBridgeService;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\IRequest;
use OCP\IUserManager;
/**
* Webhook for support.f7cloud.ru: notify F7cloud user about a new support reply.
*
* Header: X-F7-Support-Push-Secret or X-F7-Push-Secret
* JSON: { userId?, email?, ticketNumber, body?, messagePreview?, ticketSubject? }
*/
class PushApiController extends OCSController {
public function __construct(
string $appName,
IRequest $request,
private ConfigService $config,
private PushBridgeService $pushBridge,
private IUserManager $userManager,
) {
parent::__construct($appName, $request);
}
public function notify(): DataResponse {
if (!$this->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);
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace OCA\F7Support\Notification;
use OCP\IURLGenerator;
use OCP\L10N\IFactory;
use OCP\Notification\IManager as INotificationManager;
use OCP\Notification\INotification;
use OCP\Notification\INotifier;
use OCP\Notification\UnknownNotificationException;
class Notifier implements INotifier {
public function __construct(
private IFactory $l10nFactory,
private IURLGenerator $urlGenerator,
private INotificationManager $notificationManager,
) {
}
public function getID(): string {
return 'f7support';
}
public function getName(): string {
return $this->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;
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace OCA\F7Support\Service;
use OCP\IConfig;
class ConfigService {
public const APP_ID = 'f7support';
public function __construct(
private IConfig $config,
) {
}
public function isPushEnabled(): bool {
return $this->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;
}
}
+83
View File
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace OCA\F7Support\Service;
use OCP\IURLGenerator;
use OCP\Notification\IManager as INotificationManager;
use Psr\Log\LoggerInterface;
class PushBridgeService {
private const PREVIEW_MAX = 240;
public function __construct(
private INotificationManager $notificationManager,
private IURLGenerator $urlGenerator,
private ConfigService $config,
private LoggerInterface $logger,
) {
}
public function notifyNewSupportMessage(
string $userId,
string $ticketNumber,
string $messagePreview,
?string $ticketSubject = null,
): bool {
if (!$this->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) . '…';
}
}