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:
+1
-1
@@ -4,7 +4,7 @@
|
||||
<name>f7support</name>
|
||||
<summary>Support ticket client for F7cloud (F7cloud-compatible)</summary>
|
||||
<description>f7support client app for creating and viewing support tickets.</description>
|
||||
<version>0.1.8</version>
|
||||
<version>0.1.9</version>
|
||||
<licence>AGPL</licence>
|
||||
<author>f7support team</author>
|
||||
<namespace>F7Support</namespace>
|
||||
|
||||
+11
-4
@@ -1,8 +1,15 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'routes' => [
|
||||
['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'],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,10 @@ class PageController extends Controller {
|
||||
// Отключить вызовы с клиента: occ config:app:set f7support client_read_receipts --value=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 только у открытого тикета.
|
||||
// По умолчанию 3000 (3 с). Пустая строка в конфиге трактуется как 3000. Явное «0» отключает опрос. Иначе 3000…120000. occ: f7support tickets_poll_ms
|
||||
$rawPoll = trim((string) $this->config->getAppValue('f7support', 'tickets_poll_ms', '3000'));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) . '…';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user