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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
|
||||
+131
-35
@@ -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,10 +117,21 @@ 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 !== '') {
|
||||
// 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.
|
||||
// In Docker/container deployments, trusted_domains[0] is often 127.0.0.1
|
||||
@@ -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();
|
||||
}
|
||||
$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,
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div class="nc-settings-section">
|
||||
<h3>Bot Configuration</h3>
|
||||
<label for="nc-bot-password"><strong>Bot App Password</strong></label><br>
|
||||
<h3>Bot App Password</h3>
|
||||
<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;">
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user