clientService = $clientService; $this->config = $config; $this->db = $db; $this->rootFolder = $rootFolder; $this->request = $request; $this->urlGenerator = $urlGenerator; $this->userManager = $userManager; $this->userSession = $userSession; $this->logger = $logger; $this->talkManager = $talkManager; $this->crypto = $crypto; $this->attendeeMapper = $attendeeMapper; $this->shareManager = $shareManager; $this->participantService = $participantService; $this->chatManager = $chatManager; $this->talkSession = $talkSession; } /** * Resolve Talk services from the global DI container. * In NC33, \OC\AppFramework\App::getAppContainer() was removed. * \OCP\Server::get() resolves the service via ServerContainer::getAppContainerForService() * which looks up the target class's app namespace (e.g. OCA\Talk) and returns the correct service. */ private function resolveTalkService(string $class): object { return \OCP\Server::get($class); } // ── Bot password ────────────────────────────────────────────── public function getBotPassword(): ?string { $encrypted = $this->config->getAppValue(self::APP_ID, 'bot_password', ''); if ($encrypted === '') { return null; } try { return $this->crypto->decrypt($encrypted); } catch (\Exception $e) { return null; } } /** * Validate a bot password by encrypting and decrypting it (round-trip test). * Returns ['valid' => true] on success, or ['valid' => false, 'error' => '...'] on failure. */ public function validateBotPassword(string $password): array { if ($password === '') { return ['valid' => false, 'error' => 'Bot password cannot be empty.']; } try { $encrypted = $this->crypto->encrypt($password); $decrypted = $this->crypto->decrypt($encrypted); if ($decrypted !== $password) { return ['valid' => false, 'error' => 'Password encryption/decryption failed. The password may contain unsupported characters.']; } return ['valid' => true]; } catch (\Exception $e) { return ['valid' => false, 'error' => 'Password encryption failed: ' . $e->getMessage()]; } } public function setBotPassword(string $password): void { $this->config->setAppValue(self::APP_ID, 'bot_password', $this->crypto->encrypt($password)); } public function hasBotPassword(): bool { return $this->getBotPassword() !== null; } public function getBotUser(): ?\OCP\IUser { return $this->userManager->get('talk-bot'); } /** * Get the raw DB connection (for diagnostic use). */ public function getDbConnection(): IDBConnection { return $this->db; } // ── Retention ───────────────────────────────────────────────── public function getRetentionDays(): int { return (int) $this->config->getAppValue(self::APP_ID, 'retention_days', '90'); } public function setRetentionDays(int $days): void { $this->config->setAppValue(self::APP_ID, 'retention_days', (string) $days); } /** * Overwrite Talk actor for the current session. * * Talk's SystemMessageListener::sendSystemMessage() checks these * session keys to determine the actor when no participant is passed. * Used to suppress the "guest" actor bug in Talk 23.0.6 where * fixMimeTypeOfVoiceMessage() fires on every TYPE_ROOM share. * * Must be cleared after use to avoid leaking to other requests. */ private function setSessionOverwrite(string $actorId): void { $reflection = new \ReflectionObject($this->talkSession); $property = $reflection->getProperty('session'); $property->setAccessible(true); /** @var \OCP\ISession $session */ $session = $property->getValue($this->talkSession); $session->set('talk-overwrite-actor-type', Attendee::ACTOR_USERS); $session->set('talk-overwrite-actor-id', $actorId); } private function clearSessionOverwrite(): void { try { $reflection = new \ReflectionObject($this->talkSession); $property = $reflection->getProperty('session'); $property->setAccessible(true); /** @var \OCP\ISession $session */ $session = $property->getValue($this->talkSession); $session->remove('talk-overwrite-actor-type'); $session->remove('talk-overwrite-actor-id'); } catch (\Exception $e) { // Session keys may not exist — ignore } } // ── Base URL ────────────────────────────────────────────────── /** * Get the server base URL for internal API calls. * Uses trusted_domains as the base URL — the HTTP client blocks * all localhost addresses, and in Docker deployments overwrite.cli.url * often points to a localhost:port that is unreachable from within the container. */ public function getBaseUrl(): string { // Prefer overwritehost (hostname override) over trusted_domains $overwriteHost = $this->config->getSystemValueString('overwritehost', ''); if ($overwriteHost !== '') { $proto = $this->config->getSystemValueString('overwriteproto', 'https'); return rtrim($proto . '://' . $overwriteHost, '/'); } $overwritten = $this->config->getSystemValueString('overwritewebroot', ''); if ($overwritten !== '') { // overwritewebroot may be a path — prepend current host/proto if needed if (preg_match('#^https?://#', $overwritten)) { return rtrim($overwritten, '/'); } // It's a path — fall through to trusted_domains } // Prefer a public (non-loopback, non-private) trusted domain. // In Docker/container deployments, trusted_domains[0] is often 127.0.0.1 // which is unreachable from within the container. $trusted = $this->config->getSystemValue('trusted_domains', []); foreach ($trusted as $domain) { if ($domain === '') { continue; } // Already has a scheme — trust it as-is if (preg_match('#^https?://#', $domain)) { return rtrim($domain, '/'); } // Skip loopback and localhost $lower = strtolower($domain); if ($lower === 'localhost' || $lower === '127.0.0.1' || $lower === '::1' ) { continue; } // Skip private IP ranges (10.x, 172.16-31.x, 192.168.x) if (preg_match( '/^(10\.\d{1,3}|172\.(1[6-9]|2\d|3[01])|192\.168)\./i', $domain )) { continue; } return 'https://' . $domain; } // Fallback to first trusted domain (may be localhost; caller may fail) if (!empty($trusted[0])) { if (preg_match('#^https?://#', $trusted[0])) { return rtrim($trusted[0], '/'); } return 'https://' . $trusted[0]; } // Last resort: try overwrite.cli.url $cliUrl = $this->config->getSystemValueString('overwrite.cli.url', ''); if ($cliUrl !== '') { return rtrim($cliUrl, '/'); } return ''; } // ── Rooms ───────────────────────────────────────────────────── /** * Get configured rooms: room token → display name */ public function getRooms(): array { $json = $this->config->getAppValue(self::APP_ID, 'rooms', '[]'); $rooms = json_decode($json, true); return is_array($rooms) ? $rooms : []; } /** * Save configured rooms: room token → display name */ public function setRooms(array $rooms): void { $this->config->setAppValue(self::APP_ID, 'rooms', json_encode($rooms)); } /** * Get available Talk rooms via database query. * * Queries the Talk DB directly (bypasses the OCS API which does not * accept app passwords for auth). Returns only public channels (type 1) * — the only room type that makes sense for a Discord-style webhook * channel picker. Returns rooms as [token => displayName] pairs. */ public function getAvailableTalkRooms(): array { // Detect table names by querying PostgreSQL information_schema directly. $roomTable = $this->detectTalkTableFromCatalog('talk_rooms', 'spreed_room'); if ($roomTable === null) { $sysPrefix = $this->config->getSystemValueString('dbtableprefix', ''); $talkPrefix = $this->config->getAppValue('spreed', 'databaseprefix', $sysPrefix); $this->logger->warning('nc_bot_webhooks: Talk tables not found', [ 'app' => self::APP_ID, 'sysPrefix' => $sysPrefix, 'talkPrefix' => $talkPrefix, ]); return []; } // In NC33 the room display name is stored in the 'name' column // for all room types. Exclude deleted (type 4), note-to-self (type 6), // sample rooms, file share rooms (object_type = 'file'), // and private DM rooms (name starts with '["'). try { $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\' AND name NOT LIKE \'["%\''; $result = $this->db->executeQuery($sql); } catch (\Exception $e) { $this->logger->warning('nc_bot_webhooks: 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('nc_bot_webhooks: getAvailableTalkRooms', [ 'app' => self::APP_ID, 'room_table' => $roomTable, ]); try { $rooms = []; while ($row = $result->fetch()) { $rooms[$row['token']] = $row['display_name'] !== '' ? $row['display_name'] : $row['token']; } $result->closeCursor(); $this->logger->info('nc_bot_webhooks: found ' . count($rooms) . ' rooms', [ 'app' => self::APP_ID, 'rooms' => array_keys($rooms), ]); return $rooms; } catch (\Exception $e) { $this->logger->error('nc_bot_webhooks: room listing exception', [ 'app' => self::APP_ID, 'error' => $e->getMessage(), 'file' => $e->getFile(), 'line' => $e->getLine(), ]); return []; } } /** * Debug helper: return all rooms with key columns for inspection. */ 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', ''); $talkPrefix = $this->config->getAppValue('spreed', 'databaseprefix', $sysPrefix); // Build candidate list ordered by likelihood $candidates = [ $talkPrefix . $newName, $sysPrefix . $newName, $talkPrefix . $oldName, $sysPrefix . $oldName, $newName, $oldName, ]; // Deduplicate preserving order $seen = []; $unique = []; foreach ($candidates as $name) { if (!isset($seen[$name])) { $seen[$name] = true; $unique[] = $name; } } // Query information_schema to find which candidates actually exist as tables try { $result = $this->db->executeQuery( 'SELECT table_name FROM information_schema.tables WHERE table_schema = \'public\' AND table_type = \'BASE TABLE\' AND table_name IN (' . implode(',', array_map(fn($n) => '\'' . $n . '\'', $unique)) . ')', ); $found = []; while ($row = $result->fetch()) { $found[] = $row['table_name']; } $result->closeCursor(); } catch (\Exception $e) { $this->logger->warning('nc_bot_webhooks: information_schema query failed', [ 'app' => self::APP_ID, 'error' => $e->getMessage(), ]); return null; } // Try executing a lightweight query on each candidate to verify it's usable. foreach ($unique as $table) { try { $testResult = $this->db->executeQuery( 'SELECT 1 FROM "' . $table . '" LIMIT 1', ); $testResult->closeCursor(); return $table; } catch (\Exception $e) { continue; } } 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 ─────────────────────────────────────────────── /** * Get auth tokens for a room: room token → [token1, token2, ...] */ public function getAuthTokens(): array { $json = $this->config->getAppValue(self::APP_ID, 'auth_tokens', '{}'); $tokens = json_decode($json, true); return is_array($tokens) ? $tokens : []; } /** * Save auth tokens for a room. */ public function setAuthTokens(array $tokens): void { $this->config->setAppValue(self::APP_ID, 'auth_tokens', json_encode($tokens)); } /** * Validate auth token for a room. */ public function validateAuthToken(string $roomToken, string $authToken): bool { $allTokens = $this->getAuthTokens(); if (!isset($allTokens[$roomToken]) || !is_array($allTokens[$roomToken])) { return false; } return in_array($authToken, $allTokens[$roomToken], true); } /** * Generate a new auth token for a room. * Returns the new token. */ public function generateAuthToken(string $roomToken): string { $token = bin2hex(random_bytes(24)); // 48-char hex token $allTokens = $this->getAuthTokens(); if (!isset($allTokens[$roomToken])) { $allTokens[$roomToken] = []; } $allTokens[$roomToken][] = $token; $this->setAuthTokens($allTokens); return $token; } /** * Revoke a token from a room. */ public function revokeAuthToken(string $roomToken, string $authToken): void { $allTokens = $this->getAuthTokens(); if (!isset($allTokens[$roomToken]) || !is_array($allTokens[$roomToken])) { return; } $allTokens[$roomToken] = array_values(array_filter( $allTokens[$roomToken], fn($t) => $t !== $authToken )); if (empty($allTokens[$roomToken])) { unset($allTokens[$roomToken]); } $this->setAuthTokens($allTokens); } // ── Payload mapping ─────────────────────────────────────────── /** * Map Discord webhook payload to Talk message text. */ /** * Prepend a display name line to a message. * Since Talk doesn't support per-message avatars, we embed the name in the message. */ public function prependDisplayName(string $displayName, string $message, string $typeIcon = ''): string { $icon = $typeIcon !== '' ? $typeIcon . ' ' : ''; $nameLine = $icon . '🤖 **' . $displayName . '**'; if ($message === '') { return $nameLine; } return $nameLine . "\n\n" . $message; } public function mapPayload(array $data): string { $parts = []; // Regular content if (!empty($data['content'])) { $parts[] = $data['content']; } // Embeds if (!empty($data['embeds']) && is_array($data['embeds'])) { foreach ($data['embeds'] as $embed) { if (!is_array($embed)) { continue; } if (!empty($embed['title'])) { $parts[] = '**' . $embed['title'] . '**'; } if (!empty($embed['description'])) { $parts[] = $embed['description']; } if (!empty($embed['fields']) && is_array($embed['fields'])) { foreach ($embed['fields'] as $field) { if (is_array($field) && !empty($field['name'])) { $fieldText = $field['name'] . ': '; if (!empty($field['value'])) { $fieldText .= $field['value']; } $parts[] = $fieldText; } } } } } return implode("\n\n", $parts); } /** * Get sender name from payload or config default. */ public function getSenderName(array $data): string { if (!empty($data['sender_name'])) { return $data['sender_name']; } if (!empty($data['username'])) { return $data['username']; } 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'); } /** * Map Apprise JSON payload to our internal format. * * Apprise sends: { version, type, title, body, attachments } * Returns: { message, senderName, richObjects } */ public function mapApprisePayload(array $data, string $roomToken = ''): array { $this->logger->info('nc_bot_webhooks: mapApprisePayload input', [ 'app' => self::APP_ID, 'type' => $data['type'] ?? 'none', 'has_attachments' => !empty($data['attachments']), 'has_attachment' => !empty($data['attachment']), 'attachments' => json_encode($data['attachments'] ?? []), 'body' => $data['body'] ?? '', 'title' => $data['title'] ?? '', ]); $parts = []; // Display name: sender_name > title > subject > config default $displayName = !empty($data['sender_name']) ? $data['sender_name'] : (!empty($data['title']) ? $data['title'] : (!empty($data['subject']) ? $data['subject'] : $this->config->getAppValue(self::APP_ID, 'sender_name', 'Webhook Bot'))); // Body if (!empty($data['body'])) { $parts[] = $data['body']; } // For image-type notifications, use the attachment URL as the message if (!empty($data['type']) && $data['type'] === 'image') { // Apprise sends image notifications with type=image and attachment (string URL) $imageUrls = []; if (!empty($data['attachment']) && is_string($data['attachment'])) { $imageUrls[] = $data['attachment']; } // Collect image URLs and base64 attachments $imageUrls = []; $imageBases64 = []; if (!empty($data['attachment']) && is_string($data['attachment'])) { $imageUrls[] = $data['attachment']; } if (!empty($data['attachments']) && is_array($data['attachments'])) { foreach ($data['attachments'] as $a) { if (is_string($a)) { $imageUrls[] = $a; } elseif (is_array($a)) { if (!empty($a['url'])) { $imageUrls[] = $a['url']; } elseif (!empty($a['path']) && (str_starts_with($a['path'], 'http://') || str_starts_with($a['path'], 'https://'))) { $imageUrls[] = $a['path']; } elseif (!empty($a['base64'])) { $imageBases64[] = $a; } } } } $richObjects = []; if (!empty($imageUrls)) { $richObjects = $this->processImageUrls($imageUrls, $roomToken); } foreach ($imageBases64 as $b64) { $fileData = base64_decode($b64['base64'], true); if ($fileData === false) { continue; } $fileName = $b64['filename'] ?? $b64['name'] ?? 'attachment'; $mimeType = $b64['mimetype'] ?? $b64['mimeType'] ?? 'image/png'; $uploadPath = $this->uploadImage($roomToken, $fileName, $fileData, $mimeType); if ($uploadPath !== null) { $richObj = $this->buildRichObject($uploadPath, $mimeType, $roomToken); if ($richObj !== null) { $richObjects[] = $richObj; } } } if (!empty($imageUrls) || !empty($imageBases64)) { // Use title as message if body is empty if (empty($data['body'])) { $message = !empty($data['title']) ? $data['title'] : ''; } else { $message = implode("\n\n", $parts); } $senderName = $displayName; return [ 'message' => $message, 'senderName' => $senderName, 'displayName' => $displayName, 'richObjects' => $richObjects, 'typeIcon' => '', ]; } } $message = implode("\n\n", $parts); // If body is empty but we have a title/subject, use it as the message // (handles test notifications and simple alerts with no body) if ($message === '') { if (!empty($data['title'])) { $message = $data['title']; } elseif (!empty($data['subject'])) { $message = $data['subject']; } } // Sender name: use app name if available, otherwise default $senderName = $displayName; // Handle attachments: download files and upload to Talk $richObjects = []; if (!empty($data['attachments']) && is_array($data['attachments'])) { foreach ($data['attachments'] as $attachment) { if (!is_array($attachment)) { continue; } $filePath = $attachment['path'] ?? ''; $this->logger->info('nc_bot_webhooks: attachment path', [ 'app' => self::APP_ID, 'path' => $filePath, 'url_key' => $attachment['url'] ?? 'none', ]); if (str_starts_with($filePath, 'file://')) { $localPath = substr($filePath, 7); // Strip 'file://' prefix if (is_file($localPath) && is_readable($localPath)) { $fileData = file_get_contents($localPath); if ($fileData === false) { continue; } $fileName = $attachment['name'] ?? basename($localPath); $mimeType = $attachment['mimeType'] ?? mime_content_type($localPath); $uploadPath = $this->uploadImage($roomToken, $fileName, $fileData, $mimeType); if ($uploadPath !== null) { $richObj = $this->buildRichObject($uploadPath, $mimeType, $roomToken); if ($richObj !== null) { $richObjects[] = $richObj; } } } } elseif (!empty($attachment['url'] ?? '')) { // Remote URL attachment $imageData = $this->downloadImage($attachment['url']); if ($imageData === null) { continue; } $fileName = $attachment['name'] ?? 'attachment'; $uploadPath = $this->uploadImage($roomToken, $fileName, $imageData['data'], $imageData['mimeType']); if ($uploadPath !== null) { $richObj = $this->buildRichObject($uploadPath, $imageData['mimeType'], $roomToken); if ($richObj !== null) { $richObjects[] = $richObj; } } } elseif (str_starts_with($filePath, 'http://') || str_starts_with($filePath, 'https://')) { // HA rest_command sends HTTP URLs in 'path' — treat as remote URL $richObjects = array_merge($richObjects, $this->processImageUrls([$filePath], $roomToken)); } elseif (!empty($attachment['url'] ?? '')) { // Remote URL attachment $imageData = $this->downloadImage($attachment['url']); if ($imageData === null) { continue; } $fileName = $attachment['name'] ?? 'attachment'; $uploadPath = $this->uploadImage($roomToken, $fileName, $imageData['data'], $imageData['mimeType']); if ($uploadPath !== null) { $richObj = $this->buildRichObject($uploadPath, $imageData['mimeType'], $roomToken); if ($richObj !== null) { $richObjects[] = $richObj; } } } elseif (!empty($attachment['base64'] ?? '')) { // Base64-encoded attachment (Apprise library JSON method) $fileData = base64_decode($attachment['base64'], true); if ($fileData === false) { continue; } $fileName = $attachment['filename'] ?? $attachment['name'] ?? 'attachment'; $mimeType = $attachment['mimetype'] ?? $attachment['mimeType'] ?? 'application/octet-stream'; $uploadPath = $this->uploadImage($roomToken, $fileName, $fileData, $mimeType); if ($uploadPath !== null) { $richObj = $this->buildRichObject($uploadPath, $mimeType, $roomToken); if ($richObj !== null) { $richObjects[] = $richObj; } } } elseif (!empty($attachment['data'] ?? '')) { // Raw data attachment (extracted from standalone multipart part) $fileData = $attachment['data']; $fileName = $attachment['filename'] ?? 'attachment'; $mimeType = $attachment['mimeType'] ?? 'application/octet-stream'; $uploadPath = $this->uploadImage($roomToken, $fileName, $fileData, $mimeType); if ($uploadPath !== null) { $richObj = $this->buildRichObject($uploadPath, $mimeType, $roomToken); if ($richObj !== null) { $richObjects[] = $richObj; } } } } } // Determine type icon for the sender line (empty for info/image) $typeIcon = ''; if (!empty($data['type'])) { $typeIcons = [ 'success' => '✅', 'warning' => '⚠️', 'error' => '❌', ]; $typeIcon = $typeIcons[$data['type']] ?? ''; } return [ 'message' => $message, 'senderName' => $senderName, 'displayName' => $displayName, 'richObjects' => $richObjects, 'typeIcon' => $typeIcon, ]; } /** * Set sender name default. */ public function setSenderName(string $name): void { $this->config->setAppValue(self::APP_ID, 'sender_name', $name); } // ── Image handling ──────────────────────────────────────────── /** * Download an image from a URL. * Returns ['data' => binary, 'mimeType' => string] or null on failure. */ public function downloadImage(string $url): ?array { try { $client = $this->clientService->newClient(); $response = $client->get($url, [ 'timeout' => 15, 'nextcloud' => [ 'allow_local_address' => true, ], ]); $mimeType = $response->getHeader('Content-Type') ?: 'image/png'; $body = $response->getBody(); $statusCode = $response->getStatusCode(); $this->logger->info('nc_bot_webhooks: image download response', [ 'app' => self::APP_ID, 'url' => $url, 'status_code' => $statusCode, 'body_length' => strlen($body), ]); if (strlen($body) === 0) { $this->logger->warning('nc_bot_webhooks: image download returned empty body', [ 'app' => self::APP_ID, 'url' => $url, 'status_code' => $statusCode, ]); return null; } return ['data' => $body, 'mimeType' => $mimeType]; } catch (\Exception $e) { $this->logger->warning('nc_bot_webhooks: Failed to download image: ' . $url . ' — ' . $e->getMessage(), ['app' => self::APP_ID]); return null; } } /** * Upload an image to the bot user's files under IMAGES_DIR//. * Filename format: talk-bot YYYY-MM-DD hh.mm.ss.microsec.ext * Returns the filecache path (e.g. nc_bot_webhooks-images/abc123/talk-bot 2026-06-13 01.35.19.123456_filename.png) or null on failure. */ public function uploadImage(string $roomToken, string $filename, string $data, string $mimeType): ?string { $bot = $this->userManager->get('talk-bot'); if (!$bot) { $this->logger->error('nc_bot_webhooks: uploadImage — bot user not found', ['app' => self::APP_ID]); return null; } try { $this->logger->info('nc_bot_webhooks: uploadImage start', [ 'app' => self::APP_ID, 'roomToken' => $roomToken, 'filename' => $filename, 'size' => strlen($data), 'bot_uid' => $bot->getUID(), ]); $userFolder = $this->rootFolder->getUserFolder($bot->getUID()); $this->logger->info('nc_bot_webhooks: getUserFolder succeeded', [ 'app' => self::APP_ID, 'userFolder_type' => get_class($userFolder), ]); // Ensure IMAGES_DIR exists (e.g. nc_bot_webhooks-images/) $imagesDir = null; try { $imagesDir = $userFolder->get(self::IMAGES_DIR); $this->logger->info('nc_bot_webhooks: images dir already exists', [ 'app' => self::APP_ID, 'path' => self::IMAGES_DIR, ]); } catch (\OCP\Files\NotFoundException $e) { $this->logger->info('nc_bot_webhooks: creating images dir', [ 'app' => self::APP_ID, 'path' => self::IMAGES_DIR, ]); $userFolder->newFolder(self::IMAGES_DIR); $imagesDir = $userFolder->get(self::IMAGES_DIR); } catch (\Error $e) { $this->logger->error('nc_bot_webhooks: get threw Error on images dir: ' . $e->getMessage(), [ 'app' => self::APP_ID, 'path' => self::IMAGES_DIR, 'exception' => get_class($e), ]); throw $e; } // Ensure per-room subdirectory exists $roomDir = null; try { $roomDir = $imagesDir->get($roomToken); $this->logger->info('nc_bot_webhooks: room dir already exists', [ 'app' => self::APP_ID, 'path' => $roomToken, ]); } catch (\OCP\Files\NotFoundException $e) { $this->logger->info('nc_bot_webhooks: creating room dir', [ 'app' => self::APP_ID, 'parent' => self::IMAGES_DIR, 'path' => $roomToken, ]); $imagesDir->newFolder($roomToken); $roomDir = $imagesDir->get($roomToken); } catch (\Error $e) { $this->logger->error('nc_bot_webhooks: get threw Error on room dir: ' . $e->getMessage(), [ 'app' => self::APP_ID, 'parent' => self::IMAGES_DIR, 'path' => $roomToken, 'exception' => get_class($e), ]); throw $e; } // Build timestamped filename: talk-bot YYYY-MM-DD hh.mm.ss.microsec.ext $safeFilename = $this->sanitizeFilename($filename); $ext = pathinfo($safeFilename, PATHINFO_EXTENSION); $base = pathinfo($safeFilename, PATHINFO_FILENAME); $timestamp = date('Y-m-d H.i.s') . '.' . sprintf('%06d', (int)(microtime(true) * 1000) % 1000000); $relativePath = 'talk-bot ' . $timestamp . '_' . $base . '.' . $ext; $this->logger->info('nc_bot_webhooks: writing file', [ 'app' => self::APP_ID, 'original' => $safeFilename, 'path' => $relativePath, ]); $roomDir->newFile($relativePath, $data); $this->logger->info('nc_bot_webhooks: file written successfully', [ 'app' => self::APP_ID, 'path' => self::IMAGES_DIR . '/' . $roomToken . '/' . $relativePath, ]); // Verify filecache entry was created by newFile. $prefix2 = strtolower($this->config->getSystemValueString('dbtableprefix', 'oc_')); if (substr($prefix2, -1) !== '_') { $prefix2 .= '_'; } $fcTable = $prefix2 . 'filecache'; try { $pdo2 = $this->db->getInner()->getNativeConnection(); $stmtFc = $pdo2->prepare( 'SELECT "fileid","storage","path","path_hash","name","mimetype","size" FROM "' . $fcTable . '" WHERE "path" = ?', ); $stmtFc->execute([self::IMAGES_DIR . '/' . $roomToken . '/' . $relativePath]); $fcRow = $stmtFc->fetchAll(\PDO::FETCH_ASSOC); $stmtFc->closeCursor(); $this->logger->info('nc_bot_webhooks: uploadImage filecache check', [ 'app' => self::APP_ID, 'found' => count($fcRow) > 0, 'entries' => $fcRow, ]); } catch (\Throwable $e) { $this->logger->warning('nc_bot_webhooks: uploadImage filecache check failed: ' . $e->getMessage(), ['app' => self::APP_ID]); } return self::IMAGES_DIR . '/' . $roomToken . '/' . $relativePath; } catch (\Error $e) { $this->logger->error('nc_bot_webhooks: uploadImage Error: ' . $e->getMessage() . ' at ' . $e->getFile() . ':' . $e->getLine(), ['app' => self::APP_ID]); return null; } catch (\Exception $e) { $this->logger->error('nc_bot_webhooks: Failed to upload image: ' . $e->getMessage(), ['app' => self::APP_ID]); return null; } } /** * Sanitize a filename to prevent path traversal. */ private function sanitizeFilename(string $filename): string { return basename($filename); } /** * Build rich object data for a Talk message from an uploaded file path. * Creates a TYPE_ROOM share in the bot user's storage so Talk's SystemMessage * parser can resolve the rich object inline. More secure than public link shares * since the share is scoped to the room rather than publicly accessible. */ public function buildRichObject(string $filePath, string $mimeType, string $roomToken): ?array { $this->logger->info('nc_bot_webhooks: buildRichObject ENTER', ['app' => self::APP_ID, 'filePath' => $filePath, 'roomToken' => $roomToken]); $bot = $this->userManager->get('talk-bot'); if (!$bot) { $this->logger->info('nc_bot_webhooks: buildRichObject EXIT noBot', ['app' => self::APP_ID]); return null; } try { $safeFilename = basename($filePath); $this->logger->info('nc_bot_webhooks: richObject step=basename', ['app' => self::APP_ID]); // filePath is the full filecache path returned by uploadImage (e.g. nc_bot_webhooks-images/abc123/talk-bot 2026-06-13 01.35.19.123456_filename.png). $fileCachePath = $filePath; $this->logger->info('nc_bot_webhooks: richObject step=buildPath', ['app' => self::APP_ID, 'path' => $fileCachePath]); // Use PDO directly to avoid any DBConnection method that could trigger lazy init. $this->logger->info('nc_bot_webhooks: richObject step=getPdo', ['app' => self::APP_ID]); $pdo = $this->db->getInner()->getNativeConnection(); $this->logger->info('nc_bot_webhooks: richObject step=pdoOk', ['app' => self::APP_ID]); // Get table prefix from system config (safe — no DB abstraction layer). $prefix = $this->config->getSystemValueString('dbtableprefix', 'oc_'); $prefix = strtolower($prefix); if ($prefix === '') { $prefix = 'oc_'; } // Ensure trailing underscore if (substr($prefix, -1) !== '_') { $prefix = $prefix . '_'; } $fileCacheTable = $prefix . 'filecache'; $shareTable = $prefix . 'share'; $this->logger->info('nc_bot_webhooks: richObject step=tables', ['app' => self::APP_ID, 'filecache' => $fileCacheTable, 'share' => $shareTable]); // Resolve the bot user's home storage ID from the storages table. // Storage IDs are instance-specific; hardcoding '1' breaks on many setups. $storageId = null; try { $storagesTable = $prefix . 'storages'; $stmt = $pdo->prepare('SELECT "numeric_id" FROM "' . $storagesTable . '" WHERE "id" = ?', []); $stmt->execute(['home::' . $bot->getUID()]); $storageId = (int)$stmt->fetch(\PDO::FETCH_COLUMN); $stmt->closeCursor(); $this->logger->info('nc_bot_webhooks: richObject step=storageId', ['app' => self::APP_ID, 'storageId' => $storageId]); } catch (\Throwable $e) { $this->logger->warning('nc_bot_webhooks: richObject step=storageIdFail', ['app' => self::APP_ID, 'error' => $e->getMessage()]); } $this->logger->info('nc_bot_webhooks: richObject step=prepareSelect', ['app' => self::APP_ID]); $stmt = $pdo->prepare( 'SELECT fileid, path, mimetype, permissions, size, storage' . ' FROM "' . $fileCacheTable . '"' . ' WHERE "path_hash" = ?', ); $pathHash = md5($fileCachePath); $this->logger->info('nc_bot_webhooks: richObject step=executeSelect', ['app' => self::APP_ID, 'pathHash' => $pathHash, 'path' => $fileCachePath]); $stmt->execute([$pathHash]); $this->logger->info('nc_bot_webhooks: richObject step=fetch', ['app' => self::APP_ID]); $row = $stmt->fetch(\PDO::FETCH_ASSOC); $stmt->closeCursor(); $this->logger->info('nc_bot_webhooks: richObject step=rowFetched', ['app' => self::APP_ID, 'found' => $row !== false, 'columns' => array_keys($row ?: []), 'data' => $row]); // Path-based fallback: if path_hash lookup found nothing, try LIKE match on path column. // This catches cases where the filecache entry exists but path_hash didn't match. if (!$row) { $this->logger->info('nc_bot_webhooks: richObject step=pathFallback', ['app' => self::APP_ID, 'path' => $fileCachePath]); $stmt2 = $pdo->prepare( 'SELECT fileid, path, mimetype, permissions, size, storage' . ' FROM "' . $fileCacheTable . '"' . ' WHERE "path" LIKE ?', ); $stmt2->execute([self::IMAGES_DIR . '/%']); $rows = $stmt2->fetchAll(\PDO::FETCH_ASSOC); $stmt2->closeCursor(); $this->logger->info('nc_bot_webhooks: richObject step=pathFallbackRows', ['app' => self::APP_ID, 'count' => count($rows)]); foreach ($rows as $r) { if ($r['path'] === $fileCachePath) { $row = $r; $this->logger->info('nc_bot_webhooks: richObject step=pathFallbackMatch', ['app' => self::APP_ID, 'fileId' => $r['fileid']]); break; } } } if (!$row) { // Manual filecache insertion — the file exists on disk but the entry is missing. // This can happen if the scanner didn't run or the entry was deleted. $this->logger->warning('nc_bot_webhooks: richObject step=insertFc', ['app' => self::APP_ID, 'path' => $fileCachePath, 'storageId' => $storageId]); try { // Look up mimetype ID from the mimetype lookup table. // In NC33, mimetype is a bigint (ID), not a string. $mimeTypeTable = $prefix . 'mimetypes'; $stmtMt = $pdo->prepare('SELECT "id" FROM "' . $mimeTypeTable . '" WHERE "mimetype" = ?'); $stmtMt->execute([$mimeType]); $mimeTypeId = (int)$stmtMt->fetch(\PDO::FETCH_COLUMN); $stmtMt->closeCursor(); if ($mimeTypeId <= 0) { $mimeTypeId = 1; // fallback } // Look up mimepart ID (top-level type, e.g. 'image' from 'image/jpeg'). $mimePart = explode('/', $mimeType)[0]; $stmtMp = $pdo->prepare('SELECT "id" FROM "' . $mimeTypeTable . '" WHERE "mimetype" = ?'); $stmtMp->execute([$mimePart]); $mimePartId = (int)$stmtMp->fetch(\PDO::FETCH_COLUMN); $stmtMp->closeCursor(); if ($mimePartId <= 0) { $mimePartId = 1; // fallback } // Look up the room directory fileid to use as parent. // filecache 'path' stores relative paths without leading slash. $roomDirPath = self::IMAGES_DIR . '/' . $roomToken; $roomDirHash = md5($roomDirPath); $stmtP = $pdo->prepare( 'SELECT "fileid" FROM "' . $fileCacheTable . '" WHERE "path_hash" = ? AND "name" = ?', ); $stmtP->execute([$roomDirHash, $roomToken]); $parentFileId = (int)$stmtP->fetch(\PDO::FETCH_COLUMN); $stmtP->closeCursor(); if ($parentFileId <= 0) { // Parent not in filecache yet — use 0 (root) as fallback. $parentFileId = 0; } $stmtInsert = $pdo->prepare( 'INSERT INTO "' . $fileCacheTable . '"' . ' ("storage","path","path_hash","parent","name","mimetype","mimepart","size","mtime","storage_mtime","encrypted","unencrypted_size","etag","permissions")' . ' VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)', ); $stmtInsert->execute([ $storageId ?: 1, $fileCachePath, $pathHash, $parentFileId, $safeFilename, $mimeTypeId, $mimePartId, 0, // size (not yet known at this point) (int)$this->config->getSystemValueInt('phpfileclient_mapping_localtime', time()), (int)$this->config->getSystemValueInt('phpfileclient_mapping_localtime', time()), 0, // encrypted 0, // unencrypted_size '', // etag \OCP\Constants::PERMISSION_READ | \OCP\Constants::PERMISSION_UPDATE | \OCP\Constants::PERMISSION_DELETE, ]); $fileId = (int)$pdo->lastInsertId($prefix . 'filecache_fileid_seq'); $this->logger->info('nc_bot_webhooks: richObject step=fcInserted', ['app' => self::APP_ID, 'fileId' => $fileId]); } catch (\Throwable $e) { $this->logger->error('nc_bot_webhooks: richObject step=fcInsertFail', ['app' => self::APP_ID, 'error' => $e->getMessage()]); return null; } } else { $fileId = (int)$row['fileid']; } $fileMimeType = $row !== false ? ($row['mimetype'] ?? $mimeType) : $mimeType; $this->logger->info('nc_bot_webhooks: richObject step=fileFound', ['app' => self::APP_ID, 'fileId' => $fileId]); $this->logger->info('nc_bot_webhooks: richObject step=prepareInsert', ['app' => self::APP_ID]); // Resolve the actual Node object so createShare() doesn't have to // lazily resolve it via getFirstNodeById() which can fail on // LazyUserFolder or with manually-inserted filecache entries. $node = null; try { $userFolder = $this->rootFolder->getUserFolder($bot->getUID()); $node = $userFolder->get($fileCachePath); $this->logger->info('nc_bot_webhooks: richObject step=nodeResolved', ['app' => self::APP_ID, 'nodeType' => get_class($node)]); } catch (\Throwable $e) { $this->logger->warning('nc_bot_webhooks: richObject step=nodeResolveFail', ['app' => self::APP_ID, 'error' => $e->getMessage()]); } // NOTE: Share creation is deferred to postToRoom() so we can set // the session-based actor overwrite before createShare() fires. // buildRichObject() now generates a placeholder shareId that will // be replaced after createShare() returns in postToRoom(). $shareId = null; // Compute actual file size from disk (filecache size may be 0 if manually inserted). $actualSize = 0; try { $basePath = $this->config->getSystemValueString('datadirectory', '/var/www/nextcloud/data'); $filePath = rtrim($basePath, '/') . '/' . ltrim($bot->getUID(), '/') . '/files/' . $fileCachePath; if (file_exists($filePath)) { $s = filesize($filePath); if ($s !== false && $s > 0) { $actualSize = $s; } // Update filecache entry with actual size and etag so Talk server // can resolve the fileId for rich object embedding. try { $fileCacheTable = $prefix . 'filecache'; $etag = substr(md5_file($filePath) . (string)(int)$this->config->getSystemValueInt('phpfileclient_mapping_localtime', time()), 0, 40); $this->db->executeUpdate( 'UPDATE "' . $fileCacheTable . '" SET "size" = ?, "unencrypted_size" = ?, "etag" = ?, "mtime" = ? WHERE "fileid" = ?', [$actualSize, $actualSize, $etag, (int)$this->config->getSystemValueInt('phpfileclient_mapping_localtime', time()), $fileId], ); $this->logger->info('nc_bot_webhooks: richObject step=filecacheUpdated', ['app' => self::APP_ID, 'fileId' => $fileId, 'size' => $actualSize, 'etag' => $etag]); } catch (\Throwable $e) { $this->logger->warning('nc_bot_webhooks: richObject step=filecacheUpdateFail', ['app' => self::APP_ID, 'error' => $e->getMessage()]); } } } catch (\Throwable $e) { // Final fallback: 0 } // TYPE_ROOM shares don't produce a public /s/{token} URL. // The file_shared system message with the share ID is the // primary mechanism for embedding the image in Talk chat. $fullPublicUrl = ''; $fullDownloadUrl = ''; return [ 'fileId' => $fileId, 'mimeType' => $mimeType, 'publicUrl' => $fullPublicUrl, 'downloadUrl' => $fullDownloadUrl, 'shareToken' => null, 'shareId' => $shareId ?? null, 'filename' => $safeFilename, 'fileCachePath' => $fileCachePath, 'actualSize' => $actualSize, 'node' => $node, ]; } catch (\Exception $e) { $this->logger->error('Failed to build rich object (Exception): ' . $e->getMessage() . ' at ' . $e->getFile() . ':' . $e->getLine(), ['app' => self::APP_ID]); $this->logger->info('nc_bot_webhooks: buildRichObject EXIT exception: ' . $e->getMessage() . ' (' . $e->getFile() . ':' . $e->getLine() . ')', ['app' => self::APP_ID]); return null; } catch (\Error $e) { $this->logger->warning('nc_bot_webhooks: buildRichObject ERROR=' . get_class($e) . ' msg=' . $e->getMessage() . ' file=' . $e->getFile() . ':' . $e->getLine(), ['app' => self::APP_ID]); return null; } $this->logger->info('nc_bot_webhooks: buildRichObject EXIT success', ['app' => self::APP_ID]); } // ── Chat API: post message to Talk room ────────────────────── /** * Post a message to a Talk room. * * When rich objects (uploaded images) are provided, a file_shared * system message is sent alongside the text so Talk's SystemMessage * parser embeds the image inline. * * @param string $roomToken Talk room token * @param string $message Message text * @param string $senderName Discord sender name (embedded in message text) * @param array $richObjects Rich object data (optional) * @return bool Success */ public function postToRoom( string $roomToken, string $message, string $senderName, array $richObjects = [], ): bool { $botPassword = $this->getBotPassword(); if ($botPassword === null) { $this->logger->error('nc_bot_webhooks: bot password not configured', ['app' => self::APP_ID]); return false; } // Check bot is enabled for this room (via AppConfig) if (!$this->isBotEnabledForRoom($roomToken)) { $this->logger->warning('nc_bot_webhooks: bot not enabled for room', [ 'app' => self::APP_ID, 'room_token' => $roomToken, ]); return false; } $baseUrl = $this->getBaseUrl(); if ($baseUrl === '') { $this->logger->error('nc_bot_webhooks: base URL not configured', ['app' => self::APP_ID]); return false; } // Get bot's actual display name from the room participant record. // In Talk 14+, actorDisplayName MUST match the participant's display name // or the message is silently dropped. $botDisplayName = $this->getBotDisplayNameForRoom($roomToken); // Basic auth: base64('talk-bot:' . bot_password) $credentials = base64_encode('talk-bot:' . $botPassword); // Build a file_shared system message with the share ID and metaData // so Talk's SystemMessage parser can resolve the file via the room // share. RecordingService::shareToChat() uses the same pattern. $richObject = $richObjects[0] ?? null; $shareId = (string)($richObject['shareId'] ?? ''); $mimeType = $richObject['mimeType'] ?? 'application/octet-stream'; $messageType = match (true) { str_starts_with($mimeType, 'image/') => 'comment', str_starts_with($mimeType, 'video/') => 'comment', str_starts_with($mimeType, 'audio/') => 'comment', default => 'comment', }; $systemMessage = json_encode([ 'message' => 'file_shared', 'parameters' => [ 'share' => $shareId !== '' ? $shareId : null, 'metaData' => [ 'mimeType' => $mimeType, 'messageType' => $messageType, ], ], ]); // Get the Room object — required for ChatManager::addSystemMessage(). // We use getRoomByToken() which works without authentication. $room = null; try { $room = $this->talkManager->getRoomByToken($roomToken); } catch (\Exception $e) { $this->logger->error('nc_bot_webhooks: failed to get room for token ' . $roomToken, [ 'app' => self::APP_ID, 'error' => $e->getMessage(), ]); return false; } $this->logger->info('nc_bot_webhooks: sending file_shared message to Talk', [ 'app' => self::APP_ID, 'room_token' => $roomToken, 'system_message' => $systemMessage, 'message_text' => $message, 'debug' => [ 'richObject_keys' => array_keys($richObject ?? []), 'richObject_fileId' => $richObject['fileId'] ?? null, 'richObject_shareId' => $richObject['shareId'] ?? null, 'richObject_fileCachePath' => $richObject['fileCachePath'] ?? null, 'richObject_downloadUrl' => $richObject['downloadUrl'] ?? null, 'richObject_publicUrl' => $richObject['publicUrl'] ?? null, 'richObject_shareToken' => $richObject['shareToken'] ?? null, 'richObject_filename' => $richObject['filename'] ?? null, 'richObject_mimeType' => $richObject['mimeType'] ?? null, 'shareId_used' => $shareId, ], ]); // Send the text message (with appended share URLs) via HTTP API so // Talk's @RequireParticipant middleware creates/finds the bot // participant record, and the message gets proper moderation checks. // This provides the human-readable text that the system message // does not include. if (trim($message) !== '') { $textEndpoint = $baseUrl . '/ocs/v2.php/apps/spreed/api/v1/chat/' . $roomToken . '?message=' . urlencode($message) . ($botDisplayName ? '&actorDisplayName=' . urlencode($botDisplayName) : ''); try { $client = $this->clientService->newClient(); $textResponse = $client->post($textEndpoint, [ 'headers' => [ 'OCS-Expect-Formatted' => 'json', 'OCS-APIRequest' => '1', 'Content-Type' => 'application/json', 'Authorization' => 'Basic ' . $credentials, ], 'body' => '{}', 'nextcloud' => [ 'allow_local_address' => true, ], ]); $textStatus = $textResponse->getStatusCode(); if ($textStatus >= 200 && $textStatus < 300) { $this->logger->info('nc_bot_webhooks: text message posted to room ' . $roomToken, [ 'app' => self::APP_ID, ]); } else { $this->logger->warning('nc_bot_webhooks: text message POST returned ' . $textStatus, [ 'app' => self::APP_ID, 'room_token' => $roomToken, ]); } } catch (\Exception $e) { $this->logger->warning('nc_bot_webhooks: text message POST failed: ' . $e->getMessage(), [ 'app' => self::APP_ID, 'room_token' => $roomToken, ]); } } // Overwrite Talk actor so the TYPE_ROOM share created below does not // trigger the "guest" system message bug in Talk 23.0.6 where // fixMimeTypeOfVoiceMessage() fires on every TYPE_ROOM share. $this->setSessionOverwrite('talk-bot'); // Create the room share AFTER text POST so the session overwrite is // active when the ShareCreatedEvent fires. This prevents the guest // message from being posted alongside the bot's message. if ($richObject !== null && $richObject['fileId'] !== null) { $bot = $this->userManager->get('talk-bot'); if (!$bot) { $this->logger->error('nc_bot_webhooks: talk-bot user not found, cannot create share', [ 'app' => self::APP_ID, ]); $this->clearSessionOverwrite(); return false; } try { // Switch user context so createShare() can resolve the file // path from the bot's filesystem (webhook runs as anonymous). $currentUser = $this->userSession->getUser(); $this->userSession->setUser($bot); $share = $this->shareManager->newShare(); // Use the already-resolved node to bypass getFirstNodeById // which can fail on LazyUserFolder or with manually-inserted // filecache entries. Fall back to setNodeId if node is null. if ($richObject['node'] !== null) { $share->setNode($richObject['node']) ->setShareType(IShare::TYPE_ROOM) ->setSharedBy($bot->getUID()) ->setShareOwner($bot->getUID()) ->setSharedWith($roomToken) ->setPermissions(\OCP\Constants::PERMISSION_READ); } else { $share->setNodeId((int)$richObject['fileId']) ->setShareType(IShare::TYPE_ROOM) ->setSharedBy($bot->getUID()) ->setShareOwner($bot->getUID()) ->setSharedWith($roomToken) ->setPermissions(\OCP\Constants::PERMISSION_READ); } $created = $this->shareManager->createShare($share); $actualShareId = (int)$created->getId(); $richObject['shareId'] = $actualShareId; // Update the system message payload with the real shareId $systemMessage = json_encode([ 'message' => 'file_shared', 'parameters' => [ 'share' => (string)$actualShareId, 'metaData' => [ 'mimeType' => $mimeType, 'messageType' => $messageType, ], ], ]); $this->logger->info('nc_bot_webhooks: room share created with session overwrite', [ 'app' => self::APP_ID, 'shareId' => $actualShareId, ]); } catch (\Throwable $e) { $this->logger->error('nc_bot_webhooks: deferred share creation failed: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine(), [ 'app' => self::APP_ID, 'fileId' => (int)$richObject['fileId'], 'roomToken' => $roomToken, 'botUid' => $bot ? $bot->getUID() : 'null', 'trace' => $e->getTraceAsString(), ]); } finally { // Always restore original user context $this->userSession->setUser($currentUser); } // Clear the session overwrite to avoid leaking to other requests $this->clearSessionOverwrite(); } // Use ChatManager::addSystemMessage() directly (PHP API) so the // system message is created with the correct verb ('object_shared') // and the attachment entry is created. The HTTP sendMessage() endpoint // only creates regular 'comment' messages — it cannot create system // messages. // // TODO: Re-enable this call when upgrading to a Talk version where // fixMimeTypeOfVoiceMessage() properly filters non-voice shares. // Currently commented out because: // 1. The session overwrite (set above) makes the event listener's // guest message appear as 'talk-bot' instead of 'guest'. // 2. This gives us the correct actor without needing our own // addSystemMessage() call. // 3. Keeping both would produce a duplicate talk-bot message. // The event listener's file_shared message is transformed by // ChatManager into object_shared verb and resolved by the // SystemMessage parser — images still embed correctly. // // try { // $bot = $this->userManager->get('talk-bot'); // $actorType = Attendee::ACTOR_USERS; // $actorId = 'talk-bot'; // // $this->chatManager->addSystemMessage( // $room, // null, // participant — not needed for webhook bot; mention perms check uses null-safe operator // $actorType, // $actorId, // $systemMessage, // new \DateTime('now', new \DateTimeZone('UTC')), // true, // sendNotifications // ); // $this->logger->info('nc_bot_webhooks: system message posted to room ' . $roomToken, [ // 'app' => self::APP_ID, // ]); // // Verify the system message was created with correct fileId/shareId // $this->logger->info('nc_bot_webhooks: system message verification', [ // 'app' => self::APP_ID, // 'fileId' => $richObject['fileId'] ?? null, // 'shareId' => $shareId, // 'fileCachePath' => $richObject['fileCachePath'] ?? null, // 'botUserExists' => $bot !== null, // 'botUserId' => $bot ? $bot->getUID() : 'null', // ]); // } catch (\Exception $e) { // $this->logger->error('nc_bot_webhooks: addSystemMessage failed: ' . $e->getMessage(), [ // 'app' => self::APP_ID, // 'room_token' => $roomToken, // ]); // return false; // } return true; } /** * 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]); } /** * Get the bot's display name as registered in a Talk room. * Falls back to 'talk-bot' if not found. */ public function getBotDisplayNameForRoom(string $roomToken): string { // Resolve token → room_id via direct DB query (TalkManager::getRoomForToken removed in Talk 14+) $roomTable = $this->detectTalkTableFromCatalog('talk_rooms', 'spreed_room'); if ($roomTable === null) { return 'talk-bot'; } try { $stmt = $this->db->executeQuery( 'SELECT id FROM "' . $roomTable . '" WHERE token = ?', [$roomToken], ); $roomId = (int)$stmt->fetchOne(); $stmt->closeCursor(); if ($roomId === 0) { return 'talk-bot'; } $attendee = $this->attendeeMapper->findByActor($roomId, Attendee::ACTOR_USERS, 'talk-bot'); return $attendee->getDisplayName() ?: 'talk-bot'; } catch (DoesNotExistException $e) { return 'talk-bot'; } catch (\Exception $e) { $this->logger->warning('nc_bot_webhooks: failed to get bot display name for room ' . $roomToken, [ 'app' => self::APP_ID, 'error' => $e->getMessage(), ]); return 'talk-bot'; } } // ── Image cleanup ───────────────────────────────────────────── /** * Purge images older than retention period. */ public function purgeOldImages(): int { $retentionDays = $this->getRetentionDays(); $cutoff = time() - ($retentionDays * 86400); $bot = $this->userManager->get('talk-bot'); if (!$bot) { return 0; } try { $userFolder = $this->rootFolder->getUserFolder($bot->getUID()); // LazyFolder compatibility — use get() with fallback $imagesDir = null; try { $imagesDir = $userFolder->get(self::IMAGES_DIR); } catch (\OCP\Files\NotFoundException $e) { return 0; // No images directory yet } return $this->purgeFolder($imagesDir, $cutoff); } catch (\Exception $e) { $this->logger->error('Image cleanup failed: ' . $e->getMessage(), ['app' => self::APP_ID]); return 0; } } /** * Recursively purge files older than cutoff from a folder. */ private function purgeFolder(Folder $folder, int $cutoff): int { $count = 0; foreach ($folder->getDirectoryListing() as $node) { if ($node->getMTime() < $cutoff) { $node->delete(); $count++; } elseif ($node instanceof Folder) { $count += $this->purgeFolder($node, $cutoff); } } // Clean up empty subdirectories foreach ($folder->getDirectoryListing() as $node) { if ($node instanceof Folder && $node->getFolderInfo() === null) { $node->delete(); } } return $count; } // ── Config save (bulk) ──────────────────────────────────────── /** * Save all config from the settings UI in one call. * * @param array $config { * bot_password?: string, * retention_days?: int, * rooms?: array, * disabled_rooms?: array, * auth_tokens?: array, * sender_name?: string * } */ public function saveConfig(array $config): void { if (isset($config['bot_password']) && $config['bot_password'] !== '') { $validation = $this->validateBotPassword($config['bot_password']); if (!$validation['valid']) { throw new \Exception($validation['error']); } $this->setBotPassword($config['bot_password']); } if (isset($config['retention_days'])) { $this->setRetentionDays((int) $config['retention_days']); } if (isset($config['rooms'])) { $rooms = $config['rooms']; } else { $rooms = $this->getRooms(); } // Remove explicitly disabled rooms from the configured set if (isset($config['disabled_rooms']) && is_array($config['disabled_rooms'])) { foreach ($config['disabled_rooms'] as $token) { unset($rooms[$token]); } } $this->setRooms($rooms); if (isset($config['auth_tokens'])) { $this->setAuthTokens($config['auth_tokens']); } if (isset($config['sender_name'])) { $this->setSenderName($config['sender_name']); } // Ensure talk-bot is a participant in all configured rooms $this->ensureBotParticipants(); } /** * Add talk-bot user as a participant in all configured rooms. * Required so the Chat API accepts requests authenticated as talk-bot. * Uses AttendeeMapper directly to avoid injecting ParticipantService (17 deps). */ private function ensureBotParticipants(): void { $rooms = $this->getRooms(); if (empty($rooms)) { return; } // Get the talk-bot user $botUser = $this->userManager->get('talk-bot'); if ($botUser === null) { $this->logger->warning('nc_bot_webhooks: talk-bot user not found', ['app' => self::APP_ID]); return; } // Get Talk DB table prefix $sysPrefix = $this->config->getSystemValueString('dbtableprefix', ''); $talkPrefix = $this->config->getAppValue('spreed', 'databaseprefix', $sysPrefix); $attendeeTable = $talkPrefix . 'talk_attendee'; // Detect Talk rooms table name $roomTable = $this->detectTalkTableFromCatalog('talk_rooms', 'spreed_room'); if ($roomTable === null) { $this->logger->warning('nc_bot_webhooks: Talk rooms table not found, skipping participant setup', [ 'app' => self::APP_ID, ]); return; } foreach (array_keys($rooms) as $token) { try { // Resolve token → room_id via direct DB query (TalkManager::getRoomForToken removed in Talk 14+) $stmt = $this->db->executeQuery( 'SELECT id FROM "' . $roomTable . '" WHERE token = ?', [$token], ); $roomId = (int)$stmt->fetchOne(); $stmt->closeCursor(); if ($roomId === 0) { $this->logger->warning('nc_bot_webhooks: room token not found in database', [ 'app' => self::APP_ID, 'token' => $token, ]); continue; } // Check if talk-bot is already an attendee try { $this->attendeeMapper->findByActor($roomId, Attendee::ACTOR_USERS, 'talk-bot'); // Already exists — nothing to do continue; } catch (DoesNotExistException $e) { // Not found — will create } // Create attendee record directly $newAttendee = new Attendee(); $newAttendee->setRoomId($roomId); $newAttendee->setActorType(Attendee::ACTOR_USERS); $newAttendee->setActorId('talk-bot'); $newAttendee->setDisplayName('talk-bot'); $newAttendee->setParticipantType(\OCA\Talk\Participant::PERMISSIONS_DEFAULT); $newAttendee->setPermissions(\OCA\Talk\Participant::PERMISSIONS_MAX_DEFAULT); $newAttendee->setNotificationLevel(3); // Full notification level $newAttendee->setFavorite(false); $newAttendee->setArchived(false); try { $this->attendeeMapper->insert($newAttendee); $this->logger->info('nc_bot_webhooks: added talk-bot as participant in room ' . $token, [ 'app' => self::APP_ID, 'room_id' => $roomId, ]); } catch (\Exception $e) { $this->logger->warning('nc_bot_webhooks: failed to insert attendee for room ' . $token, [ 'app' => self::APP_ID, 'error' => $e->getMessage(), ]); } } catch (\Exception $e) { $this->logger->warning('nc_bot_webhooks: failed to add talk-bot to room ' . $token, [ 'app' => self::APP_ID, 'error' => $e->getMessage(), ]); } } } /** * Process a list of image URLs — download, upload, and create rich objects. * Returns array of rich objects. */ private function processImageUrls(array $urls, string $roomToken): array { $richObjects = []; foreach ($urls as $imageUrl) { $this->logger->info('nc_bot_webhooks: attempting image download', [ 'app' => self::APP_ID, 'url' => $imageUrl, ]); $imageData = $this->downloadImage($imageUrl); if ($imageData === null) { $this->logger->warning('nc_bot_webhooks: image download returned null', [ 'app' => self::APP_ID, 'url' => $imageUrl, ]); continue; } $fileName = basename(parse_url($imageUrl, PHP_URL_PATH)) ?: 'attachment'; $this->logger->info('nc_bot_webhooks: uploading image', [ 'app' => self::APP_ID, 'roomToken' => $roomToken, 'fileName' => $fileName, 'mimeType' => $imageData['mimeType'], 'size' => strlen($imageData['data']), ]); $uploadPath = $this->uploadImage($roomToken, $fileName, $imageData['data'], $imageData['mimeType']); if ($uploadPath !== null) { $this->logger->info('nc_bot_webhooks: image uploaded', [ 'app' => self::APP_ID, 'path' => $uploadPath, ]); $this->logger->info('nc_bot_webhooks: building rich object', [ 'app' => self::APP_ID, 'path' => $uploadPath, ]); $richObj = $this->buildRichObject($uploadPath, $imageData['mimeType'], $roomToken); if ($richObj !== null) { $this->logger->info('nc_bot_webhooks: rich object built', [ 'app' => self::APP_ID, 'richObject' => json_encode($richObj), ]); $richObjects[] = $richObj; } } else { $this->logger->warning('nc_bot_webhooks: image upload returned null', [ 'app' => self::APP_ID, 'fileName' => $fileName, ]); } } return $richObjects; } }