fix: update TalkService for Talk 14 compatibility and add CLI copy helper

- **TalkService**:
  - Refactored `ensureBotParticipants` to use `AttendeeMapper` directly, removing the dependency on `ParticipantService`.
  - Added `getBotDisplayNameForRoom` to resolve the bot's display name via direct DB query.
  - Updated `sendMessage` to use Chat API v1 and include `richObjectsEnd`.
  - Improved `getBaseUrl` logic to handle `overwritehost` and `overwritewebroot`.
- **WebhookController**:
  - Fixed `getAllValues` to iterate known keys manually, preventing `RuntimeException` in Nextcloud 33.
  - Added error handling for `saveConfig`.
  - Removed unused `ParticipantService` import.
- **js/settings.js**:
  - Added functionality to copy CLI commands to the clipboard.
- **Deletions**:
  - Removed `lib/Command/AddBotParticipant.php` as its logic is now integrated into `TalkService`.
- **Templates**:
  - Updated admin settings labels and comments.
This commit is contained in:
kyle
2026-06-12 14:32:52 -07:00
parent 53b47efcd6
commit 6453715bf0
6 changed files with 172 additions and 167 deletions
+16
View File
@@ -39,6 +39,22 @@ document.addEventListener('DOMContentLoaded', function () {
savePasswordBtn.style.display = this.value.trim() ? 'inline-block' : 'none'; savePasswordBtn.style.display = this.value.trim() ? 'inline-block' : 'none';
}); });
// Copy CLI commands to clipboard
document.querySelectorAll('.nc-copy-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
var text = this.dataset.copy || '';
navigator.clipboard.writeText(text).then(function () {
var orig = this.textContent;
this.textContent = '✓';
this.style.color = '#28a745';
setTimeout(function () {
btn.textContent = orig;
btn.style.color = '';
}, 2000);
}.bind(this));
});
});
// Fetch available Talk rooms // Fetch available Talk rooms
function fetchRooms() { function fetchRooms() {
fetchRoomsBtn.disabled = true; fetchRoomsBtn.disabled = true;
-123
View File
@@ -1,123 +0,0 @@
<?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;
}
}
+22 -7
View File
@@ -17,7 +17,6 @@ use OCP\Http\Client\IClientService;
use OCP\IGroupManager; use OCP\IGroupManager;
use OCP\IRequest; use OCP\IRequest;
use OCP\IUserSession; use OCP\IUserSession;
use OCA\Talk\ParticipantService;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
class WebhookController extends Controller { class WebhookController extends Controller {
@@ -193,7 +192,18 @@ class WebhookController extends Controller {
); );
} }
$this->talkService->saveConfig($config); try {
$this->talkService->saveConfig($config);
} catch (\Exception $e) {
$this->logger->error('NCdiscordhook: saveConfig failed: ' . $e->getMessage(), [
'app' => 'ncdiscordhook',
'exception' => (string)$e,
]);
return new DataResponse(
['error' => 'Save failed: ' . $e->getMessage()],
Http::STATUS_INTERNAL_SERVER_ERROR,
);
}
return new DataResponse([ return new DataResponse([
'status' => 'ok', 'status' => 'ok',
@@ -245,10 +255,15 @@ class WebhookController extends Controller {
$info['system_config_error'] = $e->getMessage(); $info['system_config_error'] = $e->getMessage();
} }
// AppConfig values // AppConfig values (iterate known keys to avoid lazy AppConfig RuntimeException in NC33)
try { try {
$appConfig = $this->appConfig->getAllValues('ncdiscordhook'); $knownKeys = ['bot_password', 'rooms', 'auth_tokens', 'retention_days', 'sender_name'];
$info['app_config_keys'] = $appConfig; $appConfigKeys = [];
foreach ($knownKeys as $key) {
$val = $this->appConfig->getValueString('ncdiscordhook', $key, '');
$appConfigKeys[$key] = $val !== '' ? '[set]' : '[empty]';
}
$info['app_config_keys'] = $appConfigKeys;
$info['app_config_room_count'] = count($this->talkService->getRooms()); $info['app_config_room_count'] = count($this->talkService->getRooms());
$info['app_config_auth_token_count'] = count($this->talkService->getAuthTokens()); $info['app_config_auth_token_count'] = count($this->talkService->getAuthTokens());
$info['app_config_bot_password_set'] = $this->talkService->hasBotPassword(); $info['app_config_bot_password_set'] = $this->talkService->hasBotPassword();
@@ -434,7 +449,7 @@ class WebhookController extends Controller {
$result['db_check_error'] = $e->getMessage(); $result['db_check_error'] = $e->getMessage();
} }
// 9. Construct Chat API endpoint // 9. Construct Chat API endpoint (v1 for Talk 19 / NC33)
$chatEndpoint = $baseUrl . '/ocs/v2.php/apps/spreed/api/v1/chat/' . $roomToken; $chatEndpoint = $baseUrl . '/ocs/v2.php/apps/spreed/api/v1/chat/' . $roomToken;
$result['chat_api_endpoint'] = $chatEndpoint; $result['chat_api_endpoint'] = $chatEndpoint;
@@ -593,7 +608,7 @@ class WebhookController extends Controller {
return $result; return $result;
} }
// Step 8: Build the Chat API request (OCS path with CSRF bypass) // Step 8: Build the Chat API request (v1 for Talk 19 / NC33)
$endpoint = $baseUrl . '/ocs/v2.php/apps/spreed/api/v1/chat/' . $roomToken; $endpoint = $baseUrl . '/ocs/v2.php/apps/spreed/api/v1/chat/' . $roomToken;
$result['step_7_endpoint'] = $endpoint; $result['step_7_endpoint'] = $endpoint;
+131 -35
View File
@@ -15,6 +15,9 @@ use OCP\IURLGenerator;
use OCP\IUserManager; use OCP\IUserManager;
use OCP\Share\IManager; use OCP\Share\IManager;
use OCA\Talk\Manager as TalkManager; use OCA\Talk\Manager as TalkManager;
use OCA\Talk\Model\Attendee;
use OCA\Talk\Model\AttendeeMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
class TalkService { class TalkService {
@@ -32,6 +35,7 @@ class TalkService {
private TalkManager $talkManager; private TalkManager $talkManager;
private ICrypto $crypto; private ICrypto $crypto;
private IConfig $config; private IConfig $config;
private AttendeeMapper $attendeeMapper;
public function __construct( public function __construct(
IClientService $clientService, IClientService $clientService,
@@ -45,6 +49,7 @@ class TalkService {
LoggerInterface $logger, LoggerInterface $logger,
TalkManager $talkManager, TalkManager $talkManager,
ICrypto $crypto, ICrypto $crypto,
AttendeeMapper $attendeeMapper,
) { ) {
$this->clientService = $clientService; $this->clientService = $clientService;
$this->config = $config; $this->config = $config;
@@ -57,6 +62,7 @@ class TalkService {
$this->logger = $logger; $this->logger = $logger;
$this->talkManager = $talkManager; $this->talkManager = $talkManager;
$this->crypto = $crypto; $this->crypto = $crypto;
$this->attendeeMapper = $attendeeMapper;
} }
// ── Bot password ────────────────────────────────────────────── // ── Bot password ──────────────────────────────────────────────
@@ -111,9 +117,20 @@ class TalkService {
* often points to a localhost:port that is unreachable from within the container. * often points to a localhost:port that is unreachable from within the container.
*/ */
public function getBaseUrl(): string { public function getBaseUrl(): string {
// Prefer overwritehost (hostname override) over trusted_domains
$overwriteHost = $this->config->getSystemValueString('overwritehost', '');
if ($overwriteHost !== '') {
$proto = $this->config->getSystemValueString('overwriteproto', 'https');
return rtrim($proto . '://' . $overwriteHost, '/');
}
$overwritten = $this->config->getSystemValueString('overwritewebroot', ''); $overwritten = $this->config->getSystemValueString('overwritewebroot', '');
if ($overwritten !== '') { if ($overwritten !== '') {
return rtrim($overwritten, '/'); // overwritewebroot may be a path — prepend current host/proto if needed
if (preg_match('#^https?://#', $overwritten)) {
return rtrim($overwritten, '/');
}
// It's a path — fall through to trusted_domains
} }
// Prefer a public (non-loopback, non-private) trusted domain. // Prefer a public (non-loopback, non-private) trusted domain.
@@ -671,7 +688,7 @@ class TalkService {
* *
* @param string $roomToken Talk room token * @param string $roomToken Talk room token
* @param string $message Message text * @param string $message Message text
* @param string $senderName Sender display name * @param string $senderName Discord sender name (embedded in message text)
* @param array $richObjects Rich object data (optional) * @param array $richObjects Rich object data (optional)
* @return bool Success * @return bool Success
*/ */
@@ -702,22 +719,32 @@ class TalkService {
return false; return false;
} }
// Use Chat API v1 (Talk 19 / NC33 only supports v1)
$endpoint = $baseUrl . '/ocs/v2.php/apps/spreed/api/v1/chat/' . $roomToken; $endpoint = $baseUrl . '/ocs/v2.php/apps/spreed/api/v1/chat/' . $roomToken;
// Build message body for Chat API // Get bot's actual display name from the room participant record.
// In Talk 14+, actorDisplayName MUST match the participant's display name
// or the message is silently dropped.
$botDisplayName = $this->getBotDisplayNameForRoom($roomToken);
// Build message body for Chat API v4
$body = [ $body = [
'message' => $message, 'message' => $message,
'actorType' => 'users', 'actorType' => 'users',
'actorId' => 'talk-bot', 'actorId' => 'talk-bot',
'actorDisplayName' => $senderName, 'actorDisplayName' => $botDisplayName,
]; ];
if (!empty($richObjects)) { if (!empty($richObjects)) {
$indexed = []; $indexed = [];
$indexedEnd = [];
foreach ($richObjects as $index => $richObj) { foreach ($richObjects as $index => $richObj) {
$indexed['file-' . $index] = $richObj; $key = 'file-' . $index;
$indexed[$key] = $richObj;
$indexedEnd[$key] = true;
} }
$body['richObjects'] = $indexed; $body['richObjects'] = $indexed;
$body['richObjectsEnd'] = $indexedEnd;
} }
$jsonBody = json_encode($body); $jsonBody = json_encode($body);
@@ -772,6 +799,42 @@ class TalkService {
return isset($rooms[$roomToken]); return isset($rooms[$roomToken]);
} }
/**
* Get the bot's display name as registered in a Talk room.
* Falls back to 'talk-bot' if not found.
*/
public function getBotDisplayNameForRoom(string $roomToken): string {
// Resolve token → room_id via direct DB query (TalkManager::getRoomForToken removed in Talk 14+)
$roomTable = $this->detectTalkTableFromCatalog('talk_rooms', 'spreed_room');
if ($roomTable === null) {
return 'talk-bot';
}
try {
$stmt = $this->db->executeQuery(
'SELECT id FROM "' . $roomTable . '" WHERE token = ?',
[$roomToken],
);
$roomId = (int)$stmt->fetchOne();
$stmt->closeCursor();
if ($roomId === 0) {
return 'talk-bot';
}
$attendee = $this->attendeeMapper->findByActor($roomId, Attendee::ACTOR_USERS, 'talk-bot');
return $attendee->getDisplayName() ?: 'talk-bot';
} catch (DoesNotExistException $e) {
return 'talk-bot';
} catch (\Exception $e) {
$this->logger->warning('NCdiscordhook: failed to get bot display name for room ' . $roomToken, [
'app' => self::APP_ID,
'error' => $e->getMessage(),
]);
return 'talk-bot';
}
}
// ── Image cleanup ───────────────────────────────────────────── // ── Image cleanup ─────────────────────────────────────────────
/** /**
@@ -845,17 +908,18 @@ class TalkService {
} }
if (isset($config['rooms'])) { if (isset($config['rooms'])) {
$this->setRooms($config['rooms']); $rooms = $config['rooms'];
} else {
$rooms = $this->getRooms();
} }
// Remove explicitly disabled rooms from config // Remove explicitly disabled rooms from the configured set
if (isset($config['disabled_rooms']) && is_array($config['disabled_rooms'])) { if (isset($config['disabled_rooms']) && is_array($config['disabled_rooms'])) {
$rooms = $this->getRooms();
foreach ($config['disabled_rooms'] as $token) { foreach ($config['disabled_rooms'] as $token) {
unset($rooms[$token]); unset($rooms[$token]);
} }
$this->setRooms($rooms);
} }
$this->setRooms($rooms);
if (isset($config['auth_tokens'])) { if (isset($config['auth_tokens'])) {
$this->setAuthTokens($config['auth_tokens']); $this->setAuthTokens($config['auth_tokens']);
@@ -872,13 +936,9 @@ class TalkService {
/** /**
* Add talk-bot user as a participant in all configured rooms. * Add talk-bot user as a participant in all configured rooms.
* Required so the Chat API accepts requests authenticated as talk-bot. * Required so the Chat API accepts requests authenticated as talk-bot.
* Uses AttendeeMapper directly to avoid injecting ParticipantService (17 deps).
*/ */
private function ensureBotParticipants(): void { 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(); $rooms = $this->getRooms();
if (empty($rooms)) { if (empty($rooms)) {
return; return;
@@ -891,33 +951,69 @@ class TalkService {
return; return;
} }
// Get Talk DB table prefix
$sysPrefix = $this->config->getSystemValueString('dbtableprefix', '');
$talkPrefix = $this->config->getAppValue('spreed', 'databaseprefix', $sysPrefix);
$attendeeTable = $talkPrefix . 'talk_attendee';
// Detect Talk rooms table name
$roomTable = $this->detectTalkTableFromCatalog('talk_rooms', 'spreed_room');
if ($roomTable === null) {
$this->logger->warning('NCdiscordhook: Talk rooms table not found, skipping participant setup', [
'app' => self::APP_ID,
]);
return;
}
foreach (array_keys($rooms) as $token) { foreach (array_keys($rooms) as $token) {
try { try {
$room = $this->talkManager->getRoomForToken($token); // Resolve token → room_id via direct DB query (TalkManager::getRoomForToken removed in Talk 14+)
// Check if talk-bot is already a participant $stmt = $this->db->executeQuery(
$participants = $room->getParticipants(); 'SELECT id FROM "' . $roomTable . '" WHERE token = ?',
$actors = []; [$token],
if (is_array($participants)) { );
$actors = $participants['actors'] ?? []; $roomId = (int)$stmt->fetchOne();
} elseif (is_object($participants) && method_exists($participants, 'getActors')) { $stmt->closeCursor();
$actors = $participants->getActors();
if ($roomId === 0) {
$this->logger->warning('NCdiscordhook: room token not found in database', [
'app' => self::APP_ID,
'token' => $token,
]);
continue;
} }
$hasBot = false;
foreach ($actors as $actor) { // Check if talk-bot is already an attendee
if (isset($actor['type']) && $actor['type'] === 'users' && isset($actor['actorId']) && $actor['actorId'] === 'talk-bot') { try {
$hasBot = true; $this->attendeeMapper->findByActor($roomId, Attendee::ACTOR_USERS, 'talk-bot');
break; // Already exists — nothing to do
} continue;
} catch (DoesNotExistException $e) {
// Not found — will create
} }
if (!$hasBot) {
$this->talkParticipantService->addParticipant( // Create attendee record directly
$room, $newAttendee = new Attendee();
$botUser, $newAttendee->setRoomId($roomId);
\OCA\Talk\Participant::TYPE_USER, $newAttendee->setActorType(Attendee::ACTOR_USERS);
\OCA\Talk\Participant::PERMISSIONS_DEFAULT, $newAttendee->setActorId('talk-bot');
); $newAttendee->setDisplayName('talk-bot');
$newAttendee->setParticipantType(\OCA\Talk\Participant::PERMISSIONS_DEFAULT);
$newAttendee->setPermissions(\OCA\Talk\Participant::PERMISSIONS_MAX_DEFAULT);
$newAttendee->setNotificationLevel(3); // Full notification level
$newAttendee->setFavorite(false);
$newAttendee->setArchived(false);
try {
$this->attendeeMapper->insert($newAttendee);
$this->logger->info('NCdiscordhook: added talk-bot as participant in room ' . $token, [ $this->logger->info('NCdiscordhook: added talk-bot as participant in room ' . $token, [
'app' => self::APP_ID, 'app' => self::APP_ID,
'room_id' => $roomId,
]);
} catch (\Exception $e) {
$this->logger->warning('NCdiscordhook: failed to insert attendee for room ' . $token, [
'app' => self::APP_ID,
'error' => $e->getMessage(),
]); ]);
} }
} catch (\Exception $e) { } catch (\Exception $e) {
+1
View File
@@ -28,6 +28,7 @@ class Admin implements ISettings {
'configuredRooms' => $this->talkService->getRooms(), 'configuredRooms' => $this->talkService->getRooms(),
'serverUrl' => $this->talkService->getBaseUrl(), 'serverUrl' => $this->talkService->getBaseUrl(),
'senderName' => $this->talkService->getSenderNameDefault(), 'senderName' => $this->talkService->getSenderNameDefault(),
// CLI commands for setup (user copies these to their server)
'l10n' => [ 'l10n' => [
'fetch_rooms' => $this->l10n->t('Fetch Rooms'), 'fetch_rooms' => $this->l10n->t('Fetch Rooms'),
'error_fetching' => $this->l10n->t('Error fetching rooms'), 'error_fetching' => $this->l10n->t('Error fetching rooms'),
+2 -2
View File
@@ -1,6 +1,6 @@
<div class="nc-settings-section"> <div class="nc-settings-section">
<h3>Bot Configuration</h3> <h3>Bot App Password</h3>
<label for="nc-bot-password"><strong>Bot App Password</strong></label><br> <label for="nc-bot-password"><strong>Enter bot app password</strong></label><br>
<div style="display: flex; gap: 8px; align-items: center; margin: 8px 0;"> <div style="display: flex; gap: 8px; align-items: center; margin: 8px 0;">
<input type="password" id="nc-bot-password" placeholder="<?= $hasBotPassword ? '●●●●●●●● (leave blank to keep current)' : 'Paste talk-bot app password here' ?>" style="flex: 1; padding: 8px; font-size: 1em;"> <input type="password" id="nc-bot-password" placeholder="<?= $hasBotPassword ? '●●●●●●●● (leave blank to keep current)' : 'Paste talk-bot app password here' ?>" style="flex: 1; padding: 8px; font-size: 1em;">
<button id="nc-save-password" type="button" style="display: <?= $hasBotPassword ? 'none' : 'inline-block' ?>;">Save</button> <button id="nc-save-password" type="button" style="display: <?= $hasBotPassword ? 'none' : 'inline-block' ?>;">Save</button>