refactor(TalkService): update sharing logic and use ChatManager for system messages
Mostly working. Image actually being embedded now. - Inject `ChatManager` dependency into `TalkService`. - Refactor `uploadImage` to generate timestamped filenames (`talk-bot YYYY-MM-DD hh.mm.ss.microsec.ext`) and fix directory creation logic for Nextcloud 33 compatibility. - Update file sharing logic to use `TYPE_ROOM` shares resolved by `fileId` and create a separate `TYPE_LINK` share for public previews. - Switch message posting to use `ChatManager::addSystemMessage` for `file_shared` events. - Send text message via HTTP API as a fallback to ensure participant records and moderation checks. - Update logging to include debug information for rich objects and share IDs.
This commit is contained in:
+200
-127
@@ -15,6 +15,7 @@ use OCP\IURLGenerator;
|
|||||||
use OCP\IUserManager;
|
use OCP\IUserManager;
|
||||||
use OCP\Share\IManager as IShareManager;
|
use OCP\Share\IManager as IShareManager;
|
||||||
use OCP\Share\IShare;
|
use OCP\Share\IShare;
|
||||||
|
use OCA\Talk\Chat\ChatManager;
|
||||||
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;
|
||||||
@@ -39,6 +40,7 @@ class TalkService {
|
|||||||
private AttendeeMapper $attendeeMapper;
|
private AttendeeMapper $attendeeMapper;
|
||||||
private IShareManager $shareManager;
|
private IShareManager $shareManager;
|
||||||
private ParticipantService $participantService;
|
private ParticipantService $participantService;
|
||||||
|
private ChatManager $chatManager;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
IClientService $clientService,
|
IClientService $clientService,
|
||||||
@@ -54,6 +56,7 @@ class TalkService {
|
|||||||
AttendeeMapper $attendeeMapper,
|
AttendeeMapper $attendeeMapper,
|
||||||
IShareManager $shareManager,
|
IShareManager $shareManager,
|
||||||
ParticipantService $participantService,
|
ParticipantService $participantService,
|
||||||
|
ChatManager $chatManager,
|
||||||
) {
|
) {
|
||||||
$this->clientService = $clientService;
|
$this->clientService = $clientService;
|
||||||
$this->config = $config;
|
$this->config = $config;
|
||||||
@@ -68,6 +71,7 @@ class TalkService {
|
|||||||
$this->attendeeMapper = $attendeeMapper;
|
$this->attendeeMapper = $attendeeMapper;
|
||||||
$this->shareManager = $shareManager;
|
$this->shareManager = $shareManager;
|
||||||
$this->participantService = $participantService;
|
$this->participantService = $participantService;
|
||||||
|
$this->chatManager = $chatManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -882,8 +886,9 @@ class TalkService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upload an image to the bot user's files.
|
* Upload an image to the bot user's files under IMAGES_DIR/<roomToken>/.
|
||||||
* Returns the file path (e.g. nc_bot_webhooks-images/roomToken/filename.png) or null on failure.
|
* 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 {
|
public function uploadImage(string $roomToken, string $filename, string $data, string $mimeType): ?string {
|
||||||
$bot = $this->userManager->get('talk-bot');
|
$bot = $this->userManager->get('talk-bot');
|
||||||
@@ -906,8 +911,7 @@ class TalkService {
|
|||||||
'userFolder_type' => get_class($userFolder),
|
'userFolder_type' => get_class($userFolder),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// LazyFolder in Nextcloud 33 doesn't delegate getFolder() properly
|
// Ensure IMAGES_DIR exists (e.g. nc_bot_webhooks-images/)
|
||||||
// Use get() + manual creation as a compatible fallback
|
|
||||||
$imagesDir = null;
|
$imagesDir = null;
|
||||||
try {
|
try {
|
||||||
$imagesDir = $userFolder->get(self::IMAGES_DIR);
|
$imagesDir = $userFolder->get(self::IMAGES_DIR);
|
||||||
@@ -920,9 +924,7 @@ class TalkService {
|
|||||||
'app' => self::APP_ID,
|
'app' => self::APP_ID,
|
||||||
'path' => self::IMAGES_DIR,
|
'path' => self::IMAGES_DIR,
|
||||||
]);
|
]);
|
||||||
// Create directory manually
|
$userFolder->newFolder(self::IMAGES_DIR);
|
||||||
$parentDir = $userFolder->get('');
|
|
||||||
$parentDir->newFolder(self::IMAGES_DIR);
|
|
||||||
$imagesDir = $userFolder->get(self::IMAGES_DIR);
|
$imagesDir = $userFolder->get(self::IMAGES_DIR);
|
||||||
} catch (\Error $e) {
|
} catch (\Error $e) {
|
||||||
$this->logger->error('NCbotwebhooks: get threw Error on images dir: ' . $e->getMessage(), [
|
$this->logger->error('NCbotwebhooks: get threw Error on images dir: ' . $e->getMessage(), [
|
||||||
@@ -933,6 +935,7 @@ class TalkService {
|
|||||||
throw $e;
|
throw $e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure per-room subdirectory exists
|
||||||
$roomDir = null;
|
$roomDir = null;
|
||||||
try {
|
try {
|
||||||
$roomDir = $imagesDir->get($roomToken);
|
$roomDir = $imagesDir->get($roomToken);
|
||||||
@@ -958,19 +961,22 @@ class TalkService {
|
|||||||
throw $e;
|
throw $e;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Avoid path traversal + use timestamped filename to prevent overwrites.
|
// Build timestamped filename: talk-bot YYYY-MM-DD hh.mm.ss.microsec.ext
|
||||||
$safeFilename = basename($filename);
|
$safeFilename = $this->sanitizeFilename($filename);
|
||||||
$ext = pathinfo($safeFilename, PATHINFO_EXTENSION);
|
$ext = pathinfo($safeFilename, PATHINFO_EXTENSION);
|
||||||
$timestampedFilename = $this->generateTimestampFilename($safeFilename, $ext);
|
$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('NCbotwebhooks: writing file', [
|
$this->logger->info('NCbotwebhooks: writing file', [
|
||||||
'app' => self::APP_ID,
|
'app' => self::APP_ID,
|
||||||
'original' => $safeFilename,
|
'original' => $safeFilename,
|
||||||
'path' => $timestampedFilename,
|
'path' => $relativePath,
|
||||||
]);
|
]);
|
||||||
$roomDir->newFile($timestampedFilename, $data);
|
$roomDir->newFile($relativePath, $data);
|
||||||
$this->logger->info('NCbotwebhooks: file written successfully', [
|
$this->logger->info('NCbotwebhooks: file written successfully', [
|
||||||
'app' => self::APP_ID,
|
'app' => self::APP_ID,
|
||||||
'path' => self::IMAGES_DIR . '/' . $roomToken . '/' . $timestampedFilename,
|
'path' => self::IMAGES_DIR . '/' . $roomToken . '/' . $relativePath,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Verify filecache entry was created by newFile.
|
// Verify filecache entry was created by newFile.
|
||||||
@@ -984,7 +990,7 @@ class TalkService {
|
|||||||
$stmtFc = $pdo2->prepare(
|
$stmtFc = $pdo2->prepare(
|
||||||
'SELECT "fileid","storage","path","path_hash","name","mimetype","size" FROM "' . $fcTable . '" WHERE "path" = ?',
|
'SELECT "fileid","storage","path","path_hash","name","mimetype","size" FROM "' . $fcTable . '" WHERE "path" = ?',
|
||||||
);
|
);
|
||||||
$stmtFc->execute([self::IMAGES_DIR . '/' . $roomToken . '/' . $timestampedFilename]);
|
$stmtFc->execute([self::IMAGES_DIR . '/' . $roomToken . '/' . $relativePath]);
|
||||||
$fcRow = $stmtFc->fetchAll(\PDO::FETCH_ASSOC);
|
$fcRow = $stmtFc->fetchAll(\PDO::FETCH_ASSOC);
|
||||||
$stmtFc->closeCursor();
|
$stmtFc->closeCursor();
|
||||||
$this->logger->info('NCbotwebhooks: uploadImage filecache check', [
|
$this->logger->info('NCbotwebhooks: uploadImage filecache check', [
|
||||||
@@ -996,7 +1002,7 @@ class TalkService {
|
|||||||
$this->logger->warning('NCbotwebhooks: uploadImage filecache check failed: ' . $e->getMessage(), ['app' => self::APP_ID]);
|
$this->logger->warning('NCbotwebhooks: uploadImage filecache check failed: ' . $e->getMessage(), ['app' => self::APP_ID]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return self::IMAGES_DIR . '/' . $roomToken . '/' . $timestampedFilename;
|
return self::IMAGES_DIR . '/' . $roomToken . '/' . $relativePath;
|
||||||
} catch (\Error $e) {
|
} catch (\Error $e) {
|
||||||
$this->logger->error('NCbotwebhooks: uploadImage Error: ' . $e->getMessage() . ' at ' . $e->getFile() . ':' . $e->getLine(), ['app' => self::APP_ID]);
|
$this->logger->error('NCbotwebhooks: uploadImage Error: ' . $e->getMessage() . ' at ' . $e->getFile() . ':' . $e->getLine(), ['app' => self::APP_ID]);
|
||||||
return null;
|
return null;
|
||||||
@@ -1007,20 +1013,13 @@ class TalkService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a unique timestamp-based filename to prevent overwrites.
|
* Sanitize a filename to prevent path traversal.
|
||||||
* 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 {
|
private function sanitizeFilename(string $filename): string {
|
||||||
$timestamp = date('Y-m-d_H-i-s') . '.' . sprintf('%06d', (int)(microtime(true) * 1000) % 1000000);
|
return basename($filename);
|
||||||
$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.
|
* 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.
|
* Creates a public link share so Talk can resolve the rich object.
|
||||||
@@ -1037,10 +1036,8 @@ class TalkService {
|
|||||||
$safeFilename = basename($filePath);
|
$safeFilename = basename($filePath);
|
||||||
$this->logger->info('NCbotwebhooks: richObject step=basename', ['app' => self::APP_ID]);
|
$this->logger->info('NCbotwebhooks: richObject step=basename', ['app' => self::APP_ID]);
|
||||||
|
|
||||||
// filecache 'path' stores relative paths WITHOUT leading slash.
|
// 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).
|
||||||
// Using the wrong format breaks path_hash lookups and inserts
|
$fileCachePath = $filePath;
|
||||||
// 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]);
|
$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.
|
// Use PDO directly to avoid any DBConnection method that could trigger lazy init.
|
||||||
@@ -1202,32 +1199,31 @@ class TalkService {
|
|||||||
$this->logger->warning('NCbotwebhooks: richObject step=nodeResolveFail', ['app' => self::APP_ID, 'error' => $e->getMessage()]);
|
$this->logger->warning('NCbotwebhooks: richObject step=nodeResolveFail', ['app' => self::APP_ID, 'error' => $e->getMessage()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a public link share using the IManager API.
|
// Create a room share so Talk's SystemMessage parser can resolve the
|
||||||
// createShare() generates its own token for TYPE_LINK shares.
|
// file via getFileFromShare(). TYPE_ROOM shares are stored in the
|
||||||
|
// share_external table (as external shares) and resolved by fileId
|
||||||
|
// lookup in the original owner's folder — matching how
|
||||||
|
// RecordingService::shareToChat() works.
|
||||||
$share = $this->shareManager->newShare();
|
$share = $this->shareManager->newShare();
|
||||||
if ($node !== null) {
|
if ($node !== null) {
|
||||||
$share->setNode($node)
|
$share->setNode($node)
|
||||||
->setShareType(IShare::TYPE_LINK)
|
->setShareType(IShare::TYPE_ROOM)
|
||||||
->setSharedBy($bot->getUID())
|
->setSharedBy($bot->getUID())
|
||||||
->setShareOwner($bot->getUID())
|
->setSharedWith($roomToken)
|
||||||
->setPermissions(\OCP\Constants::PERMISSION_READ);
|
->setPermissions(\OCP\Constants::PERMISSION_READ);
|
||||||
} else {
|
} else {
|
||||||
$share->setNodeId($fileId)
|
$share->setNodeId($fileId)
|
||||||
->setShareType(IShare::TYPE_LINK)
|
->setShareType(IShare::TYPE_ROOM)
|
||||||
->setSharedBy($bot->getUID())
|
->setSharedBy($bot->getUID())
|
||||||
->setShareOwner($bot->getUID())
|
->setSharedWith($roomToken)
|
||||||
->setPermissions(\OCP\Constants::PERMISSION_READ);
|
->setPermissions(\OCP\Constants::PERMISSION_READ);
|
||||||
}
|
}
|
||||||
|
|
||||||
$shareToken = null;
|
|
||||||
$shareId = null;
|
$shareId = null;
|
||||||
try {
|
try {
|
||||||
$created = $this->shareManager->createShare($share);
|
$created = $this->shareManager->createShare($share);
|
||||||
$shareId = (int)$created->getId();
|
$shareId = (int)$created->getId();
|
||||||
// createShare() generates its own token for TYPE_LINK shares,
|
$this->logger->info('NCbotwebhooks: richObject step=shareCreated', ['app' => self::APP_ID, 'shareId' => $shareId]);
|
||||||
// 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) {
|
} catch (\Throwable $e) {
|
||||||
$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()]);
|
||||||
}
|
}
|
||||||
@@ -1260,47 +1256,47 @@ class TalkService {
|
|||||||
// Final fallback: 0
|
// Final fallback: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear the password so the share URL works for inline image embedding.
|
// Create a public link share so Talk's linkifier can scrape the
|
||||||
// Server default policy applies a password to all new link shares, but
|
// /s/{token} page for og:image meta tags and render rich preview
|
||||||
// browsers cannot authenticate when Talk loads the image inline — they
|
// cards. This compensates for Talk 23.0.6's lack of a fallback
|
||||||
// hit the password entry page instead. Clearing the password makes the
|
// when the file_shared system message can't resolve the file in
|
||||||
// /s/{token} URL publicly fetchable while keeping the clickable link
|
// the strict path (chat history for regular users).
|
||||||
// as a verification URL the user can open in a browser.
|
$linkShareId = null;
|
||||||
$passwordCleared = false;
|
$linkToken = null;
|
||||||
if ($shareId !== null) {
|
$linkPublicUrl = '';
|
||||||
try {
|
try {
|
||||||
$rows = $this->db->executeUpdate(
|
$linkShare = $this->shareManager->newShare();
|
||||||
'UPDATE "' . $shareTable . '" SET "password" = NULL WHERE "id" = ? AND "share_type" = ' . IShare::TYPE_LINK,
|
if ($node !== null) {
|
||||||
[$shareId],
|
$linkShare->setNode($node)
|
||||||
);
|
->setShareType(IShare::TYPE_LINK)
|
||||||
if ($rows > 0) {
|
->setSharedBy($bot->getUID())
|
||||||
$passwordCleared = true;
|
->setPermissions(\OCP\Constants::PERMISSION_READ);
|
||||||
$this->logger->info('NCbotwebhooks: richObject step=passwordCleared', ['app' => self::APP_ID, 'shareId' => $shareId]);
|
} else {
|
||||||
} else {
|
$linkShare->setNodeId($fileId)
|
||||||
$this->logger->warning('NCbotwebhooks: richObject step=passwordClearedNoMatch', ['app' => self::APP_ID, 'shareId' => $shareId]);
|
->setShareType(IShare::TYPE_LINK)
|
||||||
}
|
->setSharedBy($bot->getUID())
|
||||||
} catch (\Throwable $e) {
|
->setPermissions(\OCP\Constants::PERMISSION_READ);
|
||||||
$this->logger->warning('NCbotwebhooks: richObject step=passwordClearFail', ['app' => self::APP_ID, 'error' => $e->getMessage()]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$createdLink = $this->shareManager->createShare($linkShare);
|
||||||
|
$linkToken = $createdLink->getToken();
|
||||||
|
$linkShareId = (int)$createdLink->getId();
|
||||||
|
$linkPublicUrl = $this->urlGen->linkToRouteAbsolute('files_sharing.sharecontroller.showShare', ['token' => $linkToken]);
|
||||||
|
|
||||||
|
$this->logger->info('NCbotwebhooks: richObject step=linkShareCreated', ['app' => self::APP_ID, 'linkShareId' => $linkShareId, 'token' => $linkToken]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->logger->warning('NCbotwebhooks: richObject step=linkShareFail', ['app' => self::APP_ID, 'error' => $e->getMessage()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the public share URL and download URL.
|
$fullPublicUrl = $linkPublicUrl;
|
||||||
// Always construct them regardless of share INSERT success so
|
$fullDownloadUrl = '';
|
||||||
// postToRoom can use them for user verification and inline embedding.
|
|
||||||
$fullPublicUrl = $shareToken !== null
|
|
||||||
? $this->urlGenerator->getBaseUrl() . '/s/' . $shareToken
|
|
||||||
: '';
|
|
||||||
$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,
|
'mimeType' => $mimeType,
|
||||||
'publicUrl' => $fullPublicUrl,
|
'publicUrl' => $fullPublicUrl,
|
||||||
'downloadUrl' => $fullDownloadUrl,
|
'downloadUrl' => $fullDownloadUrl,
|
||||||
'shareToken' => $shareToken,
|
'shareToken' => $linkToken,
|
||||||
'shareId' => $shareId ?? null,
|
'shareId' => $shareId ?? null,
|
||||||
'filename' => $safeFilename,
|
'filename' => $safeFilename,
|
||||||
'fileCachePath' => $fileCachePath,
|
'fileCachePath' => $fileCachePath,
|
||||||
@@ -1368,18 +1364,10 @@ class TalkService {
|
|||||||
// 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
|
// Append public share URLs to the message text as a fallback so Talk's
|
||||||
// scrape OG data and render rich link preview cards with image thumbnails.
|
// linkifier can scrape OG data and render rich link preview cards with
|
||||||
//
|
// image thumbnails, in case the file_shared system message fails to
|
||||||
// We intentionally do NOT use file_shared system messages here. The
|
// produce an inline image preview.
|
||||||
// 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 = [];
|
$shareUrls = [];
|
||||||
if (!empty($richObjects)) {
|
if (!empty($richObjects)) {
|
||||||
foreach ($richObjects as $richObject) {
|
foreach ($richObjects as $richObject) {
|
||||||
@@ -1399,60 +1387,145 @@ class TalkService {
|
|||||||
$message .= "\n\n" . implode("\n", $shareUrls);
|
$message .= "\n\n" . implode("\n", $shareUrls);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send the text message via the chat API.
|
// Build a file_shared system message with the share ID and metaData
|
||||||
// POST /api/v1/chat/{token}?message={text}&actorDisplayName={name}
|
// so Talk's SystemMessage parser can resolve the file via the room
|
||||||
// Actor type and ID come from the basic auth credentials (talk-bot user).
|
// share. RecordingService::shareToChat() uses the same pattern.
|
||||||
// The message text includes public share URLs appended above so Talk's
|
$richObject = $richObjects[0] ?? null;
|
||||||
// linkifier can scrape og:image and render rich link preview cards.
|
$shareId = (string)($richObject['shareId'] ?? '');
|
||||||
$textEndpoint = $baseUrl . '/ocs/v2.php/apps/spreed/api/v1/chat/' . $roomToken . '?message=' . urlencode($message) . ($botDisplayName ? '&actorDisplayName=' . urlencode($botDisplayName) : '');
|
$mimeType = $richObject['mimeType'] ?? 'application/octet-stream';
|
||||||
|
$messageType = match (true) {
|
||||||
$this->logger->info('NCbotwebhooks: sending text message to Talk', [
|
str_starts_with($mimeType, 'image/') => 'comment',
|
||||||
'app' => self::APP_ID,
|
str_starts_with($mimeType, 'video/') => 'comment',
|
||||||
'room_token' => $roomToken,
|
str_starts_with($mimeType, 'audio/') => 'comment',
|
||||||
'message' => $message,
|
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 {
|
try {
|
||||||
$client = $this->clientService->newClient();
|
$room = $this->talkManager->getRoomByToken($roomToken);
|
||||||
$response = $client->post($textEndpoint, [
|
} catch (\Exception $e) {
|
||||||
'headers' => [
|
$this->logger->error('NCbotwebhooks: failed to get room for token ' . $roomToken, [
|
||||||
'OCS-Expect-Formatted' => 'json',
|
|
||||||
'OCS-APIRequest' => '1',
|
|
||||||
'Authorization' => 'Basic ' . $credentials,
|
|
||||||
],
|
|
||||||
'nextcloud' => [
|
|
||||||
'allow_local_address' => true,
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$statusCode = $response->getStatusCode();
|
|
||||||
$responseBody = $response->getBody()->getContents();
|
|
||||||
$this->logger->info('NCbotwebhooks: chat API response', [
|
|
||||||
'app' => self::APP_ID,
|
'app' => self::APP_ID,
|
||||||
'room_token' => $roomToken,
|
'error' => $e->getMessage(),
|
||||||
'status' => $statusCode,
|
|
||||||
'body' => $responseBody,
|
|
||||||
]);
|
|
||||||
if ($statusCode >= 200 && $statusCode < 300) {
|
|
||||||
$this->logger->info('NCbotwebhooks: message posted to room ' . $roomToken, [
|
|
||||||
'app' => self::APP_ID,
|
|
||||||
]);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->logger->error('NCbotwebhooks: chat API returned ' . $statusCode, [
|
|
||||||
'app' => self::APP_ID,
|
|
||||||
'room_token' => $roomToken,
|
|
||||||
'body' => $responseBody,
|
|
||||||
]);
|
]);
|
||||||
return false;
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logger->info('NCbotwebhooks: 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,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
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('NCbotwebhooks: system message posted to room ' . $roomToken, [
|
||||||
|
'app' => self::APP_ID,
|
||||||
|
]);
|
||||||
|
// Verify the system message was created with correct fileId/shareId
|
||||||
|
$this->logger->info('NCbotwebhooks: 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) {
|
} catch (\Exception $e) {
|
||||||
$this->logger->error('NCbotwebhooks: chat API request failed: ' . $e->getMessage(), [
|
$this->logger->error('NCbotwebhooks: addSystemMessage failed: ' . $e->getMessage(), [
|
||||||
'app' => self::APP_ID,
|
'app' => self::APP_ID,
|
||||||
'room_token' => $roomToken,
|
'room_token' => $roomToken,
|
||||||
]);
|
]);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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('NCbotwebhooks: text message posted to room ' . $roomToken, [
|
||||||
|
'app' => self::APP_ID,
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
$this->logger->warning('NCbotwebhooks: text message POST returned ' . $textStatus, [
|
||||||
|
'app' => self::APP_ID,
|
||||||
|
'room_token' => $roomToken,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->logger->warning('NCbotwebhooks: text message POST failed: ' . $e->getMessage(), [
|
||||||
|
'app' => self::APP_ID,
|
||||||
|
'room_token' => $roomToken,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user