Files
2026-07-03 17:08:52 -07:00

293 lines
10 KiB
PHP

<?php
declare(strict_types=1);
namespace OCA\NuqloudScrum\Service;
use OCA\NuqloudScrum\Db\ThreadMessageMapper;
use OCA\NuqloudScrum\Db\ThreadTask;
use OCA\NuqloudScrum\Db\ThreadTaskMapper;
use OCA\NuqloudScrum\Db\Workspace;
use OCA\NuqloudScrum\Db\WorkspaceMapper;
use OCA\NuqloudScrum\Db\MirroredFileMapper;
use OCP\AppFramework\Db\DoesNotExistException;
class MessageSyncService {
public function __construct(
private TalkApiClient $talkApiClient,
private WorkspaceMapper $workspaceMapper,
private ThreadTaskMapper $threadTaskMapper,
private ThreadMessageMapper $threadMessageMapper,
private MirroredFileMapper $mirroredFileMapper,
private FolderMirrorService $folderMirrorService,
private ConfigService $configService,
) {
}
/** @return array<string, mixed> */
public function syncMessages(): array {
$workspaces = $this->workspaceMapper->findEnabled();
$processed = 0;
$tasks = 0;
$messages = 0;
$files = 0;
$workspaceResults = [];
foreach ($workspaces as $workspace) {
$result = $this->syncWorkspace($workspace);
$processed++;
$tasks += (int)$result['tasks'];
$messages += (int)$result['messages'];
$files += (int)$result['files'];
$workspaceResults[] = $result;
}
return [
'ok' => true,
'workspaces' => $processed,
'tasks' => $tasks,
'messages' => $messages,
'files' => $files,
'results' => $workspaceResults,
];
}
/** @return array<string, mixed> */
private function syncWorkspace(Workspace $workspace): array {
$lastMessageId = $workspace->getLastMessageId();
$settings = $this->configService->getInternalSettings();
$botCommandsEnabled = !empty($settings['botCommandsEnabled']);
$fileMirrorEnabled = !empty($settings['fileMirrorEnabled']);
$fetched = $this->talkApiClient->listMessages($workspace->getTalkToken(), $lastMessageId);
$maxMessageId = $lastMessageId;
$taskCount = 0;
$messageCount = 0;
$fileCount = 0;
foreach ($fetched as $message) {
$messageId = (int)($message['id'] ?? 0);
if ($messageId <= 0) {
continue;
}
$maxMessageId = max($maxMessageId, $messageId);
$fileMeta = $this->extractFileMeta($message);
$parent = $message['parent'] ?? null;
if (!is_array($parent) || !empty($parent['deleted'])) {
if ($fileMirrorEnabled && $fileMeta !== null) {
$this->indexWorkspaceFile($workspace, $messageId, $fileMeta);
$fileCount++;
}
continue;
}
$rootMessageId = (int)($parent['id'] ?? 0);
if ($rootMessageId <= 0) {
continue;
}
$task = $this->ensureTask($workspace, $rootMessageId, $parent, $message);
$taskCount++;
$isNewMessage = $this->storeMessage($task, $message);
if ($isNewMessage && $botCommandsEnabled) {
$task = $this->applyStatusCommand($workspace, $task, $message);
}
$messageCount++;
if ($fileMirrorEnabled && $fileMeta !== null) {
$this->indexTaskFile($workspace, $task, $messageId, $fileMeta);
$fileCount++;
}
}
$this->workspaceMapper->updateMessageCursor($workspace, $maxMessageId);
return [
'workspaceId' => $workspace->getId(),
'talkToken' => $workspace->getTalkToken(),
'tasks' => $taskCount,
'messages' => $messageCount,
'files' => $fileCount,
'lastMessageId' => $maxMessageId,
];
}
/** @param array<string, mixed> $parent */
/** @param array<string, mixed> $message */
private function ensureTask(Workspace $workspace, int $rootMessageId, array $parent, array $message): ThreadTask {
$title = $this->titleFromMessage($parent);
$slug = $this->folderMirrorService->sanitizeSlug($title);
$folderPath = $this->folderMirrorService->ensureTaskFolder($workspace, $rootMessageId, $slug, $title);
return $this->threadTaskMapper->upsertTask((int)$workspace->getId(), $rootMessageId, [
'title' => $title,
'slug' => $slug,
'folderPath' => $folderPath,
'createdBy' => (string)($parent['actorId'] ?? $message['actorId'] ?? ''),
'lastActivityMessageId' => (int)($message['id'] ?? 0),
]);
}
/** @param array<string, mixed> $message */
private function storeMessage(ThreadTask $task, array $message): bool {
$talkMessageId = (int)($message['id'] ?? 0);
if ($talkMessageId <= 0) {
return false;
}
$isNew = false;
try {
$this->threadMessageMapper->findByTalkMessage((int)$task->getId(), $talkMessageId);
} catch (DoesNotExistException) {
$isNew = true;
}
$text = trim((string)($message['message'] ?? ''));
$rawPayload = json_encode($message, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
$this->threadMessageMapper->upsertMessage((int)$task->getId(), $talkMessageId, [
'actorType' => (string)($message['actorType'] ?? 'users'),
'actorId' => (string)($message['actorId'] ?? ''),
'actorDisplayName' => (string)($message['actorDisplayName'] ?? ''),
'messageText' => $text,
'rawPayload' => $rawPayload,
]);
if (!$isNew) {
return false;
}
$this->folderMirrorService->appendTaskActivity($task, [
'talkMessageId' => $talkMessageId,
'actorType' => (string)($message['actorType'] ?? 'users'),
'actorId' => (string)($message['actorId'] ?? ''),
'actorDisplayName' => (string)($message['actorDisplayName'] ?? ''),
'messageText' => $text,
'createdAt' => (int)($message['timestamp'] ?? time()),
'syncedAt' => gmdate('c'),
]);
return true;
}
/** @param array<string, mixed> $message */
private function applyStatusCommand(Workspace $workspace, ThreadTask $task, array $message): ThreadTask {
$status = $this->statusCommandFromMessage($message);
if ($status === null || $status === $task->getStatus()) {
return $task;
}
$task = $this->threadTaskMapper->setStatus($task, $status);
$this->folderMirrorService->writeTaskMetadata($workspace, $task);
$this->folderMirrorService->appendTaskActivity($task, [
'type' => 'status',
'status' => $status,
'talkMessageId' => (int)($message['id'] ?? 0),
'actorType' => (string)($message['actorType'] ?? 'users'),
'actorId' => (string)($message['actorId'] ?? ''),
'actorDisplayName' => (string)($message['actorDisplayName'] ?? ''),
'syncedAt' => gmdate('c'),
]);
return $task;
}
/** @param array<string, mixed> $message */
private function statusCommandFromMessage(array $message): ?string {
$text = strtolower(trim((string)($message['message'] ?? '')));
$text = preg_replace('/\\s+/', ' ', $text) ?? '';
if (!preg_match('/^(?:\\/|!|#)?(todo|inbox|doing|wip|done|complete|completed|blocked)\\b/', $text, $matches)) {
return null;
}
return match ($matches[1]) {
'doing', 'wip' => 'doing',
'done', 'complete', 'completed' => 'done',
'blocked' => 'blocked',
default => 'inbox',
};
}
/** @param array<string, mixed> $message */
private function titleFromMessage(array $message): string {
$text = trim((string)($message['message'] ?? ''));
$text = preg_replace('/\\s+/', ' ', $text) ?? '';
if ($text === '') {
return 'Talk task ' . (int)($message['id'] ?? 0);
}
$sentenceEnd = strcspn($text, ".!?\n\r");
if ($sentenceEnd > 20) {
$text = substr($text, 0, $sentenceEnd);
}
if (function_exists('mb_substr')) {
return mb_substr($text, 0, 120);
}
return substr($text, 0, 120);
}
/** @param array<string, mixed> $message */
/** @return array<string, mixed>|null */
private function extractFileMeta(array $message): ?array {
$objectType = strtolower(trim((string)($message['objectType'] ?? '')));
$messageType = strtolower(trim((string)($message['messageType'] ?? '')));
$parameters = $message['messageParameters'] ?? [];
$candidate = null;
if (is_array($parameters)) {
foreach ($parameters as $parameter) {
if (!is_array($parameter)) {
continue;
}
$type = strtolower(trim((string)($parameter['type'] ?? $parameter['objectType'] ?? '')));
if ($type === 'file' || $type === 'file-share' || array_key_exists('path', $parameter) || array_key_exists('fileId', $parameter)) {
$candidate = $parameter;
break;
}
}
}
if ($candidate === null && ($objectType === 'file' || $objectType === 'files' || $messageType === 'file')) {
$candidate = is_array($parameters) ? $parameters : [];
}
if (!is_array($candidate)) {
return null;
}
$name = trim((string)($candidate['name'] ?? $candidate['fileName'] ?? $candidate['path'] ?? $message['objectId'] ?? ''));
$path = trim((string)($candidate['path'] ?? $candidate['link'] ?? $candidate['url'] ?? $message['objectId'] ?? ''));
$fileIdRaw = $candidate['fileId'] ?? $candidate['id'] ?? $candidate['fileid'] ?? null;
$fileId = is_numeric($fileIdRaw) ? (int)$fileIdRaw : null;
if ($name === '' && $path === '' && $fileId === null) {
return null;
}
return [
'name' => $name !== '' ? $name : basename($path),
'path' => $path,
'fileId' => $fileId,
'link' => trim((string)($candidate['link'] ?? $candidate['url'] ?? '')),
'mimeType' => trim((string)($candidate['mimetype'] ?? $candidate['mimeType'] ?? '')),
'size' => is_numeric($candidate['size'] ?? null) ? (int)$candidate['size'] : 0,
];
}
/** @param array<string, mixed> $fileMeta */
private function indexWorkspaceFile(Workspace $workspace, int $talkMessageId, array $fileMeta): void {
$mirror = $this->folderMirrorService->mirrorWorkspaceFile($workspace, $talkMessageId, $fileMeta);
$workspacePath = $mirror['path'];
$this->mirroredFileMapper->upsertFile((int)$workspace->getId(), $fileMeta['fileId'] ?? null, $workspacePath, [
'talkMessageId' => $talkMessageId,
'originalPath' => (string)($fileMeta['path'] ?: $fileMeta['name'] ?? $workspacePath),
'size' => (int)($fileMeta['size'] ?? 0),
'mimeType' => (string)($fileMeta['mimeType'] ?? ''),
'mirrorState' => $mirror['state'],
'syncError' => $mirror['error'],
]);
}
/** @param array<string, mixed> $fileMeta */
private function indexTaskFile(Workspace $workspace, ThreadTask $task, int $talkMessageId, array $fileMeta): void {
$mirror = $this->folderMirrorService->mirrorTaskFile($workspace, $task, $talkMessageId, $fileMeta);
$workspacePath = $mirror['path'];
$this->mirroredFileMapper->upsertFile((int)$workspace->getId(), $fileMeta['fileId'] ?? null, $workspacePath, [
'threadId' => $task->getId(),
'talkMessageId' => $talkMessageId,
'originalPath' => (string)($fileMeta['path'] ?: $fileMeta['name'] ?? $workspacePath),
'size' => (int)($fileMeta['size'] ?? 0),
'mimeType' => (string)($fileMeta['mimeType'] ?? ''),
'mirrorState' => $mirror['state'],
'syncError' => $mirror['error'],
]);
}
}