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.
This commit is contained in:
+16
-6
@@ -81,14 +81,12 @@ php occ app:enable ncdiscordhook
|
|||||||
8. For each room, click **+ Generate Auth Token** to create a webhook URL
|
8. For each room, click **+ Generate Auth Token** to create a webhook URL
|
||||||
9. Click **Save Configuration**
|
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**
|
1. For Discord: go to **Channel Settings → Integrations → Webhooks**, create a new webhook, and set the Webhook URL to:
|
||||||
2. Create a new webhook (or edit an existing one)
|
|
||||||
3. Set the Webhook URL to:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
https://your-nextcloud-server/apps/ncdiscordhook/bot-webhook/<room-token>/<auth-token>
|
https://your-nextcloud-server/apps/ncdiscordhook/discord-webhook/<room-token>/<auth-token>
|
||||||
```
|
```
|
||||||
|
|
||||||
Replace `<room-token>` and `<auth-token>` with the values shown in the Nextcloud admin settings for the selected room.
|
Replace `<room-token>` and `<auth-token>` with the values shown in the Nextcloud admin settings for the selected room.
|
||||||
@@ -105,9 +103,21 @@ Test with curl:
|
|||||||
curl -X POST \
|
curl -X POST \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"content":"Test message from NCdiscordhook","username":"CI Bot"}' \
|
-d '{"content":"Test message from NCdiscordhook","username":"CI Bot"}' \
|
||||||
https://your-nextcloud-server/apps/ncdiscordhook/bot-webhook/<room-token>/<auth-token>
|
https://your-nextcloud-server/apps/ncdiscordhook/discord-webhook/<room-token>/<auth-token>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 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/<room-token>/notify/<auth-token>
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
## Troubleshooting
|
||||||
|
|
||||||
- **"talk-bot user not found"** — The `talk-bot` user doesn't exist. Re-run Step 1.
|
- **"talk-bot user not found"** — The `talk-bot` user doesn't exist. Re-run Step 1.
|
||||||
|
|||||||
@@ -36,10 +36,10 @@ Go to **Settings → Admin → NCdiscordhook**:
|
|||||||
|
|
||||||
### 5. Point your services at the webhook URLs
|
### 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/<room-token>/<auth-token>
|
https://your-server.com/apps/ncdiscordhook/discord-webhook/<room-token>/<auth-token>
|
||||||
```
|
```
|
||||||
|
|
||||||
Copy the auth token from the app settings for each room.
|
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:
|
Each room gets its own webhook URL with a unique auth token:
|
||||||
|
|
||||||
```
|
```
|
||||||
/bot-webhook/<room-token>/<auth-token>
|
/discord-webhook/<room-token>/<auth-token>
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Room token** — identifies which Talk room to post to (from `occ talk:room:list`)
|
- **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.
|
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/<room-token>/notify/<auth-token>
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
## Image management
|
||||||
|
|
||||||
- Images are uploaded to the bot user's files at `/NCdiscordhook-images/<room-token>/`
|
- Images are uploaded to the bot user's files at `/NCdiscordhook-images/<room-token>/`
|
||||||
|
|||||||
+11
-1
@@ -4,7 +4,7 @@ return [
|
|||||||
'routes' => [
|
'routes' => [
|
||||||
[
|
[
|
||||||
'name' => 'webhook#receive',
|
'name' => 'webhook#receive',
|
||||||
'url' => '/bot-webhook/{roomToken}/{token}',
|
'url' => '/discord-webhook/{roomToken}/{token}',
|
||||||
'verb' => 'POST',
|
'verb' => 'POST',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
@@ -28,5 +28,15 @@ return [
|
|||||||
'verb' => 'GET',
|
'verb' => 'GET',
|
||||||
'type' => 'noCsrf',
|
'type' => 'noCsrf',
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'name' => 'webhook#receiveApprise',
|
||||||
|
'url' => '/apprise-webhook/{roomToken}/{token}',
|
||||||
|
'verb' => 'POST',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'webhook#receiveAppriseNotify',
|
||||||
|
'url' => '/apprise-webhook/{roomToken}/notify/{token}',
|
||||||
|
'verb' => 'POST',
|
||||||
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
+75
-36
@@ -132,9 +132,12 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
|
|
||||||
const tokens = authTokensState[roomToken] || [];
|
const tokens = authTokensState[roomToken] || [];
|
||||||
// Build the full webhook URL with server origin
|
|
||||||
var webhookPath = OC.generateUrl('/apps/' + APP_ID + '/bot-webhook/') + roomToken + '/{token}';
|
// Build both webhook URLs with server origin
|
||||||
var webhookUrl = window.location.protocol + '//' + window.location.host + webhookPath;
|
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) {
|
if (tokens.length === 0) {
|
||||||
// Show a hint that they can generate a token
|
// Show a hint that they can generate a token
|
||||||
@@ -147,50 +150,86 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tokens.forEach(function (token) {
|
tokens.forEach(function (token) {
|
||||||
// Display the full webhook URL (encode token for URL safety)
|
var discordFullUrl = discordUrl.replace('{token}', encodeURIComponent(token));
|
||||||
var fullUrl = webhookUrl.replace('{token}', encodeURIComponent(token));
|
var appriseFullUrl = appriseUrl.replace('{token}', encodeURIComponent(token));
|
||||||
|
|
||||||
var row = document.createElement('div');
|
// Discord webhook URL row
|
||||||
row.className = 'nc-token-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');
|
var rowDiscord = document.createElement('div');
|
||||||
urlInput.type = 'text';
|
rowDiscord.className = 'nc-token-row';
|
||||||
urlInput.value = fullUrl;
|
|
||||||
urlInput.readOnly = true;
|
|
||||||
urlInput.className = 'nc-token-url-input';
|
|
||||||
urlInput.title = fullUrl;
|
|
||||||
|
|
||||||
var copyBtn = document.createElement('button');
|
var urlInputD = document.createElement('input');
|
||||||
copyBtn.type = 'button';
|
urlInputD.type = 'text';
|
||||||
copyBtn.className = 'nc-token-copy';
|
urlInputD.value = discordFullUrl;
|
||||||
copyBtn.textContent = 'Copy';
|
urlInputD.readOnly = true;
|
||||||
copyBtn.addEventListener('click', function () {
|
urlInputD.className = 'nc-token-url-input';
|
||||||
navigator.clipboard.writeText(fullUrl).then(function () {
|
urlInputD.title = discordFullUrl;
|
||||||
copyBtn.textContent = 'Copied!';
|
|
||||||
setTimeout(function () { copyBtn.textContent = 'Copy'; }, 2000);
|
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');
|
var revokeBtn = document.createElement('button');
|
||||||
revokeBtn.type = 'button';
|
revokeBtn.type = 'button';
|
||||||
revokeBtn.className = 'nc-token-revoke';
|
revokeBtn.className = 'nc-token-revoke';
|
||||||
revokeBtn.textContent = 'Revoke';
|
revokeBtn.textContent = 'Revoke All';
|
||||||
revokeBtn.addEventListener('click', function () {
|
revokeBtn.addEventListener('click', function () {
|
||||||
if (!confirm('Revoke this auth token?')) return;
|
if (!confirm('Revoke all auth tokens for this room?')) return;
|
||||||
tokens.splice(tokens.indexOf(token), 1);
|
tokens.length = 0;
|
||||||
if (tokens.length === 0) {
|
delete authTokensState[roomToken];
|
||||||
delete authTokensState[roomToken];
|
renderTokens(roomToken, container);
|
||||||
container.style.display = 'none';
|
|
||||||
} else {
|
|
||||||
authTokensState[roomToken] = tokens;
|
|
||||||
renderTokens(roomToken, container);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
container.appendChild(revokeBtn);
|
||||||
row.appendChild(urlInput);
|
|
||||||
row.appendChild(copyBtn);
|
|
||||||
row.appendChild(revokeBtn);
|
|
||||||
container.appendChild(row);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Generate new token button
|
// Generate new token button
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ class WebhookController extends Controller {
|
|||||||
/**
|
/**
|
||||||
* Receive Discord webhook payload for a room.
|
* Receive Discord webhook payload for a room.
|
||||||
*
|
*
|
||||||
* URL: POST /apps/ncdiscordhook/bot-webhook/{roomToken}/{token}
|
* URL: POST /apps/ncdiscordhook/discord-webhook/{roomToken}/{token}
|
||||||
*/
|
*/
|
||||||
#[PublicPage]
|
#[PublicPage]
|
||||||
#[NoCSRFRequired]
|
#[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.
|
* Save bot password from the settings UI.
|
||||||
*
|
*
|
||||||
@@ -216,7 +353,7 @@ class WebhookController extends Controller {
|
|||||||
*
|
*
|
||||||
* URL: GET /apps/ncdiscordhook/debug
|
* URL: GET /apps/ncdiscordhook/debug
|
||||||
* Query params:
|
* 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
|
* - test_post=1: Actually POST a test message to the room
|
||||||
*/
|
*/
|
||||||
#[PublicPage]
|
#[PublicPage]
|
||||||
@@ -372,8 +509,8 @@ class WebhookController extends Controller {
|
|||||||
$path = $parsed['path'] ?? '';
|
$path = $parsed['path'] ?? '';
|
||||||
$segments = explode('/', trim($path, '/'));
|
$segments = explode('/', trim($path, '/'));
|
||||||
|
|
||||||
// Expected: apps/ncdiscordhook/bot-webhook/{roomToken}/{token}
|
// Expected: apps/ncdiscordhook/discord-webhook/{roomToken}/{token}
|
||||||
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') {
|
||||||
$roomToken = $segments[3];
|
$roomToken = $segments[3];
|
||||||
$token = $segments[4];
|
$token = $segments[4];
|
||||||
$result['room_token'] = $roomToken;
|
$result['room_token'] = $roomToken;
|
||||||
@@ -525,7 +662,7 @@ class WebhookController extends Controller {
|
|||||||
|
|
||||||
} else {
|
} else {
|
||||||
$result['url_valid'] = false;
|
$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;
|
return $result;
|
||||||
@@ -542,7 +679,7 @@ class WebhookController extends Controller {
|
|||||||
$path = $parsed['path'] ?? '';
|
$path = $parsed['path'] ?? '';
|
||||||
$segments = explode('/', trim($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'];
|
return ['error' => 'Invalid webhook URL format'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -566,6 +566,144 @@ class TalkService {
|
|||||||
return $this->config->getAppValue(self::APP_ID, 'sender_name', 'Webhook Bot');
|
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.
|
* Set sender name default.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user