KI-AGENT: Anhänge im Chat über Matrix unterstützen

This commit is contained in:
2026-05-19 10:51:33 +02:00
parent 7caa37378b
commit 26ffc4421a
3 changed files with 370 additions and 3 deletions

View File

@@ -20,6 +20,11 @@ type MatrixRoomEvent = {
content?: {
body?: string
msgtype?: string
url?: string
info?: {
mimetype?: string
size?: number
}
}
}
@@ -59,6 +64,13 @@ type MatrixTenantRoomOptions = {
inviteUserIds?: string[]
}
type MatrixAttachmentInput = {
buffer: Buffer
filename: string
mimeType: string
size: number
}
type MatrixCachedValue<T = any> = {
exists: true
cachedUntil: number
@@ -404,6 +416,41 @@ export function matrixService(server: FastifyInstance) {
})
}
const mxcToMediaPath = (mxcUri: string) => {
const match = mxcUri.match(/^mxc:\/\/([^/]+)\/(.+)$/)
if (!match) {
throw Object.assign(
new Error("Ungültige Matrix-Media-URI"),
{ statusCode: 400 }
)
}
return `/_matrix/media/v3/download/${encodeURIComponent(match[1])}/${encodeURIComponent(match[2])}`
}
const matrixMediaUrl = (mxcUri: string) => `${homeserverUrl()}${mxcToMediaPath(mxcUri)}`
const attachmentFromEvent = (event: MatrixRoomEvent) => {
const msgtype = event.content?.msgtype || "m.text"
if (!["m.file", "m.image"].includes(msgtype) || !event.content?.url) {
return null
}
const mimeType = event.content.info?.mimetype || "application/octet-stream"
return {
type: msgtype === "m.image" ? "image" : "file",
url: event.content.url,
fileName: event.content.body || "Anhang",
mimeType,
size: event.content.info?.size || 0,
previewUrl: msgtype === "m.image" ? matrixMediaUrl(event.content.url) : null,
downloadUrl: matrixMediaUrl(event.content.url),
isImage: msgtype === "m.image" || mimeType.startsWith("image/"),
}
}
const createAccessTokenForUser = async (userId: string, tenantId: number | null) => {
const matrixUserId = await matrixUserIdForUser(userId, tenantId)
const cacheKey = `${tenantId || "global"}:${userId}`
@@ -1118,12 +1165,16 @@ export function matrixService(server: FastifyInstance) {
name: room.name,
matrixUserId: session.matrixUserId,
messages: response.chunk
.filter((event) => event.type === "m.room.message" && event.content?.msgtype === "m.text")
.filter((event) =>
event.type === "m.room.message" &&
["m.text", "m.file", "m.image"].includes(event.content?.msgtype || "")
)
.map((event) => ({
id: event.event_id,
sender: event.sender,
senderDisplayName: members.joined[event.sender]?.display_name || event.sender,
body: event.content?.body || "",
attachment: attachmentFromEvent(event),
timestamp: event.origin_server_ts,
own: event.sender === session.matrixUserId,
}))
@@ -1210,6 +1261,103 @@ export function matrixService(server: FastifyInstance) {
}
}
const sendTenantRoomAttachment = async (
userId: string,
tenantId: number | null,
options: MatrixTenantRoomOptions = {},
attachment: MatrixAttachmentInput
) => {
if (!attachment.buffer?.length) {
throw Object.assign(
new Error("Attachment file is required"),
{ statusCode: 400 }
)
}
const room = await provisionTenantRoom(userId, tenantId, options)
const session = await ensureCurrentUserJoinedRoom(userId, tenantId, {
roomId: room.roomId,
alias: room.alias,
})
const upload = await requestMatrixJson<{ content_uri: string }>(
`/_matrix/media/v3/upload?filename=${encodeURIComponent(attachment.filename)}`,
session.accessToken,
{
method: "POST",
headers: { "Content-Type": attachment.mimeType || "application/octet-stream" },
body: attachment.buffer as any,
}
)
const txnId = `${Date.now()}-${randomBytes(8).toString("hex")}`
const isImage = (attachment.mimeType || "").startsWith("image/")
const messageContent = {
msgtype: isImage ? "m.image" : "m.file",
body: attachment.filename,
url: upload.content_uri,
info: {
mimetype: attachment.mimeType || "application/octet-stream",
size: attachment.size,
},
}
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(messageContent),
}
)
return {
id: response.event_id,
sender: session.matrixUserId,
senderDisplayName: await getCurrentUserDisplayName(userId, tenantId),
body: attachment.filename,
attachment: {
type: isImage ? "image" : "file",
url: upload.content_uri,
fileName: attachment.filename,
mimeType: attachment.mimeType || "application/octet-stream",
size: attachment.size,
previewUrl: isImage ? matrixMediaUrl(upload.content_uri) : null,
downloadUrl: matrixMediaUrl(upload.content_uri),
isImage,
},
timestamp: Date.now(),
own: true,
roomId: room.roomId,
alias: room.alias,
key: room.key,
}
}
const getMediaContent = async (
userId: string,
tenantId: number | null,
mxcUri: string
) => {
const session = await createAccessTokenForUser(userId, tenantId)
const response = await fetch(matrixMediaUrl(mxcUri), {
headers: {
Authorization: `Bearer ${session.accessToken}`,
},
})
if (!response.ok) {
throw Object.assign(
new Error(`Matrix media request failed with ${response.status}`),
{ statusCode: response.status }
)
}
return {
buffer: Buffer.from(await response.arrayBuffer()),
contentType: response.headers.get("content-type") || "application/octet-stream",
contentLength: response.headers.get("content-length"),
}
}
const createElementRoomSession = async (
userId: string,
tenantId: number | null,
@@ -1442,6 +1590,17 @@ export function matrixService(server: FastifyInstance) {
text
)
const sendGeneralRoomAttachment = (userId: string, tenantId: number | null, attachment: MatrixAttachmentInput) =>
sendTenantRoomAttachment(
userId,
tenantId,
{
key: "allgemein",
name: "Allgemeiner Chat",
},
attachment
)
return {
getStatus,
matrixUserIdForUser,
@@ -1456,11 +1615,14 @@ export function matrixService(server: FastifyInstance) {
getTenantRoomMessages,
getTenantRoomMembers,
sendTenantRoomMessage,
sendTenantRoomAttachment,
getMediaContent,
createElementRoomSession,
createLiveKitRoomSession,
syncTenantRoomMembers,
getGeneralRoomMessages,
getGeneralRoomMembers,
sendGeneralRoomMessage,
sendGeneralRoomAttachment,
}
}

View File

@@ -1,5 +1,6 @@
import { createHash } from "node:crypto"
import { FastifyInstance } from "fastify"
import multipart from "@fastify/multipart"
import { and, eq, inArray, ne } from "drizzle-orm"
import { authProfiles, authTenantUsers, authUsers, notificationsItems, projects } from "../../db/schema"
import { matrixService } from "../modules/matrix.service"
@@ -24,6 +25,10 @@ type ChatRecipient = {
}
export default async function communicationRoutes(server: FastifyInstance) {
await server.register(multipart, {
limits: { fileSize: 25 * 1024 * 1024 },
})
const matrix = matrixService(server)
const notifications = new NotificationService(server, getUserDirectory)
const handleMatrixError = (req: any, reply: any, err: any, fallbackMessage: string) => {
@@ -226,6 +231,24 @@ export default async function communicationRoutes(server: FastifyInstance) {
return { read: ids.length }
}
const uploadedAttachmentFromRequest = async (req: any) => {
const data = await req.file()
if (!data?.file) {
throw Object.assign(
new Error("Keine Datei hochgeladen"),
{ statusCode: 400 }
)
}
const buffer = await data.toBuffer()
return {
buffer,
filename: data.filename || "Anhang",
mimeType: data.mimetype || "application/octet-stream",
size: buffer.length,
}
}
const callModeFromRequest = (req: any): "audio" | "video" => {
const body = (req.body || {}) as { mode?: string }
return body.mode === "audio" ? "audio" : "video"
@@ -336,6 +359,23 @@ export default async function communicationRoutes(server: FastifyInstance) {
}
})
server.get("/communication/matrix/media", async (req, reply) => {
try {
const query = req.query as { uri?: string; name?: string }
if (!query.uri) return reply.code(400).send({ error: "Matrix-Media-URI fehlt" })
const media = await matrix.getMediaContent(req.user.user_id, req.user.tenant_id, query.uri)
reply.header("Content-Type", media.contentType)
if (media.contentLength) reply.header("Content-Length", media.contentLength)
if (query.name) {
reply.header("Content-Disposition", `inline; filename="${query.name.replace(/"/g, "")}"`)
}
return reply.send(media.buffer)
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix media failed")
}
})
server.get("/communication/matrix/project-rooms", async (req, reply) => {
try {
if (!req.user.tenant_id) return reply.code(400).send({ error: "Kein aktiver Mandant" })
@@ -611,6 +651,18 @@ export default async function communicationRoutes(server: FastifyInstance) {
}
})
server.post("/communication/matrix/rooms/general/attachments", async (req, reply) => {
try {
const attachment = await uploadedAttachmentFromRequest(req)
const message = await matrix.sendGeneralRoomAttachment(req.user.user_id, req.user.tenant_id, attachment)
const room = await matrix.getTenantRoomStatus(req.user.tenant_id, "allgemein", "Allgemeiner Chat")
await notifyUsersAboutChatMessage(req, room, message, `Anhang: ${attachment.filename}`)
return message
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix attachment send failed")
}
})
server.get("/communication/matrix/rooms/:roomKey", async (req, reply) => {
try {
const params = req.params as { roomKey: string }
@@ -721,4 +773,21 @@ export default async function communicationRoutes(server: FastifyInstance) {
return handleMatrixError(req, reply, err, "Matrix message send failed")
}
})
server.post("/communication/matrix/rooms/:roomKey/attachments", async (req, reply) => {
try {
const attachment = await uploadedAttachmentFromRequest(req)
const message = await matrix.sendTenantRoomAttachment(
req.user.user_id,
req.user.tenant_id,
roomOptionsFromRequest(req),
attachment
)
const room = await matrix.getTenantRoomStatus(req.user.tenant_id, message.key)
await notifyUsersAboutChatMessage(req, room, message, `Anhang: ${attachment.filename}`)
return message
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix attachment send failed")
}
})
}