From 42fa70491d267e0194561e8b0f3a590f20372cb6 Mon Sep 17 00:00:00 2001 From: kyle Date: Fri, 12 Jun 2026 16:40:53 -0700 Subject: [PATCH] feat(webhook): add Apprise webhook support and rename bot-webhook to discord-webhook - Rename the Discord webhook endpoint from `/bot-webhook/` to `/discord-webhook/` for better clarity. - Add new routes and controller methods (`receiveApprise`, `receiveAppriseNotify`) to support Apprise integrations. - Implement `mapApprisePayload` in TalkService to map Apprise JSON format (title, body, type, attachments) to Talk message format. - Update the settings UI to generate and display URLs for both Discord and Apprise webhooks. - Update `INSTALL.md` and `README.md` documentation to reflect the new webhook paths and Apprise support. - Refactor token revocation logic to revoke all tokens for a room at once. --- INSTALL.md | 22 ++-- README.md | 31 +++++- appinfo/routes.php | 12 ++- js/settings.js | 111 +++++++++++++------- lib/Controller/WebhookController.php | 149 +++++++++++++++++++++++++-- lib/Service/TalkService.php | 138 +++++++++++++++++++++++++ 6 files changed, 411 insertions(+), 52 deletions(-) diff --git a/INSTALL.md b/INSTALL.md index 649f7cf..a48804c 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -81,14 +81,12 @@ php occ app:enable ncdiscordhook 8. For each room, click **+ Generate Auth Token** to create a webhook URL 9. Click **Save Configuration** -## Step 4: Set up the Discord webhook +## Step 4: Set up the webhook URL -1. In your Discord channel, go to **Channel Settings → Integrations → Webhooks** -2. Create a new webhook (or edit an existing one) -3. Set the Webhook URL to: +1. For Discord: go to **Channel Settings → Integrations → Webhooks**, create a new webhook, and set the Webhook URL to: ``` -https://your-nextcloud-server/apps/ncdiscordhook/bot-webhook// +https://your-nextcloud-server/apps/ncdiscordhook/discord-webhook// ``` Replace `` and `` with the values shown in the Nextcloud admin settings for the selected room. @@ -105,9 +103,21 @@ Test with curl: curl -X POST \ -H "Content-Type: application/json" \ -d '{"content":"Test message from NCdiscordhook","username":"CI Bot"}' \ - https://your-nextcloud-server/apps/ncdiscordhook/bot-webhook// + https://your-nextcloud-server/apps/ncdiscordhook/discord-webhook// ``` +## Apprise webhook + +For Apprise integrations, use the `/apprise-webhook/` endpoint with the same room-token and auth-token: + +``` +https://your-nextcloud-server/apps/ncdiscordhook/apprise-webhook//notify/ +``` + +Note: the `notify` segment is required — Apprise's `apprises://` URL scheme inserts it in the path. + +Apprise sends a different JSON format (`title`, `body`, `type`, `attachments`) — the app maps these to the same Talk message format. + ## Troubleshooting - **"talk-bot user not found"** — The `talk-bot` user doesn't exist. Re-run Step 1. diff --git a/README.md b/README.md index bc53822..e99650b 100644 --- a/README.md +++ b/README.md @@ -36,10 +36,10 @@ Go to **Settings → Admin → NCdiscordhook**: ### 5. Point your services at the webhook URLs -Each configured room gets a webhook URL: +Each configured room gets **two** webhook URLs (Discord and Apprise formats): ``` -https://your-server.com/apps/ncdiscordhook/bot-webhook// +https://your-server.com/apps/ncdiscordhook/discord-webhook// ``` Copy the auth token from the app settings for each room. @@ -92,7 +92,7 @@ Copy the auth token from the app settings for each room. Each room gets its own webhook URL with a unique auth token: ``` -/bot-webhook// +/discord-webhook// ``` - **Room token** — identifies which Talk room to post to (from `occ talk:room:list`) @@ -100,6 +100,31 @@ Each room gets its own webhook URL with a unique auth token: Multiple auth tokens can be created per room — useful if you need to rotate keys or share the webhook across multiple services. +### Apprise webhook + +For Apprise integrations, each room also gets an Apprise webhook URL: + +``` +https://your-server.com/apps/ncdiscordhook/apprise-webhook//notify/ +``` + +Note: the `notify` segment is required — Apprise's `apprises://` URL scheme inserts it in the path. + +Apprise sends a different JSON format. Supported fields: + +```json +{ + "title": "Build #1234", + "body": "Successfully deployed to production", + "type": "info", + "attachments": [ + { "url": "https://example.com/screenshot.png" } + ] +} +``` + +The apprise webhook maps `title`, `body`, and `attachments` to the same Talk message format as the Discord endpoint, so images and formatting work the same way. + ## Image management - Images are uploaded to the bot user's files at `/NCdiscordhook-images//` diff --git a/appinfo/routes.php b/appinfo/routes.php index adb3902..203adac 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -4,7 +4,7 @@ return [ 'routes' => [ [ 'name' => 'webhook#receive', - 'url' => '/bot-webhook/{roomToken}/{token}', + 'url' => '/discord-webhook/{roomToken}/{token}', 'verb' => 'POST', ], [ @@ -28,5 +28,15 @@ return [ 'verb' => 'GET', 'type' => 'noCsrf', ], + [ + 'name' => 'webhook#receiveApprise', + 'url' => '/apprise-webhook/{roomToken}/{token}', + 'verb' => 'POST', + ], + [ + 'name' => 'webhook#receiveAppriseNotify', + 'url' => '/apprise-webhook/{roomToken}/notify/{token}', + 'verb' => 'POST', + ], ], ]; diff --git a/js/settings.js b/js/settings.js index 5911fdc..42a9b82 100644 --- a/js/settings.js +++ b/js/settings.js @@ -132,9 +132,12 @@ document.addEventListener('DOMContentLoaded', function () { container.innerHTML = ''; const tokens = authTokensState[roomToken] || []; - // Build the full webhook URL with server origin - var webhookPath = OC.generateUrl('/apps/' + APP_ID + '/bot-webhook/') + roomToken + '/{token}'; - var webhookUrl = window.location.protocol + '//' + window.location.host + webhookPath; + + // Build both webhook URLs with server origin + var discordPath = OC.generateUrl('/apps/' + APP_ID + '/discord-webhook/') + roomToken + '/{token}'; + var apprisePath = OC.generateUrl('/apps/' + APP_ID + '/apprise-webhook/') + roomToken + '/notify/{token}'; + var discordUrl = window.location.protocol + '//' + window.location.host + discordPath; + var appriseUrl = window.location.protocol + '//' + window.location.host + apprisePath; if (tokens.length === 0) { // Show a hint that they can generate a token @@ -147,50 +150,86 @@ document.addEventListener('DOMContentLoaded', function () { } tokens.forEach(function (token) { - // Display the full webhook URL (encode token for URL safety) - var fullUrl = webhookUrl.replace('{token}', encodeURIComponent(token)); + var discordFullUrl = discordUrl.replace('{token}', encodeURIComponent(token)); + var appriseFullUrl = appriseUrl.replace('{token}', encodeURIComponent(token)); - var row = document.createElement('div'); - row.className = 'nc-token-row'; + // Discord webhook URL row + var labelDiscord = document.createElement('div'); + labelDiscord.className = 'nc-token-hint'; + labelDiscord.style.marginBottom = '2px'; + labelDiscord.textContent = 'Discord webhook:'; + container.appendChild(labelDiscord); - var urlInput = document.createElement('input'); - urlInput.type = 'text'; - urlInput.value = fullUrl; - urlInput.readOnly = true; - urlInput.className = 'nc-token-url-input'; - urlInput.title = fullUrl; + var rowDiscord = document.createElement('div'); + rowDiscord.className = 'nc-token-row'; - var copyBtn = document.createElement('button'); - copyBtn.type = 'button'; - copyBtn.className = 'nc-token-copy'; - copyBtn.textContent = 'Copy'; - copyBtn.addEventListener('click', function () { - navigator.clipboard.writeText(fullUrl).then(function () { - copyBtn.textContent = 'Copied!'; - setTimeout(function () { copyBtn.textContent = 'Copy'; }, 2000); + var urlInputD = document.createElement('input'); + urlInputD.type = 'text'; + urlInputD.value = discordFullUrl; + urlInputD.readOnly = true; + urlInputD.className = 'nc-token-url-input'; + urlInputD.title = discordFullUrl; + + var copyBtnD = document.createElement('button'); + copyBtnD.type = 'button'; + copyBtnD.className = 'nc-token-copy'; + copyBtnD.textContent = 'Copy'; + copyBtnD.addEventListener('click', function () { + navigator.clipboard.writeText(discordFullUrl).then(function () { + copyBtnD.textContent = 'Copied!'; + setTimeout(function () { copyBtnD.textContent = 'Copy'; }, 2000); }); }); + rowDiscord.appendChild(urlInputD); + rowDiscord.appendChild(copyBtnD); + container.appendChild(rowDiscord); + + // Apprise webhook URL row + var labelApprise = document.createElement('div'); + labelApprise.className = 'nc-token-hint'; + labelApprise.style.marginTop = '4px'; + labelApprise.style.marginBottom = '2px'; + labelApprise.textContent = 'Apprise webhook:'; + container.appendChild(labelApprise); + + var rowApprise = document.createElement('div'); + rowApprise.className = 'nc-token-row'; + + var urlInputA = document.createElement('input'); + urlInputA.type = 'text'; + urlInputA.value = appriseFullUrl; + urlInputA.readOnly = true; + urlInputA.className = 'nc-token-url-input'; + urlInputA.title = appriseFullUrl; + + var copyBtnA = document.createElement('button'); + copyBtnA.type = 'button'; + copyBtnA.className = 'nc-token-copy'; + copyBtnA.textContent = 'Copy'; + copyBtnA.addEventListener('click', function () { + navigator.clipboard.writeText(appriseFullUrl).then(function () { + copyBtnA.textContent = 'Copied!'; + setTimeout(function () { copyBtnA.textContent = 'Copy'; }, 2000); + }); + }); + + rowApprise.appendChild(urlInputA); + rowApprise.appendChild(copyBtnA); + container.appendChild(rowApprise); + + // Revoke button (applied to all tokens for this room) var revokeBtn = document.createElement('button'); revokeBtn.type = 'button'; revokeBtn.className = 'nc-token-revoke'; - revokeBtn.textContent = 'Revoke'; + revokeBtn.textContent = 'Revoke All'; revokeBtn.addEventListener('click', function () { - if (!confirm('Revoke this auth token?')) return; - tokens.splice(tokens.indexOf(token), 1); - if (tokens.length === 0) { - delete authTokensState[roomToken]; - container.style.display = 'none'; - } else { - authTokensState[roomToken] = tokens; - renderTokens(roomToken, container); - } + if (!confirm('Revoke all auth tokens for this room?')) return; + tokens.length = 0; + delete authTokensState[roomToken]; + renderTokens(roomToken, container); }); - - row.appendChild(urlInput); - row.appendChild(copyBtn); - row.appendChild(revokeBtn); - container.appendChild(row); + container.appendChild(revokeBtn); }); // Generate new token button diff --git a/lib/Controller/WebhookController.php b/lib/Controller/WebhookController.php index 2ca508e..cccc55b 100644 --- a/lib/Controller/WebhookController.php +++ b/lib/Controller/WebhookController.php @@ -44,7 +44,7 @@ class WebhookController extends Controller { /** * Receive Discord webhook payload for a room. * - * URL: POST /apps/ncdiscordhook/bot-webhook/{roomToken}/{token} + * URL: POST /apps/ncdiscordhook/discord-webhook/{roomToken}/{token} */ #[PublicPage] #[NoCSRFRequired] @@ -153,6 +153,143 @@ class WebhookController extends Controller { ); } + /** + * Receive Apprise webhook payload for a room. + * + * URL: POST /apps/ncdiscordhook/apprise-webhook/{roomToken}/{token} + * Also handles Apprise's notify URL format: /apprise-webhook/{roomToken}/notify/{token} + * + * Apprise sends JSON like: + * { + * "version": 0, + * "type": "info|success|warning|error", + * "title": "Title", + * "body": "Message body", + * "attachments": [{"path": "file:///path/to/file", "name": "file.png"}] + * } + */ + #[PublicPage] + #[NoCSRFRequired] + public function receiveApprise(string $roomToken, string $token): DataResponse { + // Validate auth token + if (!$this->talkService->validateAuthToken($roomToken, $token)) { + $this->logger->warning('NCdiscordhook: invalid auth token for room', [ + 'app' => 'ncdiscordhook', + 'room_token' => $roomToken, + ]); + return new DataResponse( + ['error' => 'Unauthorized'], + Http::STATUS_UNAUTHORIZED, + ['X-Webhook-Status' => 'unauthorized'], + ); + } + + // Parse payload — Apprise API sends {"version": 0, "notifications": [...]} + // while direct webhook may send form-encoded or flat JSON + $body = file_get_contents('php://input'); + $contentType = $_SERVER['CONTENT_TYPE'] ?? ''; + + // Try JSON first + $data = @json_decode($body, true); + $jsonOk = json_last_error() === JSON_ERROR_NONE && is_array($data); + + if (!$jsonOk) { + // Check if content-type indicates multipart form data + if (stripos($contentType, 'multipart/form-data') !== false) { + // PHP auto-parses multipart into $_POST + if (!empty($_POST)) { + $data = $_POST; + } + } elseif (!empty($_POST)) { + // Some content-types still get auto-parsed + $data = $_POST; + } else { + // Fallback: try form-encoded + parse_str($body, $parsed); + if (!empty($parsed)) { + $data = $parsed; + } + } + } + + if (empty($data) || !is_array($data)) { + $this->logger->warning('NCdiscordhook: invalid payload from apprise webhook', [ + 'app' => 'ncdiscordhook', + 'room_token' => $roomToken, + 'content_type' => $contentType, + 'body_length' => strlen($body), + 'body_preview' => substr($body, 0, 1000), + 'json_error' => $jsonOk ? null : json_last_error_msg(), + 'post_empty' => empty($_POST) ? true : null, + ]); + return new DataResponse( + ['error' => 'Invalid payload'], + Http::STATUS_BAD_REQUEST, + ['X-Webhook-Status' => 'bad_request'], + ); + } + + // Apprise API wraps notifications in a "notifications" array + if (isset($data['notifications']) && is_array($data['notifications']) && !empty($data['notifications'])) { + $data = $data['notifications'][0]; + } + + // Map apprise format to our internal payload format + $mapped = $this->talkService->mapApprisePayload($data, $roomToken); + + // Allow empty message for image-only notifications (type=image) + if ((empty($mapped['message']) || $mapped['message'] === '') && empty($mapped['richObjects'])) { + return new DataResponse( + ['error' => 'No message content'], + Http::STATUS_BAD_REQUEST, + ['X-Webhook-Status' => 'no_content'], + ); + } + + $senderName = $mapped['senderName'] ?? $this->talkService->getSenderNameDefault(); + $richObjects = $mapped['richObjects'] ?? []; + + // Post to Talk via Chat API + $success = $this->talkService->postToRoom($roomToken, $mapped['message'], $senderName, $richObjects); + + if ($success) { + $this->logger->info('NCdiscordhook: apprise webhook processed successfully', [ + 'app' => 'ncdiscordhook', + 'room_token' => $roomToken, + ]); + return new DataResponse( + ['status' => 'ok'], + Http::STATUS_CREATED, + ['X-Webhook-Status' => 'ok'], + ); + } + + $this->logger->error('NCdiscordhook: failed to post apprise message to Talk', [ + 'app' => 'ncdiscordhook', + 'room_token' => $roomToken, + ]); + return new DataResponse( + ['error' => 'Failed to post to Talk'], + Http::STATUS_INTERNAL_SERVER_ERROR, + ['X-Webhook-Status' => 'error'], + ); + } + + /** + * Receive Apprise webhook payload for a room. + * + * Handles Apprise's notify URL format: /apprise-webhook/{roomToken}/notify/{token} + * Apprise's apprise:// URL scheme inserts 'notify' in the path. + * Delegates to receiveApprise() for processing. + * + * URL: POST /apps/ncdiscordhook/apprise-webhook/{roomToken}/notify/{token} + */ + #[PublicPage] + #[NoCSRFRequired] + public function receiveAppriseNotify(string $roomToken, string $token): DataResponse { + return $this->receiveApprise($roomToken, $token); + } + /** * Save bot password from the settings UI. * @@ -216,7 +353,7 @@ class WebhookController extends Controller { * * URL: GET /apps/ncdiscordhook/debug * Query params: - * - webhook_url: Full webhook URL to diagnose (e.g. /apps/ncdiscordhook/bot-webhook/{room}/{token}) + * - webhook_url: Full webhook URL to diagnose (e.g. /apps/ncdiscordhook/discord-webhook/{room}/{token}) * - test_post=1: Actually POST a test message to the room */ #[PublicPage] @@ -372,8 +509,8 @@ class WebhookController extends Controller { $path = $parsed['path'] ?? ''; $segments = explode('/', trim($path, '/')); - // Expected: apps/ncdiscordhook/bot-webhook/{roomToken}/{token} - if (count($segments) >= 5 && $segments[0] === 'apps' && $segments[1] === 'ncdiscordhook' && $segments[2] === 'bot-webhook') { + // Expected: apps/ncdiscordhook/discord-webhook/{roomToken}/{token} + if (count($segments) >= 5 && $segments[0] === 'apps' && $segments[1] === 'ncdiscordhook' && $segments[2] === 'discord-webhook') { $roomToken = $segments[3]; $token = $segments[4]; $result['room_token'] = $roomToken; @@ -525,7 +662,7 @@ class WebhookController extends Controller { } else { $result['url_valid'] = false; - $result['parse_error'] = 'URL does not match expected pattern: /apps/ncdiscordhook/bot-webhook/{roomToken}/{token}'; + $result['parse_error'] = 'URL does not match expected pattern: /apps/ncdiscordhook/discord-webhook/{roomToken}/{token}'; } return $result; @@ -542,7 +679,7 @@ class WebhookController extends Controller { $path = $parsed['path'] ?? ''; $segments = explode('/', trim($path, '/')); - if (!(count($segments) >= 5 && $segments[0] === 'apps' && $segments[1] === 'ncdiscordhook' && $segments[2] === 'bot-webhook')) { + if (!(count($segments) >= 5 && $segments[0] === 'apps' && $segments[1] === 'ncdiscordhook' && $segments[2] === 'discord-webhook')) { return ['error' => 'Invalid webhook URL format']; } diff --git a/lib/Service/TalkService.php b/lib/Service/TalkService.php index 75a9e99..12423e0 100644 --- a/lib/Service/TalkService.php +++ b/lib/Service/TalkService.php @@ -566,6 +566,144 @@ class TalkService { return $this->config->getAppValue(self::APP_ID, 'sender_name', 'Webhook Bot'); } + /** + * Map Apprise JSON payload to our internal format. + * + * Apprise sends: { version, type, title, body, attachments } + * Returns: { message, senderName, richObjects } + */ + public function mapApprisePayload(array $data, string $roomToken = ''): array { + $parts = []; + + // Title (if present) + if (!empty($data['title'])) { + $parts[] = '**' . $data['title'] . '**'; + } + + // Body + if (!empty($data['body'])) { + $parts[] = $data['body']; + } + + // For image-type notifications, use the attachment URL as the message + if (!empty($data['type']) && $data['type'] === 'image') { + // Apprise sends image notifications with type=image and attachment (string URL) + $imageUrls = []; + if (!empty($data['attachment']) && is_string($data['attachment'])) { + $imageUrls[] = $data['attachment']; + } + if (!empty($data['attachments']) && is_array($data['attachments'])) { + foreach ($data['attachments'] as $a) { + if (is_string($a)) { + $imageUrls[] = $a; + } elseif (is_array($a) && !empty($a['url'])) { + $imageUrls[] = $a['url']; + } + } + } + if (!empty($imageUrls)) { + // Use title as message if body is empty + if (empty($data['body'])) { + $message = !empty($data['title']) ? '**' . $data['title'] . '**' : ''; + } else { + $message = implode("\n\n", $parts); + } + $senderName = $this->config->getAppValue(self::APP_ID, 'sender_name', 'Webhook Bot'); + $richObjects = []; + foreach ($imageUrls as $imageUrl) { + $imageData = $this->downloadImage($imageUrl); + if ($imageData === null) { + continue; + } + $fileName = basename(parse_url($imageUrl, PHP_URL_PATH)) ?: 'attachment'; + $uploadPath = $this->uploadImage($roomToken, $fileName, $imageData['data'], $imageData['mimeType']); + if ($uploadPath !== null) { + $richObj = $this->buildRichObject($uploadPath, $imageData['mimeType'], $roomToken); + if ($richObj !== null) { + $richObjects[] = $richObj; + } + } + } + return [ + 'message' => $message, + 'senderName' => $senderName, + 'richObjects' => $richObjects, + ]; + } + } + + // Type prefix for context (e.g. "[Warning]") + if (!empty($data['type'])) { + $typeLabels = [ + 'info' => '[Info]', + 'success' => '[Success]', + 'warning' => '[Warning]', + 'error' => '[Error]', + ]; + if (isset($typeLabels[$data['type']])) { + array_unshift($parts, $typeLabels[$data['type']]); + } + } + + $message = implode("\n\n", $parts); + + // Sender name: use app name if available, otherwise default + $senderName = $this->config->getAppValue(self::APP_ID, 'sender_name', 'Webhook Bot'); + + // Handle attachments: download files and upload to Talk + $richObjects = []; + if (!empty($data['attachments']) && is_array($data['attachments'])) { + foreach ($data['attachments'] as $attachment) { + if (!is_array($attachment)) { + continue; + } + + $filePath = $attachment['path'] ?? ''; + if (str_starts_with($filePath, 'file://')) { + $localPath = substr($filePath, 7); // Strip 'file://' prefix + if (is_file($localPath) && is_readable($localPath)) { + $fileData = file_get_contents($localPath); + if ($fileData === false) { + continue; + } + + $fileName = $attachment['name'] ?? basename($localPath); + $mimeType = $attachment['mimeType'] ?? mime_content_type($localPath); + + $uploadPath = $this->uploadImage($roomToken, $fileName, $fileData, $mimeType); + if ($uploadPath !== null) { + $richObj = $this->buildRichObject($uploadPath, $mimeType, $roomToken); + if ($richObj !== null) { + $richObjects[] = $richObj; + } + } + } + } elseif (!empty($attachment['url'] ?? '')) { + // Remote URL attachment + $imageData = $this->downloadImage($attachment['url']); + if ($imageData === null) { + continue; + } + + $fileName = $attachment['name'] ?? 'attachment'; + $uploadPath = $this->uploadImage($roomToken, $fileName, $imageData['data'], $imageData['mimeType']); + if ($uploadPath !== null) { + $richObj = $this->buildRichObject($uploadPath, $imageData['mimeType'], $roomToken); + if ($richObj !== null) { + $richObjects[] = $richObj; + } + } + } + } + } + + return [ + 'message' => $message, + 'senderName' => $senderName, + 'richObjects' => $richObjects, + ]; + } + /** * Set sender name default. */