KI-AGENT: Nativen Matrix-Chat in FEDEO starten

This commit is contained in:
2026-05-18 17:41:31 +02:00
parent 5fca7792a2
commit 655459a46b
3 changed files with 312 additions and 18 deletions

View File

@@ -11,6 +11,17 @@ type MatrixErrorResponse = {
error?: string error?: string
} }
type MatrixRoomEvent = {
event_id: string
sender: string
origin_server_ts: number
type: string
content?: {
body?: string
msgtype?: string
}
}
const trimTrailingSlash = (value: string) => value.replace(/\/+$/, "") const trimTrailingSlash = (value: string) => value.replace(/\/+$/, "")
const readLocalDevRegistrationSharedSecret = () => { const readLocalDevRegistrationSharedSecret = () => {
if (process.env.NODE_ENV === "production") return "" if (process.env.NODE_ENV === "production") return ""
@@ -277,6 +288,30 @@ export function matrixService(server: FastifyInstance) {
}) })
} }
const createAccessTokenForUser = async (userId: string, tenantId: number | null) => {
await provisionCurrentUser(userId, tenantId)
const matrixUserId = await matrixUserIdForUser(userId, tenantId)
const serviceLogin = await ensureServiceAccessToken()
const validUntilMs = Date.now() + 10 * 60 * 1000
const login = await requestMatrixJson<{ access_token: string }>(
`/_synapse/admin/v1/users/${encodeURIComponent(matrixUserId)}/login`,
serviceLogin.access_token,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ valid_until_ms: validUntilMs }),
}
)
return {
accessToken: login.access_token,
matrixUserId,
validUntilMs,
}
}
const getStatus = async () => { const getStatus = async () => {
const configured = Boolean(homeserverUrl() && serverName()) const configured = Boolean(homeserverUrl() && serverName())
@@ -607,6 +642,118 @@ export function matrixService(server: FastifyInstance) {
} }
} }
const ensureCurrentUserJoinedRoom = async (
userId: string,
tenantId: number | null,
room: { roomId: string, alias: string }
) => {
const session = await createAccessTokenForUser(userId, tenantId)
try {
await requestMatrixJson(
`/_matrix/client/v3/join/${encodeURIComponent(room.roomId || room.alias)}`,
session.accessToken,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
}
)
} catch (err: any) {
if (err.errcode !== "M_FORBIDDEN" && err.errcode !== "M_UNKNOWN") {
throw err
}
}
return session
}
const getGeneralRoomMessages = async (userId: string, tenantId: number | null) => {
const room = await provisionTenantRoom(userId, tenantId, {
key: "allgemein",
name: "Allgemeiner Chat",
})
const session = await ensureCurrentUserJoinedRoom(userId, tenantId, {
roomId: room.roomId,
alias: room.alias,
})
const response = await requestMatrixJson<{
chunk: MatrixRoomEvent[]
start?: string
end?: string
}>(
`/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/messages?dir=b&limit=50`,
session.accessToken
)
return {
roomId: room.roomId,
alias: room.alias,
matrixUserId: session.matrixUserId,
messages: response.chunk
.filter((event) => event.type === "m.room.message" && event.content?.msgtype === "m.text")
.map((event) => ({
id: event.event_id,
sender: event.sender,
body: event.content?.body || "",
timestamp: event.origin_server_ts,
own: event.sender === session.matrixUserId,
}))
.reverse(),
}
}
const sendGeneralRoomMessage = async (
userId: string,
tenantId: number | null,
text: string
) => {
const message = text.trim()
if (!message) {
throw Object.assign(
new Error("Message text is required"),
{ statusCode: 400 }
)
}
const room = await provisionTenantRoom(userId, tenantId, {
key: "allgemein",
name: "Allgemeiner Chat",
})
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,
}),
}
)
return {
id: response.event_id,
sender: session.matrixUserId,
body: message,
timestamp: Date.now(),
own: true,
roomId: room.roomId,
alias: room.alias,
}
}
return { return {
getStatus, getStatus,
matrixUserIdForUser, matrixUserIdForUser,
@@ -616,5 +763,8 @@ export function matrixService(server: FastifyInstance) {
provisionCurrentTenantSpace, provisionCurrentTenantSpace,
getTenantRoomStatus, getTenantRoomStatus,
provisionTenantRoom, provisionTenantRoom,
createAccessTokenForUser,
getGeneralRoomMessages,
sendGeneralRoomMessage,
} }
} }

