From 1a5c69fcfb34d178a006cfa3379701280a6281d8 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Wed, 20 May 2026 20:27:38 +0200 Subject: [PATCH] =?UTF-8?q?KI-AGENT:=20Live-Sync=20und=20Nachrichtenaktion?= =?UTF-8?q?en=20im=20Chat=20erg=C3=A4nzen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/modules/matrix.service.ts | 230 +++++++++++++++++++++++++ backend/src/routes/communication.ts | 45 +++++ frontend/pages/communication/chat.vue | 235 ++++++++++++++++++++++++-- 3 files changed, 496 insertions(+), 14 deletions(-) diff --git a/backend/src/modules/matrix.service.ts b/backend/src/modules/matrix.service.ts index 09e4974..bf4e529 100644 --- a/backend/src/modules/matrix.service.ts +++ b/backend/src/modules/matrix.service.ts @@ -17,6 +17,7 @@ type MatrixRoomEvent = { sender: string origin_server_ts: number type: string + redacts?: string content?: { body?: string msgtype?: string @@ -60,6 +61,20 @@ type MatrixRoomSearchResponse = { } } +type MatrixSyncResponse = { + next_batch?: string + rooms?: { + join?: Record + } +} + type MatrixUserSession = { accessToken: string matrixUserId: string @@ -1370,6 +1385,133 @@ export function matrixService(server: FastifyInstance) { } } + const syncTenantRoomEvents = async ( + userId: string, + tenantId: number | null, + options: MatrixTenantRoomOptions = {}, + since?: string, + initial = false + ) => { + const room = await provisionTenantRoom(userId, tenantId, options) + const session = await ensureCurrentUserJoinedRoom(userId, tenantId, { + roomId: room.roomId, + alias: room.alias, + }) + const filter = { + room: { + rooms: [room.roomId], + timeline: { + limit: 30, + }, + }, + presence: { + types: [], + }, + account_data: { + types: [], + }, + } + const params = new URLSearchParams({ + timeout: since && !initial ? "25000" : "0", + filter: JSON.stringify(filter), + }) + + if (since) params.set("since", since) + + const response = await requestMatrixJson( + `/_matrix/client/v3/sync?${params.toString()}`, + session.accessToken + ) + const joinedRoom = response.rooms?.join?.[room.roomId] + const timelineEvents = joinedRoom?.timeline?.events || [] + const stateEvents = joinedRoom?.state?.events || [] + + if (initial) { + return { + roomId: room.roomId, + alias: room.alias, + key: room.key, + name: room.name, + nextBatch: response.next_batch || since || "", + messages: [], + replacements: [], + reactions: [], + redactions: [], + membersChanged: false, + } + } + + const messages = timelineEvents + .filter((event) => + event.type === "m.room.message" && + ["m.text", "m.file", "m.image"].includes(event.content?.msgtype || "") && + event.content?.["m.relates_to"]?.rel_type !== "m.replace" + ) + .map((event) => ({ + id: event.event_id, + sender: event.sender, + senderDisplayName: event.sender, + body: event.content?.body || "", + attachment: attachmentFromEvent(event), + timestamp: event.origin_server_ts, + own: event.sender === session.matrixUserId, + replyToEventId: event.content?.["m.relates_to"]?.["m.in_reply_to"]?.event_id || null, + reactions: [], + })) + + const replacements = timelineEvents + .filter((event) => + event.type === "m.room.message" && + event.content?.["m.relates_to"]?.rel_type === "m.replace" && + Boolean(event.content?.["m.relates_to"]?.event_id) + ) + .map((event) => ({ + id: event.event_id, + targetEventId: event.content?.["m.relates_to"]?.event_id, + body: event.content?.["m.new_content"]?.body || event.content?.body || "", + timestamp: event.origin_server_ts, + sender: event.sender, + own: event.sender === session.matrixUserId, + })) + + const reactions = timelineEvents + .filter((event) => + event.type === "m.reaction" && + event.content?.["m.relates_to"]?.rel_type === "m.annotation" && + Boolean(event.content?.["m.relates_to"]?.event_id) && + Boolean(event.content?.["m.relates_to"]?.key) + ) + .map((event) => ({ + id: event.event_id, + targetEventId: event.content?.["m.relates_to"]?.event_id, + key: event.content?.["m.relates_to"]?.key, + sender: event.sender, + own: event.sender === session.matrixUserId, + })) + + const redactions = timelineEvents + .filter((event) => event.type === "m.room.redaction" && Boolean(event.redacts)) + .map((event) => ({ + id: event.event_id, + targetEventId: event.redacts, + sender: event.sender, + timestamp: event.origin_server_ts, + })) + + return { + roomId: room.roomId, + alias: room.alias, + key: room.key, + name: room.name, + nextBatch: response.next_batch || since || "", + messages, + replacements, + reactions, + redactions, + membersChanged: [...timelineEvents, ...stateEvents].some((event) => event.type === "m.room.member"), + } + } + const sendTenantRoomMessage = async ( userId: string, tenantId: number | null, @@ -1472,6 +1614,91 @@ export function matrixService(server: FastifyInstance) { return { success: true, eventId, key: reactionKey } } + const editTenantRoomMessage = async ( + userId: string, + tenantId: number | null, + options: MatrixTenantRoomOptions = {}, + eventId: string, + text: string + ) => { + const message = text.trim() + if (!eventId || !message) { + throw Object.assign( + new Error("Nachricht und Zielnachricht sind erforderlich"), + { statusCode: 400 } + ) + } + + const room = await provisionTenantRoom(userId, tenantId, options) + const session = await ensureCurrentUserJoinedRoom(userId, tenantId, { + roomId: room.roomId, + alias: room.alias, + }) + const txnId = `${Date.now()}-${randomBytes(8).toString("hex")}` + const response = await requestMatrixJson<{ event_id: string }>( + `/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/send/m.room.message/${encodeURIComponent(txnId)}`, + session.accessToken, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + msgtype: "m.text", + body: `* ${message}`, + "m.new_content": { + msgtype: "m.text", + body: message, + }, + "m.relates_to": { + rel_type: "m.replace", + event_id: eventId, + }, + }), + } + ) + + return { + id: response.event_id, + targetEventId: eventId, + body: message, + timestamp: Date.now(), + own: true, + } + } + + const redactTenantRoomMessage = async ( + userId: string, + tenantId: number | null, + options: MatrixTenantRoomOptions = {}, + eventId: string + ) => { + if (!eventId) { + throw Object.assign( + new Error("Zielnachricht ist erforderlich"), + { statusCode: 400 } + ) + } + + const room = await provisionTenantRoom(userId, tenantId, options) + const session = await ensureCurrentUserJoinedRoom(userId, tenantId, { + roomId: room.roomId, + alias: room.alias, + }) + const txnId = `${Date.now()}-${randomBytes(8).toString("hex")}` + await requestMatrixJson( + `/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/redact/${encodeURIComponent(eventId)}/${encodeURIComponent(txnId)}`, + session.accessToken, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + reason: "Nachricht in FEDEO gelöscht", + }), + } + ) + + return { success: true, eventId } + } + const markTenantRoomRead = async ( userId: string, tenantId: number | null, @@ -1961,8 +2188,11 @@ export function matrixService(server: FastifyInstance) { getTenantRoomMessages, getTenantRoomMembers, searchTenantRoomMessages, + syncTenantRoomEvents, sendTenantRoomMessage, sendTenantRoomReaction, + editTenantRoomMessage, + redactTenantRoomMessage, markTenantRoomRead, sendTenantRoomAttachment, getMediaContent, diff --git a/backend/src/routes/communication.ts b/backend/src/routes/communication.ts index bea8443..7412e94 100644 --- a/backend/src/routes/communication.ts +++ b/backend/src/routes/communication.ts @@ -736,6 +736,21 @@ export default async function communicationRoutes(server: FastifyInstance) { } }) + server.get("/communication/matrix/rooms/:roomKey/sync", async (req, reply) => { + try { + const query = req.query as { since?: string; initial?: string } + return await matrix.syncTenantRoomEvents( + req.user.user_id, + req.user.tenant_id, + roomOptionsFromRequest(req), + query.since, + query.initial === "1" + ) + } catch (err: any) { + return handleMatrixError(req, reply, err, "Matrix sync failed") + } + }) + server.get("/communication/matrix/rooms/:roomKey/members", async (req, reply) => { try { return await matrix.getTenantRoomMembers( @@ -851,6 +866,36 @@ export default async function communicationRoutes(server: FastifyInstance) { } }) + server.put("/communication/matrix/rooms/:roomKey/messages/:eventId", async (req, reply) => { + try { + const params = req.params as { eventId: string } + const body = req.body as { text?: string } + return await matrix.editTenantRoomMessage( + req.user.user_id, + req.user.tenant_id, + roomOptionsFromRequest(req), + params.eventId, + body.text || "" + ) + } catch (err: any) { + return handleMatrixError(req, reply, err, "Matrix message edit failed") + } + }) + + server.delete("/communication/matrix/rooms/:roomKey/messages/:eventId", async (req, reply) => { + try { + const params = req.params as { eventId: string } + return await matrix.redactTenantRoomMessage( + req.user.user_id, + req.user.tenant_id, + roomOptionsFromRequest(req), + params.eventId + ) + } catch (err: any) { + return handleMatrixError(req, reply, err, "Matrix message delete failed") + } + }) + server.post("/communication/matrix/rooms/:roomKey/attachments", async (req, reply) => { try { const attachment = await uploadedAttachmentFromRequest(req) diff --git a/frontend/pages/communication/chat.vue b/frontend/pages/communication/chat.vue index 5913006..7626864 100644 --- a/frontend/pages/communication/chat.vue +++ b/frontend/pages/communication/chat.vue @@ -20,6 +20,7 @@ const matrixMessagesViewport = ref(null) const matrixAttachmentInput = ref(null) const matrixAttachmentObjectUrls = ref({}) const matrixReplyTarget = ref(null) +const matrixEditingMessage = ref(null) const matrixDragActive = ref(false) const memberInviteOpen = ref(false) const memberInviteUserId = ref("") @@ -62,13 +63,14 @@ const matrixAttachmentUploading = ref(false) const matrixAttachmentUploadCount = ref(0) const matrixAutoRefreshActive = ref(false) const lastUpdated = ref(null) -let matrixRefreshInterval = null let matrixCallDurationInterval = null let matrixMessagesRequestActive = false let matrixMembersRequestActive = false let matrixLiveKitRoom = null +let matrixLiveSyncRunId = 0 const matrixCallVideoElements = new Map() const matrixAttachmentPreviewRequests = new Set() +const matrixSyncSince = ref("") const canUseMatrixChat = computed(() => Boolean(status.value?.reachable && status.value?.provisioningConfigured) @@ -248,6 +250,7 @@ const setActiveRoom = async (room) => { matrixSearchResults.value = [] memberInviteOpen.value = false await loadRoomChat({ includeMembers: true }) + restartMatrixLiveSync() } const mergeRoomIntoLists = (room) => { @@ -286,6 +289,7 @@ const provisionRoomFromList = async (room) => { }) await loadRoomChat({ includeMembers: true }) + restartMatrixLiveSync() } catch (error) { toast.add({ title: "Chatraum konnte nicht erstellt werden", @@ -311,9 +315,11 @@ const mergeMatrixMessages = (incomingMessages) => { } for (const message of incomingMessages || []) { + const member = matrixMembers.value.find((item) => item.matrixUserId === message.sender) byId.set(message.id, { ...byId.get(message.id), ...message, + senderDisplayName: member?.displayName || message.senderDisplayName, pending: false, failed: false }) @@ -324,6 +330,64 @@ const mergeMatrixMessages = (incomingMessages) => { loadAttachmentPreviews() } +const applyMatrixReplacements = (replacements) => { + if (!replacements?.length) return + + const replacementByTarget = new Map() + for (const replacement of replacements) { + if (!replacement.targetEventId) continue + const previous = replacementByTarget.get(replacement.targetEventId) + if (!previous || (replacement.timestamp || 0) >= (previous.timestamp || 0)) { + replacementByTarget.set(replacement.targetEventId, replacement) + } + } + + matrixMessages.value = matrixMessages.value.map((message) => { + const replacement = replacementByTarget.get(message.id) + if (!replacement) return message + + return { + ...message, + body: replacement.body, + edited: true, + timestamp: replacement.timestamp || message.timestamp + } + }) +} + +const applyMatrixReactions = (reactions) => { + if (!reactions?.length) return + + matrixMessages.value = matrixMessages.value.map((message) => { + const messageReactions = reactions.filter((reaction) => reaction.targetEventId === message.id) + if (!messageReactions.length) return message + + let nextReactions = [...(message.reactions || [])] + for (const reaction of messageReactions) { + const current = nextReactions.find((item) => item.key === reaction.key) + nextReactions = current + ? nextReactions.map((item) => item.key === reaction.key ? { + ...item, + count: item.count + 1, + own: item.own || reaction.own + } : item) + : [...nextReactions, { key: reaction.key, count: 1, own: reaction.own }] + } + + return { + ...message, + reactions: nextReactions + } + }) +} + +const applyMatrixRedactions = (redactions) => { + const redactedIds = new Set((redactions || []).map((redaction) => redaction.targetEventId).filter(Boolean)) + if (!redactedIds.size) return + + matrixMessages.value = matrixMessages.value.filter((message) => !redactedIds.has(message.id)) +} + const findMessage = (messageId) => matrixMessages.value.find((message) => message.id === messageId) @@ -333,6 +397,7 @@ const replyPreview = (message) => { } const setReplyTarget = (message) => { + matrixEditingMessage.value = null matrixReplyTarget.value = { id: message.id, senderDisplayName: message.own ? "Du" : message.senderDisplayName || message.sender, @@ -344,6 +409,22 @@ const clearReplyTarget = () => { matrixReplyTarget.value = null } +const beginEditMessage = (message) => { + if (!message?.id || !message.own || message.attachment) return + + matrixEditingMessage.value = { + id: message.id, + body: message.body || "" + } + matrixMessageDraft.value = message.body || "" + clearReplyTarget() +} + +const cancelEditMessage = () => { + matrixEditingMessage.value = null + matrixMessageDraft.value = "" +} + const scrollMessagesToBottom = async () => { await nextTick() if (!matrixMessagesViewport.value) return @@ -485,6 +566,7 @@ const createRoom = async () => { }) await loadRoomChat({ includeMembers: true }) + restartMatrixLiveSync() } catch (error) { toast.add({ title: "Chatraum konnte nicht erstellt werden", @@ -963,6 +1045,29 @@ const sendMatrixMessage = async () => { const text = matrixMessageDraft.value.trim() if (!text || !canUseMatrixChat.value || !activeRoom.value?.exists) return + if (matrixEditingMessage.value?.id) { + const editingId = matrixEditingMessage.value.id + matrixMessageSending.value = true + try { + const replacement = await $api(`${activeRoomEndpoint.value}/messages/${encodeURIComponent(editingId)}`, { + method: "PUT", + body: { text } + }) + + applyMatrixReplacements([replacement]) + matrixMessageDraft.value = "" + matrixEditingMessage.value = null + } catch (error) { + toast.add({ + title: "Nachricht konnte nicht bearbeitet werden", + color: "error" + }) + } finally { + matrixMessageSending.value = false + } + return + } + const optimisticId = `pending-${Date.now()}` const optimisticMessage = { id: optimisticId, @@ -1150,26 +1255,92 @@ const reactToMatrixMessage = async (message, key) => { } } +const deleteMatrixMessage = async (message) => { + if (!message?.id || !message.own || !canUseMatrixChat.value || !activeRoom.value?.exists) return + + try { + await $api(`${activeRoomEndpoint.value}/messages/${encodeURIComponent(message.id)}`, { + method: "DELETE" + }) + matrixMessages.value = matrixMessages.value.filter((item) => item.id !== message.id) + if (matrixEditingMessage.value?.id === message.id) { + cancelEditMessage() + } + } catch (error) { + toast.add({ + title: "Nachricht konnte nicht gelöscht werden", + color: "error" + }) + } +} + +const applyMatrixSync = async (syncResult) => { + if (syncResult.nextBatch) { + matrixSyncSince.value = syncResult.nextBatch + } + + mergeMatrixMessages(syncResult.messages || []) + applyMatrixReplacements(syncResult.replacements) + applyMatrixReactions(syncResult.reactions) + applyMatrixRedactions(syncResult.redactions) + + if ((syncResult.messages || []).length) { + await markActiveRoomRead() + await scrollMessagesToBottom() + } + + if (syncResult.membersChanged) { + await loadRoomMembers({ silent: true }) + } +} + +const runMatrixLiveSync = async (runId) => { + while (matrixLiveSyncRunId === runId) { + if (!canUseMatrixChat.value || !activeRoom.value?.exists || document.hidden) { + await new Promise((resolve) => window.setTimeout(resolve, 3000)) + continue + } + + try { + const params = new URLSearchParams() + if (matrixSyncSince.value) { + params.set("since", matrixSyncSince.value) + } else { + params.set("initial", "1") + } + + const syncResult = await $api(`${activeRoomEndpoint.value}/sync?${params.toString()}`) + if (matrixLiveSyncRunId !== runId) return + + await applyMatrixSync(syncResult) + await loadUnreadCounts() + lastUpdated.value = new Date() + } catch (error) { + if (matrixLiveSyncRunId !== runId) return + await new Promise((resolve) => window.setTimeout(resolve, 5000)) + } + } +} + const startMatrixAutoRefresh = () => { - if (matrixRefreshInterval) return + if (matrixAutoRefreshActive.value) return matrixAutoRefreshActive.value = true - matrixRefreshInterval = window.setInterval(() => { - if (!document.hidden && canUseMatrixChat.value && activeRoom.value?.exists) { - loadRoomChat({ silent: true }) - loadUnreadCounts() - } - }, 15000) + matrixLiveSyncRunId += 1 + runMatrixLiveSync(matrixLiveSyncRunId) } const stopMatrixAutoRefresh = () => { - if (!matrixRefreshInterval) return - - window.clearInterval(matrixRefreshInterval) - matrixRefreshInterval = null + matrixLiveSyncRunId += 1 matrixAutoRefreshActive.value = false } +const restartMatrixLiveSync = () => { + matrixSyncSince.value = "" + stopMatrixAutoRefresh() + startMatrixAutoRefresh() +} + const formatMessageTime = (timestamp) => { if (!timestamp) return "" @@ -1749,11 +1920,47 @@ onBeforeUnmount(() => { > Antworten + + +
+ +
+

Nachricht bearbeiten

+

{{ matrixEditingMessage.body }}

+
+ +
+
{ @@ -1805,7 +2012,7 @@ onBeforeUnmount(() => { :loading="matrixMessageSending" :disabled="!matrixMessageDraft.trim() || !canSendChatInput" > - Senden + {{ matrixEditingMessage ? "Speichern" : "Senden" }}