From 640a3f4f27f7dae08c6539303a6b5f6167ab1565 Mon Sep 17 00:00:00 2001 From: kyle Date: Wed, 10 Jun 2026 14:18:47 -0700 Subject: [PATCH] First commit --- INSTALL.md | 116 +++++++ README.md | 126 +++++++ appinfo/cron.php | 6 + appinfo/info.xml | 29 ++ appinfo/routes.php | 20 ++ img/app (copy).svg | 110 +++++++ img/app.svg | 103 ++++++ lib/Controller/WebhookController.php | 160 +++++++++ lib/Cron/ImageCleanup.php | 50 +++ lib/Service/TalkService.php | 475 +++++++++++++++++++++++++++ lib/Settings/Admin.php | 41 +++ templates/adminSettings.php | 309 +++++++++++++++++ 12 files changed, 1545 insertions(+) create mode 100644 INSTALL.md create mode 100644 README.md create mode 100644 appinfo/cron.php create mode 100644 appinfo/info.xml create mode 100644 appinfo/routes.php create mode 100644 img/app (copy).svg create mode 100644 img/app.svg create mode 100644 lib/Controller/WebhookController.php create mode 100644 lib/Cron/ImageCleanup.php create mode 100644 lib/Service/TalkService.php create mode 100644 lib/Settings/Admin.php create mode 100644 templates/adminSettings.php diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..d94165a --- /dev/null +++ b/INSTALL.md @@ -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// +``` + +Replace `` and `` 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// +``` + +## 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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..0bbde92 --- /dev/null +++ b/README.md @@ -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// +``` + +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** — 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//` +- 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 | diff --git a/appinfo/cron.php b/appinfo/cron.php new file mode 100644 index 0000000..2fb6d3a --- /dev/null +++ b/appinfo/cron.php @@ -0,0 +1,6 @@ + [ + \OCA\NCdiscordhook\Cron\ImageCleanup::class, + ], +]; diff --git a/appinfo/info.xml b/appinfo/info.xml new file mode 100644 index 0000000..b237f10 --- /dev/null +++ b/appinfo/info.xml @@ -0,0 +1,29 @@ + + + ncdiscordhook + NCdiscordhook + Discord webhook bridge for Nextcloud Talk with image support + 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. + 1.0.0 + agpl + Kyle + NCdiscordhook + + + + + + + OCA\NCdiscordhook\Cron\ImageCleanup + + + + OCA\NCdiscordhook\Settings\Admin + + + integration + + https://github.com/your-org/ncdiscordhook/issues + https://github.com/your-org/ncdiscordhook + diff --git a/appinfo/routes.php b/appinfo/routes.php new file mode 100644 index 0000000..4351480 --- /dev/null +++ b/appinfo/routes.php @@ -0,0 +1,20 @@ + [ + [ + 'name' => 'webhook#receive', + 'url' => '/webhook/{roomToken}/{authToken}', + 'verb' => 'POST', + ], + [ + 'name' => 'webhook#saveConfig', + 'url' => '/save-config', + 'verb' => 'POST', + ], + [ + 'name' => 'webhook#getRooms', + 'url' => '/rooms', + 'verb' => 'GET', + ], + ], +]; diff --git a/img/app (copy).svg b/img/app (copy).svg new file mode 100644 index 0000000..99f2ac6 --- /dev/null +++ b/img/app (copy).svg @@ -0,0 +1,110 @@ + +Discord_logo-svgDiscord_logo-svg diff --git a/img/app.svg b/img/app.svg new file mode 100644 index 0000000..3da4e41 --- /dev/null +++ b/img/app.svg @@ -0,0 +1,103 @@ + +Discord_logo-svgDiscord_logo-svg diff --git a/lib/Controller/WebhookController.php b/lib/Controller/WebhookController.php new file mode 100644 index 0000000..7b7d309 --- /dev/null +++ b/lib/Controller/WebhookController.php @@ -0,0 +1,160 @@ +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); + } +} diff --git a/lib/Cron/ImageCleanup.php b/lib/Cron/ImageCleanup.php new file mode 100644 index 0000000..8fc9e51 --- /dev/null +++ b/lib/Cron/ImageCleanup.php @@ -0,0 +1,50 @@ +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'], + ); + } + } +} diff --git a/lib/Service/TalkService.php b/lib/Service/TalkService.php new file mode 100644 index 0000000..76a3354 --- /dev/null +++ b/lib/Service/TalkService.php @@ -0,0 +1,475 @@ +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, + * auth_tokens?: array, + * 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']); + } + } +} diff --git a/lib/Settings/Admin.php b/lib/Settings/Admin.php new file mode 100644 index 0000000..f03e39e --- /dev/null +++ b/lib/Settings/Admin.php @@ -0,0 +1,41 @@ +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'; + } +} diff --git a/templates/adminSettings.php b/templates/adminSettings.php new file mode 100644 index 0000000..af8ffd7 --- /dev/null +++ b/templates/adminSettings.php @@ -0,0 +1,309 @@ + + + + +
+

Bot Configuration

+
+ +

+ Generate in Nextcloud Settings → talk-bot → Devices & sessions → Add device. + +

+
+ +
+

Image Retention

+
+ +

Images older than this many days will be purged by the daily cron job. Default: 90 days.

+
+ +
+

Room Management

+ +
+

Select Talk rooms to accept webhooks for. Each room gets its own webhook URL with an auth token.

+
+ +
+ + +