From 5b521afec1859e838a90e9ab246611a70407783d Mon Sep 17 00:00:00 2001 From: kyle Date: Fri, 12 Jun 2026 11:17:51 -0700 Subject: [PATCH] feat: update for Nextcloud 33 and refactor Talk integration Channel list working, url generation, just need posting working now - Update dependency to Nextcloud 33 and add `spreed` dependency - Change webhook route from `/webhook` to `/bot-webhook` - Add `NavigationProvider` to add app to admin settings menu - Refactor `TalkService` to query Talk DB directly for room listing (public channels) - Update `postToRoom` to use Talk Chat API with Basic Auth for `talk-bot` user - Add debug helper methods to `TalkService` for room debugging - Enhance `debug` endpoint with detailed system and Talk configuration info - Update admin settings UI with "Default Sender Name" field and improved room selection - Add localization strings to admin settings - Update documentation (`INSTALL.md`, `README.md`) for new route and bot admin requirement - Improve `getBaseUrl` logic to prioritize `trusted_domains` for Docker compatibility --- INSTALL.md | 26 +- README.md | 4 +- appinfo/info.xml | 3 +- appinfo/routes.php | 9 +- js/settings.js | 27 +- lib/Controller/WebhookController.php | 237 +++++++++++----- lib/NavigationProvider.php | 37 +++ lib/Service/TalkService.php | 391 +++++++++++++++++---------- lib/Settings/Admin.php | 27 +- templates/adminSettings.php | 34 ++- 10 files changed, 544 insertions(+), 251 deletions(-) create mode 100644 lib/NavigationProvider.php diff --git a/INSTALL.md b/INSTALL.md index 2bda7a1..649f7cf 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -8,7 +8,7 @@ ## 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. +The app posts messages as a dedicated bot user using the Talk Chat API (Basic auth). You must create this user and generate an app password. ### Create the bot user (via `occ`): @@ -20,6 +20,15 @@ export OC_PASS="your-bot-password-here" php occ user:add --password-from-env talk-bot ``` +### Make the bot user an admin + +The bot must have admin privileges to list all Talk rooms. Grant admin access: + +1. Go to **Settings → Users** +2. Find **talk-bot** and click it +3. Check **Admin** under "Settings" +4. Save + ### Generate an app password for the bot: 1. Log in to Nextcloud as **admin** @@ -65,11 +74,12 @@ php occ app:enable ncdiscordhook 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** +4. **Default Sender Name** — Set the name that appears as the message sender (default: "Webhook Bot") +5. **Image Retention** — Set how many days to keep uploaded images (default: 90) +6. **Room Selection** — Click **Fetch Rooms** to list available Talk rooms +7. Check the rooms you want to accept webhooks for +8. For each room, click **+ Generate Auth Token** to create a webhook URL +9. Click **Save Configuration** ## Step 4: Set up the Discord webhook @@ -78,7 +88,7 @@ php occ app:enable ncdiscordhook 3. Set the Webhook URL to: ``` -https://your-nextcloud-server/apps/ncdiscordhook/webhook// +https://your-nextcloud-server/apps/ncdiscordhook/bot-webhook// ``` Replace `` and `` with the values shown in the Nextcloud admin settings for the selected room. @@ -95,7 +105,7 @@ Test with curl: curl -X POST \ -H "Content-Type: application/json" \ -d '{"content":"Test message from NCdiscordhook","username":"CI Bot"}' \ - https://your-nextcloud-server/apps/ncdiscordhook/webhook// + https://your-nextcloud-server/apps/ncdiscordhook/bot-webhook// ``` ## Troubleshooting diff --git a/README.md b/README.md index 0bbde92..bc53822 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Go to **Settings → Admin → NCdiscordhook**: Each configured room gets a webhook URL: ``` -https://your-server.com/apps/ncdiscordhook/webhook// +https://your-server.com/apps/ncdiscordhook/bot-webhook// ``` Copy the auth token from the app settings for each room. @@ -92,7 +92,7 @@ Copy the auth token from the app settings for each room. Each room gets its own webhook URL with a unique auth token: ``` -/webhook// +/bot-webhook// ``` - **Room token** — identifies which Talk room to post to (from `occ talk:room:list`) diff --git a/appinfo/info.xml b/appinfo/info.xml index 980380a..0da1f6f 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -12,7 +12,8 @@ integration - + + spreed diff --git a/appinfo/routes.php b/appinfo/routes.php index 08391a2..adb3902 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -1,9 +1,10 @@ [ [ 'name' => 'webhook#receive', - 'url' => '/webhook/{roomToken}/{authToken}', + 'url' => '/bot-webhook/{roomToken}/{token}', 'verb' => 'POST', ], [ @@ -25,11 +26,7 @@ return [ 'name' => 'webhook#debug', 'url' => '/debug', 'verb' => 'GET', - ], - [ - 'name' => 'webhook#debugTables', - 'url' => '/debug-tables', - 'verb' => 'GET', + 'type' => 'noCsrf', ], ], ]; diff --git a/js/settings.js b/js/settings.js index d9a5749..c047cac 100644 --- a/js/settings.js +++ b/js/settings.js @@ -12,9 +12,11 @@ document.addEventListener('DOMContentLoaded', function () { const dataEl = document.getElementById('nc-config-data'); const configuredRooms = parseData(dataEl, 'configuredRooms'); const authTokens = parseData(dataEl, 'authTokens'); + const serverUrl = dataEl ? (dataEl.dataset.serverUrl || '') : ''; // Elements const botPasswordInput = document.getElementById('nc-bot-password'); + const senderNameInput = document.getElementById('nc-sender-name'); const retentionInput = document.getElementById('nc-retention'); const fetchRoomsBtn = document.getElementById('nc-fetch-rooms'); const roomsList = document.getElementById('nc-rooms-list'); @@ -63,9 +65,16 @@ document.addEventListener('DOMContentLoaded', function () { cb.id = 'room-' + room.token; if (room.configured) { cb.checked = true; - cb.disabled = true; } - cb.addEventListener('change', function () { toggleRoomTokens(room.token, this.checked); }); + cb.addEventListener('change', function () { + const tokenDiv = document.getElementById('tokens-' + room.token); + if (tokenDiv) { + tokenDiv.style.display = cb.checked ? 'block' : 'none'; + if (cb.checked) { + renderTokens(room.token, tokenDiv); + } + } + }); var nameSpan = document.createElement('span'); nameSpan.textContent = room.name || room.token; @@ -107,7 +116,7 @@ document.addEventListener('DOMContentLoaded', function () { const tokens = authTokensState[roomToken] || []; // Build the full webhook URL with server origin - var webhookPath = OC.generateUrl('/apps/' + APP_ID + '/webhook/') + roomToken + '/{token}'; + var webhookPath = OC.generateUrl('/apps/' + APP_ID + '/bot-webhook/') + roomToken + '/{token}'; var webhookUrl = window.location.protocol + '//' + window.location.host + webhookPath; if (tokens.length === 0) { @@ -186,17 +195,6 @@ document.addEventListener('DOMContentLoaded', function () { 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'; - if (checked) { - renderTokens(roomToken, tokenDiv); - } - } - } - // Save bot password only savePasswordBtn.addEventListener('click', async function () { var pw = botPasswordInput.value.trim(); @@ -250,6 +248,7 @@ document.addEventListener('DOMContentLoaded', function () { rooms: rooms, auth_tokens: authTokensState, retention_days: parseInt(retentionInput.value) || 90, + sender_name: senderNameInput.value || 'Webhook Bot', }; if (botPasswordInput.value) { diff --git a/lib/Controller/WebhookController.php b/lib/Controller/WebhookController.php index 553ab97..8c35212 100644 --- a/lib/Controller/WebhookController.php +++ b/lib/Controller/WebhookController.php @@ -7,40 +7,51 @@ 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\Http\Client\IClientService; -use OCP\IRequest; -use OCP\IUserSession; -use OCP\AppFramework\Controller\Attribute\AdminRequired; -use Psr\Log\LoggerInterface; use OCP\AppFramework\Http\Attribute\NoCSRFRequired; use OCP\AppFramework\Http\Attribute\PublicPage; -use OCP\AppFramework\Http\Attribute\SubAdminRequired; +use OCP\AppFramework\Http\Attribute\AdminRequired; +use OCP\App\IAppManager; +use OCP\IAppConfig; +use OCP\IConfig; +use OCP\IGroupManager; +use OCP\IRequest; +use OCP\IUserSession; +use Psr\Log\LoggerInterface; class WebhookController extends Controller { private TalkService $talkService; private LoggerInterface $logger; + private IAppManager $appManager; + private IUserSession $userSession; + private IGroupManager $groupManager; + private IConfig $config; + private IAppConfig $appConfig; - public function __construct(IRequest $request, TalkService $talkService, LoggerInterface $logger) { - // Debug: write to file to bypass output buffering - @file_put_contents('/tmp/ncdebug.txt', date('c') . " WebhookController constructed\n", FILE_APPEND); + public function __construct(IRequest $request, TalkService $talkService, LoggerInterface $logger, IAppManager $appManager, IUserSession $userSession, IGroupManager $groupManager, IConfig $config, IAppConfig $appConfig) { parent::__construct('ncdiscordhook', $request); $this->talkService = $talkService; $this->logger = $logger; + $this->appManager = $appManager; + $this->userSession = $userSession; + $this->groupManager = $groupManager; + $this->config = $config; + $this->appConfig = $appConfig; } /** * Receive Discord webhook payload for a room. * - * URL: POST /apps/ncdiscordhook/webhook/{roomToken}/{authToken} + * URL: POST /apps/ncdiscordhook/bot-webhook/{roomToken}/{token} */ #[PublicPage] #[NoCSRFRequired] - public function receive(string $roomToken, string $authToken): DataResponse { + public function receive(string $roomToken, string $token): DataResponse { // Validate auth token - if (!$this->talkService->validateAuthToken($roomToken, $authToken)) { + 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, @@ -52,6 +63,10 @@ class WebhookController extends Controller { $body = file_get_contents('php://input'); $data = @json_decode($body, true); if (json_last_error() !== JSON_ERROR_NONE || !is_array($data)) { + $this->logger->warning('NCdiscordhook: invalid JSON from webhook', [ + 'app' => 'ncdiscordhook', + 'room_token' => $roomToken, + ]); return new DataResponse( ['error' => 'Invalid JSON'], Http::STATUS_BAD_REQUEST, @@ -109,10 +124,14 @@ class WebhookController extends Controller { } } - // Post to Talk + // Post to Talk via Chat API $success = $this->talkService->postToRoom($roomToken, $message, $senderName, $richObjects); if ($success) { + $this->logger->info('NCdiscordhook: webhook processed successfully', [ + 'app' => 'ncdiscordhook', + 'room_token' => $roomToken, + ]); return new DataResponse( ['status' => 'ok'], Http::STATUS_CREATED, @@ -120,6 +139,10 @@ class WebhookController extends Controller { ); } + $this->logger->error('NCdiscordhook: failed to post webhook message to Talk', [ + 'app' => 'ncdiscordhook', + 'room_token' => $roomToken, + ]); return new DataResponse( ['error' => 'Failed to post to Talk'], Http::STATUS_INTERNAL_SERVER_ERROR, @@ -176,12 +199,18 @@ class WebhookController extends Controller { * * URL: GET /apps/ncdiscordhook/debug */ + #[PublicPage] #[NoCSRFRequired] + #[NoAdminRequired] public function debug(): DataResponse { + $user = $this->userSession->getUser(); + $uid = $user !== null ? $user->getUID() : null; + $isAdmin = $user !== null && $this->groupManager->isAdmin($uid); + $info = [ - 'app_enabled' => \OC_App::isEnabled('ncdiscordhook'), - 'user' => \OC_User::getUser(), - 'user_is_admin' => \OC_User::isAdminUser(\OC_User::getUser()), + 'app_enabled' => $this->appManager->isInstalled('ncdiscordhook'), + 'user' => $uid, + 'user_is_admin' => $isAdmin, 'bot_user' => null, 'has_bot_password' => false, ]; @@ -194,60 +223,104 @@ class WebhookController extends Controller { $info['bot_error'] = $e->getMessage(); } - return new DataResponse($info); - } - - /** - * Diagnostic endpoint to discover Talk table names. - * - * URL: GET /apps/ncdiscordhook/debug-tables - */ - #[NoCSRFRequired] - public function debugTables(): DataResponse { - $result = []; - + // System config values try { - $conn = $this->talkService->getDbConnection(); - $pdo = $conn->getConnection()->getWrappedConnection(); - - // List all tables that could be Talk-related - $tables = $pdo->query(" - SELECT table_schema, table_name - FROM information_schema.tables - WHERE table_type = 'BASE TABLE' - AND table_schema NOT IN ('pg_catalog', 'information_schema') - AND (table_name LIKE 'talk%' OR table_name LIKE 'spreed%' - OR table_name LIKE '%room%' OR table_name LIKE '%attendee%' - OR table_name LIKE '%conversation%') - ORDER BY table_schema, table_name - ")->fetchAll(); - - $result['tables'] = $tables; - - // Check Talk app config - $talkPrefix = \OC::$server->get(\OCP\IConfig::class)->getAppValue('spreed', 'databaseprefix', ''); - $sysPrefix = \OC::$server->get(\OCP\IConfig::class)->getSystemValueString('dbtableprefix', ''); - $result['sysPrefix'] = $sysPrefix; - $result['talkPrefix'] = $talkPrefix; - - // Check each candidate with tableExists - $candidates = [ - $talkPrefix . 'talk_rooms', - $sysPrefix . 'talk_rooms', - 'talk_rooms', - $talkPrefix . 'talk_attendees', - $sysPrefix . 'talk_attendees', - 'talk_attendees', + $info['system_config'] = [ + 'trusted_domains' => $this->config->getSystemValue('trusted_domains', []), + 'overwrite.cli.url' => $this->config->getSystemValueString('overwrite.cli.url', ''), + 'overwritewebroot' => $this->config->getSystemValueString('overwritewebroot', ''), + 'dbtableprefix' => $this->config->getSystemValueString('dbtableprefix', ''), ]; - $result['tableExists_results'] = []; - foreach ($candidates as $c) { - $result['tableExists_results'][$c] = $conn->tableExists($c); - } } catch (\Exception $e) { - $result['error'] = $e->getMessage(); + $info['system_config_error'] = $e->getMessage(); } - return new DataResponse($result); + // AppConfig values + try { + $appConfig = $this->appConfig->getAllValues('ncdiscordhook'); + $info['app_config_keys'] = $appConfig; + $info['app_config_room_count'] = count($this->talkService->getRooms()); + $info['app_config_auth_token_count'] = count($this->talkService->getAuthTokens()); + $info['app_config_bot_password_set'] = $this->talkService->hasBotPassword(); + } catch (\Exception $e) { + $info['app_config_error'] = $e->getMessage(); + } + + // Talk table schema and sample data + try { + $talkService = $this->talkService; + + // Resolve database prefix and table name + $sysPrefix = $this->config->getSystemValueString('dbtableprefix', ''); + $talkPrefix = $this->config->getAppValue('spreed', 'databaseprefix', $sysPrefix); + $info['database'] = [ + 'system_prefix' => $sysPrefix, + 'talk_prefix' => $talkPrefix, + 'talk_prefix_differs' => $talkPrefix !== $sysPrefix, + ]; + + $roomTable = $talkService->detectTalkTableFromCatalog('talk_rooms', 'spreed_room'); + $info['talk_room_table'] = $roomTable; + + if ($roomTable !== null) { + // Column schema + try { + $columns = $talkService->getTalkTableColumns($roomTable); + $info['room_table_columns'] = $columns; + } catch (\Exception $e) { + $info['room_table_columns_error'] = $e->getMessage(); + } + + // Sample rows (all columns) + try { + $sampleRows = $talkService->getTalkTableSample($roomTable, 100); + $info['room_table_sample'] = $sampleRows; + } catch (\Exception $e) { + $info['room_table_sample_error'] = $e->getMessage(); + } + + // Room type breakdown + try { + $typeCounts = $talkService->getRoomTypeBreakdown(); + $info['room_type_breakdown'] = $typeCounts; + } catch (\Exception $e) { + $info['room_type_breakdown_error'] = $e->getMessage(); + } + + // All rooms with type and name for debugging + try { + $allRooms = $talkService->getAllTalkRoomsDebug(100); + $info['all_talk_rooms_debug'] = $allRooms; + } catch (\Exception $e) { + $info['all_talk_rooms_debug_error'] = $e->getMessage(); + } + + // Rooms that match our filter (type IN 1,2,3, not sample, not note_to_self, not files) + try { + $filteredRooms = $talkService->getAvailableTalkRooms(); + $info['filtered_rooms'] = $filteredRooms; + } catch (\Exception $e) { + $info['filtered_rooms_error'] = $e->getMessage(); + } + + // The raw SQL that getAvailableTalkRooms executes + $info['filtered_rooms_sql'] = 'SELECT token, COALESCE(NULLIF(name, \'\'), token) as display_name + FROM "' . $roomTable . '" + WHERE type IN (1, 2, 3) + AND (object_type IS NULL OR object_type != \'sample\') + AND object_type != \'note_to_self\' + AND object_type != \'file\''; + } else { + $info['talk_debug_error'] = 'Talk table not found in database catalog'; + } + } catch (\Exception $e) { + $info['talk_debug_error'] = $e->getMessage(); + $info['talk_debug_error_file'] = $e->getFile(); + $info['talk_debug_error_line'] = $e->getLine(); + $info['talk_debug_error_trace'] = substr($e->getTraceAsString(), 0, 2000); + } + + return new DataResponse($info); } /** @@ -265,13 +338,39 @@ class WebhookController extends Controller { } $configured = $this->talkService->getRooms(); - // Mark which rooms are already configured + // Also fetch type/object_type for each room from the DB + $roomTable = $this->talkService->detectTalkTableFromCatalog('talk_rooms', 'spreed_room'); + $typeMap = []; + $objectTypeMap = []; + if ($roomTable !== null) { + $sql = 'SELECT token, type, object_type + FROM "' . $roomTable . '" + WHERE type IN (1, 2, 3) + AND (object_type IS NULL OR object_type != \'sample\') + AND object_type != \'note_to_self\' + AND object_type != \'file\' + AND name NOT LIKE \'["%\''; + $result = $this->talkService->getDbConnection()->executeQuery($sql); + while ($row = $result->fetch()) { + $typeMap[$row['token']] = (int)$row['type']; + $objectTypeMap[$row['token']] = $row['object_type'] ?: null; + } + $result->closeCursor(); + } + + // Type labels for reference + $typeLabels = [1 => 'public', 2 => 'private', 3 => 'password']; + + // Mark which rooms are configured $result = []; foreach ($rooms as $token => $name) { $result[] = [ 'token' => $token, 'name' => $name !== '' ? $name : $token, 'configured' => isset($configured[$token]), + 'type' => $typeMap[$token] ?? null, + 'type_label' => $typeLabels[$typeMap[$token] ?? 0] ?? 'unknown', + 'object_type' => $objectTypeMap[$token] ?? null, ]; } diff --git a/lib/NavigationProvider.php b/lib/NavigationProvider.php new file mode 100644 index 0000000..ca606c7 --- /dev/null +++ b/lib/NavigationProvider.php @@ -0,0 +1,37 @@ +userSession->getUser(); + if ($user === null || !$user->isAdmin()) { + return []; + } + + return [ + [ + 'id' => 'ncdiscordhook', + 'app_id' => 'ncdiscordhook', + 'type' => 'settings', + 'name' => $this->l10n->t('NCdiscordhook'), + 'href' => $this->urlGenerator->linkToRoute('settings.AdminSettings#index'), + 'icon' => $this->urlGenerator->imagePath('ncdiscordhook', 'app.svg'), + 'order' => 0, + ], + ]; + } +} diff --git a/lib/Service/TalkService.php b/lib/Service/TalkService.php index 5a1d565..943a53e 100644 --- a/lib/Service/TalkService.php +++ b/lib/Service/TalkService.php @@ -2,66 +2,61 @@ 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\Security\ICrypto; use OCP\IConfig; use OCP\IDBConnection; use OCP\IRequest; -use OCP\Security\ICrypto; use OCP\IURLGenerator; use OCP\IUserManager; use OCP\Share\IManager; +use OCA\Talk\Manager as TalkManager; use Psr\Log\LoggerInterface; class TalkService { private const APP_ID = 'ncdiscordhook'; private const IMAGES_DIR = 'NCdiscordhook-images'; - // Internal system rooms to hide from the room picker. - private const INTERNAL_ROOMS = [ - 'talk-bot', - 'Note to self', - "Let's get started!", - ]; - - private IClient $client; - private IConfig $config; private IDBConnection $db; private IRootFolder $rootFolder; - private ICrypto $crypto; + private IRequest $request; private IURLGenerator $urlGenerator; private IUserManager $userManager; - private IRequest $request; private IManager $shareManager; private LoggerInterface $logger; + private IClientService $clientService; + private TalkManager $talkManager; + private ICrypto $crypto; + private IConfig $config; public function __construct( IClientService $clientService, IConfig $config, IDBConnection $db, IRootFolder $rootFolder, - ICrypto $crypto, + IRequest $request, IURLGenerator $urlGenerator, IUserManager $userManager, - IRequest $request, IManager $shareManager, LoggerInterface $logger, + TalkManager $talkManager, + ICrypto $crypto, ) { - $this->client = $clientService->newClient(); + $this->clientService = $clientService; $this->config = $config; $this->db = $db; $this->rootFolder = $rootFolder; - $this->crypto = $crypto; + $this->request = $request; $this->urlGenerator = $urlGenerator; $this->userManager = $userManager; - $this->request = $request; $this->shareManager = $shareManager; $this->logger = $logger; + $this->talkManager = $talkManager; + $this->crypto = $crypto; } // ── Bot password ────────────────────────────────────────────── @@ -111,52 +106,36 @@ class TalkService { /** * Get the server base URL for internal API calls. - * Falls back to overwrite.cli.url when overwritewebroot is not set. - * When the URL resolves to localhost, falls back to the request host - * or trusted_domains — the HTTP client blocks localhost for SSRF safety. + * Uses trusted_domains as the base URL — the HTTP client blocks + * all localhost addresses, and in Docker deployments overwrite.cli.url + * often points to a localhost:port that is unreachable from within the container. */ - private function getBaseUrl(): string { + public function getBaseUrl(): string { $overwritten = $this->config->getSystemValueString('overwritewebroot', ''); if ($overwritten !== '') { return rtrim($overwritten, '/'); } + + // Use the first trusted domain as the base URL. + // This is the only reliable method when the HTTP client blocks localhost + // and overwrite.cli.url points to an internal Docker address. + $trusted = $this->config->getSystemValue('trusted_domains', []); + if (!empty($trusted[0])) { + // Check if trusted_domains[0] already has a scheme + if (preg_match('#^https?://#', $trusted[0])) { + return rtrim($trusted[0], '/'); + } + // Use https by default for trusted domains + return 'https://' . $trusted[0]; + } + + // Last resort: try overwrite.cli.url $cliUrl = $this->config->getSystemValueString('overwrite.cli.url', ''); - if ($cliUrl === '') { - return ''; - } - $parsed = parse_url($cliUrl); - $scheme = $parsed['scheme'] ?? 'https'; - $host = $parsed['host'] ?? ''; - $port = $parsed['port'] ?? ''; - $path = rtrim($parsed['path'] ?? '', '/'); - $portStr = $port !== '' ? ':' . $port : ''; - $baseUrl = $scheme . '://' . $host . $portStr . $path; - - // HTTP client blocks localhost — fall back to request host or trusted_domains - if (in_array(strtolower($host), ['localhost', '127.0.0.1', '::1'], true)) { - $requestHost = $this->request->getServerHost(); - if ($requestHost !== '') { - // Extract port from Host header (e.g. "example.com:443" or "example.com") - $hostHeader = $this->request->getHeader('Host'); - $requestPort = 0; - if (str_contains($hostHeader, ':')) { - $parts = explode(':', $hostHeader); - $requestPort = (int) end($parts); - } - $basePort = parse_url($baseUrl, PHP_URL_PORT); - $requestPortStr = $requestPort !== 0 && $requestPort !== $basePort - ? ':' . $requestPort - : ''; - return $scheme . '://' . $requestHost . $requestPortStr . $path; - } - // Last resort: trusted_domains - $trusted = $this->config->getSystemValue('trusted_domains', []); - if (!empty($trusted[0])) { - return $scheme . '://' . $trusted[0] . $path; - } + if ($cliUrl !== '') { + return rtrim($cliUrl, '/'); } - return $baseUrl; + return ''; } // ── Rooms ───────────────────────────────────────────────────── @@ -180,25 +159,16 @@ class TalkService { /** * Get available Talk rooms via database query. * - * Queries the Talk DB directly to find rooms where the bot is a participant. - * Bypasses the OCS API which does not accept app passwords for auth. - * Returns rooms as [token => displayName] pairs. + * Queries the Talk DB directly (bypasses the OCS API which does not + * accept app passwords for auth). Returns only public channels (type 1) + * — the only room type that makes sense for a Discord-style webhook + * channel picker. Returns rooms as [token => displayName] pairs. */ public function getAvailableTalkRooms(): array { - $botUser = $this->getBotUser(); - if (!$botUser) { - return []; - } - - $botUid = $botUser->getUID(); - // Detect table names by querying PostgreSQL information_schema directly. - // We cannot rely on tableExists() — it has case-sensitivity issues with PostgreSQL - // where it may match a differently-cased table name but raw SQL still fails. $roomTable = $this->detectTalkTableFromCatalog('talk_rooms', 'spreed_room'); - $participantTable = $this->detectTalkTableFromCatalog('talk_attendees', 'spreed_participant'); - if ($roomTable === null || $participantTable === null) { + if ($roomTable === null) { $sysPrefix = $this->config->getSystemValueString('dbtableprefix', ''); $talkPrefix = $this->config->getAppValue('spreed', 'databaseprefix', $sysPrefix); $this->logger->warning('NCdiscordhook: Talk tables not found', [ @@ -209,34 +179,43 @@ class TalkService { return []; } - // Find rooms where the bot is a participant. - // Use raw SQL — query builder's expr() methods re-parse table names and double-prefix them. - $sql = 'SELECT "' . $roomTable . '".token, "' . $roomTable . '".name, "' . $roomTable . '".type - FROM "' . $roomTable . '" - JOIN "' . $participantTable . '" ON "' . $participantTable . '".room_id = "' . $roomTable . '".id - WHERE "' . $participantTable . '".actor_type = :actorType - AND "' . $participantTable . '".actor_id = :actorId - AND "' . $roomTable . '".type <> :excludeType'; - - $result = $this->db->executeQuery($sql, [ - 'actorType' => 'users', - 'actorId' => $botUid, - 'excludeType' => -1, // exclude UNKNOWN type - ]); + // In NC33 the room display name is stored in the 'name' column + // for all room types. Exclude deleted (type 4), note-to-self (type 6), + // sample rooms, file share rooms (object_type = 'file'), + // and private DM rooms (name starts with '["'). + try { + $sql = 'SELECT token, COALESCE(NULLIF(name, \'\'), token) as display_name + FROM "' . $roomTable . '" + WHERE type IN (1, 2, 3) + AND (object_type IS NULL OR object_type != \'sample\') + AND object_type != \'note_to_self\' + AND object_type != \'file\' + AND name NOT LIKE \'["%\''; + $result = $this->db->executeQuery($sql); + } catch (\Exception $e) { + $this->logger->warning('NCdiscordhook: room name query failed, using token fallback', [ + 'app' => self::APP_ID, + 'error' => $e->getMessage(), + ]); + $sql = 'SELECT token, token as display_name + FROM "' . $roomTable . '" + WHERE type IN (1, 2, 3) + AND (object_type IS NULL OR object_type != \'sample\') + AND object_type != \'note_to_self\' + AND object_type != \'file\' + AND name NOT LIKE \'["%\''; + $result = $this->db->executeQuery($sql); + } $this->logger->info('NCdiscordhook: getAvailableTalkRooms', [ 'app' => self::APP_ID, - 'bot_user' => $botUid, 'room_table' => $roomTable, - 'participant_table' => $participantTable, ]); try { $rooms = []; while ($row = $result->fetch()) { - $token = $row['token']; - $name = $row['name'] !== '' ? $row['name'] : $token; - $rooms[$token] = $name; + $rooms[$row['token']] = $row['display_name'] !== '' ? $row['display_name'] : $row['token']; } $result->closeCursor(); @@ -245,15 +224,6 @@ class TalkService { 'rooms' => array_keys($rooms), ]); - // Filter out internal system rooms. - foreach (self::INTERNAL_ROOMS as $internal) { - foreach ($rooms as $token => $name) { - if (mb_strtolower($name) === mb_strtolower($internal)) { - unset($rooms[$token]); - } - } - } - return $rooms; } catch (\Exception $e) { $this->logger->error('NCdiscordhook: room listing exception', [ @@ -267,16 +237,58 @@ class TalkService { } /** - * Detect which Talk table name variant exists in the database. - * - * Queries PostgreSQL information_schema directly to find actual table names, - * then verifies by checking column presence. Avoids tableExists() which has - * case-sensitivity issues with PostgreSQL. - * - * Talk app has its own database prefix (spreed.appconfig.databaseprefix), - * separate from the main dbtableprefix. + * Debug helper: return all rooms with key columns for inspection. */ - private function detectTalkTableFromCatalog(string $newName, string $oldName): ?string { + public function getAllTalkRoomsDebug(int $limit = 100): array { + $roomTable = $this->detectTalkTableFromCatalog('talk_rooms', 'spreed_room'); + if ($roomTable === null) { + return []; + } + $sql = 'SELECT id, token, type, readable_name, label, name, object_type, object_id + FROM "' . $roomTable . '" + ORDER BY id LIMIT ' . (int)$limit; + $result = $this->db->executeQuery($sql); + $rooms = []; + while ($row = $result->fetch()) { + $rooms[] = [ + 'id' => $row['id'], + 'token' => $row['token'], + 'type' => (int)$row['type'], + 'readable_name' => $row['readable_name'] ?? null, + 'label' => $row['label'] ?? null, + 'name' => $row['name'] ?? null, + 'object_type' => $row['object_type'] ?? null, + 'object_id' => $row['object_id'] ?? null, + ]; + } + $result->closeCursor(); + return $rooms; + } + + /** + * Debug helper: count of rooms per type. + */ + public function getRoomTypeBreakdown(): array { + $roomTable = $this->detectTalkTableFromCatalog('talk_rooms', 'spreed_room'); + if ($roomTable === null) { + return []; + } + $sql = 'SELECT type, COUNT(*) as count + FROM "' . $roomTable . '" + GROUP BY type ORDER BY type'; + $result = $this->db->executeQuery($sql); + $breakdown = []; + while ($row = $result->fetch()) { + $breakdown[(int)$row['type']] = (int)$row['count']; + } + $result->closeCursor(); + return $breakdown; + } + + /** + * Detect which Talk table name variant exists in the database. + */ + public function detectTalkTableFromCatalog(string $newName, string $oldName): ?string { $sysPrefix = $this->config->getSystemValueString('dbtableprefix', ''); $talkPrefix = $this->config->getAppValue('spreed', 'databaseprefix', $sysPrefix); @@ -319,8 +331,6 @@ class TalkService { } // Try executing a lightweight query on each candidate to verify it's usable. - // This avoids the PostgreSQL case-sensitivity mismatch where tableExists() - // may match a differently-cased table but raw SQL fails. foreach ($unique as $table) { try { $testResult = $this->db->executeQuery( @@ -329,7 +339,6 @@ class TalkService { $testResult->closeCursor(); return $table; } catch (\Exception $e) { - // Table exists in catalog but query failed — skip to next candidate continue; } } @@ -337,6 +346,63 @@ class TalkService { return null; } + /** + * Debug helper: get column names and types for a table. + */ + public function getTalkTableColumns(string $tableName): array { + $columns = $this->db->executeQuery( + 'SELECT column_name, data_type, is_nullable + FROM information_schema.columns + WHERE table_name = ? + ORDER BY ordinal_position', + [$tableName], + ); + $result = []; + while ($row = $columns->fetch()) { + $result[] = [ + 'name' => $row['column_name'], + 'type' => $row['data_type'], + 'nullable' => $row['is_nullable'], + ]; + } + $columns->closeCursor(); + return $result; + } + + /** + * Debug helper: get all column names from a table, then fetch sample rows. + */ + public function getTalkTableSample(string $tableName, int $limit): array { + // Get column names + $colResult = $this->db->executeQuery( + 'SELECT column_name FROM information_schema.columns + WHERE table_name = ? ORDER BY ordinal_position', + [$tableName], + ); + $colNames = []; + while ($row = $colResult->fetch()) { + $colNames[] = $row['column_name']; + } + $colResult->closeCursor(); + + if (empty($colNames)) { + return []; + } + + $colList = implode(', ', array_map(fn($c) => '"' . $c . '"', $colNames)); + $rows = $this->db->executeQuery( + 'SELECT ' . $colList . ' FROM "' . $tableName . '" ORDER BY id LIMIT ?', + [$limit], + ); + + $result = []; + while ($row = $rows->fetch()) { + $result[] = $row; + } + $rows->closeCursor(); + return $result; + } + // ── Auth tokens ─────────────────────────────────────────────── /** @@ -451,6 +517,13 @@ class TalkService { return $this->config->getAppValue(self::APP_ID, 'sender_name', 'Webhook Bot'); } + /** + * Get sender name default. + */ + public function getSenderNameDefault(): string { + return $this->config->getAppValue(self::APP_ID, 'sender_name', 'Webhook Bot'); + } + /** * Set sender name default. */ @@ -466,7 +539,8 @@ class TalkService { */ public function downloadImage(string $url): ?array { try { - $response = $this->client->get($url, [ + $client = $this->clientService->newClient(); + $response = $client->get($url, [ 'timeout' => 15, 'nextcloud' => [ 'allow_local_address' => false, @@ -539,8 +613,6 @@ class TalkService { $linkShare = $this->shareManager->getShareById($share->getId()); - $serverUrl = $this->config->getSystemValueString('overwrite.cli.url', 'https://example.com'); - return [ 'rich_object' => [ 'id' => '', @@ -564,10 +636,13 @@ class TalkService { } } - // ── Talk Chat API ───────────────────────────────────────────── + // ── Chat API: post message to Talk room ────────────────────── /** - * Post a message to a Talk room via Chat API. + * Post a message to a Talk room via the Chat API. + * + * Uses Basic auth with the talk-bot user's app password. + * Endpoint: /ocs/v2.php/apps/spreed/api/v1/chat/{roomToken} * * @param string $roomToken Talk room token * @param string $message Message text @@ -581,25 +656,35 @@ class TalkService { string $senderName, array $richObjects = [], ): bool { - $bot = $this->userManager->get('talk-bot'); - if (!$bot) { - $this->logger->error('talk-bot user not found', ['app' => self::APP_ID]); + $botPassword = $this->getBotPassword(); + if ($botPassword === null) { + $this->logger->error('NCdiscordhook: bot password not configured', ['app' => self::APP_ID]); return false; } - $botPassword = $this->getBotPassword(); - if (!$botPassword) { - $this->logger->error('Bot password not configured', ['app' => self::APP_ID]); + // Check bot is enabled for this room (via AppConfig) + if (!$this->isBotEnabledForRoom($roomToken)) { + $this->logger->warning('NCdiscordhook: bot not enabled for room', [ + 'app' => self::APP_ID, + 'room_token' => $roomToken, + ]); return false; } $baseUrl = $this->getBaseUrl(); - $url = $baseUrl . '/ocs/v2.php/apps/spreed/api/v1/chat/' . rawurlencode($roomToken); + if ($baseUrl === '') { + $this->logger->error('NCdiscordhook: base URL not configured', ['app' => self::APP_ID]); + return false; + } - // Build form body - $bodyParts = [ + $endpoint = $baseUrl . '/ocs/v2.php/apps/spreed/api/v1/chat/' . $roomToken; + + // Build message body for Chat API + $body = [ 'message' => $message, - 'username' => $senderName, + 'actorType' => 'users', + 'actorId' => 'talk-bot', + 'actorDisplayName' => $senderName, ]; if (!empty($richObjects)) { @@ -607,32 +692,60 @@ class TalkService { foreach ($richObjects as $index => $richObj) { $indexed['file-' . $index] = $richObj; } - $bodyParts['richObjects'] = $indexed; + $body['richObjects'] = $indexed; } - $body = http_build_query($bodyParts, '', '&', PHP_QUERY_RFC3986); + $jsonBody = json_encode($body); + + // Basic auth: base64('talk-bot:' . bot_password) + $credentials = base64_encode('talk-bot:' . $botPassword); try { - $response = $this->client->post($url, [ - 'auth' => 'basic', - 'basic' => [$bot->getUID(), $botPassword], + $client = $this->clientService->newClient(); + $response = $client->post($endpoint, [ + 'body' => $jsonBody, 'headers' => [ - 'OCS-Expect' => '100', - 'OCS-ApiRequest' => 'true', - 'Content-Type' => 'application/x-www-form-urlencoded', - 'Content-Length' => (string) strlen($body), + 'OCS-Expect-Formatted' => 'json', + 'Authorization' => 'Basic ' . $credentials, + 'Content-Type' => 'application/json', + ], + 'nextcloud' => [ + 'allow_local_address' => false, ], - 'body' => $body, ]); - $httpCode = $response->getStatusCode(); - return $httpCode === 200; + $statusCode = $response->getStatusCode(); + if ($statusCode === Http::STATUS_OK) { + $this->logger->info('NCdiscordhook: message posted to room ' . $roomToken, [ + 'app' => self::APP_ID, + ]); + return true; + } + + $responseBody = $response->getBody(); + $this->logger->error('NCdiscordhook: chat API returned ' . $statusCode . ': ' . $responseBody, [ + 'app' => self::APP_ID, + 'room_token' => $roomToken, + 'status' => $statusCode, + ]); + return false; } catch (\Exception $e) { - $this->logger->error('Failed to post to Talk: ' . $e->getMessage(), ['app' => self::APP_ID]); + $this->logger->error('NCdiscordhook: chat API request failed: ' . $e->getMessage(), [ + 'app' => self::APP_ID, + 'room_token' => $roomToken, + ]); return false; } } + /** + * Check if the bot is enabled for a specific room (via AppConfig). + */ + public function isBotEnabledForRoom(string $roomToken): bool { + $rooms = $this->getRooms(); + return isset($rooms[$roomToken]); + } + // ── Image cleanup ───────────────────────────────────────────── /** diff --git a/lib/Settings/Admin.php b/lib/Settings/Admin.php index e4ca351..4d84a56 100644 --- a/lib/Settings/Admin.php +++ b/lib/Settings/Admin.php @@ -23,8 +23,33 @@ class Admin implements ISettings { $params = [ 'hasBotPassword' => $this->talkService->hasBotPassword(), 'retentionDays' => $this->talkService->getRetentionDays(), - 'rooms' => $this->talkService->getRooms(), + 'rooms' => $this->talkService->getAvailableTalkRooms(), 'authTokens' => $this->talkService->getAuthTokens(), + 'configuredRooms' => $this->talkService->getRooms(), + 'serverUrl' => $this->talkService->getBaseUrl(), + 'senderName' => $this->talkService->getSenderNameDefault(), + 'l10n' => [ + 'fetch_rooms' => $this->l10n->t('Fetch Rooms'), + 'error_fetching' => $this->l10n->t('Error fetching rooms'), + 'save_config' => $this->l10n->t('Save Configuration'), + 'config_saved' => $this->l10n->t('Configuration saved'), + 'save_failed' => $this->l10n->t('Configuration save failed'), + 'generate_token' => $this->l10n->t('Generate Token'), + 'revoke_token' => $this->l10n->t('Revoke'), + 'no_rooms' => $this->l10n->t('No rooms found'), + 'no_rooms_msg' => $this->l10n->t('No Talk rooms are available or you don\'t have permission to view them.'), + 'bot_password' => $this->l10n->t('Bot App Password'), + 'bot_password_desc' => $this->l10n->t('App password for the "talk-bot" user. Create one in Settings → talk-bot → Devices & sessions.'), + 'sender_name' => $this->l10n->t('Default Sender Name'), + 'sender_name_desc' => $this->l10n->t('Name used when posting messages as the bot.'), + 'image_retention' => $this->l10n->t('Image Retention (days)'), + 'image_retention_desc' => $this->l10n->t('How long to keep uploaded images before cleanup.'), + 'room_selection' => $this->l10n->t('Room Selection'), + 'room_selection_desc' => $this->l10n->t('Select which Talk rooms should accept webhooks. Enable the bot for a room by checking it.'), + 'auth_tokens' => $this->l10n->t('Auth Tokens'), + 'auth_tokens_desc' => $this->l10n->t('Auth tokens for this room. Used to validate incoming webhook requests.'), + 'no_password' => $this->l10n->t('Enter bot password to enable configuration.'), + ], ]; $response = new TemplateResponse('ncdiscordhook', 'adminSettings', $params); diff --git a/templates/adminSettings.php b/templates/adminSettings.php index f4066ef..cbc955c 100644 --- a/templates/adminSettings.php +++ b/templates/adminSettings.php @@ -1,9 +1,9 @@

Bot Configuration

-
-
- - +
+
+ +

Generate in Nextcloud Settings → talk-bot → Devices & sessions → Add device. @@ -14,11 +14,11 @@

- -