- Update method line numbers in agent.md to match current codebase - Correct file sizes and line counts in the documentation table - Update TODO.md: mark line number and file size updates as completed - Move remaining tasks to Future Ideas section
49 KiB
nc_bot_webhooks — Maintenance Reference
Complete maintenance reference for the nc_bot_webhooks Nextcloud app. Covers every file, API interaction, data flow, design decision, and operational detail.
Table of Contents
- Project Structure
- App Identity & Configuration
- Every File — Purpose & Key Details
- API Interactions
- Data Flow
- Configuration Model
- Security Model
- Known Issues & Limitations
- Operational Procedures
- Debugging Guide
- Migration Notes
- Testing
Project Structure
nc_bot_webhooks/
├── appinfo/
│ ├── routes.php # HTTP route definitions
│ ├── info.xml # App metadata (id, version, dependencies, classes)
│ └── cron.php # Background job registration
├── lib/
│ ├── Controller/
│ │ └── WebhookController.php # HTTP handlers (webhooks + admin endpoints)
│ ├── Service/
│ │ └── TalkService.php # Core business logic (1904 lines)
│ ├── Command/
│ │ └── DebugToggle.php # CLI command for debug endpoint
│ ├── Settings/
│ │ └── Admin.php # Admin settings form handler (ISettings)
│ ├── NavigationProvider.php # App navigation (INavigationProvider)
│ └── Cron/
│ └── ImageCleanup.php # Background job (IJob)
├── js/
│ └── settings.js # Admin settings UI (351 lines)
├── templates/
│ └── adminSettings.php # Settings form HTML
├── css/
│ └── adminSettings.css # Settings UI styles
├── img/
│ └── app.svg # App icon (used in navigation + admin)
├── composer.json # Package name + PSR-4 mapping (dev tooling only)
├── README.md # User-facing documentation
├── INSTALL.md # Installation guide
├── architecture.md # Architecture documentation
└── agent.md # This file
Key paths at runtime:
- Web URL base:
/apps/nc_bot_webhooks/ - Admin settings path:
Settings → Admin → nc_bot_webhooks - Bot user:
talk-bot(must exist and be admin) - Image storage:
/nc_bot_webhooks-images/<room-token>/undertalk-bot's personal files
App Identity & Configuration
Constants
| Constant | Value | Location |
|---|---|---|
APP_ID |
'nc_bot_webhooks' |
TalkService.php:24, WebhookController.php:33, DebugToggle.php:48 |
IMAGES_DIR |
'nc_bot_webhooks-images' |
TalkService.php:25, ImageCleanup.php:12 |
DEBUG_KEY |
'debug_enabled' |
DebugToggle.php:49 |
AppConfig keys (stored in oc_appconfig table, appid = 'nc_bot_webhooks')
| Key | Type | Purpose |
|---|---|---|
bot_password |
encrypted string | Bot user's app password (encrypted via ICrypto) |
rooms |
JSON object | Room token → display name mapping for configured rooms |
retention_days |
string (int) | Image retention period in days |
sender_name |
string | Default sender display name |
auth_tokens |
JSON object | Room token → auth token array mapping |
debug_enabled |
bool | Whether the /debug endpoint is active |
Critical: After the rename from ncdiscordhook to nc_bot_webhooks, old config data under the old app ID is inaccessible. Users upgrading must run:
UPDATE oc_appconfig SET appid = 'nc_bot_webhooks' WHERE appid = 'ncdiscordhook';
Namespace
OCA\Ncbotwebhooks\
├── Controller\WebhookController
├── Service\TalkService
├── Command\DebugToggle
├── Settings\Admin
├── NavigationProvider
└── Cron\ImageCleanup
Every File — Purpose & Key Details
lib/Service/TalkService.php (1904 lines)
The single most important file. Contains all business logic.
Constructor / Dependencies
public function __construct(
IClientService $clientService, // HTTP client factory
IConfig $config, // App + system config
IDBConnection $db, // Database connection
IRootFolder $rootFolder, // Filesystem root
IRequest $request, // HTTP request
IURLGenerator $urlGenerator, // URL generation
IUserManager $userManager, // User lookup
IUserSession $userSession, // User session management
LoggerInterface $logger, // PSR-3 logger
TalkManager $talkManager, // Talk room manager
ICrypto $crypto, // Encryption (bot password)
AttendeeMapper $attendeeMapper, // Talk attendee queries
IShareManager $shareManager, // File share management
ParticipantService $participantService, // Talk participant management
ChatManager $chatManager, // Talk chat management
TalkSession $talkSession, // Talk session persistence
)
Bot Password Methods
| Method | Lines | Purpose |
|---|---|---|
hasBotPassword() |
134-138 | Checks if bot_password exists in AppConfig |
getBotPassword() |
97-111 | Retrieves and decrypts bot password via ICrypto. Returns null if not set. |
setBotPassword(string) |
130-138 | Encrypts and stores bot password via ICrypto → IAppConfig |
validateBotPassword(string) |
113-128 | Round-trip encryption test: encrypt → decrypt → compare. Checks: non-empty, no crypto-breaking chars, crypto layer functional. Does NOT validate against Nextcloud server. |
Why round-trip test? The original code used TalkManager::getRemoteServer() which requires a valid remote URL. The round-trip test is faster and more reliable — it verifies the password works with the crypto layer without making a network call.
Room Methods
| Method | Lines | Purpose |
|---|---|---|
getRooms() |
270-285 | Returns JSON-decoded rooms from AppConfig — array<string, string> mapping room token → display name. No type/type_label/object_type fields; those come from getAvailableTalkRooms(). |
setRooms(array) |
279-285 | Stores room token → display name mapping as JSON in AppConfig |
getAvailableTalkRooms() |
291-365 | Queries Talk DB directly (bypasses TalkManager). Filters: type IN (1,2,3), excludes deleted/note-to-self/sample/file rooms. Returns array<string, string> mapping room token → display name (or token if name is empty). Note: does not return type, type_label, or object_type fields — only token and name are selected. |
isBotEnabledForRoom(string) |
1610-1617 | Checks if room token exists in configured rooms |
Why direct DB queries? Talk 14+ removed TalkManager::getRoomForToken. Talk's OCS API requires user sessions, not app passwords. Direct queries bypass both issues.
Room type filter: type IN (1, 2, 3)
- Type 1: public channel
- Type 2: group direct message
- Type 3: public direct message
- Excluded: type 4 (deleted), type 6 (note-to-self), sample rooms (
object_type = 'sample'), file share rooms (object_type = 'file'), private DM rooms (name LIKE '["%') - Known gap: type 6 (note-to-self) is explicitly excluded above, but future room types are also missed by the hardcoded
INlist. - Future consideration: If Nextcloud adds new room types, this query silently excludes them. Switching from an inclusive model (
IN (1,2,3)) to an exclusive model (NOT IN (4) AND type != 'note_to_self' AND object_type != 'file' ...) would be more forward-compatible.
Room name resolution: In NC33, room display name is in the name column. Falls back to token if name is empty.
Base URL Resolution
getBaseUrl() resolves the server URL in this priority order:
overwritehost(+overwriteproto, defaulthttps) — explicit hostname overrideoverwritewebroot— if full URL (starts withhttp:///https://) returns as-is; if path → falls through- Non-loopback trusted domain — iterates
trusted_domains, skips127.0.0.1,::1,localhost, and private ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7). Returns first valid domain. If none pass the filter, falls through. trusted_domains[0]fallback — first trusted domain regardless of range (may be localhost). Caller may fail if it's unreachable.overwrite.cli.url— last resort
Why this order? Docker/container deployments often have trusted_domains[0] = 127.0.0.1 (unreachable from within the container) and overwrite.cli.url pointing to localhost:port. The non-loopback filter handles this.
Auth Token Methods
| Method | Lines | Purpose |
|---|---|---|
getAuthTokens() |
535-543 | Returns JSON-decoded auth_tokens from AppConfig |
setAuthTokens(array) |
544-550 | Stores auth tokens as JSON in AppConfig |
validateAuthToken(roomToken, authToken) |
551-562 | Checks if authToken exists in the array for roomToken |
generateAuthToken(roomToken) |
563-576 | Server-side: bin2hex(random_bytes(24)) → 48-char hex token |
revokeAuthToken(roomToken, authToken) |
577-600 | Removes token from array, cleans up empty room entries |
Note: The settings UI generates tokens client-side (btoa(Math.random() + Date.now())) — this is a known security limitation. Use generateAuthToken() for server-side generation.
Payload Mapping
| Method | Lines | Purpose |
|---|---|---|
mapPayload(array) |
610-649 | Maps Discord webhook JSON to Talk message text. Extracts: content, embed title (bold), embed description, embed fields (name: value). Joins with \n\n. |
mapApprisePayload(array, roomToken) |
673-906 | Maps Apprise JSON to internal format. Handles: body, title/subject as message, type → icon (✅ success, ⚠️ warning, ❌ error; none for info/image), attachments (remote URL, local file, base64), type=image special case. Returns {message, senderName, displayName, richObjects, typeIcon}. |
getSenderName(array) |
650-662 | Returns username from Discord payload, or config default |
getSenderNameDefault() |
663-672 | Returns config sender_name default |
prependDisplayName(string, string, string) |
601-609 | Prepends {typeIcon}🤖 **{name}**\n\n to message. typeIcon is ✅/⚠️/❌ for success/warning/error types, empty for info/image. Since Talk doesn't support per-message avatars. |
Apprise attachment formats handled:
- Remote URL:
attachment['url']→ download → upload → rich object - Local file:
attachment['path']starting withfile://→ read file → upload → rich object - Base64:
attachment['base64']→ decode → upload → rich object - type=image: special case — uses
attachment(string URL) orattachments(array) as image URLs directly
Type icon mapping:
success→ ✅warning→ ⚠️error→ ❌info/image/ missing → no icon (empty string)
Base64 attachment format (from Apprise library JSON method):
{
"base64": "<base64-encoded-data>",
"filename": "image.png",
"mimetype": "image/png"
}
type=image handling (lines 622-667): When Apprise sends type: "image", the body may be empty. The code uses title as the message text if body is empty, and processes all image URLs from attachment or attachments fields.
Image Handling
| Method | Lines | Purpose |
|---|---|---|
downloadImage(string) |
907-947 | HTTP GET via Nextcloud IClientService. Timeout: 15s. allow_local_address: true (needed for Docker). Returns ['data' => binary, 'mimeType' => string] or null. |
uploadImage(roomToken, filename, data, mimeType) |
948-1083 | Writes to nc_bot_webhooks-images/<roomToken>/<filename> under talk-bot's personal storage. Uses basename() for path traversal protection. Returns relative path or null. |
buildRichObject(filePath, mimeType, roomToken) |
1084-1336 | Creates a TYPE_ROOM share for the uploaded file so Talk's SystemMessage parser can resolve the rich object inline. Uses PDO directly to bypass DBConnection lazy init. Resolves bot's home storage from storages table (avoids hardcoded '1'). Returns rich object data with shareId, fileId, fileCachePath, downloadUrl, publicUrl, shareToken, filename, mimeType. |
Image flow for Discord:
- Extract
embed[].image.urlorembed[].thumbnail.url downloadImage()— HTTP GET- Derive filename from URL path (or use
webhook-image.<ext>) uploadImage()— write to bot user storagebuildRichObject()— create TYPE_ROOM share in bot user's storage- Pass rich object to
postToRoom()asrichObjects
Image flow for Apprise:
- Parse
attachmentsarray (supports URL, local file, base64) - For each attachment: download/read → upload → build rich object
- Collect all rich objects → pass to
postToRoom()
Talk Posting
| Method | Lines | Purpose |
|---|---|---|
postToRoom(roomToken, message, senderName, richObjects) |
1337-1608 | Posts message to Talk Chat API v1. Uses query params (?message=...&actorDisplayName=...) with empty JSON body ({}). Basic auth: talk-bot:botPassword. Endpoint: /ocs/v2.php/apps/spreed/api/v1/chat/{roomToken}. Also sends a file_shared system message for rich objects. |
Chat API v1 query params:
?message=🤖 **CI Bot**%0A%0ABuild%20%231234%20passed&actorDisplayName=<bot's display name from room participant>
Rich objects: Sent separately as a file_shared system message so Talk's SystemMessage parser can resolve the file inline. The system message body contains the share ID and metadata.
Critical: actorDisplayName must match the bot's display name in the room's participant record. In Talk 14+, if it doesn't match, the message is silently dropped. Resolved via getBotDisplayNameForRoom() which queries AttendeeMapper.
Critical: actorDisplayName must match the bot's display name in the room's participant record. In Talk 14+, if it doesn't match, the message is silently dropped. Resolved via getBotDisplayNameForRoom() which queries AttendeeMapper.
getBotDisplayNameForRoom() (line 1619):
- Queries
talk_roomstable for room ID from token - Uses
AttendeeMapper::findByActor(roomId, ACTOR_USERS, 'talk-bot')to get attendee record - Returns attendee's display name, or
'talk-bot'as fallback
Config Save
saveConfig(array $config) (lines 1720-1757):
- Validate bot password (if provided) via round-trip test
- Set bot password (if provided)
- Set retention days (if provided)
- Merge rooms: use provided
roomsarray, then remove disabled rooms fromdisabled_rooms(token → unused) - Set rooms
- Set auth tokens (if provided)
- Set sender name (if provided)
ensureBotParticipants()— adds talk-bot as participant in all configured rooms
ensureBotParticipants() (line 1764, private — called at end of every saveConfig() call):
- Queries configured rooms
- For each room, checks if talk-bot is already a participant
- If not, inserts an attendee record via
AttendeeMapper - Ensures bot is in all rooms for Chat API to accept messages
Image Cleanup
| Method | Lines | Purpose |
|---|---|---|
purgeOldImages() |
1656-1683 | Gets retention days, computes cutoff time, gets bot user folder, calls purgeFolder() |
purgeFolder(Folder, int) |
1684-1719 | Recursively deletes files/folders by mtime only. Cleans up empty subdirectories. |
⚠️ SECURITY CONCERN: purgeFolder() deletes by mtime only — no file ownership verification. It operates on the nc_bot_webhooks-images/ directory under the bot user's personal storage. This is safe because:
- It only operates within the bot user's personal folder
- The bot user is isolated from other users
- The directory is specifically for webhook images
User requested per-file attributes for additional safety — NOT YET IMPLEMENTED.
Debug Helpers
| Method | Purpose |
|---|---|
detectTalkTableFromCatalog(newName, oldName) |
Queries information_schema.tables for Talk table. Tries both talk_rooms/spreed_room with prefix candidates from spreed.databaseprefix and dbtableprefix. |
getTalkTableColumns(tableName) |
Returns column names + types for a table |
getTalkTableSample(tableName, limit) |
Returns sample rows from a table |
getAllTalkRoomsDebug(limit) |
Returns all rooms with id, token, type, readable_name, label, name, object_type, object_id |
getRoomTypeBreakdown() |
Returns count of rooms per type |
lib/Controller/WebhookController.php
HTTP request handlers. Extends Controller, constructor takes appName, request, TalkService, IAppConfig, LoggerInterface.
Discord Webhook Handler: receive()
Route: POST /discord-webhook/{roomToken}/{token}
Flow:
- Validate auth token via
TalkService::validateAuthToken() - Parse JSON from
php://input mapPayload()— convert to Talk message text- If no message content → return 400
no_content getSenderName()— resolve sender nameprependDisplayName()— embed sender name in message- Iterate embeds → extract image/thumbnail URLs →
downloadImage()→uploadImage()→buildRichObject() postToRoom()— post to Talk Chat API- Return 201
okor 500error
Response headers: X-Webhook-Status: ok|unauthorized|bad_request|no_content|error
HTTP status codes: 401 (unauthorized), 400 (bad_request/no_content), 201 (ok), 500 (error)
Apprise Webhook Handler: receiveApprise()
Route: POST /apprise-webhook/{roomToken}/{token}
Flow:
- Validate auth token
- Detect content type: JSON →
$_POST→ form-encoded - Parse payload (try JSON →
$_POST→parse_str) - Unwrap
notificationsarray if present (Apprise API wrapper) - Propagate wrapper-level
subject/titleto notification entry (preserves original data before extraction) mapApprisePayload()— convert to internal format- Handle attachments (remote URL, local file, base64)
prependDisplayName()— embed sender namepostToRoom()— post to Talk- Return response with
X-Webhook-Status
Key difference from Discord handler: Apprise sends a different payload structure with notifications array wrapper. The code handles:
- Single notification (no wrapper)
- Wrapped notification (extract from
notifications[0]) type=imagenotifications (special image handling)- Multiple attachment formats (URL, local file, base64)
Wrapper-level subject/title propagation fix: When the wrapper has subject or title but the inner notification doesn't, these values are propagated to the notification entry so the sender name is preserved.
Apprise Notify Handler: receiveAppriseNotify()
Route: POST /apprise-webhook/{roomToken}/notify/{token}
Simply delegates to receiveApprise(). Apprise's apprises:// URL scheme inserts notify in the path automatically.
Admin Endpoints
| Method | Route | Auth | Purpose |
|---|---|---|---|
saveConfig() |
POST /save-config |
Admin session | Bulk config save (rooms, auth tokens, retention, sender name, bot password). Validates bot password, saves all config, ensures bot participants. |
saveBotPassword() |
POST /save-bot-password |
Admin session | Standalone bot password save with validation. |
getRooms() |
GET /rooms |
Admin session | Returns available Talk rooms and configured rooms for JS. |
debug() |
GET /debug |
Admin session (gated by debug_enabled flag) | Exposes DB schema, bot credentials, config (disabled by default). Admin gate re-enabled — see TODO for auto-disable timer. |
saveConfig() flow:
TalkService::saveConfig()— validates, saves, ensures participants- Return JSON:
{'status': 'ok', 'auth_tokens': <server state>} - If validation fails → throw Exception → catch → return JSON error
getRooms() flow:
TalkService::getAvailableTalkRooms()→ available roomsTalkService::getRooms()→ configured rooms- Merge: for each available room, check if configured →
{'token', 'name', 'configured'} - Return JSON array
debug() endpoint (when enabled):
- Get bot password from AppConfig
- Get rooms from AppConfig
- Get auth tokens from AppConfig
- Get Talk table columns via
detectTalkTableFromCatalog()→getTalkTableColumns() - Get room type breakdown
- Return all as JSON
lib/Command/DebugToggle.php
CLI command for managing the debug endpoint. Uses IAppConfig with key debug_enabled under app ID nc_bot_webhooks.
Commands:
php occ nc_bot_webhooks:debug:enable— enablephp occ nc_bot_webhooks:debug:disable— disablephp occ nc_bot_webhooks:debug:toggle— togglephp occ nc_bot_webhooks:debug:status— show status
Default: disabled. Warning: enables public access to sensitive data (DB schema, bot credentials, config).
lib/Settings/Admin.php
Admin settings form handler. Implements ISettings.
getForm():
- Loads CSS (
adminSettings) and JS (settings) - Injects config data into template params:
hasBotPassword— whether bot password is configuredretentionDays— current retention valuerooms— configured rooms (for checkbox state)authTokens— current auth tokensconfiguredRooms— configured rooms (for JS state)serverUrl— resolved base URLsenderName— current sender namel10n— translated strings
- Returns
TemplateResponse('nc_bot_webhooks', 'adminSettings', $params)
getPriority(): 10 — higher number = displayed lower in settings list
getSection(): 'additional' — places under "Additional" section
getIcons(): [imagePath('nc_bot_webhooks', 'app.svg')] — app icon
lib/NavigationProvider.php
Implements INavigationProvider. Adds "nc_bot_webhooks" entry to admin settings navigation.
Admin-only: returns empty array if user is not admin.
Navigation entry:
[
'id' => 'nc_bot_webhooks',
'app_id' => 'nc_bot_webhooks',
'type' => 'settings',
'name' => 'nc_bot_webhooks',
'href' => linkToRoute('settings.AdminSettings#index'),
'icon' => imagePath('nc_bot_webhooks', 'app.svg'),
'order' => 0,
]
lib/Cron/ImageCleanup.php
Background job. Implements IJob.
run($argument):
- Get
talk-botuser - Get
nc_bot_webhooks-imagesfolder from bot's personal storage - Call
TalkService::purgeOldImages() - Log purge count
Registration: via appinfo/info.xml <background-jobs> and appinfo/cron.php.
⚠️ NOTE: Only deletes by mtime — no per-file attribute check. User requested per-file attributes for safety (NOT YET IMPLEMENTED).
js/settings.js (351 lines)
Admin settings UI. All logic in DOMContentLoaded event handler.
State variables:
configuredRoomsState— current room configurationauthTokensState— current auth tokens (client-side)serverAuthTokens— canonical auth tokens from server (synced after save)
Data sources: Read from DOM data attributes on #nc-config-data:
data-configured-rooms— JSON of configured roomsdata-auth-tokens— JSON of auth tokensdata-server-url— server base URLdata-sender-name— current sender namedata-has-bot-password— 1 or 0data-retention— current retention daysdata-l10n— JSON of translated strings
Key functions:
| Function | Lines | Purpose |
|---|---|---|
parseData(el, key) |
5-11 | Safely parse JSON from data attribute |
showStatus(msg, type) |
31-35 | Show transient status message (success/error) |
fetchRooms() |
59-122 | GET /rooms, render checkboxes + token divs |
renderTokens(roomToken, container) |
131-252 | Render auth tokens with webhook URLs + generate/revoke |
savePasswordBtn handler |
255-282 | POST /save-bot-password with validation |
saveBtn handler |
285-349 | POST /save-config with rooms, tokens, retention, sender name |
Token generation (line 245-246):
var raw = Math.random().toString(36).substring(2) + Date.now().toString(36);
var token = btoa(raw).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
Uses URL-safe base64 encoding of Math.random() + Date.now(). Known security limitation — server-side random_bytes(32) would be more secure.
URL generation (lines 137-140):
var discordPath = OC.generateUrl('/apps/' + APP_ID + '/discord-webhook/') + roomToken + '/{token}';
var apprisePath = OC.generateUrl('/apps/' + APP_ID + '/apprise-webhook/') + roomToken + '/notify/{token}';
var discordUrl = window.location.protocol + '//' + window.location.host + discordPath;
var appriseUrl = window.location.protocol + '//' + window.location.host + apprisePath;
Uses window.location for protocol/host, {token} placeholder replaced when token is generated.
Save flow (bulk config):
- Collect checked checkboxes → rooms
- Track disabled rooms (previously checked, now unchecked)
- Build payload: rooms, disabled_rooms, auth_tokens, retention_days, sender_name
- Include bot_password only if input is non-empty
- POST to
/save-config - On success: sync
authTokensStatefrom server response, re-render token divs
Auto-fetch: If configured rooms exist on load, automatically fetches room list.
templates/adminSettings.php
Settings form HTML. Single .nc-app-frame wrapper containing:
- Bot App Password — password input + conditional save button (shown only if no password configured)
- Default Sender Name — text input
- Image Retention — number input (1-365)
- Room Selection — Fetch Rooms button + checkbox list + Generate Auth Token + Save Configuration + status
Config data passed to JS via #nc-config-data data attributes (JSON-encoded).
css/adminSettings.css
Styles for the settings UI:
.nc-app-frame— bordered frame around entire settings.nc-settings-section— bordered sections for each config group.nc-room-item— checkbox row layout.nc-room-tokens— token container with dark background.nc-token-row— token input + button row.nc-token-url-input— monospace URL display.nc-token-copy/.nc-token-revoke— action buttons.nc-generate-token— generate button.nc-status-success/.nc-status-error— status colors.nc-hint— helper text
appinfo/routes.php
Route definitions. All webhook routes use {roomToken} and {token} path parameters.
[
'webhook#receive', // POST /discord-webhook/{roomToken}/{token}
'webhook#saveConfig', // POST /save-config
'webhook#getRooms', // GET /rooms
'webhook#saveBotPassword', // POST /save-bot-password
'webhook#debug', // GET /debug (noCsrf)
'webhook#receiveApprise', // POST /apprise-webhook/{roomToken}/{token}
'webhook#receiveAppriseNotify', // POST /apprise-webhook/{roomToken}/notify/{token}
]
appinfo/info.xml
<id>nc_bot_webhooks</id>
<name>nc_bot_webhooks</name>
<summary>Discord webhook bridge for Nextcloud Talk with image support</summary>
<description>Accepts Discord webhook-style JSON payloads and posts them into Nextcloud Talk rooms, preserving image attachments.</description>
<version>1.2.1</version>
<licence>AGPL-3.0-or-later</licence>
<namespace>Ncbotwebhooks</namespace>
<dependencies>
<nextcloud min-version="33" max-version="35"/>
<app>spreed</app>
</dependencies>
Dependencies:
- Nextcloud 33 only (explicit version range)
spreedapp (Talk) must be enabled
Class registrations:
- Background job:
OCA\Ncbotwebhooks\Cron\ImageCleanup - Admin settings:
OCA\Ncbotwebhooks\Settings\Admin - CLI command:
OCA\Ncbotwebhooks\Command\DebugToggle
composer.json
{
"name": "nc_bot_webhooks/app",
"autoload": {
"psr-4": {
"OCA\\Ncbotwebhooks\\": "../lib/"
}
}
}
Note: The ../lib/ path is for local dev tooling only. Nextcloud apps use their own autoloader (registered via the app framework) — this file is not used in production.
API Interactions
Nextcloud Talk Chat API v1
Endpoint: POST /ocs/v2.php/apps/spreed/api/v1/chat/{roomToken}
Authentication: Basic auth with talk-bot:bot_password
Headers:
OCS-Expect-Formatted: json
OCS-APIRequest: 1
Authorization: Basic <base64('talk-bot:bot_password')>
Content-Type: application/json
Request body:
{
"message": "Message text (supports rich text formatting)",
"actorType": "users",
"actorId": "talk-bot",
"actorDisplayName": "Display name from room participant",
"richObjects": {
"file-0": {
"rich_object": {
"id": "",
"elements": [{
"type": "file",
"id": "<share token>",
"name": "filename.png",
"mimetype": "image/png",
"thumbnailReady": true,
"fileTarget": "/nc_bot_webhooks-images/roomToken/filename.png",
"path": "filename.png"
}]
},
"source": "file"
}
},
"richObjectsEnd": {
"file-0": true
}
}
Response: 200 on success, error on failure. The app checks statusCode >= 200 && statusCode < 300.
Critical constraint: actorDisplayName MUST match the bot's display name in the room's participant record. Mismatch → message silently dropped.
Apprise API
Apprise sends webhook payloads in several formats depending on the transport:
JSON format (primary):
{
"version": 0,
"type": "info|success|warning|error|image",
"title": "Title",
"subject": "Subject",
"body": "Message body",
"notifications": [
{
"subject": "Subject",
"title": "Title",
"body": "Body",
"type": "type",
"attachments": [...]
}
],
"attachments": [...]
}
Wrapper behavior: Apprise wraps individual notifications in a notifications array. The code handles both wrapped (extract from array) and unwrapped (use directly) formats.
Wrapper-level subject/title propagation: When the wrapper has subject or title but the inner notification doesn't, these are propagated down to preserve sender name context.
Content types handled:
application/json— direct JSON parsemultipart/form-data—$_POST(files in$_FILES['file01'])application/x-www-form-urlencoded—parse_str()
Apprise URL schemes:
discord://→ uses Discord webhook formatapprises://→ uses Apprise format withnotifyin path:/apprise-webhook/{room}/notify/{token}
Type field: The type field maps to an icon displayed before the bot name:
success→ ✅warning→ ⚠️error→ ❌info/image/ missing → no icon
Discord Webhook API
Discord webhooks send JSON with these fields:
{
"content": "Message text",
"embeds": [
{
"title": "Embed title",
"description": "Embed description",
"color": 3066993,
"fields": [{"name": "Field", "value": "Value"}],
"image": {"url": "https://..."},
"thumbnail": {"url": "https://..."}
}
],
"username": "Sender name",
"avatar_url": "https://..."
}
Mapping to Talk:
content→ message textembeds[].title→ bold line**Title**embeds[].description→ message bodyembeds[].fields→name: valuelinesembeds[].image.url/thumbnail.url→ download → upload → inline imageusername→ sender display name (prepended as🤖 **username**)avatar_url→ ignored (Talk doesn't support per-message avatars)
Data Flow
Webhook Ingestion (Discord)
POST /discord-webhook/{roomToken}/{token}
│
├─► WebhookController::receive()
│ │
│ ├─ 1. validateAuthToken(roomToken, token)
│ ├─ 2. json_decode(file_get_contents('php://input'))
│ ├─ 3. mapPayload(data) → message text
│ ├─ 4. getSenderName(data) → sender name
│ ├─ 5. prependDisplayName(senderName, message)
│ ├─ 6. For each embed image/thumbnail:
│ │ ├─ downloadImage(url)
│ │ ├─ uploadImage(roomToken, filename, data, mimeType)
│ │ └─ buildRichObject(filePath, mimeType, roomToken)
│ ├─ 7. postToRoom(roomToken, message, senderName, richObjects)
│ │ ├─ getBotPassword()
│ │ ├─ isBotEnabledForRoom(roomToken)
│ │ ├─ getBaseUrl()
│ │ ├─ getBotDisplayNameForRoom(roomToken)
│ │ └─ HTTP POST with Basic auth
│ └─► DataResponse with X-Webhook-Status
│
└─► HTTP 201/400/401/500 + X-Webhook-Status header
Webhook Ingestion (Apprise)
POST /apprise-webhook/{roomToken}/{token}
│
├─► WebhookController::receiveApprise()
│ │
│ ├─ 1. validateAuthToken(roomToken, token)
│ ├─ 2. Detect content type (JSON → $_POST → parse_str)
│ ├─ 3. Parse payload
│ ├─ 4. If 'notifications' in data: extract first entry, propagate wrapper subject/title
│ ├─ 5. mapApprisePayload(data, roomToken)
│ │ ├─ Resolve displayName from title/subject
│ │ ├─ Build message from body (or title if body empty)
│ │ ├─ Add type prefix ([Info], [Warning], etc.)
│ │ └─ Process attachments (URL → download, file:// → read, base64 → decode)
│ ├─ 6. prependDisplayName(displayName, message)
│ ├─ 7. postToRoom(roomToken, message, senderName, richObjects)
│ └─► DataResponse with X-Webhook-Status
│
└─► HTTP 201/400/401/500 + X-Webhook-Status header
Configuration Save
POST /save-config (admin)
│
├─► WebhookController::saveConfig()
│ │
│ ├─► TalkService::saveConfig(config)
│ │ ├─ validateBotPassword() if provided
│ │ ├─ setBotPassword() if provided
│ │ ├─ setRetentionDays() if provided
│ │ ├─ setRooms() with disabled rooms removed
│ │ ├─ setAuthTokens() if provided
│ │ ├─ setSenderName() if provided
│ │ └─ ensureBotParticipants()
│ └─► JSON {status: 'ok', auth_tokens: ...}
│
└─► JSON response
Room Listing
GET /rooms (admin)
│
├─► WebhookController::getRooms()
│ │
│ ├─► TalkService::getAvailableTalkRooms()
│ │ ├─ detectTalkTableFromCatalog('talk_rooms', 'spreed_room')
│ │ ├─ Query: SELECT token, COALESCE(NULLIF(name,''),token) FROM talk_rooms
│ │ │ WHERE type IN (1,2,3) AND object_type NOT IN ('sample','note_to_self','file')
│ │ └─ Return token → display name
│ │
│ ├─► TalkService::getRooms() (configured rooms)
│ │
│ └─► Merge: for each available room, add 'configured' flag
│
└─► JSON array of {token, name, configured}
Configuration Model
AppConfig (oc_appconfig table)
All config is stored in the oc_appconfig table with appid = 'nc_bot_webhooks'.
| appid | config_key | config_value |
|---|---|---|
| nc_bot_webhooks | bot_password | <encrypted> |
| nc_bot_webhooks | rooms | {"roomToken1": "Room Name", ...} |
| nc_bot_webhooks | retention_days | "90" |
| nc_bot_webhooks | sender_name | "Webhook Bot" |
| nc_bot_webhooks | auth_tokens | {"roomToken1": ["token1", "token2"], ...} |
| nc_bot_webhooks | debug_enabled | 0 or 1 |
Bot User Storage
talk-bot/
└── nc_bot_webhooks-images/
├── <room-token-1>/
│ ├── screenshot.png
│ └── diagram.jpg
└── <room-token-2>/
└── photo.png
Each image has a public link share (SHARE_TYPE_LINK) created via ShareManager. The share token is embedded in the Talk message's rich_object so Talk resolves and displays the inline image.
Base URL Resolution Priority
overwritehost— explicit hostname overrideoverwritewebroot— if full URL- Non-loopback
trusted_domains— skips 127.0.0.1, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, ::1, fc00::/7 overwrite.cli.url— last resort
Talk Table Detection Priority
spreed.databaseprefix(app config) +talk_roomsdbtableprefix(system config) +talk_roomsspreed.databaseprefix+spreed_roomdbtableprefix+spreed_roomtalk_rooms(no prefix)spreed_room(no prefix)
Each candidate is verified via information_schema.tables + test query.
Security Model
Authentication Layers
| Layer | Mechanism |
|---|---|
| Webhook access | Auth token in URL path (/discord-webhook/{room}/{token}) |
| Admin settings | Nextcloud session auth + #[AdminRequired] attribute |
| Bot → Talk | Basic auth (talk-bot:bot_password) |
Authorization
- Admin-only endpoints:
/save-config,/save-bot-password,/roomsrequire admin session - Webhook endpoints:
#[PublicPage]+#[NoCSRFRequired]— auth token is sole access control - Bot must be participant in target room (enforced by
isBotEnabledForRoom()+ Talk's own check)
Data Isolation
- Bot password: encrypted at rest via
ICrypto - Images: stored in
talk-bot's personal storage undernc_bot_webhooks-images/<room-token>/ - Image cleanup: only operates within
nc_bot_webhooks-images/under bot user's folder - Per-file attributes: NOT YET IMPLEMENTED (user requested for extra safety)
Image Download Safety
- Uses Nextcloud
IClientServicewith built-in local address blocking allow_local_address: true— needed for Docker (container-internal addresses)- Filenames sanitized via
basename()— prevents path traversal
Debug Endpoint
/apps/nc_bot_webhooks/debugexposes DB schema, bot credentials, config- Disabled by default
- Enabled only via CLI (
php occ nc_bot_webhooks:debug:enable) - Stored in
appconfig(not hardcoded) - Should be disabled after troubleshooting
Known Issues & Limitations
Critical
-
Apprise 500 error after rename: Config data still under old app ID
ncdiscordhook. Fix:UPDATE oc_appconfig SET appid = 'nc_bot_webhooks' WHERE appid = 'ncdiscordhook'; -
Version pinning to NC33:
<nextcloud min-version="33" max-version="33"/>— app only works on NC33. If upgrading Nextcloud, remove max-version or update it.
Security
-
Client-side token generation: Settings UI uses
btoa(Math.random() + Date.now()). Server-siderandom_bytes(32)would be more secure. UseTalkService::generateAuthToken()for server-side generation. -
No rate limiting: Webhook endpoints have no rate limiting. Handle at web server / reverse proxy level.
-
Image cleanup by mtime only: No per-file ownership verification. Safe because scoped to bot user's folder, but user requested per-file attributes for extra safety (NOT YET IMPLEMENTED).
Functional
- Link preview suppression removed: Nextcloud Talk generates link previews server-side. URL suppression via angle brackets (
<url>) does not work. This feature was removed.
Functional (continued)
-
avatar_url ignored: NCTalk doesn't support per-message avatars.
usernameis embedded as sender name instead. -
actorDisplayName mismatch: If bot's display name changes in the room, messages will be silently dropped.
getBotDisplayNameForRoom()resolves this on each message. -
Table name detection failure: If
information_schemais unavailable or Talk table uses an unusual name,detectTalkTableFromCatalog()returns null and room listing fails. -
Bot not in room: If talk-bot is removed from a configured room,
postToRoom()will fail.ensureBotParticipants()runs on every save to prevent this.
Operational Procedures
Enabling the App
# 1. Create bot user
php occ user:add --password-from-env --display-name="Webhook Bot" talk-bot
# 2. Make bot admin (via web UI: Settings → Users → talk-bot → Admin)
# 3. Generate app password (via web UI: Settings → talk-bot → Devices & sessions → Add device)
# 4. Copy app to Nextcloud apps directory
cp -r nc_bot_webhooks /path/to/nextcloud/apps/
# 5. Enable app
php occ app:enable nc_bot_webhooks
# 6. Configure via web UI (Settings → Admin → nc_bot_webhooks)
Configuring Rooms
- Go to Settings → Admin → nc_bot_webhooks
- Enter bot app password
- Click "Fetch Rooms" to list available Talk rooms
- Check rooms to enable webhooks for
- Generate auth tokens for each room
- Click "Save Configuration"
Managing Auth Tokens
Via UI:
- Click "+ Generate Auth Token" for a room
- Click "Revoke All" to remove all tokens for a room
- Click "Copy" to copy webhook URLs
Via CLI / direct DB:
- Tokens stored as JSON in
oc_appconfigunderauth_tokens - Format:
{"roomToken": ["token1", "token2"], ...}
Debugging Webhook Issues
- Check
X-Webhook-Statusheader in response - Enable debug endpoint:
php occ nc_bot_webhooks:debug:enable - Visit
/apps/nc_bot_webhooks/debugto inspect config - Check Nextcloud log:
data/nextcloud.logor Settings → Admin → Logging - Disable debug:
php occ nc_bot_webhooks:debug:disable
Migrating from ncdiscordhook
UPDATE oc_appconfig SET appid = 'nc_bot_webhooks' WHERE appid = 'ncdiscordhook';
Image Cleanup
Manual trigger:
# The cron job runs automatically. To trigger manually:
php occ background:cron
Adjust retention: Settings → Admin → nc_bot_webhooks → Image Retention (1-365 days)
Check images directory:
ls /path/to/nextcloud/data/talk-bot/nc_bot_webhooks-images/
Debugging Guide
Common Error Patterns
| Symptom | Cause | Fix |
|---|---|---|
| Apprise 500 error | Config under old app ID | Run migration SQL above |
| "talk-bot user not found" | Bot user doesn't exist | Re-create bot user |
| "Bot password not configured" | No bot password in settings | Enter bot password |
| Messages not appearing | Bot not in room / actorDisplayName mismatch | Check admin settings, re-save |
| Image upload fails | Bot storage quota exceeded | Check bot user storage |
| "No Talk rooms found" | Talk not enabled / table detection failed | Verify Talk app, check debug endpoint |
| Room listing empty | Bot not admin / Talk version incompatible | Grant admin, check NC version |
Debug Endpoints
CLI:
php occ nc_bot_webhooks:debug:enable # Enable
php occ nc_bot_webhooks:debug:disable # Disable
php occ nc_bot_webhooks:debug:status # Check
php occ nc_bot_webhooks:debug:toggle # Toggle
Web (when enabled): GET /apps/nc_bot_webhooks/debug returns JSON with:
- Bot password (plaintext)
- Configured rooms
- Auth tokens
- Talk table columns
- Room type breakdown
Talk Service Debug Helpers
These public methods on TalkService are available for programmatic debugging:
| Method | Returns |
|---|---|
detectTalkTableFromCatalog('talk_rooms', 'spreed_room') |
Table name or null |
getTalkTableColumns('table_name') |
Array of {name, type, nullable} |
getTalkTableSample('table_name', 10) |
Array of sample rows |
getAllTalkRoomsDebug(100) |
Array of {id, token, type, readable_name, label, name, object_type, object_id} |
getRoomTypeBreakdown() |
{type: count} array |
Log Tags
All log entries use:
- Logger tag:
'nc_bot_webhooks:' - App tag:
'app' => 'nc_bot_webhooks'
Example log entries:
nc_bot_webhooks: webhook processed successfully
nc_bot_webhooks: failed to post webhook message to Talk
nc_bot_webhooks: message posted to room ABC123
nc_bot_webhooks: bot password not configured
nc_bot_webhooks: bot not enabled for room
nc_bot_webhooks: base URL not configured
nc_bot_webhooks: getAvailableTalkRooms
nc_bot_webhooks: found 5 rooms
nc_bot_webhooks: purgeOldImages
nc_bot_webhooks: purged 3 old image files
Migration Notes
From ncdiscordhook to nc_bot_webhooks (v1.2.1)
Breaking change: New app ID = new installation. Existing config data is under old app ID.
Migration steps:
- Copy new app files to Nextcloud apps directory
- Enable app:
php occ app:enable nc_bot_webhooks - Run config migration SQL
- Re-enter bot password in settings (encrypted values don't migrate)
- Re-configure rooms and auth tokens (or migrate via SQL)
Config migration SQL:
UPDATE oc_appconfig SET appid = 'nc_bot_webhooks' WHERE appid = 'ncdiscordhook';
What migrates:
rooms— room configurationauth_tokens— auth tokensretention_days— retention periodsender_name— sender name
What does NOT migrate:
bot_password— must be re-entered (encrypted values are tied to the instance, not the app ID)
Nextcloud Version Compatibility
Currently pinned to Nextcloud 33–35 (min-version="33" max-version="35"). If upgrading Nextcloud beyond 35:
- Update
max-versionininfo.xml - Test Talk API compatibility (Chat API v1 may change)
- Test
information_schemaqueries on new NC version - Test AttendeeMapper API compatibility
Talk API Version Notes
- Uses Chat API v1 only (
/ocs/v2.php/apps/spreed/api/v1/chat/{roomToken}) - Talk 14+ removed
TalkManager::getRoomForToken— app uses direct DB queries - Talk 14+ requires
actorDisplayNameto match participant record exactly - Talk 19 / NC33 only supports v1 of the Chat API (older versions deprecated)
Testing
Manual Testing Checklist
- Discord webhook — POST valid payload → message appears in Talk room
- Discord webhook with images — embed with image URL → image appears inline
- Discord webhook with embeds — embed with title/description/fields → formatted message
- Discord webhook invalid token → 401 unauthorized
- Discord webhook invalid JSON → 400 bad_request
- Discord webhook empty content → 200 no_content
- Apprise webhook — POST valid notification → message appears in Talk room
- Apprise webhook with attachments — URL attachments → images appear inline
- Apprise webhook type=image — image notification → inline image
- Apprise webhook base64 attachment — base64 data → inline image
- Apprise webhook form-encoded — form data → message appears
- Admin settings — save config → rooms configured, bot participants added
- Token generation — generate token → appears in room token list
- Token revoke — revoke all → tokens cleared
- Image cleanup — wait for cron → old images purged
- Debug endpoint — enable → inspect config → disable
Curl Test Commands
Discord webhook:
curl -X POST \
-H "Content-Type: application/json" \
-d '{"content":"Test message","username":"CI Bot"}' \
https://your-server/apps/nc_bot_webhooks/discord-webhook/<room-token>/<auth-token>
Apprise webhook:
curl -X POST \
-H "Content-Type: application/json" \
-d '{"title":"Test","body":"Test message","type":"info"}' \
https://your-server/apps/nc_bot_webhooks/apprise-webhook/<room-token>/<auth-token>
Check debug endpoint (when enabled):
curl -u admin:password https://your-server/apps/nc_bot_webhooks/debug | jq .
Quick Reference
File Sizes (approximate)
| File | Lines | Size |
|---|---|---|
TalkService.php |
1906 | ~35 KB |
WebhookController.php |
1130 | ~15 KB |
settings.js |
351 | ~12 KB |
Admin.php |
74 | ~3 KB |
ImageCleanup.php |
59 | ~1.5 KB |
DebugToggle.php |
101 | ~3 KB |
NavigationProvider.php |
37 | ~1 KB |
adminSettings.php |
53 | ~2 KB |
adminSettings.css |
105 | ~2 KB |
routes.php |
42 | ~1 KB |
info.xml |
32 | ~1 KB |
Key Dependencies
| Dependency | Purpose |
|---|---|
IAppConfig |
App-level key-value config storage |
IClientService |
HTTP client (with local address blocking) |
IShareManager |
File share management (public link shares) |
IRootFolder |
Filesystem root (user folder access) |
IUserManager |
User lookup (get('talk-bot')) |
IDBConnection |
Database queries |
IAttendeeMapper |
Talk attendee queries |
ICrypto |
Password encryption/decryption |
IShareManager |
Public link share creation |
IL10N |
Internationalization |
LoggerInterface |
PSR-3 logging |
Nextcloud APIs Used
| API | Version | Endpoint / Interface |
|---|---|---|
| Talk Chat API | v1 | /ocs/v2.php/apps/spreed/api/v1/chat/{roomToken} |
| Talk AttendeeMapper | — | findByActor(roomId, actorType, actorId) |
| Talk Room catalog | — | information_schema.tables + table name detection |
| Nextcloud Share API | — | newShare(), createShare(), getShareById() |
| Nextcloud File API | — | getUserFolder(), getFolder(), newFile() |
| Nextcloud AppConfig | — | getAppValue(), setAppValue(), getValueBool() |
| Nextcloud Crypto | — | encrypt(), decrypt() via ICrypto |
| Nextcloud OCS API | — | OCS-Expect-Formatted / OCS-APIRequest headers |