import { createHash, createHmac, randomBytes } from "node:crypto" import { existsSync, readFileSync } from "node:fs" import { resolve } from "node:path" import { FastifyInstance } from "fastify" import { authProfiles, authTenantUsers, authUsers, communicationRooms, tenants } from "../../db/schema" import { and, eq, isNotNull } from "drizzle-orm" import { secrets } from "../utils/secrets" import jwt from "jsonwebtoken" type MatrixErrorResponse = { errcode?: string error?: string } type MatrixRoomEvent = { event_id: string sender: string origin_server_ts: number type: string redacts?: string content?: { body?: string msgtype?: string url?: string info?: { mimetype?: string size?: number } "m.new_content"?: { body?: string msgtype?: string } "m.relates_to"?: { event_id?: string key?: string rel_type?: string "m.in_reply_to"?: { event_id?: string } } } } type MatrixJoinedMembersResponse = { joined: Record } type MatrixRoomSearchResponse = { search_categories?: { room_events?: { count?: number results?: Array<{ result?: MatrixRoomEvent & { room_id?: string } }> } } } type MatrixSyncResponse = { next_batch?: string rooms?: { join?: Record } } type MatrixUserSession = { accessToken: string matrixUserId: string validUntilMs: number } type MatrixLoginTokenResponse = { login_token: string expires_in_ms: number } type LiveKitGrant = { roomJoin: boolean room: string canPublish: boolean canSubscribe: boolean } type MatrixTenantRoomOptions = { key?: string name?: string topic?: string type?: string entityType?: string | null entityId?: number | null entityUuid?: string | null inviteUserIds?: string[] } type MatrixAttachmentInput = { buffer: Buffer filename: string mimeType: string size: number } type MatrixMessageOptions = { replyToEventId?: string } type MatrixCachedValue = { exists: true cachedUntil: number value: T } const matrixUserSessionCache = new Map() const matrixJoinedRoomCache = new Map() const matrixProvisionedUserCache = new Map() const matrixTenantSpaceCache = new Map() const matrixTenantRoomCache = new Map() let matrixServiceSessionCache: MatrixUserSession | null = null const defaultTenantRooms: Required>[] = [ { key: "allgemein", name: "Allgemeiner Chat", type: "general", }, ] const trimTrailingSlash = (value: string) => value.replace(/\/+$/, "") const readLocalDevRegistrationSharedSecret = () => { if (process.env.NODE_ENV === "production") return "" const candidates = [ resolve(process.cwd(), "../matrix/dev/synapse/homeserver.yaml"), resolve(process.cwd(), "matrix/dev/synapse/homeserver.yaml"), ] for (const candidate of candidates) { if (!existsSync(candidate)) continue const content = readFileSync(candidate, "utf8") const match = content.match(/^registration_shared_secret:\s*["']?(.+?)["']?\s*$/m) if (match?.[1]) { return match[1] } } return "" } const normalizeMatrixLocalpartSeed = (value: string) => { const normalized = value .toLowerCase() .normalize("NFKD") .replace(/[\u0300-\u036f]/g, "") .replace(/ä/g, "a") .replace(/ö/g, "o") .replace(/ü/g, "u") .replace(/ß/g, "ss") .replace(/[^a-z0-9._=-]+/g, "_") .replace(/_+/g, "_") .replace(/^[._=-]+|[._=-]+$/g, "") return normalized || "user" } const normalizeMatrixAliasSeed = (value: string) => normalizeMatrixLocalpartSeed(value) .replace(/[.=]/g, "_") .replace(/_+/g, "_") export function matrixService(server: FastifyInstance) { const homeserverUrl = () => trimTrailingSlash( process.env.MATRIX_HOMESERVER_URL || secrets.MATRIX_HOMESERVER_URL || "http://localhost:8008" ) const serverName = () => process.env.MATRIX_SERVER_NAME || secrets.MATRIX_SERVER_NAME || "localhost" const registrationSharedSecret = () => process.env.MATRIX_REGISTRATION_SHARED_SECRET || secrets.MATRIX_REGISTRATION_SHARED_SECRET || readLocalDevRegistrationSharedSecret() || "" const rtcHost = () => process.env.MATRIX_RTC_HOST || secrets.MATRIX_RTC_HOST || "call.fedeo.de" const rtcJwtUrl = () => process.env.MATRIX_RTC_JWT_URL || secrets.MATRIX_RTC_JWT_URL || (process.env.NODE_ENV === "production" ? `https://${rtcHost()}/livekit/jwt` : `http://localhost:${process.env.MATRIX_DEV_RTC_JWT_PORT || "8081"}`) const livekitUrl = () => process.env.MATRIX_LIVEKIT_URL || secrets.MATRIX_LIVEKIT_URL || (process.env.NODE_ENV === "production" ? `wss://${rtcHost()}/livekit/sfu` : `ws://localhost:${process.env.MATRIX_DEV_LIVEKIT_PORT || "7880"}`) const livekitKey = () => process.env.LIVEKIT_KEY || secrets.LIVEKIT_KEY || (process.env.NODE_ENV === "production" ? "" : "devkey") const livekitSecret = () => process.env.LIVEKIT_SECRET || secrets.LIVEKIT_SECRET || (process.env.NODE_ENV === "production" ? "" : "devsecret-local-matrix-stack-32-chars") const serviceUserLocalpart = () => process.env.MATRIX_SERVICE_USER_LOCALPART || secrets.MATRIX_SERVICE_USER_LOCALPART || "fedeo_service" const serviceUserPassword = () => createHmac("sha256", registrationSharedSecret()) .update(`${serverName()}:fedeo-service-user`) .digest("base64url") const getUserIdentitySeed = async (userId: string, tenantId: number | null) => { const [user] = await server.db .select({ email: authUsers.email }) .from(authUsers) .where(eq(authUsers.id, userId)) .limit(1) if (user?.email) { return user.email.split("@")[0] || user.email } if (tenantId) { const displayName = await getCurrentUserDisplayName(userId, tenantId) if (displayName && !displayName.startsWith("@")) { return displayName } } return "user" } const matrixLocalpartForUser = async (userId: string, tenantId: number | null) => { const seed = normalizeMatrixLocalpartSeed(await getUserIdentitySeed(userId, tenantId)) const hash = createHash("sha256").update(userId).digest("hex").slice(0, 8) return `${seed}_${hash}` } const matrixUserIdForUser = async (userId: string, tenantId: number | null) => `@${await matrixLocalpartForUser(userId, tenantId)}:${serverName()}` const tenantSpaceAliasLocalpart = (tenant: { id: number, short?: string | null, name?: string | null }) => { const seed = normalizeMatrixAliasSeed(tenant.short || tenant.name || `tenant_${tenant.id}`) return `fedeo_${seed}_${tenant.id}` } const tenantSpaceAlias = (tenant: { id: number, short?: string | null, name?: string | null }) => `#${tenantSpaceAliasLocalpart(tenant)}:${serverName()}` const tenantRoomAliasLocalpart = ( tenant: { id: number, short?: string | null, name?: string | null }, roomKey: string ) => { const tenantSeed = normalizeMatrixAliasSeed(tenant.short || tenant.name || `tenant_${tenant.id}`) const roomSeed = normalizeMatrixAliasSeed(roomKey) return `fedeo_${tenantSeed}_${tenant.id}_${roomSeed}` } const tenantRoomAlias = ( tenant: { id: number, short?: string | null, name?: string | null }, 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, type: options.type || "room", entityType: options.entityType || null, entityId: options.entityId || null, entityUuid: options.entityUuid || null, inviteUserIds: options.inviteUserIds || [], } } const buildSharedSecretMac = ( nonce: string, username: string, password: string, admin: boolean ) => { const hmac = createHmac("sha1", registrationSharedSecret()) hmac.update(nonce) hmac.update("\0") hmac.update(username) hmac.update("\0") hmac.update(password) hmac.update("\0") hmac.update(admin ? "admin" : "notadmin") return hmac.digest("hex") } const registerWithSharedSecret = async ( username: string, password: string, admin: boolean ) => { const nonceResponse = await requestJson<{ nonce: string }>( `${homeserverUrl()}/_synapse/admin/v1/register` ) const mac = buildSharedSecretMac(nonceResponse.nonce, username, password, admin) return requestJson(`${homeserverUrl()}/_synapse/admin/v1/register`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ nonce: nonceResponse.nonce, username, password, admin, mac, }), }) } const loginMatrixUser = async (username: string, password: string) => { return requestJson<{ access_token: string user_id: string }>(`${homeserverUrl()}/_matrix/client/v3/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ type: "m.login.password", identifier: { type: "m.id.user", user: username, }, password, }), }) } const ensureServiceAccessToken = async () => { if (!registrationSharedSecret()) { throw Object.assign( new Error("MATRIX_REGISTRATION_SHARED_SECRET is not configured"), { statusCode: 503 } ) } if (matrixServiceSessionCache && matrixServiceSessionCache.validUntilMs > Date.now() + 60_000) { return matrixServiceSessionCache } const username = serviceUserLocalpart() const password = serviceUserPassword() try { await registerWithSharedSecret(username, password, true) } catch (err: any) { if (err.errcode !== "M_USER_IN_USE") { throw err } } const login = await loginMatrixUser(username, password) matrixServiceSessionCache = { accessToken: login.access_token, matrixUserId: login.user_id, validUntilMs: Date.now() + 30 * 60 * 1000, } return matrixServiceSessionCache } const getCurrentUserDisplayName = async (userId: string, tenantId: number | null) => { if (tenantId) { const [profile] = await server.db .select({ firstName: authProfiles.first_name, lastName: authProfiles.last_name, }) .from(authProfiles) .where(and( eq(authProfiles.user_id, userId), eq(authProfiles.tenant_id, tenantId) )) .limit(1) const profileName = [profile?.firstName, profile?.lastName] .filter(Boolean) .join(" ") .trim() if (profileName) return profileName } const [user] = await server.db .select({ email: authUsers.email }) .from(authUsers) .where(eq(authUsers.id, userId)) .limit(1) return user?.email || await matrixUserIdForUser(userId, tenantId) } const requestJson = async (url: string, init?: RequestInit): Promise => { const response = await fetch(url, init) const text = await response.text() const body = text ? JSON.parse(text) : {} if (!response.ok) { const error = body as MatrixErrorResponse throw Object.assign( new Error(error.error || `Matrix request failed with ${response.status}`), { statusCode: response.status, errcode: error.errcode, body, } ) } return body as T } const requestMatrixJson = async (path: string, accessToken: string, init?: RequestInit): Promise => { return requestJson(`${homeserverUrl()}${path}`, { ...init, headers: { ...(init?.headers || {}), Authorization: `Bearer ${accessToken}`, }, }) } 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}` const cachedSession = matrixUserSessionCache.get(cacheKey) if (cachedSession && cachedSession.validUntilMs > Date.now() + 60_000) { return cachedSession } await provisionCurrentUser(userId, tenantId) const serviceLogin = await ensureServiceAccessToken() const validUntilMs = Date.now() + 30 * 60 * 1000 const login = await requestMatrixJson<{ access_token: string }>( `/_synapse/admin/v1/users/${encodeURIComponent(matrixUserId)}/login`, serviceLogin.accessToken, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ valid_until_ms: validUntilMs }), } ) const session = { accessToken: login.access_token, matrixUserId, validUntilMs, } matrixUserSessionCache.set(cacheKey, session) return session } const getStatus = async () => { const configured = Boolean(homeserverUrl() && serverName()) if (!configured) { return { configured: false, homeserverUrl: homeserverUrl(), serverName: serverName(), provisioningConfigured: Boolean(registrationSharedSecret()), reachable: false, calls: { provider: "matrixrtc-livekit", configured: Boolean(rtcJwtUrl() && livekitUrl()), rtcHost: rtcHost(), rtcJwtUrl: rtcJwtUrl(), livekitUrl: livekitUrl(), }, } } try { const versions = await requestJson<{ versions: string[] }>( `${homeserverUrl()}/_matrix/client/versions` ) return { configured: true, homeserverUrl: homeserverUrl(), serverName: serverName(), provisioningConfigured: Boolean(registrationSharedSecret()), reachable: true, calls: { provider: "matrixrtc-livekit", configured: Boolean(rtcJwtUrl() && livekitUrl()), rtcHost: rtcHost(), rtcJwtUrl: rtcJwtUrl(), livekitUrl: livekitUrl(), }, versions: versions.versions, } } catch (err: any) { return { configured: true, homeserverUrl: homeserverUrl(), serverName: serverName(), provisioningConfigured: Boolean(registrationSharedSecret()), reachable: false, calls: { provider: "matrixrtc-livekit", configured: Boolean(rtcJwtUrl() && livekitUrl()), rtcHost: rtcHost(), rtcJwtUrl: rtcJwtUrl(), livekitUrl: livekitUrl(), }, error: err.message, } } } const provisionCurrentUser = async (userId: string, tenantId: number | null) => { if (!registrationSharedSecret()) { throw Object.assign( new Error("MATRIX_REGISTRATION_SHARED_SECRET is not configured"), { statusCode: 503 } ) } const username = await matrixLocalpartForUser(userId, tenantId) const matrixUserId = await matrixUserIdForUser(userId, tenantId) const displayName = await getCurrentUserDisplayName(userId, tenantId) const cacheKey = `${tenantId || "global"}:${userId}` const cachedUntil = matrixProvisionedUserCache.get(cacheKey) if (cachedUntil && cachedUntil > Date.now()) { return { matrixUserId, localpart: username, displayName, created: false, alreadyExisted: true, } } const password = randomBytes(32).toString("base64url") try { await registerWithSharedSecret(username, password, false) matrixProvisionedUserCache.set(cacheKey, Date.now() + 30 * 60 * 1000) return { matrixUserId, localpart: username, displayName, created: true, alreadyExisted: false, } } catch (err: any) { if (err.errcode === "M_USER_IN_USE") { matrixProvisionedUserCache.set(cacheKey, Date.now() + 30 * 60 * 1000) return { matrixUserId, localpart: username, displayName, created: false, alreadyExisted: true, } } throw err } } const getCurrentTenant = async (tenantId: number | null) => { if (!tenantId) { throw Object.assign( new Error("No active tenant selected"), { statusCode: 400 } ) } const [tenant] = await server.db .select({ id: tenants.id, name: tenants.name, short: tenants.short, }) .from(tenants) .where(eq(tenants.id, tenantId)) .limit(1) if (!tenant) { throw Object.assign( new Error("Tenant not found"), { statusCode: 404 } ) } return tenant } const getTenantSpaceStatus = async (tenantId: number | null) => { const tenant = await getCurrentTenant(tenantId) const alias = tenantSpaceAlias(tenant) try { const directoryEntry = await requestJson<{ room_id: string servers: string[] }>(`${homeserverUrl()}/_matrix/client/v3/directory/room/${encodeURIComponent(alias)}`) return { tenantId: tenant.id, tenantName: tenant.name, alias, exists: true, roomId: directoryEntry.room_id, servers: directoryEntry.servers, } } catch (err: any) { if (err.statusCode === 404 || err.errcode === "M_NOT_FOUND") { return { tenantId: tenant.id, tenantName: tenant.name, alias, exists: false, roomId: null, servers: [], } } throw err } } const provisionCurrentTenantSpace = async (userId: string, tenantId: number | null) => { const tenant = await getCurrentTenant(tenantId) const cacheKey = String(tenant.id) const cachedSpace = matrixTenantSpaceCache.get(cacheKey) if (cachedSpace?.exists && cachedSpace.cachedUntil > Date.now()) { return cachedSpace.value } const existing = await getTenantSpaceStatus(tenant.id) const userAccount = await provisionCurrentUser(userId, tenant.id) if (existing.exists) { const value = { ...existing, created: false, alreadyExisted: true, invitedUserId: userAccount.matrixUserId, } matrixTenantSpaceCache.set(cacheKey, { exists: true, cachedUntil: Date.now() + 30 * 60 * 1000, value, }) return value } const serviceLogin = await ensureServiceAccessToken() const aliasLocalpart = tenantSpaceAliasLocalpart(tenant) const createdRoom = await requestMatrixJson<{ room_id: string }>( "/_matrix/client/v3/createRoom", serviceLogin.accessToken, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ creation_content: { type: "m.space", }, name: `FEDEO · ${tenant.name}`, topic: `Kommunikationsbereich für ${tenant.name}`, preset: "private_chat", visibility: "private", room_alias_name: aliasLocalpart, invite: [userAccount.matrixUserId], initial_state: [ { type: "m.room.history_visibility", state_key: "", content: { history_visibility: "invited", }, }, ], }), } ) const value = { tenantId: tenant.id, tenantName: tenant.name, alias: tenantSpaceAlias(tenant), exists: true, created: true, alreadyExisted: false, roomId: createdRoom.room_id, invitedUserId: userAccount.matrixUserId, serviceUserId: serviceLogin.matrixUserId, } matrixTenantSpaceCache.set(cacheKey, { exists: true, cachedUntil: Date.now() + 30 * 60 * 1000, value, }) return value } const roomMetadataToApi = ( tenant: { id: number, name?: string | null, short?: string | null }, room: typeof communicationRooms.$inferSelect ) => ({ id: room.id, tenantId: tenant.id, tenantName: tenant.name, key: room.key, name: room.name, topic: room.topic, type: room.type, entityType: room.entityType, entityId: room.entityId, entityUuid: room.entityUuid, alias: room.matrixAlias || tenantRoomAlias(tenant, room.key), exists: Boolean(room.matrixRoomId), roomId: room.matrixRoomId, parentSpaceRoomId: room.parentSpaceRoomId, servers: [], archived: room.archived, }) const findTenantRoomMetadata = async (tenantId: number, key: string) => { const [room] = await server.db .select() .from(communicationRooms) .where(and( eq(communicationRooms.tenantId, tenantId), eq(communicationRooms.key, key) )) .limit(1) return room } const ensureTenantRoomMetadata = async ( tenant: { id: number, name?: string | null, short?: string | null }, options: MatrixTenantRoomOptions ) => { const normalizedOptions = normalizeTenantRoomOptions(options) const existing = await findTenantRoomMetadata(tenant.id, normalizedOptions.key) if (existing) { const shouldUpdate = (options.name !== undefined && existing.name !== normalizedOptions.name) || (options.topic !== undefined && existing.topic !== normalizedOptions.topic) || (options.type !== undefined && existing.type !== normalizedOptions.type) || (options.entityType !== undefined && existing.entityType !== normalizedOptions.entityType) || (options.entityId !== undefined && existing.entityId !== normalizedOptions.entityId) || (options.entityUuid !== undefined && existing.entityUuid !== normalizedOptions.entityUuid) if (!shouldUpdate) return existing const [updated] = await server.db .update(communicationRooms) .set({ name: options.name !== undefined ? normalizedOptions.name : existing.name, topic: options.topic !== undefined ? normalizedOptions.topic : existing.topic, type: options.type !== undefined ? normalizedOptions.type : existing.type, entityType: options.entityType !== undefined ? normalizedOptions.entityType : existing.entityType, entityId: options.entityId !== undefined ? normalizedOptions.entityId : existing.entityId, entityUuid: options.entityUuid !== undefined ? normalizedOptions.entityUuid : existing.entityUuid, updatedAt: new Date(), }) .where(eq(communicationRooms.id, existing.id)) .returning() return updated } const [created] = await server.db .insert(communicationRooms) .values({ tenantId: tenant.id, key: normalizedOptions.key, name: normalizedOptions.name, topic: normalizedOptions.topic, type: normalizedOptions.type, entityType: normalizedOptions.entityType, entityId: normalizedOptions.entityId, entityUuid: normalizedOptions.entityUuid, matrixAlias: tenantRoomAlias(tenant, normalizedOptions.key), }) .returning() return created } const markTenantRoomProvisioned = async ( metadataId: string, values: { matrixRoomId: string matrixAlias: string parentSpaceRoomId?: string | null } ) => { const [updated] = await server.db .update(communicationRooms) .set({ matrixRoomId: values.matrixRoomId, matrixAlias: values.matrixAlias, parentSpaceRoomId: values.parentSpaceRoomId || null, updatedAt: new Date(), }) .where(eq(communicationRooms.id, metadataId)) .returning() return updated } const getTenantRoomStatus = async ( tenantId: number | null, roomKey: string, roomName?: string ) => { const tenant = await getCurrentTenant(tenantId) const normalizedOptions = normalizeTenantRoomOptions({ key: roomKey, name: roomName }) const metadata = await ensureTenantRoomMetadata(tenant, normalizedOptions) const alias = metadata.matrixAlias || tenantRoomAlias(tenant, normalizedOptions.key) try { const directoryEntry = await requestJson<{ room_id: string servers: string[] }>(`${homeserverUrl()}/_matrix/client/v3/directory/room/${encodeURIComponent(alias)}`) const roomMetadata = metadata.matrixRoomId === directoryEntry.room_id ? metadata : await markTenantRoomProvisioned(metadata.id, { matrixRoomId: directoryEntry.room_id, matrixAlias: alias, parentSpaceRoomId: metadata.parentSpaceRoomId, }) return { tenantId: tenant.id, tenantName: tenant.name, id: roomMetadata.id, key: roomMetadata.key, name: roomMetadata.name, topic: roomMetadata.topic, type: roomMetadata.type, entityType: roomMetadata.entityType, entityId: roomMetadata.entityId, entityUuid: roomMetadata.entityUuid, alias, exists: true, roomId: directoryEntry.room_id, parentSpaceRoomId: roomMetadata.parentSpaceRoomId, servers: directoryEntry.servers, } } catch (err: any) { if (err.statusCode === 404 || err.errcode === "M_NOT_FOUND") { return { tenantId: tenant.id, tenantName: tenant.name, id: metadata.id, key: metadata.key, name: metadata.name, topic: metadata.topic, type: metadata.type, entityType: metadata.entityType, entityId: metadata.entityId, entityUuid: metadata.entityUuid, alias, exists: false, roomId: metadata.matrixRoomId, parentSpaceRoomId: metadata.parentSpaceRoomId, servers: [], } } throw err } } const provisionTenantRoom = async ( userId: string, tenantId: number | null, options: MatrixTenantRoomOptions = {} ) => { const tenant = await getCurrentTenant(tenantId) const normalizedOptions = normalizeTenantRoomOptions(options) const metadata = await ensureTenantRoomMetadata(tenant, normalizedOptions) 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) if (cachedRoom?.exists && cachedRoom.cachedUntil > Date.now()) { return cachedRoom.value } const existing = await getTenantRoomStatus(tenant.id, key, name) const userAccount = await provisionCurrentUser(userId, tenant.id) const invitedMatrixUserIds = await matrixUserIdsForInvitees(userId, tenant.id, normalizedOptions.inviteUserIds || []) const tenantSpace = await provisionCurrentTenantSpace(userId, tenant.id) if (existing.exists) { await markTenantRoomProvisioned(metadata.id, { matrixRoomId: existing.roomId, matrixAlias: existing.alias, parentSpaceRoomId: existing.parentSpaceRoomId, }) const value = { ...existing, created: false, alreadyExisted: true, parentSpaceRoomId: tenantSpace.roomId, invitedUserId: userAccount.matrixUserId, } await inviteUsersToRoom(existing.roomId, invitedMatrixUserIds) matrixTenantRoomCache.set(cacheKey, { exists: true, cachedUntil: Date.now() + 30 * 60 * 1000, value, }) return value } const serviceLogin = await ensureServiceAccessToken() const createdRoom = await requestMatrixJson<{ room_id: string }>( "/_matrix/client/v3/createRoom", serviceLogin.accessToken, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, topic, preset: "private_chat", visibility: "private", room_alias_name: tenantRoomAliasLocalpart(tenant, key), invite: Array.from(new Set([userAccount.matrixUserId, ...invitedMatrixUserIds])), initial_state: [ { type: "m.room.history_visibility", state_key: "", content: { history_visibility: "invited", }, }, { type: "m.space.parent", state_key: tenantSpace.roomId, content: { via: [serverName()], canonical: true, }, }, ], }), } ) await requestMatrixJson( `/_matrix/client/v3/rooms/${encodeURIComponent(tenantSpace.roomId)}/state/m.space.child/${encodeURIComponent(createdRoom.room_id)}`, serviceLogin.accessToken, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ via: [serverName()], suggested: true, order: "10", }), } ) const value = { id: metadata.id, tenantId: tenant.id, tenantName: tenant.name, key, name, topic, type: metadata.type, entityType: metadata.entityType, entityId: metadata.entityId, entityUuid: metadata.entityUuid, alias: tenantRoomAlias(tenant, key), exists: true, created: true, alreadyExisted: false, roomId: createdRoom.room_id, parentSpaceRoomId: tenantSpace.roomId, invitedUserId: userAccount.matrixUserId, serviceUserId: serviceLogin.matrixUserId, } await markTenantRoomProvisioned(metadata.id, { matrixRoomId: value.roomId, matrixAlias: value.alias, parentSpaceRoomId: value.parentSpaceRoomId, }) matrixTenantRoomCache.set(cacheKey, { exists: true, cachedUntil: Date.now() + 30 * 60 * 1000, value, }) return value } const matrixUserIdsForInvitees = async ( currentUserId: string, tenantId: number, inviteUserIds: string[] ) => { const uniqueUserIds = Array.from(new Set(inviteUserIds.filter((id) => id && id !== currentUserId))) return await Promise.all(uniqueUserIds.map(async (inviteUserId) => { const account = await provisionCurrentUser(inviteUserId, tenantId) return account.matrixUserId })) } const inviteUsersToRoom = async (roomId: string | null, matrixUserIds: string[]) => { if (!roomId || !matrixUserIds.length) return const serviceLogin = await ensureServiceAccessToken() for (const matrixUserId of matrixUserIds) { try { await requestMatrixJson( `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/invite`, serviceLogin.accessToken, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ user_id: matrixUserId }), } ) } catch (err: any) { if (err.statusCode === 403 || err.statusCode === 400) continue throw err } } } const ensureServiceUserJoinedRoom = async (room: { roomId?: string | null; alias?: string | null }) => { const target = room.roomId || room.alias if (!target) return { ok: false, status: "missing_room" } const serviceLogin = await ensureServiceAccessToken() try { await requestMatrixJson( `/_synapse/admin/v1/join/${encodeURIComponent(target)}`, serviceLogin.accessToken, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ user_id: serviceLogin.matrixUserId }), } ) return { ok: true, status: "joined_admin", roomId: target } } catch (adminErr: any) { try { await requestMatrixJson( `/_matrix/client/v3/join/${encodeURIComponent(target)}`, serviceLogin.accessToken, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}), } ) return { ok: true, status: "joined_client", roomId: target } } catch (clientErr: any) { return { ok: false, status: "failed", roomId: target, error: clientErr.message || adminErr.message || "Service-Join fehlgeschlagen", } } } } const syncServiceJoinedTenantRooms = async () => { const rooms = await server.db .select({ id: communicationRooms.id, tenantId: communicationRooms.tenantId, key: communicationRooms.key, name: communicationRooms.name, matrixRoomId: communicationRooms.matrixRoomId, matrixAlias: communicationRooms.matrixAlias, }) .from(communicationRooms) .where(and( eq(communicationRooms.archived, false), isNotNull(communicationRooms.matrixRoomId) )) const results = [] for (const room of rooms) { const result = await ensureServiceUserJoinedRoom({ roomId: room.matrixRoomId, alias: room.matrixAlias, }) results.push({ roomId: room.matrixRoomId, roomKey: room.key, roomName: room.name, ...result, }) } return { total: results.length, joined: results.filter((result) => result.ok).length, failed: results.filter((result) => !result.ok).length, results, } } const ensureCurrentUserJoinedRoom = async ( userId: string, tenantId: number | null, room: { roomId: string, alias: string } ) => { const session = await createAccessTokenForUser(userId, tenantId) const joinCacheKey = `${session.matrixUserId}:${room.roomId || room.alias}` const joinedUntil = matrixJoinedRoomCache.get(joinCacheKey) if (joinedUntil && joinedUntil > Date.now()) { return session } await requestMatrixJson( `/_matrix/client/v3/join/${encodeURIComponent(room.roomId || room.alias)}`, session.accessToken, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}), } ) matrixJoinedRoomCache.set(joinCacheKey, Date.now() + 30 * 60 * 1000) return session } const listTenantRooms = async (tenantId: number | null) => { const tenant = await getCurrentTenant(tenantId) for (const room of defaultTenantRooms) { await ensureTenantRoomMetadata(tenant, room) } const rooms = await server.db .select() .from(communicationRooms) .where(and( eq(communicationRooms.tenantId, tenant.id), eq(communicationRooms.archived, false) )) return { tenantId: tenant.id, tenantName: tenant.name, rooms: rooms.map((room) => roomMetadataToApi(tenant, room)).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, 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 ) const members = await requestMatrixJson( `/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/joined_members`, session.accessToken ) const replacementByEventId = new Map() const reactionsByEventId = new Map>() for (const event of response.chunk) { const relation = event.content?.["m.relates_to"] if ( event.type === "m.room.message" && relation?.rel_type === "m.replace" && relation.event_id ) { replacementByEventId.set(relation.event_id, event) } if ( event.type === "m.reaction" && relation?.rel_type === "m.annotation" && relation.event_id && relation.key ) { const eventReactions = reactionsByEventId.get(relation.event_id) || new Map() const reaction = eventReactions.get(relation.key) || { key: relation.key, count: 0, own: false, } reaction.count += 1 reaction.own = reaction.own || event.sender === session.matrixUserId eventReactions.set(relation.key, reaction) reactionsByEventId.set(relation.event_id, eventReactions) } } const messages = response.chunk .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) => { const replacement = replacementByEventId.get(event.event_id) const content = replacement?.content?.["m.new_content"] || replacement?.content || event.content return { id: event.event_id, sender: event.sender, senderDisplayName: members.joined[event.sender]?.display_name || event.sender, body: content?.body || "", attachment: attachmentFromEvent({ ...event, content }), timestamp: replacement?.origin_server_ts || event.origin_server_ts, own: event.sender === session.matrixUserId, edited: Boolean(replacement), replyToEventId: event.content?.["m.relates_to"]?.["m.in_reply_to"]?.event_id || null, reactions: Array.from(reactionsByEventId.get(event.event_id)?.values() || []), } }) .reverse() return { roomId: room.roomId, alias: room.alias, key: room.key, name: room.name, matrixUserId: session.matrixUserId, messages, } } 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, alias: room.alias, }) const members = await requestMatrixJson( `/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/joined_members`, session.accessToken ) 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, avatarUrl: member.avatar_url || null, own: matrixUserId === session.matrixUserId, })), } } const searchTenantRoomMessages = async ( userId: string, tenantId: number | null, options: MatrixTenantRoomOptions = {}, query: string ) => { const searchTerm = query.trim() if (searchTerm.length < 2) { return { roomId: "", alias: "", key: options.key || "allgemein", name: options.name || options.key || "Chat", count: 0, results: [], } } const room = await provisionTenantRoom(userId, tenantId, options) const session = await ensureCurrentUserJoinedRoom(userId, tenantId, { roomId: room.roomId, alias: room.alias, }) const [response, members] = await Promise.all([ requestMatrixJson( "/_matrix/client/v3/search", session.accessToken, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ search_categories: { room_events: { search_term: searchTerm, keys: ["content.body"], order_by: "recent", filter: { limit: 25, rooms: [room.roomId], }, }, }, }), } ), requestMatrixJson( `/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/joined_members`, session.accessToken ), ]) const roomEvents = response.search_categories?.room_events const results = (roomEvents?.results || []) .map((item) => item.result) .filter((event): event is MatrixRoomEvent & { room_id?: string } => Boolean( event?.event_id && event.type === "m.room.message" && ["m.text", "m.file", "m.image"].includes(event.content?.msgtype || "") ) ) .map((event) => ({ id: event.event_id, roomId: event.room_id || room.roomId, key: room.key, 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, })) return { roomId: room.roomId, alias: room.alias, key: room.key, name: room.name, count: roomEvents?.count || results.length, results, } } 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 syncServiceRoomEvents = async (since?: string, initial = false) => { const service = await ensureServiceAccessToken() const filter = { room: { timeline: { limit: 50, }, }, 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()}`, service.accessToken ) const joinedRooms = response.rooms?.join || {} return { nextBatch: response.next_batch || since || "", serviceUserId: service.matrixUserId, rooms: Object.entries(joinedRooms).map(([roomId, joinedRoom]) => { const timelineEvents = joinedRoom.timeline?.events || [] const messages = initial ? [] : 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, roomId, sender: event.sender, senderDisplayName: event.sender, body: event.content?.body || "", attachment: attachmentFromEvent(event), timestamp: event.origin_server_ts, own: event.sender === service.matrixUserId, replyToEventId: event.content?.["m.relates_to"]?.["m.in_reply_to"]?.event_id || null, })) return { roomId, messages, membersChanged: [...timelineEvents, ...(joinedRoom.state?.events || [])] .some((event) => event.type === "m.room.member"), } }), } } const sendTenantRoomMessage = async ( userId: string, tenantId: number | null, options: MatrixTenantRoomOptions = {}, text: string, messageOptions: MatrixMessageOptions = {} ) => { const message = text.trim() if (!message) { throw Object.assign( new Error("Message text is required"), { 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 content: Record = { msgtype: "m.text", body: message, } if (messageOptions.replyToEventId) { content["m.relates_to"] = { "m.in_reply_to": { event_id: messageOptions.replyToEventId, }, } } 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(content), } ) return { id: response.event_id, sender: session.matrixUserId, senderDisplayName: await getCurrentUserDisplayName(userId, tenantId), body: message, timestamp: Date.now(), own: true, roomId: room.roomId, alias: room.alias, key: room.key, replyToEventId: messageOptions.replyToEventId || null, reactions: [], } } const sendTenantRoomReaction = async ( userId: string, tenantId: number | null, options: MatrixTenantRoomOptions = {}, eventId: string, key: string ) => { const reactionKey = key.trim() if (!eventId || !reactionKey) { throw Object.assign( new Error("Reaction target and key are required"), { 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<{ event_id: string }>( `/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/send/m.reaction/${encodeURIComponent(txnId)}`, session.accessToken, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ "m.relates_to": { rel_type: "m.annotation", event_id: 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 ( userId: string, tenantId: number | null, options: MatrixTenantRoomOptions = {}, eventId: string ) => { if (!eventId) return { success: true, skipped: true } const room = await provisionTenantRoom(userId, tenantId, options) const session = await ensureCurrentUserJoinedRoom(userId, tenantId, { roomId: room.roomId, alias: room.alias, }) await requestMatrixJson( `/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/receipt/m.read/${encodeURIComponent(eventId)}`, session.accessToken, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}), } ) return { success: true, eventId } } 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, options: MatrixTenantRoomOptions = {} ) => { const room = await provisionTenantRoom(userId, tenantId, options) const session = await ensureCurrentUserJoinedRoom(userId, tenantId, { roomId: room.roomId, alias: room.alias, }) const token = await requestMatrixJson( "/_matrix/client/v1/login/get_token", session.accessToken, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}), } ) return { roomId: room.roomId, alias: room.alias, key: room.key, name: room.name, matrixUserId: session.matrixUserId, loginToken: token.login_token, expiresInMs: token.expires_in_ms, homeserverUrl: homeserverUrl(), serverName: serverName(), } } const createLiveKitRoomSession = async ( userId: string, tenantId: number | null, options: MatrixTenantRoomOptions = {} ) => { if (!livekitKey() || !livekitSecret()) { throw Object.assign( new Error("LIVEKIT_KEY and LIVEKIT_SECRET are not configured"), { statusCode: 503 } ) } const room = await provisionTenantRoom(userId, tenantId, options) const session = await ensureCurrentUserJoinedRoom(userId, tenantId, { roomId: room.roomId, alias: room.alias, }) const displayName = await getCurrentUserDisplayName(userId, tenantId) const liveKitRoomName = `fedeo-${tenantId || "global"}-${room.key}`.replace(/[^a-zA-Z0-9_-]/g, "_") const now = Math.floor(Date.now() / 1000) const expiresInSeconds = 60 * 60 const video: LiveKitGrant = { roomJoin: true, room: liveKitRoomName, canPublish: true, canSubscribe: true, } const token = jwt.sign( { sub: session.matrixUserId, name: displayName, video, nbf: now - 10, exp: now + expiresInSeconds, }, livekitSecret(), { algorithm: "HS256", issuer: livekitKey(), } ) return { roomId: room.roomId, alias: room.alias, key: room.key, name: room.name, matrixUserId: session.matrixUserId, displayName, liveKitUrl: livekitUrl(), liveKitRoomName, liveKitToken: token, expiresInMs: expiresInSeconds * 1000, } } const listTenantCommunicationUsers = async (tenantId: number | null) => { const tenant = await getCurrentTenant(tenantId) const rows = await server.db .select({ userId: authTenantUsers.user_id, email: authUsers.email, profileActive: authProfiles.active, firstName: authProfiles.first_name, lastName: authProfiles.last_name, fullName: authProfiles.full_name, }) .from(authTenantUsers) .innerJoin(authUsers, eq(authUsers.id, authTenantUsers.user_id)) .leftJoin(authProfiles, and( eq(authProfiles.user_id, authTenantUsers.user_id), eq(authProfiles.tenant_id, tenant.id) )) .where(eq(authTenantUsers.tenant_id, tenant.id)) const users = rows .filter((row) => row.profileActive !== false) .map((row) => ({ userId: row.userId, email: row.email, displayName: row.fullName || `${row.firstName || ""} ${row.lastName || ""}`.trim() || row.email, })) return await Promise.all(users.map(async (user) => ({ ...user, matrixUserId: await matrixUserIdForUser(user.userId, tenant.id), }))) } const inviteMatrixUserToRoom = async ( room: { roomId: string }, matrixUserId: string, reason?: string ) => { const serviceLogin = await ensureServiceAccessToken() try { await requestMatrixJson( `/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/invite`, serviceLogin.accessToken, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ user_id: matrixUserId, reason, }), } ) return "invited" } catch (err: any) { if ( err.errcode === "M_FORBIDDEN" || err.errcode === "M_BAD_STATE" || err.errcode === "M_UNKNOWN" ) { return "already_available" } throw err } } const syncTenantRoomMembers = async ( requestingUserId: string, tenantId: number | null, options: MatrixTenantRoomOptions = {} ) => { const room = await provisionTenantRoom(requestingUserId, tenantId, options) const users = await listTenantCommunicationUsers(tenantId) const results = [] for (const user of users) { try { const account = await provisionCurrentUser(user.userId, tenantId) await inviteMatrixUserToRoom( { roomId: room.roomId }, account.matrixUserId, `FEDEO-Raumsynchronisation: ${room.name}` ) await ensureCurrentUserJoinedRoom(user.userId, tenantId, { roomId: room.roomId, alias: room.alias, }) results.push({ userId: user.userId, email: user.email, displayName: user.displayName, matrixUserId: account.matrixUserId, status: "joined", ok: true, }) } catch (err: any) { results.push({ userId: user.userId, email: user.email, displayName: user.displayName, status: "failed", ok: false, error: err.message || "Synchronisation fehlgeschlagen", }) } } return { roomId: room.roomId, alias: room.alias, key: room.key, name: room.name, total: results.length, joined: results.filter((item) => item.status === "joined").length, invited: results.filter((item) => item.status === "invited").length, alreadyAvailable: results.filter((item) => item.status === "already_available").length, failed: results.filter((item) => !item.ok).length, results, } } const inviteTenantRoomMember = async ( requestingUserId: string, tenantId: number | null, options: MatrixTenantRoomOptions = {}, targetUserId: string ) => { if (!targetUserId) { throw Object.assign( new Error("Benutzer ist erforderlich"), { statusCode: 400 } ) } const users = await listTenantCommunicationUsers(tenantId) const targetUser = users.find((user) => user.userId === targetUserId) if (!targetUser) { throw Object.assign( new Error("Benutzer gehört nicht zum aktiven Mandanten"), { statusCode: 404 } ) } const room = await provisionTenantRoom(requestingUserId, tenantId, options) const account = await provisionCurrentUser(targetUser.userId, tenantId) const inviteStatus = await inviteMatrixUserToRoom( { roomId: room.roomId }, account.matrixUserId, `FEDEO-Einladung: ${room.name}` ) await ensureCurrentUserJoinedRoom(targetUser.userId, tenantId, { roomId: room.roomId, alias: room.alias, }) return { roomId: room.roomId, alias: room.alias, key: room.key, name: room.name, userId: targetUser.userId, email: targetUser.email, displayName: targetUser.displayName, matrixUserId: account.matrixUserId, status: inviteStatus === "invited" ? "joined" : inviteStatus, ok: true, } } const removeTenantRoomMember = async ( requestingUserId: string, tenantId: number | null, options: MatrixTenantRoomOptions = {}, matrixUserId: string ) => { if (!matrixUserId) { throw Object.assign( new Error("Matrix-Benutzer ist erforderlich"), { statusCode: 400 } ) } const room = await provisionTenantRoom(requestingUserId, tenantId, options) const session = await createAccessTokenForUser(requestingUserId, tenantId) if (matrixUserId === session.matrixUserId) { throw Object.assign( new Error("Du kannst dich nicht selbst aus dem Raum entfernen"), { statusCode: 400 } ) } const serviceLogin = await ensureServiceAccessToken() await requestMatrixJson( `/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/kick`, serviceLogin.accessToken, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ user_id: matrixUserId, reason: "FEDEO-Mitgliederverwaltung", }), } ) matrixJoinedRoomCache.delete(`${matrixUserId}:${room.roomId}`) return { success: true, roomId: room.roomId, alias: room.alias, key: room.key, matrixUserId, } } 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, messageOptions: MatrixMessageOptions = {} ) => sendTenantRoomMessage( userId, tenantId, { key: "allgemein", name: "Allgemeiner Chat", }, text, messageOptions ) const sendGeneralRoomAttachment = (userId: string, tenantId: number | null, attachment: MatrixAttachmentInput) => sendTenantRoomAttachment( userId, tenantId, { key: "allgemein", name: "Allgemeiner Chat", }, attachment ) return { getStatus, matrixUserIdForUser, getCurrentUserDisplayName, provisionCurrentUser, getTenantSpaceStatus, provisionCurrentTenantSpace, listTenantRooms, listTenantCommunicationUsers, getTenantRoomStatus, provisionTenantRoom, createAccessTokenForUser, getTenantRoomMessages, getTenantRoomMembers, searchTenantRoomMessages, syncTenantRoomEvents, syncServiceRoomEvents, syncServiceJoinedTenantRooms, sendTenantRoomMessage, sendTenantRoomReaction, editTenantRoomMessage, redactTenantRoomMessage, markTenantRoomRead, sendTenantRoomAttachment, getMediaContent, createElementRoomSession, createLiveKitRoomSession, inviteTenantRoomMember, removeTenantRoomMember, syncTenantRoomMembers, getGeneralRoomMessages, getGeneralRoomMembers, sendGeneralRoomMessage, sendGeneralRoomAttachment, } }