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
+123
View File
@@ -0,0 +1,123 @@
<?php
namespace OCA\NCdiscordhook\Command;
use OCA\Talk\Manager as TalkManager;
use OCA\Talk\Participant;
use OCA\Talk\Model\Attendee;
use OCA\Talk\Model\AttendeeMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\DB\Exception;
use OCP\IConfig;
use OCP\IDBConnection;
use OCP\IUserManager;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class AddBotParticipant extends Command {
public static defaultName = 'ncdiscordhook:add-bot-participant';
public static defaultDescription = 'Add talk-bot as a participant in all configured rooms (fixes Chat API 404)';
private IConfig $config;
private IDBConnection $db;
private TalkManager $talkManager;
private IUserManager $userManager;
private AttendeeMapper $attendeeMapper;
private const APP_ID = 'ncdiscordhook';
public function __construct(
IConfig $config,
IDBConnection $db,
TalkManager $talkManager,
IUserManager $userManager,
AttendeeMapper $attendeeMapper,
) {
parent::__construct();
$this->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('<error>No configured rooms found.</error>');
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('<error>talk-bot user not found. Creating it...</error>');
// talk-bot might not exist yet
$output->writeln('<comment>Run: php occ user:add --password-from-env talk-bot</comment>');
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(' <info>Room ' . $token . ': talk-bot already an attendee (id=' . $roomId . ')</info>');
$skipped++;
continue;
} catch (DoesNotExistException $e) {
// Not found - will create
} catch (\Exception $e) {
$output->writeln(' <warning>Room ' . $token . ': query error - ' . $e->getMessage() . '</warning>');
$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(' <info>Room ' . $token . ': added talk-bot as attendee (room_id=' . $roomId . ')</info>');
$added++;
} catch (Exception $e) {
$output->writeln(' <warning>Room ' . $token . ': insert failed - ' . $e->getMessage() . '</warning>');
}
} catch (\Exception $e) {
$output->writeln(' <error>Room ' . $token . ': ' . $e->getMessage() . '</error>');
}
}
$output->writeln('');
$output->writeln('<info>Done: ' . $added . ' added, ' . $skipped . ' skipped.</info>');
return 0;
}
}
+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.
*
+95 -6
View File
@@ -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(),
]);
}
}
}
}