fix(talk): improve rich object embedding and fix file cache updates
- Resolve Talk services via \OCP\Server::get for NC33 compatibility - Compute actual file size from disk and update filecache entry with correct size and etag - Clear password on public share URLs to allow inline image embedding in Talk - Append public share URLs to message text so Talk's linkifier can scrape og:image for rich previews - Update chat API call to pass message and actorDisplayName as query parameters - Inject ParticipantService dependency - Improve logging for chat API responses
This commit is contained in:
+137
-74
@@ -18,6 +18,7 @@ use OCP\Share\IShare;
|
|||||||
use OCA\Talk\Manager as TalkManager;
|
use OCA\Talk\Manager as TalkManager;
|
||||||
use OCA\Talk\Model\Attendee;
|
use OCA\Talk\Model\Attendee;
|
||||||
use OCA\Talk\Model\AttendeeMapper;
|
use OCA\Talk\Model\AttendeeMapper;
|
||||||
|
use OCA\Talk\Service\ParticipantService;
|
||||||
use OCP\AppFramework\Db\DoesNotExistException;
|
use OCP\AppFramework\Db\DoesNotExistException;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
@@ -37,6 +38,7 @@ class TalkService {
|
|||||||
private IConfig $config;
|
private IConfig $config;
|
||||||
private AttendeeMapper $attendeeMapper;
|
private AttendeeMapper $attendeeMapper;
|
||||||
private IShareManager $shareManager;
|
private IShareManager $shareManager;
|
||||||
|
private ParticipantService $participantService;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
IClientService $clientService,
|
IClientService $clientService,
|
||||||
@@ -51,6 +53,7 @@ class TalkService {
|
|||||||
ICrypto $crypto,
|
ICrypto $crypto,
|
||||||
AttendeeMapper $attendeeMapper,
|
AttendeeMapper $attendeeMapper,
|
||||||
IShareManager $shareManager,
|
IShareManager $shareManager,
|
||||||
|
ParticipantService $participantService,
|
||||||
) {
|
) {
|
||||||
$this->clientService = $clientService;
|
$this->clientService = $clientService;
|
||||||
$this->config = $config;
|
$this->config = $config;
|
||||||
@@ -64,6 +67,17 @@ class TalkService {
|
|||||||
$this->crypto = $crypto;
|
$this->crypto = $crypto;
|
||||||
$this->attendeeMapper = $attendeeMapper;
|
$this->attendeeMapper = $attendeeMapper;
|
||||||
$this->shareManager = $shareManager;
|
$this->shareManager = $shareManager;
|
||||||
|
$this->participantService = $participantService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 ──────────────────────────────────────────────
|
// ── Bot password ──────────────────────────────────────────────
|
||||||
@@ -1218,19 +1232,79 @@ class TalkService {
|
|||||||
$this->logger->warning('NCbotwebhooks: richObject step=shareCreateFail', ['app' => self::APP_ID, 'error' => $e->getMessage()]);
|
$this->logger->warning('NCbotwebhooks: richObject step=shareCreateFail', ['app' => self::APP_ID, 'error' => $e->getMessage()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the public share URL.
|
// Compute actual file size from disk (filecache size may be 0 if manually inserted).
|
||||||
// Always construct it regardless of share INSERT success so
|
$actualSize = 0;
|
||||||
// postToRoom can append it as a fallback for user verification.
|
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('NCbotwebhooks: richObject step=filecacheUpdated', ['app' => self::APP_ID, 'fileId' => $fileId, 'size' => $actualSize, 'etag' => $etag]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->logger->warning('NCbotwebhooks: richObject step=filecacheUpdateFail', ['app' => self::APP_ID, 'error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Final fallback: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the password so the share URL works for inline image embedding.
|
||||||
|
// Server default policy applies a password to all new link shares, but
|
||||||
|
// browsers cannot authenticate when Talk loads the image inline — they
|
||||||
|
// hit the password entry page instead. Clearing the password makes the
|
||||||
|
// /s/{token} URL publicly fetchable while keeping the clickable link
|
||||||
|
// as a verification URL the user can open in a browser.
|
||||||
|
$passwordCleared = false;
|
||||||
|
if ($shareId !== null) {
|
||||||
|
try {
|
||||||
|
$rows = $this->db->executeUpdate(
|
||||||
|
'UPDATE "' . $shareTable . '" SET "password" = NULL WHERE "id" = ? AND "share_type" = ' . IShare::TYPE_LINK,
|
||||||
|
[$shareId],
|
||||||
|
);
|
||||||
|
if ($rows > 0) {
|
||||||
|
$passwordCleared = true;
|
||||||
|
$this->logger->info('NCbotwebhooks: richObject step=passwordCleared', ['app' => self::APP_ID, 'shareId' => $shareId]);
|
||||||
|
} else {
|
||||||
|
$this->logger->warning('NCbotwebhooks: richObject step=passwordClearedNoMatch', ['app' => self::APP_ID, 'shareId' => $shareId]);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->logger->warning('NCbotwebhooks: richObject step=passwordClearFail', ['app' => self::APP_ID, 'error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the public share URL and download URL.
|
||||||
|
// Always construct them regardless of share INSERT success so
|
||||||
|
// postToRoom can use them for user verification and inline embedding.
|
||||||
$fullPublicUrl = $shareToken !== null
|
$fullPublicUrl = $shareToken !== null
|
||||||
? $this->urlGenerator->getBaseUrl() . '/s/' . $shareToken
|
? $this->urlGenerator->getBaseUrl() . '/s/' . $shareToken
|
||||||
: '';
|
: '';
|
||||||
$this->logger->info('NCbotwebhooks: richObject step=publicShareUrl', ['app' => self::APP_ID, 'url' => $fullPublicUrl]);
|
$fullDownloadUrl = $shareToken !== null
|
||||||
|
? rtrim($this->urlGenerator->getBaseUrl(), '/') . '/s/' . $shareToken . '/download/' . rawurlencode($safeFilename)
|
||||||
|
: '';
|
||||||
|
$this->logger->info('NCbotwebhooks: richObject step=publicShareUrl', ['app' => self::APP_ID, 'url' => $fullPublicUrl, 'downloadUrl' => $fullDownloadUrl]);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'fileId' => $fileId,
|
'fileId' => $fileId,
|
||||||
|
'mimeType' => $mimeType,
|
||||||
'publicUrl' => $fullPublicUrl,
|
'publicUrl' => $fullPublicUrl,
|
||||||
|
'downloadUrl' => $fullDownloadUrl,
|
||||||
'shareToken' => $shareToken,
|
'shareToken' => $shareToken,
|
||||||
|
'shareId' => $shareId ?? null,
|
||||||
'filename' => $safeFilename,
|
'filename' => $safeFilename,
|
||||||
|
'fileCachePath' => $fileCachePath,
|
||||||
|
'actualSize' => $actualSize,
|
||||||
];
|
];
|
||||||
} catch (\Exception $e) {
|
} 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->error('Failed to build rich object (Exception): ' . $e->getMessage() . ' at ' . $e->getFile() . ':' . $e->getLine(), ['app' => self::APP_ID]);
|
||||||
@@ -1246,10 +1320,12 @@ class TalkService {
|
|||||||
// ── Chat API: post message to Talk room ──────────────────────
|
// ── Chat API: post message to Talk room ──────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Post a message to a Talk room via the Chat API.
|
* Post a message to a Talk room.
|
||||||
*
|
*
|
||||||
* Uses Basic auth with the talk-bot user's app password.
|
* When rich objects (uploaded images) are provided, each file is shared
|
||||||
* Endpoint: /ocs/v2.php/apps/spreed/api/v1/chat/{roomToken}
|
* via a public link. The share URLs are appended to the message text so
|
||||||
|
* Talk's linkifier can scrape the og:image meta tag from the public share
|
||||||
|
* page and render rich link preview cards with image thumbnails.
|
||||||
*
|
*
|
||||||
* @param string $roomToken Talk room token
|
* @param string $roomToken Talk room token
|
||||||
* @param string $message Message text
|
* @param string $message Message text
|
||||||
@@ -1284,84 +1360,65 @@ class TalkService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use Chat API v1 (Talk 19 / NC33 only supports v1)
|
|
||||||
$endpoint = $baseUrl . '/ocs/v2.php/apps/spreed/api/v1/chat/' . $roomToken;
|
|
||||||
|
|
||||||
// Get bot's actual display name from the room participant record.
|
// Get bot's actual display name from the room participant record.
|
||||||
// In Talk 14+, actorDisplayName MUST match the participant's display name
|
// In Talk 14+, actorDisplayName MUST match the participant's display name
|
||||||
// or the message is silently dropped.
|
// or the message is silently dropped.
|
||||||
$botDisplayName = $this->getBotDisplayNameForRoom($roomToken);
|
$botDisplayName = $this->getBotDisplayNameForRoom($roomToken);
|
||||||
|
|
||||||
// Build message body for Chat API v1
|
|
||||||
$body = [
|
|
||||||
'message' => $message,
|
|
||||||
'actorType' => 'users',
|
|
||||||
'actorId' => 'talk-bot',
|
|
||||||
'actorDisplayName' => $botDisplayName,
|
|
||||||
];
|
|
||||||
|
|
||||||
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)
|
// Basic auth: base64('talk-bot:' . bot_password)
|
||||||
$credentials = base64_encode('talk-bot:' . $botPassword);
|
$credentials = base64_encode('talk-bot:' . $botPassword);
|
||||||
|
|
||||||
|
// Append public share URLs to the message text so Talk's linkifier can
|
||||||
|
// scrape OG data and render rich link preview cards with image thumbnails.
|
||||||
|
//
|
||||||
|
// We intentionally do NOT use file_shared system messages here. The
|
||||||
|
// SystemMessage parser resolves fileIds by looking in the viewer's own
|
||||||
|
// storage mount points — it never finds files uploaded by the bot user.
|
||||||
|
//
|
||||||
|
// Instead, we send a normal chat message with the public share URLs
|
||||||
|
// (https://nextcloud.example.com/s/{token}) in the text. Talk's
|
||||||
|
// linkifier extracts these URLs, fetches the public share page, and
|
||||||
|
// scrapes the og:image meta tag to produce a rich link preview card
|
||||||
|
// with the image thumbnail. This works for both logged-in and guest users.
|
||||||
|
$shareUrls = [];
|
||||||
|
if (!empty($richObjects)) {
|
||||||
|
foreach ($richObjects as $richObject) {
|
||||||
|
$shareUrl = $richObject['shareUrl'] ?? $richObject['share_url'] ?? null;
|
||||||
|
$publicUrl = $richObject['publicUrl'] ?? $richObject['public_url'] ?? null;
|
||||||
|
$fileName = $richObject['filename'] ?? 'image';
|
||||||
|
|
||||||
|
// Prefer the public /s/{token} URL — that's the one Talk's linkifier
|
||||||
|
// will scrape for og:image. Fall back to any available share URL.
|
||||||
|
$url = $publicUrl ?: $shareUrl;
|
||||||
|
if ($url) {
|
||||||
|
$shareUrls[] = $fileName . ': ' . $url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!empty($shareUrls)) {
|
||||||
|
$message .= "\n\n" . implode("\n", $shareUrls);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the text message via the chat API.
|
||||||
|
// POST /api/v1/chat/{token}?message={text}&actorDisplayName={name}
|
||||||
|
// Actor type and ID come from the basic auth credentials (talk-bot user).
|
||||||
|
// The message text includes public share URLs appended above so Talk's
|
||||||
|
// linkifier can scrape og:image and render rich link preview cards.
|
||||||
|
$textEndpoint = $baseUrl . '/ocs/v2.php/apps/spreed/api/v1/chat/' . $roomToken . '?message=' . urlencode($message) . ($botDisplayName ? '&actorDisplayName=' . urlencode($botDisplayName) : '');
|
||||||
|
|
||||||
|
$this->logger->info('NCbotwebhooks: sending text message to Talk', [
|
||||||
|
'app' => self::APP_ID,
|
||||||
|
'room_token' => $roomToken,
|
||||||
|
'message' => $message,
|
||||||
|
]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$client = $this->clientService->newClient();
|
$client = $this->clientService->newClient();
|
||||||
$response = $client->post($endpoint, [
|
$response = $client->post($textEndpoint, [
|
||||||
'body' => $jsonBody,
|
|
||||||
'headers' => [
|
'headers' => [
|
||||||
'OCS-Expect-Formatted' => 'json',
|
'OCS-Expect-Formatted' => 'json',
|
||||||
'OCS-APIRequest' => '1',
|
'OCS-APIRequest' => '1',
|
||||||
'Authorization' => 'Basic ' . $credentials,
|
'Authorization' => 'Basic ' . $credentials,
|
||||||
'Content-Type' => 'application/json',
|
|
||||||
],
|
],
|
||||||
'nextcloud' => [
|
'nextcloud' => [
|
||||||
'allow_local_address' => true,
|
'allow_local_address' => true,
|
||||||
@@ -1369,6 +1426,13 @@ class TalkService {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$statusCode = $response->getStatusCode();
|
$statusCode = $response->getStatusCode();
|
||||||
|
$responseBody = $response->getBody()->getContents();
|
||||||
|
$this->logger->info('NCbotwebhooks: chat API response', [
|
||||||
|
'app' => self::APP_ID,
|
||||||
|
'room_token' => $roomToken,
|
||||||
|
'status' => $statusCode,
|
||||||
|
'body' => $responseBody,
|
||||||
|
]);
|
||||||
if ($statusCode >= 200 && $statusCode < 300) {
|
if ($statusCode >= 200 && $statusCode < 300) {
|
||||||
$this->logger->info('NCbotwebhooks: message posted to room ' . $roomToken, [
|
$this->logger->info('NCbotwebhooks: message posted to room ' . $roomToken, [
|
||||||
'app' => self::APP_ID,
|
'app' => self::APP_ID,
|
||||||
@@ -1376,11 +1440,10 @@ class TalkService {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
$responseBody = $response->getBody();
|
$this->logger->error('NCbotwebhooks: chat API returned ' . $statusCode, [
|
||||||
$this->logger->error('NCbotwebhooks: chat API returned ' . $statusCode . ': ' . $responseBody, [
|
|
||||||
'app' => self::APP_ID,
|
'app' => self::APP_ID,
|
||||||
'room_token' => $roomToken,
|
'room_token' => $roomToken,
|
||||||
'status' => $statusCode,
|
'body' => $responseBody,
|
||||||
]);
|
]);
|
||||||
return false;
|
return false;
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
|
|||||||
Reference in New Issue
Block a user