import { createHash } from "node:crypto" import type { FastifyInstance } from "fastify" import { and, desc, eq, inArray, isNotNull, ne } from "drizzle-orm" import { authProfiles, authTenantUsers, authUsers, communicationRooms, notificationsItems } from "../../db/schema" import { matrixService } from "./matrix.service" import { NotificationService, UserDirectory } from "./notification.service" type ChatRecipient = { userId: string email?: string | null firstName?: string | null lastName?: string | null fullName?: string | null matrixUserId?: string } type MatrixPushWorkerEvent = { at: string type: string roomKey?: string roomId?: string | null messageId?: string sender?: string targets?: number created?: number delivered?: number failed?: number error?: string } const matrixPushWorkerState = { enabled: false, startedAt: null as string | null, lastRunAt: null as string | null, lastJoinAt: null as string | null, lastJoinTotal: 0, lastJoinJoined: 0, lastJoinFailed: 0, hasSyncToken: false, lastSyncRooms: 0, lastSyncMessages: 0, lastMatchedRooms: 0, lastNotificationsCreated: 0, lastNotificationsDelivered: 0, lastNotificationsFailed: 0, lastError: null as string | null, events: [] as MatrixPushWorkerEvent[], } const rememberWorkerEvent = (event: MatrixPushWorkerEvent) => { matrixPushWorkerState.events = [ { at: new Date().toISOString(), ...event, }, ...matrixPushWorkerState.events, ].slice(0, 25) } export const getMatrixPushWorkerState = () => ({ ...matrixPushWorkerState, events: [...matrixPushWorkerState.events], }) const getUserDirectory: UserDirectory = async (server: FastifyInstance, userId) => { const rows = await server.db .select({ email: authUsers.email }) .from(authUsers) .where(eq(authUsers.id, userId)) .limit(1) return rows[0] || null } const displayUserName = (user: { fullName?: string | null; firstName?: string | null; lastName?: string | null; email?: string | null }) => { const name = user.fullName || [user.firstName, user.lastName].filter(Boolean).join(" ") return name || user.email || "Benutzer" } const directRoomKey = (firstUserId: string, secondUserId: string) => { const hash = createHash("sha256") .update([firstUserId, secondUserId].sort().join(":")) .digest("hex") .slice(0, 16) return `direct_${hash}` } const mentionAliasesForUser = (user: ChatRecipient) => { const name = displayUserName(user) return Array.from(new Set([ name, user.fullName, [user.firstName, user.lastName].filter(Boolean).join(" "), user.firstName, user.email, ].filter(Boolean).map((value) => String(value).toLowerCase()))) } const mentionedRecipientIds = (text: string, recipients: ChatRecipient[]) => { const normalizedText = text.toLowerCase() return recipients .filter((recipient) => mentionAliasesForUser(recipient).some((alias) => normalizedText.includes(`@${alias}`) )) .map((recipient) => recipient.userId) } export function startMatrixPushWorker(server: FastifyInstance) { if (process.env.MATRIX_PUSH_WORKER_DISABLED === "1") { server.log.info("Matrix-Push-Worker ist deaktiviert") return } matrixPushWorkerState.enabled = true matrixPushWorkerState.startedAt = new Date().toISOString() rememberWorkerEvent({ at: new Date().toISOString(), type: "started" }) const matrix = matrixService(server) const notifications = new NotificationService(server, getUserDirectory) const intervalMs = Math.max(Number(process.env.MATRIX_PUSH_WORKER_INTERVAL_MS || 3000), 1000) let since: string | undefined let running = false let stopped = false let timer: ReturnType | undefined let lastServiceJoinSyncAt = 0 let errorBackoffMs = 0 const getTenantRecipients = async (tenantId: number) => { const rows = await server.db .select({ userId: authTenantUsers.user_id, email: authUsers.email, 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, tenantId) )) .where(eq(authTenantUsers.tenant_id, tenantId)) return await Promise.all(rows.map(async (row) => ({ ...row, matrixUserId: await matrix.matrixUserIdForUser(row.userId, tenantId), }))) } const hasChatNotificationForMessage = async (tenantId: number, userId: string, messageId: string) => { const rows = await server.db .select({ payload: notificationsItems.payload, }) .from(notificationsItems) .where(and( eq(notificationsItems.tenantId, tenantId), eq(notificationsItems.userId, userId), eq(notificationsItems.eventType, "communication.message.new") )) .orderBy(desc(notificationsItems.createdAt)) .limit(200) return rows.some((row) => (row.payload as any)?.messageId === messageId) } const recipientsForMessage = ( room: typeof communicationRooms.$inferSelect, recipients: ChatRecipient[], senderUserId: string | null, text: string ) => { const candidates = senderUserId ? recipients.filter((recipient) => recipient.userId !== senderUserId) : recipients const mentioned = new Set(mentionedRecipientIds(text, candidates)) const directRecipients = new Set() if (room.type === "direct" && room.entityUuid && room.entityUuid !== senderUserId) { directRecipients.add(room.entityUuid) } else if (room.type === "direct" && senderUserId) { candidates .filter((recipient) => directRoomKey(senderUserId, recipient.userId) === room.key) .forEach((recipient) => directRecipients.add(recipient.userId)) } return candidates .filter((recipient) => directRecipients.has(recipient.userId) || mentioned.has(recipient.userId)) .map((recipient) => ({ ...recipient, mentioned: mentioned.has(recipient.userId), direct: directRecipients.has(recipient.userId), })) } const deliverMessageNotification = async ( room: typeof communicationRooms.$inferSelect, message: any, recipients: ChatRecipient[] ) => { if (!message.id || message.own) return const sender = recipients.find((recipient) => recipient.matrixUserId === message.sender) || null const text = message.body || message.attachment?.fileName || "Neue Nachricht" const targets = recipientsForMessage(room, recipients, sender?.userId || null, text) rememberWorkerEvent({ at: new Date().toISOString(), type: "message_seen", roomKey: room.key, roomId: room.matrixRoomId, messageId: message.id, sender: message.sender, targets: targets.length, }) if (!targets.length) return const senderName = sender ? displayUserName(sender) : message.senderDisplayName || message.sender || "Matrix" const preview = text.length > 160 ? `${text.slice(0, 157)}...` : text for (const target of targets) { if (await hasChatNotificationForMessage(room.tenantId, target.userId, message.id)) { rememberWorkerEvent({ at: new Date().toISOString(), type: "notification_skipped_duplicate", roomKey: room.key, roomId: room.matrixRoomId, messageId: message.id, sender: message.sender, targets: 1, }) continue } const result = await notifications.trigger({ tenantId: room.tenantId, userId: target.userId, eventType: "communication.message.new", title: target.mentioned ? `${senderName} hat dich erwähnt` : `Neue Direktnachricht von ${senderName}`, message: preview, payload: { link: `/communication/chat?room=${encodeURIComponent(room.key)}`, roomKey: room.key, roomName: room.name, roomType: room.type, messageId: message.id, matrixSender: message.sender, mentioned: target.mentioned, direct: target.direct, }, channels: ["inapp", "push"], }) matrixPushWorkerState.lastNotificationsCreated += result.created || 0 matrixPushWorkerState.lastNotificationsDelivered += result.delivered || 0 matrixPushWorkerState.lastNotificationsFailed += result.failed || 0 rememberWorkerEvent({ at: new Date().toISOString(), type: "notification_triggered", roomKey: room.key, roomId: room.matrixRoomId, messageId: message.id, sender: message.sender, targets: 1, created: result.created || 0, delivered: result.delivered || 0, failed: result.failed || 0, }) } } const runOnce = async () => { if (running || stopped) return running = true try { matrixPushWorkerState.lastRunAt = new Date().toISOString() matrixPushWorkerState.lastError = null matrixPushWorkerState.lastSyncRooms = 0 matrixPushWorkerState.lastSyncMessages = 0 matrixPushWorkerState.lastMatchedRooms = 0 matrixPushWorkerState.lastNotificationsCreated = 0 matrixPushWorkerState.lastNotificationsDelivered = 0 matrixPushWorkerState.lastNotificationsFailed = 0 if (!lastServiceJoinSyncAt || Date.now() - lastServiceJoinSyncAt > 60_000) { const joinResult = await matrix.syncServiceJoinedTenantRooms() lastServiceJoinSyncAt = Date.now() matrixPushWorkerState.lastJoinAt = new Date().toISOString() matrixPushWorkerState.lastJoinTotal = joinResult.total matrixPushWorkerState.lastJoinJoined = joinResult.joined matrixPushWorkerState.lastJoinFailed = joinResult.failed rememberWorkerEvent({ at: new Date().toISOString(), type: "service_join_sync", targets: joinResult.total, delivered: joinResult.joined, failed: joinResult.failed, }) if (joinResult.failed) { console.warn("Matrix-Push-Worker: Service-User konnte nicht alle Räume joinen", { total: joinResult.total, joined: joinResult.joined, failed: joinResult.failed, }) } } const initial = !since const sync = await matrix.syncServiceRoomEvents(since, initial) since = sync.nextBatch || since matrixPushWorkerState.hasSyncToken = Boolean(since) matrixPushWorkerState.lastSyncRooms = sync.rooms?.length || 0 matrixPushWorkerState.lastSyncMessages = (sync.rooms || []) .reduce((sum: number, room: any) => sum + (room.messages?.length || 0), 0) if (!initial && sync.rooms?.length) { const roomIds = sync.rooms.map((room: any) => room.roomId).filter(Boolean) const rooms = roomIds.length ? await server.db .select() .from(communicationRooms) .where(and( inArray(communicationRooms.matrixRoomId, roomIds), ne(communicationRooms.archived, true), isNotNull(communicationRooms.matrixRoomId) )) : [] const roomsByMatrixId = new Map(rooms.map((room) => [room.matrixRoomId, room])) matrixPushWorkerState.lastMatchedRooms = rooms.length const recipientsByTenant = new Map() for (const syncedRoom of sync.rooms) { const room = roomsByMatrixId.get(syncedRoom.roomId) if (!room || !syncedRoom.messages?.length) continue if (!recipientsByTenant.has(room.tenantId)) { recipientsByTenant.set(room.tenantId, await getTenantRecipients(room.tenantId)) } const recipients = recipientsByTenant.get(room.tenantId) || [] for (const message of syncedRoom.messages) { await deliverMessageNotification(room, message, recipients) } } } errorBackoffMs = 0 } catch (err) { matrixPushWorkerState.lastError = err instanceof Error ? err.message : String(err) const retryAfterMs = Number((err as any)?.retryAfterMs || (err as any)?.body?.retry_after_ms || 0) errorBackoffMs = Math.min( Math.max(retryAfterMs || (errorBackoffMs ? errorBackoffMs * 2 : 30_000), 30_000), 5 * 60_000 ) rememberWorkerEvent({ at: new Date().toISOString(), type: "error", error: matrixPushWorkerState.lastError, }) console.error("Matrix-Push-Worker konnte Matrix-Events nicht verarbeiten", err) server.log.error({ err }, "Matrix-Push-Worker konnte Matrix-Events nicht verarbeiten") } finally { running = false if (!stopped) { const nextDelay = errorBackoffMs || (since ? 0 : intervalMs) timer = setTimeout(() => void runOnce(), nextDelay) } } } timer = setTimeout(() => void runOnce(), intervalMs) server.addHook("onClose", async () => { stopped = true if (timer) clearTimeout(timer) }) }