Can now get channels!
- Inject LoggerInterface across WebhookController, TalkService, and ImageCleanup - Replace TalkService.getAvailableTalkRooms() OCS API calls with direct database queries - Add getBaseUrl() helper to resolve localhost/SSRF constraints for internal API calls - Update buildRichObject() to generate public link shares for file attachments - Add saveBotPassword, debug, and debugTables endpoints to WebhookController - Separate frontend assets (JS/CSS) from adminSettings template - Add composer.json and autoload files for PSR-4 autoloading - Update Admin settings to use TemplateResponse and register app icons
This commit is contained in:
@@ -16,5 +16,20 @@ return [
|
|||||||
'url' => '/rooms',
|
'url' => '/rooms',
|
||||||
'verb' => 'GET',
|
'verb' => 'GET',
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'name' => 'webhook#saveBotPassword',
|
||||||
|
'url' => '/save-bot-password',
|
||||||
|
'verb' => 'POST',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'webhook#debug',
|
||||||
|
'url' => '/debug',
|
||||||
|
'verb' => 'GET',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'webhook#debugTables',
|
||||||
|
'url' => '/debug-tables',
|
||||||
|
'verb' => 'GET',
|
||||||
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "ncdiscordhook/app",
|
||||||
|
"description": "Discord webhook bridge for Nextcloud Talk",
|
||||||
|
"type": "nextcloud-app",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"OCA\\NCdiscordhook\\": "../lib/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// Generated autoload file for ncdiscordhook
|
||||||
|
// Provides PSR-4 autoloading for OCA\NCdiscordhook\
|
||||||
|
|
||||||
|
$baseDir = dirname(__DIR__);
|
||||||
|
|
||||||
|
spl_autoload_register(function ($class) use ($baseDir) {
|
||||||
|
// OCA\NCdiscordhook\ prefix
|
||||||
|
$prefix = 'OCA\\NCdiscordhook\\';
|
||||||
|
$prefixLen = strlen($prefix);
|
||||||
|
if (strncmp($class, $prefix, $prefixLen) !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$relativeClass = substr($class, $prefixLen);
|
||||||
|
$file = $baseDir . '/lib/' . str_replace('\\', '/', $relativeClass) . '.php';
|
||||||
|
|
||||||
|
if (file_exists($file)) {
|
||||||
|
require $file;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// autoload_psr4.php - generated manually for Nextcloud PSR-4 autoloading
|
||||||
|
|
||||||
|
$vendorDir = '';
|
||||||
|
$baseDir = dirname(__DIR__);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'OCA\\NCdiscordhook\\' => [$baseDir . '/lib'],
|
||||||
|
];
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
.nc-settings-section {
|
||||||
|
margin-bottom: 2em;
|
||||||
|
padding: 1em;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.nc-settings-section h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.nc-room-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
.nc-room-item span {
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.nc-room-tokens {
|
||||||
|
margin-left: 24px;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
background: var(--color-background-dark);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.nc-token-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.nc-token-input {
|
||||||
|
flex: 1;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.85em;
|
||||||
|
background: var(--color-main-background);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.nc-token-copy {
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: var(--color-primary-element);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
.nc-token-revoke {
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: var(--color-error);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
.nc-generate-token {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
background: var(--color-primary-element);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.nc-empty {
|
||||||
|
color: var(--color-text-lighter);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.nc-status-success { color: var(--color-success); }
|
||||||
|
.nc-status-error { color: var(--color-error); }
|
||||||
|
.nc-hint {
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: var(--color-text-lighter);
|
||||||
|
}
|
||||||
+244
@@ -0,0 +1,244 @@
|
|||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
const APP_ID = 'ncdiscordhook';
|
||||||
|
|
||||||
|
// Read config passed via data attributes on the page
|
||||||
|
const dataEl = document.getElementById('nc-config-data');
|
||||||
|
const configuredRooms = dataEl ? JSON.parse(dataEl.dataset.configuredRooms || '{}') : {};
|
||||||
|
const authTokens = dataEl ? JSON.parse(dataEl.dataset.authTokens || '{}') : {};
|
||||||
|
|
||||||
|
// Elements
|
||||||
|
const botPasswordInput = document.getElementById('nc-bot-password');
|
||||||
|
const retentionInput = document.getElementById('nc-retention');
|
||||||
|
const fetchRoomsBtn = document.getElementById('nc-fetch-rooms');
|
||||||
|
const roomsList = document.getElementById('nc-rooms-list');
|
||||||
|
const saveBtn = document.getElementById('nc-save');
|
||||||
|
const savePasswordBtn = document.getElementById('nc-save-password');
|
||||||
|
const statusMsg = document.getElementById('nc-status');
|
||||||
|
|
||||||
|
let configuredRoomsState = configuredRooms;
|
||||||
|
let authTokensState = authTokens;
|
||||||
|
|
||||||
|
function showStatus(msg, type) {
|
||||||
|
statusMsg.textContent = msg;
|
||||||
|
statusMsg.className = 'nc-status-' + type;
|
||||||
|
setTimeout(() => { statusMsg.textContent = ''; }, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show/hide the save-password button when user types
|
||||||
|
botPasswordInput.addEventListener('input', function () {
|
||||||
|
savePasswordBtn.style.display = this.value.trim() ? 'inline-block' : 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch available Talk rooms
|
||||||
|
fetchRoomsBtn.addEventListener('click', async function () {
|
||||||
|
fetchRoomsBtn.disabled = true;
|
||||||
|
fetchRoomsBtn.textContent = 'Fetching...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(OC.generateUrl('/apps/' + APP_ID + '/rooms'));
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
roomsList.innerHTML = '';
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
roomsList.innerHTML = '<p class="nc-empty">No Talk rooms found. Create rooms first via the Nextcloud Talk settings.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
data.forEach(function (room) {
|
||||||
|
const label = document.createElement('label');
|
||||||
|
label.className = 'nc-room-item';
|
||||||
|
|
||||||
|
const cb = document.createElement('input');
|
||||||
|
cb.type = 'checkbox';
|
||||||
|
cb.value = room.token;
|
||||||
|
cb.id = 'room-' + room.token;
|
||||||
|
if (room.configured) {
|
||||||
|
cb.checked = true;
|
||||||
|
cb.disabled = true;
|
||||||
|
}
|
||||||
|
cb.addEventListener('change', function () { toggleRoomTokens(room.token, this.checked); });
|
||||||
|
|
||||||
|
const nameSpan = document.createElement('span');
|
||||||
|
nameSpan.textContent = room.name || room.token;
|
||||||
|
|
||||||
|
label.appendChild(cb);
|
||||||
|
label.appendChild(nameSpan);
|
||||||
|
roomsList.appendChild(label);
|
||||||
|
|
||||||
|
// Auth tokens container
|
||||||
|
const tokenDiv = document.createElement('div');
|
||||||
|
tokenDiv.className = 'nc-room-tokens';
|
||||||
|
tokenDiv.id = 'tokens-' + room.token;
|
||||||
|
tokenDiv.style.display = 'none';
|
||||||
|
if (room.configured || (authTokensState[room.token] && authTokensState[room.token].length > 0)) {
|
||||||
|
tokenDiv.style.display = 'block';
|
||||||
|
renderTokens(room.token, tokenDiv);
|
||||||
|
}
|
||||||
|
roomsList.appendChild(tokenDiv);
|
||||||
|
});
|
||||||
|
|
||||||
|
fetchRoomsBtn.textContent = 'Refresh Rooms';
|
||||||
|
} catch (err) {
|
||||||
|
showStatus('Failed to fetch rooms', 'error');
|
||||||
|
fetchRoomsBtn.textContent = 'Fetch Rooms';
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchRoomsBtn.disabled = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Render auth tokens for a room
|
||||||
|
function renderTokens(roomToken, container) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
const tokens = authTokensState[roomToken] || [];
|
||||||
|
|
||||||
|
tokens.forEach(function (token) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'nc-token-row';
|
||||||
|
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'text';
|
||||||
|
input.value = token;
|
||||||
|
input.readOnly = true;
|
||||||
|
input.className = 'nc-token-input';
|
||||||
|
|
||||||
|
const copyBtn = document.createElement('button');
|
||||||
|
copyBtn.type = 'button';
|
||||||
|
copyBtn.className = 'nc-token-copy';
|
||||||
|
copyBtn.textContent = 'Copy';
|
||||||
|
copyBtn.addEventListener('click', function () {
|
||||||
|
navigator.clipboard.writeText(token).then(() => {
|
||||||
|
copyBtn.textContent = 'Copied!';
|
||||||
|
setTimeout(() => { copyBtn.textContent = 'Copy'; }, 2000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const revokeBtn = document.createElement('button');
|
||||||
|
revokeBtn.type = 'button';
|
||||||
|
revokeBtn.className = 'nc-token-revoke';
|
||||||
|
revokeBtn.textContent = 'Revoke';
|
||||||
|
revokeBtn.addEventListener('click', function () {
|
||||||
|
if (!confirm('Revoke this auth token?')) return;
|
||||||
|
tokens.splice(tokens.indexOf(token), 1);
|
||||||
|
if (tokens.length === 0) {
|
||||||
|
delete authTokensState[roomToken];
|
||||||
|
container.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
authTokensState[roomToken] = tokens;
|
||||||
|
renderTokens(roomToken, container);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
row.appendChild(input);
|
||||||
|
row.appendChild(copyBtn);
|
||||||
|
row.appendChild(revokeBtn);
|
||||||
|
container.appendChild(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate new token button
|
||||||
|
const genBtn = document.createElement('button');
|
||||||
|
genBtn.type = 'button';
|
||||||
|
genBtn.className = 'nc-generate-token';
|
||||||
|
genBtn.textContent = '+ Generate Auth Token';
|
||||||
|
genBtn.addEventListener('click', function () {
|
||||||
|
if (!authTokensState[roomToken]) {
|
||||||
|
authTokensState[roomToken] = [];
|
||||||
|
}
|
||||||
|
authTokensState[roomToken].push(btoa(Math.random().toString(36).substring(2) + Date.now().toString(36)).replace(/=/g, ''));
|
||||||
|
renderTokens(roomToken, container);
|
||||||
|
container.style.display = 'block';
|
||||||
|
});
|
||||||
|
container.appendChild(genBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle room token display
|
||||||
|
function toggleRoomTokens(roomToken, checked) {
|
||||||
|
const tokenDiv = document.getElementById('tokens-' + roomToken);
|
||||||
|
if (tokenDiv) {
|
||||||
|
tokenDiv.style.display = checked ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save bot password only
|
||||||
|
savePasswordBtn.addEventListener('click', async function () {
|
||||||
|
var pw = botPasswordInput.value.trim();
|
||||||
|
if (!pw) return;
|
||||||
|
savePasswordBtn.disabled = true;
|
||||||
|
savePasswordBtn.textContent = 'Saving...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
var resp = await fetch(OC.generateUrl('/apps/' + APP_ID + '/save-bot-password'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ bot_password: pw }),
|
||||||
|
});
|
||||||
|
var data = await resp.json();
|
||||||
|
|
||||||
|
if (data.status === 'ok') {
|
||||||
|
showStatus('Bot password saved', 'success');
|
||||||
|
botPasswordInput.value = '';
|
||||||
|
savePasswordBtn.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
showStatus('Save failed: ' + (data.error || 'unknown error'), 'error');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showStatus('Save failed: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
savePasswordBtn.disabled = false;
|
||||||
|
savePasswordBtn.textContent = 'Save';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save configuration
|
||||||
|
saveBtn.addEventListener('click', async function () {
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
saveBtn.textContent = 'Saving...';
|
||||||
|
|
||||||
|
// Collect configured rooms
|
||||||
|
const rooms = {};
|
||||||
|
document.querySelectorAll('#nc-rooms-list input[type="checkbox"]:checked').forEach(function (cb) {
|
||||||
|
const token = cb.value;
|
||||||
|
rooms[token] = ''; // name will be filled from existing config
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore names from existing config for newly checked rooms
|
||||||
|
Object.keys(configuredRoomsState).forEach(function (token) {
|
||||||
|
if (rooms[token] === undefined) {
|
||||||
|
rooms[token] = configuredRoomsState[token];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
rooms: rooms,
|
||||||
|
auth_tokens: authTokensState,
|
||||||
|
retention_days: parseInt(retentionInput.value) || 90,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (botPasswordInput.value) {
|
||||||
|
payload.bot_password = botPasswordInput.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(OC.generateUrl('/apps/' + APP_ID + '/save-config'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
if (data.status === 'ok') {
|
||||||
|
configuredRoomsState = rooms;
|
||||||
|
showStatus('Configuration saved', 'success');
|
||||||
|
botPasswordInput.value = '';
|
||||||
|
} else {
|
||||||
|
showStatus('Save failed: ' + (data.error || 'unknown error'), 'error');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showStatus('Save failed: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
saveBtn.textContent = 'Save Configuration';
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,15 +10,24 @@ use OCP\AppFramework\Http\JSONResponse;
|
|||||||
use OCP\AppFramework\Middleware\Security\CSRF\Token;
|
use OCP\AppFramework\Middleware\Security\CSRF\Token;
|
||||||
use OCP\AppFramework\Middleware\Security\CSRF\TokenStore;
|
use OCP\AppFramework\Middleware\Security\CSRF\TokenStore;
|
||||||
use OCP\AppFramework\Utility\IControllerMethodReflector;
|
use OCP\AppFramework\Utility\IControllerMethodReflector;
|
||||||
|
use OCP\Http\Client\IClientService;
|
||||||
use OCP\IRequest;
|
use OCP\IRequest;
|
||||||
use OCP\IUserSession;
|
use OCP\IUserSession;
|
||||||
|
use OCP\AppFramework\Controller\Attribute\AdminRequired;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
||||||
|
use OCP\AppFramework\Http\Attribute\SubAdminRequired;
|
||||||
|
|
||||||
class WebhookController extends Controller {
|
class WebhookController extends Controller {
|
||||||
private TalkService $talkService;
|
private TalkService $talkService;
|
||||||
|
private LoggerInterface $logger;
|
||||||
|
|
||||||
public function __construct(IRequest $request, TalkService $talkService) {
|
public function __construct(IRequest $request, TalkService $talkService, LoggerInterface $logger) {
|
||||||
|
// Debug: write to file to bypass output buffering
|
||||||
|
@file_put_contents('/tmp/ncdebug.txt', date('c') . " WebhookController constructed\n", FILE_APPEND);
|
||||||
parent::__construct('ncdiscordhook', $request);
|
parent::__construct('ncdiscordhook', $request);
|
||||||
$this->talkService = $talkService;
|
$this->talkService = $talkService;
|
||||||
|
$this->logger = $logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,6 +35,7 @@ class WebhookController extends Controller {
|
|||||||
*
|
*
|
||||||
* URL: POST /apps/ncdiscordhook/webhook/{roomToken}/{authToken}
|
* URL: POST /apps/ncdiscordhook/webhook/{roomToken}/{authToken}
|
||||||
*/
|
*/
|
||||||
|
#[NoCSRFRequired]
|
||||||
public function receive(string $roomToken, string $authToken): DataResponse {
|
public function receive(string $roomToken, string $authToken): DataResponse {
|
||||||
// Validate auth token
|
// Validate auth token
|
||||||
if (!$this->talkService->validateAuthToken($roomToken, $authToken)) {
|
if (!$this->talkService->validateAuthToken($roomToken, $authToken)) {
|
||||||
@@ -37,7 +47,7 @@ class WebhookController extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parse Discord JSON payload
|
// Parse Discord JSON payload
|
||||||
$body = $this->request->getContent();
|
$body = file_get_contents('php://input');
|
||||||
$data = @json_decode($body, true);
|
$data = @json_decode($body, true);
|
||||||
if (json_last_error() !== JSON_ERROR_NONE || !is_array($data)) {
|
if (json_last_error() !== JSON_ERROR_NONE || !is_array($data)) {
|
||||||
return new DataResponse(
|
return new DataResponse(
|
||||||
@@ -88,7 +98,10 @@ class WebhookController extends Controller {
|
|||||||
|
|
||||||
$filePath = $this->talkService->uploadImage($roomToken, $filename, $imageData['data'], $imageData['mimeType']);
|
$filePath = $this->talkService->uploadImage($roomToken, $filename, $imageData['data'], $imageData['mimeType']);
|
||||||
if ($filePath !== null) {
|
if ($filePath !== null) {
|
||||||
$richObjects[] = $this->talkService->buildRichObject($filePath, $imageData['mimeType'], $roomToken);
|
$richObj = $this->talkService->buildRichObject($filePath, $imageData['mimeType'], $roomToken);
|
||||||
|
if ($richObj !== null) {
|
||||||
|
$richObjects[] = $richObj;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -112,14 +125,37 @@ class WebhookController extends Controller {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save bot password from the settings UI.
|
||||||
|
*
|
||||||
|
* URL: POST /apps/ncdiscordhook/save-bot-password
|
||||||
|
*/
|
||||||
|
#[AdminRequired]
|
||||||
|
#[NoCSRFRequired]
|
||||||
|
public function saveBotPassword(): DataResponse {
|
||||||
|
$body = file_get_contents('php://input');
|
||||||
|
$data = @json_decode($body, true);
|
||||||
|
if (!is_array($data) || empty($data['bot_password'])) {
|
||||||
|
return new DataResponse(
|
||||||
|
['error' => 'Invalid data'],
|
||||||
|
Http::STATUS_BAD_REQUEST,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->talkService->setBotPassword($data['bot_password']);
|
||||||
|
|
||||||
|
return new DataResponse(['status' => 'ok']);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save configuration from the settings UI.
|
* Save configuration from the settings UI.
|
||||||
*
|
*
|
||||||
* URL: POST /apps/ncdiscordhook/save-config
|
* URL: POST /apps/ncdiscordhook/save-config
|
||||||
*/
|
*/
|
||||||
@AdminRequired
|
#[AdminRequired]
|
||||||
|
#[NoCSRFRequired]
|
||||||
public function saveConfig(): DataResponse {
|
public function saveConfig(): DataResponse {
|
||||||
$body = $this->request->getContent();
|
$body = file_get_contents('php://input');
|
||||||
$config = @json_decode($body, true);
|
$config = @json_decode($body, true);
|
||||||
if (!is_array($config)) {
|
if (!is_array($config)) {
|
||||||
return new DataResponse(
|
return new DataResponse(
|
||||||
@@ -134,27 +170,116 @@ class WebhookController extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get available Talk rooms.
|
* Debug endpoint to verify the app is working.
|
||||||
*
|
*
|
||||||
* URL: GET /apps/ncdiscordhook/rooms
|
* URL: GET /apps/ncdiscordhook/debug
|
||||||
*/
|
*/
|
||||||
@AdminRequired
|
#[NoCSRFRequired]
|
||||||
public function getRooms(): DataResponse {
|
public function debug(): DataResponse {
|
||||||
$rooms = $this->talkService->getAvailableTalkRooms();
|
$info = [
|
||||||
$configured = $this->talkService->getRooms();
|
'app_enabled' => \OC_App::isEnabled('ncdiscordhook'),
|
||||||
|
'user' => \OC_User::getUser(),
|
||||||
|
'user_is_admin' => \OC_User::isAdminUser(\OC_User::getUser()),
|
||||||
|
'bot_user' => null,
|
||||||
|
'has_bot_password' => false,
|
||||||
|
];
|
||||||
|
|
||||||
// Mark which rooms are already configured
|
try {
|
||||||
|
$bot = $this->talkService->getBotUser();
|
||||||
|
$info['bot_user'] = $bot ? $bot->getUID() : null;
|
||||||
|
$info['has_bot_password'] = $this->talkService->hasBotPassword();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$info['bot_error'] = $e->getMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DataResponse($info);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diagnostic endpoint to discover Talk table names.
|
||||||
|
*
|
||||||
|
* URL: GET /apps/ncdiscordhook/debug-tables
|
||||||
|
*/
|
||||||
|
#[NoCSRFRequired]
|
||||||
|
public function debugTables(): DataResponse {
|
||||||
$result = [];
|
$result = [];
|
||||||
foreach ($rooms as $room) {
|
|
||||||
$token = $room['token'] ?? $room['roomId'] ?? '';
|
try {
|
||||||
$name = $room['displayName'] ?? $room['name'] ?? '';
|
$conn = $this->talkService->getDbConnection();
|
||||||
$result[] = [
|
$pdo = $conn->getConnection()->getWrappedConnection();
|
||||||
'token' => $token,
|
|
||||||
'name' => $name,
|
// List all tables that could be Talk-related
|
||||||
'configured' => isset($configured[$token]),
|
$tables = $pdo->query("
|
||||||
|
SELECT table_schema, table_name
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_type = 'BASE TABLE'
|
||||||
|
AND table_schema NOT IN ('pg_catalog', 'information_schema')
|
||||||
|
AND (table_name LIKE 'talk%' OR table_name LIKE 'spreed%'
|
||||||
|
OR table_name LIKE '%room%' OR table_name LIKE '%attendee%'
|
||||||
|
OR table_name LIKE '%conversation%')
|
||||||
|
ORDER BY table_schema, table_name
|
||||||
|
")->fetchAll();
|
||||||
|
|
||||||
|
$result['tables'] = $tables;
|
||||||
|
|
||||||
|
// Check Talk app config
|
||||||
|
$talkPrefix = \OC::$server->get(\OCP\IConfig::class)->getAppValue('spreed', 'databaseprefix', '');
|
||||||
|
$sysPrefix = \OC::$server->get(\OCP\IConfig::class)->getSystemValueString('dbtableprefix', '');
|
||||||
|
$result['sysPrefix'] = $sysPrefix;
|
||||||
|
$result['talkPrefix'] = $talkPrefix;
|
||||||
|
|
||||||
|
// Check each candidate with tableExists
|
||||||
|
$candidates = [
|
||||||
|
$talkPrefix . 'talk_rooms',
|
||||||
|
$sysPrefix . 'talk_rooms',
|
||||||
|
'talk_rooms',
|
||||||
|
$talkPrefix . 'talk_attendees',
|
||||||
|
$sysPrefix . 'talk_attendees',
|
||||||
|
'talk_attendees',
|
||||||
];
|
];
|
||||||
|
$result['tableExists_results'] = [];
|
||||||
|
foreach ($candidates as $c) {
|
||||||
|
$result['tableExists_results'][$c] = $conn->tableExists($c);
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$result['error'] = $e->getMessage();
|
||||||
}
|
}
|
||||||
|
|
||||||
return new DataResponse($result);
|
return new DataResponse($result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available Talk rooms.
|
||||||
|
*
|
||||||
|
* URL: GET /apps/ncdiscordhook/rooms
|
||||||
|
*/
|
||||||
|
#[AdminRequired]
|
||||||
|
#[NoCSRFRequired]
|
||||||
|
public function getRooms(): DataResponse {
|
||||||
|
try {
|
||||||
|
$rooms = $this->talkService->getAvailableTalkRooms();
|
||||||
|
if (!is_array($rooms)) {
|
||||||
|
$rooms = [];
|
||||||
|
}
|
||||||
|
$configured = $this->talkService->getRooms();
|
||||||
|
|
||||||
|
// Mark which rooms are already configured
|
||||||
|
$result = [];
|
||||||
|
foreach ($rooms as $token => $name) {
|
||||||
|
$result[] = [
|
||||||
|
'token' => $token,
|
||||||
|
'name' => $name !== '' ? $name : $token,
|
||||||
|
'configured' => isset($configured[$token]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DataResponse($result);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->logger->error('NCdiscordhook getRooms failed: ' . $e->getMessage(), ['app' => 'ncdiscordhook', 'exception' => (string)$e]);
|
||||||
|
return new DataResponse(
|
||||||
|
['error' => 'Server error: ' . $e->getMessage()],
|
||||||
|
Http::STATUS_INTERNAL_SERVER_ERROR,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,20 +7,24 @@ use OCP\BackgroundJob\IJob;
|
|||||||
use OCP\Files\IRootFolder;
|
use OCP\Files\IRootFolder;
|
||||||
use OCP\IConfig;
|
use OCP\IConfig;
|
||||||
use OCP\IUserManager;
|
use OCP\IUserManager;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
class ImageCleanup implements IJob {
|
class ImageCleanup implements IJob {
|
||||||
private TalkService $talkService;
|
private TalkService $talkService;
|
||||||
private IRootFolder $rootFolder;
|
private IRootFolder $rootFolder;
|
||||||
private IUserManager $userManager;
|
private IUserManager $userManager;
|
||||||
|
private LoggerInterface $logger;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
TalkService $talkService,
|
TalkService $talkService,
|
||||||
IRootFolder $rootFolder,
|
IRootFolder $rootFolder,
|
||||||
IUserManager $userManager,
|
IUserManager $userManager,
|
||||||
|
LoggerInterface $logger,
|
||||||
) {
|
) {
|
||||||
$this->talkService = $talkService;
|
$this->talkService = $talkService;
|
||||||
$this->rootFolder = $rootFolder;
|
$this->rootFolder = $rootFolder;
|
||||||
$this->userManager = $userManager;
|
$this->userManager = $userManager;
|
||||||
|
$this->logger = $logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function run($argument = null): void {
|
public function run($argument = null): void {
|
||||||
@@ -41,7 +45,7 @@ class ImageCleanup implements IJob {
|
|||||||
$count = $this->talkService->purgeOldImages();
|
$count = $this->talkService->purgeOldImages();
|
||||||
|
|
||||||
if ($count > 0) {
|
if ($count > 0) {
|
||||||
\OC::$server->getLogger()->info(
|
$this->logger->info(
|
||||||
'NCdiscordhook: purged ' . $count . ' old image files',
|
'NCdiscordhook: purged ' . $count . ' old image files',
|
||||||
['app' => 'ncdiscordhook'],
|
['app' => 'ncdiscordhook'],
|
||||||
);
|
);
|
||||||
|
|||||||
+277
-50
@@ -10,10 +10,13 @@ use OCP\Files\IRootFolder;
|
|||||||
use OCP\Http\Client\IClient;
|
use OCP\Http\Client\IClient;
|
||||||
use OCP\Http\Client\IClientService;
|
use OCP\Http\Client\IClientService;
|
||||||
use OCP\IConfig;
|
use OCP\IConfig;
|
||||||
|
use OCP\IDBConnection;
|
||||||
use OCP\IRequest;
|
use OCP\IRequest;
|
||||||
use OCP\Security\Crypto\DefaultCrypto;
|
use OCP\Security\ICrypto;
|
||||||
use OCP\IURLGenerator;
|
use OCP\IURLGenerator;
|
||||||
use OCP\IUserManager;
|
use OCP\IUserManager;
|
||||||
|
use OCP\Share\IManager;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
class TalkService {
|
class TalkService {
|
||||||
private const APP_ID = 'ncdiscordhook';
|
private const APP_ID = 'ncdiscordhook';
|
||||||
@@ -21,28 +24,37 @@ class TalkService {
|
|||||||
|
|
||||||
private IClient $client;
|
private IClient $client;
|
||||||
private IConfig $config;
|
private IConfig $config;
|
||||||
|
private IDBConnection $db;
|
||||||
private IRootFolder $rootFolder;
|
private IRootFolder $rootFolder;
|
||||||
private DefaultCrypto $crypto;
|
private ICrypto $crypto;
|
||||||
private IURLGenerator $urlGenerator;
|
private IURLGenerator $urlGenerator;
|
||||||
private IUserManager $userManager;
|
private IUserManager $userManager;
|
||||||
private IRequest $request;
|
private IRequest $request;
|
||||||
|
private IManager $shareManager;
|
||||||
|
private LoggerInterface $logger;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
IClientService $clientService,
|
IClientService $clientService,
|
||||||
IConfig $config,
|
IConfig $config,
|
||||||
|
IDBConnection $db,
|
||||||
IRootFolder $rootFolder,
|
IRootFolder $rootFolder,
|
||||||
DefaultCrypto $crypto,
|
ICrypto $crypto,
|
||||||
IURLGenerator $urlGenerator,
|
IURLGenerator $urlGenerator,
|
||||||
IUserManager $userManager,
|
IUserManager $userManager,
|
||||||
IRequest $request,
|
IRequest $request,
|
||||||
|
IManager $shareManager,
|
||||||
|
LoggerInterface $logger,
|
||||||
) {
|
) {
|
||||||
$this->client = $clientService->newClient();
|
$this->client = $clientService->newClient();
|
||||||
$this->config = $config;
|
$this->config = $config;
|
||||||
|
$this->db = $db;
|
||||||
$this->rootFolder = $rootFolder;
|
$this->rootFolder = $rootFolder;
|
||||||
$this->crypto = $crypto;
|
$this->crypto = $crypto;
|
||||||
$this->urlGenerator = $urlGenerator;
|
$this->urlGenerator = $urlGenerator;
|
||||||
$this->userManager = $userManager;
|
$this->userManager = $userManager;
|
||||||
$this->request = $request;
|
$this->request = $request;
|
||||||
|
$this->shareManager = $shareManager;
|
||||||
|
$this->logger = $logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Bot password ──────────────────────────────────────────────
|
// ── Bot password ──────────────────────────────────────────────
|
||||||
@@ -71,6 +83,13 @@ class TalkService {
|
|||||||
return $this->userManager->get('talk-bot');
|
return $this->userManager->get('talk-bot');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the raw DB connection (for diagnostic use).
|
||||||
|
*/
|
||||||
|
public function getDbConnection(): IDBConnection {
|
||||||
|
return $this->db;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Retention ─────────────────────────────────────────────────
|
// ── Retention ─────────────────────────────────────────────────
|
||||||
|
|
||||||
public function getRetentionDays(): int {
|
public function getRetentionDays(): int {
|
||||||
@@ -81,6 +100,58 @@ class TalkService {
|
|||||||
$this->config->setAppValue(self::APP_ID, 'retention_days', (string) $days);
|
$this->config->setAppValue(self::APP_ID, 'retention_days', (string) $days);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Base URL ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the server base URL for internal API calls.
|
||||||
|
* Falls back to overwrite.cli.url when overwritewebroot is not set.
|
||||||
|
* When the URL resolves to localhost, falls back to the request host
|
||||||
|
* or trusted_domains — the HTTP client blocks localhost for SSRF safety.
|
||||||
|
*/
|
||||||
|
private function getBaseUrl(): string {
|
||||||
|
$overwritten = $this->config->getSystemValueString('overwritewebroot', '');
|
||||||
|
if ($overwritten !== '') {
|
||||||
|
return rtrim($overwritten, '/');
|
||||||
|
}
|
||||||
|
$cliUrl = $this->config->getSystemValueString('overwrite.cli.url', '');
|
||||||
|
if ($cliUrl === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$parsed = parse_url($cliUrl);
|
||||||
|
$scheme = $parsed['scheme'] ?? 'https';
|
||||||
|
$host = $parsed['host'] ?? '';
|
||||||
|
$port = $parsed['port'] ?? '';
|
||||||
|
$path = rtrim($parsed['path'] ?? '', '/');
|
||||||
|
$portStr = $port !== '' ? ':' . $port : '';
|
||||||
|
$baseUrl = $scheme . '://' . $host . $portStr . $path;
|
||||||
|
|
||||||
|
// HTTP client blocks localhost — fall back to request host or trusted_domains
|
||||||
|
if (in_array(strtolower($host), ['localhost', '127.0.0.1', '::1'], true)) {
|
||||||
|
$requestHost = $this->request->getServerHost();
|
||||||
|
if ($requestHost !== '') {
|
||||||
|
// Extract port from Host header (e.g. "example.com:443" or "example.com")
|
||||||
|
$hostHeader = $this->request->getHeader('Host');
|
||||||
|
$requestPort = 0;
|
||||||
|
if (str_contains($hostHeader, ':')) {
|
||||||
|
$parts = explode(':', $hostHeader);
|
||||||
|
$requestPort = (int) end($parts);
|
||||||
|
}
|
||||||
|
$basePort = parse_url($baseUrl, PHP_URL_PORT);
|
||||||
|
$requestPortStr = $requestPort !== 0 && $requestPort !== $basePort
|
||||||
|
? ':' . $requestPort
|
||||||
|
: '';
|
||||||
|
return $scheme . '://' . $requestHost . $requestPortStr . $path;
|
||||||
|
}
|
||||||
|
// Last resort: trusted_domains
|
||||||
|
$trusted = $this->config->getSystemValue('trusted_domains', []);
|
||||||
|
if (!empty($trusted[0])) {
|
||||||
|
return $scheme . '://' . $trusted[0] . $path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Rooms ─────────────────────────────────────────────────────
|
// ── Rooms ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -100,33 +171,154 @@ class TalkService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get available Talk rooms via API.
|
* Get available Talk rooms via database query.
|
||||||
|
*
|
||||||
|
* Queries the Talk DB directly to find rooms where the bot is a participant.
|
||||||
|
* Bypasses the OCS API which does not accept app passwords for auth.
|
||||||
|
* Returns rooms as [token => displayName] pairs.
|
||||||
*/
|
*/
|
||||||
public function getAvailableTalkRooms(): array {
|
public function getAvailableTalkRooms(): array {
|
||||||
$baseUrl = rtrim($this->config->getSystemValueString('overwritewebroot', ''), '/');
|
$botUser = $this->getBotUser();
|
||||||
$url = $baseUrl . '/ocs/v2.php/apps/spreed/api/v1/room';
|
if (!$botUser) {
|
||||||
|
return [];
|
||||||
try {
|
|
||||||
$response = $this->client->get($url, [
|
|
||||||
'auth' => 'basic',
|
|
||||||
'basic' => [
|
|
||||||
$this->config->getSystemValueString('adminuser', 'admin'),
|
|
||||||
$this->config->getSystemValueString('adminpass', ''),
|
|
||||||
],
|
|
||||||
'headers' => [
|
|
||||||
'OCS-Expect' => '100',
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$data = json_decode($response->getBody(), true);
|
|
||||||
if (isset($data['ocs']['data'])) {
|
|
||||||
return $data['ocs']['data'];
|
|
||||||
}
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
// Log but don't fail — rooms list is optional
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
$botUid = $botUser->getUID();
|
||||||
|
|
||||||
|
// Detect table names by querying PostgreSQL information_schema directly.
|
||||||
|
// We cannot rely on tableExists() — it has case-sensitivity issues with PostgreSQL
|
||||||
|
// where it may match a differently-cased table name but raw SQL still fails.
|
||||||
|
$roomTable = $this->detectTalkTableFromCatalog('talk_rooms', 'spreed_room');
|
||||||
|
$participantTable = $this->detectTalkTableFromCatalog('talk_attendees', 'spreed_participant');
|
||||||
|
|
||||||
|
if ($roomTable === null || $participantTable === null) {
|
||||||
|
$sysPrefix = $this->config->getSystemValueString('dbtableprefix', '');
|
||||||
|
$talkPrefix = $this->config->getAppValue('spreed', 'databaseprefix', $sysPrefix);
|
||||||
|
$this->logger->warning('NCdiscordhook: Talk tables not found', [
|
||||||
|
'app' => self::APP_ID,
|
||||||
|
'sysPrefix' => $sysPrefix,
|
||||||
|
'talkPrefix' => $talkPrefix,
|
||||||
|
]);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find rooms where the bot is a participant.
|
||||||
|
// Use raw SQL — query builder's expr() methods re-parse table names and double-prefix them.
|
||||||
|
$sql = 'SELECT "' . $roomTable . '".token, "' . $roomTable . '".name, "' . $roomTable . '".type
|
||||||
|
FROM "' . $roomTable . '"
|
||||||
|
JOIN "' . $participantTable . '" ON "' . $participantTable . '".room_id = "' . $roomTable . '".id
|
||||||
|
WHERE "' . $participantTable . '".actor_type = :actorType
|
||||||
|
AND "' . $participantTable . '".actor_id = :actorId
|
||||||
|
AND "' . $roomTable . '".type <> :excludeType';
|
||||||
|
|
||||||
|
$result = $this->db->executeQuery($sql, [
|
||||||
|
'actorType' => 'users',
|
||||||
|
'actorId' => $botUid,
|
||||||
|
'excludeType' => -1, // exclude UNKNOWN type
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->logger->info('NCdiscordhook: getAvailableTalkRooms', [
|
||||||
|
'app' => self::APP_ID,
|
||||||
|
'bot_user' => $botUid,
|
||||||
|
'room_table' => $roomTable,
|
||||||
|
'participant_table' => $participantTable,
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$rooms = [];
|
||||||
|
while ($row = $result->fetch()) {
|
||||||
|
$token = $row['token'];
|
||||||
|
$name = $row['name'] !== '' ? $row['name'] : $token;
|
||||||
|
$rooms[$token] = $name;
|
||||||
|
}
|
||||||
|
$result->closeCursor();
|
||||||
|
|
||||||
|
$this->logger->info('NCdiscordhook: found ' . count($rooms) . ' rooms', [
|
||||||
|
'app' => self::APP_ID,
|
||||||
|
'rooms' => array_keys($rooms),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $rooms;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->logger->error('NCdiscordhook: room listing exception', [
|
||||||
|
'app' => self::APP_ID,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'file' => $e->getFile(),
|
||||||
|
'line' => $e->getLine(),
|
||||||
|
]);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect which Talk table name variant exists in the database.
|
||||||
|
*
|
||||||
|
* Queries PostgreSQL information_schema directly to find actual table names,
|
||||||
|
* then verifies by checking column presence. Avoids tableExists() which has
|
||||||
|
* case-sensitivity issues with PostgreSQL.
|
||||||
|
*
|
||||||
|
* Talk app has its own database prefix (spreed.appconfig.databaseprefix),
|
||||||
|
* separate from the main dbtableprefix.
|
||||||
|
*/
|
||||||
|
private function detectTalkTableFromCatalog(string $newName, string $oldName): ?string {
|
||||||
|
$sysPrefix = $this->config->getSystemValueString('dbtableprefix', '');
|
||||||
|
$talkPrefix = $this->config->getAppValue('spreed', 'databaseprefix', $sysPrefix);
|
||||||
|
|
||||||
|
// Build candidate list ordered by likelihood
|
||||||
|
$candidates = [
|
||||||
|
$talkPrefix . $newName,
|
||||||
|
$sysPrefix . $newName,
|
||||||
|
$talkPrefix . $oldName,
|
||||||
|
$sysPrefix . $oldName,
|
||||||
|
$newName,
|
||||||
|
$oldName,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Deduplicate preserving order
|
||||||
|
$seen = [];
|
||||||
|
$unique = [];
|
||||||
|
foreach ($candidates as $name) {
|
||||||
|
if (!isset($seen[$name])) {
|
||||||
|
$seen[$name] = true;
|
||||||
|
$unique[] = $name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query information_schema to find which candidates actually exist as tables
|
||||||
|
try {
|
||||||
|
$result = $this->db->executeQuery(
|
||||||
|
'SELECT table_name FROM information_schema.tables WHERE table_schema = \'public\' AND table_type = \'BASE TABLE\' AND table_name IN (' . implode(',', array_map(fn($n) => '\'' . $n . '\'', $unique)) . ')',
|
||||||
|
);
|
||||||
|
$found = [];
|
||||||
|
while ($row = $result->fetch()) {
|
||||||
|
$found[] = $row['table_name'];
|
||||||
|
}
|
||||||
|
$result->closeCursor();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->logger->warning('NCdiscordhook: information_schema query failed', [
|
||||||
|
'app' => self::APP_ID,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try executing a lightweight query on each candidate to verify it's usable.
|
||||||
|
// This avoids the PostgreSQL case-sensitivity mismatch where tableExists()
|
||||||
|
// may match a differently-cased table but raw SQL fails.
|
||||||
|
foreach ($unique as $table) {
|
||||||
|
try {
|
||||||
|
$testResult = $this->db->executeQuery(
|
||||||
|
'SELECT 1 FROM "' . $table . '" LIMIT 1',
|
||||||
|
);
|
||||||
|
$testResult->closeCursor();
|
||||||
|
return $table;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Table exists in catalog but query failed — skip to next candidate
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Auth tokens ───────────────────────────────────────────────
|
// ── Auth tokens ───────────────────────────────────────────────
|
||||||
@@ -274,7 +466,7 @@ class TalkService {
|
|||||||
|
|
||||||
return ['data' => $body, 'mimeType' => $mimeType];
|
return ['data' => $body, 'mimeType' => $mimeType];
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
\OC::$server->getLogger()->error('Failed to download image: ' . $url, ['app' => self::APP_ID]);
|
$this->logger->error('Failed to download image: ' . $url, ['app' => self::APP_ID]);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -291,8 +483,8 @@ class TalkService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
$userFolder = $this->rootFolder->getUserFolder($bot->getUID());
|
$userFolder = $this->rootFolder->getUserFolder($bot->getUID());
|
||||||
$imagesDir = $userFolder->getFolder(self::IMAGES_DIR);
|
$imagesDir = $userFolder->getFolder(self::IMAGES_DIR, true);
|
||||||
$roomDir = $imagesDir->getFolder($roomToken);
|
$roomDir = $imagesDir->getFolder($roomToken, true);
|
||||||
|
|
||||||
// Avoid path traversal
|
// Avoid path traversal
|
||||||
$safeFilename = basename($filename);
|
$safeFilename = basename($filename);
|
||||||
@@ -300,29 +492,60 @@ class TalkService {
|
|||||||
|
|
||||||
return self::IMAGES_DIR . '/' . $roomToken . '/' . $safeFilename;
|
return self::IMAGES_DIR . '/' . $roomToken . '/' . $safeFilename;
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
\OC::$server->getLogger()->error('Failed to upload image: ' . $e->getMessage(), ['app' => self::APP_ID]);
|
$this->logger->error('Failed to upload image: ' . $e->getMessage(), ['app' => self::APP_ID]);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build rich object data for a Talk message from an uploaded file path.
|
* Build rich object data for a Talk message from an uploaded file path.
|
||||||
|
* Creates a public link share so Talk can resolve the rich object.
|
||||||
*/
|
*/
|
||||||
public function buildRichObject(string $filePath, string $mimeType, string $roomToken): array {
|
public function buildRichObject(string $filePath, string $mimeType, string $roomToken): ?array {
|
||||||
$baseUrl = rtrim($this->config->getSystemValueString('overwritewebroot', ''), '/');
|
$bot = $this->userManager->get('talk-bot');
|
||||||
$serverUrl = $this->config->getSystemValueString('overwrite.cli.url', 'https://example.com');
|
if (!$bot) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
try {
|
||||||
'type' => 'file',
|
$userFolder = $this->rootFolder->getUserFolder($bot->getUID());
|
||||||
'source' => 'share',
|
$file = $userFolder->get($filePath);
|
||||||
'fileId' => basename($filePath),
|
if (!$file || !$file->isReadable()) {
|
||||||
'sourceType' => 'file',
|
return null;
|
||||||
'mimetype' => $mimeType,
|
}
|
||||||
'title' => basename($filePath),
|
|
||||||
'description' => 'Webhook image attachment',
|
$share = $this->shareManager->newShare();
|
||||||
'thumbnailReady' => true,
|
$share->setNode($file)
|
||||||
'fileTarget' => '/' . $filePath,
|
->setPermissions(\OCP\Constants::PERMISSION_READ | \OCP\Constants::PERMISSION_DOWNLOAD)
|
||||||
];
|
->setType(\OCP\Constants::SHARE_TYPE_LINK)
|
||||||
|
->setName(basename($filePath));
|
||||||
|
$share = $this->shareManager->createShare($share);
|
||||||
|
|
||||||
|
$linkShare = $this->shareManager->getShareById($share->getId());
|
||||||
|
|
||||||
|
$serverUrl = $this->config->getSystemValueString('overwrite.cli.url', 'https://example.com');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'rich_object' => [
|
||||||
|
'id' => '',
|
||||||
|
'elements' => [
|
||||||
|
[
|
||||||
|
'type' => 'file',
|
||||||
|
'id' => $linkShare->getToken(),
|
||||||
|
'name' => basename($filePath),
|
||||||
|
'mimetype' => $mimeType,
|
||||||
|
'thumbnailReady' => true,
|
||||||
|
'fileTarget' => '/' . $filePath,
|
||||||
|
'path' => basename($filePath),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'source' => 'file',
|
||||||
|
];
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->logger->error('Failed to build rich object: ' . $e->getMessage(), ['app' => self::APP_ID]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Talk Chat API ─────────────────────────────────────────────
|
// ── Talk Chat API ─────────────────────────────────────────────
|
||||||
@@ -344,17 +567,17 @@ class TalkService {
|
|||||||
): bool {
|
): bool {
|
||||||
$bot = $this->userManager->get('talk-bot');
|
$bot = $this->userManager->get('talk-bot');
|
||||||
if (!$bot) {
|
if (!$bot) {
|
||||||
\OC::$server->getLogger()->error('talk-bot user not found', ['app' => self::APP_ID]);
|
$this->logger->error('talk-bot user not found', ['app' => self::APP_ID]);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$botPassword = $this->getBotPassword();
|
$botPassword = $this->getBotPassword();
|
||||||
if (!$botPassword) {
|
if (!$botPassword) {
|
||||||
\OC::$server->getLogger()->error('Bot password not configured', ['app' => self::APP_ID]);
|
$this->logger->error('Bot password not configured', ['app' => self::APP_ID]);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$baseUrl = rtrim($this->config->getSystemValueString('overwritewebroot', ''), '/');
|
$baseUrl = $this->getBaseUrl();
|
||||||
$url = $baseUrl . '/ocs/v2.php/apps/spreed/api/v1/chat/' . rawurlencode($roomToken);
|
$url = $baseUrl . '/ocs/v2.php/apps/spreed/api/v1/chat/' . rawurlencode($roomToken);
|
||||||
|
|
||||||
// Build form body
|
// Build form body
|
||||||
@@ -364,7 +587,11 @@ class TalkService {
|
|||||||
];
|
];
|
||||||
|
|
||||||
if (!empty($richObjects)) {
|
if (!empty($richObjects)) {
|
||||||
$bodyParts['richObjects'] = json_encode($richObjects);
|
$indexed = [];
|
||||||
|
foreach ($richObjects as $index => $richObj) {
|
||||||
|
$indexed['file-' . $index] = $richObj;
|
||||||
|
}
|
||||||
|
$bodyParts['richObjects'] = $indexed;
|
||||||
}
|
}
|
||||||
|
|
||||||
$body = http_build_query($bodyParts, '', '&', PHP_QUERY_RFC3986);
|
$body = http_build_query($bodyParts, '', '&', PHP_QUERY_RFC3986);
|
||||||
@@ -384,7 +611,7 @@ class TalkService {
|
|||||||
$httpCode = $response->getStatusCode();
|
$httpCode = $response->getStatusCode();
|
||||||
return $httpCode === 200;
|
return $httpCode === 200;
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
\OC::$server->getLogger()->error('Failed to post to Talk: ' . $e->getMessage(), ['app' => self::APP_ID]);
|
$this->logger->error('Failed to post to Talk: ' . $e->getMessage(), ['app' => self::APP_ID]);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -408,7 +635,7 @@ class TalkService {
|
|||||||
$imagesDir = $userFolder->getFolder(self::IMAGES_DIR);
|
$imagesDir = $userFolder->getFolder(self::IMAGES_DIR);
|
||||||
return $this->purgeFolder($imagesDir, $cutoff);
|
return $this->purgeFolder($imagesDir, $cutoff);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
\OC::$server->getLogger()->error('Image cleanup failed: ' . $e->getMessage(), ['app' => self::APP_ID]);
|
$this->logger->error('Image cleanup failed: ' . $e->getMessage(), ['app' => self::APP_ID]);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-8
@@ -3,6 +3,7 @@
|
|||||||
namespace OCA\NCdiscordhook\Settings;
|
namespace OCA\NCdiscordhook\Settings;
|
||||||
|
|
||||||
use OCA\NCdiscordhook\Service\TalkService;
|
use OCA\NCdiscordhook\Service\TalkService;
|
||||||
|
use OCP\AppFramework\Http\TemplateResponse;
|
||||||
use OCP\IL10N;
|
use OCP\IL10N;
|
||||||
use OCP\Settings\ISettings;
|
use OCP\Settings\ISettings;
|
||||||
|
|
||||||
@@ -15,7 +16,10 @@ class Admin implements ISettings {
|
|||||||
$this->l10n = $l10n;
|
$this->l10n = $l10n;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getForm(): string {
|
public function getForm(): TemplateResponse {
|
||||||
|
\OCP\Util::addStyle('ncdiscordhook', 'adminSettings');
|
||||||
|
\OCP\Util::addScript('ncdiscordhook', 'settings');
|
||||||
|
|
||||||
$params = [
|
$params = [
|
||||||
'hasBotPassword' => $this->talkService->hasBotPassword(),
|
'hasBotPassword' => $this->talkService->hasBotPassword(),
|
||||||
'retentionDays' => $this->talkService->getRetentionDays(),
|
'retentionDays' => $this->talkService->getRetentionDays(),
|
||||||
@@ -23,12 +27,8 @@ class Admin implements ISettings {
|
|||||||
'authTokens' => $this->talkService->getAuthTokens(),
|
'authTokens' => $this->talkService->getAuthTokens(),
|
||||||
];
|
];
|
||||||
|
|
||||||
$template = \OC::$server->get(\OCP\ITemplate\ITemplateFactory::class)->load('ncdiscordhook', 'adminSettings');
|
$response = new TemplateResponse('ncdiscordhook', 'adminSettings', $params);
|
||||||
foreach ($params as $key => $value) {
|
return $response;
|
||||||
$template->assign($key, $value);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $template->fetchPage();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getPriority(): int {
|
public function getPriority(): int {
|
||||||
@@ -36,6 +36,13 @@ class Admin implements ISettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function getSection(): string {
|
public function getSection(): string {
|
||||||
return 'server';
|
return 'additional';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getIcons(): array {
|
||||||
|
$urlGenerator = \OC::$server->get(\OCP\IURLGenerator::class);
|
||||||
|
return [
|
||||||
|
$urlGenerator->imagePath('ncdiscordhook', 'app.svg'),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-282
@@ -1,292 +1,24 @@
|
|||||||
<script>
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
|
||||||
const APP_ID = 'ncdiscordhook';
|
|
||||||
|
|
||||||
// Elements
|
|
||||||
const botPasswordInput = document.getElementById('nc-bot-password');
|
|
||||||
const retentionInput = document.getElementById('nc-retention');
|
|
||||||
const fetchRoomsBtn = document.getElementById('nc-fetch-rooms');
|
|
||||||
const roomsList = document.getElementById('nc-rooms-list');
|
|
||||||
const saveBtn = document.getElementById('nc-save');
|
|
||||||
const statusMsg = document.getElementById('nc-status');
|
|
||||||
|
|
||||||
let configuredRooms = <?= json_encode($rooms ?? []) ?>;
|
|
||||||
let authTokens = <?= json_encode($authTokens ?? []) ?>;
|
|
||||||
|
|
||||||
function showStatus(msg, type) {
|
|
||||||
statusMsg.textContent = msg;
|
|
||||||
statusMsg.className = 'nc-status-' + type;
|
|
||||||
setTimeout(() => { statusMsg.textContent = ''; }, 5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch available Talk rooms
|
|
||||||
fetchRoomsBtn.addEventListener('click', async function () {
|
|
||||||
fetchRoomsBtn.disabled = true;
|
|
||||||
fetchRoomsBtn.textContent = 'Fetching...';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const resp = await fetch(OC.generateUrl('/apps/' + APP_ID + '/rooms'));
|
|
||||||
const data = await resp.json();
|
|
||||||
|
|
||||||
roomsList.innerHTML = '';
|
|
||||||
|
|
||||||
if (data.length === 0) {
|
|
||||||
roomsList.innerHTML = '<p class="nc-empty">No Talk rooms found. Create rooms first via the Nextcloud Talk settings.</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
data.forEach(function (room) {
|
|
||||||
const label = document.createElement('label');
|
|
||||||
label.className = 'nc-room-item';
|
|
||||||
|
|
||||||
const cb = document.createElement('input');
|
|
||||||
cb.type = 'checkbox';
|
|
||||||
cb.value = room.token;
|
|
||||||
cb.id = 'room-' + room.token;
|
|
||||||
if (room.configured) {
|
|
||||||
cb.checked = true;
|
|
||||||
cb.disabled = true;
|
|
||||||
}
|
|
||||||
cb.addEventListener('change', function () { toggleRoomTokens(room.token, this.checked); });
|
|
||||||
|
|
||||||
const nameSpan = document.createElement('span');
|
|
||||||
nameSpan.textContent = room.name || room.token;
|
|
||||||
|
|
||||||
label.appendChild(cb);
|
|
||||||
label.appendChild(nameSpan);
|
|
||||||
roomsList.appendChild(label);
|
|
||||||
|
|
||||||
// Auth tokens container
|
|
||||||
const tokenDiv = document.createElement('div');
|
|
||||||
tokenDiv.className = 'nc-room-tokens';
|
|
||||||
tokenDiv.id = 'tokens-' + room.token;
|
|
||||||
tokenDiv.style.display = 'none';
|
|
||||||
if (room.configured || (authTokens[room.token] && authTokens[room.token].length > 0)) {
|
|
||||||
tokenDiv.style.display = 'block';
|
|
||||||
renderTokens(room.token, tokenDiv);
|
|
||||||
}
|
|
||||||
roomsList.appendChild(tokenDiv);
|
|
||||||
});
|
|
||||||
|
|
||||||
fetchRoomsBtn.textContent = 'Refresh Rooms';
|
|
||||||
} catch (err) {
|
|
||||||
showStatus('Failed to fetch rooms', 'error');
|
|
||||||
fetchRoomsBtn.textContent = 'Fetch Rooms';
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchRoomsBtn.disabled = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Render auth tokens for a room
|
|
||||||
function renderTokens(roomToken, container) {
|
|
||||||
container.innerHTML = '';
|
|
||||||
|
|
||||||
const tokens = authTokens[roomToken] || [];
|
|
||||||
|
|
||||||
tokens.forEach(function (token) {
|
|
||||||
const row = document.createElement('div');
|
|
||||||
row.className = 'nc-token-row';
|
|
||||||
|
|
||||||
const input = document.createElement('input');
|
|
||||||
input.type = 'text';
|
|
||||||
input.value = token;
|
|
||||||
input.readOnly = true;
|
|
||||||
input.className = 'nc-token-input';
|
|
||||||
|
|
||||||
const copyBtn = document.createElement('button');
|
|
||||||
copyBtn.type = 'button';
|
|
||||||
copyBtn.className = 'nc-token-copy';
|
|
||||||
copyBtn.textContent = 'Copy';
|
|
||||||
copyBtn.addEventListener('click', function () {
|
|
||||||
navigator.clipboard.writeText(token).then(() => {
|
|
||||||
copyBtn.textContent = 'Copied!';
|
|
||||||
setTimeout(() => { copyBtn.textContent = 'Copy'; }, 2000);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const revokeBtn = document.createElement('button');
|
|
||||||
revokeBtn.type = 'button';
|
|
||||||
revokeBtn.className = 'nc-token-revoke';
|
|
||||||
revokeBtn.textContent = 'Revoke';
|
|
||||||
revokeBtn.addEventListener('click', function () {
|
|
||||||
if (!confirm('Revoke this auth token?')) return;
|
|
||||||
tokens.splice(tokens.indexOf(token), 1);
|
|
||||||
if (tokens.length === 0) {
|
|
||||||
delete authTokens[roomToken];
|
|
||||||
container.style.display = 'none';
|
|
||||||
} else {
|
|
||||||
authTokens[roomToken] = tokens;
|
|
||||||
renderTokens(roomToken, container);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
row.appendChild(input);
|
|
||||||
row.appendChild(copyBtn);
|
|
||||||
row.appendChild(revokeBtn);
|
|
||||||
container.appendChild(row);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Generate new token button
|
|
||||||
const genBtn = document.createElement('button');
|
|
||||||
genBtn.type = 'button';
|
|
||||||
genBtn.className = 'nc-generate-token';
|
|
||||||
genBtn.textContent = '+ Generate Auth Token';
|
|
||||||
genBtn.addEventListener('click', function () {
|
|
||||||
if (!authTokens[roomToken]) {
|
|
||||||
authTokens[roomToken] = [];
|
|
||||||
}
|
|
||||||
authTokens[roomToken].push(btoa(Math.random().toString(36).substring(2) + Date.now().toString(36)).replace(/=/g, ''));
|
|
||||||
renderTokens(roomToken, container);
|
|
||||||
container.style.display = 'block';
|
|
||||||
});
|
|
||||||
container.appendChild(genBtn);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Toggle room token display
|
|
||||||
function toggleRoomTokens(roomToken, checked) {
|
|
||||||
const tokenDiv = document.getElementById('tokens-' + roomToken);
|
|
||||||
if (tokenDiv) {
|
|
||||||
tokenDiv.style.display = checked ? 'block' : 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save configuration
|
|
||||||
saveBtn.addEventListener('click', async function () {
|
|
||||||
saveBtn.disabled = true;
|
|
||||||
saveBtn.textContent = 'Saving...';
|
|
||||||
|
|
||||||
// Collect configured rooms
|
|
||||||
const rooms = {};
|
|
||||||
document.querySelectorAll('#nc-rooms-list input[type="checkbox"]:checked').forEach(function (cb) {
|
|
||||||
const token = cb.value;
|
|
||||||
rooms[token] = ''; // name will be filled from existing config
|
|
||||||
});
|
|
||||||
|
|
||||||
// Restore names from existing config for newly checked rooms
|
|
||||||
Object.keys(configuredRooms).forEach(function (token) {
|
|
||||||
if (rooms[token] === undefined) {
|
|
||||||
rooms[token] = configuredRooms[token];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
rooms: rooms,
|
|
||||||
auth_tokens: authTokens,
|
|
||||||
retention_days: parseInt(retentionInput.value) || 90,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (botPasswordInput.value) {
|
|
||||||
payload.bot_password = botPasswordInput.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const resp = await fetch(OC.generateUrl('/apps/' + APP_ID + '/save-config'), {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
const data = await resp.json();
|
|
||||||
|
|
||||||
if (data.status === 'ok') {
|
|
||||||
configuredRooms = rooms;
|
|
||||||
showStatus('Configuration saved', 'success');
|
|
||||||
botPasswordInput.value = '';
|
|
||||||
} else {
|
|
||||||
showStatus('Save failed: ' + (data.error || 'unknown error'), 'error');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
showStatus('Save failed: ' + err.message, 'error');
|
|
||||||
}
|
|
||||||
|
|
||||||
saveBtn.disabled = false;
|
|
||||||
saveBtn.textContent = 'Save Configuration';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.nc-settings-section {
|
|
||||||
margin-bottom: 2em;
|
|
||||||
padding: 1em;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
.nc-settings-section h3 {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
.nc-room-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 4px 0;
|
|
||||||
}
|
|
||||||
.nc-room-item span {
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
.nc-room-tokens {
|
|
||||||
margin-left: 24px;
|
|
||||||
margin-top: 8px;
|
|
||||||
padding: 8px;
|
|
||||||
background: var(--color-background-dark);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
.nc-token-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
.nc-token-input {
|
|
||||||
flex: 1;
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: 0.85em;
|
|
||||||
background: var(--color-main-background);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
.nc-token-copy {
|
|
||||||
padding: 4px 8px;
|
|
||||||
background: var(--color-primary-element);
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 3px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.85em;
|
|
||||||
}
|
|
||||||
.nc-token-revoke {
|
|
||||||
padding: 4px 8px;
|
|
||||||
background: var(--color-error);
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 3px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.85em;
|
|
||||||
}
|
|
||||||
.nc-generate-token {
|
|
||||||
margin-top: 8px;
|
|
||||||
padding: 4px 12px;
|
|
||||||
background: var(--color-primary-element);
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 3px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.nc-empty {
|
|
||||||
color: var(--color-text-lighter);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
.nc-status-success { color: var(--color-success); }
|
|
||||||
.nc-status-error { color: var(--color-error); }
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<div class="nc-settings-section">
|
<div class="nc-settings-section">
|
||||||
<h3>Bot Configuration</h3>
|
<h3>Bot Configuration</h3>
|
||||||
<label for="nc-bot-password">Bot App Password</label><br>
|
<label for="nc-bot-password">Bot App Password</label><br>
|
||||||
<input type="password" id="nc-bot-password" placeholder="<?= $hasBotPassword ? '•••••••• (leave blank to keep current)' : 'Paste talk-bot app password here' ?>">
|
<div style="display: flex; gap: 8px; align-items: center;">
|
||||||
|
<input type="password" id="nc-bot-password" placeholder="<?= $hasBotPassword ? '•••••••• (leave blank to keep current)' : 'Paste talk-bot app password here' ?>" style="flex: 1;">
|
||||||
|
<button id="nc-save-password" type="button" style="display: none;">Save</button>
|
||||||
|
</div>
|
||||||
<p class="nc-hint">
|
<p class="nc-hint">
|
||||||
Generate in Nextcloud Settings → <strong>talk-bot</strong> → Devices & sessions → <strong>Add device</strong>.
|
Generate in Nextcloud Settings → <strong>talk-bot</strong> → Devices & sessions → <strong>Add device</strong>.
|
||||||
<?= $hasBotPassword ? 'Leave blank to keep current password.' : 'Required to send messages to Talk.' ?>
|
<?= $hasBotPassword ? 'Leave blank to keep current password.' : 'Required to send messages to Talk.' ?>
|
||||||
</p>
|
</p>
|
||||||
|
<p class="nc-hint" style="margin-top: 4px;">
|
||||||
|
<strong>The bot user must be an admin</strong> to list all Talk rooms. Grant admin access in <strong>Settings → Users → [your-bot-user] → Admin</strong>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Config data passed to JS via data attributes -->
|
||||||
|
<div id="nc-config-data"
|
||||||
|
data-configured-rooms="<?= json_encode($rooms ?? []) ?>"
|
||||||
|
data-auth-tokens="<?= json_encode($authTokens ?? []) ?>"
|
||||||
|
style="display:none;">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="nc-settings-section">
|
<div class="nc-settings-section">
|
||||||
|
|||||||
Reference in New Issue
Block a user