feat: update for Nextcloud 33 and refactor Talk integration
Channel list working, url generation, just need posting working now - Update dependency to Nextcloud 33 and add `spreed` dependency - Change webhook route from `/webhook` to `/bot-webhook` - Add `NavigationProvider` to add app to admin settings menu - Refactor `TalkService` to query Talk DB directly for room listing (public channels) - Update `postToRoom` to use Talk Chat API with Basic Auth for `talk-bot` user - Add debug helper methods to `TalkService` for room debugging - Enhance `debug` endpoint with detailed system and Talk configuration info - Update admin settings UI with "Default Sender Name" field and improved room selection - Add localization strings to admin settings - Update documentation (`INSTALL.md`, `README.md`) for new route and bot admin requirement - Improve `getBaseUrl` logic to prioritize `trusted_domains` for Docker compatibility
This commit is contained in:
+18
-8
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
## Step 1: Enable the `talk-bot` user
|
## Step 1: Enable the `talk-bot` user
|
||||||
|
|
||||||
The app posts messages as a dedicated bot user. You must create this user and generate an app password.
|
The app posts messages as a dedicated bot user using the Talk Chat API (Basic auth). You must create this user and generate an app password.
|
||||||
|
|
||||||
### Create the bot user (via `occ`):
|
### Create the bot user (via `occ`):
|
||||||
|
|
||||||
@@ -20,6 +20,15 @@ export OC_PASS="your-bot-password-here"
|
|||||||
php occ user:add --password-from-env talk-bot
|
php occ user:add --password-from-env talk-bot
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Make the bot user an admin
|
||||||
|
|
||||||
|
The bot must have admin privileges to list all Talk rooms. Grant admin access:
|
||||||
|
|
||||||
|
1. Go to **Settings → Users**
|
||||||
|
2. Find **talk-bot** and click it
|
||||||
|
3. Check **Admin** under "Settings"
|
||||||
|
4. Save
|
||||||
|
|
||||||
### Generate an app password for the bot:
|
### Generate an app password for the bot:
|
||||||
|
|
||||||
1. Log in to Nextcloud as **admin**
|
1. Log in to Nextcloud as **admin**
|
||||||
@@ -65,11 +74,12 @@ php occ app:enable ncdiscordhook
|
|||||||
1. Log in to Nextcloud as **admin**
|
1. Log in to Nextcloud as **admin**
|
||||||
2. Go to **Settings → Admin → NCdiscordhook**
|
2. Go to **Settings → Admin → NCdiscordhook**
|
||||||
3. **Bot App Password** — Paste the device password you generated in Step 1
|
3. **Bot App Password** — Paste the device password you generated in Step 1
|
||||||
4. **Image Retention** — Set how many days to keep uploaded images (default: 90)
|
4. **Default Sender Name** — Set the name that appears as the message sender (default: "Webhook Bot")
|
||||||
5. **Room Management** — Click **Fetch Rooms** to list available Talk rooms
|
5. **Image Retention** — Set how many days to keep uploaded images (default: 90)
|
||||||
6. Check the rooms you want to accept webhooks for
|
6. **Room Selection** — Click **Fetch Rooms** to list available Talk rooms
|
||||||
7. For each room, click **+ Generate Auth Token** to create a webhook URL
|
7. Check the rooms you want to accept webhooks for
|
||||||
8. Click **Save Configuration**
|
8. For each room, click **+ Generate Auth Token** to create a webhook URL
|
||||||
|
9. Click **Save Configuration**
|
||||||
|
|
||||||
## Step 4: Set up the Discord webhook
|
## Step 4: Set up the Discord webhook
|
||||||
|
|
||||||
@@ -78,7 +88,7 @@ php occ app:enable ncdiscordhook
|
|||||||
3. Set the Webhook URL to:
|
3. Set the Webhook URL to:
|
||||||
|
|
||||||
```
|
```
|
||||||
https://your-nextcloud-server/apps/ncdiscordhook/webhook/<room-token>/<auth-token>
|
https://your-nextcloud-server/apps/ncdiscordhook/bot-webhook/<room-token>/<auth-token>
|
||||||
```
|
```
|
||||||
|
|
||||||
Replace `<room-token>` and `<auth-token>` with the values shown in the Nextcloud admin settings for the selected room.
|
Replace `<room-token>` and `<auth-token>` with the values shown in the Nextcloud admin settings for the selected room.
|
||||||
@@ -95,7 +105,7 @@ Test with curl:
|
|||||||
curl -X POST \
|
curl -X POST \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"content":"Test message from NCdiscordhook","username":"CI Bot"}' \
|
-d '{"content":"Test message from NCdiscordhook","username":"CI Bot"}' \
|
||||||
https://your-nextcloud-server/apps/ncdiscordhook/webhook/<room-token>/<auth-token>
|
https://your-nextcloud-server/apps/ncdiscordhook/bot-webhook/<room-token>/<auth-token>
|
||||||
```
|
```
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ Go to **Settings → Admin → NCdiscordhook**:
|
|||||||
Each configured room gets a webhook URL:
|
Each configured room gets a webhook URL:
|
||||||
|
|
||||||
```
|
```
|
||||||
https://your-server.com/apps/ncdiscordhook/webhook/<room-token>/<auth-token>
|
https://your-server.com/apps/ncdiscordhook/bot-webhook/<room-token>/<auth-token>
|
||||||
```
|
```
|
||||||
|
|
||||||
Copy the auth token from the app settings for each room.
|
Copy the auth token from the app settings for each room.
|
||||||
@@ -92,7 +92,7 @@ Copy the auth token from the app settings for each room.
|
|||||||
Each room gets its own webhook URL with a unique auth token:
|
Each room gets its own webhook URL with a unique auth token:
|
||||||
|
|
||||||
```
|
```
|
||||||
/webhook/<room-token>/<auth-token>
|
/bot-webhook/<room-token>/<auth-token>
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Room token** — identifies which Talk room to post to (from `occ talk:room:list`)
|
- **Room token** — identifies which Talk room to post to (from `occ talk:room:list`)
|
||||||
|
|||||||
+2
-1
@@ -12,7 +12,8 @@
|
|||||||
<category>integration</category>
|
<category>integration</category>
|
||||||
|
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<nextcloud min-version="28" max-version="33"/>
|
<nextcloud min-version="33" max-version="33"/>
|
||||||
|
<app>spreed</app>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<background-jobs>
|
<background-jobs>
|
||||||
|
|||||||
+3
-6
@@ -1,9 +1,10 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'routes' => [
|
'routes' => [
|
||||||
[
|
[
|
||||||
'name' => 'webhook#receive',
|
'name' => 'webhook#receive',
|
||||||
'url' => '/webhook/{roomToken}/{authToken}',
|
'url' => '/bot-webhook/{roomToken}/{token}',
|
||||||
'verb' => 'POST',
|
'verb' => 'POST',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
@@ -25,11 +26,7 @@ return [
|
|||||||
'name' => 'webhook#debug',
|
'name' => 'webhook#debug',
|
||||||
'url' => '/debug',
|
'url' => '/debug',
|
||||||
'verb' => 'GET',
|
'verb' => 'GET',
|
||||||
],
|
'type' => 'noCsrf',
|
||||||
[
|
|
||||||
'name' => 'webhook#debugTables',
|
|
||||||
'url' => '/debug-tables',
|
|
||||||
'verb' => 'GET',
|
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
+13
-14
@@ -12,9 +12,11 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
const dataEl = document.getElementById('nc-config-data');
|
const dataEl = document.getElementById('nc-config-data');
|
||||||
const configuredRooms = parseData(dataEl, 'configuredRooms');
|
const configuredRooms = parseData(dataEl, 'configuredRooms');
|
||||||
const authTokens = parseData(dataEl, 'authTokens');
|
const authTokens = parseData(dataEl, 'authTokens');
|
||||||
|
const serverUrl = dataEl ? (dataEl.dataset.serverUrl || '') : '';
|
||||||
|
|
||||||
// Elements
|
// Elements
|
||||||
const botPasswordInput = document.getElementById('nc-bot-password');
|
const botPasswordInput = document.getElementById('nc-bot-password');
|
||||||
|
const senderNameInput = document.getElementById('nc-sender-name');
|
||||||
const retentionInput = document.getElementById('nc-retention');
|
const retentionInput = document.getElementById('nc-retention');
|
||||||
const fetchRoomsBtn = document.getElementById('nc-fetch-rooms');
|
const fetchRoomsBtn = document.getElementById('nc-fetch-rooms');
|
||||||
const roomsList = document.getElementById('nc-rooms-list');
|
const roomsList = document.getElementById('nc-rooms-list');
|
||||||
@@ -63,9 +65,16 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
cb.id = 'room-' + room.token;
|
cb.id = 'room-' + room.token;
|
||||||
if (room.configured) {
|
if (room.configured) {
|
||||||
cb.checked = true;
|
cb.checked = true;
|
||||||
cb.disabled = true;
|
|
||||||
}
|
}
|
||||||
cb.addEventListener('change', function () { toggleRoomTokens(room.token, this.checked); });
|
cb.addEventListener('change', function () {
|
||||||
|
const tokenDiv = document.getElementById('tokens-' + room.token);
|
||||||
|
if (tokenDiv) {
|
||||||
|
tokenDiv.style.display = cb.checked ? 'block' : 'none';
|
||||||
|
if (cb.checked) {
|
||||||
|
renderTokens(room.token, tokenDiv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
var nameSpan = document.createElement('span');
|
var nameSpan = document.createElement('span');
|
||||||
nameSpan.textContent = room.name || room.token;
|
nameSpan.textContent = room.name || room.token;
|
||||||
@@ -107,7 +116,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
|
|
||||||
const tokens = authTokensState[roomToken] || [];
|
const tokens = authTokensState[roomToken] || [];
|
||||||
// Build the full webhook URL with server origin
|
// Build the full webhook URL with server origin
|
||||||
var webhookPath = OC.generateUrl('/apps/' + APP_ID + '/webhook/') + roomToken + '/{token}';
|
var webhookPath = OC.generateUrl('/apps/' + APP_ID + '/bot-webhook/') + roomToken + '/{token}';
|
||||||
var webhookUrl = window.location.protocol + '//' + window.location.host + webhookPath;
|
var webhookUrl = window.location.protocol + '//' + window.location.host + webhookPath;
|
||||||
|
|
||||||
if (tokens.length === 0) {
|
if (tokens.length === 0) {
|
||||||
@@ -186,17 +195,6 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
container.appendChild(genBtn);
|
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';
|
|
||||||
if (checked) {
|
|
||||||
renderTokens(roomToken, tokenDiv);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save bot password only
|
// Save bot password only
|
||||||
savePasswordBtn.addEventListener('click', async function () {
|
savePasswordBtn.addEventListener('click', async function () {
|
||||||
var pw = botPasswordInput.value.trim();
|
var pw = botPasswordInput.value.trim();
|
||||||
@@ -250,6 +248,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
rooms: rooms,
|
rooms: rooms,
|
||||||
auth_tokens: authTokensState,
|
auth_tokens: authTokensState,
|
||||||
retention_days: parseInt(retentionInput.value) || 90,
|
retention_days: parseInt(retentionInput.value) || 90,
|
||||||
|
sender_name: senderNameInput.value || 'Webhook Bot',
|
||||||
};
|
};
|
||||||
|
|
||||||
if (botPasswordInput.value) {
|
if (botPasswordInput.value) {
|
||||||
|
|||||||
@@ -7,40 +7,51 @@ use OCP\AppFramework\Controller;
|
|||||||
use OCP\AppFramework\Http;
|
use OCP\AppFramework\Http;
|
||||||
use OCP\AppFramework\Http\DataResponse;
|
use OCP\AppFramework\Http\DataResponse;
|
||||||
use OCP\AppFramework\Http\JSONResponse;
|
use OCP\AppFramework\Http\JSONResponse;
|
||||||
use OCP\AppFramework\Middleware\Security\CSRF\Token;
|
|
||||||
use OCP\AppFramework\Middleware\Security\CSRF\TokenStore;
|
|
||||||
use OCP\AppFramework\Utility\IControllerMethodReflector;
|
|
||||||
use OCP\Http\Client\IClientService;
|
|
||||||
use OCP\IRequest;
|
|
||||||
use OCP\IUserSession;
|
|
||||||
use OCP\AppFramework\Controller\Attribute\AdminRequired;
|
|
||||||
use Psr\Log\LoggerInterface;
|
|
||||||
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
||||||
use OCP\AppFramework\Http\Attribute\PublicPage;
|
use OCP\AppFramework\Http\Attribute\PublicPage;
|
||||||
use OCP\AppFramework\Http\Attribute\SubAdminRequired;
|
use OCP\AppFramework\Http\Attribute\AdminRequired;
|
||||||
|
use OCP\App\IAppManager;
|
||||||
|
use OCP\IAppConfig;
|
||||||
|
use OCP\IConfig;
|
||||||
|
use OCP\IGroupManager;
|
||||||
|
use OCP\IRequest;
|
||||||
|
use OCP\IUserSession;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
class WebhookController extends Controller {
|
class WebhookController extends Controller {
|
||||||
private TalkService $talkService;
|
private TalkService $talkService;
|
||||||
private LoggerInterface $logger;
|
private LoggerInterface $logger;
|
||||||
|
private IAppManager $appManager;
|
||||||
|
private IUserSession $userSession;
|
||||||
|
private IGroupManager $groupManager;
|
||||||
|
private IConfig $config;
|
||||||
|
private IAppConfig $appConfig;
|
||||||
|
|
||||||
public function __construct(IRequest $request, TalkService $talkService, LoggerInterface $logger) {
|
public function __construct(IRequest $request, TalkService $talkService, LoggerInterface $logger, IAppManager $appManager, IUserSession $userSession, IGroupManager $groupManager, IConfig $config, IAppConfig $appConfig) {
|
||||||
// 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;
|
$this->logger = $logger;
|
||||||
|
$this->appManager = $appManager;
|
||||||
|
$this->userSession = $userSession;
|
||||||
|
$this->groupManager = $groupManager;
|
||||||
|
$this->config = $config;
|
||||||
|
$this->appConfig = $appConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Receive Discord webhook payload for a room.
|
* Receive Discord webhook payload for a room.
|
||||||
*
|
*
|
||||||
* URL: POST /apps/ncdiscordhook/webhook/{roomToken}/{authToken}
|
* URL: POST /apps/ncdiscordhook/bot-webhook/{roomToken}/{token}
|
||||||
*/
|
*/
|
||||||
#[PublicPage]
|
#[PublicPage]
|
||||||
#[NoCSRFRequired]
|
#[NoCSRFRequired]
|
||||||
public function receive(string $roomToken, string $authToken): DataResponse {
|
public function receive(string $roomToken, string $token): DataResponse {
|
||||||
// Validate auth token
|
// Validate auth token
|
||||||
if (!$this->talkService->validateAuthToken($roomToken, $authToken)) {
|
if (!$this->talkService->validateAuthToken($roomToken, $token)) {
|
||||||
|
$this->logger->warning('NCdiscordhook: invalid auth token for room', [
|
||||||
|
'app' => 'ncdiscordhook',
|
||||||
|
'room_token' => $roomToken,
|
||||||
|
]);
|
||||||
return new DataResponse(
|
return new DataResponse(
|
||||||
['error' => 'Unauthorized'],
|
['error' => 'Unauthorized'],
|
||||||
Http::STATUS_UNAUTHORIZED,
|
Http::STATUS_UNAUTHORIZED,
|
||||||
@@ -52,6 +63,10 @@ class WebhookController extends Controller {
|
|||||||
$body = file_get_contents('php://input');
|
$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)) {
|
||||||
|
$this->logger->warning('NCdiscordhook: invalid JSON from webhook', [
|
||||||
|
'app' => 'ncdiscordhook',
|
||||||
|
'room_token' => $roomToken,
|
||||||
|
]);
|
||||||
return new DataResponse(
|
return new DataResponse(
|
||||||
['error' => 'Invalid JSON'],
|
['error' => 'Invalid JSON'],
|
||||||
Http::STATUS_BAD_REQUEST,
|
Http::STATUS_BAD_REQUEST,
|
||||||
@@ -109,10 +124,14 @@ class WebhookController extends Controller {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Post to Talk
|
// Post to Talk via Chat API
|
||||||
$success = $this->talkService->postToRoom($roomToken, $message, $senderName, $richObjects);
|
$success = $this->talkService->postToRoom($roomToken, $message, $senderName, $richObjects);
|
||||||
|
|
||||||
if ($success) {
|
if ($success) {
|
||||||
|
$this->logger->info('NCdiscordhook: webhook processed successfully', [
|
||||||
|
'app' => 'ncdiscordhook',
|
||||||
|
'room_token' => $roomToken,
|
||||||
|
]);
|
||||||
return new DataResponse(
|
return new DataResponse(
|
||||||
['status' => 'ok'],
|
['status' => 'ok'],
|
||||||
Http::STATUS_CREATED,
|
Http::STATUS_CREATED,
|
||||||
@@ -120,6 +139,10 @@ class WebhookController extends Controller {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->logger->error('NCdiscordhook: failed to post webhook message to Talk', [
|
||||||
|
'app' => 'ncdiscordhook',
|
||||||
|
'room_token' => $roomToken,
|
||||||
|
]);
|
||||||
return new DataResponse(
|
return new DataResponse(
|
||||||
['error' => 'Failed to post to Talk'],
|
['error' => 'Failed to post to Talk'],
|
||||||
Http::STATUS_INTERNAL_SERVER_ERROR,
|
Http::STATUS_INTERNAL_SERVER_ERROR,
|
||||||
@@ -176,12 +199,18 @@ class WebhookController extends Controller {
|
|||||||
*
|
*
|
||||||
* URL: GET /apps/ncdiscordhook/debug
|
* URL: GET /apps/ncdiscordhook/debug
|
||||||
*/
|
*/
|
||||||
|
#[PublicPage]
|
||||||
#[NoCSRFRequired]
|
#[NoCSRFRequired]
|
||||||
|
#[NoAdminRequired]
|
||||||
public function debug(): DataResponse {
|
public function debug(): DataResponse {
|
||||||
|
$user = $this->userSession->getUser();
|
||||||
|
$uid = $user !== null ? $user->getUID() : null;
|
||||||
|
$isAdmin = $user !== null && $this->groupManager->isAdmin($uid);
|
||||||
|
|
||||||
$info = [
|
$info = [
|
||||||
'app_enabled' => \OC_App::isEnabled('ncdiscordhook'),
|
'app_enabled' => $this->appManager->isInstalled('ncdiscordhook'),
|
||||||
'user' => \OC_User::getUser(),
|
'user' => $uid,
|
||||||
'user_is_admin' => \OC_User::isAdminUser(\OC_User::getUser()),
|
'user_is_admin' => $isAdmin,
|
||||||
'bot_user' => null,
|
'bot_user' => null,
|
||||||
'has_bot_password' => false,
|
'has_bot_password' => false,
|
||||||
];
|
];
|
||||||
@@ -194,60 +223,104 @@ class WebhookController extends Controller {
|
|||||||
$info['bot_error'] = $e->getMessage();
|
$info['bot_error'] = $e->getMessage();
|
||||||
}
|
}
|
||||||
|
|
||||||
return new DataResponse($info);
|
// System config values
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Diagnostic endpoint to discover Talk table names.
|
|
||||||
*
|
|
||||||
* URL: GET /apps/ncdiscordhook/debug-tables
|
|
||||||
*/
|
|
||||||
#[NoCSRFRequired]
|
|
||||||
public function debugTables(): DataResponse {
|
|
||||||
$result = [];
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$conn = $this->talkService->getDbConnection();
|
$info['system_config'] = [
|
||||||
$pdo = $conn->getConnection()->getWrappedConnection();
|
'trusted_domains' => $this->config->getSystemValue('trusted_domains', []),
|
||||||
|
'overwrite.cli.url' => $this->config->getSystemValueString('overwrite.cli.url', ''),
|
||||||
// List all tables that could be Talk-related
|
'overwritewebroot' => $this->config->getSystemValueString('overwritewebroot', ''),
|
||||||
$tables = $pdo->query("
|
'dbtableprefix' => $this->config->getSystemValueString('dbtableprefix', ''),
|
||||||
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) {
|
} catch (\Exception $e) {
|
||||||
$result['error'] = $e->getMessage();
|
$info['system_config_error'] = $e->getMessage();
|
||||||
}
|
}
|
||||||
|
|
||||||
return new DataResponse($result);
|
// AppConfig values
|
||||||
|
try {
|
||||||
|
$appConfig = $this->appConfig->getAllValues('ncdiscordhook');
|
||||||
|
$info['app_config_keys'] = $appConfig;
|
||||||
|
$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();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$info['app_config_error'] = $e->getMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Talk table schema and sample data
|
||||||
|
try {
|
||||||
|
$talkService = $this->talkService;
|
||||||
|
|
||||||
|
// Resolve database prefix and table name
|
||||||
|
$sysPrefix = $this->config->getSystemValueString('dbtableprefix', '');
|
||||||
|
$talkPrefix = $this->config->getAppValue('spreed', 'databaseprefix', $sysPrefix);
|
||||||
|
$info['database'] = [
|
||||||
|
'system_prefix' => $sysPrefix,
|
||||||
|
'talk_prefix' => $talkPrefix,
|
||||||
|
'talk_prefix_differs' => $talkPrefix !== $sysPrefix,
|
||||||
|
];
|
||||||
|
|
||||||
|
$roomTable = $talkService->detectTalkTableFromCatalog('talk_rooms', 'spreed_room');
|
||||||
|
$info['talk_room_table'] = $roomTable;
|
||||||
|
|
||||||
|
if ($roomTable !== null) {
|
||||||
|
// Column schema
|
||||||
|
try {
|
||||||
|
$columns = $talkService->getTalkTableColumns($roomTable);
|
||||||
|
$info['room_table_columns'] = $columns;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$info['room_table_columns_error'] = $e->getMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sample rows (all columns)
|
||||||
|
try {
|
||||||
|
$sampleRows = $talkService->getTalkTableSample($roomTable, 100);
|
||||||
|
$info['room_table_sample'] = $sampleRows;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$info['room_table_sample_error'] = $e->getMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Room type breakdown
|
||||||
|
try {
|
||||||
|
$typeCounts = $talkService->getRoomTypeBreakdown();
|
||||||
|
$info['room_type_breakdown'] = $typeCounts;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$info['room_type_breakdown_error'] = $e->getMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
// All rooms with type and name for debugging
|
||||||
|
try {
|
||||||
|
$allRooms = $talkService->getAllTalkRoomsDebug(100);
|
||||||
|
$info['all_talk_rooms_debug'] = $allRooms;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$info['all_talk_rooms_debug_error'] = $e->getMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rooms that match our filter (type IN 1,2,3, not sample, not note_to_self, not files)
|
||||||
|
try {
|
||||||
|
$filteredRooms = $talkService->getAvailableTalkRooms();
|
||||||
|
$info['filtered_rooms'] = $filteredRooms;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$info['filtered_rooms_error'] = $e->getMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The raw SQL that getAvailableTalkRooms executes
|
||||||
|
$info['filtered_rooms_sql'] = 'SELECT token, COALESCE(NULLIF(name, \'\'), token) as display_name
|
||||||
|
FROM "' . $roomTable . '"
|
||||||
|
WHERE type IN (1, 2, 3)
|
||||||
|
AND (object_type IS NULL OR object_type != \'sample\')
|
||||||
|
AND object_type != \'note_to_self\'
|
||||||
|
AND object_type != \'file\'';
|
||||||
|
} else {
|
||||||
|
$info['talk_debug_error'] = 'Talk table not found in database catalog';
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$info['talk_debug_error'] = $e->getMessage();
|
||||||
|
$info['talk_debug_error_file'] = $e->getFile();
|
||||||
|
$info['talk_debug_error_line'] = $e->getLine();
|
||||||
|
$info['talk_debug_error_trace'] = substr($e->getTraceAsString(), 0, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DataResponse($info);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -265,13 +338,39 @@ class WebhookController extends Controller {
|
|||||||
}
|
}
|
||||||
$configured = $this->talkService->getRooms();
|
$configured = $this->talkService->getRooms();
|
||||||
|
|
||||||
// Mark which rooms are already configured
|
// Also fetch type/object_type for each room from the DB
|
||||||
|
$roomTable = $this->talkService->detectTalkTableFromCatalog('talk_rooms', 'spreed_room');
|
||||||
|
$typeMap = [];
|
||||||
|
$objectTypeMap = [];
|
||||||
|
if ($roomTable !== null) {
|
||||||
|
$sql = 'SELECT token, type, object_type
|
||||||
|
FROM "' . $roomTable . '"
|
||||||
|
WHERE type IN (1, 2, 3)
|
||||||
|
AND (object_type IS NULL OR object_type != \'sample\')
|
||||||
|
AND object_type != \'note_to_self\'
|
||||||
|
AND object_type != \'file\'
|
||||||
|
AND name NOT LIKE \'["%\'';
|
||||||
|
$result = $this->talkService->getDbConnection()->executeQuery($sql);
|
||||||
|
while ($row = $result->fetch()) {
|
||||||
|
$typeMap[$row['token']] = (int)$row['type'];
|
||||||
|
$objectTypeMap[$row['token']] = $row['object_type'] ?: null;
|
||||||
|
}
|
||||||
|
$result->closeCursor();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type labels for reference
|
||||||
|
$typeLabels = [1 => 'public', 2 => 'private', 3 => 'password'];
|
||||||
|
|
||||||
|
// Mark which rooms are configured
|
||||||
$result = [];
|
$result = [];
|
||||||
foreach ($rooms as $token => $name) {
|
foreach ($rooms as $token => $name) {
|
||||||
$result[] = [
|
$result[] = [
|
||||||
'token' => $token,
|
'token' => $token,
|
||||||
'name' => $name !== '' ? $name : $token,
|
'name' => $name !== '' ? $name : $token,
|
||||||
'configured' => isset($configured[$token]),
|
'configured' => isset($configured[$token]),
|
||||||
|
'type' => $typeMap[$token] ?? null,
|
||||||
|
'type_label' => $typeLabels[$typeMap[$token] ?? 0] ?? 'unknown',
|
||||||
|
'object_type' => $objectTypeMap[$token] ?? null,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace OCA\NCdiscordhook;
|
||||||
|
|
||||||
|
use OCP\IConfig;
|
||||||
|
use OCP\IL10N;
|
||||||
|
use OCP\IURLGenerator;
|
||||||
|
use OCP\IUserSession;
|
||||||
|
use OCP\Navigation\INavigationProvider;
|
||||||
|
|
||||||
|
class NavigationProvider implements INavigationProvider {
|
||||||
|
public function __construct(
|
||||||
|
private readonly IURLGenerator $urlGenerator,
|
||||||
|
private readonly IUserSession $userSession,
|
||||||
|
private readonly IConfig $config,
|
||||||
|
private readonly IL10N $l10n,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function getNavigation(): array {
|
||||||
|
$user = $this->userSession->getUser();
|
||||||
|
if ($user === null || !$user->isAdmin()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'id' => 'ncdiscordhook',
|
||||||
|
'app_id' => 'ncdiscordhook',
|
||||||
|
'type' => 'settings',
|
||||||
|
'name' => $this->l10n->t('NCdiscordhook'),
|
||||||
|
'href' => $this->urlGenerator->linkToRoute('settings.AdminSettings#index'),
|
||||||
|
'icon' => $this->urlGenerator->imagePath('ncdiscordhook', 'app.svg'),
|
||||||
|
'order' => 0,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
+252
-139
@@ -2,66 +2,61 @@
|
|||||||
|
|
||||||
namespace OCA\NCdiscordhook\Service;
|
namespace OCA\NCdiscordhook\Service;
|
||||||
|
|
||||||
use OCP\AppFramework\Db\DoesNotExistException;
|
|
||||||
use OCP\AppFramework\Http;
|
use OCP\AppFramework\Http;
|
||||||
use OCP\Files\File;
|
use OCP\Files\File;
|
||||||
use OCP\Files\Folder;
|
use OCP\Files\Folder;
|
||||||
use OCP\Files\IRootFolder;
|
use OCP\Files\IRootFolder;
|
||||||
use OCP\Http\Client\IClient;
|
|
||||||
use OCP\Http\Client\IClientService;
|
use OCP\Http\Client\IClientService;
|
||||||
|
use OCP\Security\ICrypto;
|
||||||
use OCP\IConfig;
|
use OCP\IConfig;
|
||||||
use OCP\IDBConnection;
|
use OCP\IDBConnection;
|
||||||
use OCP\IRequest;
|
use OCP\IRequest;
|
||||||
use OCP\Security\ICrypto;
|
|
||||||
use OCP\IURLGenerator;
|
use OCP\IURLGenerator;
|
||||||
use OCP\IUserManager;
|
use OCP\IUserManager;
|
||||||
use OCP\Share\IManager;
|
use OCP\Share\IManager;
|
||||||
|
use OCA\Talk\Manager as TalkManager;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
class TalkService {
|
class TalkService {
|
||||||
private const APP_ID = 'ncdiscordhook';
|
private const APP_ID = 'ncdiscordhook';
|
||||||
private const IMAGES_DIR = 'NCdiscordhook-images';
|
private const IMAGES_DIR = 'NCdiscordhook-images';
|
||||||
|
|
||||||
// Internal system rooms to hide from the room picker.
|
|
||||||
private const INTERNAL_ROOMS = [
|
|
||||||
'talk-bot',
|
|
||||||
'Note to self',
|
|
||||||
"Let's get started!",
|
|
||||||
];
|
|
||||||
|
|
||||||
private IClient $client;
|
|
||||||
private IConfig $config;
|
|
||||||
private IDBConnection $db;
|
private IDBConnection $db;
|
||||||
private IRootFolder $rootFolder;
|
private IRootFolder $rootFolder;
|
||||||
private ICrypto $crypto;
|
private IRequest $request;
|
||||||
private IURLGenerator $urlGenerator;
|
private IURLGenerator $urlGenerator;
|
||||||
private IUserManager $userManager;
|
private IUserManager $userManager;
|
||||||
private IRequest $request;
|
|
||||||
private IManager $shareManager;
|
private IManager $shareManager;
|
||||||
private LoggerInterface $logger;
|
private LoggerInterface $logger;
|
||||||
|
private IClientService $clientService;
|
||||||
|
private TalkManager $talkManager;
|
||||||
|
private ICrypto $crypto;
|
||||||
|
private IConfig $config;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
IClientService $clientService,
|
IClientService $clientService,
|
||||||
IConfig $config,
|
IConfig $config,
|
||||||
IDBConnection $db,
|
IDBConnection $db,
|
||||||
IRootFolder $rootFolder,
|
IRootFolder $rootFolder,
|
||||||
ICrypto $crypto,
|
IRequest $request,
|
||||||
IURLGenerator $urlGenerator,
|
IURLGenerator $urlGenerator,
|
||||||
IUserManager $userManager,
|
IUserManager $userManager,
|
||||||
IRequest $request,
|
|
||||||
IManager $shareManager,
|
IManager $shareManager,
|
||||||
LoggerInterface $logger,
|
LoggerInterface $logger,
|
||||||
|
TalkManager $talkManager,
|
||||||
|
ICrypto $crypto,
|
||||||
) {
|
) {
|
||||||
$this->client = $clientService->newClient();
|
$this->clientService = $clientService;
|
||||||
$this->config = $config;
|
$this->config = $config;
|
||||||
$this->db = $db;
|
$this->db = $db;
|
||||||
$this->rootFolder = $rootFolder;
|
$this->rootFolder = $rootFolder;
|
||||||
$this->crypto = $crypto;
|
$this->request = $request;
|
||||||
$this->urlGenerator = $urlGenerator;
|
$this->urlGenerator = $urlGenerator;
|
||||||
$this->userManager = $userManager;
|
$this->userManager = $userManager;
|
||||||
$this->request = $request;
|
|
||||||
$this->shareManager = $shareManager;
|
$this->shareManager = $shareManager;
|
||||||
$this->logger = $logger;
|
$this->logger = $logger;
|
||||||
|
$this->talkManager = $talkManager;
|
||||||
|
$this->crypto = $crypto;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Bot password ──────────────────────────────────────────────
|
// ── Bot password ──────────────────────────────────────────────
|
||||||
@@ -111,52 +106,36 @@ class TalkService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the server base URL for internal API calls.
|
* Get the server base URL for internal API calls.
|
||||||
* Falls back to overwrite.cli.url when overwritewebroot is not set.
|
* Uses trusted_domains as the base URL — the HTTP client blocks
|
||||||
* When the URL resolves to localhost, falls back to the request host
|
* all localhost addresses, and in Docker deployments overwrite.cli.url
|
||||||
* or trusted_domains — the HTTP client blocks localhost for SSRF safety.
|
* often points to a localhost:port that is unreachable from within the container.
|
||||||
*/
|
*/
|
||||||
private function getBaseUrl(): string {
|
public function getBaseUrl(): string {
|
||||||
$overwritten = $this->config->getSystemValueString('overwritewebroot', '');
|
$overwritten = $this->config->getSystemValueString('overwritewebroot', '');
|
||||||
if ($overwritten !== '') {
|
if ($overwritten !== '') {
|
||||||
return rtrim($overwritten, '/');
|
return rtrim($overwritten, '/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use the first trusted domain as the base URL.
|
||||||
|
// This is the only reliable method when the HTTP client blocks localhost
|
||||||
|
// and overwrite.cli.url points to an internal Docker address.
|
||||||
|
$trusted = $this->config->getSystemValue('trusted_domains', []);
|
||||||
|
if (!empty($trusted[0])) {
|
||||||
|
// Check if trusted_domains[0] already has a scheme
|
||||||
|
if (preg_match('#^https?://#', $trusted[0])) {
|
||||||
|
return rtrim($trusted[0], '/');
|
||||||
|
}
|
||||||
|
// Use https by default for trusted domains
|
||||||
|
return 'https://' . $trusted[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: try overwrite.cli.url
|
||||||
$cliUrl = $this->config->getSystemValueString('overwrite.cli.url', '');
|
$cliUrl = $this->config->getSystemValueString('overwrite.cli.url', '');
|
||||||
if ($cliUrl === '') {
|
if ($cliUrl !== '') {
|
||||||
return '';
|
return rtrim($cliUrl, '/');
|
||||||
}
|
|
||||||
$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;
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Rooms ─────────────────────────────────────────────────────
|
// ── Rooms ─────────────────────────────────────────────────────
|
||||||
@@ -180,25 +159,16 @@ class TalkService {
|
|||||||
/**
|
/**
|
||||||
* Get available Talk rooms via database query.
|
* Get available Talk rooms via database query.
|
||||||
*
|
*
|
||||||
* Queries the Talk DB directly to find rooms where the bot is a participant.
|
* Queries the Talk DB directly (bypasses the OCS API which does not
|
||||||
* Bypasses the OCS API which does not accept app passwords for auth.
|
* accept app passwords for auth). Returns only public channels (type 1)
|
||||||
* Returns rooms as [token => displayName] pairs.
|
* — the only room type that makes sense for a Discord-style webhook
|
||||||
|
* channel picker. Returns rooms as [token => displayName] pairs.
|
||||||
*/
|
*/
|
||||||
public function getAvailableTalkRooms(): array {
|
public function getAvailableTalkRooms(): array {
|
||||||
$botUser = $this->getBotUser();
|
|
||||||
if (!$botUser) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$botUid = $botUser->getUID();
|
|
||||||
|
|
||||||
// Detect table names by querying PostgreSQL information_schema directly.
|
// 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');
|
$roomTable = $this->detectTalkTableFromCatalog('talk_rooms', 'spreed_room');
|
||||||
$participantTable = $this->detectTalkTableFromCatalog('talk_attendees', 'spreed_participant');
|
|
||||||
|
|
||||||
if ($roomTable === null || $participantTable === null) {
|
if ($roomTable === null) {
|
||||||
$sysPrefix = $this->config->getSystemValueString('dbtableprefix', '');
|
$sysPrefix = $this->config->getSystemValueString('dbtableprefix', '');
|
||||||
$talkPrefix = $this->config->getAppValue('spreed', 'databaseprefix', $sysPrefix);
|
$talkPrefix = $this->config->getAppValue('spreed', 'databaseprefix', $sysPrefix);
|
||||||
$this->logger->warning('NCdiscordhook: Talk tables not found', [
|
$this->logger->warning('NCdiscordhook: Talk tables not found', [
|
||||||
@@ -209,34 +179,43 @@ class TalkService {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find rooms where the bot is a participant.
|
// In NC33 the room display name is stored in the 'name' column
|
||||||
// Use raw SQL — query builder's expr() methods re-parse table names and double-prefix them.
|
// for all room types. Exclude deleted (type 4), note-to-self (type 6),
|
||||||
$sql = 'SELECT "' . $roomTable . '".token, "' . $roomTable . '".name, "' . $roomTable . '".type
|
// sample rooms, file share rooms (object_type = 'file'),
|
||||||
FROM "' . $roomTable . '"
|
// and private DM rooms (name starts with '["').
|
||||||
JOIN "' . $participantTable . '" ON "' . $participantTable . '".room_id = "' . $roomTable . '".id
|
try {
|
||||||
WHERE "' . $participantTable . '".actor_type = :actorType
|
$sql = 'SELECT token, COALESCE(NULLIF(name, \'\'), token) as display_name
|
||||||
AND "' . $participantTable . '".actor_id = :actorId
|
FROM "' . $roomTable . '"
|
||||||
AND "' . $roomTable . '".type <> :excludeType';
|
WHERE type IN (1, 2, 3)
|
||||||
|
AND (object_type IS NULL OR object_type != \'sample\')
|
||||||
$result = $this->db->executeQuery($sql, [
|
AND object_type != \'note_to_self\'
|
||||||
'actorType' => 'users',
|
AND object_type != \'file\'
|
||||||
'actorId' => $botUid,
|
AND name NOT LIKE \'["%\'';
|
||||||
'excludeType' => -1, // exclude UNKNOWN type
|
$result = $this->db->executeQuery($sql);
|
||||||
]);
|
} catch (\Exception $e) {
|
||||||
|
$this->logger->warning('NCdiscordhook: room name query failed, using token fallback', [
|
||||||
|
'app' => self::APP_ID,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
$sql = 'SELECT token, token as display_name
|
||||||
|
FROM "' . $roomTable . '"
|
||||||
|
WHERE type IN (1, 2, 3)
|
||||||
|
AND (object_type IS NULL OR object_type != \'sample\')
|
||||||
|
AND object_type != \'note_to_self\'
|
||||||
|
AND object_type != \'file\'
|
||||||
|
AND name NOT LIKE \'["%\'';
|
||||||
|
$result = $this->db->executeQuery($sql);
|
||||||
|
}
|
||||||
|
|
||||||
$this->logger->info('NCdiscordhook: getAvailableTalkRooms', [
|
$this->logger->info('NCdiscordhook: getAvailableTalkRooms', [
|
||||||
'app' => self::APP_ID,
|
'app' => self::APP_ID,
|
||||||
'bot_user' => $botUid,
|
|
||||||
'room_table' => $roomTable,
|
'room_table' => $roomTable,
|
||||||
'participant_table' => $participantTable,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$rooms = [];
|
$rooms = [];
|
||||||
while ($row = $result->fetch()) {
|
while ($row = $result->fetch()) {
|
||||||
$token = $row['token'];
|
$rooms[$row['token']] = $row['display_name'] !== '' ? $row['display_name'] : $row['token'];
|
||||||
$name = $row['name'] !== '' ? $row['name'] : $token;
|
|
||||||
$rooms[$token] = $name;
|
|
||||||
}
|
}
|
||||||
$result->closeCursor();
|
$result->closeCursor();
|
||||||
|
|
||||||
@@ -245,15 +224,6 @@ class TalkService {
|
|||||||
'rooms' => array_keys($rooms),
|
'rooms' => array_keys($rooms),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Filter out internal system rooms.
|
|
||||||
foreach (self::INTERNAL_ROOMS as $internal) {
|
|
||||||
foreach ($rooms as $token => $name) {
|
|
||||||
if (mb_strtolower($name) === mb_strtolower($internal)) {
|
|
||||||
unset($rooms[$token]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $rooms;
|
return $rooms;
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->logger->error('NCdiscordhook: room listing exception', [
|
$this->logger->error('NCdiscordhook: room listing exception', [
|
||||||
@@ -267,16 +237,58 @@ class TalkService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect which Talk table name variant exists in the database.
|
* Debug helper: return all rooms with key columns for inspection.
|
||||||
*
|
|
||||||
* 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 {
|
public function getAllTalkRoomsDebug(int $limit = 100): array {
|
||||||
|
$roomTable = $this->detectTalkTableFromCatalog('talk_rooms', 'spreed_room');
|
||||||
|
if ($roomTable === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$sql = 'SELECT id, token, type, readable_name, label, name, object_type, object_id
|
||||||
|
FROM "' . $roomTable . '"
|
||||||
|
ORDER BY id LIMIT ' . (int)$limit;
|
||||||
|
$result = $this->db->executeQuery($sql);
|
||||||
|
$rooms = [];
|
||||||
|
while ($row = $result->fetch()) {
|
||||||
|
$rooms[] = [
|
||||||
|
'id' => $row['id'],
|
||||||
|
'token' => $row['token'],
|
||||||
|
'type' => (int)$row['type'],
|
||||||
|
'readable_name' => $row['readable_name'] ?? null,
|
||||||
|
'label' => $row['label'] ?? null,
|
||||||
|
'name' => $row['name'] ?? null,
|
||||||
|
'object_type' => $row['object_type'] ?? null,
|
||||||
|
'object_id' => $row['object_id'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$result->closeCursor();
|
||||||
|
return $rooms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debug helper: count of rooms per type.
|
||||||
|
*/
|
||||||
|
public function getRoomTypeBreakdown(): array {
|
||||||
|
$roomTable = $this->detectTalkTableFromCatalog('talk_rooms', 'spreed_room');
|
||||||
|
if ($roomTable === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$sql = 'SELECT type, COUNT(*) as count
|
||||||
|
FROM "' . $roomTable . '"
|
||||||
|
GROUP BY type ORDER BY type';
|
||||||
|
$result = $this->db->executeQuery($sql);
|
||||||
|
$breakdown = [];
|
||||||
|
while ($row = $result->fetch()) {
|
||||||
|
$breakdown[(int)$row['type']] = (int)$row['count'];
|
||||||
|
}
|
||||||
|
$result->closeCursor();
|
||||||
|
return $breakdown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect which Talk table name variant exists in the database.
|
||||||
|
*/
|
||||||
|
public function detectTalkTableFromCatalog(string $newName, string $oldName): ?string {
|
||||||
$sysPrefix = $this->config->getSystemValueString('dbtableprefix', '');
|
$sysPrefix = $this->config->getSystemValueString('dbtableprefix', '');
|
||||||
$talkPrefix = $this->config->getAppValue('spreed', 'databaseprefix', $sysPrefix);
|
$talkPrefix = $this->config->getAppValue('spreed', 'databaseprefix', $sysPrefix);
|
||||||
|
|
||||||
@@ -319,8 +331,6 @@ class TalkService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Try executing a lightweight query on each candidate to verify it's usable.
|
// 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) {
|
foreach ($unique as $table) {
|
||||||
try {
|
try {
|
||||||
$testResult = $this->db->executeQuery(
|
$testResult = $this->db->executeQuery(
|
||||||
@@ -329,7 +339,6 @@ class TalkService {
|
|||||||
$testResult->closeCursor();
|
$testResult->closeCursor();
|
||||||
return $table;
|
return $table;
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
// Table exists in catalog but query failed — skip to next candidate
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -337,6 +346,63 @@ class TalkService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debug helper: get column names and types for a table.
|
||||||
|
*/
|
||||||
|
public function getTalkTableColumns(string $tableName): array {
|
||||||
|
$columns = $this->db->executeQuery(
|
||||||
|
'SELECT column_name, data_type, is_nullable
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = ?
|
||||||
|
ORDER BY ordinal_position',
|
||||||
|
[$tableName],
|
||||||
|
);
|
||||||
|
$result = [];
|
||||||
|
while ($row = $columns->fetch()) {
|
||||||
|
$result[] = [
|
||||||
|
'name' => $row['column_name'],
|
||||||
|
'type' => $row['data_type'],
|
||||||
|
'nullable' => $row['is_nullable'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$columns->closeCursor();
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debug helper: get all column names from a table, then fetch sample rows.
|
||||||
|
*/
|
||||||
|
public function getTalkTableSample(string $tableName, int $limit): array {
|
||||||
|
// Get column names
|
||||||
|
$colResult = $this->db->executeQuery(
|
||||||
|
'SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_name = ? ORDER BY ordinal_position',
|
||||||
|
[$tableName],
|
||||||
|
);
|
||||||
|
$colNames = [];
|
||||||
|
while ($row = $colResult->fetch()) {
|
||||||
|
$colNames[] = $row['column_name'];
|
||||||
|
}
|
||||||
|
$colResult->closeCursor();
|
||||||
|
|
||||||
|
if (empty($colNames)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$colList = implode(', ', array_map(fn($c) => '"' . $c . '"', $colNames));
|
||||||
|
$rows = $this->db->executeQuery(
|
||||||
|
'SELECT ' . $colList . ' FROM "' . $tableName . '" ORDER BY id LIMIT ?',
|
||||||
|
[$limit],
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = [];
|
||||||
|
while ($row = $rows->fetch()) {
|
||||||
|
$result[] = $row;
|
||||||
|
}
|
||||||
|
$rows->closeCursor();
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Auth tokens ───────────────────────────────────────────────
|
// ── Auth tokens ───────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -451,6 +517,13 @@ class TalkService {
|
|||||||
return $this->config->getAppValue(self::APP_ID, 'sender_name', 'Webhook Bot');
|
return $this->config->getAppValue(self::APP_ID, 'sender_name', 'Webhook Bot');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get sender name default.
|
||||||
|
*/
|
||||||
|
public function getSenderNameDefault(): string {
|
||||||
|
return $this->config->getAppValue(self::APP_ID, 'sender_name', 'Webhook Bot');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set sender name default.
|
* Set sender name default.
|
||||||
*/
|
*/
|
||||||
@@ -466,7 +539,8 @@ class TalkService {
|
|||||||
*/
|
*/
|
||||||
public function downloadImage(string $url): ?array {
|
public function downloadImage(string $url): ?array {
|
||||||
try {
|
try {
|
||||||
$response = $this->client->get($url, [
|
$client = $this->clientService->newClient();
|
||||||
|
$response = $client->get($url, [
|
||||||
'timeout' => 15,
|
'timeout' => 15,
|
||||||
'nextcloud' => [
|
'nextcloud' => [
|
||||||
'allow_local_address' => false,
|
'allow_local_address' => false,
|
||||||
@@ -539,8 +613,6 @@ class TalkService {
|
|||||||
|
|
||||||
$linkShare = $this->shareManager->getShareById($share->getId());
|
$linkShare = $this->shareManager->getShareById($share->getId());
|
||||||
|
|
||||||
$serverUrl = $this->config->getSystemValueString('overwrite.cli.url', 'https://example.com');
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'rich_object' => [
|
'rich_object' => [
|
||||||
'id' => '',
|
'id' => '',
|
||||||
@@ -564,10 +636,13 @@ class TalkService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Talk Chat API ─────────────────────────────────────────────
|
// ── Chat API: post message to Talk room ──────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Post a message to a Talk room via Chat API.
|
* Post a message to a Talk room via the Chat API.
|
||||||
|
*
|
||||||
|
* Uses Basic auth with the talk-bot user's app password.
|
||||||
|
* Endpoint: /ocs/v2.php/apps/spreed/api/v1/chat/{roomToken}
|
||||||
*
|
*
|
||||||
* @param string $roomToken Talk room token
|
* @param string $roomToken Talk room token
|
||||||
* @param string $message Message text
|
* @param string $message Message text
|
||||||
@@ -581,25 +656,35 @@ class TalkService {
|
|||||||
string $senderName,
|
string $senderName,
|
||||||
array $richObjects = [],
|
array $richObjects = [],
|
||||||
): bool {
|
): bool {
|
||||||
$bot = $this->userManager->get('talk-bot');
|
$botPassword = $this->getBotPassword();
|
||||||
if (!$bot) {
|
if ($botPassword === null) {
|
||||||
$this->logger->error('talk-bot user not found', ['app' => self::APP_ID]);
|
$this->logger->error('NCdiscordhook: bot password not configured', ['app' => self::APP_ID]);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$botPassword = $this->getBotPassword();
|
// Check bot is enabled for this room (via AppConfig)
|
||||||
if (!$botPassword) {
|
if (!$this->isBotEnabledForRoom($roomToken)) {
|
||||||
$this->logger->error('Bot password not configured', ['app' => self::APP_ID]);
|
$this->logger->warning('NCdiscordhook: bot not enabled for room', [
|
||||||
|
'app' => self::APP_ID,
|
||||||
|
'room_token' => $roomToken,
|
||||||
|
]);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$baseUrl = $this->getBaseUrl();
|
$baseUrl = $this->getBaseUrl();
|
||||||
$url = $baseUrl . '/ocs/v2.php/apps/spreed/api/v1/chat/' . rawurlencode($roomToken);
|
if ($baseUrl === '') {
|
||||||
|
$this->logger->error('NCdiscordhook: base URL not configured', ['app' => self::APP_ID]);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Build form body
|
$endpoint = $baseUrl . '/ocs/v2.php/apps/spreed/api/v1/chat/' . $roomToken;
|
||||||
$bodyParts = [
|
|
||||||
|
// Build message body for Chat API
|
||||||
|
$body = [
|
||||||
'message' => $message,
|
'message' => $message,
|
||||||
'username' => $senderName,
|
'actorType' => 'users',
|
||||||
|
'actorId' => 'talk-bot',
|
||||||
|
'actorDisplayName' => $senderName,
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!empty($richObjects)) {
|
if (!empty($richObjects)) {
|
||||||
@@ -607,32 +692,60 @@ class TalkService {
|
|||||||
foreach ($richObjects as $index => $richObj) {
|
foreach ($richObjects as $index => $richObj) {
|
||||||
$indexed['file-' . $index] = $richObj;
|
$indexed['file-' . $index] = $richObj;
|
||||||
}
|
}
|
||||||
$bodyParts['richObjects'] = $indexed;
|
$body['richObjects'] = $indexed;
|
||||||
}
|
}
|
||||||
|
|
||||||
$body = http_build_query($bodyParts, '', '&', PHP_QUERY_RFC3986);
|
$jsonBody = json_encode($body);
|
||||||
|
|
||||||
|
// Basic auth: base64('talk-bot:' . bot_password)
|
||||||
|
$credentials = base64_encode('talk-bot:' . $botPassword);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$response = $this->client->post($url, [
|
$client = $this->clientService->newClient();
|
||||||
'auth' => 'basic',
|
$response = $client->post($endpoint, [
|
||||||
'basic' => [$bot->getUID(), $botPassword],
|
'body' => $jsonBody,
|
||||||
'headers' => [
|
'headers' => [
|
||||||
'OCS-Expect' => '100',
|
'OCS-Expect-Formatted' => 'json',
|
||||||
'OCS-ApiRequest' => 'true',
|
'Authorization' => 'Basic ' . $credentials,
|
||||||
'Content-Type' => 'application/x-www-form-urlencoded',
|
'Content-Type' => 'application/json',
|
||||||
'Content-Length' => (string) strlen($body),
|
],
|
||||||
|
'nextcloud' => [
|
||||||
|
'allow_local_address' => false,
|
||||||
],
|
],
|
||||||
'body' => $body,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$httpCode = $response->getStatusCode();
|
$statusCode = $response->getStatusCode();
|
||||||
return $httpCode === 200;
|
if ($statusCode === Http::STATUS_OK) {
|
||||||
|
$this->logger->info('NCdiscordhook: message posted to room ' . $roomToken, [
|
||||||
|
'app' => self::APP_ID,
|
||||||
|
]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$responseBody = $response->getBody();
|
||||||
|
$this->logger->error('NCdiscordhook: chat API returned ' . $statusCode . ': ' . $responseBody, [
|
||||||
|
'app' => self::APP_ID,
|
||||||
|
'room_token' => $roomToken,
|
||||||
|
'status' => $statusCode,
|
||||||
|
]);
|
||||||
|
return false;
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->logger->error('Failed to post to Talk: ' . $e->getMessage(), ['app' => self::APP_ID]);
|
$this->logger->error('NCdiscordhook: chat API request failed: ' . $e->getMessage(), [
|
||||||
|
'app' => self::APP_ID,
|
||||||
|
'room_token' => $roomToken,
|
||||||
|
]);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the bot is enabled for a specific room (via AppConfig).
|
||||||
|
*/
|
||||||
|
public function isBotEnabledForRoom(string $roomToken): bool {
|
||||||
|
$rooms = $this->getRooms();
|
||||||
|
return isset($rooms[$roomToken]);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Image cleanup ─────────────────────────────────────────────
|
// ── Image cleanup ─────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+26
-1
@@ -23,8 +23,33 @@ class Admin implements ISettings {
|
|||||||
$params = [
|
$params = [
|
||||||
'hasBotPassword' => $this->talkService->hasBotPassword(),
|
'hasBotPassword' => $this->talkService->hasBotPassword(),
|
||||||
'retentionDays' => $this->talkService->getRetentionDays(),
|
'retentionDays' => $this->talkService->getRetentionDays(),
|
||||||
'rooms' => $this->talkService->getRooms(),
|
'rooms' => $this->talkService->getAvailableTalkRooms(),
|
||||||
'authTokens' => $this->talkService->getAuthTokens(),
|
'authTokens' => $this->talkService->getAuthTokens(),
|
||||||
|
'configuredRooms' => $this->talkService->getRooms(),
|
||||||
|
'serverUrl' => $this->talkService->getBaseUrl(),
|
||||||
|
'senderName' => $this->talkService->getSenderNameDefault(),
|
||||||
|
'l10n' => [
|
||||||
|
'fetch_rooms' => $this->l10n->t('Fetch Rooms'),
|
||||||
|
'error_fetching' => $this->l10n->t('Error fetching rooms'),
|
||||||
|
'save_config' => $this->l10n->t('Save Configuration'),
|
||||||
|
'config_saved' => $this->l10n->t('Configuration saved'),
|
||||||
|
'save_failed' => $this->l10n->t('Configuration save failed'),
|
||||||
|
'generate_token' => $this->l10n->t('Generate Token'),
|
||||||
|
'revoke_token' => $this->l10n->t('Revoke'),
|
||||||
|
'no_rooms' => $this->l10n->t('No rooms found'),
|
||||||
|
'no_rooms_msg' => $this->l10n->t('No Talk rooms are available or you don\'t have permission to view them.'),
|
||||||
|
'bot_password' => $this->l10n->t('Bot App Password'),
|
||||||
|
'bot_password_desc' => $this->l10n->t('App password for the "talk-bot" user. Create one in Settings → talk-bot → Devices & sessions.'),
|
||||||
|
'sender_name' => $this->l10n->t('Default Sender Name'),
|
||||||
|
'sender_name_desc' => $this->l10n->t('Name used when posting messages as the bot.'),
|
||||||
|
'image_retention' => $this->l10n->t('Image Retention (days)'),
|
||||||
|
'image_retention_desc' => $this->l10n->t('How long to keep uploaded images before cleanup.'),
|
||||||
|
'room_selection' => $this->l10n->t('Room Selection'),
|
||||||
|
'room_selection_desc' => $this->l10n->t('Select which Talk rooms should accept webhooks. Enable the bot for a room by checking it.'),
|
||||||
|
'auth_tokens' => $this->l10n->t('Auth Tokens'),
|
||||||
|
'auth_tokens_desc' => $this->l10n->t('Auth tokens for this room. Used to validate incoming webhook requests.'),
|
||||||
|
'no_password' => $this->l10n->t('Enter bot password to enable configuration.'),
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
$response = new TemplateResponse('ncdiscordhook', 'adminSettings', $params);
|
$response = new TemplateResponse('ncdiscordhook', 'adminSettings', $params);
|
||||||
|
|||||||
+23
-11
@@ -1,9 +1,9 @@
|
|||||||
<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"><strong>Bot App Password</strong></label><br>
|
||||||
<div style="display: flex; gap: 8px; align-items: center;">
|
<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;">
|
<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: none;">Save</button>
|
<button id="nc-save-password" type="button" style="display: <?= $hasBotPassword ? 'none' : 'inline-block' ?>;">Save</button>
|
||||||
</div>
|
</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>.
|
||||||
@@ -14,11 +14,11 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Config data passed to JS via data attributes -->
|
<div class="nc-settings-section">
|
||||||
<div id="nc-config-data"
|
<h3>Default Sender Name</h3>
|
||||||
data-configured-rooms="<?= htmlspecialchars(json_encode($rooms ?? []), ENT_QUOTES, 'UTF-8') ?>"
|
<label for="nc-sender-name">Sender name used when posting messages</label><br>
|
||||||
data-auth-tokens="<?= htmlspecialchars(json_encode($authTokens ?? []), ENT_QUOTES, 'UTF-8') ?>"
|
<input type="text" id="nc-sender-name" value="<?= htmlspecialchars($senderName ?? 'Webhook Bot') ?>" style="width: 100%; padding: 8px; font-size: 1em; margin: 4px 0;">
|
||||||
style="display:none;">
|
<p class="nc-hint">This name appears as the sender of webhook messages in Talk.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="nc-settings-section">
|
<div class="nc-settings-section">
|
||||||
@@ -29,13 +29,25 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="nc-settings-section">
|
<div class="nc-settings-section">
|
||||||
<h3>Room Management</h3>
|
<h3>Room Selection</h3>
|
||||||
<button id="nc-fetch-rooms" type="button">Fetch Rooms</button>
|
<button id="nc-fetch-rooms" type="button">Fetch Rooms</button>
|
||||||
<div id="nc-rooms-list"></div>
|
<div id="nc-rooms-list"></div>
|
||||||
<p class="nc-hint">Select Talk rooms to accept webhooks for. Each room gets its own webhook URL with an auth token.</p>
|
<p class="nc-hint">Check rooms to enable webhooks for. Each room gets its own auth token.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="nc-settings-section">
|
<div class="nc-settings-section">
|
||||||
<button id="nc-save" type="button">Save Configuration</button>
|
<button id="nc-save" type="button">Save Configuration</button>
|
||||||
<span id="nc-status" class="nc-status"></span>
|
<span id="nc-status" class="nc-status"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Config data passed to JS via data attributes -->
|
||||||
|
<div id="nc-config-data"
|
||||||
|
data-configured-rooms="<?= htmlspecialchars(json_encode($rooms ?? []), ENT_QUOTES, 'UTF-8') ?>"
|
||||||
|
data-auth-tokens="<?= htmlspecialchars(json_encode($authTokens ?? []), ENT_QUOTES, 'UTF-8') ?>"
|
||||||
|
data-server-url="<?= htmlspecialchars($serverUrl ?? 'https://localhost', ENT_QUOTES, 'UTF-8') ?>"
|
||||||
|
data-sender-name="<?= htmlspecialchars($senderName ?? 'Webhook Bot', ENT_QUOTES, 'UTF-8') ?>"
|
||||||
|
data-has-bot-password="<?= $hasBotPassword ? '1' : '0' ?>"
|
||||||
|
data-retention="<?= htmlspecialchars((string)($retentionDays ?? 90), ENT_QUOTES, 'UTF-8') ?>"
|
||||||
|
data-l10n="<?= htmlspecialchars(json_encode($l10n ?? [], ENT_QUOTES | JSON_UNESCAPED_UNICODE), ENT_QUOTES, 'UTF-8') ?>"
|
||||||
|
style="display:none;">
|
||||||
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user