View File

@@ -74,4 +74,27 @@ export default async function communicationRoutes(server: FastifyInstance) {
.send({ error: err.message || "Matrix room provisioning failed" }) .send({ error: err.message || "Matrix room provisioning failed" })
} }
}) })
server.get("/communication/matrix/rooms/general/messages", async (req, reply) => {
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" })
}
})
server.post("/communication/matrix/rooms/general/messages", async (req, reply) => {
try {
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" })
}
})
} }

View File

@@ -10,10 +10,14 @@ const generalRoom = ref(null)
const provisionResult = ref(null) const provisionResult = ref(null)
const tenantSpaceProvisionResult = ref(null) const tenantSpaceProvisionResult = ref(null)
const generalRoomProvisionResult = ref(null) const generalRoomProvisionResult = ref(null)
const matrixMessages = ref([])
const matrixMessageDraft = ref("")
const loading = ref(false) const loading = ref(false)
const provisioning = ref(false) const provisioning = ref(false)
const tenantSpaceProvisioning = ref(false) const tenantSpaceProvisioning = ref(false)
const generalRoomProvisioning = ref(false) const generalRoomProvisioning = ref(false)
const matrixMessagesLoading = ref(false)
const matrixMessageSending = ref(false)
const lastUpdated = ref(null) const lastUpdated = ref(null)
const statusItems = computed(() => [ const statusItems = computed(() => [
@@ -83,6 +87,10 @@ const loadMatrixInfo = async () => {
tenantSpace.value = tenantSpaceRes tenantSpace.value = tenantSpaceRes
generalRoom.value = generalRoomRes generalRoom.value = generalRoomRes
lastUpdated.value = new Date() lastUpdated.value = new Date()
if (generalRoomRes?.exists) {
await loadGeneralMessages()
}
} catch (error) { } catch (error) {
toast.add({ toast.add({
title: "Matrix-Status konnte nicht geladen werden", title: "Matrix-Status konnte nicht geladen werden",
@@ -180,6 +188,8 @@ const provisionGeneralRoom = async () => {
title: res.alreadyExisted ? "Allgemeiner Chat ist bereits vorhanden" : "Allgemeiner Chat erstellt", title: res.alreadyExisted ? "Allgemeiner Chat ist bereits vorhanden" : "Allgemeiner Chat erstellt",
color: "success" color: "success"
}) })
await loadGeneralMessages()
} catch (error) { } catch (error) {
toast.add({ toast.add({
title: "Allgemeiner Chat konnte nicht erstellt werden", title: "Allgemeiner Chat konnte nicht erstellt werden",
@@ -190,6 +200,53 @@ const provisionGeneralRoom = async () => {
} }
} }
const loadGeneralMessages = async () => {
matrixMessagesLoading.value = true
try {
const res = await $api("/api/communication/matrix/rooms/general/messages")
matrixMessages.value = res.messages || []
generalRoom.value = {
...generalRoom.value,
alias: res.alias || generalRoom.value?.alias,
exists: true,
roomId: res.roomId || generalRoom.value?.roomId
}
} catch (error) {
toast.add({
title: "Matrix-Nachrichten konnten nicht geladen werden",
color: "error"
})
} finally {
matrixMessagesLoading.value = false
}
}
const sendMatrixMessage = async () => {
const text = matrixMessageDraft.value.trim()
if (!text) return
matrixMessageSending.value = true
try {
const message = await $api("/api/communication/matrix/rooms/general/messages", {
method: "POST",
body: { text }
})
matrixMessages.value = [
...matrixMessages.value,
message
]
matrixMessageDraft.value = ""
} catch (error) {
toast.add({
title: "Matrix-Nachricht konnte nicht gesendet werden",
color: "error"
})
} finally {
matrixMessageSending.value = false
}
}
const formatDateTime = (value) => { const formatDateTime = (value) => {
if (!value) return "-" if (!value) return "-"
@@ -199,6 +256,15 @@ const formatDateTime = (value) => {
}).format(value) }).format(value)
} }
const formatMessageTime = (timestamp) => {
if (!timestamp) return ""
return new Intl.DateTimeFormat("de-DE", {
dateStyle: "short",
timeStyle: "short"
}).format(new Date(timestamp))
}
onMounted(loadMatrixInfo) onMounted(loadMatrixInfo)
</script> </script>
@@ -504,31 +570,86 @@ onMounted(loadMatrixInfo)
Matrix-Kommunikation Matrix-Kommunikation
</h2> </h2>
<p class="mt-1 truncate text-xs text-muted"> <p class="mt-1 truncate text-xs text-muted">
{{ activeMatrixTarget?.name || activeMatrixTarget?.alias || "Element Web" }} {{ generalRoom?.name || generalRoom?.alias || "Allgemeiner Chat" }}
</p> </p>
</div> </div>
</div> </div>
<UButton <div class="flex flex-wrap gap-2">
icon="i-heroicons-arrow-top-right-on-square" <UButton
color="neutral" icon="i-heroicons-arrow-path"
variant="outline" color="neutral"
:to="embeddedElementUrl" variant="outline"
target="_blank" :loading="matrixMessagesLoading"
> :disabled="!status?.reachable || !status?.provisioningConfigured"
Separat öffnen @click="loadGeneralMessages"
</UButton> >
Nachrichten laden
</UButton>
<UButton
icon="i-heroicons-arrow-top-right-on-square"
color="neutral"
variant="outline"
:to="embeddedElementUrl"
target="_blank"
>
Element öffnen
</UButton>
</div>
</div> </div>
</template> </template>
<div class="h-[720px] min-h-[520px] bg-muted"> <div class="flex h-[640px] min-h-[520px] flex-col bg-muted">
<iframe <div class="flex-1 space-y-3 overflow-y-auto p-4 sm:p-5">
class="h-full w-full border-0" <div
:src="embeddedElementUrl" v-if="!matrixMessages.length && !matrixMessagesLoading"
title="Matrix-Kommunikation" class="flex h-full min-h-64 items-center justify-center text-sm text-muted"
allow="camera; microphone; display-capture; clipboard-read; clipboard-write; autoplay; fullscreen" >
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads allow-modals allow-presentation" Noch keine Nachrichten im allgemeinen Chat.
/> </div>
<div
v-for="message in matrixMessages"
:key="message.id"
class="flex"
:class="message.own ? 'justify-end' : 'justify-start'"
>
<div
class="max-w-[78%] rounded-lg px-3 py-2 shadow-sm"
:class="message.own ? 'bg-primary text-inverted' : 'bg-default text-highlighted'"
>
<div class="mb-1 flex items-center gap-2 text-[11px] opacity-75">
<span class="truncate font-medium">
{{ message.own ? "Du" : message.sender }}
</span>
<span>{{ formatMessageTime(message.timestamp) }}</span>
</div>
<p class="whitespace-pre-wrap break-words text-sm">
{{ message.body }}
</p>
</div>
</div>
</div>
<form
class="flex gap-2 border-t border-default bg-default p-3"
@submit.prevent="sendMatrixMessage"
>
<UInput
v-model="matrixMessageDraft"
class="min-w-0 flex-1"
placeholder="Nachricht schreiben"
:disabled="matrixMessageSending || !status?.reachable || !status?.provisioningConfigured"
/>
<UButton
type="submit"
icon="i-heroicons-paper-airplane"
:loading="matrixMessageSending"
:disabled="!matrixMessageDraft.trim() || !status?.reachable || !status?.provisioningConfigured"
>
Senden
</UButton>
</form>
</div> </div>
</UCard> </UCard>
</div> </div>