Files
nuqloud-ai/lib/Service/AttachmentResolver.php
T
Pascal Kienast 0739d3da6a Initial open-source release of Talk AI
Talk AI is a multi-bot AI assistant manager for Nextcloud Talk:
per-bot prompts and models, agentic tool calling (MCP + built-in
tools), RAG over Nextcloud files, room-document search, vision and
speech-to-text attachments, persistent bot wikis, approval workflows,
rate limiting, and multi-provider LLM support (any OpenAI-compatible
endpoint).

Developed within EDUC - the European Digital UniverCity
(https://educalliance.eu), where it runs as the 'EDUC AI' assistant on
the alliance-wide Nextcloud portal. This public repository is the
upstream point of truth; deployment-specific tools plug in via the
tool-provider extension point (docs/TOOL_PROVIDERS.md).

License: AGPL-3.0-or-later.
2026-07-08 21:13:13 +02:00

282 lines
7.3 KiB
PHP

<?php
declare(strict_types=1);
namespace OCA\EducAI\Service;
use Exception;
use OCA\EducAI\Webhook\IncomingTalkAttachment;
use OCP\Files\File;
use OCP\Files\IRootFolder;
use OCP\Http\Client\IClientService;
use Psr\Log\LoggerInterface;
class AttachmentResolver {
private IRootFolder $rootFolder;
private IClientService $clientService;
private LoggerInterface $logger;
public function __construct(
IRootFolder $rootFolder,
IClientService $clientService,
LoggerInterface $logger
) {
$this->rootFolder = $rootFolder;
$this->clientService = $clientService;
$this->logger = $logger;
}
public function resolveSourceFile(IncomingTalkAttachment $attachment): ?File {
$fileRef = $attachment->getFileRef();
foreach ($this->collectCandidateIds($fileRef) as $candidateId) {
try {
$nodes = $this->rootFolder->getById($candidateId);
foreach ($nodes as $node) {
if ($node instanceof File) {
return $node;
}
}
} catch (\Throwable $e) {
$this->logger->debug('Failed to resolve attachment node candidate', [
'candidate_id' => $candidateId,
'attachment' => $attachment->getDisplayName(),
'exception' => $e,
]);
}
}
$file = $this->resolveFromOwnerFolder($fileRef);
if ($file instanceof File) {
return $file;
}
return null;
}
/**
* @throws Exception
*/
public function resolveToTempFile(IncomingTalkAttachment $attachment): ResolvedAttachment {
$file = $this->resolveSourceFile($attachment);
if ($file instanceof File) {
$mimeType = $attachment->getMimeType() ?? $file->getMimeType();
return $this->writeTempFile($attachment->getDisplayName(), (string)$file->getContent(), $mimeType);
}
$url = $this->resolveRemoteUrl($attachment->getFileRef());
if ($url !== null) {
$client = $this->clientService->newClient();
$response = $client->get($url, [
'timeout' => 60,
]);
$contentType = $attachment->getMimeType();
try {
$header = $response->getHeader('Content-Type');
if (is_string($header) && $header !== '') {
$contentType = $header;
} elseif (is_array($header) && isset($header[0]) && is_string($header[0])) {
$contentType = $header[0];
}
} catch (\Throwable $e) {
// Ignore missing header.
}
return $this->writeTempFile($attachment->getDisplayName(), (string)$response->getBody(), $contentType);
}
throw new Exception('Unable to resolve attachment content for ' . $attachment->getDisplayName());
}
/**
* @param array<string,mixed> $data
* @return array<int,int>
*/
private function collectCandidateIds(array $data): array {
$matches = [];
$whitelist = ['fileid', 'file_id', 'fileId', 'nodeid', 'node_id', 'nodeId', 'id'];
$this->collectRecursiveIds($data, $matches, $whitelist);
return array_values(array_unique($matches));
}
/**
* @param array<string,mixed> $data
* @param array<int,int> $matches
* @param array<int,string> $whitelist
*/
private function collectRecursiveIds(array $data, array &$matches, array $whitelist): void {
foreach ($data as $key => $value) {
$normalizedKey = is_string($key) ? $key : '';
if (in_array($normalizedKey, $whitelist, true) && is_numeric($value)) {
$matches[] = (int)$value;
}
if (is_array($value)) {
$this->collectRecursiveIds($value, $matches, $whitelist);
}
}
}
/**
* @param array<string,mixed> $data
*/
private function resolveRemoteUrl(array $data): ?string {
$candidates = $this->collectRemoteUrls($data);
foreach ($candidates as $candidate) {
if (filter_var($candidate, FILTER_VALIDATE_URL)) {
return $candidate;
}
}
return null;
}
/**
* @param array<string,mixed> $data
* @return array<int,string>
*/
private function collectRemoteUrls(array $data): array {
$matches = [];
foreach ($data as $key => $value) {
if (is_string($key) && in_array($key, ['link', 'url', 'downloadUrl', 'download_url', 'previewUrl', 'preview_url'], true) && is_string($value)) {
$matches[] = $value;
continue;
}
if (is_array($value)) {
$matches = array_merge($matches, $this->collectRemoteUrls($value));
}
}
return $matches;
}
/**
* @param array<string,mixed> $fileRef
*/
private function resolveFromOwnerFolder(array $fileRef): ?File {
$ownerUid = $this->resolveOwnerUid($fileRef);
if ($ownerUid === null) {
return null;
}
try {
$userFolder = $this->rootFolder->getUserFolder($ownerUid);
} catch (\Throwable $e) {
$this->logger->debug('Failed to resolve user folder for attachment owner', [
'owner_uid' => $ownerUid,
'exception' => $e,
]);
return null;
}
foreach ($this->collectCandidateUserPaths($fileRef) as $candidatePath) {
try {
$node = $userFolder->get($candidatePath);
if ($node instanceof File) {
return $node;
}
} catch (\Throwable $e) {
$this->logger->debug('Failed to resolve attachment from owner folder path candidate', [
'owner_uid' => $ownerUid,
'candidate_path' => $candidatePath,
'exception' => $e,
]);
}
}
return null;
}
/**
* @param array<string,mixed> $fileRef
*/
private function resolveOwnerUid(array $fileRef): ?string {
$candidates = [
$fileRef['_educai_owner_uid'] ?? null,
$fileRef['owner_uid'] ?? null,
$fileRef['ownerUid'] ?? null,
];
foreach ($candidates as $candidate) {
if (!is_string($candidate) || trim($candidate) === '') {
continue;
}
$normalized = trim($candidate);
if (str_starts_with($normalized, 'users/')) {
$normalized = substr($normalized, strlen('users/'));
}
if ($normalized !== '') {
return $normalized;
}
}
return null;
}
/**
* @param array<string,mixed> $fileRef
* @return array<int,string>
*/
private function collectCandidateUserPaths(array $fileRef): array {
$paths = [];
$candidates = [
$fileRef['path'] ?? null,
$fileRef['name'] ?? null,
is_array($fileRef['file'] ?? null) ? ($fileRef['file']['path'] ?? null) : null,
is_array($fileRef['file'] ?? null) ? ($fileRef['file']['name'] ?? null) : null,
];
foreach ($candidates as $candidate) {
if (!is_string($candidate) || trim($candidate) === '') {
continue;
}
$this->appendCandidateUserPath($paths, $candidate);
}
return array_values(array_unique($paths));
}
/**
* @param array<int,string> $paths
*/
private function appendCandidateUserPath(array &$paths, string $candidate): void {
$normalized = str_replace('\\', '/', trim($candidate));
$normalized = ltrim($normalized, '/');
if ($normalized === '') {
return;
}
if (preg_match('#^remote\.php/dav/files/[^/]+/(.+)$#', $normalized, $matches) === 1) {
$normalized = $matches[1];
}
if (preg_match('#^files/([^/]+)/(.+)$#', $normalized, $matches) === 1) {
$normalized = $matches[2];
}
if (!str_starts_with($normalized, 'Talk/')) {
$paths[] = 'Talk/' . $normalized;
}
$paths[] = $normalized;
}
/**
* @throws Exception
*/
private function writeTempFile(string $displayName, string $content, ?string $mimeType): ResolvedAttachment {
$tempPath = tempnam(sys_get_temp_dir(), 'educai_att_');
if ($tempPath === false) {
throw new Exception('Unable to allocate temporary file for attachment');
}
if (file_put_contents($tempPath, $content) === false) {
throw new Exception('Unable to write temporary attachment file');
}
return new ResolvedAttachment($tempPath, $displayName, $mimeType);
}
}