KI-AGENT: Live-Sync und Nachrichtenaktionen im Chat ergänzen

This commit is contained in:
2026-05-20 20:27:38 +02:00
parent a671ae392d
commit 1a5c69fcfb
3 changed files with 496 additions and 14 deletions

View File

@@ -17,6 +17,7 @@ type MatrixRoomEvent = {
sender: string sender: string
origin_server_ts: number origin_server_ts: number
type: string type: string
redacts?: string
content?: { content?: {
body?: string body?: string
msgtype?: string msgtype?: string
@@ -60,6 +61,20 @@ type MatrixRoomSearchResponse = {
} }
} }
type MatrixSyncResponse = {
next_batch?: string
rooms?: {
join?: Record<string, {
timeline?: {
events?: MatrixRoomEvent[]
}
state?: {
events?: MatrixRoomEvent[]
}
}>
}
}
type MatrixUserSession = { type MatrixUserSession = {
accessToken: string accessToken: string
matrixUserId: 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<MatrixSyncResponse>(
`/_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 ( const sendTenantRoomMessage = async (
userId: string, userId: string,
tenantId: number | null, tenantId: number | null,
@@ -1472,6 +1614,91 @@ export function matrixService(server: FastifyInstance) {
return { success: true, eventId, key: reactionKey } 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 ( const markTenantRoomRead = async (
userId: string, userId: string,
tenantId: number | null, tenantId: number | null,
@@ -1961,8 +2188,11 @@ export function matrixService(server: FastifyInstance) {
getTenantRoomMessages, getTenantRoomMessages,
getTenantRoomMembers, getTenantRoomMembers,
searchTenantRoomMessages, searchTenantRoomMessages,
syncTenantRoomEvents,
sendTenantRoomMessage, sendTenantRoomMessage,
sendTenantRoomReaction, sendTenantRoomReaction,
editTenantRoomMessage,
redactTenantRoomMessage,
markTenantRoomRead, markTenantRoomRead,
sendTenantRoomAttachment, sendTenantRoomAttachment,
getMediaContent, getMediaContent,

View File

@@ -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) => { server.get("/communication/matrix/rooms/:roomKey/members", async (req, reply) => {
try { try {
return await matrix.getTenantRoomMembers( 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) => { server.post("/communication/matrix/rooms/:roomKey/attachments", async (req, reply) => {
try { try {
const attachment = await uploadedAttachmentFromRequest(req) const attachment = await uploadedAttachmentFromRequest(req)

View File

@@ -20,6 +20,7 @@ const matrixMessagesViewport = ref(null)
const matrixAttachmentInput = ref(null) const matrixAttachmentInput = ref(null)
const matrixAttachmentObjectUrls = ref({}) const matrixAttachmentObjectUrls = ref({})
const matrixReplyTarget = ref(null) const matrixReplyTarget = ref(null)
const matrixEditingMessage = ref(null)
const matrixDragActive = ref(false) const matrixDragActive = ref(false)
const memberInviteOpen = ref(false) const memberInviteOpen = ref(false)
const memberInviteUserId = ref("") const memberInviteUserId = ref("")
@@ -62,13 +63,14 @@ const matrixAttachmentUploading = ref(false)
const matrixAttachmentUploadCount = ref(0) const matrixAttachmentUploadCount = ref(0)
const matrixAutoRefreshActive = ref(false) const matrixAutoRefreshActive = ref(false)
const lastUpdated = ref(null) const lastUpdated = ref(null)
let matrixRefreshInterval = null
let matrixCallDurationInterval = null let matrixCallDurationInterval = null
let matrixMessagesRequestActive = false let matrixMessagesRequestActive = false
let matrixMembersRequestActive = false let matrixMembersRequestActive = false
let matrixLiveKitRoom = null let matrixLiveKitRoom = null
let matrixLiveSyncRunId = 0
const matrixCallVideoElements = new Map() const matrixCallVideoElements = new Map()
const matrixAttachmentPreviewRequests = new Set() const matrixAttachmentPreviewRequests = new Set()
const matrixSyncSince = ref("")
const canUseMatrixChat = computed(() => const canUseMatrixChat = computed(() =>
Boolean(status.value?.reachable && status.value?.provisioningConfigured) Boolean(status.value?.reachable && status.value?.provisioningConfigured)
@@ -248,6 +250,7 @@ const setActiveRoom = async (room) => {
matrixSearchResults.value = [] matrixSearchResults.value = []
memberInviteOpen.value = false memberInviteOpen.value = false
await loadRoomChat({ includeMembers: true }) await loadRoomChat({ includeMembers: true })
restartMatrixLiveSync()
} }
const mergeRoomIntoLists = (room) => { const mergeRoomIntoLists = (room) => {
@@ -286,6 +289,7 @@ const provisionRoomFromList = async (room) => {
}) })
await loadRoomChat({ includeMembers: true }) await loadRoomChat({ includeMembers: true })
restartMatrixLiveSync()
} catch (error) { } catch (error) {
toast.add({ toast.add({
title: "Chatraum konnte nicht erstellt werden", title: "Chatraum konnte nicht erstellt werden",
@@ -311,9 +315,11 @@ const mergeMatrixMessages = (incomingMessages) => {
} }
for (const message of incomingMessages || []) { for (const message of incomingMessages || []) {
const member = matrixMembers.value.find((item) => item.matrixUserId === message.sender)
byId.set(message.id, { byId.set(message.id, {
...byId.get(message.id), ...byId.get(message.id),
...message, ...message,
senderDisplayName: member?.displayName || message.senderDisplayName,
pending: false, pending: false,
failed: false failed: false
}) })
@@ -324,6 +330,64 @@ const mergeMatrixMessages = (incomingMessages) => {
loadAttachmentPreviews() 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) => const findMessage = (messageId) =>
matrixMessages.value.find((message) => message.id === messageId) matrixMessages.value.find((message) => message.id === messageId)
@@ -333,6 +397,7 @@ const replyPreview = (message) => {
} }
const setReplyTarget = (message) => { const setReplyTarget = (message) => {
matrixEditingMessage.value = null
matrixReplyTarget.value = { matrixReplyTarget.value = {
id: message.id, id: message.id,
senderDisplayName: message.own ? "Du" : message.senderDisplayName || message.sender, senderDisplayName: message.own ? "Du" : message.senderDisplayName || message.sender,
@@ -344,6 +409,22 @@ const clearReplyTarget = () => {
matrixReplyTarget.value = null 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 () => { const scrollMessagesToBottom = async () => {
await nextTick() await nextTick()
if (!matrixMessagesViewport.value) return if (!matrixMessagesViewport.value) return
@@ -485,6 +566,7 @@ const createRoom = async () => {
}) })
await loadRoomChat({ includeMembers: true }) await loadRoomChat({ includeMembers: true })
restartMatrixLiveSync()
} catch (error) { } catch (error) {
toast.add({ toast.add({
title: "Chatraum konnte nicht erstellt werden", title: "Chatraum konnte nicht erstellt werden",
@@ -963,6 +1045,29 @@ const sendMatrixMessage = async () => {
const text = matrixMessageDraft.value.trim() const text = matrixMessageDraft.value.trim()
if (!text || !canUseMatrixChat.value || !activeRoom.value?.exists) return 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 optimisticId = `pending-${Date.now()}`
const optimisticMessage = { const optimisticMessage = {
id: optimisticId, 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 = () => { const startMatrixAutoRefresh = () => {
if (matrixRefreshInterval) return if (matrixAutoRefreshActive.value) return
matrixAutoRefreshActive.value = true matrixAutoRefreshActive.value = true
matrixRefreshInterval = window.setInterval(() => { matrixLiveSyncRunId += 1
if (!document.hidden && canUseMatrixChat.value && activeRoom.value?.exists) { runMatrixLiveSync(matrixLiveSyncRunId)
loadRoomChat({ silent: true })
loadUnreadCounts()
}
}, 15000)
} }
const stopMatrixAutoRefresh = () => { const stopMatrixAutoRefresh = () => {
if (!matrixRefreshInterval) return matrixLiveSyncRunId += 1
window.clearInterval(matrixRefreshInterval)
matrixRefreshInterval = null
matrixAutoRefreshActive.value = false matrixAutoRefreshActive.value = false
} }
const restartMatrixLiveSync = () => {
matrixSyncSince.value = ""
stopMatrixAutoRefresh()
startMatrixAutoRefresh()
}
const formatMessageTime = (timestamp) => { const formatMessageTime = (timestamp) => {
if (!timestamp) return "" if (!timestamp) return ""
@@ -1749,11 +1920,47 @@ onBeforeUnmount(() => {
> >
Antworten Antworten
</button> </button>
<button
v-if="message.own && !message.attachment && !message.pending"
type="button"
class="rounded-full px-2 py-0.5 text-xs opacity-70 hover:opacity-100"
:class="message.own ? 'bg-white/10' : 'bg-muted'"
@click="beginEditMessage(message)"
>
Bearbeiten
</button>
<button
v-if="message.own && !message.pending"
type="button"
class="rounded-full px-2 py-0.5 text-xs opacity-70 hover:opacity-100"
:class="message.own ? 'bg-white/10' : 'bg-muted'"
@click="deleteMatrixMessage(message)"
>
Löschen
</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div
v-if="matrixEditingMessage"
class="flex shrink-0 items-center gap-3 border-t border-default bg-default px-3 py-2 text-sm"
>
<UIcon name="i-heroicons-pencil-square" class="size-4 text-muted" />
<div class="min-w-0 flex-1">
<p class="truncate text-xs text-muted">Nachricht bearbeiten</p>
<p class="truncate text-highlighted">{{ matrixEditingMessage.body }}</p>
</div>
<UButton
icon="i-heroicons-x-mark"
color="neutral"
variant="ghost"
size="xs"
@click="cancelEditMessage"
/>
</div>
<div <div
v-if="matrixReplyTarget" v-if="matrixReplyTarget"
class="flex shrink-0 items-center gap-3 border-t border-default bg-default px-3 py-2 text-sm" class="flex shrink-0 items-center gap-3 border-t border-default bg-default px-3 py-2 text-sm"
@@ -1795,7 +2002,7 @@ onBeforeUnmount(() => {
<UInput <UInput
v-model="matrixMessageDraft" v-model="matrixMessageDraft"
class="min-w-0 flex-1" class="min-w-0 flex-1"
:placeholder="matrixAttachmentUploading ? `${matrixAttachmentUploadCount} Anhang/Anhänge werden hochgeladen` : 'Nachricht schreiben'" :placeholder="matrixEditingMessage ? 'Änderung schreiben' : (matrixAttachmentUploading ? `${matrixAttachmentUploadCount} Anhang/Anhänge werden hochgeladen` : 'Nachricht schreiben')"
:disabled="!canSendChatInput" :disabled="!canSendChatInput"
@keydown.enter.exact.prevent="sendMatrixMessage" @keydown.enter.exact.prevent="sendMatrixMessage"
/> />
@@ -1805,7 +2012,7 @@ onBeforeUnmount(() => {
:loading="matrixMessageSending" :loading="matrixMessageSending"
:disabled="!matrixMessageDraft.trim() || !canSendChatInput" :disabled="!matrixMessageDraft.trim() || !canSendChatInput"
> >
Senden {{ matrixEditingMessage ? "Speichern" : "Senden" }}
</UButton> </UButton>
</form> </form>
</main> </main>