All Downloads are FREE. Search and download functionalities are using the official Maven repository.

commonMain.net.folivo.trixnity.client.notification.NotificationService.kt Maven / Gradle / Ivy

There is a newer version: 4.7.1
Show newest version
package net.folivo.trixnity.client.notification

import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.*
import net.folivo.trixnity.client.CurrentSyncState
import net.folivo.trixnity.client.notification.NotificationService.Notification
import net.folivo.trixnity.client.room.RoomService
import net.folivo.trixnity.client.store.*
import net.folivo.trixnity.clientserverapi.client.MatrixClientServerApiClient
import net.folivo.trixnity.clientserverapi.client.SyncState
import net.folivo.trixnity.core.ClientEventEmitter
import net.folivo.trixnity.core.UserInfo
import net.folivo.trixnity.core.model.events.*
import net.folivo.trixnity.core.model.events.ClientEvent.RoomEvent
import net.folivo.trixnity.core.model.events.ClientEvent.StrippedStateEvent
import net.folivo.trixnity.core.model.events.m.PushRulesEventContent
import net.folivo.trixnity.core.model.events.m.room.PowerLevelsEventContent
import net.folivo.trixnity.core.model.events.m.room.RoomMessageEventContent
import net.folivo.trixnity.core.model.push.PushAction
import net.folivo.trixnity.core.model.push.PushCondition
import net.folivo.trixnity.core.model.push.PushRule
import net.folivo.trixnity.core.subscribeAsFlow
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds


private val log = KotlinLogging.logger { }

interface NotificationService {
    data class Notification(
        val event: ClientEvent<*>,
        val actions: Set,
    )

    fun getNotifications(
        decryptionTimeout: Duration = 5.seconds,
        syncResponseBufferSize: Int = 4
    ): Flow
}

