From f33ccf730a0864e96803d5163de6aefe46d2dde8 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Mon, 18 May 2026 18:12:26 +0200 Subject: [PATCH] KI-AGENT: Matrix-Raum-API verallgemeinern --- backend/src/modules/matrix.service.ts | 128 ++++++++++++++++++----- backend/src/routes/communication.ts | 136 +++++++++++++++++++------ frontend/pages/communication/chat.vue | 140 ++++++++++++++------------ 3 files changed, 286 insertions(+), 118 deletions(-) diff --git a/backend/src/modules/matrix.service.ts b/backend/src/modules/matrix.service.ts index 213f435..9d28245 100644 --- a/backend/src/modules/matrix.service.ts +++ b/backend/src/modules/matrix.service.ts @@ -35,6 +35,12 @@ type MatrixUserSession = { validUntilMs: number } +type MatrixTenantRoomOptions = { + key?: string + name?: string + topic?: string +} + type MatrixCachedValue = { exists: true cachedUntil: number @@ -48,6 +54,13 @@ const matrixTenantSpaceCache = new Map() const matrixTenantRoomCache = new Map() let matrixServiceSessionCache: MatrixUserSession | null = null +const defaultTenantRooms: Required>[] = [ + { + key: "allgemein", + name: "Allgemeiner Chat", + }, +] + const trimTrailingSlash = (value: string) => value.replace(/\/+$/, "") const readLocalDevRegistrationSharedSecret = () => { if (process.env.NODE_ENV === "production") return "" @@ -173,6 +186,19 @@ export function matrixService(server: FastifyInstance) { roomKey: string ) => `#${tenantRoomAliasLocalpart(tenant, roomKey)}:${serverName()}` + const normalizeTenantRoomOptions = (options: MatrixTenantRoomOptions = {}) => { + const fallbackName = options.key || options.name || "Allgemeiner Chat" + const key = normalizeMatrixAliasSeed(options.key || fallbackName) + const name = (options.name || fallbackName).trim() || "Allgemeiner Chat" + const topic = options.topic?.trim() + + return { + key, + name, + topic, + } + } + const buildSharedSecretMac = ( nonce: string, username: string, @@ -637,16 +663,13 @@ export function matrixService(server: FastifyInstance) { const provisionTenantRoom = async ( userId: string, tenantId: number | null, - options: { - key?: string - name?: string - topic?: string - } = {} + options: MatrixTenantRoomOptions = {} ) => { const tenant = await getCurrentTenant(tenantId) - const key = normalizeMatrixAliasSeed(options.key || options.name || "allgemein") - const name = (options.name || "Allgemeiner Chat").trim() || "Allgemeiner Chat" - const topic = (options.topic || `Allgemeiner Kommunikationsraum für ${tenant.name}`).trim() + const normalizedOptions = normalizeTenantRoomOptions(options) + const key = normalizedOptions.key + const name = normalizedOptions.name + const topic = (normalizedOptions.topic || `Allgemeiner Kommunikationsraum für ${tenant.name}`).trim() const cacheKey = `${tenant.id}:${key}` const cachedRoom = matrixTenantRoomCache.get(cacheKey) @@ -780,11 +803,39 @@ export function matrixService(server: FastifyInstance) { return session } - const getGeneralRoomMessages = async (userId: string, tenantId: number | null) => { - const room = await provisionTenantRoom(userId, tenantId, { - key: "allgemein", - name: "Allgemeiner Chat", - }) + const listTenantRooms = async (tenantId: number | null) => { + const tenant = await getCurrentTenant(tenantId) + const rooms = new Map() + + for (const room of defaultTenantRooms) { + rooms.set(room.key, await getTenantRoomStatus(tenant.id, room.key, room.name)) + } + + for (const [cacheKey, cachedRoom] of matrixTenantRoomCache.entries()) { + const [cachedTenantId, roomKey] = cacheKey.split(":") + if (cachedTenantId !== String(tenant.id) || !cachedRoom.value) continue + + rooms.set(roomKey, { + ...cachedRoom.value, + exists: true, + }) + } + + return { + tenantId: tenant.id, + tenantName: tenant.name, + rooms: Array.from(rooms.values()).sort((a, b) => + String(a.name || a.key).localeCompare(String(b.name || b.key), "de") + ), + } + } + + const getTenantRoomMessages = async ( + userId: string, + tenantId: number | null, + options: MatrixTenantRoomOptions = {} + ) => { + const room = await provisionTenantRoom(userId, tenantId, options) const session = await ensureCurrentUserJoinedRoom(userId, tenantId, { roomId: room.roomId, @@ -807,6 +858,8 @@ export function matrixService(server: FastifyInstance) { return { roomId: room.roomId, alias: room.alias, + key: room.key, + name: room.name, matrixUserId: session.matrixUserId, messages: response.chunk .filter((event) => event.type === "m.room.message" && event.content?.msgtype === "m.text") @@ -822,11 +875,12 @@ export function matrixService(server: FastifyInstance) { } } - const getGeneralRoomMembers = async (userId: string, tenantId: number | null) => { - const room = await provisionTenantRoom(userId, tenantId, { - key: "allgemein", - name: "Allgemeiner Chat", - }) + const getTenantRoomMembers = async ( + userId: string, + tenantId: number | null, + options: MatrixTenantRoomOptions = {} + ) => { + const room = await provisionTenantRoom(userId, tenantId, options) const session = await ensureCurrentUserJoinedRoom(userId, tenantId, { roomId: room.roomId, @@ -840,6 +894,8 @@ export function matrixService(server: FastifyInstance) { return { roomId: room.roomId, alias: room.alias, + key: room.key, + name: room.name, members: Object.entries(members.joined).map(([matrixUserId, member]) => ({ matrixUserId, displayName: member.display_name || matrixUserId, @@ -849,9 +905,10 @@ export function matrixService(server: FastifyInstance) { } } - const sendGeneralRoomMessage = async ( + const sendTenantRoomMessage = async ( userId: string, tenantId: number | null, + options: MatrixTenantRoomOptions = {}, text: string ) => { const message = text.trim() @@ -863,10 +920,7 @@ export function matrixService(server: FastifyInstance) { ) } - const room = await provisionTenantRoom(userId, tenantId, { - key: "allgemein", - name: "Allgemeiner Chat", - }) + const room = await provisionTenantRoom(userId, tenantId, options) const session = await ensureCurrentUserJoinedRoom(userId, tenantId, { roomId: room.roomId, @@ -896,9 +950,33 @@ export function matrixService(server: FastifyInstance) { own: true, roomId: room.roomId, alias: room.alias, + key: room.key, } } + const getGeneralRoomMessages = (userId: string, tenantId: number | null) => + getTenantRoomMessages(userId, tenantId, { + key: "allgemein", + name: "Allgemeiner Chat", + }) + + const getGeneralRoomMembers = (userId: string, tenantId: number | null) => + getTenantRoomMembers(userId, tenantId, { + key: "allgemein", + name: "Allgemeiner Chat", + }) + + const sendGeneralRoomMessage = (userId: string, tenantId: number | null, text: string) => + sendTenantRoomMessage( + userId, + tenantId, + { + key: "allgemein", + name: "Allgemeiner Chat", + }, + text + ) + return { getStatus, matrixUserIdForUser, @@ -906,9 +984,13 @@ export function matrixService(server: FastifyInstance) { provisionCurrentUser, getTenantSpaceStatus, provisionCurrentTenantSpace, + listTenantRooms, getTenantRoomStatus, provisionTenantRoom, createAccessTokenForUser, + getTenantRoomMessages, + getTenantRoomMembers, + sendTenantRoomMessage, getGeneralRoomMessages, getGeneralRoomMembers, sendGeneralRoomMessage, diff --git a/backend/src/routes/communication.ts b/backend/src/routes/communication.ts index df76c1f..322b2b6 100644 --- a/backend/src/routes/communication.ts +++ b/backend/src/routes/communication.ts @@ -3,6 +3,23 @@ import { matrixService } from "../modules/matrix.service" export default async function communicationRoutes(server: FastifyInstance) { const matrix = matrixService(server) + const handleMatrixError = (req: any, reply: any, err: any, fallbackMessage: string) => { + req.log.error(err) + return reply + .code(err.statusCode || 500) + .send({ error: err.message || fallbackMessage }) + } + + const roomOptionsFromRequest = (req: any) => { + const params = req.params as { roomKey?: string } + const body = (req.body || {}) as { key?: string, name?: string, topic?: string } + + return { + key: params.roomKey || body.key, + name: body.name, + topic: body.topic, + } + } server.get("/communication/matrix/status", async () => { return matrix.getStatus() @@ -21,10 +38,7 @@ export default async function communicationRoutes(server: FastifyInstance) { try { return await matrix.provisionCurrentUser(req.user.user_id, req.user.tenant_id) } catch (err: any) { - req.log.error(err) - return reply - .code(err.statusCode || 500) - .send({ error: err.message || "Matrix provisioning failed" }) + return handleMatrixError(req, reply, err, "Matrix provisioning failed") } }) @@ -32,10 +46,7 @@ export default async function communicationRoutes(server: FastifyInstance) { try { return await matrix.getTenantSpaceStatus(req.user.tenant_id) } catch (err: any) { - req.log.error(err) - return reply - .code(err.statusCode || 500) - .send({ error: err.message || "Matrix tenant space status failed" }) + return handleMatrixError(req, reply, err, "Matrix tenant space status failed") } }) @@ -43,10 +54,27 @@ export default async function communicationRoutes(server: FastifyInstance) { try { return await matrix.provisionCurrentTenantSpace(req.user.user_id, req.user.tenant_id) } catch (err: any) { - req.log.error(err) - return reply - .code(err.statusCode || 500) - .send({ error: err.message || "Matrix tenant space provisioning failed" }) + return handleMatrixError(req, reply, err, "Matrix tenant space provisioning failed") + } + }) + + server.get("/communication/matrix/rooms", async (req, reply) => { + try { + return await matrix.listTenantRooms(req.user.tenant_id) + } catch (err: any) { + return handleMatrixError(req, reply, err, "Matrix rooms failed") + } + }) + + server.post("/communication/matrix/rooms", async (req, reply) => { + try { + return await matrix.provisionTenantRoom( + req.user.user_id, + req.user.tenant_id, + roomOptionsFromRequest(req) + ) + } catch (err: any) { + return handleMatrixError(req, reply, err, "Matrix room provisioning failed") } }) @@ -54,10 +82,7 @@ export default async function communicationRoutes(server: FastifyInstance) { try { return await matrix.getTenantRoomStatus(req.user.tenant_id, "allgemein", "Allgemeiner Chat") } catch (err: any) { - req.log.error(err) - return reply - .code(err.statusCode || 500) - .send({ error: err.message || "Matrix room status failed" }) + return handleMatrixError(req, reply, err, "Matrix room status failed") } }) @@ -68,10 +93,7 @@ export default async function communicationRoutes(server: FastifyInstance) { name: "Allgemeiner Chat", }) } catch (err: any) { - req.log.error(err) - return reply - .code(err.statusCode || 500) - .send({ error: err.message || "Matrix room provisioning failed" }) + return handleMatrixError(req, reply, err, "Matrix room provisioning failed") } }) @@ -79,10 +101,7 @@ export default async function communicationRoutes(server: FastifyInstance) { try { return await matrix.getGeneralRoomMessages(req.user.user_id, req.user.tenant_id) } catch (err: any) { - req.log.error(err) - return reply - .code(err.statusCode || 500) - .send({ error: err.message || "Matrix messages failed" }) + return handleMatrixError(req, reply, err, "Matrix messages failed") } }) @@ -90,10 +109,7 @@ export default async function communicationRoutes(server: FastifyInstance) { try { return await matrix.getGeneralRoomMembers(req.user.user_id, req.user.tenant_id) } catch (err: any) { - req.log.error(err) - return reply - .code(err.statusCode || 500) - .send({ error: err.message || "Matrix members failed" }) + return handleMatrixError(req, reply, err, "Matrix members failed") } }) @@ -102,10 +118,66 @@ export default async function communicationRoutes(server: FastifyInstance) { const body = req.body as { text?: string } return await matrix.sendGeneralRoomMessage(req.user.user_id, req.user.tenant_id, body.text || "") } catch (err: any) { - req.log.error(err) - return reply - .code(err.statusCode || 500) - .send({ error: err.message || "Matrix message send failed" }) + return handleMatrixError(req, reply, err, "Matrix message send failed") + } + }) + + server.get("/communication/matrix/rooms/:roomKey", async (req, reply) => { + try { + const params = req.params as { roomKey: string } + return await matrix.getTenantRoomStatus(req.user.tenant_id, params.roomKey, params.roomKey) + } catch (err: any) { + return handleMatrixError(req, reply, err, "Matrix room status failed") + } + }) + + server.post("/communication/matrix/rooms/:roomKey/provision", async (req, reply) => { + try { + return await matrix.provisionTenantRoom( + req.user.user_id, + req.user.tenant_id, + roomOptionsFromRequest(req) + ) + } catch (err: any) { + return handleMatrixError(req, reply, err, "Matrix room provisioning failed") + } + }) + + server.get("/communication/matrix/rooms/:roomKey/messages", async (req, reply) => { + try { + return await matrix.getTenantRoomMessages( + req.user.user_id, + req.user.tenant_id, + roomOptionsFromRequest(req) + ) + } catch (err: any) { + return handleMatrixError(req, reply, err, "Matrix messages failed") + } + }) + + server.get("/communication/matrix/rooms/:roomKey/members", async (req, reply) => { + try { + return await matrix.getTenantRoomMembers( + req.user.user_id, + req.user.tenant_id, + roomOptionsFromRequest(req) + ) + } catch (err: any) { + return handleMatrixError(req, reply, err, "Matrix members failed") + } + }) + + server.post("/communication/matrix/rooms/:roomKey/messages", async (req, reply) => { + try { + const body = req.body as { text?: string } + return await matrix.sendTenantRoomMessage( + req.user.user_id, + req.user.tenant_id, + roomOptionsFromRequest(req), + body.text || "" + ) + } catch (err: any) { + return handleMatrixError(req, reply, err, "Matrix message send failed") } }) } diff --git a/frontend/pages/communication/chat.vue b/frontend/pages/communication/chat.vue index abfb14e..a7d6960 100644 --- a/frontend/pages/communication/chat.vue +++ b/frontend/pages/communication/chat.vue @@ -4,7 +4,8 @@ const { $api } = useNuxtApp() const status = ref(null) const identity = ref(null) -const generalRoom = ref(null) +const matrixRooms = ref([]) +const activeRoomKey = ref("allgemein") const matrixMessages = ref([]) const matrixMembers = ref([]) const matrixMessageDraft = ref("") @@ -24,17 +25,26 @@ const canUseMatrixChat = computed(() => Boolean(status.value?.reachable && status.value?.provisioningConfigured) ) -const activeRoom = computed(() => ({ - key: "general", - name: generalRoom.value?.name || "Allgemeiner Chat", - description: generalRoom.value?.alias || "Mandantenweiter Austausch", - exists: Boolean(generalRoom.value?.exists), - roomId: generalRoom.value?.roomId || "", - unread: 0 -})) +const activeRoom = computed(() => + matrixRooms.value.find((room) => room.key === activeRoomKey.value) || { + key: activeRoomKey.value, + name: activeRoomKey.value, + description: "Mandantenweiter Austausch", + exists: false, + roomId: "", + unread: 0 + } +) + +const activeRoomEndpoint = computed(() => + `/api/communication/matrix/rooms/${encodeURIComponent(activeRoomKey.value)}` +) const rooms = computed(() => [ - activeRoom.value, + ...matrixRooms.value.map((room) => ({ + ...room, + description: room.alias || room.roomId || "Mandantenweiter Austausch" + })), { key: "projects", name: "Projekt-Chats", @@ -51,6 +61,15 @@ const rooms = computed(() => [ } ]) +const setActiveRoom = async (room) => { + if (room.disabled || room.key === activeRoomKey.value) return + + activeRoomKey.value = room.key + matrixMessages.value = [] + matrixMembers.value = [] + await loadRoomChat({ includeMembers: true }) +} + const mergeMatrixMessages = (incomingMessages) => { const byId = new Map() @@ -81,19 +100,19 @@ const scrollMessagesToBottom = async () => { const loadChatInfo = async () => { loading.value = true try { - const [statusRes, identityRes, roomRes] = await Promise.all([ + const [statusRes, identityRes, roomsRes] = await Promise.all([ $api("/api/communication/matrix/status"), $api("/api/communication/matrix/me"), - $api("/api/communication/matrix/rooms/general") + $api("/api/communication/matrix/rooms") ]) status.value = statusRes identity.value = identityRes - generalRoom.value = roomRes + matrixRooms.value = roomsRes.rooms || [] lastUpdated.value = new Date() - if (roomRes?.exists && canUseMatrixChat.value) { - await loadGeneralChat({ silent: true, includeMembers: true }) + if (activeRoom.value?.exists && canUseMatrixChat.value) { + await loadRoomChat({ silent: true, includeMembers: true }) } } catch (error) { toast.add({ @@ -105,33 +124,27 @@ const loadChatInfo = async () => { } } -const provisionGeneralRoom = async () => { +const provisionActiveRoom = async () => { roomProvisioning.value = true try { - const res = await $api("/api/communication/matrix/rooms/general/provision", { + const res = await $api(`${activeRoomEndpoint.value}/provision`, { method: "POST" }) - generalRoom.value = { - tenantId: res.tenantId, - tenantName: res.tenantName, - key: res.key, - name: res.name, - alias: res.alias, - exists: true, - roomId: res.roomId, - servers: res.servers || [] - } + const roomWasKnown = matrixRooms.value.some((room) => room.key === res.key) + matrixRooms.value = roomWasKnown + ? matrixRooms.value.map((room) => room.key === res.key ? { ...room, ...res, exists: true } : room) + : [...matrixRooms.value, { ...res, exists: true }] toast.add({ - title: res.alreadyExisted ? "Allgemeiner Chat ist bereit" : "Allgemeiner Chat erstellt", + title: res.alreadyExisted ? "Chatraum ist bereit" : "Chatraum erstellt", color: "success" }) - await loadGeneralChat({ includeMembers: true }) + await loadRoomChat({ includeMembers: true }) } catch (error) { toast.add({ - title: "Allgemeiner Chat konnte nicht erstellt werden", + title: "Chatraum konnte nicht erstellt werden", color: "error" }) } finally { @@ -139,22 +152,22 @@ const provisionGeneralRoom = async () => { } } -const loadGeneralMessages = async ({ silent = false } = {}) => { - if (!canUseMatrixChat.value || !generalRoom.value?.exists) return +const loadRoomMessages = async ({ silent = false } = {}) => { + if (!canUseMatrixChat.value || !activeRoom.value?.exists) return if (matrixMessagesRequestActive) return matrixMessagesRequestActive = true if (!silent) matrixMessagesLoading.value = true try { - const res = await $api("/api/communication/matrix/rooms/general/messages") + const res = await $api(`${activeRoomEndpoint.value}/messages`) mergeMatrixMessages(res.messages || []) - generalRoom.value = { - ...generalRoom.value, - alias: res.alias || generalRoom.value?.alias, + matrixRooms.value = matrixRooms.value.map((room) => room.key === activeRoomKey.value ? { + ...room, + alias: res.alias || room.alias, exists: true, - roomId: res.roomId || generalRoom.value?.roomId - } + roomId: res.roomId || room.roomId + } : room) await scrollMessagesToBottom() } catch (error) { if (!silent) { @@ -169,15 +182,15 @@ const loadGeneralMessages = async ({ silent = false } = {}) => { } } -const loadGeneralMembers = async ({ silent = false } = {}) => { - if (!canUseMatrixChat.value || !generalRoom.value?.exists) return +const loadRoomMembers = async ({ silent = false } = {}) => { + if (!canUseMatrixChat.value || !activeRoom.value?.exists) return if (matrixMembersRequestActive) return matrixMembersRequestActive = true if (!silent) matrixMembersLoading.value = true try { - const res = await $api("/api/communication/matrix/rooms/general/members") + const res = await $api(`${activeRoomEndpoint.value}/members`) matrixMembers.value = res.members || [] } catch (error) { if (!silent) { @@ -192,17 +205,17 @@ const loadGeneralMembers = async ({ silent = false } = {}) => { } } -const loadGeneralChat = async ({ silent = false, includeMembers = false } = {}) => { - await loadGeneralMessages({ silent }) +const loadRoomChat = async ({ silent = false, includeMembers = false } = {}) => { + await loadRoomMessages({ silent }) if (includeMembers) { - await loadGeneralMembers({ silent }) + await loadRoomMembers({ silent }) } } const sendMatrixMessage = async () => { const text = matrixMessageDraft.value.trim() - if (!text || !canUseMatrixChat.value || !generalRoom.value?.exists) return + if (!text || !canUseMatrixChat.value || !activeRoom.value?.exists) return const optimisticId = `pending-${Date.now()}` const optimisticMessage = { @@ -225,7 +238,7 @@ const sendMatrixMessage = async () => { matrixMessageSending.value = true try { - const message = await $api("/api/communication/matrix/rooms/general/messages", { + const message = await $api(`${activeRoomEndpoint.value}/messages`, { method: "POST", body: { text } }) @@ -251,8 +264,8 @@ const startMatrixAutoRefresh = () => { matrixAutoRefreshActive.value = true matrixRefreshInterval = window.setInterval(() => { - if (!document.hidden && canUseMatrixChat.value && generalRoom.value?.exists) { - loadGeneralChat({ silent: true }) + if (!document.hidden && canUseMatrixChat.value && activeRoom.value?.exists) { + loadRoomChat({ silent: true }) } }, 15000) } @@ -315,7 +328,7 @@ onBeforeUnmount(stopMatrixAutoRefresh) variant="ghost" :loading="loading || matrixMessagesLoading" :disabled="!canUseMatrixChat" - @click="loadGeneralChat({ includeMembers: true })" + @click="loadRoomChat({ includeMembers: true })" /> @@ -328,6 +341,7 @@ onBeforeUnmount(stopMatrixAutoRefresh) class="mb-1 flex w-full items-center gap-3 rounded-md px-3 py-2 text-left transition" :class="room.disabled ? 'cursor-not-allowed opacity-50' : 'bg-muted text-highlighted'" :disabled="room.disabled" + @click="setActiveRoom(room)" > @@ -368,7 +382,7 @@ onBeforeUnmount(stopMatrixAutoRefresh) {{ activeRoom.name }} Laden @@ -417,7 +431,7 @@ onBeforeUnmount(stopMatrixAutoRefresh)
Chat erstellen @@ -487,14 +501,14 @@ onBeforeUnmount(stopMatrixAutoRefresh) v-model="matrixMessageDraft" class="min-w-0 flex-1" placeholder="Nachricht schreiben" - :disabled="matrixMessageSending || !canUseMatrixChat || !generalRoom?.exists" + :disabled="matrixMessageSending || !canUseMatrixChat || !activeRoom?.exists" @keydown.enter.exact.prevent="sendMatrixMessage" /> Senden @@ -507,7 +521,7 @@ onBeforeUnmount(stopMatrixAutoRefresh) Raumdetails

- {{ generalRoom?.roomId || generalRoom?.alias || "Noch kein Matrix-Raum" }} + {{ activeRoom?.roomId || activeRoom?.alias || "Noch kein Matrix-Raum" }}

@@ -523,26 +537,26 @@ onBeforeUnmount(stopMatrixAutoRefresh) variant="ghost" size="xs" :loading="matrixMembersLoading" - :disabled="!canUseMatrixChat || !generalRoom?.exists" - @click="loadGeneralMembers" + :disabled="!canUseMatrixChat || !activeRoom?.exists" + @click="loadRoomMembers" />
- {{ (member.displayName || member.userId || "?").slice(0, 1).toUpperCase() }} + {{ (member.displayName || member.matrixUserId || "?").slice(0, 1).toUpperCase() }}

- {{ member.displayName || member.userId }} + {{ member.displayName || member.matrixUserId }}

- {{ member.userId }} + {{ member.matrixUserId }}