2355 lines
80 KiB
TypeScript
2355 lines
80 KiB
TypeScript
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<string, {
|
|
display_name?: string
|
|
avatar_url?: string
|
|
}>
|
|
}
|
|
|
|
type MatrixRoomSearchResponse = {
|
|
search_categories?: {
|
|
room_events?: {
|
|
count?: number
|
|
results?: Array<{
|
|
result?: MatrixRoomEvent & {
|
|
room_id?: string
|
|
}
|
|
}>
|
|
}
|
|
}
|
|
}
|
|
|
|
type MatrixSyncResponse = {
|
|
next_batch?: string
|
|
rooms?: {
|
|
join?: Record<string, {
|
|
timeline?: {
|
|
events?: MatrixRoomEvent[]
|
|
}
|
|
state?: {
|
|
events?: MatrixRoomEvent[]
|
|
}
|
|
}>
|
|
}
|
|
}
|
|
|
|
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<T = any> = {
|
|
exists: true
|
|
cachedUntil: number
|
|
value: T
|
|
}
|
|
|
|
const matrixUserSessionCache = new Map<string, MatrixUserSession>()
|
|
const matrixJoinedRoomCache = new Map<string, number>()
|
|
const matrixProvisionedUserCache = new Map<string, number>()
|
|
const matrixTenantSpaceCache = new Map<string, MatrixCachedValue>()
|
|
const matrixTenantRoomCache = new Map<string, MatrixCachedValue>()
|
|
let matrixServiceSessionCache: MatrixUserSession | null = null
|
|
|
|
const defaultTenantRooms: Required<Pick<MatrixTenantRoomOptions, "key" | "name" | "type">>[] = [
|
|
{
|
|
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 <T>(url: string, init?: RequestInit): Promise<T> => {
|
|
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 <T>(path: string, accessToken: string, init?: RequestInit): Promise<T> => {
|
|
return requestJson<T>(`${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<MatrixJoinedMembersResponse>(
|
|
`/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/joined_members`,
|
|
session.accessToken
|
|
)
|
|
|
|
const replacementByEventId = new Map<string, MatrixRoomEvent>()
|
|
const reactionsByEventId = new Map<string, Map<string, { key: string; count: number; own: boolean }>>()
|
|
|
|
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<MatrixJoinedMembersResponse>(
|
|
`/_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<MatrixRoomSearchResponse>(
|
|
"/_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<MatrixJoinedMembersResponse>(
|
|
`/_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<MatrixSyncResponse>(
|
|
`/_matrix/client/v3/sync?${params.toString()}`,
|
|
session.accessToken
|
|
)
|
|
const joinedRoom = response.rooms?.join?.[room.roomId]
|
|
const timelineEvents = joinedRoom?.timeline?.events || []
|
|
const stateEvents = joinedRoom?.state?.events || []
|
|
|
|
if (initial) {
|
|
return {
|
|
roomId: room.roomId,
|
|
alias: room.alias,
|
|
key: room.key,
|
|
name: room.name,
|
|
nextBatch: response.next_batch || since || "",
|
|
messages: [],
|
|
replacements: [],
|
|
reactions: [],
|
|
redactions: [],
|
|
membersChanged: false,
|
|
}
|
|
}
|
|
|
|
const messages = timelineEvents
|
|
.filter((event) =>
|
|
event.type === "m.room.message" &&
|
|
["m.text", "m.file", "m.image"].includes(event.content?.msgtype || "") &&
|
|
event.content?.["m.relates_to"]?.rel_type !== "m.replace"
|
|
)
|
|
.map((event) => ({
|
|
id: event.event_id,
|
|
sender: event.sender,
|
|
senderDisplayName: event.sender,
|
|
body: event.content?.body || "",
|
|
attachment: attachmentFromEvent(event),
|
|
timestamp: event.origin_server_ts,
|
|
own: event.sender === session.matrixUserId,
|
|
replyToEventId: event.content?.["m.relates_to"]?.["m.in_reply_to"]?.event_id || null,
|
|
reactions: [],
|
|
}))
|
|
|
|
const replacements = timelineEvents
|
|
.filter((event) =>
|
|
event.type === "m.room.message" &&
|
|
event.content?.["m.relates_to"]?.rel_type === "m.replace" &&
|
|
Boolean(event.content?.["m.relates_to"]?.event_id)
|
|
)
|
|
.map((event) => ({
|
|
id: event.event_id,
|
|
targetEventId: event.content?.["m.relates_to"]?.event_id,
|
|
body: event.content?.["m.new_content"]?.body || event.content?.body || "",
|
|
timestamp: event.origin_server_ts,
|
|
sender: event.sender,
|
|
own: event.sender === session.matrixUserId,
|
|
}))
|
|
|
|
const reactions = timelineEvents
|
|
.filter((event) =>
|
|
event.type === "m.reaction" &&
|
|
event.content?.["m.relates_to"]?.rel_type === "m.annotation" &&
|
|
Boolean(event.content?.["m.relates_to"]?.event_id) &&
|
|
Boolean(event.content?.["m.relates_to"]?.key)
|
|
)
|
|
.map((event) => ({
|
|
id: event.event_id,
|
|
targetEventId: event.content?.["m.relates_to"]?.event_id,
|
|
key: event.content?.["m.relates_to"]?.key,
|
|
sender: event.sender,
|
|
own: event.sender === session.matrixUserId,
|
|
}))
|
|
|
|
const redactions = timelineEvents
|
|
.filter((event) => event.type === "m.room.redaction" && Boolean(event.redacts))
|
|
.map((event) => ({
|
|
id: event.event_id,
|
|
targetEventId: event.redacts,
|
|
sender: event.sender,
|
|
timestamp: event.origin_server_ts,
|
|
}))
|
|
|
|
return {
|
|
roomId: room.roomId,
|
|
alias: room.alias,
|
|
key: room.key,
|
|
name: room.name,
|
|
nextBatch: response.next_batch || since || "",
|
|
messages,
|
|
replacements,
|
|
reactions,
|
|
redactions,
|
|
membersChanged: [...timelineEvents, ...stateEvents].some((event) => event.type === "m.room.member"),
|
|
}
|
|
}
|
|
|
|
const 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<MatrixSyncResponse>(
|
|
`/_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<string, any> = {
|
|
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<MatrixLoginTokenResponse>(
|
|
"/_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,
|
|
}
|
|
}
|