From 53b47efcd616d09a72fd606e8579d389b3e8b02c Mon Sep 17 00:00:00 2001 From: kyle Date: Fri, 12 Jun 2026 13:05:43 -0700 Subject: [PATCH] feat: enhance webhook debugging and ensure bot participant registration - Add `ncdiscordhook:add-bot-participant` console command to register talk-bot in all configured rooms - Extend debug endpoint with webhook URL validation and test post capabilities - Improve `getBaseUrl()` to prefer public trusted domains, fixing container deployment issues - Add `OCS-APIRequest` header and accept 2xx HTTP status codes for successful message posting - Auto-register talk-bot as a participant during configuration initialization --- lib/Command/AddBotParticipant.php | 123 ++++++++++ lib/Controller/WebhookController.php | 338 ++++++++++++++++++++++++++- lib/Service/TalkService.php | 101 +++++++- 3 files changed, 555 insertions(+), 7 deletions(-) create mode 100644 lib/Command/AddBotParticipant.php diff --git a/lib/Command/AddBotParticipant.php b/lib/Command/AddBotParticipant.php new file mode 100644 index 0000000..f482782 --- /dev/null +++ b/lib/Command/AddBotParticipant.php @@ -0,0 +1,123 @@ +config = $config; + $this->db = $db; + $this->talkManager = $talkManager; + $this->userManager = $userManager; + $this->attendeeMapper = $attendeeMapper; + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $output->writeln('Reading configured rooms from app config...'); + + // Get configured rooms from app config (stored as JSON) + $roomsJson = $this->config->getAppValue(self::APP_ID, 'rooms', '[]'); + $rooms = @json_decode($roomsJson, true); + if (!is_array($rooms)) { + $output->writeln('No configured rooms found.'); + return 1; + } + + $roomTokens = array_keys($rooms); + $output->writeln('Found ' . count($roomTokens) . ' configured room(s).'); + + // Get talk-bot user + $botUser = $this->userManager->get('talk-bot'); + if ($botUser === null) { + $output->writeln('talk-bot user not found. Creating it...'); + // talk-bot might not exist yet + $output->writeln('Run: php occ user:add --password-from-env talk-bot'); + return 1; + } + + // Get table prefix + $sysPrefix = $this->config->getSystemValueString('dbtableprefix', ''); + $talkPrefix = $this->config->getAppValue('spreed', 'databaseprefix', $sysPrefix); + $attendeeTable = $talkPrefix . 'talk_attendee'; + + $added = 0; + $skipped = 0; + + foreach ($roomTokens as $token) { + try { + $room = $this->talkManager->getRoomForToken($token); + $roomId = $room->getId(); + + // Check if talk-bot is already an attendee + try { + $attendee = $this->attendeeMapper->findByActor($roomId, 'users', 'talk-bot'); + $output->writeln(' Room ' . $token . ': talk-bot already an attendee (id=' . $roomId . ')'); + $skipped++; + continue; + } catch (DoesNotExistException $e) { + // Not found - will create + } catch (\Exception $e) { + $output->writeln(' Room ' . $token . ': query error - ' . $e->getMessage() . ''); + $skipped++; + continue; + } + + // Create attendee record + $newAttendee = new Attendee(); + $newAttendee->setRoomId($roomId); + $newAttendee->setActorType('users'); + $newAttendee->setActorId('talk-bot'); + $newAttendee->setDisplayName('talk-bot'); + $newAttendee->setParticipantType(Participant::PERMISSIONS_DEFAULT); + $newAttendee->setPermissions(Participant::PERMISSIONS_MAX_DEFAULT); + $newAttendee->setNotificationLevel(3); // Full level + $newAttendee->setFavorite(false); + $newAttendee->setArchived(false); + + try { + $this->attendeeMapper->insert($newAttendee); + $output->writeln(' Room ' . $token . ': added talk-bot as attendee (room_id=' . $roomId . ')'); + $added++; + } catch (Exception $e) { + $output->writeln(' Room ' . $token . ': insert failed - ' . $e->getMessage() . ''); + } + } catch (\Exception $e) { + $output->writeln(' Room ' . $token . ': ' . $e->getMessage() . ''); + } + } + + $output->writeln(''); + $output->writeln('Done: ' . $added . ' added, ' . $skipped . ' skipped.'); + return 0; + } +} diff --git a/lib/Controller/WebhookController.php b/lib/Controller/WebhookController.php index b9a07b2..da09967 100644 --- a/lib/Controller/WebhookController.php +++ b/lib/Controller/WebhookController.php @@ -13,9 +13,11 @@ use OCP\AppFramework\Http\Attribute\AdminRequired; use OCP\App\IAppManager; use OCP\IAppConfig; use OCP\IConfig; +use OCP\Http\Client\IClientService; use OCP\IGroupManager; use OCP\IRequest; use OCP\IUserSession; +use OCA\Talk\ParticipantService; use Psr\Log\LoggerInterface; class WebhookController extends Controller { @@ -26,8 +28,9 @@ class WebhookController extends Controller { private IGroupManager $groupManager; private IConfig $config; private IAppConfig $appConfig; + private IClientService $clientService; - public function __construct(IRequest $request, TalkService $talkService, LoggerInterface $logger, IAppManager $appManager, IUserSession $userSession, IGroupManager $groupManager, IConfig $config, IAppConfig $appConfig) { + public function __construct(IRequest $request, TalkService $talkService, LoggerInterface $logger, IAppManager $appManager, IUserSession $userSession, IGroupManager $groupManager, IConfig $config, IAppConfig $appConfig, IClientService $clientService) { parent::__construct('ncdiscordhook', $request); $this->talkService = $talkService; $this->logger = $logger; @@ -36,6 +39,7 @@ class WebhookController extends Controller { $this->groupManager = $groupManager; $this->config = $config; $this->appConfig = $appConfig; + $this->clientService = $clientService; } /** @@ -201,6 +205,9 @@ class WebhookController extends Controller { * Debug endpoint to verify the app is working. * * URL: GET /apps/ncdiscordhook/debug + * Query params: + * - webhook_url: Full webhook URL to diagnose (e.g. /apps/ncdiscordhook/bot-webhook/{room}/{token}) + * - test_post=1: Actually POST a test message to the room */ #[PublicPage] #[NoCSRFRequired] @@ -323,9 +330,338 @@ class WebhookController extends Controller { $info['talk_debug_error_trace'] = substr($e->getTraceAsString(), 0, 2000); } + // ── Webhook URL debug ───────────────────────────────────────── + // Parse webhook URL if provided via query param + $webhookUrl = $this->request->getParam('webhook_url', ''); + $doTestPost = (bool) $this->request->getParam('test_post', 0); + if ($webhookUrl !== '') { + $info['webhook_debug'] = $this->debugWebhookUrl($webhookUrl); + if ($doTestPost) { + $info['test_post'] = $this->runTestPost($webhookUrl); + } + } + return new DataResponse($info); } + /** + * Debug a webhook URL step by step. + */ + private function debugWebhookUrl(string $webhookUrl): array { + $result = []; + + // 1. Parse the URL + $parsed = parse_url($webhookUrl); + $result['parsed_url'] = $parsed; + + $path = $parsed['path'] ?? ''; + $segments = explode('/', trim($path, '/')); + + // Expected: apps/ncdiscordhook/bot-webhook/{roomToken}/{token} + if (count($segments) >= 5 && $segments[0] === 'apps' && $segments[1] === 'ncdiscordhook' && $segments[2] === 'bot-webhook') { + $roomToken = $segments[3]; + $token = $segments[4]; + $result['room_token'] = $roomToken; + $result['token'] = $token; + $result['url_valid'] = true; + + // 2. Validate auth token + $valid = $this->talkService->validateAuthToken($roomToken, $token); + $result['auth_token_valid'] = $valid; + + // 3. Check if room is configured + $configured = $this->talkService->isBotEnabledForRoom($roomToken); + $result['room_configured'] = $configured; + + // 4. Check bot user + try { + $bot = $this->talkService->getBotUser(); + $result['bot_user_exists'] = $bot !== null; + $result['bot_uid'] = $bot !== null ? $bot->getUID() : null; + } catch (\Exception $e) { + $result['bot_user_error'] = $e->getMessage(); + } + + // 5. Check bot password + $result['has_bot_password'] = $this->talkService->hasBotPassword(); + try { + $pw = $this->talkService->getBotPassword(); + $result['bot_password_length'] = $pw !== null ? strlen($pw) : null; + $result['bot_password_starts_with'] = $pw !== null ? substr($pw, 0, 4) . '...' : null; + } catch (\Exception $e) { + $result['bot_password_error'] = $e->getMessage(); + } + + // 6. Check base URL + $baseUrl = $this->talkService->getBaseUrl(); + $result['base_url'] = $baseUrl; + $result['base_url_scheme'] = parse_url($baseUrl, PHP_URL_SCHEME) ?? null; + $result['base_url_host'] = parse_url($baseUrl, PHP_URL_HOST) ?? null; + + // 7. Check if room exists in DB + try { + $sysPrefix = $this->config->getSystemValueString('dbtableprefix', ''); + $talkPrefix = $this->config->getAppValue('spreed', 'databaseprefix', $sysPrefix); + $roomTable = $this->talkService->detectTalkTableFromCatalog('talk_rooms', 'spreed_room'); + $result['room_table'] = $roomTable; + + if ($roomTable !== null) { + $stmt = $this->talkService->getDbConnection()->executeQuery( + 'SELECT token, type, name, object_type, object_id + FROM "' . $roomTable . '" WHERE token = ?', + [$roomToken], + ); + $row = $stmt->fetch(); + $stmt->closeCursor(); + + $result['room_found_in_db'] = $row !== false; + if ($row !== false) { + $result['room_type'] = (int)$row['type']; + // readable_name may not exist in all Talk versions + $result['room_readable_name'] = isset($row['readable_name']) ? ($row['readable_name'] ?: null) : null; + $result['room_name'] = $row['name'] ?: null; + $result['room_object_type'] = $row['object_type'] ?: null; + $result['room_object_id'] = $row['object_id'] ?: null; + + // 8. Check room name doesn't look like a DM JSON + if (is_string($row['name']) && str_starts_with($row['name'], '["')) { + $result['room_name_is_json_dm'] = true; + $result['room_name_is_json_dm_warning'] = 'Room name looks like a DM JSON array — this room type is normally filtered out but is explicitly configured.'; + } + } + } + } catch (\Exception $e) { + $result['db_check_error'] = $e->getMessage(); + } + + // 9. Construct Chat API endpoint + $chatEndpoint = $baseUrl . '/ocs/v2.php/apps/spreed/api/v1/chat/' . $roomToken; + $result['chat_api_endpoint'] = $chatEndpoint; + + // 10. Construct Basic auth header (without exposing full password) + try { + $pw = $this->talkService->getBotPassword(); + if ($pw !== null) { + $b64 = base64_encode('talk-bot:' . $pw); + $result['basic_auth_header_prefix'] = 'Basic ' . substr($b64, 0, 20) . '...'; + $result['basic_auth_header_length'] = strlen($b64); + } + } catch (\Exception $e) { + $result['basic_auth_error'] = $e->getMessage(); + } + + // 11. Test HTTP connectivity to the Chat API endpoint + try { + $client = $this->clientService->newClient(); + $response = $client->get($chatEndpoint, [ + 'headers' => [ + 'OCS-Expect-Formatted' => 'json', + 'Authorization' => 'Basic ' . base64_encode('talk-bot:' . ($this->talkService->getBotPassword() ?? 'ERROR')), + ], + 'nextcloud' => [ + 'allow_local_address' => true, + ], + ]); + $result['http_test_status'] = $response->getStatusCode(); + $result['http_test_body'] = $response->getBody(); + } catch (\Guzzle\Exception\ConnectException $e) { + $result['http_test_error'] = 'Connection failed: ' . $e->getMessage(); + $result['http_test_error_type'] = 'connection'; + } catch (\Guzzle\Exception\ServerException $e) { + $result['http_test_status'] = $e->getResponse() ? $e->getResponse()->getStatusCode() : null; + $result['http_test_body'] = $e->getResponse() ? $e->getResponse()->getBody() : null; + $result['http_test_error'] = 'Server error: ' . $e->getMessage(); + $result['http_test_error_type'] = 'server'; + } catch (\Guzzle\Exception\ClientException $e) { + $result['http_test_status'] = $e->getResponse() ? $e->getResponse()->getStatusCode() : null; + $result['http_test_body'] = $e->getResponse() ? $e->getResponse()->getBody() : null; + $result['http_test_error'] = 'Client error: ' . $e->getMessage(); + $result['http_test_error_type'] = 'client'; + } catch (\Exception $e) { + $result['http_test_error'] = $e->getMessage(); + $result['http_test_error_type'] = 'other'; + } + + // 12. Summary of issues + $issues = []; + if (!$result['auth_token_valid'] ?? false === false) { + $issues[] = 'Auth token is INVALID for this room'; + } + if (!($result['room_configured'] ?? false)) { + $issues[] = 'Room is NOT configured (bot not enabled)'; + } + if (!($result['bot_user_exists'] ?? false)) { + $issues[] = 'talk-bot user does NOT exist'; + } + if (!($result['has_bot_password'] ?? false)) { + $issues[] = 'Bot password is NOT configured'; + } + if (($result['room_found_in_db'] ?? false) === false) { + $issues[] = 'Room NOT found in Talk database'; + } + if (($result['http_test_status'] ?? null) !== null && ($result['http_test_status'] ?? 0) >= 400) { + $issues[] = 'Chat API returned HTTP ' . ($result['http_test_status'] ?? 'unknown'); + } + if (isset($result['http_test_error'])) { + $issues[] = 'HTTP test failed: ' . $result['http_test_error']; + } + $result['issues'] = $issues; + $result['summary'] = empty($issues) ? 'All checks passed — if webhook still fails, check Talk server logs' : implode('; ', $issues); + + } else { + $result['url_valid'] = false; + $result['parse_error'] = 'URL does not match expected pattern: /apps/ncdiscordhook/bot-webhook/{roomToken}/{token}'; + } + + return $result; + } + + /** + * Run a test post to simulate a full webhook delivery. + */ + private function runTestPost(string $webhookUrl): array { + $result = []; + + // Parse URL to get roomToken and token + $parsed = parse_url($webhookUrl); + $path = $parsed['path'] ?? ''; + $segments = explode('/', trim($path, '/')); + + if (!(count($segments) >= 5 && $segments[0] === 'apps' && $segments[1] === 'ncdiscordhook' && $segments[2] === 'bot-webhook')) { + return ['error' => 'Invalid webhook URL format']; + } + + $roomToken = $segments[3]; + $token = $segments[4]; + $result['room_token'] = $roomToken; + $result['token'] = $token; + + // Step 1: Validate auth token + $authValid = $this->talkService->validateAuthToken($roomToken, $token); + $result['step_1_auth_token'] = $authValid ? 'OK' : 'FAIL — token not found for this room'; + if (!$authValid) { + $result['steps'] = $result; + return $result; + } + + // Step 2: Build a realistic Discord-style test payload + $testPayload = [ + 'content' => '🔧 **Test message from ncdiscordhook debug**\n\nThis is a test message sent via the debug endpoint. If you see this, the webhook pipeline is working!', + 'embeds' => [ + [ + 'title' => 'Webhook Test', + 'description' => 'Sent at ' . date('Y-m-d H:i:s T'), + 'color' => 3066993, + 'footer' => [ + 'text' => 'ncdiscordhook debug endpoint', + ], + ], + ], + 'username' => 'Debug Test', + 'avatar_url' => null, + ]; + + // Step 3: Map payload to message + $message = $this->talkService->mapPayload($testPayload); + $result['step_2_map_payload'] = $message !== '' ? 'OK — "' . substr($message, 0, 100) . '..." (' . strlen($message) . ' chars)' : 'FAIL — empty message'; + if ($message === '') { + return $result; + } + + // Step 4: Get sender name + $senderName = $this->talkService->getSenderName($testPayload); + $result['step_3_sender_name'] = $senderName; + + // Step 5: Check bot password + $botPassword = $this->talkService->getBotPassword(); + $result['step_4_bot_password'] = $botPassword !== null ? 'OK (' . strlen($botPassword) . ' chars)' : 'FAIL — not configured'; + if ($botPassword === null) { + return $result; + } + + // Step 6: Check bot enabled for room + $botEnabled = $this->talkService->isBotEnabledForRoom($roomToken); + $result['step_5_bot_enabled'] = $botEnabled ? 'OK' : 'FAIL — room not enabled in config'; + if (!$botEnabled) { + return $result; + } + + // Step 7: Get base URL + $baseUrl = $this->talkService->getBaseUrl(); + $result['step_6_base_url'] = $baseUrl !== '' ? $baseUrl : 'FAIL — not configured'; + if ($baseUrl === '') { + return $result; + } + + // Step 8: Build the Chat API request (OCS path with CSRF bypass) + $endpoint = $baseUrl . '/ocs/v2.php/apps/spreed/api/v1/chat/' . $roomToken; + $result['step_7_endpoint'] = $endpoint; + + // Step 9: Try the actual Chat API POST + try { + $client = $this->clientService->newClient(); + $response = $client->post($endpoint, [ + 'body' => json_encode([ + 'message' => $message, + 'actorType' => 'users', + 'actorId' => 'talk-bot', + 'actorDisplayName' => $senderName, + ]), + 'headers' => [ + 'OCS-Expect-Formatted' => 'json', + 'OCS-APIRequest' => '1', + 'Authorization' => 'Basic ' . base64_encode('talk-bot:' . $botPassword), + 'Content-Type' => 'application/json', + ], + 'nextcloud' => [ + 'allow_local_address' => true, + ], + ]); + + $statusCode = $response->getStatusCode(); + $body = $response->getBody(); + $result['step_8_http_status'] = $statusCode; + $result['step_8_http_body'] = $body; + + if ($statusCode === 200) { + $result['step_8_result'] = 'SUCCESS — message posted to Talk!'; + $result['overall'] = 'Webhook pipeline is WORKING. If messages still fail from Discord, check your Discord webhook configuration.'; + } else { + $result['step_8_result'] = 'FAIL — HTTP ' . $statusCode; + + // Parse OCS error if possible + if (strpos($body, 'ocs') !== false) { + $parsedBody = @json_decode($body, true); + if (is_array($parsedBody) && isset($parsedBody['ocs']['meta']['message'])) { + $result['step_8_error_message'] = $parsedBody['ocs']['meta']['message']; + } + } + } + } catch (\Guzzle\Exception\ConnectException $e) { + $result['step_8_result'] = 'FAIL — Connection error'; + $result['step_8_error'] = $e->getMessage(); + } catch (\Guzzle\Exception\ServerException $e) { + $result['step_8_result'] = 'FAIL — Server error (5xx)'; + $result['step_8_error'] = $e->getMessage(); + if ($e->getResponse()) { + $result['step_8_status'] = $e->getResponse()->getStatusCode(); + $result['step_8_body'] = $e->getResponse()->getBody(); + } + } catch (\Guzzle\Exception\ClientException $e) { + $result['step_8_result'] = 'FAIL — Client error (4xx)'; + $result['step_8_error'] = $e->getMessage(); + if ($e->getResponse()) { + $result['step_8_status'] = $e->getResponse()->getStatusCode(); + $result['step_8_body'] = $e->getResponse()->getBody(); + } + } catch (\Exception $e) { + $result['step_8_result'] = 'FAIL — Unexpected error'; + $result['step_8_error'] = $e->getMessage(); + } + + return $result; + } + /** * Get available Talk rooms. * diff --git a/lib/Service/TalkService.php b/lib/Service/TalkService.php index 714868e..8421b7e 100644 --- a/lib/Service/TalkService.php +++ b/lib/Service/TalkService.php @@ -116,16 +116,41 @@ class TalkService { 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. + // Prefer a public (non-loopback, non-private) trusted domain. + // In Docker/container deployments, trusted_domains[0] is often 127.0.0.1 + // which is unreachable from within the container. $trusted = $this->config->getSystemValue('trusted_domains', []); + foreach ($trusted as $domain) { + if ($domain === '') { + continue; + } + // Already has a scheme — trust it as-is + if (preg_match('#^https?://#', $domain)) { + return rtrim($domain, '/'); + } + // Skip loopback and localhost + $lower = strtolower($domain); + if ($lower === 'localhost' + || $lower === '127.0.0.1' + || $lower === '::1' + ) { + continue; + } + // Skip private IP ranges (10.x, 172.16-31.x, 192.168.x) + if (preg_match( + '/^(10\.\d{1,3}|172\.(1[6-9]|2\d|3[01])|192\.168)\./i', + $domain + )) { + continue; + } + return 'https://' . $domain; + } + + // Fallback to first trusted domain (may be localhost; caller may fail) 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]; } @@ -706,6 +731,7 @@ class TalkService { 'body' => $jsonBody, 'headers' => [ 'OCS-Expect-Formatted' => 'json', + 'OCS-APIRequest' => '1', 'Authorization' => 'Basic ' . $credentials, 'Content-Type' => 'application/json', ], @@ -715,7 +741,7 @@ class TalkService { ]); $statusCode = $response->getStatusCode(); - if ($statusCode === Http::STATUS_OK) { + if ($statusCode >= 200 && $statusCode < 300) { $this->logger->info('NCdiscordhook: message posted to room ' . $roomToken, [ 'app' => self::APP_ID, ]); @@ -838,5 +864,68 @@ class TalkService { if (isset($config['sender_name'])) { $this->setSenderName($config['sender_name']); } + + // Ensure talk-bot is a participant in all configured rooms + $this->ensureBotParticipants(); + } + + /** + * Add talk-bot user as a participant in all configured rooms. + * Required so the Chat API accepts requests authenticated as talk-bot. + */ + private function ensureBotParticipants(): void { + if ($this->talkParticipantService === null) { + $this->logger->info('NCdiscordhook: ParticipantService not available, skipping auto-join for talk-bot', ['app' => self::APP_ID]); + return; + } + + $rooms = $this->getRooms(); + if (empty($rooms)) { + return; + } + + // Get the talk-bot user + $botUser = $this->userManager->get('talk-bot'); + if ($botUser === null) { + $this->logger->warning('NCdiscordhook: talk-bot user not found', ['app' => self::APP_ID]); + return; + } + + foreach (array_keys($rooms) as $token) { + try { + $room = $this->talkManager->getRoomForToken($token); + // Check if talk-bot is already a participant + $participants = $room->getParticipants(); + $actors = []; + if (is_array($participants)) { + $actors = $participants['actors'] ?? []; + } elseif (is_object($participants) && method_exists($participants, 'getActors')) { + $actors = $participants->getActors(); + } + $hasBot = false; + foreach ($actors as $actor) { + if (isset($actor['type']) && $actor['type'] === 'users' && isset($actor['actorId']) && $actor['actorId'] === 'talk-bot') { + $hasBot = true; + break; + } + } + if (!$hasBot) { + $this->talkParticipantService->addParticipant( + $room, + $botUser, + \OCA\Talk\Participant::TYPE_USER, + \OCA\Talk\Participant::PERMISSIONS_DEFAULT, + ); + $this->logger->info('NCdiscordhook: added talk-bot as participant in room ' . $token, [ + 'app' => self::APP_ID, + ]); + } + } catch (\Exception $e) { + $this->logger->warning('NCdiscordhook: failed to add talk-bot to room ' . $token, [ + 'app' => self::APP_ID, + 'error' => $e->getMessage(), + ]); + } + } } }