class NotificationServiceImpl(
    private val userInfo: UserInfo,
    private val api: MatrixClientServerApiClient,
    private val room: RoomService,
    private val roomStore: RoomStore,
    private val roomStateStore: RoomStateStore,
    private val roomUserStore: RoomUserStore,
    private val globalAccountDataStore: GlobalAccountDataStore,
    private val json: Json,
    private val currentSyncState: CurrentSyncState,
) : NotificationService {

    private val roomSizePattern = Regex("\\s*(==|<|>|<=|>=)\\s*([0-9]+)")

    @OptIn(ExperimentalCoroutinesApi::class)
    override fun getNotifications(
        decryptionTimeout: Duration,
        syncResponseBufferSize: Int,
    ): Flow = channelFlow {
        currentSyncState.first { it == SyncState.STARTED || it == SyncState.RUNNING }
        val syncResponseFlow =
            api.sync.subscribeAsFlow(ClientEventEmitter.Priority.AFTER_DEFAULT).map { it.syncResponse }

        val pushRules =
            globalAccountDataStore.get().map { event ->
                event?.content?.global?.let { globalRuleSet ->
                    log.trace { "global rule set: $globalRuleSet" }
                    globalRuleSet.override.orEmpty() +
                            globalRuleSet.content.orEmpty() +
                            globalRuleSet.room.orEmpty() +
                            globalRuleSet.sender.orEmpty() +
                            globalRuleSet.underride.orEmpty()
                } ?: listOf()
            }.stateIn(this)
        val inviteEventsFlow = syncResponseFlow
            .map { syncResponse ->
                syncResponse.room?.invite?.values?.flatMap { inviteRoom ->
                    inviteRoom.inviteState?.events.orEmpty()
                }?.asFlow()
            }.filterNotNull()
            .flattenConcat()

        val timelineEventsFlow =
            room.getTimelineEventsFromNowOn(decryptionTimeout, syncResponseBufferSize)
                .map { extractDecryptedEvent(it) }
                .filterNotNull()
                .filter {
                    it.sender != userInfo.userId
                }
        merge(inviteEventsFlow, timelineEventsFlow)
            .map {
                evaluatePushRules(
                    event = it,
                    allRules = pushRules.value
                )
            }.filterNotNull()
            .collect { send(it) }
    }.buffer(0)

    private fun extractDecryptedEvent(timelineEvent: TimelineEvent): RoomEvent<*>? {
        val originalEvent = timelineEvent.event
        val content = timelineEvent.content?.getOrNull()
        return when {
            timelineEvent.isEncrypted.not() -> originalEvent
            content == null -> null
            originalEvent is RoomEvent.MessageEvent<*> && content is MessageEventContent ->
                RoomEvent.MessageEvent(
                    content = content,
                    id = originalEvent.id,
                    sender = originalEvent.sender,
                    roomId = originalEvent.roomId,
                    originTimestamp = originalEvent.originTimestamp,
                    unsigned = originalEvent.unsigned
                )

            originalEvent is RoomEvent.StateEvent<*> && content is StateEventContent -> originalEvent
            else -> null
        }
    }

    @OptIn(ExperimentalSerializationApi::class)
    private suspend fun evaluatePushRules(
        event: ClientEvent<*>,
        allRules: List,
    ): Notification? {
        log.trace { "evaluate push rules for event: ${event.idOrNull}" }
        val eventJson = lazy {
            try {
                when (event) {
                    is RoomEvent -> json.serializersModule.getContextual(RoomEvent::class)?.let {
                        json.encodeToJsonElement(it, event)
                    }?.jsonObject

                    is StrippedStateEvent -> json.serializersModule.getContextual(StrippedStateEvent::class)?.let {
                        json.encodeToJsonElement(it, event)
                    }?.jsonObject

                    else -> throw IllegalStateException("event did have unexpected type ${event::class}")
                }
            } catch (exception: Exception) {
                log.warn(exception) { "could not serialize event" }
                null
            }
        }
        val rule = allRules
            .filter { it.enabled }
            .find { pushRule ->
                when (pushRule) {
                    is PushRule.Override -> pushRule.conditions.orEmpty()
                        .all { matchPushCondition(event, eventJson, it) }

                    is PushRule.Content -> bodyContainsPattern(event, pushRule.pattern)
                    is PushRule.Room -> pushRule.roomId == event.roomIdOrNull
                    is PushRule.Sender -> pushRule.userId == event.senderOrNull
                    is PushRule.Underride -> pushRule.conditions.orEmpty()
                        .all { matchPushCondition(event, eventJson, it) }
                }
            }
        log.trace { "event ${event.idOrNull}, found matching rule: ${rule?.ruleId}, actions: ${rule?.actions}" }
        return if (rule?.actions?.contains(PushAction.Notify) == true) {
            log.debug { "notify for event ${event.idOrNull} (type: ${event::class}, content type: ${event.content::class}) (PushRule is $rule)" }
            Notification(event, rule.actions)
        } else null
    }

    private suspend fun matchPushCondition(
        event: ClientEvent<*>,
        eventJson: Lazy,
        pushCondition: PushCondition
    ): Boolean {
        return when (pushCondition) {
            is PushCondition.ContainsDisplayName -> {
                val content = event.content
                if (content is RoomMessageEventContent) {
                    event.roomIdOrNull?.let { roomId ->
                        roomUserStore.get(userInfo.userId, roomId).first()?.name?.let { username ->
                            content.body.contains(username)
                        } ?: false
                    } ?: false
                } else false
            }

            is PushCondition.RoomMemberCount -> {
                event.roomIdOrNull?.let { roomId ->
                    pushCondition.isCount.checkIsCount(
                        roomStore.get(roomId).first()?.name?.summary?.joinedMemberCount ?: 0
                    )
                } ?: false
            }

            is PushCondition.SenderNotificationPermission -> {
                event.roomIdOrNull?.let { roomId ->
                    // at the moment, key can only be "room"
                    val powerLevels =
                        roomStateStore.getByStateKey(roomId, "").first()?.content
                    val requiredNotificationPowerLevel = powerLevels?.notifications?.room ?: 100
                    val senderPowerLevel = powerLevels?.users?.get(event.senderOrNull) ?: powerLevels?.usersDefault ?: 0
                    senderPowerLevel >= requiredNotificationPowerLevel
                } ?: false
            }

            is PushCondition.EventMatch -> {
                val propertyValue =
                    (getEventProperty(eventJson, pushCondition.key) as? JsonPrimitive)?.contentOrNull
                if (propertyValue == null) {
                    log.debug { "cannot get the event's value for key '${pushCondition.key}' or value is 'null'" }
                    false
                } else {
                    pushCondition.pattern.matrixGlobToRegExp().containsMatchIn(propertyValue)
                }
            }

            is PushCondition.EventPropertyIs -> {
                val propertyValue = getEventProperty(eventJson, pushCondition.key) as? JsonPrimitive
                if (propertyValue == null) {
                    log.debug { "cannot get the event's value for key '${pushCondition.key}' or value is 'null'" }
                    false
                } else {
                    propertyValue == pushCondition.value
                }
            }

            is PushCondition.EventPropertyContains -> {
                val propertyValue = getEventProperty(eventJson, pushCondition.key) as? JsonArray
                if (propertyValue == null) {
                    log.debug { "cannot get the event's value for key '${pushCondition.key}' or value is 'null'" }
                    false
                } else {
                    propertyValue.contains(pushCondition.value)
                }
            }

            is PushCondition.Unknown -> false
        }
    }

    private fun bodyContainsPattern(event: ClientEvent<*>, pattern: String): Boolean {
        val content = event.content
        return if (content is RoomMessageEventContent.TextBased.Text) {
            pattern.matrixGlobToRegExp().containsMatchIn(content.body)
        } else false
    }

    private val dotRegex = "(?,
        key: String
    ): JsonElement? {
        return try {
            var targetProperty: JsonElement? = initialEventJson.value
            key.split(dotRegex)
                .map { it.replace(removeEscapes, "$1") }
                .forEach { segment ->
                    targetProperty = (targetProperty as? JsonObject)?.get(segment)
                }
            targetProperty
        } catch (exc: Exception) {
            log.warn(exc) { "could not find event property for key $key in event ${initialEventJson.value}" }
            null
        }
    }

    private fun String.checkIsCount(size: Long): Boolean {
        this.toLongOrNull()?.let { count ->
            return size == count
        }
        val result = roomSizePattern.find(this)
        val bound = result?.groupValues?.getOrNull(2)?.toLongOrNull() ?: 0
        val operator = result?.groupValues?.getOrNull(1)
        log.debug { "room size ($size) $operator bound ($bound)" }
        return when (operator) {
            "==" -> size == bound
            "<" -> size < bound
            ">" -> size > bound
            "<=" -> size <= bound
            ">=" -> size >= bound
            else -> false
        }
    }

    private fun String.matrixGlobToRegExp(): Regex {
        return buildString {
            [email protected] { char ->
                when (char) {
                    '*' -> append(".*")
                    '?' -> append(".")
                    '.' -> append("""\.""")
                    '\\' -> append("""\\""")
                    else -> append(char)
                }
            }
        }.toRegex()
    }

}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy