diff --git a/js/settings.js b/js/settings.js
index 546bc1b..5911fdc 100644
--- a/js/settings.js
+++ b/js/settings.js
@@ -39,6 +39,22 @@ document.addEventListener('DOMContentLoaded', function () {
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
function fetchRooms() {
fetchRoomsBtn.disabled = true;
diff --git a/lib/Command/AddBotParticipant.php b/lib/Command/AddBotParticipant.php
deleted file mode 100644
index f482782..0000000
--- a/lib/Command/AddBotParticipant.php
+++ /dev/null
@@ -1,123 +0,0 @@
-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 da09967..2ca508e 100644
--- a/lib/Controller/WebhookController.php
+++ b/lib/Controller/WebhookController.php
@@ -17,7 +17,6 @@ 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 {
@@ -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([
'status' => 'ok',
@@ -245,10 +255,15 @@ class WebhookController extends Controller {
$info['system_config_error'] = $e->getMessage();
}
- // AppConfig values
+ // AppConfig values (iterate known keys to avoid lazy AppConfig RuntimeException in NC33)
try {
- $appConfig = $this->appConfig->getAllValues('ncdiscordhook');
- $info['app_config_keys'] = $appConfig;
+ $knownKeys = ['bot_password', 'rooms', 'auth_tokens', 'retention_days', 'sender_name'];
+ $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_auth_token_count'] = count($this->talkService->getAuthTokens());
$info['app_config_bot_password_set'] = $this->talkService->hasBotPassword();
@@ -434,7 +449,7 @@ class WebhookController extends Controller {
$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;
$result['chat_api_endpoint'] = $chatEndpoint;
@@ -593,7 +608,7 @@ class WebhookController extends Controller {
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;
$result['step_7_endpoint'] = $endpoint;
diff --git a/lib/Service/TalkService.php b/lib/Service/TalkService.php
index 8421b7e..75a9e99 100644
--- a/lib/Service/TalkService.php
+++ b/lib/Service/TalkService.php
@@ -15,6 +15,9 @@ use OCP\IURLGenerator;
use OCP\IUserManager;
use OCP\Share\IManager;
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;
class TalkService {
@@ -32,6 +35,7 @@ class TalkService {
private TalkManager $talkManager;
private ICrypto $crypto;
private IConfig $config;
+ private AttendeeMapper $attendeeMapper;
public function __construct(
IClientService $clientService,
@@ -45,6 +49,7 @@ class TalkService {
LoggerInterface $logger,
TalkManager $talkManager,
ICrypto $crypto,
+ AttendeeMapper $attendeeMapper,
) {
$this->clientService = $clientService;
$this->config = $config;
@@ -57,6 +62,7 @@ class TalkService {
$this->logger = $logger;
$this->talkManager = $talkManager;
$this->crypto = $crypto;
+ $this->attendeeMapper = $attendeeMapper;
}
// ── Bot password ──────────────────────────────────────────────
@@ -111,9 +117,20 @@ class TalkService {
* often points to a localhost:port that is unreachable from within the container.
*/
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', '');
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.
@@ -671,7 +688,7 @@ class TalkService {
*
* @param string $roomToken Talk room token
* @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)
* @return bool Success
*/
@@ -702,22 +719,32 @@ class TalkService {
return false;
}
+ // Use Chat API v1 (Talk 19 / NC33 only supports v1)
$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 = [
'message' => $message,
'actorType' => 'users',
'actorId' => 'talk-bot',
- 'actorDisplayName' => $senderName,
+ 'actorDisplayName' => $botDisplayName,
];
if (!empty($richObjects)) {
$indexed = [];
+ $indexedEnd = [];
foreach ($richObjects as $index => $richObj) {
- $indexed['file-' . $index] = $richObj;
+ $key = 'file-' . $index;
+ $indexed[$key] = $richObj;
+ $indexedEnd[$key] = true;
}
$body['richObjects'] = $indexed;
+ $body['richObjectsEnd'] = $indexedEnd;
}
$jsonBody = json_encode($body);
@@ -772,6 +799,42 @@ class TalkService {
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 ─────────────────────────────────────────────
/**
@@ -845,17 +908,18 @@ class TalkService {
}
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'])) {
- $rooms = $this->getRooms();
foreach ($config['disabled_rooms'] as $token) {
unset($rooms[$token]);
}
- $this->setRooms($rooms);
}
+ $this->setRooms($rooms);
if (isset($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.
* Required so the Chat API accepts requests authenticated as talk-bot.
+ * Uses AttendeeMapper directly to avoid injecting ParticipantService (17 deps).
*/
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;
@@ -891,33 +951,69 @@ class TalkService {
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) {
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();
+ // Resolve token → room_id via direct DB query (TalkManager::getRoomForToken removed in Talk 14+)
+ $stmt = $this->db->executeQuery(
+ 'SELECT id FROM "' . $roomTable . '" WHERE token = ?',
+ [$token],
+ );
+ $roomId = (int)$stmt->fetchOne();
+ $stmt->closeCursor();
+
+ 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) {
- if (isset($actor['type']) && $actor['type'] === 'users' && isset($actor['actorId']) && $actor['actorId'] === 'talk-bot') {
- $hasBot = true;
- break;
- }
+
+ // Check if talk-bot is already an attendee
+ try {
+ $this->attendeeMapper->findByActor($roomId, Attendee::ACTOR_USERS, 'talk-bot');
+ // Already exists — nothing to do
+ continue;
+ } catch (DoesNotExistException $e) {
+ // Not found — will create
}
- if (!$hasBot) {
- $this->talkParticipantService->addParticipant(
- $room,
- $botUser,
- \OCA\Talk\Participant::TYPE_USER,
- \OCA\Talk\Participant::PERMISSIONS_DEFAULT,
- );
+
+ // Create attendee record directly
+ $newAttendee = new Attendee();
+ $newAttendee->setRoomId($roomId);
+ $newAttendee->setActorType(Attendee::ACTOR_USERS);
+ $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, [
'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) {
diff --git a/lib/Settings/Admin.php b/lib/Settings/Admin.php
index 0b48f16..11dbe21 100644
--- a/lib/Settings/Admin.php
+++ b/lib/Settings/Admin.php
@@ -28,6 +28,7 @@ class Admin implements ISettings {
'configuredRooms' => $this->talkService->getRooms(),
'serverUrl' => $this->talkService->getBaseUrl(),
'senderName' => $this->talkService->getSenderNameDefault(),
+ // CLI commands for setup (user copies these to their server)
'l10n' => [
'fetch_rooms' => $this->l10n->t('Fetch Rooms'),
'error_fetching' => $this->l10n->t('Error fetching rooms'),
diff --git a/templates/adminSettings.php b/templates/adminSettings.php
index cbc955c..0551301 100644
--- a/templates/adminSettings.php
+++ b/templates/adminSettings.php
@@ -1,6 +1,6 @@
-
Bot Configuration
-
+
Bot App Password
+