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
This commit is contained in:
kyle
2026-06-12 13:05:43 -07:00
parent 4d0bb40120
commit 53b47efcd6
3 changed files with 555 additions and 7 deletions
+337 -1
View File
@@ -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.
*