First commit
This commit is contained in:
+116
@@ -0,0 +1,116 @@
|
||||
# NCdiscordhook — Installation Guide
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Nextcloud 28+ with the **Talk** app enabled
|
||||
- PHP 8.1+
|
||||
- Access to the Nextcloud server (SSH or direct file access)
|
||||
|
||||
## Step 1: Enable the `talk-bot` user
|
||||
|
||||
The app posts messages as a dedicated bot user. You must create this user and generate an app password.
|
||||
|
||||
### Create the bot user (via `occ`):
|
||||
|
||||
```bash
|
||||
cd /path/to/nextcloud
|
||||
php occ user:add --password-from-env talk-bot
|
||||
# You'll be prompted to set a password interactively, or:
|
||||
export OC_PASS="your-bot-password-here"
|
||||
php occ user:add --password-from-env talk-bot
|
||||
```
|
||||
|
||||
### Generate an app password for the bot:
|
||||
|
||||
1. Log in to Nextcloud as **admin**
|
||||
2. Go to **Settings → talk-bot → Devices & sessions**
|
||||
3. Click **Add device** and give it a name (e.g., "NCdiscordhook")
|
||||
4. Copy the generated device password — you'll need this in Step 3
|
||||
|
||||
## Step 2: Install the app
|
||||
|
||||
### Option A: From the web UI (recommended)
|
||||
|
||||
1. ZIP only the `ncdiscordhook` directory (not the parent folder):
|
||||
|
||||
```bash
|
||||
cd /path/to/ncdiscordhook
|
||||
zip -r ../ncdiscdhook.zip .
|
||||
```
|
||||
|
||||
2. Upload via web UI:
|
||||
- Go to **Apps → Apps management** (or **Administration → Apps**)
|
||||
- Click **Upload app** (or **Download and enable** → upload the ZIP)
|
||||
- Select `ncdiscdhook.zip`
|
||||
- The app will install and enable automatically
|
||||
|
||||
### Option B: From the command line
|
||||
|
||||
```bash
|
||||
cd /path/to/nextcloud/apps
|
||||
git clone https://github.com/your-org/ncdiscordhook.git
|
||||
# or copy the directory manually:
|
||||
# cp -r /path/to/ncdiscordhook /path/to/nextcloud/apps/ncdiscordhook
|
||||
```
|
||||
|
||||
Enable the app:
|
||||
|
||||
```bash
|
||||
cd /path/to/nextcloud
|
||||
php occ app:enable ncdiscordhook
|
||||
```
|
||||
|
||||
## Step 3: Configure the app
|
||||
|
||||
1. Log in to Nextcloud as **admin**
|
||||
2. Go to **Settings → Admin → NCdiscordhook**
|
||||
3. **Bot App Password** — Paste the device password you generated in Step 1
|
||||
4. **Image Retention** — Set how many days to keep uploaded images (default: 90)
|
||||
5. **Room Management** — Click **Fetch Rooms** to list available Talk rooms
|
||||
6. Check the rooms you want to accept webhooks for
|
||||
7. For each room, click **+ Generate Auth Token** to create a webhook URL
|
||||
8. Click **Save Configuration**
|
||||
|
||||
## Step 4: Set up the Discord webhook
|
||||
|
||||
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:
|
||||
|
||||
```
|
||||
https://your-nextcloud-server/apps/ncdiscordhook/webhook/<room-token>/<auth-token>
|
||||
```
|
||||
|
||||
Replace `<room-token>` and `<auth-token>` with the values shown in the Nextcloud admin settings for the selected room.
|
||||
|
||||
4. Save the webhook in Discord
|
||||
|
||||
## Step 5: Verify
|
||||
|
||||
Send a test message through the Discord webhook. You should see it appear in the corresponding Nextcloud Talk room with the configured sender name.
|
||||
|
||||
Test with curl:
|
||||
|
||||
```bash
|
||||
curl -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"content":"Test message from NCdiscordhook","username":"CI Bot"}' \
|
||||
https://your-nextcloud-server/apps/ncdiscordhook/webhook/<room-token>/<auth-token>
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"talk-bot user not found"** — The `talk-bot` user doesn't exist. Re-run Step 1.
|
||||
- **"Bot password not configured"** — You haven't entered the bot password in the admin settings, or it was cleared. Re-enter it in Step 3.
|
||||
- **Messages not appearing** — Check the Nextcloud log (`data/nextcloud.log` or Settings → Admin → Logging) for errors from the `ncdiscordhook` app.
|
||||
- **Image upload fails** — Verify the bot user has file storage quota and the `NCdiscordhook-images` folder can be created.
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Each room has its own auth token — treat them like passwords
|
||||
- The webhook endpoint is public (no auth required) but validates the auth token from the URL path
|
||||
- Admin settings endpoints (`/save-config`, `/rooms`) require admin login
|
||||
- Images are stored in the bot user's files and purged after the retention period
|
||||
- **Known limitations (to be fixed in a future release):**
|
||||
- Auth tokens generated from the settings UI use client-side generation; for higher security, regenerate them via the server API
|
||||
- The webhook endpoint has no rate limiting — consider placing it behind a reverse proxy rate limiter if exposing to untrusted sources
|
||||
@@ -0,0 +1,126 @@
|
||||
# NCdiscordhook
|
||||
|
||||
Discord webhook bridge for Nextcloud Talk with image attachment support.
|
||||
|
||||
Accepts Discord webhook-style JSON payloads and posts them into Nextcloud Talk rooms, preserving images and embed formatting.
|
||||
|
||||
## Installation
|
||||
|
||||
### 1. Deploy the app
|
||||
|
||||
```bash
|
||||
cd /path/to/nextcloud/apps
|
||||
cp -r /path/to/ncdiscordhook .
|
||||
php occ app:enable ncdiscordhook
|
||||
```
|
||||
|
||||
### 2. Create the bot user
|
||||
|
||||
```bash
|
||||
php occ user:add --password-from-env --display-name="Webhook Bot" talk-bot
|
||||
```
|
||||
|
||||
### 3. Generate an app password for the bot
|
||||
|
||||
1. Log in as `talk-bot` (or set a password as admin)
|
||||
2. Go to **Settings → Security → Devices & sessions → Add device**
|
||||
3. Copy the app password
|
||||
|
||||
### 4. Configure the app
|
||||
|
||||
Go to **Settings → Admin → NCdiscordhook**:
|
||||
|
||||
1. **Bot Configuration** — paste the app password from step 3
|
||||
2. **Image Retention** — set how long to keep uploaded images (default: 90 days)
|
||||
3. **Room Management** — click "Fetch Rooms" to see available Talk rooms, select the ones you want, and generate auth tokens
|
||||
|
||||
### 5. Point your services at the webhook URLs
|
||||
|
||||
Each configured room gets a webhook URL:
|
||||
|
||||
```
|
||||
https://your-server.com/apps/ncdiscordhook/webhook/<room-token>/<auth-token>
|
||||
```
|
||||
|
||||
Copy the auth token from the app settings for each room.
|
||||
|
||||
## API
|
||||
|
||||
### Accepts (Discord webhook format)
|
||||
|
||||
```json
|
||||
{
|
||||
"content": "Build #1234 passed",
|
||||
"embeds": [
|
||||
{
|
||||
"title": "Deployment",
|
||||
"description": "Successfully deployed to production",
|
||||
"color": 3066993,
|
||||
"image": { "url": "https://example.com/screenshot.png" },
|
||||
"fields": [
|
||||
{ "name": "Duration", "value": "2m 34s" },
|
||||
{ "name": "Environment", "value": "Production" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"username": "CI/CD",
|
||||
"avatar_url": "https://example.com/icon.png"
|
||||
}
|
||||
```
|
||||
|
||||
### Sends to Nextcloud Talk
|
||||
|
||||
- Formatted text message (content + embeds + fields)
|
||||
- Each image from `embeds[].image` or `embeds[].thumbnail` uploaded and shared inline
|
||||
- `username` shown as the sender display name
|
||||
|
||||
### Payload mapping
|
||||
|
||||
| Discord field | Nextcloud action |
|
||||
|---|---|
|
||||
| `content` | Sent as text message |
|
||||
| `embeds[].title` | Included as bold line |
|
||||
| `embeds[].description` | Included in message body |
|
||||
| `embeds[].image.url` | Downloaded, uploaded to NC, shared inline |
|
||||
| `embeds[].thumbnail.url` | Downloaded, uploaded to NC, shared inline |
|
||||
| `embeds[].fields` | Formatted as `name: value` lines |
|
||||
| `username` | Sender display name |
|
||||
| `avatar_url` | Ignored (NCTalk doesn't support per-message avatars) |
|
||||
|
||||
## Room routing
|
||||
|
||||
Each room gets its own webhook URL with a unique auth token:
|
||||
|
||||
```
|
||||
/webhook/<room-token>/<auth-token>
|
||||
```
|
||||
|
||||
- **Room token** — identifies which Talk room to post to (from `occ talk:room:list`)
|
||||
- **Auth token** — secret key for this webhook endpoint (generated in app settings)
|
||||
|
||||
Multiple auth tokens can be created per room — useful if you need to rotate keys or share the webhook across multiple services.
|
||||
|
||||
## Image management
|
||||
|
||||
- Images are uploaded to the bot user's files at `/NCdiscordhook-images/<room-token>/`
|
||||
- Cron job purges images older than the configured retention period (default: 90 days)
|
||||
- Images are stored in the bot user's storage — they count toward the bot user's quota
|
||||
|
||||
## Security
|
||||
|
||||
- Auth token in the URL is the primary auth mechanism — keep it secret
|
||||
- Bot password is encrypted at rest using Nextcloud's crypto
|
||||
- Image download uses Nextcloud's HTTP client with local address blocking
|
||||
- Rate limiting should be handled at the web server or reverse proxy level
|
||||
|
||||
## Logging
|
||||
|
||||
Responses include a `X-Webhook-Status` header:
|
||||
|
||||
| Header value | Meaning |
|
||||
|---|---|
|
||||
| `ok` | Forwarded successfully |
|
||||
| `unauthorized` | Invalid auth token |
|
||||
| `bad_request` | Invalid JSON payload |
|
||||
| `no_content` | No message content in payload |
|
||||
| `error` | Check server logs for details |
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
return [
|
||||
'jobs' => [
|
||||
\OCA\NCdiscordhook\Cron\ImageCleanup::class,
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0"?>
|
||||
<info xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="https://apps.nextcloud.com/schema/apps/info.xsd">
|
||||
<id>ncdiscordhook</id>
|
||||
<name>NCdiscordhook</name>
|
||||
<summary>Discord webhook bridge for Nextcloud Talk with image support</summary>
|
||||
<description>Accepts Discord webhook-style JSON payloads and posts them into Nextcloud Talk rooms, preserving image attachments. Each room gets its own webhook URL with an auth token for security.</description>
|
||||
<version>1.0.0</version>
|
||||
<licence>agpl</licence>
|
||||
<author>Kyle</author>
|
||||
<namespace>NCdiscordhook</namespace>
|
||||
|
||||
<dependencies>
|
||||
<nextcloud min-version="28" max-version="33"/>
|
||||
</dependencies>
|
||||
|
||||
<background-jobs>
|
||||
OCA\NCdiscordhook\Cron\ImageCleanup
|
||||
</background-jobs>
|
||||
|
||||
<settings>
|
||||
<admin>OCA\NCdiscordhook\Settings\Admin</admin>
|
||||
</settings>
|
||||
|
||||
<category>integration</category>
|
||||
|
||||
<bugs-url>https://github.com/your-org/ncdiscordhook/issues</bugs-url>
|
||||
<source>https://github.com/your-org/ncdiscordhook</source>
|
||||
</info>
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
return [
|
||||
'routes' => [
|
||||
[
|
||||
'name' => 'webhook#receive',
|
||||
'url' => '/webhook/{roomToken}/{authToken}',
|
||||
'verb' => 'POST',
|
||||
],
|
||||
[
|
||||
'name' => 'webhook#saveConfig',
|
||||
'url' => '/save-config',
|
||||
'verb' => 'POST',
|
||||
],
|
||||
[
|
||||
'name' => 'webhook#getRooms',
|
||||
'url' => '/rooms',
|
||||
'verb' => 'GET',
|
||||
],
|
||||
],
|
||||
];
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 20 KiB |
+103
@@ -0,0 +1,103 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
version="1.2"
|
||||
viewBox="0 0 1396 1070"
|
||||
width="1396"
|
||||
height="1070"
|
||||
id="svg2"
|
||||
sodipodi:docname="app.svg"
|
||||
xml:space="preserve"
|
||||
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"><sodipodi:namedview
|
||||
id="namedview2"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:zoom="1.0981308"
|
||||
inkscape:cx="697.54896"
|
||||
inkscape:cy="535.00002"
|
||||
inkscape:window-width="3440"
|
||||
inkscape:window-height="1377"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="1440"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg2" /><title
|
||||
id="title1">Discord_logo-svg</title><defs
|
||||
id="defs2"><clipPath
|
||||
clipPathUnits="userSpaceOnUse"
|
||||
id="cp1"><path
|
||||
d="m0 0h5586.5v1069.8h-5586.5z"
|
||||
id="path1" /></clipPath><clipPath
|
||||
clipPathUnits="userSpaceOnUse"
|
||||
id="cp2"><path
|
||||
d="m0 0h5586.5v1069.8h-5586.5z"
|
||||
id="path2" /></clipPath><clipPath
|
||||
clipPathUnits="userSpaceOnUse"
|
||||
id="clipPath8812"><circle
|
||||
id="circle8814"
|
||||
cx="95.669289"
|
||||
cy="95.669296"
|
||||
r="79.724197"
|
||||
style="fill:#00080d;fill-opacity:1;stroke-width:1" /></clipPath><inkscape:path-effect
|
||||
effect="fill_between_many"
|
||||
method="originald"
|
||||
linkedpaths="#path1052,0,1|"
|
||||
id="path-effect29" /><inkscape:path-effect
|
||||
effect="fill_between_many"
|
||||
method="originald"
|
||||
linkedpaths="#path1052,0,1|"
|
||||
id="path-effect30" /><inkscape:path-effect
|
||||
effect="fill_between_many"
|
||||
method="originald"
|
||||
linkedpaths="#g866,0,1|"
|
||||
id="path-effect31" /></defs><style
|
||||
id="style2">
|
||||
.s0 { fill: #5865f2 }
|
||||
</style><g
|
||||
id="layer1"><g
|
||||
id="g866"><g
|
||||
id="Clip-Path: g835"
|
||||
clip-path="url(#cp1)"><g
|
||||
id="g835"><g
|
||||
id="Clip-Path: g833"
|
||||
clip-path="url(#cp2)"><g
|
||||
id="g833"><path
|
||||
id="path815"
|
||||
fill-rule="evenodd"
|
||||
class="s0"
|
||||
d="m1389.7 890.5c-120.8 89.5-238.1 143.8-353.3 179.3-28.6-38.7-53.8-80-75.7-123.3 41.6-15.7 81.6-35 119.4-57.6-9.9-7.3-19.7-14.9-29.2-22.8-226.9 106.3-476.5 106.3-706.1 0-9.4 7.9-19.2 15.5-29.2 22.8 37.7 22.5 77.5 41.8 119.1 57.4-21.8 43.5-47.2 84.6-75.6 123.4-115.2-35.5-232.3-89.8-353.2-179.2-24.7-262.1 24.7-528 207-800.7 90.3-41.9 187-72.5 288.1-89.8 12.5 22.2 27.3 52.1 37.3 75.8q158.1-24 319.1 0c10-23.7 24.5-53.6 36.9-75.8 101 17.3 197.6 47.7 288 89.6 157.9 233.6 236.4 497 207.4 800.9zm-798.2-302.6c0-78.2-56.1-141.4-125.5-141.4-69.4 0-125.5 63.2-125.5 141.4 0 78.2 56.1 141.4 125.5 141.4 69.4 0 125.5-63.2 125.5-141.4zm463.7 0c0-78.2-56.1-141.4-125.5-141.4-69.4 0-125.5 63.2-125.5 141.4 0 78.2 56.1 141.4 125.5 141.4 69.4 0 125.5-63.2 125.5-141.4z"
|
||||
inkscape:label="path815" /></g></g></g></g></g><path
|
||||
inkscape:original-d="M 0,0"
|
||||
inkscape:path-effect="#path-effect31"
|
||||
d="M 0,0"
|
||||
id="path31"
|
||||
style="stroke:#ffffff;stroke-opacity:1;fill:#c4c4c4;fill-opacity:1" /></g><g
|
||||
id="g2"
|
||||
transform="matrix(12.025809,0,0,12.025809,-99.68963,163.39161)"><path
|
||||
id="path29"
|
||||
style="fill:#000000;fill-opacity:1;stroke-width:1.00001;stroke-dasharray:none"
|
||||
d="m 65.292969,8.4921875 c -10.195025,0.3341599 -19.8511,7.1027375 -23.633131,16.5793265 -0.205581,0.626649 -0.4004,1.155323 -0.743661,0.292305 C 37.092277,19.665198 29.777645,16.454 23.006803,17.82915 16.128883,19.088971 10.227733,24.719207 8.8633712,31.611796 7.5932171,37.455075 9.50706,43.890305 13.865187,48.007432 c 4.347094,4.245861 10.953799,6.13821 16.844804,4.469596 4.339966,-1.167425 8.231087,-4.008038 10.582978,-7.848122 3.44571,9.449457 12.600651,16.564094 22.649653,17.388854 8.622251,0.830235 17.547928,-2.836986 23.027121,-9.560726 1.905247,-2.274293 3.431272,-4.867555 4.485335,-7.640628 3.788171,6.049009 11.473202,9.453639 18.484732,7.893675 6.79931,-1.377539 12.56997,-7.020504 13.87074,-13.870237 1.20352,-5.810308 -0.71384,-12.18039 -5.04547,-16.263182 -4.4373,-4.318065 -11.22431,-6.185521 -17.19175,-4.355071 -4.128337,1.198165 -7.814167,3.918483 -10.10458,7.563565 C 87.993317,16.498986 79.001772,9.4879801 69.091346,8.6176818 67.829143,8.4918282 66.561001,8.4562382 65.292969,8.4921875 Z" /><path
|
||||
inkscape:original-d="M 0,0"
|
||||
inkscape:path-effect="#path-effect30"
|
||||
d="m 66.407896,9.375 c -11.805271,0 -21.811217,8.003196 -24.912392,18.846621 -2.695245,-5.751517 -8.535934,-9.780938 -15.263394,-9.780938 -9.25185,0 -16.85711,7.605263 -16.85711,16.857108 0,9.251833 7.60526,16.860567 16.85711,16.860567 6.72746,0 12.568149,-4.031885 15.263395,-9.784412 3.101175,10.84425 13.10712,18.850106 24.912391,18.850106 11.717964,0 21.67289,-7.885111 24.853382,-18.607048 2.745036,5.621934 8.513436,9.541354 15.145342,9.541354 9.25185,0 16.86057,-7.608734 16.86057,-16.860567 0,-9.251845 -7.60872,-16.857108 -16.86057,-16.857108 -6.631906,0 -12.400306,3.916965 -15.145342,9.537891 C 88.080786,17.257475 78.12586,9.375 66.407896,9.375 Z"
|
||||
id="path30"
|
||||
style="fill:#ffffff;fill-opacity:1" /><path
|
||||
inkscape:connector-curvature="0"
|
||||
id="path1052"
|
||||
d="m 66.407896,9.375 c -11.805271,0 -21.811217,8.003196 -24.912392,18.846621 -2.695245,-5.751517 -8.535934,-9.780938 -15.263394,-9.780938 -9.25185,0 -16.85711,7.605263 -16.85711,16.857108 0,9.251833 7.60526,16.860567 16.85711,16.860567 6.72746,0 12.568149,-4.031885 15.263395,-9.784412 3.101175,10.84425 13.10712,18.850106 24.912391,18.850106 11.717964,0 21.67289,-7.885111 24.853382,-18.607048 2.745036,5.621934 8.513436,9.541354 15.145342,9.541354 9.25185,0 16.86057,-7.608734 16.86057,-16.860567 0,-9.251845 -7.60872,-16.857108 -16.86057,-16.857108 -6.631906,0 -12.400306,3.916965 -15.145342,9.537891 C 88.080786,17.257475 78.12586,9.375 66.407896,9.375 Z m 0,9.895518 c 8.911648,0 16.030748,7.115653 16.030748,16.027273 0,8.911605 -7.1191,16.030737 -16.030748,16.030737 -8.911593,0 -16.027247,-7.119132 -16.027247,-16.030737 0,-8.91162 7.115653,-16.027271 16.027247,-16.027273 z M 26.23211,28.336202 c 3.90438,0 6.96505,3.057188 6.96505,6.961589 0,3.904386 -3.06067,6.965049 -6.96505,6.965049 -3.90439,0 -6.96161,-3.060663 -6.96161,-6.965049 0,-3.904401 3.05722,-6.961589 6.96161,-6.961589 z m 80.17451,0 c 3.90442,0 6.96506,3.057188 6.96506,6.961589 0,3.904386 -3.06066,6.965049 -6.96506,6.965049 -3.90436,0 -6.961576,-3.060663 -6.961576,-6.965049 0,-3.904401 3.057226,-6.961589 6.961576,-6.961589 z"
|
||||
style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#0082c9;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:5.56589;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:10;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
|
||||
inkscape:export-filename="Nextcloud Hub logo variants.png"
|
||||
inkscape:export-xdpi="300"
|
||||
inkscape:export-ydpi="300" /></g><metadata
|
||||
id="metadata31"><rdf:RDF><cc:Work
|
||||
rdf:about=""><dc:title>Discord_logo-svg</dc:title></cc:Work></rdf:RDF></metadata></svg>
|
||||
|
After Width: | Height: | Size: 8.1 KiB |
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
namespace OCA\NCdiscordhook\Controller;
|
||||
|
||||
use OCA\NCdiscordhook\Service\TalkService;
|
||||
use OCP\AppFramework\Controller;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
use OCP\AppFramework\Http\JSONResponse;
|
||||
use OCP\AppFramework\Middleware\Security\CSRF\Token;
|
||||
use OCP\AppFramework\Middleware\Security\CSRF\TokenStore;
|
||||
use OCP\AppFramework\Utility\IControllerMethodReflector;
|
||||
use OCP\IRequest;
|
||||
use OCP\IUserSession;
|
||||
|
||||
class WebhookController extends Controller {
|
||||
private TalkService $talkService;
|
||||
|
||||
public function __construct(IRequest $request, TalkService $talkService) {
|
||||
parent::__construct('ncdiscordhook', $request);
|
||||
$this->talkService = $talkService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Receive Discord webhook payload for a room.
|
||||
*
|
||||
* URL: POST /apps/ncdiscordhook/webhook/{roomToken}/{authToken}
|
||||
*/
|
||||
public function receive(string $roomToken, string $authToken): DataResponse {
|
||||
// Validate auth token
|
||||
if (!$this->talkService->validateAuthToken($roomToken, $authToken)) {
|
||||
return new DataResponse(
|
||||
['error' => 'Unauthorized'],
|
||||
Http::STATUS_UNAUTHORIZED,
|
||||
['X-Webhook-Status' => 'unauthorized'],
|
||||
);
|
||||
}
|
||||
|
||||
// Parse Discord JSON payload
|
||||
$body = $this->request->getContent();
|
||||
$data = @json_decode($body, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE || !is_array($data)) {
|
||||
return new DataResponse(
|
||||
['error' => 'Invalid JSON'],
|
||||
Http::STATUS_BAD_REQUEST,
|
||||
['X-Webhook-Status' => 'bad_request'],
|
||||
);
|
||||
}
|
||||
|
||||
// Map to Talk message
|
||||
$message = $this->talkService->mapPayload($data);
|
||||
if ($message === '') {
|
||||
return new DataResponse(
|
||||
['error' => 'No message content'],
|
||||
Http::STATUS_BAD_REQUEST,
|
||||
['X-Webhook-Status' => 'no_content'],
|
||||
);
|
||||
}
|
||||
|
||||
$senderName = $this->talkService->getSenderName($data);
|
||||
|
||||
// Handle images
|
||||
$richObjects = [];
|
||||
if (!empty($data['embeds']) && is_array($data['embeds'])) {
|
||||
foreach ($data['embeds'] as $embed) {
|
||||
if (!is_array($embed)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (['image', 'thumbnail'] as $imageKey) {
|
||||
if (empty($embed[$imageKey]) || !is_array($embed[$imageKey]) || empty($embed[$imageKey]['url'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$imageData = $this->talkService->downloadImage($embed[$imageKey]['url']);
|
||||
if ($imageData === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Derive filename from URL or content type
|
||||
$parsed = parse_url($embed[$imageKey]['url']);
|
||||
$pathParts = explode('/', $parsed['path'] ?? '');
|
||||
$filename = end($pathParts);
|
||||
if (!$filename || strlen($filename) < 2) {
|
||||
$ext = pathinfo($imageData['mimeType'], PATHINFO_EXTENSION) ?: 'png';
|
||||
$filename = 'webhook-image.' . $ext;
|
||||
}
|
||||
|
||||
$filePath = $this->talkService->uploadImage($roomToken, $filename, $imageData['data'], $imageData['mimeType']);
|
||||
if ($filePath !== null) {
|
||||
$richObjects[] = $this->talkService->buildRichObject($filePath, $imageData['mimeType'], $roomToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Post to Talk
|
||||
$success = $this->talkService->postToRoom($roomToken, $message, $senderName, $richObjects);
|
||||
|
||||
if ($success) {
|
||||
return new DataResponse(
|
||||
['status' => 'ok'],
|
||||
Http::STATUS_CREATED,
|
||||
['X-Webhook-Status' => 'ok'],
|
||||
);
|
||||
}
|
||||
|
||||
return new DataResponse(
|
||||
['error' => 'Failed to post to Talk'],
|
||||
Http::STATUS_INTERNAL_SERVER_ERROR,
|
||||
['X-Webhook-Status' => 'error'],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save configuration from the settings UI.
|
||||
*
|
||||
* URL: POST /apps/ncdiscordhook/save-config
|
||||
*/
|
||||
@AdminRequired
|
||||
public function saveConfig(): DataResponse {
|
||||
$body = $this->request->getContent();
|
||||
$config = @json_decode($body, true);
|
||||
if (!is_array($config)) {
|
||||
return new DataResponse(
|
||||
['error' => 'Invalid config'],
|
||||
Http::STATUS_BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
$this->talkService->saveConfig($config);
|
||||
|
||||
return new DataResponse(['status' => 'ok']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available Talk rooms.
|
||||
*
|
||||
* URL: GET /apps/ncdiscordhook/rooms
|
||||
*/
|
||||
@AdminRequired
|
||||
public function getRooms(): DataResponse {
|
||||
$rooms = $this->talkService->getAvailableTalkRooms();
|
||||
$configured = $this->talkService->getRooms();
|
||||
|
||||
// Mark which rooms are already configured
|
||||
$result = [];
|
||||
foreach ($rooms as $room) {
|
||||
$token = $room['token'] ?? $room['roomId'] ?? '';
|
||||
$name = $room['displayName'] ?? $room['name'] ?? '';
|
||||
$result[] = [
|
||||
'token' => $token,
|
||||
'name' => $name,
|
||||
'configured' => isset($configured[$token]),
|
||||
];
|
||||
}
|
||||
|
||||
return new DataResponse($result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace OCA\NCdiscordhook\Cron;
|
||||
|
||||
use OCA\NCdiscordhook\Service\TalkService;
|
||||
use OCP\BackgroundJob\IJob;
|
||||
use OCP\Files\IRootFolder;
|
||||
use OCP\IConfig;
|
||||
use OCP\IUserManager;
|
||||
|
||||
class ImageCleanup implements IJob {
|
||||
private TalkService $talkService;
|
||||
private IRootFolder $rootFolder;
|
||||
private IUserManager $userManager;
|
||||
|
||||
public function __construct(
|
||||
TalkService $talkService,
|
||||
IRootFolder $rootFolder,
|
||||
IUserManager $userManager,
|
||||
) {
|
||||
$this->talkService = $talkService;
|
||||
$this->rootFolder = $rootFolder;
|
||||
$this->userManager = $userManager;
|
||||
}
|
||||
|
||||
public function run($argument = null): void {
|
||||
// Check if bot user exists
|
||||
$bot = $this->userManager->get('talk-bot');
|
||||
if (!$bot) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if images directory exists
|
||||
try {
|
||||
$userFolder = $this->rootFolder->getUserFolder($bot->getUID());
|
||||
$imagesDir = $userFolder->getFolder('NCdiscordhook-images');
|
||||
} catch (\Exception $e) {
|
||||
return;
|
||||
}
|
||||
|
||||
$count = $this->talkService->purgeOldImages();
|
||||
|
||||
if ($count > 0) {
|
||||
\OC::$server->getLogger()->info(
|
||||
'NCdiscordhook: purged ' . $count . ' old image files',
|
||||
['app' => 'ncdiscordhook'],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
<?php
|
||||
|
||||
namespace OCA\NCdiscordhook\Service;
|
||||
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\Files\File;
|
||||
use OCP\Files\Folder;
|
||||
use OCP\Files\IRootFolder;
|
||||
use OCP\Http\Client\IClient;
|
||||
use OCP\Http\Client\IClientService;
|
||||
use OCP\IConfig;
|
||||
use OCP\IRequest;
|
||||
use OCP\Security\Crypto\DefaultCrypto;
|
||||
use OCP\IURLGenerator;
|
||||
use OCP\IUserManager;
|
||||
|
||||
class TalkService {
|
||||
private const APP_ID = 'ncdiscordhook';
|
||||
private const IMAGES_DIR = 'NCdiscordhook-images';
|
||||
|
||||
private IClient $client;
|
||||
private IConfig $config;
|
||||
private IRootFolder $rootFolder;
|
||||
private DefaultCrypto $crypto;
|
||||
private IURLGenerator $urlGenerator;
|
||||
private IUserManager $userManager;
|
||||
private IRequest $request;
|
||||
|
||||
public function __construct(
|
||||
IClientService $clientService,
|
||||
IConfig $config,
|
||||
IRootFolder $rootFolder,
|
||||
DefaultCrypto $crypto,
|
||||
IURLGenerator $urlGenerator,
|
||||
IUserManager $userManager,
|
||||
IRequest $request,
|
||||
) {
|
||||
$this->client = $clientService->newClient();
|
||||
$this->config = $config;
|
||||
$this->rootFolder = $rootFolder;
|
||||
$this->crypto = $crypto;
|
||||
$this->urlGenerator = $urlGenerator;
|
||||
$this->userManager = $userManager;
|
||||
$this->request = $request;
|
||||
}
|
||||
|
||||
// ── Bot password ──────────────────────────────────────────────
|
||||
|
||||
public function getBotPassword(): ?string {
|
||||
$encrypted = $this->config->getAppValue(self::APP_ID, 'bot_password', '');
|
||||
if ($encrypted === '') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return $this->crypto->decrypt($encrypted);
|
||||
} catch (\Exception $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function setBotPassword(string $password): void {
|
||||
$this->config->setAppValue(self::APP_ID, 'bot_password', $this->crypto->encrypt($password));
|
||||
}
|
||||
|
||||
public function hasBotPassword(): bool {
|
||||
return $this->getBotPassword() !== null;
|
||||
}
|
||||
|
||||
public function getBotUser(): ?\OCP\IUser {
|
||||
return $this->userManager->get('talk-bot');
|
||||
}
|
||||
|
||||
// ── Retention ─────────────────────────────────────────────────
|
||||
|
||||
public function getRetentionDays(): int {
|
||||
return (int) $this->config->getAppValue(self::APP_ID, 'retention_days', '90');
|
||||
}
|
||||
|
||||
public function setRetentionDays(int $days): void {
|
||||
$this->config->setAppValue(self::APP_ID, 'retention_days', (string) $days);
|
||||
}
|
||||
|
||||
// ── Rooms ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get configured rooms: room token → display name
|
||||
*/
|
||||
public function getRooms(): array {
|
||||
$json = $this->config->getAppValue(self::APP_ID, 'rooms', '[]');
|
||||
$rooms = json_decode($json, true);
|
||||
return is_array($rooms) ? $rooms : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Save configured rooms: room token → display name
|
||||
*/
|
||||
public function setRooms(array $rooms): void {
|
||||
$this->config->setAppValue(self::APP_ID, 'rooms', json_encode($rooms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available Talk rooms via API.
|
||||
*/
|
||||
public function getAvailableTalkRooms(): array {
|
||||
$baseUrl = rtrim($this->config->getSystemValueString('overwritewebroot', ''), '/');
|
||||
$url = $baseUrl . '/ocs/v2.php/apps/spreed/api/v1/room';
|
||||
|
||||
try {
|
||||
$response = $this->client->get($url, [
|
||||
'auth' => 'basic',
|
||||
'basic' => [
|
||||
$this->config->getSystemValueString('adminuser', 'admin'),
|
||||
$this->config->getSystemValueString('adminpass', ''),
|
||||
],
|
||||
'headers' => [
|
||||
'OCS-Expect' => '100',
|
||||
],
|
||||
]);
|
||||
|
||||
$data = json_decode($response->getBody(), true);
|
||||
if (isset($data['ocs']['data'])) {
|
||||
return $data['ocs']['data'];
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Log but don't fail — rooms list is optional
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
// ── Auth tokens ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get auth tokens for a room: room token → [token1, token2, ...]
|
||||
*/
|
||||
public function getAuthTokens(): array {
|
||||
$json = $this->config->getAppValue(self::APP_ID, 'auth_tokens', '{}');
|
||||
$tokens = json_decode($json, true);
|
||||
return is_array($tokens) ? $tokens : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Save auth tokens for a room.
|
||||
*/
|
||||
public function setAuthTokens(array $tokens): void {
|
||||
$this->config->setAppValue(self::APP_ID, 'auth_tokens', json_encode($tokens));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate auth token for a room.
|
||||
*/
|
||||
public function validateAuthToken(string $roomToken, string $authToken): bool {
|
||||
$allTokens = $this->getAuthTokens();
|
||||
if (!isset($allTokens[$roomToken]) || !is_array($allTokens[$roomToken])) {
|
||||
return false;
|
||||
}
|
||||
return in_array($authToken, $allTokens[$roomToken], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new auth token for a room.
|
||||
* Returns the new token.
|
||||
*/
|
||||
public function generateAuthToken(string $roomToken): string {
|
||||
$token = bin2hex(random_bytes(24)); // 48-char hex token
|
||||
$allTokens = $this->getAuthTokens();
|
||||
if (!isset($allTokens[$roomToken])) {
|
||||
$allTokens[$roomToken] = [];
|
||||
}
|
||||
$allTokens[$roomToken][] = $token;
|
||||
$this->setAuthTokens($allTokens);
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a token from a room.
|
||||
*/
|
||||
public function revokeAuthToken(string $roomToken, string $authToken): void {
|
||||
$allTokens = $this->getAuthTokens();
|
||||
if (!isset($allTokens[$roomToken]) || !is_array($allTokens[$roomToken])) {
|
||||
return;
|
||||
}
|
||||
$allTokens[$roomToken] = array_values(array_filter(
|
||||
$allTokens[$roomToken],
|
||||
fn($t) => $t !== $authToken
|
||||
));
|
||||
if (empty($allTokens[$roomToken])) {
|
||||
unset($allTokens[$roomToken]);
|
||||
}
|
||||
$this->setAuthTokens($allTokens);
|
||||
}
|
||||
|
||||
// ── Payload mapping ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Map Discord webhook payload to Talk message text.
|
||||
*/
|
||||
public function mapPayload(array $data): string {
|
||||
$parts = [];
|
||||
|
||||
// Regular content
|
||||
if (!empty($data['content'])) {
|
||||
$parts[] = $data['content'];
|
||||
}
|
||||
|
||||
// Embeds
|
||||
if (!empty($data['embeds']) && is_array($data['embeds'])) {
|
||||
foreach ($data['embeds'] as $embed) {
|
||||
if (!is_array($embed)) {
|
||||
continue;
|
||||
}
|
||||
if (!empty($embed['title'])) {
|
||||
$parts[] = '**' . $embed['title'] . '**';
|
||||
}
|
||||
if (!empty($embed['description'])) {
|
||||
$parts[] = $embed['description'];
|
||||
}
|
||||
if (!empty($embed['fields']) && is_array($embed['fields'])) {
|
||||
foreach ($embed['fields'] as $field) {
|
||||
if (is_array($field) && !empty($field['name'])) {
|
||||
$fieldText = $field['name'] . ': ';
|
||||
if (!empty($field['value'])) {
|
||||
$fieldText .= $field['value'];
|
||||
}
|
||||
$parts[] = $fieldText;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return implode("\n\n", $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sender name from payload or config default.
|
||||
*/
|
||||
public function getSenderName(array $data): string {
|
||||
if (!empty($data['username'])) {
|
||||
return $data['username'];
|
||||
}
|
||||
return $this->config->getAppValue(self::APP_ID, 'sender_name', 'Webhook Bot');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set sender name default.
|
||||
*/
|
||||
public function setSenderName(string $name): void {
|
||||
$this->config->setAppValue(self::APP_ID, 'sender_name', $name);
|
||||
}
|
||||
|
||||
// ── Image handling ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Download an image from a URL.
|
||||
* Returns ['data' => binary, 'mimeType' => string] or null on failure.
|
||||
*/
|
||||
public function downloadImage(string $url): ?array {
|
||||
try {
|
||||
$response = $this->client->get($url, [
|
||||
'timeout' => 15,
|
||||
'nextcloud' => [
|
||||
'allow_local_address' => false,
|
||||
],
|
||||
]);
|
||||
|
||||
$mimeType = $response->getHeader('Content-Type') ?: 'image/png';
|
||||
$body = $response->getBody();
|
||||
|
||||
if (strlen($body) === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ['data' => $body, 'mimeType' => $mimeType];
|
||||
} catch (\Exception $e) {
|
||||
\OC::$server->getLogger()->error('Failed to download image: ' . $url, ['app' => self::APP_ID]);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload an image to the bot user's files.
|
||||
* Returns the file path (e.g. NCdiscordhook-images/roomToken/filename.png) or null on failure.
|
||||
*/
|
||||
public function uploadImage(string $roomToken, string $filename, string $data, string $mimeType): ?string {
|
||||
$bot = $this->userManager->get('talk-bot');
|
||||
if (!$bot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$userFolder = $this->rootFolder->getUserFolder($bot->getUID());
|
||||
$imagesDir = $userFolder->getFolder(self::IMAGES_DIR);
|
||||
$roomDir = $imagesDir->getFolder($roomToken);
|
||||
|
||||
// Avoid path traversal
|
||||
$safeFilename = basename($filename);
|
||||
$filePath = $roomDir->newFile($safeFilename, $data);
|
||||
|
||||
return self::IMAGES_DIR . '/' . $roomToken . '/' . $safeFilename;
|
||||
} catch (\Exception $e) {
|
||||
\OC::$server->getLogger()->error('Failed to upload image: ' . $e->getMessage(), ['app' => self::APP_ID]);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build rich object data for a Talk message from an uploaded file path.
|
||||
*/
|
||||
public function buildRichObject(string $filePath, string $mimeType, string $roomToken): array {
|
||||
$baseUrl = rtrim($this->config->getSystemValueString('overwritewebroot', ''), '/');
|
||||
$serverUrl = $this->config->getSystemValueString('overwrite.cli.url', 'https://example.com');
|
||||
|
||||
return [
|
||||
'type' => 'file',
|
||||
'source' => 'share',
|
||||
'fileId' => basename($filePath),
|
||||
'sourceType' => 'file',
|
||||
'mimetype' => $mimeType,
|
||||
'title' => basename($filePath),
|
||||
'description' => 'Webhook image attachment',
|
||||
'thumbnailReady' => true,
|
||||
'fileTarget' => '/' . $filePath,
|
||||
];
|
||||
}
|
||||
|
||||
// ── Talk Chat API ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Post a message to a Talk room via Chat API.
|
||||
*
|
||||
* @param string $roomToken Talk room token
|
||||
* @param string $message Message text
|
||||
* @param string $senderName Sender display name
|
||||
* @param array $richObjects Rich object data (optional)
|
||||
* @return bool Success
|
||||
*/
|
||||
public function postToRoom(
|
||||
string $roomToken,
|
||||
string $message,
|
||||
string $senderName,
|
||||
array $richObjects = [],
|
||||
): bool {
|
||||
$bot = $this->userManager->get('talk-bot');
|
||||
if (!$bot) {
|
||||
\OC::$server->getLogger()->error('talk-bot user not found', ['app' => self::APP_ID]);
|
||||
return false;
|
||||
}
|
||||
|
||||
$botPassword = $this->getBotPassword();
|
||||
if (!$botPassword) {
|
||||
\OC::$server->getLogger()->error('Bot password not configured', ['app' => self::APP_ID]);
|
||||
return false;
|
||||
}
|
||||
|
||||
$baseUrl = rtrim($this->config->getSystemValueString('overwritewebroot', ''), '/');
|
||||
$url = $baseUrl . '/ocs/v2.php/apps/spreed/api/v1/chat/' . rawurlencode($roomToken);
|
||||
|
||||
// Build form body
|
||||
$bodyParts = [
|
||||
'message' => $message,
|
||||
'username' => $senderName,
|
||||
];
|
||||
|
||||
if (!empty($richObjects)) {
|
||||
$bodyParts['richObjects'] = json_encode($richObjects);
|
||||
}
|
||||
|
||||
$body = http_build_query($bodyParts, '', '&', PHP_QUERY_RFC3986);
|
||||
|
||||
try {
|
||||
$response = $this->client->post($url, [
|
||||
'auth' => 'basic',
|
||||
'basic' => [$bot->getUID(), $botPassword],
|
||||
'headers' => [
|
||||
'OCS-Expect' => '100',
|
||||
'Content-Type' => 'application/x-www-form-urlencoded',
|
||||
'Content-Length' => (string) strlen($body),
|
||||
],
|
||||
'body' => $body,
|
||||
]);
|
||||
|
||||
$httpCode = $response->getStatusCode();
|
||||
return $httpCode === 200;
|
||||
} catch (\Exception $e) {
|
||||
\OC::$server->getLogger()->error('Failed to post to Talk: ' . $e->getMessage(), ['app' => self::APP_ID]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Image cleanup ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Purge images older than retention period.
|
||||
*/
|
||||
public function purgeOldImages(): int {
|
||||
$retentionDays = $this->getRetentionDays();
|
||||
$cutoff = time() - ($retentionDays * 86400);
|
||||
|
||||
$bot = $this->userManager->get('talk-bot');
|
||||
if (!$bot) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
$userFolder = $this->rootFolder->getUserFolder($bot->getUID());
|
||||
$imagesDir = $userFolder->getFolder(self::IMAGES_DIR);
|
||||
return $this->purgeFolder($imagesDir, $cutoff);
|
||||
} catch (\Exception $e) {
|
||||
\OC::$server->getLogger()->error('Image cleanup failed: ' . $e->getMessage(), ['app' => self::APP_ID]);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively purge files older than cutoff from a folder.
|
||||
*/
|
||||
private function purgeFolder(Folder $folder, int $cutoff): int {
|
||||
$count = 0;
|
||||
|
||||
foreach ($folder->getDirectoryListing() as $node) {
|
||||
if ($node->getMTime() < $cutoff) {
|
||||
$node->delete();
|
||||
$count++;
|
||||
} elseif ($node instanceof Folder) {
|
||||
$count += $this->purgeFolder($node, $cutoff);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up empty subdirectories
|
||||
foreach ($folder->getDirectoryListing() as $node) {
|
||||
if ($node instanceof Folder && $node->getFolderInfo() === null) {
|
||||
// Folder is empty, check if parent has content
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
// ── Config save (bulk) ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Save all config from the settings UI in one call.
|
||||
*
|
||||
* @param array $config {
|
||||
* bot_password?: string,
|
||||
* retention_days?: int,
|
||||
* rooms?: array<string, string>,
|
||||
* auth_tokens?: array<string, string[]>,
|
||||
* sender_name?: string
|
||||
* }
|
||||
*/
|
||||
public function saveConfig(array $config): void {
|
||||
if (isset($config['bot_password']) && $config['bot_password'] !== '') {
|
||||
$this->setBotPassword($config['bot_password']);
|
||||
}
|
||||
|
||||
if (isset($config['retention_days'])) {
|
||||
$this->setRetentionDays((int) $config['retention_days']);
|
||||
}
|
||||
|
||||
if (isset($config['rooms'])) {
|
||||
$this->setRooms($config['rooms']);
|
||||
}
|
||||
|
||||
if (isset($config['auth_tokens'])) {
|
||||
$this->setAuthTokens($config['auth_tokens']);
|
||||
}
|
||||
|
||||
if (isset($config['sender_name'])) {
|
||||
$this->setSenderName($config['sender_name']);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace OCA\NCdiscordhook\Settings;
|
||||
|
||||
use OCA\NCdiscordhook\Service\TalkService;
|
||||
use OCP\IL10N;
|
||||
use OCP\Settings\ISettings;
|
||||
|
||||
class Admin implements ISettings {
|
||||
private TalkService $talkService;
|
||||
private IL10N $l10n;
|
||||
|
||||
public function __construct(TalkService $talkService, IL10N $l10n) {
|
||||
$this->talkService = $talkService;
|
||||
$this->l10n = $l10n;
|
||||
}
|
||||
|
||||
public function getForm(): string {
|
||||
$params = [
|
||||
'hasBotPassword' => $this->talkService->hasBotPassword(),
|
||||
'retentionDays' => $this->talkService->getRetentionDays(),
|
||||
'rooms' => $this->talkService->getRooms(),
|
||||
'authTokens' => $this->talkService->getAuthTokens(),
|
||||
];
|
||||
|
||||
$template = \OC::$server->get(\OCP\ITemplate\ITemplateFactory::class)->load('ncdiscordhook', 'adminSettings');
|
||||
foreach ($params as $key => $value) {
|
||||
$template->assign($key, $value);
|
||||
}
|
||||
|
||||
return $template->fetchPage();
|
||||
}
|
||||
|
||||
public function getPriority(): int {
|
||||
return 10;
|
||||
}
|
||||
|
||||
public function getSection(): string {
|
||||
return 'server';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const APP_ID = 'ncdiscordhook';
|
||||
|
||||
// Elements
|
||||
const botPasswordInput = document.getElementById('nc-bot-password');
|
||||
const retentionInput = document.getElementById('nc-retention');
|
||||
const fetchRoomsBtn = document.getElementById('nc-fetch-rooms');
|
||||
const roomsList = document.getElementById('nc-rooms-list');
|
||||
const saveBtn = document.getElementById('nc-save');
|
||||
const statusMsg = document.getElementById('nc-status');
|
||||
|
||||
let configuredRooms = <?= json_encode($rooms ?? []) ?>;
|
||||
let authTokens = <?= json_encode($authTokens ?? []) ?>;
|
||||
|
||||
function showStatus(msg, type) {
|
||||
statusMsg.textContent = msg;
|
||||
statusMsg.className = 'nc-status-' + type;
|
||||
setTimeout(() => { statusMsg.textContent = ''; }, 5000);
|
||||
}
|
||||
|
||||
// Fetch available Talk rooms
|
||||
fetchRoomsBtn.addEventListener('click', async function () {
|
||||
fetchRoomsBtn.disabled = true;
|
||||
fetchRoomsBtn.textContent = 'Fetching...';
|
||||
|
||||
try {
|
||||
const resp = await fetch(OC.generateUrl('/apps/' + APP_ID + '/rooms'));
|
||||
const data = await resp.json();
|
||||
|
||||
roomsList.innerHTML = '';
|
||||
|
||||
if (data.length === 0) {
|
||||
roomsList.innerHTML = '<p class="nc-empty">No Talk rooms found. Create rooms first via the Nextcloud Talk settings.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
data.forEach(function (room) {
|
||||
const label = document.createElement('label');
|
||||
label.className = 'nc-room-item';
|
||||
|
||||
const cb = document.createElement('input');
|
||||
cb.type = 'checkbox';
|
||||
cb.value = room.token;
|
||||
cb.id = 'room-' + room.token;
|
||||
if (room.configured) {
|
||||
cb.checked = true;
|
||||
cb.disabled = true;
|
||||
}
|
||||
cb.addEventListener('change', function () { toggleRoomTokens(room.token, this.checked); });
|
||||
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.textContent = room.name || room.token;
|
||||
|
||||
label.appendChild(cb);
|
||||
label.appendChild(nameSpan);
|
||||
roomsList.appendChild(label);
|
||||
|
||||
// Auth tokens container
|
||||
const tokenDiv = document.createElement('div');
|
||||
tokenDiv.className = 'nc-room-tokens';
|
||||
tokenDiv.id = 'tokens-' + room.token;
|
||||
tokenDiv.style.display = 'none';
|
||||
if (room.configured || (authTokens[room.token] && authTokens[room.token].length > 0)) {
|
||||
tokenDiv.style.display = 'block';
|
||||
renderTokens(room.token, tokenDiv);
|
||||
}
|
||||
roomsList.appendChild(tokenDiv);
|
||||
});
|
||||
|
||||
fetchRoomsBtn.textContent = 'Refresh Rooms';
|
||||
} catch (err) {
|
||||
showStatus('Failed to fetch rooms', 'error');
|
||||
fetchRoomsBtn.textContent = 'Fetch Rooms';
|
||||
}
|
||||
|
||||
fetchRoomsBtn.disabled = false;
|
||||
});
|
||||
|
||||
// Render auth tokens for a room
|
||||
function renderTokens(roomToken, container) {
|
||||
container.innerHTML = '';
|
||||
|
||||
const tokens = authTokens[roomToken] || [];
|
||||
|
||||
tokens.forEach(function (token) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'nc-token-row';
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.value = token;
|
||||
input.readOnly = true;
|
||||
input.className = 'nc-token-input';
|
||||
|
||||
const copyBtn = document.createElement('button');
|
||||
copyBtn.type = 'button';
|
||||
copyBtn.className = 'nc-token-copy';
|
||||
copyBtn.textContent = 'Copy';
|
||||
copyBtn.addEventListener('click', function () {
|
||||
navigator.clipboard.writeText(token).then(() => {
|
||||
copyBtn.textContent = 'Copied!';
|
||||
setTimeout(() => { copyBtn.textContent = 'Copy'; }, 2000);
|
||||
});
|
||||
});
|
||||
|
||||
const revokeBtn = document.createElement('button');
|
||||
revokeBtn.type = 'button';
|
||||
revokeBtn.className = 'nc-token-revoke';
|
||||
revokeBtn.textContent = 'Revoke';
|
||||
revokeBtn.addEventListener('click', function () {
|
||||
if (!confirm('Revoke this auth token?')) return;
|
||||
tokens.splice(tokens.indexOf(token), 1);
|
||||
if (tokens.length === 0) {
|
||||
delete authTokens[roomToken];
|
||||
container.style.display = 'none';
|
||||
} else {
|
||||
authTokens[roomToken] = tokens;
|
||||
renderTokens(roomToken, container);
|
||||
}
|
||||
});
|
||||
|
||||
row.appendChild(input);
|
||||
row.appendChild(copyBtn);
|
||||
row.appendChild(revokeBtn);
|
||||
container.appendChild(row);
|
||||
});
|
||||
|
||||
// Generate new token button
|
||||
const genBtn = document.createElement('button');
|
||||
genBtn.type = 'button';
|
||||
genBtn.className = 'nc-generate-token';
|
||||
genBtn.textContent = '+ Generate Auth Token';
|
||||
genBtn.addEventListener('click', function () {
|
||||
if (!authTokens[roomToken]) {
|
||||
authTokens[roomToken] = [];
|
||||
}
|
||||
authTokens[roomToken].push(btoa(Math.random().toString(36).substring(2) + Date.now().toString(36)).replace(/=/g, ''));
|
||||
renderTokens(roomToken, container);
|
||||
container.style.display = 'block';
|
||||
});
|
||||
container.appendChild(genBtn);
|
||||
}
|
||||
|
||||
// Toggle room token display
|
||||
function toggleRoomTokens(roomToken, checked) {
|
||||
const tokenDiv = document.getElementById('tokens-' + roomToken);
|
||||
if (tokenDiv) {
|
||||
tokenDiv.style.display = checked ? 'block' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Save configuration
|
||||
saveBtn.addEventListener('click', async function () {
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.textContent = 'Saving...';
|
||||
|
||||
// Collect configured rooms
|
||||
const rooms = {};
|
||||
document.querySelectorAll('#nc-rooms-list input[type="checkbox"]:checked').forEach(function (cb) {
|
||||
const token = cb.value;
|
||||
rooms[token] = ''; // name will be filled from existing config
|
||||
});
|
||||
|
||||
// Restore names from existing config for newly checked rooms
|
||||
Object.keys(configuredRooms).forEach(function (token) {
|
||||
if (rooms[token] === undefined) {
|
||||
rooms[token] = configuredRooms[token];
|
||||
}
|
||||
});
|
||||
|
||||
const payload = {
|
||||
rooms: rooms,
|
||||
auth_tokens: authTokens,
|
||||
retention_days: parseInt(retentionInput.value) || 90,
|
||||
};
|
||||
|
||||
if (botPasswordInput.value) {
|
||||
payload.bot_password = botPasswordInput.value;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch(OC.generateUrl('/apps/' + APP_ID + '/save-config'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await resp.json();
|
||||
|
||||
if (data.status === 'ok') {
|
||||
configuredRooms = rooms;
|
||||
showStatus('Configuration saved', 'success');
|
||||
botPasswordInput.value = '';
|
||||
} else {
|
||||
showStatus('Save failed: ' + (data.error || 'unknown error'), 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showStatus('Save failed: ' + err.message, 'error');
|
||||
}
|
||||
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.textContent = 'Save Configuration';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.nc-settings-section {
|
||||
margin-bottom: 2em;
|
||||
padding: 1em;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.nc-settings-section h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
.nc-room-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.nc-room-item span {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.nc-room-tokens {
|
||||
margin-left: 24px;
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
background: var(--color-background-dark);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.nc-token-row {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.nc-token-input {
|
||||
flex: 1;
|
||||
font-family: monospace;
|
||||
font-size: 0.85em;
|
||||
background: var(--color-main-background);
|
||||
border: 1px solid var(--color-border);
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.nc-token-copy {
|
||||
padding: 4px 8px;
|
||||
background: var(--color-primary-element);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.nc-token-revoke {
|
||||
padding: 4px 8px;
|
||||
background: var(--color-error);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.nc-generate-token {
|
||||
margin-top: 8px;
|
||||
padding: 4px 12px;
|
||||
background: var(--color-primary-element);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.nc-empty {
|
||||
color: var(--color-text-lighter);
|
||||
font-style: italic;
|
||||
}
|
||||
.nc-status-success { color: var(--color-success); }
|
||||
.nc-status-error { color: var(--color-error); }
|
||||
</style>
|
||||
|
||||
<div class="nc-settings-section">
|
||||
<h3>Bot Configuration</h3>
|
||||
<label for="nc-bot-password">Bot App Password</label><br>
|
||||
<input type="password" id="nc-bot-password" placeholder="<?= $hasBotPassword ? '•••••••• (leave blank to keep current)' : 'Paste talk-bot app password here' ?>">
|
||||
<p class="nc-hint">
|
||||
Generate in Nextcloud Settings → <strong>talk-bot</strong> → Devices & sessions → <strong>Add device</strong>.
|
||||
<?= $hasBotPassword ? 'Leave blank to keep current password.' : 'Required to send messages to Talk.' ?>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="nc-settings-section">
|
||||
<h3>Image Retention</h3>
|
||||
<label for="nc-retention">Retention period (days)</label><br>
|
||||
<input type="number" id="nc-retention" value="<?= $retentionDays ?>" min="1" max="365" step="1">
|
||||
<p class="nc-hint">Images older than this many days will be purged by the daily cron job. Default: 90 days.</p>
|
||||
</div>
|
||||
|
||||
<div class="nc-settings-section">
|
||||
<h3>Room Management</h3>
|
||||
<button id="nc-fetch-rooms" type="button">Fetch Rooms</button>
|
||||
<div id="nc-rooms-list"></div>
|
||||
<p class="nc-hint">Select Talk rooms to accept webhooks for. Each room gets its own webhook URL with an auth token.</p>
|
||||
</div>
|
||||
|
||||
<div class="nc-settings-section">
|
||||
<button id="nc-save" type="button">Save Configuration</button>
|
||||
<span id="nc-status" class="nc-status"></span>
|
||||
</div>
|
||||
Reference in New Issue
Block a user