fix(webhooks): improve Nextcloud 33 compatibility and HA REST notify payload handling

- Replace getFolder() with get() and fallback creation to fix LazyFolder compatibility in NC33
- Add support for unwrapping nested 'data' keys in Home Assistant REST notify payloads
- Implement fallback to $_GET when request body is empty
- Refactor buildRichObject to use direct PDO queries for filecache and share creation, bypassing lazy node resolution
- Generate timestamped filenames for uploaded images to prevent overwrites
- Add comprehensive logging for webhook payloads, image processing, and filecache verification
- Update IShareManager imports and usage to align with Nextcloud API changes
- Improve image attachment handling to support URLs, base64, and file paths
- Wrap image processing in try-catch to prevent webhook crashes on image failures
This commit is contained in:
kyle
2026-06-13 14:25:22 -07:00
parent 18084d0f69
commit 9d569453de
3 changed files with 687 additions and 88 deletions
+158 -28
View File
@@ -17,6 +17,7 @@ use OCP\Http\Client\IClientService;
use OCP\IGroupManager;
use OCP\IRequest;
use OCP\IUserSession;
use OCP\Share\IManager as IShareManager;
use Psr\Log\LoggerInterface;
class WebhookController extends Controller {
@@ -28,8 +29,9 @@ class WebhookController extends Controller {
private IConfig $config;
private IAppConfig $appConfig;
private IClientService $clientService;
private IShareManager $shareManager;
public function __construct(IRequest $request, TalkService $talkService, LoggerInterface $logger, IAppManager $appManager, IUserSession $userSession, IGroupManager $groupManager, IConfig $config, IAppConfig $appConfig, IClientService $clientService) {
public function __construct(IRequest $request, TalkService $talkService, LoggerInterface $logger, IAppManager $appManager, IUserSession $userSession, IGroupManager $groupManager, IConfig $config, IAppConfig $appConfig, IClientService $clientService, IShareManager $shareManager) {
parent::__construct('nc_bot_webhooks', $request);
$this->talkService = $talkService;
$this->logger = $logger;
@@ -39,6 +41,7 @@ class WebhookController extends Controller {
$this->config = $config;
$this->appConfig = $appConfig;
$this->clientService = $clientService;
$this->shareManager = $shareManager;
}
/**
@@ -65,10 +68,19 @@ class WebhookController extends Controller {
// Parse Discord JSON payload
$body = file_get_contents('php://input');
$data = @json_decode($body, true);
if (json_last_error() !== JSON_ERROR_NONE || !is_array($data)) {
// Fall back to $_GET — HA's rest_command sends payload as query params
// when the request body is empty.
if (!empty($_GET) && is_array($_GET)) {
$data = $_GET;
}
}
if (json_last_error() !== JSON_ERROR_NONE || !is_array($data)) {
$this->logger->warning('NCbotwebhooks: invalid JSON from webhook', [
'app' => 'nc_bot_webhooks',
'room_token' => $roomToken,
'body_length' => strlen($body),
'get_empty' => empty($_GET) ? true : null,
]);
return new DataResponse(
['error' => 'Invalid JSON'],
@@ -77,6 +89,30 @@ class WebhookController extends Controller {
);
}
// Unwrap nested "data" key — HA's REST notify platform wraps the actual payload under data:
// {"message": "...", "data": {"embeds": [...], "username": "..."}}
if (empty($data['content']) && isset($data['data']) && is_array($data['data'])) {
if (!empty($data['data']['content'])) {
$data['content'] = $data['data']['content'];
} elseif (!empty($data['data']['message'])) {
$data['content'] = $data['data']['message'];
}
if (empty($data['embeds']) && !empty($data['data']['embeds'])) {
$data['embeds'] = $data['data']['embeds'];
}
if (empty($data['username']) && !empty($data['data']['username'])) {
$data['username'] = $data['data']['username'];
}
if (empty($data['sender_name']) && !empty($data['data']['sender_name'])) {
$data['sender_name'] = $data['data']['sender_name'];
}
}
// Fallback: use "message" key as content if no content found (HA REST notify sends `message` not `content`)
if (empty($data['content']) && !empty($data['message'])) {
$data['content'] = $data['message'];
}
// Map to Talk message
$message = $this->talkService->mapPayload($data);
if ($message === '') {
@@ -91,42 +127,52 @@ class WebhookController extends Controller {
// Prepend display name since Talk doesn't support per-message avatars
$message = $this->talkService->prependDisplayName($senderName, $message);
// Handle images
// Handle images — wrap in try-catch so image failures don't crash the message
$richObjects = [];
if (!empty($data['embeds']) && is_array($data['embeds'])) {
foreach ($data['embeds'] as $embed) {
if (!is_array($embed)) {
continue;
}
foreach (['image', 'thumbnail'] as $imageKey) {
if (empty($embed[$imageKey]) || !is_array($embed[$imageKey]) || empty($embed[$imageKey]['url'])) {
$imageError = null;
try {
if (!empty($data['embeds']) && is_array($data['embeds'])) {
foreach ($data['embeds'] as $embed) {
if (!is_array($embed)) {
continue;
}
$imageData = $this->talkService->downloadImage($embed[$imageKey]['url']);
if ($imageData === null) {
continue;
}
foreach (['image', 'thumbnail'] as $imageKey) {
if (empty($embed[$imageKey]) || !is_array($embed[$imageKey]) || empty($embed[$imageKey]['url'])) {
continue;
}
// Derive filename from URL or content type
$parsed = parse_url($embed[$imageKey]['url']);
$pathParts = explode('/', $parsed['path'] ?? '');
$filename = end($pathParts);
if (!$filename || strlen($filename) < 2) {
$ext = pathinfo($imageData['mimeType'], PATHINFO_EXTENSION) ?: 'png';
$filename = 'webhook-image.' . $ext;
}
$imageData = $this->talkService->downloadImage($embed[$imageKey]['url']);
if ($imageData === null) {
continue;
}
$filePath = $this->talkService->uploadImage($roomToken, $filename, $imageData['data'], $imageData['mimeType']);
if ($filePath !== null) {
$richObj = $this->talkService->buildRichObject($filePath, $imageData['mimeType'], $roomToken);
if ($richObj !== null) {
$richObjects[] = $richObj;
// Derive filename from URL or content type
$parsed = parse_url($embed[$imageKey]['url']);
$pathParts = explode('/', $parsed['path'] ?? '');
$filename = end($pathParts);
if (!$filename || strlen($filename) < 2) {
$ext = pathinfo($imageData['mimeType'], PATHINFO_EXTENSION) ?: 'png';
$filename = 'webhook-image.' . $ext;
}
$filePath = $this->talkService->uploadImage($roomToken, $filename, $imageData['data'], $imageData['mimeType']);
if ($filePath !== null) {
$richObj = $this->talkService->buildRichObject($filePath, $imageData['mimeType'], $roomToken);
if ($richObj !== null) {
$richObjects[] = $richObj;
}
}
}
}
}
} catch (\Exception $e) {
$imageError = $e->getMessage();
$this->logger->error('NCbotwebhooks: image processing failed, continuing without images', [
'app' => 'nc_bot_webhooks',
'room_token' => $roomToken,
'error' => $e->getMessage(),
]);
}
// Post to Talk via Chat API
@@ -189,6 +235,16 @@ class WebhookController extends Controller {
// Parse payload — Apprise API sends {"version": 0, "notifications": [...]}
// while direct webhook may send form-encoded or flat JSON
$body = file_get_contents('php://input');
// Nextcloud framework can consume php://input — fall back to /dev/stdin
if ($body === false || $body === '') {
$stdin = fopen('/dev/stdin', 'r');
if ($stdin) {
$body = stream_get_contents($stdin);
fclose($stdin);
}
}
$contentType = $_SERVER['CONTENT_TYPE'] ?? '';
// Try JSON first
@@ -214,6 +270,14 @@ class WebhookController extends Controller {
}
}
if (empty($data) || !is_array($data)) {
// Fall back to $_GET — HA's rest_command sends payload as query params
// when the request body is empty.
if (!empty($_GET) && is_array($_GET)) {
$data = $_GET;
}
}
if (empty($data) || !is_array($data)) {
$this->logger->warning('NCbotwebhooks: invalid payload from apprise webhook', [
'app' => 'nc_bot_webhooks',
@@ -223,6 +287,7 @@ class WebhookController extends Controller {
'body_preview' => substr($body, 0, 1000),
'json_error' => $jsonOk ? null : json_last_error_msg(),
'post_empty' => empty($_POST) ? true : null,
'get_empty' => empty($_GET) ? true : null,
]);
return new DataResponse(
['error' => 'Invalid payload'],
@@ -244,13 +309,78 @@ class WebhookController extends Controller {
}
}
// Unwrap nested "data" key — HA's REST notify platform wraps the actual payload under data:
// {"message": "...", "data": {"body": "...", "subject": "...", "attachments": [...]}}
if (empty($data['body']) && isset($data['data']) && is_array($data['data'])) {
if (!empty($data['data']['body'])) {
$data['body'] = $data['data']['body'];
} elseif (!empty($data['data']['message'])) {
$data['body'] = $data['data']['message'];
}
if (empty($data['subject']) && !empty($data['data']['subject'])) {
$data['subject'] = $data['data']['subject'];
}
if (empty($data['title']) && !empty($data['data']['title'])) {
$data['title'] = $data['data']['title'];
}
if (empty($data['type']) && !empty($data['data']['type'])) {
$data['type'] = $data['data']['type'];
}
if (empty($data['attachments']) && !empty($data['data']['attachments'])) {
$data['attachments'] = $data['data']['attachments'];
}
}
// Fallback: use "message" key as body if no body found (HA REST notify sends `message` not `body`)
if (empty($data['body']) && !empty($data['message'])) {
$data['body'] = $data['message'];
}
// Unwrap sender_name from data key if present
if (empty($data['sender_name']) && isset($data['data']) && is_array($data['data']) && !empty($data['data']['sender_name'])) {
$data['sender_name'] = $data['data']['sender_name'];
}
// Fallback: if no subject/title anywhere, use config default
if (empty($data['subject']) && empty($data['title'])) {
$data['subject'] = $this->talkService->getSenderNameDefault();
}
// Map apprise format to our internal payload format
$mapped = $this->talkService->mapApprisePayload($data, $roomToken);
// Wrap in try-catch: image download/upload can throw exceptions that would
// otherwise crash the entire webhook response
$mapped = null;
$imageError = null;
try {
$mapped = $this->talkService->mapApprisePayload($data, $roomToken);
} catch (\Exception $e) {
$imageError = $e->getMessage();
$this->logger->error('NCbotwebhooks: image processing failed, continuing without images', [
'app' => 'nc_bot_webhooks',
'room_token' => $roomToken,
'error' => $e->getMessage(),
]);
}
if ($mapped === null) {
// Fallback: map without image processing
$mapped = [
'message' => $data['body'] ?? $data['message'] ?? '',
'senderName' => $data['sender_name'] ?? $data['title'] ?? $data['subject'] ?? $this->talkService->getSenderNameDefault(),
'displayName' => $data['sender_name'] ?? $data['title'] ?? $data['subject'] ?? $this->talkService->getSenderNameDefault(),
'richObjects' => [],
];
}
// DEBUG: log mapped values
$this->logger->info('NCbotwebhooks: DEBUG mapped', [
'app' => 'nc_bot_webhooks',
'message' => $mapped['message'] ?? 'EMPTY',
'senderName' => $mapped['senderName'] ?? 'EMPTY',
'displayName' => $mapped['displayName'] ?? 'EMPTY',
'richObjects' => count($mapped['richObjects'] ?? []),
'imageError' => $imageError ?? 'none',
]);
// Allow empty message for image-only notifications (type=image)
if ((empty($mapped['message']) || $mapped['message'] === '') && empty($mapped['richObjects'])) {
+6 -1
View File
@@ -35,9 +35,14 @@ class ImageCleanup implements IJob {
}
// Check if images directory exists
// LazyFolder in Nextcloud 33 doesn't delegate getFolder() properly
try {
$userFolder = $this->rootFolder->getUserFolder($bot->getUID());
$imagesDir = $userFolder->getFolder('nc_bot_webhooks-images');
try {
$userFolder->get('nc_bot_webhooks-images');
} catch (\OCP\Files\NotFoundException $e) {
return;
}
} catch (\Exception $e) {
return;
}
+523 -59
View File
@@ -13,7 +13,8 @@ use OCP\IDBConnection;
use OCP\IRequest;
use OCP\IURLGenerator;
use OCP\IUserManager;
use OCP\Share\IManager;
use OCP\Share\IManager as IShareManager;
use OCP\Share\IShare;
use OCA\Talk\Manager as TalkManager;
use OCA\Talk\Model\Attendee;
use OCA\Talk\Model\AttendeeMapper;
@@ -29,13 +30,13 @@ class TalkService {
private IRequest $request;
private IURLGenerator $urlGenerator;
private IUserManager $userManager;
private IManager $shareManager;
private LoggerInterface $logger;
private IClientService $clientService;
private TalkManager $talkManager;
private ICrypto $crypto;
private IConfig $config;
private AttendeeMapper $attendeeMapper;
private IShareManager $shareManager;
public function __construct(
IClientService $clientService,
@@ -45,11 +46,11 @@ class TalkService {
IRequest $request,
IURLGenerator $urlGenerator,
IUserManager $userManager,
IManager $shareManager,
LoggerInterface $logger,
TalkManager $talkManager,
ICrypto $crypto,
AttendeeMapper $attendeeMapper,
IShareManager $shareManager,
) {
$this->clientService = $clientService;
$this->config = $config;
@@ -58,11 +59,11 @@ class TalkService {
$this->request = $request;
$this->urlGenerator = $urlGenerator;
$this->userManager = $userManager;
$this->shareManager = $shareManager;
$this->logger = $logger;
$this->talkManager = $talkManager;
$this->crypto = $crypto;
$this->attendeeMapper = $attendeeMapper;
$this->shareManager = $shareManager;
}
// ── Bot password ──────────────────────────────────────────────
@@ -586,6 +587,9 @@ class TalkService {
* 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'];
}
@@ -606,12 +610,22 @@ class TalkService {
* Returns: { message, senderName, richObjects }
*/
public function mapApprisePayload(array $data, string $roomToken = ''): array {
$this->logger->info('NCbotwebhooks: 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: use Apprise title/subject (source/app name) as the display name
$displayName = !empty($data['title']) ? $data['title']
// 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'));
: $this->config->getAppValue(self::APP_ID, 'sender_name', 'Webhook Bot')));
// Body
if (!empty($data['body'])) {
@@ -625,16 +639,47 @@ class TalkService {
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) && !empty($a['url'])) {
$imageUrls[] = $a['url'];
} 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'] : '';
@@ -642,21 +687,6 @@ class TalkService {
$message = implode("\n\n", $parts);
}
$senderName = $displayName;
$richObjects = [];
foreach ($imageUrls as $imageUrl) {
$imageData = $this->downloadImage($imageUrl);
if ($imageData === null) {
continue;
}
$fileName = basename(parse_url($imageUrl, PHP_URL_PATH)) ?: '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;
}
}
}
return [
'message' => $message,
'senderName' => $senderName,
@@ -703,6 +733,11 @@ class TalkService {
}
$filePath = $attachment['path'] ?? '';
$this->logger->info('NCbotwebhooks: 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)) {
@@ -729,6 +764,24 @@ class TalkService {
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) {
@@ -791,13 +844,25 @@ class TalkService {
$mimeType = $response->getHeader('Content-Type') ?: 'image/png';
$body = $response->getBody();
$statusCode = $response->getStatusCode();
$this->logger->info('NCbotwebhooks: image download response', [
'app' => self::APP_ID,
'url' => $url,
'status_code' => $statusCode,
'body_length' => strlen($body),
]);
if (strlen($body) === 0) {
$this->logger->warning('NCbotwebhooks: 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->error('Failed to download image: ' . $url, ['app' => self::APP_ID]);
$this->logger->warning('NCbotwebhooks: Failed to download image: ' . $url . ' — ' . $e->getMessage(), ['app' => self::APP_ID]);
return null;
}
}
@@ -809,72 +874,373 @@ class TalkService {
public function uploadImage(string $roomToken, string $filename, string $data, string $mimeType): ?string {
$bot = $this->userManager->get('talk-bot');
if (!$bot) {
$this->logger->error('NCbotwebhooks: uploadImage — bot user not found', ['app' => self::APP_ID]);
return null;
}
try {
$this->logger->info('NCbotwebhooks: uploadImage start', [
'app' => self::APP_ID,
'roomToken' => $roomToken,
'filename' => $filename,
'size' => strlen($data),
'bot_uid' => $bot->getUID(),
]);
$userFolder = $this->rootFolder->getUserFolder($bot->getUID());
$imagesDir = $userFolder->getFolder(self::IMAGES_DIR, true);
$roomDir = $imagesDir->getFolder($roomToken, true);
$this->logger->info('NCbotwebhooks: getUserFolder succeeded', [
'app' => self::APP_ID,
'userFolder_type' => get_class($userFolder),
]);
// Avoid path traversal
// LazyFolder in Nextcloud 33 doesn't delegate getFolder() properly
// Use get() + manual creation as a compatible fallback
$imagesDir = null;
try {
$imagesDir = $userFolder->get(self::IMAGES_DIR);
$this->logger->info('NCbotwebhooks: images dir already exists', [
'app' => self::APP_ID,
'path' => self::IMAGES_DIR,
]);
} catch (\OCP\Files\NotFoundException $e) {
$this->logger->info('NCbotwebhooks: creating images dir', [
'app' => self::APP_ID,
'path' => self::IMAGES_DIR,
]);
// Create directory manually
$parentDir = $userFolder->get('');
$parentDir->newFolder(self::IMAGES_DIR);
$imagesDir = $userFolder->get(self::IMAGES_DIR);
} catch (\Error $e) {
$this->logger->error('NCbotwebhooks: get threw Error on images dir: ' . $e->getMessage(), [
'app' => self::APP_ID,
'path' => self::IMAGES_DIR,
'exception' => get_class($e),
]);
throw $e;
}
$roomDir = null;
try {
$roomDir = $imagesDir->get($roomToken);
$this->logger->info('NCbotwebhooks: room dir already exists', [
'app' => self::APP_ID,
'path' => $roomToken,
]);
} catch (\OCP\Files\NotFoundException $e) {
$this->logger->info('NCbotwebhooks: 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('NCbotwebhooks: get threw Error on room dir: ' . $e->getMessage(), [
'app' => self::APP_ID,
'parent' => self::IMAGES_DIR,
'path' => $roomToken,
'exception' => get_class($e),
]);
throw $e;
}
// Avoid path traversal + use timestamped filename to prevent overwrites.
$safeFilename = basename($filename);
$filePath = $roomDir->newFile($safeFilename, $data);
$ext = pathinfo($safeFilename, PATHINFO_EXTENSION);
$timestampedFilename = $this->generateTimestampFilename($safeFilename, $ext);
$this->logger->info('NCbotwebhooks: writing file', [
'app' => self::APP_ID,
'original' => $safeFilename,
'path' => $timestampedFilename,
]);
$roomDir->newFile($timestampedFilename, $data);
$this->logger->info('NCbotwebhooks: file written successfully', [
'app' => self::APP_ID,
'path' => self::IMAGES_DIR . '/' . $roomToken . '/' . $timestampedFilename,
]);
return self::IMAGES_DIR . '/' . $roomToken . '/' . $safeFilename;
// 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 . '/' . $timestampedFilename]);
$fcRow = $stmtFc->fetchAll(\PDO::FETCH_ASSOC);
$stmtFc->closeCursor();
$this->logger->info('NCbotwebhooks: uploadImage filecache check', [
'app' => self::APP_ID,
'found' => count($fcRow) > 0,
'entries' => $fcRow,
]);
} catch (\Throwable $e) {
$this->logger->warning('NCbotwebhooks: uploadImage filecache check failed: ' . $e->getMessage(), ['app' => self::APP_ID]);
}
return self::IMAGES_DIR . '/' . $roomToken . '/' . $timestampedFilename;
} catch (\Error $e) {
$this->logger->error('NCbotwebhooks: uploadImage Error: ' . $e->getMessage() . ' at ' . $e->getFile() . ':' . $e->getLine(), ['app' => self::APP_ID]);
return null;
} catch (\Exception $e) {
$this->logger->error('Failed to upload image: ' . $e->getMessage(), ['app' => self::APP_ID]);
$this->logger->error('NCbotwebhooks: Failed to upload image: ' . $e->getMessage(), ['app' => self::APP_ID]);
return null;
}
}
/**
* Generate a unique timestamp-based filename to prevent overwrites.
* Format: YYYY-MM-DD hh.mm.ss.microsec.ext (e.g. 2026-06-13 01.35.19.123456.jpg)
*/
private function generateTimestampFilename(string $originalFilename, string $extension): string {
$timestamp = date('Y-m-d_H-i-s') . '.' . sprintf('%06d', (int)(microtime(true) * 1000) % 1000000);
$base = pathinfo($originalFilename, PATHINFO_FILENAME);
// Sanitize base name — only keep safe chars
$base = preg_replace('/[^A-Za-z0-9_\-\x80-\xFF]/', '_', $base);
if ($extension !== '') {
return $base . '_' . $timestamp . '.' . $extension;
}
return $base . '_' . $timestamp;
}
/**
* Build rich object data for a Talk message from an uploaded file path.
* Creates a public link share so Talk can resolve the rich object.
*/
public function buildRichObject(string $filePath, string $mimeType, string $roomToken): ?array {
$this->logger->info('NCbotwebhooks: buildRichObject ENTER', ['app' => self::APP_ID, 'filePath' => $filePath, 'roomToken' => $roomToken]);
$bot = $this->userManager->get('talk-bot');
if (!$bot) {
$this->logger->info('NCbotwebhooks: buildRichObject EXIT noBot', ['app' => self::APP_ID]);
return null;
}
try {
$userFolder = $this->rootFolder->getUserFolder($bot->getUID());
$file = $userFolder->get($filePath);
if (!$file || !$file->isReadable()) {
return null;
$safeFilename = basename($filePath);
$this->logger->info('NCbotwebhooks: richObject step=basename', ['app' => self::APP_ID]);
// filecache 'path' stores relative paths WITHOUT leading slash.
// Using the wrong format breaks path_hash lookups and inserts
// duplicate entries with wrong parent references.
$fileCachePath = self::IMAGES_DIR . '/' . $roomToken . '/' . $safeFilename;
$this->logger->info('NCbotwebhooks: 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('NCbotwebhooks: richObject step=getPdo', ['app' => self::APP_ID]);
$pdo = $this->db->getInner()->getNativeConnection();
$this->logger->info('NCbotwebhooks: 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 . '_';
}
$share = $this->shareManager->newShare();
$share->setNode($file)
->setPermissions(\OCP\Constants::PERMISSION_READ | \OCP\Constants::PERMISSION_DOWNLOAD)
->setType(\OCP\Constants::SHARE_TYPE_LINK)
->setName(basename($filePath));
$share = $this->shareManager->createShare($share);
$fileCacheTable = $prefix . 'filecache';
$shareTable = $prefix . 'share';
$this->logger->info('NCbotwebhooks: richObject step=tables', ['app' => self::APP_ID, 'filecache' => $fileCacheTable, 'share' => $shareTable]);
$linkShare = $this->shareManager->getShareById($share->getId());
// 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('NCbotwebhooks: richObject step=storageId', ['app' => self::APP_ID, 'storageId' => $storageId]);
} catch (\Throwable $e) {
$this->logger->warning('NCbotwebhooks: richObject step=storageIdFail', ['app' => self::APP_ID, 'error' => $e->getMessage()]);
}
$this->logger->info('NCbotwebhooks: 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('NCbotwebhooks: richObject step=executeSelect', ['app' => self::APP_ID, 'pathHash' => $pathHash, 'path' => $fileCachePath]);
$stmt->execute([$pathHash]);
$this->logger->info('NCbotwebhooks: richObject step=fetch', ['app' => self::APP_ID]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
$stmt->closeCursor();
$this->logger->info('NCbotwebhooks: 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('NCbotwebhooks: 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('NCbotwebhooks: richObject step=pathFallbackRows', ['app' => self::APP_ID, 'count' => count($rows)]);
foreach ($rows as $r) {
if ($r['path'] === $fileCachePath) {
$row = $r;
$this->logger->info('NCbotwebhooks: 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('NCbotwebhooks: 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('NCbotwebhooks: richObject step=fcInserted', ['app' => self::APP_ID, 'fileId' => $fileId]);
} catch (\Throwable $e) {
$this->logger->error('NCbotwebhooks: 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('NCbotwebhooks: richObject step=fileFound', ['app' => self::APP_ID, 'fileId' => $fileId]);
$this->logger->info('NCbotwebhooks: 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('NCbotwebhooks: richObject step=nodeResolved', ['app' => self::APP_ID, 'nodeType' => get_class($node)]);
} catch (\Throwable $e) {
$this->logger->warning('NCbotwebhooks: richObject step=nodeResolveFail', ['app' => self::APP_ID, 'error' => $e->getMessage()]);
}
// Create a public link share using the IManager API.
// createShare() generates its own token for TYPE_LINK shares.
$share = $this->shareManager->newShare();
if ($node !== null) {
$share->setNode($node)
->setShareType(IShare::TYPE_LINK)
->setSharedBy($bot->getUID())
->setShareOwner($bot->getUID())
->setPermissions(\OCP\Constants::PERMISSION_READ);
} else {
$share->setNodeId($fileId)
->setShareType(IShare::TYPE_LINK)
->setSharedBy($bot->getUID())
->setShareOwner($bot->getUID())
->setPermissions(\OCP\Constants::PERMISSION_READ);
}
$shareToken = null;
$shareId = null;
try {
$created = $this->shareManager->createShare($share);
$shareId = (int)$created->getId();
// createShare() generates its own token for TYPE_LINK shares,
// overriding the one we set via setToken(). Use the generated token.
$shareToken = $created->getToken();
$this->logger->info('NCbotwebhooks: richObject step=shareCreated', ['app' => self::APP_ID, 'shareId' => $shareId, 'token' => $shareToken]);
} catch (\Throwable $e) {
$this->logger->warning('NCbotwebhooks: richObject step=shareCreateFail', ['app' => self::APP_ID, 'error' => $e->getMessage()]);
}
// Build the public share URL.
// Always construct it regardless of share INSERT success so
// postToRoom can append it as a fallback for user verification.
$fullPublicUrl = $shareToken !== null
? $this->urlGenerator->getBaseUrl() . '/s/' . $shareToken
: '';
$this->logger->info('NCbotwebhooks: richObject step=publicShareUrl', ['app' => self::APP_ID, 'url' => $fullPublicUrl]);
return [
'rich_object' => [
'id' => '',
'elements' => [
[
'type' => 'file',
'id' => $linkShare->getToken(),
'name' => basename($filePath),
'mimetype' => $mimeType,
'thumbnailReady' => true,
'fileTarget' => '/' . $filePath,
'path' => basename($filePath),
],
],
],
'source' => 'file',
'fileId' => $fileId,
'publicUrl' => $fullPublicUrl,
'shareToken' => $shareToken,
'filename' => $safeFilename,
];
} catch (\Exception $e) {
$this->logger->error('Failed to build rich object: ' . $e->getMessage(), ['app' => self::APP_ID]);
$this->logger->error('Failed to build rich object (Exception): ' . $e->getMessage() . ' at ' . $e->getFile() . ':' . $e->getLine(), ['app' => self::APP_ID]);
$this->logger->info('NCbotwebhooks: buildRichObject EXIT exception: ' . $e->getMessage() . ' (' . $e->getFile() . ':' . $e->getLine() . ')', ['app' => self::APP_ID]);
return null;
} catch (\Error $e) {
$this->logger->warning('NCbotwebhooks: buildRichObject ERROR=' . get_class($e) . ' msg=' . $e->getMessage() . ' file=' . $e->getFile() . ':' . $e->getLine(), ['app' => self::APP_ID]);
return null;
}
$this->logger->info('NCbotwebhooks: buildRichObject EXIT success', ['app' => self::APP_ID]);
}
// ── Chat API: post message to Talk room ──────────────────────
@@ -937,16 +1303,52 @@ class TalkService {
if (!empty($richObjects)) {
$indexed = [];
$indexedEnd = [];
$fileId = null;
$publicUrl = null;
foreach ($richObjects as $index => $richObj) {
$key = 'file-' . $index;
$indexed[$key] = $richObj;
$indexedEnd[$key] = true;
// Extract fileId and publicUrl from the new buildRichObject format.
if ($fileId === null && isset($richObj['fileId'])) {
$fileId = (int) $richObj['fileId'];
}
if ($publicUrl === null && isset($richObj['publicUrl'])) {
$publicUrl = $richObj['publicUrl'];
}
}
$body['richObjects'] = $indexed;
$body['richObjectsEnd'] = $indexedEnd;
// Use parameters.fileId (Talk API standard) so images render inline.
if ($fileId !== null) {
$body['parameters'] = [
'fileId' => (string) $fileId,
'metaData' => [
'messageType' => 'file',
],
];
}
// Always append public URL to message text for user verification.
if ($publicUrl !== null) {
$body['message'] = $message . "\n" . $publicUrl;
}
$this->logger->info('NCbotwebhooks: postToRoom richObjects processed', [
'app' => self::APP_ID,
'fileId' => $fileId,
'publicUrl' => $publicUrl,
'parameters_set' => $fileId !== null,
]);
}
$jsonBody = json_encode($body);
$this->logger->info('NCbotwebhooks: chat API request body', [
'app' => self::APP_ID,
'room_token' => $roomToken,
'body' => $jsonBody,
]);
// Basic auth: base64('talk-bot:' . bot_password)
$credentials = base64_encode('talk-bot:' . $botPassword);
@@ -1050,7 +1452,13 @@ class TalkService {
try {
$userFolder = $this->rootFolder->getUserFolder($bot->getUID());
$imagesDir = $userFolder->getFolder(self::IMAGES_DIR);
// 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]);
@@ -1227,4 +1635,60 @@ class TalkService {
}
}
}
/**
* 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('NCbotwebhooks: attempting image download', [
'app' => self::APP_ID,
'url' => $imageUrl,
]);
$imageData = $this->downloadImage($imageUrl);
if ($imageData === null) {
$this->logger->warning('NCbotwebhooks: image download returned null', [
'app' => self::APP_ID,
'url' => $imageUrl,
]);
continue;
}
$fileName = basename(parse_url($imageUrl, PHP_URL_PATH)) ?: 'attachment';
$this->logger->info('NCbotwebhooks: 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('NCbotwebhooks: image uploaded', [
'app' => self::APP_ID,
'path' => $uploadPath,
]);
$this->logger->info('NCbotwebhooks: building rich object', [
'app' => self::APP_ID,
'path' => $uploadPath,
]);
$richObj = $this->buildRichObject($uploadPath, $imageData['mimeType'], $roomToken);
if ($richObj !== null) {
$this->logger->info('NCbotwebhooks: rich object built', [
'app' => self::APP_ID,
'richObject' => json_encode($richObj),
]);
$richObjects[] = $richObj;
}
} else {
$this->logger->warning('NCbotwebhooks: image upload returned null', [
'app' => self::APP_ID,
'fileName' => $fileName,
]);
}
}
return $richObjects;
}
}