diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 00000000..524b0f36 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,54 @@ +name: Docker Build and Publish +on: + push: + branches: + - master + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Validate Gradle wrapper + uses: gradle/wrapper-validation-action@v1 + + - name: Persist Gradle cache + uses: actions/cache@v3 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Setup JDK 18 + uses: actions/setup-java@v3 + with: + distribution: temurin + java-version: 18 + + - name: Build + run: ./gradlew build -x check -x test --no-daemon + + - name: Upload build artifacts + uses: actions/upload-artifact@v3 + with: + name: Artifacts + path: | + build/libs/ + module/**/build/libs/ + + - name: Login to GitHub Container Registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - run: docker build -t ghcr.io/the-proxyfox-group/proxyfox:latest -t ghcr.io/the-proxyfox-group/proxyfox:${GITHUB_SHA} . + - run: docker push ghcr.io/the-proxyfox-group/proxyfox:latest + - run: docker push ghcr.io/the-proxyfox-group/proxyfox:${GITHUB_SHA} diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 5fd1b70e..a2f66091 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -1,20 +1,25 @@ name: Build -on: [pull_request, push] +on: [ pull_request, push ] jobs: build: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest + services: mongo: image: mongo ports: - 27017:27017 + steps: - - name: checkout repository + - name: Checkout repository uses: actions/checkout@v3 - - name: validate gradle wrapper + + - name: Validate Gradle wrapper uses: gradle/wrapper-validation-action@v1 - - uses: actions/cache@v3 + + - name: Persist Gradle cache + uses: actions/cache@v3 with: path: | ~/.gradle/caches @@ -22,21 +27,25 @@ jobs: key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} restore-keys: | ${{ runner.os }}-gradle- - - name: setup jdk 17 + + - name: Setup JDK 18 uses: actions/setup-java@v3 with: distribution: temurin - java-version: 17 - - name: build + java-version: 18 + + - name: Build run: ./gradlew build --no-daemon - - name: capture build artifacts + + - name: Upload build artifacts uses: actions/upload-artifact@v3 with: name: Artifacts path: | build/libs/ modules/**/build/libs/ - - name: capture test reports on failure + + - name: Capture test reports on failure uses: actions/upload-artifact@v3 if: failure() with: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index dd11f65a..b50de4ad 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -8,10 +8,16 @@ on: jobs: build: runs-on: ubuntu-latest + steps: - - uses: actions/checkout@v3 - - uses: gradle/wrapper-validation-action@v1 - - uses: actions/cache@v3 + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Validate Gradle wrapper + uses: gradle/wrapper-validation-action@v1 + + - name: Persist Gradle cache + uses: actions/cache@v3 with: path: | ~/.gradle/caches @@ -19,14 +25,17 @@ jobs: key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} restore-keys: | ${{ runner.os }}-gradle- + - uses: actions/setup-java@v3 with: distribution: temurin - java-version: 17 + java-version: 18 + - name: Build and publish with Gradle run: ./gradlew poolRelease --no-daemon + - name: Upload build artifacts uses: AButler/upload-release-assets@v2.0 with: files: 'build/pool/*' - repo-token: ${{secrets.GITHUB_TOKEN}} \ No newline at end of file + repo-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 7555e099..f961aa70 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,7 @@ build/ out/ mods/ proxyfox.db.properties -systems.json -.env \ No newline at end of file +.env +.pf-command-lock +.quilt/ +config/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..a5d6fa48 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM openjdk:17-jdk-slim + +COPY modules/bot/build/libs/proxyfox-*.jar /usr/local/lib/ProxyFox.jar + +RUN mkdir /bot + +WORKDIR /bot + +ENTRYPOINT ["java", "-Xms2G", "-Xmx2G", "-jar", "/usr/local/lib/ProxyFox.jar"] diff --git a/build.gradle.kts b/build.gradle.kts index 815f23d3..63e73cf4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -43,18 +43,18 @@ allprojects { } java { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 + sourceCompatibility = JavaVersion.VERSION_18 + targetCompatibility = JavaVersion.VERSION_18 } repositories { mavenCentral() - maven("https://libraries.minecraft.net/") maven("https://oss.sonatype.org/content/repositories/snapshots") maven("https://maven.quiltmc.org/repository/release/") maven("https://maven.quiltmc.org/repository/snapshot/") maven("https://maven.fabricmc.net/") maven("https://jitpack.io") + maven("https://maven.proxyfox.dev") } dependencies { diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..eda015aa --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,35 @@ +version: "3" + +networks: + proxyfox: + external: false + +services: + proxyfox: + image: ghcr.io/the-proxyfox-group/proxyfox:latest + container_name: proxyfox + restart: always + environment: + - PROXYFOX_KEY= + - PROXYFOX_MONGO=mongodb://:@mongo + networks: + - proxyfox + depends_on: + - mongo + + mongo: + image: mongo:latest + container_name: proxyfox_mongo + restart: always + environment: + - MONGO_INITDB_ROOT_USERNAME= + - MONGO_INITDB_ROOT_PASSWORD= + networks: + - proxyfox + volumes: + - proxyfox-mongo:/data/db + ports: + - 27017:27017 + +volumes: + proxyfox-mongo: diff --git a/gradle.properties b/gradle.properties index 271619ba..87e6d36b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ kotlin.code.style=official group=dev.proxyfox -version=2.0.13 +version=2.1.0 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6c948cf5..5e119cac 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,15 +1,23 @@ [versions] -guava = "31.1-jre" -logback = "1.2.11" -kord = "0.8.0-M16" -kotlin = "1.7.10" -kotlinx_coroutines = "1.6.4" +guava = "32.1.1-jre" +logback = "1.4.8" +kord = "0.10.0" +kotlin = "1.9.0" +kotlinx_coroutines = "1.7.2" -# Database-specific +# ProxyFox Libraries +proxyfox_command = "2.0" +pluralkt = "1.8" +markt = "1.4" + +# Database postgres = "42.3.3" kjdbc = "0.5.2" -gson = "2.9.0" kmongo = "4.6.0" +kotlinx_datetime = "0.4.0" + +# API +ktor = "2.1.3" # Testing testng = "7.6.1" @@ -17,33 +25,44 @@ mockk = "1.+" # Plugins shadow = "7.1.2" -licenser = "1.1.2" +licenser = "1.2.0" [libraries] guava = { module = "com.google.guava:guava", version.ref = "guava" } kord = { module = "dev.kord:kord-core", version.ref = "kord" } logback = { module = "ch.qos.logback:logback-classic", version.ref = "logback" } +proxyfox_command = { module = "dev.proxyfox:proxyfox-command", version.ref = "proxyfox_command" } +pluralkt = { module = "dev.proxyfox:pluralkt", version.ref = "pluralkt" } +markt = { module = "dev.proxyfox:MarKt", version.ref = "markt" } + kotlin_stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } kotlinx_coroutines_core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx_coroutines" } -gson = { module = "com.google.code.gson:gson", version.ref = "gson" } kjdbc = { module = "com.vladsch.kotlin-jdbc:kotlin-jdbc", version.ref = "kjdbc" } postgres = { module = "org.postgresql:postgresql", version.ref = "postgres" } -kmongo_base = { module = "org.litote.kmongo:kmongo", version.ref = "kmongo" } -kmongo_coroutine = { module = "org.litote.kmongo:kmongo-coroutine", version.ref = "kmongo" } -kmongo_async = { module = "org.litote.kmongo:kmongo-async", version.ref = "kmongo" } +kmongo_base = { module = "org.litote.kmongo:kmongo-serialization", version.ref = "kmongo" } +kmongo_coroutine = { module = "org.litote.kmongo:kmongo-coroutine-serialization", version.ref = "kmongo" } +kmongo_async = { module = "org.litote.kmongo:kmongo-async-serialization", version.ref = "kmongo" } +kotlinx_datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx_datetime" } + +ktor_server = { module = "io.ktor:ktor-server-core", version.ref = "ktor" } +ktor_server_netty = { module = "io.ktor:ktor-server-netty", version.ref = "ktor" } +ktor_content_negotiation = { module = "io.ktor:ktor-server-content-negotiation", version.ref = "ktor" } +ktor_serialization = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } testng = { module = "org.testng:testng", version.ref = "testng" } mockk = { module = "io.mockk:mockk", version.ref = "mockk" } kotlinx_coroutines_test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx_coroutines" } [bundles] -base = ["guava", "logback", "kotlin_stdlib", "kotlinx_coroutines_core", "kord"] +base = ["guava", "logback", "kotlin_stdlib", "kotlinx_coroutines_core", "kord", "proxyfox_command", "pluralkt", "markt"] +database = ["kmongo_base", "kmongo_coroutine", "kmongo_async", "kotlinx_datetime"] +api = ["ktor_server", "ktor_server_netty", "ktor_content_negotiation", "ktor_serialization"] test = ["testng", "kotlinx_coroutines_test", "mockk"] -database = ["gson", "kmongo_base", "kmongo_coroutine", "kmongo_async"] [plugins] kotlin_jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } shadow = { id = "com.github.johnrengelman.shadow", version.ref = "shadow" } -licenser = { id = "org.quiltmc.gradle.licenser", version.ref = "licenser" } \ No newline at end of file +licenser = { id = "org.quiltmc.gradle.licenser", version.ref = "licenser" } +serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } \ No newline at end of file diff --git a/modules/api/server/src/main/kotlin/dev/proxyfox/api/server/ServerMain.kt b/modules/api/build.gradle.kts similarity index 52% rename from modules/api/server/src/main/kotlin/dev/proxyfox/api/server/ServerMain.kt rename to modules/api/build.gradle.kts index 4b23e0a5..e1d3265b 100644 --- a/modules/api/server/src/main/kotlin/dev/proxyfox/api/server/ServerMain.kt +++ b/modules/api/build.gradle.kts @@ -6,12 +6,14 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -package dev.proxyfox.api.server +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.shadow) + alias(libs.plugins.serialization) +} -fun main() = ServerMain.main() - -object ServerMain { - fun main() { - - } +dependencies { + api(project(":modules:common")) + api(project(":modules:database")) + api(libs.bundles.api) } \ No newline at end of file diff --git a/modules/api/src/main/kotlin/dev/proxyfox/api/ApiMain.kt b/modules/api/src/main/kotlin/dev/proxyfox/api/ApiMain.kt new file mode 100644 index 00000000..527fb3d0 --- /dev/null +++ b/modules/api/src/main/kotlin/dev/proxyfox/api/ApiMain.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2022-2023, The ProxyFox Group + * + * This Source Code is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.proxyfox.api + +import dev.proxyfox.api.routes.* +import io.ktor.serialization.kotlinx.json.* +import io.ktor.server.application.* +import io.ktor.server.engine.* +import io.ktor.server.netty.* +import io.ktor.server.plugins.contentnegotiation.* +import io.ktor.server.routing.* + +object ApiMain { + fun main() = embeddedServer(Netty, port = System.getenv("PORT")?.toIntOrNull() ?: 8080) { + configureRouting() + configurePlugins() + }.start() + + private fun Application.configurePlugins() { + install(ContentNegotiation) { + json() + } + } + + private fun Application.configureRouting() { + routing { + route("/v1") { + systemRoutes() + memberRoutes() + switchRoutes() + messageRoutes() + tokenRoutes() + coffeeRoutes() + } + } + } +} diff --git a/modules/api/src/main/kotlin/dev/proxyfox/api/Authentication.kt b/modules/api/src/main/kotlin/dev/proxyfox/api/Authentication.kt new file mode 100644 index 00000000..c1cc3e5f --- /dev/null +++ b/modules/api/src/main/kotlin/dev/proxyfox/api/Authentication.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2022-2023, The ProxyFox Group + * + * This Source Code is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.proxyfox.api + +import dev.proxyfox.api.models.ApiError +import dev.proxyfox.database.database +import dev.proxyfox.database.records.misc.TokenType +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import io.ktor.util.* +import io.ktor.util.pipeline.* + +@Suppress("FunctionName") +fun ApiPlugin(name: String, accessFunction: TokenType.() -> Boolean) = createRouteScopedPlugin(name = name) { + onCall { call -> + val tokenString = call.request.headers["Authorization"] + ?: return@onCall call.respond(ApiError(401, "Unauthorized")) + val token = database.fetchToken(tokenString) ?: return@onCall call.respond(ApiError(401, "Unauthorized")) + if (token.type.accessFunction()) return@onCall call.respond(ApiError(401, "Unauthorized")) + } +} + +val AccessPlugin = ApiPlugin("AccessPlugin", TokenType::canViewApi) + +val EditPlugin = ApiPlugin("EditPlugin", TokenType::canEditApi) + +@KtorDsl +fun Route.getAccess(body: PipelineInterceptor) { + install(AccessPlugin) + get(body) +} + +@KtorDsl +fun Route.getAccess(path: String, body: PipelineInterceptor) { + route(path, HttpMethod.Get) { + install(AccessPlugin) + handle(body) + } +} diff --git a/modules/bot/src/main/kotlin/dev/proxyfox/bot/string/node/NodeType.kt b/modules/api/src/main/kotlin/dev/proxyfox/api/models/ApiError.kt similarity index 51% rename from modules/bot/src/main/kotlin/dev/proxyfox/bot/string/node/NodeType.kt rename to modules/api/src/main/kotlin/dev/proxyfox/api/models/ApiError.kt index 020b9e65..e9e0bcd4 100644 --- a/modules/bot/src/main/kotlin/dev/proxyfox/bot/string/node/NodeType.kt +++ b/modules/api/src/main/kotlin/dev/proxyfox/api/models/ApiError.kt @@ -1,15 +1,17 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -package dev.proxyfox.bot.string.node +package dev.proxyfox.api.models -enum class NodeType { - LITERAL, - VARIABLE, - GREEDY -} \ No newline at end of file +import kotlinx.serialization.Serializable + +@Serializable +data class ApiError( + val code: Int, + val message: String +) diff --git a/modules/api/src/main/kotlin/dev/proxyfox/api/models/Coffee.kt b/modules/api/src/main/kotlin/dev/proxyfox/api/models/Coffee.kt new file mode 100644 index 00000000..c1ac4fb8 --- /dev/null +++ b/modules/api/src/main/kotlin/dev/proxyfox/api/models/Coffee.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2022-2023, The ProxyFox Group + * + * This Source Code is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.proxyfox.api.models + +import kotlinx.serialization.Serializable + +@Serializable +data class Coffee( + val id: String, + var state: BrewState, +) + +@Serializable +enum class BrewState { + STARTING, + BREWING, + READY; + + fun advance(): BrewState = when (this) { + STARTING -> BREWING + BREWING -> READY + READY -> READY + } +} \ No newline at end of file diff --git a/modules/api/src/main/kotlin/dev/proxyfox/api/models/Member.kt b/modules/api/src/main/kotlin/dev/proxyfox/api/models/Member.kt new file mode 100644 index 00000000..986f7dc4 --- /dev/null +++ b/modules/api/src/main/kotlin/dev/proxyfox/api/models/Member.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2022-2023, The ProxyFox Group + * + * This Source Code is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.proxyfox.api.models + +import dev.proxyfox.common.fromColor +import dev.proxyfox.database.PkId +import dev.proxyfox.database.database +import dev.proxyfox.database.etc.ktx.serializaton.InstantLongMillisecondSerializer +import dev.proxyfox.database.etc.ktx.serializaton.LocalDateLongMillisecondSerializer +import dev.proxyfox.database.records.member.MemberRecord +import kotlinx.coroutines.runBlocking +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Represents a member. + * + * Accessed with the `/system/{sysid}/members` or + * `/system/{sysid}/members/{memid}` routes. + * + * Requires a token to access. + * + * @param id the Pk-compatible ID of the member + * @param name the name of the member + * @param displayName the display name of the member + * @param description the description of the member + * @param color the color of the member (in a hexadecimal RGB format) + * @param avatarUrl the URL for the member's avatar + * @param keepProxy whether the member keeps their proxy tags in proxied messages + * @param autoProxy whether autoproxy is enabled for this member + * @param messageCount the amount of messages this member has sent + * @param created the timestamp of the member creation + * @param birthday the member's birthday + * @param age the age of the member + * @param role the role of the member + * @param proxyTags the member's proxy tags + * */ +@Serializable +data class Member( + val id: PkId, + val name: String, + @SerialName("display_name") + val displayName: String?, + val description: String?, + val pronouns: String?, + val color: String?, + @SerialName("avatar_url") + val avatarUrl: String?, + @SerialName("keep_proxy") + val keepProxy: Boolean, + @SerialName("auto_proxy") + val autoProxy: Boolean, + @SerialName("message_count") + val messageCount: ULong, + val created: Instant, + val birthday: LocalDate?, + val age: String?, + val role: String?, + @SerialName("proxy_tags") + val proxyTags : List +) { + companion object { + fun fromRecord(member: MemberRecord) = Member( + id = member.id, + name = member.name, + displayName = member.displayName, + description = member.description, + pronouns = member.pronouns, + color = member.color.fromColor(), + avatarUrl = member.avatarUrl, + keepProxy = member.keepProxy, + autoProxy = member.autoProxy, + messageCount = member.messageCount, + created = member.timestamp, + birthday = member.birthday, + age = member.age, + role = member.role, + proxyTags = runBlocking { database.fetchProxiesFromSystemAndMember(member.systemId, member.id)?.map(ProxyTag::fromRecord) ?: emptyList() } + ) + } +} \ No newline at end of file diff --git a/modules/api/src/main/kotlin/dev/proxyfox/api/models/MemberGuildSettings.kt b/modules/api/src/main/kotlin/dev/proxyfox/api/models/MemberGuildSettings.kt new file mode 100644 index 00000000..7293a1a6 --- /dev/null +++ b/modules/api/src/main/kotlin/dev/proxyfox/api/models/MemberGuildSettings.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2022-2023, The ProxyFox Group + * + * This Source Code is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.proxyfox.api.models + +import dev.proxyfox.database.records.member.MemberServerSettingsRecord +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Represents a member's guild settings. + * + * Accessed with the `/systems/{sysid}/members/{memid}/guilds/{guildid}` route. + * + * Requires a token to access. + * + * @param displayName the display name of this member for this guild + * @param avatarUrl the url for the avatar of this member for this guild + * @param autoProxy whether autoproxy is enabled for this member for this guild + * @param proxyEnabled whether proxying is enabled for this member for this guild + * */ +@Serializable +data class MemberGuildSettings( + @SerialName("display_name") + val displayName: String?, + @SerialName("avatar_url") + val avatarUrl: String?, + @SerialName("auto_proxy") + val autoProxy: Boolean, + @SerialName("proxy_enabled") + val proxyEnabled: Boolean +) { + companion object { + fun fromRecord(record: MemberServerSettingsRecord) = MemberGuildSettings( + displayName = record.nickname, + avatarUrl = record.avatarUrl, + autoProxy = record.autoProxy, + proxyEnabled = record.proxyEnabled + ) + } +} diff --git a/modules/api/src/main/kotlin/dev/proxyfox/api/models/Message.kt b/modules/api/src/main/kotlin/dev/proxyfox/api/models/Message.kt new file mode 100644 index 00000000..281eb68e --- /dev/null +++ b/modules/api/src/main/kotlin/dev/proxyfox/api/models/Message.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2022-2023, The ProxyFox Group + * + * This Source Code is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.proxyfox.api.models + +import dev.kord.common.entity.Snowflake +import dev.proxyfox.common.snowflake +import dev.proxyfox.database.PkId +import dev.proxyfox.database.etc.ktx.serializaton.InstantLongMillisecondSerializer +import dev.proxyfox.database.records.misc.ProxiedMessageRecord +import kotlinx.datetime.Instant +import kotlinx.serialization.Serializable + +/** + * Represents a proxied message. + * + * Accessed in the `/messages/{id}` route. + * + * Doesn't require a token. + * + * @param timestamp the time of creation + * @param sender the Discord account ID of the author + * @param original the message ID of the original message + * @param proxied the message ID of the new (proxied) message + * @param channel the ID of the channel the message was sent in + * @param guild the ID of the guild the message was sent in + * @param thread the ID of the thread the message was sent in, if applicable + * @param system the Pk-formatted ID of the system that created the message + * @param member the Pk-formatted ID of the member that created the message + * */ +@Serializable +data class Message( + val timestamp: Instant, + val sender: Snowflake, + val original: Snowflake, + val proxied: Snowflake, + val channel: Snowflake, + val guild: Snowflake, + val thread: Snowflake?, + val system: PkId, + val member: PkId +) { + companion object { + fun fromRecord(record: ProxiedMessageRecord) = Message( + timestamp = record.creationDate, + sender = record.userId.snowflake, + original = record.oldMessageId.snowflake, + proxied = record.newMessageId.snowflake, + channel = record.channelId.snowflake, + guild = record.guildId.snowflake, + thread = record.threadId?.snowflake, + system = record.systemId, + member = record.memberId + ) + } +} \ No newline at end of file diff --git a/modules/api/src/main/kotlin/dev/proxyfox/api/models/ProxyTag.kt b/modules/api/src/main/kotlin/dev/proxyfox/api/models/ProxyTag.kt new file mode 100644 index 00000000..f3dbd71a --- /dev/null +++ b/modules/api/src/main/kotlin/dev/proxyfox/api/models/ProxyTag.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2022-2023, The ProxyFox Group + * + * This Source Code is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.proxyfox.api.models + +import dev.proxyfox.database.records.member.MemberProxyTagRecord +import kotlinx.serialization.Serializable + +/** + * Represents a proxy tag. + * + * @param prefix the prefix for the proxy + * @param suffix the suffix for the proxy + * */ +@Serializable +data class ProxyTag(val prefix: String?, val suffix: String?) { + companion object { + fun fromRecord(record: MemberProxyTagRecord) = ProxyTag(record.prefix, record.suffix) + } +} \ No newline at end of file diff --git a/modules/api/src/main/kotlin/dev/proxyfox/api/models/Switch.kt b/modules/api/src/main/kotlin/dev/proxyfox/api/models/Switch.kt new file mode 100644 index 00000000..463464f1 --- /dev/null +++ b/modules/api/src/main/kotlin/dev/proxyfox/api/models/Switch.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2022-2023, The ProxyFox Group + * + * This Source Code is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.proxyfox.api.models + +import dev.proxyfox.database.PkId +import dev.proxyfox.database.etc.ktx.serializaton.InstantLongMillisecondSerializer +import dev.proxyfox.database.records.system.SystemSwitchRecord +import kotlinx.datetime.Instant +import kotlinx.serialization.Serializable + +/** + * Represents a switch. + * + * Accessed via the `/systems/{sysid}/switches` or `/system/{sysid}/fronters` routes. + * + * Requires a token to access. + * + * @param id the Pk-formatted ID of the switch + * @param members the Pk-formatted IDs of the members in this switch + * @param timestamp the timestamp of switch creation + * */ +@Serializable +data class Switch( + val id: PkId, + val members: List, + val timestamp: Instant +) { + companion object { + fun fromRecord(record: SystemSwitchRecord) = Switch( + id = record.id, + members = record.memberIds, + timestamp = record.timestamp + ) + } +} diff --git a/modules/api/src/main/kotlin/dev/proxyfox/api/models/System.kt b/modules/api/src/main/kotlin/dev/proxyfox/api/models/System.kt new file mode 100644 index 00000000..7abd5ace --- /dev/null +++ b/modules/api/src/main/kotlin/dev/proxyfox/api/models/System.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2022-2023, The ProxyFox Group + * + * This Source Code is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.proxyfox.api.models + +import dev.proxyfox.common.fromColor +import dev.proxyfox.database.PkId +import dev.proxyfox.database.etc.ktx.serializaton.InstantLongMillisecondSerializer +import dev.proxyfox.database.records.misc.AutoProxyMode +import dev.proxyfox.database.records.system.SystemRecord +import kotlinx.datetime.Instant +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Represents a system. + * + * Accessed via the `/systems/{sysid}` route. + * + * Requires a token to access. + * + * @param id the Pk-formatted ID of the system + * @param name the name of the system + * @param description the description of the system + * @param tag the system tag + * @param pronouns the pronouns for the system + * @param color the default color for the system (in a hexadecimal RGB format) + * @param avatarUrl the URL for the default avatar + * @param timezone the timezone of the system + * @param created the timestamp of the creation of the system + * @param autoProxy the ID of the member that's currently being autoproxied + * @param autoType the current mode of autoproxy + * */ +@Serializable +data class System( + val id: PkId, + val name: String?, + val description: String?, + val tag: String?, + val pronouns: String?, + val color: String?, + @SerialName("avatar_url") + val avatarUrl: String?, + val timezone: String?, + val created: Instant, + @SerialName("auto_proxy") + val autoProxy: String?, + @SerialName("auto_type") + val autoType: AutoProxyMode +) { + companion object { + fun fromRecord(system: SystemRecord) = System( + id = system.id, + name = system.name, + description = system.description, + tag = system.tag, + pronouns = system.pronouns, + color = system.color.fromColor(), + avatarUrl = system.avatarUrl, + timezone = system.timezone, + created = system.timestamp, + autoProxy = system.autoProxy, + autoType = system.autoType + ) + } +} \ No newline at end of file diff --git a/modules/api/src/main/kotlin/dev/proxyfox/api/models/SystemGuildSettings.kt b/modules/api/src/main/kotlin/dev/proxyfox/api/models/SystemGuildSettings.kt new file mode 100644 index 00000000..3c64722e --- /dev/null +++ b/modules/api/src/main/kotlin/dev/proxyfox/api/models/SystemGuildSettings.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2022-2023, The ProxyFox Group + * + * This Source Code is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.proxyfox.api.models + +import dev.proxyfox.database.records.misc.AutoProxyMode +import dev.proxyfox.database.records.system.SystemServerSettingsRecord +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Represents a system's guild settings + * + * Accessed via the `/systems/{sysid}/guilds/{guildid}` route. + * + * Requires a token to access. + * + * @param proxyEnabled whether proxying is enabled in this guild + * @param autoProxy the ID of the member that's currently being autoproxied (if autoProxyMode is not FALLBACK) + * @param autoType the current mode of autoproxy + * */ +@Serializable +data class SystemGuildSettings( + @SerialName("proxy_enabled") + val proxyEnabled: Boolean, + @SerialName("auto_proxy") + val autoProxy: String?, + @SerialName("auto_type") + val autoType: AutoProxyMode +) { + companion object { + fun fromRecord(record: SystemServerSettingsRecord) = SystemGuildSettings( + proxyEnabled = record.proxyEnabled, + autoProxy = record.autoProxy, + autoType = record.autoProxyMode + ) + } +} diff --git a/modules/api/src/main/kotlin/dev/proxyfox/api/models/Token.kt b/modules/api/src/main/kotlin/dev/proxyfox/api/models/Token.kt new file mode 100644 index 00000000..50fcd512 --- /dev/null +++ b/modules/api/src/main/kotlin/dev/proxyfox/api/models/Token.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2023, The ProxyFox Group + * + * This Source Code is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.proxyfox.api.models + +import dev.proxyfox.database.PkId +import dev.proxyfox.database.records.misc.TokenRecord +import dev.proxyfox.database.records.misc.TokenType +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Represents a token + * + * Accessed via the `/tokens` route + * + * Requires a token to access. + * + * @param token the token + * @param systemId the ID for the system it's attached to + * @param type the type of token it is + * */ +@Serializable +data class Token( + val token: String, + @SerialName("system_id") + val systemId: PkId, + val type: TokenType +) { + companion object { + fun fromRecord(record: TokenRecord) = Token(record.token, record.systemId, record.type) + } +} diff --git a/modules/api/src/main/kotlin/dev/proxyfox/api/routes/CoffeeRoutes.kt b/modules/api/src/main/kotlin/dev/proxyfox/api/routes/CoffeeRoutes.kt new file mode 100644 index 00000000..b4d4af1a --- /dev/null +++ b/modules/api/src/main/kotlin/dev/proxyfox/api/routes/CoffeeRoutes.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2022-2023, The ProxyFox Group + * + * This Source Code is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.proxyfox.api.routes + +import dev.proxyfox.api.models.ApiError +import dev.proxyfox.api.models.BrewState +import dev.proxyfox.api.models.Coffee +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import kotlinx.coroutines.withTimeout + +var coffees = HashMap() + +fun generateIdString(): String { + val chars = 'A'..'Z' + var str = ""; + for (i in 0..5) { + str += chars.random() + } + + return str +} + +fun Route.coffeeRoutes() { + route("/coffee") { + method(CoffeeMethods.Brew) { handle { + if (Math.random() < 0.0625) { + call.respond(CoffeeCodes.Teapot, ApiError(418, "I'm a teapot!")) + return@handle + } + + val id = generateIdString() + + if (coffees.containsKey(id)) { + call.respond(CoffeeCodes.Teapot, ApiError(418, "I'm a teapot!")) + return@handle + } + + val coffee = Coffee( + id, + BrewState.STARTING + ) + + coffees[id] = coffee + + withTimeout(600_000) { + coffees.remove(id) + } + + call.respond(HttpStatusCode.OK, coffee) + }} + + get("/{coffee}") { + val id = call.parameters["coffee"] + + if (id == null) { + call.respond(CoffeeCodes.Teapot, ApiError(418, "I'm a teapot!")) + return@get + } + + val coffee = coffees[id] + + if (coffee == null) { + call.respond(CoffeeCodes.Teapot, ApiError(418, "I'm a teapot!")) + return@get + } + + coffee.state = coffee.state.advance() + + call.respond(HttpStatusCode.OK, coffee) + } + + route("/{coffee}", CoffeeMethods.When) { handle { + val id = call.parameters["coffee"] + + if (id == null) { + call.respond(CoffeeCodes.Teapot, ApiError(418, "I'm a teapot!")) + return@handle + } + + val coffee = coffees[id] + + if (coffee == null) { + call.respond(CoffeeCodes.Teapot, ApiError(418, "I'm a teapot!")) + return@handle + } + + call.respond(HttpStatusCode.OK, coffee) + coffees.remove(id) + }} + } +} + +object CoffeeMethods { + val Brew = HttpMethod("BREW") + val When = HttpMethod("WHEN") +} + +object CoffeeCodes { + val Teapot = HttpStatusCode(418, "I'm a Teapot!") +} diff --git a/modules/api/src/main/kotlin/dev/proxyfox/api/routes/MemberRoutes.kt b/modules/api/src/main/kotlin/dev/proxyfox/api/routes/MemberRoutes.kt new file mode 100644 index 00000000..7de92479 --- /dev/null +++ b/modules/api/src/main/kotlin/dev/proxyfox/api/routes/MemberRoutes.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2022-2023, The ProxyFox Group + * + * This Source Code is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.proxyfox.api.routes + +import dev.kord.common.entity.Snowflake +import dev.proxyfox.api.AccessPlugin +import dev.proxyfox.api.models.ApiError +import dev.proxyfox.api.models.Member +import dev.proxyfox.api.models.MemberGuildSettings +import dev.proxyfox.database.database +import io.ktor.server.application.* +import io.ktor.server.response.* +import io.ktor.server.routing.* + +fun Route.memberRoutes() { + route("/systems/{id}/members") { + install(AccessPlugin) + get { + val id = call.parameters["id"] ?: return@get call.respond(ApiError(404, "System Not Found")) + call.respond(database.fetchMembersFromSystem(id)?.map(Member.Companion::fromRecord) ?: emptyList()) + } + + route("/{member}") { + install(AccessPlugin) + get { + val id = call.parameters["id"] ?: return@get call.respond(ApiError(404, "System Not Found")) + val member = database.fetchMemberFromSystem(id, call.parameters["member"]!!) + ?: return@get call.respond(ApiError(404, "Member Not Found")) + call.respond(Member.fromRecord(member)) + } + + get("/guilds/{guild}") { + val id = call.parameters["id"] ?: return@get call.respond(ApiError(404, "System Not Found")) + val member = database.fetchMemberFromSystem(id, call.parameters["member"]!!) + ?: return@get call.respond(ApiError(404, "Member Not Found")) + val guildSettings = database.fetchMemberServerSettingsFromSystemAndMember(Snowflake(call.parameters["guild"]!!).value, id, member.id) + ?: return@get call.respond(ApiError(404, "Guild Not Found")) + call.respond(MemberGuildSettings.fromRecord(guildSettings)) + } + } + } +} \ No newline at end of file diff --git a/modules/api/src/main/kotlin/dev/proxyfox/api/routes/MessageRoutes.kt b/modules/api/src/main/kotlin/dev/proxyfox/api/routes/MessageRoutes.kt new file mode 100644 index 00000000..b9c3f1b9 --- /dev/null +++ b/modules/api/src/main/kotlin/dev/proxyfox/api/routes/MessageRoutes.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2022-2023, The ProxyFox Group + * + * This Source Code is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.proxyfox.api.routes + +import dev.kord.common.entity.Snowflake +import dev.proxyfox.api.models.ApiError +import dev.proxyfox.api.models.Message +import dev.proxyfox.database.database +import io.ktor.server.application.* +import io.ktor.server.response.* +import io.ktor.server.routing.* + +fun Route.messageRoutes() { + get("/messages/{id}") { + val message = database.fetchMessage(Snowflake(call.parameters["id"]!!)) + ?: return@get call.respond(ApiError(404, "Message Not Found")) + call.respond(Message.fromRecord(message)) + } +} \ No newline at end of file diff --git a/modules/api/src/main/kotlin/dev/proxyfox/api/routes/SwitchRoutes.kt b/modules/api/src/main/kotlin/dev/proxyfox/api/routes/SwitchRoutes.kt new file mode 100644 index 00000000..aed23dc0 --- /dev/null +++ b/modules/api/src/main/kotlin/dev/proxyfox/api/routes/SwitchRoutes.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2022-2023, The ProxyFox Group + * + * This Source Code is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.proxyfox.api.routes + +import dev.proxyfox.api.getAccess +import dev.proxyfox.api.models.ApiError +import dev.proxyfox.api.models.Member +import dev.proxyfox.api.models.Switch +import dev.proxyfox.database.database +import io.ktor.server.application.* +import io.ktor.server.response.* +import io.ktor.server.routing.* + +fun Route.switchRoutes() { + route("/systems/{id}/switches") { + getAccess { + val id = call.parameters["id"] ?: return@getAccess call.respond(ApiError(404, "System Not Found")) + call.respond(database.fetchSwitchesFromSystem(id)?.map(Switch.Companion::fromRecord) ?: emptyList()) + } + } + + route("/systems/{id}/fronters") { + getAccess { + val id = call.parameters["id"] ?: return@getAccess call.respond(ApiError(404, "System Not Found")) + call.respond(database.fetchFrontingMembersFromSystem(id)?.map(Member.Companion::fromRecord) ?: emptyList()) + } + } +} \ No newline at end of file diff --git a/modules/api/src/main/kotlin/dev/proxyfox/api/routes/SystemRoutes.kt b/modules/api/src/main/kotlin/dev/proxyfox/api/routes/SystemRoutes.kt new file mode 100644 index 00000000..f0f51ccf --- /dev/null +++ b/modules/api/src/main/kotlin/dev/proxyfox/api/routes/SystemRoutes.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2022-2023, The ProxyFox Group + * + * This Source Code is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.proxyfox.api.routes + +import dev.kord.common.entity.Snowflake +import dev.proxyfox.api.getAccess +import dev.proxyfox.api.models.ApiError +import dev.proxyfox.api.models.System +import dev.proxyfox.api.models.SystemGuildSettings +import dev.proxyfox.database.database +import io.ktor.server.application.* +import io.ktor.server.response.* +import io.ktor.server.routing.* + +fun Route.systemRoutes() { + route("/systems/{id}") { + getAccess { + val system = database.fetchSystemFromId(call.parameters["id"]!!) + ?: return@getAccess call.respond(ApiError(404, "SystemNot Found")) + call.respond(System.fromRecord(system)) + } + + getAccess("/guilds/{guild}") { + val id = call.parameters["id"] ?: return@getAccess call.respond(ApiError(404, "System Not Found")) + val settings = database.getOrCreateServerSettingsFromSystem(Snowflake(call.parameters["guild"]!!).value, id) + call.respond(SystemGuildSettings.fromRecord(settings)) + } + } +} diff --git a/modules/api/src/main/kotlin/dev/proxyfox/api/routes/TokenRoutes.kt b/modules/api/src/main/kotlin/dev/proxyfox/api/routes/TokenRoutes.kt new file mode 100644 index 00000000..7afb6510 --- /dev/null +++ b/modules/api/src/main/kotlin/dev/proxyfox/api/routes/TokenRoutes.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023, The ProxyFox Group + * + * This Source Code is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.proxyfox.api.routes + +import dev.proxyfox.api.models.ApiError +import dev.proxyfox.api.models.Token +import dev.proxyfox.database.database +import io.ktor.server.application.* +import io.ktor.server.response.* +import io.ktor.server.routing.* + +fun Route.tokenRoutes() { + get("/tokens") { + val tokenString = call.request.headers["Authorization"] ?: return@get call.respond(ApiError(404, "Not Found")) + val token = database.fetchToken(tokenString) ?: return@get call.respond(ApiError(404, "Not Found")) + call.respond(Token.fromRecord(token)) + } +} diff --git a/modules/bot/build.gradle.kts b/modules/bot/build.gradle.kts index b816886c..592fbe92 100644 --- a/modules/bot/build.gradle.kts +++ b/modules/bot/build.gradle.kts @@ -9,12 +9,15 @@ plugins { application alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.serialization) alias(libs.plugins.shadow) } dependencies { implementation(project(":modules:common")) implementation(project(":modules:database")) + implementation(project(":modules:api")) + implementation(project(":modules:sync")) } application.mainClass.set("dev.proxyfox.bot.BotMainKt") diff --git a/modules/bot/src/main/kotlin/dev/proxyfox/bot/BotMain.kt b/modules/bot/src/main/kotlin/dev/proxyfox/bot/BotMain.kt index f4bc1cd0..514eecb5 100644 --- a/modules/bot/src/main/kotlin/dev/proxyfox/bot/BotMain.kt +++ b/modules/bot/src/main/kotlin/dev/proxyfox/bot/BotMain.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -8,8 +8,7 @@ package dev.proxyfox.bot -import dev.proxyfox.bot.command.Commands -import dev.proxyfox.bot.md.parseMarkdown +import dev.proxyfox.api.ApiMain import dev.proxyfox.bot.terminal.TerminalCommands import dev.proxyfox.common.printFancy import dev.proxyfox.database.DatabaseMain @@ -26,12 +25,14 @@ object BotMain { printFancy("Initializing ProxyFox") - // Register commands - Commands.register() + markdownParser.addDefaultRules() // Setup database DatabaseMain.main(findUnixValue(args, "--database=")) + // Start API + ApiMain.main() + // Start reading console input TerminalCommands.start() diff --git a/modules/bot/src/main/kotlin/dev/proxyfox/bot/BotUtil.kt b/modules/bot/src/main/kotlin/dev/proxyfox/bot/BotUtil.kt index 822ff9f3..318de610 100644 --- a/modules/bot/src/main/kotlin/dev/proxyfox/bot/BotUtil.kt +++ b/modules/bot/src/main/kotlin/dev/proxyfox/bot/BotUtil.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -28,43 +28,67 @@ import dev.kord.core.entity.channel.TextChannel import dev.kord.core.entity.channel.thread.ThreadChannel import dev.kord.core.event.gateway.ReadyEvent import dev.kord.core.event.interaction.ButtonInteractionCreateEvent +import dev.kord.core.event.interaction.ChatInputCommandInteractionCreateEvent +import dev.kord.core.event.interaction.MessageCommandInteractionCreateEvent +import dev.kord.core.event.interaction.ModalSubmitInteractionCreateEvent import dev.kord.core.event.message.MessageCreateEvent +import dev.kord.core.event.message.MessageDeleteEvent import dev.kord.core.event.message.MessageUpdateEvent import dev.kord.core.event.message.ReactionAddEvent import dev.kord.core.on import dev.kord.gateway.Intent import dev.kord.gateway.PrivilegedIntent import dev.kord.gateway.builder.Shards +import dev.kord.rest.builder.interaction.GlobalChatInputCreateBuilder import dev.kord.rest.builder.message.EmbedBuilder +import dev.kord.rest.json.request.ApplicationCommandCreateRequest import dev.kord.rest.request.KtorRequestException +import dev.proxyfox.bot.command.* +import dev.proxyfox.bot.command.interaction.ProxyFoxChatInputCreateBuilderImpl +import dev.proxyfox.bot.command.interaction.ProxyFoxMessageCommandCreateBuilderImpl import dev.proxyfox.common.* import dev.proxyfox.database.database import dev.proxyfox.database.records.member.MemberRecord import dev.proxyfox.database.records.system.SystemRecord +import dev.proxyfox.markt.MarkdownParser import io.ktor.client.* import io.ktor.client.engine.cio.* +import io.ktor.client.request.forms.* import io.ktor.http.* -import kotlinx.coroutines.* +import io.ktor.utils.io.jvm.javaio.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.count import kotlinx.coroutines.flow.fold +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.datetime.Clock import kotlinx.datetime.Instant import kotlinx.serialization.SerializationException import java.lang.Integer.min -import java.time.OffsetDateTime -import java.util.* import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.TimeUnit import kotlin.contracts.ExperimentalContracts import kotlin.contracts.InvocationKind import kotlin.contracts.contract import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes +import kotlin.time.DurationUnit const val UPLOAD_LIMIT = 1024 * 1024 * 25 val scheduler = Executors.newScheduledThreadPool(Runtime.getRuntime().availableProcessors()) +suspend fun ScheduledExecutorService.schedule(duration: Duration, action: suspend () -> Unit) { + schedule({ + runBlocking { + action() + } + }, duration.toLong(DurationUnit.SECONDS), TimeUnit.SECONDS) +} + private val idUrl = System.getenv("PROXYFOX_KEY").let { it.substring(0, it.indexOf('.')) } private val webhook = Regex("https?://(?:[^./]\\.)?discord(?:app)?\\.com/api/(v\\d+/)?webhooks/\\d+/\\S+") @@ -83,6 +107,8 @@ val errorChannelId = try { } var errorChannel: TextChannel? = null +val markdownParser = MarkdownParser() + @OptIn(PrivilegedIntent::class) suspend fun login() { printStep("Setting up HTTP client", 1) @@ -115,6 +141,10 @@ suspend fun login() { } } + kord.on { + onMessageDelete() + } + kord.on { try { onMessageUpdate() @@ -133,6 +163,19 @@ suspend fun login() { } } + kord.on { + handleModal() + } + + kord.registerCommands() + + kord.on { + onInteract() + } + kord.on { + onInteract() + } + var initialized = false kord.on { if (!initialized) { @@ -164,6 +207,40 @@ suspend fun login() { } } +suspend fun Kord.registerCommands() { + printStep("Registering commands", 2) + deferredCommands.addAll( + listOf( + ProxyFoxMessageCommandCreateBuilderImpl("Delete Message").toRequest(), + ProxyFoxMessageCommandCreateBuilderImpl("Fetch Message Info").toRequest(), + ProxyFoxMessageCommandCreateBuilderImpl("Ping Message Author").toRequest(), + ProxyFoxMessageCommandCreateBuilderImpl("Edit Message").toRequest() + ) + ) + Commands { + +SystemCommands + +MemberCommands + +GroupCommands + +SwitchCommands + +MiscCommands + } + + rest.interaction.createGlobalApplicationCommands( + resources.applicationId, + deferredCommands + ) +} + +val deferredCommands = arrayListOf() + +fun deferChatInputCommand( + name: String, + description: String, + builder: GlobalChatInputCreateBuilder.() -> Unit = {} +) { + deferredCommands.add(ProxyFoxChatInputCreateBuilderImpl(name, description).apply(builder).toRequest()) +} + suspend fun updatePresence() { startTime = Clock.System.now() scheduler.fixedRateAction(Duration.ZERO, 2.minutes) { @@ -186,12 +263,17 @@ suspend fun updatePresence() { else -> throw AssertionError("Count ($count) not in 0..2") } kord.editPresence { - watching("for pf>help! $append") + watching("for /info help! $append") } } } -suspend fun handleError(err: Throwable, message: MessageBehavior) { +suspend fun handleError(err: Throwable, interaction: ChatInputCommandInteractionCreateEvent) = + handleError(err, interaction.interaction.channel) + +suspend fun handleError(err: Throwable, message: MessageBehavior) = handleError(err, message.channel) + +suspend fun handleError(err: Throwable, channel: MessageChannelBehavior) { // Catch any errors and log them val timestamp = System.currentTimeMillis() // Let the logger unwind the stacktrace. @@ -203,13 +285,33 @@ suspend fun handleError(err: Throwable, message: MessageBehavior) { val reason = err.message?.replace(webhook, "[WEBHOOK]")?.replace(token, "[TOKEN]") var cause = "" err.stackTrace.forEach { - if (it.className.startsWith("dev.proxyfox")) - cause += " at $it\n" + if (it.className.startsWith("dev.proxyfox.")) + cause += " at ${it.pfString()}\n" + } + if(err !is SerializationException) { + for (suppressed in err.suppressed) { + var supCause = "" + val supReason = suppressed.message?.replace(webhook, "[WEBHOOK]")?.replace(token, "[TOKEN]") + suppressed.stackTrace.forEach { + if (it.className.startsWith("dev.proxyfox.")) + supCause += " at ${it.pfString()}\n" + } + cause += " Suppressed: ${suppressed.javaClass.name}: $supReason\n$supCause" + } + try { + channel.createMessage( + "An unexpected error occurred. Report this to us at https://discord.gg/q3yF8ay9V7\nTimestamp: `$timestamp`\n```\n${err.javaClass.name}: $reason\n$cause".let { + if (it.length > 1997) { + it.substring(0, it.lastIndexOf('\n', 1997)) + "```" + } else { + "$it```" + } + } + ) + } catch (any: Exception) { + logger.warn("Cannot send the error message to sender", any) + } } - if (err !is SerializationException) - message.channel.createMessage( - "An unexpected error occurred.\nTimestamp: `$timestamp`\n```\n${err.javaClass.name}: $reason\n$cause```" - ) if (err is DebugException) return if (errorChannel == null && errorChannelId != null) errorChannel = kord.getChannel(errorChannelId) as TextChannel @@ -219,11 +321,21 @@ suspend fun handleError(err: Throwable, message: MessageBehavior) { errorChannel!!.createMessage { content = "`$timestamp`" - addFile("exception.log", cause.byteInputStream()) + addFile("exception.log", ChannelProvider { cause.byteInputStream().toByteReadChannel() }) } } } +fun StackTraceElement.pfString(): String { + val names = className.split('.') + val clazz = names.last() + var path = "" + + names.dropLast(1).forEach { path += it[0] + "." } + + return "$path.$clazz($fileName:$lineNumber)" +} + fun findUnixValue(args: Array, key: String): String? { for (i in args.indices) { if (args[i].startsWith(key)) { @@ -233,7 +345,14 @@ fun findUnixValue(args: Array, key: String): String? { return null } -fun OffsetDateTime.toKtInstant() = Instant.fromEpochSeconds(epochSeconds = toEpochSecond(), nanosecondAdjustment = nano) +fun hasUnixValue(args: Array, key: String): Boolean { + for (i in args.indices) { + if (args[i].startsWith(key)) { + return true + } + } + return true +} fun Int.kordColor() = if (this < 0) null else Color(this) diff --git a/modules/bot/src/main/kotlin/dev/proxyfox/bot/Emojis.kt b/modules/bot/src/main/kotlin/dev/proxyfox/bot/Emojis.kt new file mode 100644 index 00000000..a3f10962 --- /dev/null +++ b/modules/bot/src/main/kotlin/dev/proxyfox/bot/Emojis.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2023, The ProxyFox Group + * + * This Source Code is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.proxyfox.bot + +import dev.kord.common.entity.DiscordPartialEmoji +import dev.kord.common.entity.optional.optional +import dev.kord.core.entity.GuildEmoji +import dev.kord.core.entity.ReactionEmoji + +object Emojis { + val check = "✅".partial + val multiply = "✖".partial + val wastebasket = "🗑".partial + val move = "🔀".partial + val rewind = "⏪".partial + val fastforward = "⏩".partial + val last = "⬅".partial + val next = "➡".partial + val numbers = "\uD83D\uDD22".partial + + val ReactionEmoji.Unicode.partial get() = name.partial + + val ReactionEmoji.Custom.partial get() = DiscordPartialEmoji(id = id, name = name, animated = isAnimated.optional()) + + val GuildEmoji.partial get() = DiscordPartialEmoji(id = id, animated = isAnimated.optional()) + + val String.partial get() = DiscordPartialEmoji(name = this) +} diff --git a/modules/bot/src/main/kotlin/dev/proxyfox/bot/MessageHandler.kt b/modules/bot/src/main/kotlin/dev/proxyfox/bot/MessageHandler.kt index e706b471..a09359ec 100644 --- a/modules/bot/src/main/kotlin/dev/proxyfox/bot/MessageHandler.kt +++ b/modules/bot/src/main/kotlin/dev/proxyfox/bot/MessageHandler.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -8,22 +8,32 @@ package dev.proxyfox.bot -import dev.kord.common.entity.MessageType -import dev.kord.common.entity.Permission -import dev.kord.common.entity.Permissions -import dev.kord.common.entity.Snowflake +import dev.kord.common.entity.* import dev.kord.core.behavior.channel.asChannelOfOrNull import dev.kord.core.behavior.channel.createMessage +import dev.kord.core.behavior.channel.threads.ThreadChannelBehavior +import dev.kord.core.behavior.interaction.modal +import dev.kord.core.behavior.interaction.respondEphemeral +import dev.kord.core.behavior.interaction.respondPublic import dev.kord.core.cache.data.AttachmentData import dev.kord.core.cache.data.EmbedData import dev.kord.core.entity.Attachment import dev.kord.core.entity.Embed import dev.kord.core.entity.channel.GuildChannel +import dev.kord.core.entity.interaction.SubCommand +import dev.kord.core.event.interaction.ChatInputCommandInteractionCreateEvent +import dev.kord.core.event.interaction.MessageCommandInteractionCreateEvent +import dev.kord.core.event.interaction.ModalSubmitInteractionCreateEvent import dev.kord.core.event.message.MessageCreateEvent +import dev.kord.core.event.message.MessageDeleteEvent import dev.kord.core.event.message.MessageUpdateEvent import dev.kord.core.event.message.ReactionAddEvent +import dev.kord.rest.builder.component.ActionRowBuilder import dev.kord.rest.builder.message.create.embed -import dev.proxyfox.bot.string.parser.parseString +import dev.proxyfox.bot.command.* +import dev.proxyfox.bot.command.context.DiscordContext +import dev.proxyfox.bot.command.context.DiscordMessageContext +import dev.proxyfox.bot.command.context.InteractionCommandContext import dev.proxyfox.bot.webhook.GuildMessage import dev.proxyfox.bot.webhook.WebhookUtil import dev.proxyfox.common.ellipsis @@ -33,9 +43,12 @@ import dev.proxyfox.database.records.member.MemberRecord import dev.proxyfox.database.records.misc.AutoProxyMode import dev.proxyfox.database.records.system.SystemRecord import dev.proxyfox.database.records.system.SystemServerSettingsRecord +import kotlinx.datetime.toJavaLocalDate import org.slf4j.LoggerFactory -val prefixRegex = Regex("^(?:(<@!?${kord.selfId}>)|pf[>;!:])\\s*", RegexOption.IGNORE_CASE) +val prefix = System.getenv("PROXYFOX_PREFIX") ?: "pf" + +val prefixRegex = Regex("^(?:(<@!?${kord.selfId}>)|$prefix[>;!:])\\s*", RegexOption.IGNORE_CASE) private val logger = LoggerFactory.getLogger("MessageHandler") @@ -59,22 +72,27 @@ suspend fun MessageCreateEvent.onMessageCreate() { val contentWithoutRegex = content.substring(matcher.end()) if (contentWithoutRegex.isBlank() && matcher.start(1) >= 0) { - channel.createMessage("Hi, I'm ProxyFox! My prefix is `pf>`.") + channel.createMessage("Hi, I'm ProxyFox! My prefix is `pf>`. I also support slash commands!") } else { // Run the command - val output = parseString(contentWithoutRegex, message) ?: return - // Send output message if exists - if (output.isNotBlank()) - channel.createMessage(output) + @Suppress("UNCHECKED_CAST") + Commands.parser.parse(DiscordMessageContext(message, contentWithoutRegex) as DiscordContext) } } else if (guildChannel != null && guildChannel.selfHasPermissions(Permissions(Permission.ManageWebhooks, Permission.ManageMessages))) { val guild = guildChannel.getGuild() val hasStickers = message.stickers.isNotEmpty() // TODO: Boost to upload limit; 8 MiB is default. - val hasOversizedFiles = message.attachments.fold(0L) { size, attachment -> size + attachment.size } >= UPLOAD_LIMIT + val hasOversizedFiles = + message.attachments.fold(0L) { size, attachment -> size + attachment.size } >= UPLOAD_LIMIT val isOversizedMessage = content.length > 2000 if (hasStickers || hasOversizedFiles || isOversizedMessage) { - logger.trace("Denying proxying {} ({}) in {} ({}) due to Discord bot constraints", user.tag, user.id, guild.name, guild.id) + logger.trace( + "Denying proxying {} ({}) in {} ({}) due to Discord bot constraints", + user.username, + user.id, + guild.name, + guild.id + ) return } @@ -82,8 +100,14 @@ suspend fun MessageCreateEvent.onMessageCreate() { } } +suspend fun MessageDeleteEvent.onMessageDelete() { + val message = database.fetchMessage(messageId) ?: return + message.deleted = true + database.updateMessage(message) +} + suspend fun MessageUpdateEvent.onMessageUpdate() { - val guild = kord.getGuild(new.guildId.value ?: return) ?: return + val guild = kord.getGuildOrNull(new.guildId.value ?: return) ?: return val content = new.content.value ?: return val authorRaw = new.author.value ?: return if (authorRaw.bot.discordBoolean) return @@ -94,8 +118,8 @@ suspend fun MessageUpdateEvent.onMessageUpdate() { if (hasStickers || hasOversizedFiles || isOversizedMessage) { logger.trace( - "Denying proxying {}#{} ({}) in {} ({}) due to Discord bot constraints", - authorRaw.username, authorRaw.discriminator, authorRaw.id, guild.name, guild.id + "Denying proxying {} ({}) in {} ({}) due to Discord bot constraints", + authorRaw.username, authorRaw.id, guild.name, guild.id ) return } @@ -112,6 +136,7 @@ suspend fun MessageUpdateEvent.onMessageUpdate() { new.embeds.value?.map { Embed(EmbedData.from(it), kord) } ?: emptyList(), new.messageReference.value?.id?.value?.let { channel.getMessage(it) }, message, + message.asMessage().flags ) handleProxying( @@ -133,7 +158,7 @@ private suspend fun handleProxying( val server = database.getOrCreateServerSettings(guild) server.proxyRole.let { if (it != 0UL && !user.asMember(guild.id).roleIds.contains(Snowflake(it))) { - logger.trace("Denying proxying {} ({}) in {} ({}) due to missing role {}", user.tag, user.id, guild.name, guild.id, it) + logger.trace("Denying proxying {} ({}) in {} ({}) due to missing role {}", user.username, user.id, guild.name, guild.id, it) return } } @@ -166,7 +191,16 @@ private suspend fun handleProxying( database.updateSystem(system) } - WebhookUtil.prepareMessage(message, content, system, member, proxy, memberServer, server.moderationDelay.toLong())?.send() + WebhookUtil.prepareMessage( + message, + content, + system, + member, + proxy, + memberServer, + server.moderationDelay.toLong(), + server.enforceTag + )?.send() } else if (content.startsWith('\\')) { // Doesn't proxy just for this message. if (content.startsWith("\\\\")) { @@ -186,7 +220,16 @@ private suspend fun handleProxying( val memberServer = database.fetchMemberServerSettingsFromSystemAndMember(guild, system.id, member.id) if (memberServer?.proxyEnabled == false) return - WebhookUtil.prepareMessage(message, content, system, member, null, memberServer, server.moderationDelay.toLong())?.send() + WebhookUtil.prepareMessage( + message, + content, + system, + member, + null, + memberServer, + server.moderationDelay.toLong(), + server.enforceTag + )?.send() } } @@ -234,13 +277,13 @@ suspend fun ReactionAddEvent.onReactionAdd() { val member = database.fetchMemberFromSystem(databaseMessage.systemId, databaseMessage.memberId) ?: return - val guild = getGuild() + val guild = getGuildOrNull() val settings = database.fetchMemberServerSettingsFromSystemAndMember(guild, system.id, member.id) val user = kord.getUser(Snowflake(databaseMessage.userId)) getUser().getDmChannel().createMessage { - content = "Message by ${member.showDisplayName()} was sent by <@${databaseMessage.userId}> (${user?.tag ?: "Unknown user"})" + content = "Message by ${member.showDisplayName()} was sent by <@${databaseMessage.userId}> (${user?.username ?: "Unknown user"})" embed { val systemName = system.name ?: system.id author { @@ -271,17 +314,185 @@ suspend fun ReactionAddEvent.onReactionAdd() { member.birthday?.let { field { name = "Birthday" - value = it.displayDate() + value = it.toJavaLocalDate().displayDate() inline = true } } footer { - text = "Member ID \u2009• \u2009${member.id}\u2007|\u2007System ID \u2009• \u2009${system.id}\u2007|\u2007Created " + text = + "Member ID \u2009• \u2009${member.id}\u2007|\u2007System ID \u2009• \u2009${system.id}\u2007|\u2007Created " } - timestamp = system.timestamp.toKtInstant() + timestamp = system.timestamp } } message.deleteReaction(userId, emoji) } } -} \ No newline at end of file +} + +suspend fun ModalSubmitInteractionCreateEvent.handleModal() { + val channel = interaction.channel + when { + interaction.modalId.startsWith("MessageEdit:") -> { + val webhook = WebhookUtil.createOrFetchWebhookFromCache(channel.fetchChannel()) + val id = Snowflake(interaction.modalId.split(":")[1]) + val content = interaction.textInputs["MessageEdit"]!!.value ?: return let { + interaction.respondEphemeral { + content = "Please provide the content to edit with" + } + } + webhook.edit(id, if (channel is ThreadChannelBehavior) channel.id else null) { + this.content = content + } + interaction.respondEphemeral { + this.content = "message edited." + } + } + } +} + +suspend fun MessageCommandInteractionCreateEvent.onInteract() { + val message = this.interaction.getTargetOrNull() ?: return let { + interaction.respondEphemeral { + content = "Message not found. Can I see it?" + } + } + val databaseMessage = database.fetchMessage(message.id) ?: return let { + interaction.respondEphemeral { + content = "Message not found in database. Did I proxy it?" + } + } + when (interaction.invokedCommandName) { + "Delete Message" -> { + // System needs to be non-null. + val system = database.fetchSystemFromUser(interaction.user) ?: return + if (databaseMessage.systemId == system.id) { + message.delete("User requested message deletion.") + databaseMessage.deleted = true + database.updateMessage(databaseMessage) + interaction.respondEphemeral { + content = "Message deleted." + } + return + } + interaction.respondEphemeral { + content = "You're not the original author of the message" + } + } + + "Fetch Message Info" -> { + val system = database.fetchSystemFromId(databaseMessage.systemId) + ?: return + + val member = database.fetchMemberFromSystem(databaseMessage.systemId, databaseMessage.memberId) + ?: return + + val guild = message.getGuild() + val settings = database.fetchMemberServerSettingsFromSystemAndMember(guild, system.id, member.id) + + val user = kord.getUser(Snowflake(databaseMessage.userId)) + + interaction.respondEphemeral { + content = + "Message by ${member.showDisplayName()} was sent by <@${databaseMessage.userId}> (${user?.username ?: "Unknown user"})" + embed { + val systemName = system.name ?: system.id + author { + name = member.displayName?.let { "$it (${member.name})\u2007•\u2007$systemName" } + ?: "${member.name}\u2007•\u2007$systemName" + icon = member.avatarUrl + } + member.avatarUrl?.let { + thumbnail { + url = it + } + } + color = member.color.kordColor() + description = member.description + settings?.nickname?.let { + field { + name = "Server Name" + value = "> $it\n*For ${guild.name}*" + inline = true + } + } + member.pronouns?.let { + field { + name = "Pronouns" + value = it + inline = true + } + } + member.birthday?.let { + field { + name = "Birthday" + value = it.toJavaLocalDate().displayDate() + inline = true + } + } + footer { + text = + "Member ID \u2009• \u2009${member.id}\u2007|\u2007System ID \u2009• \u2009${system.id}\u2007|\u2007Created " + } + timestamp = system.timestamp + } + } + } + + "Ping Message Author" -> { + interaction.respondPublic { + content = + "Psst.. ${databaseMessage.memberName} (<@${databaseMessage.userId}>)$ellipsis You were pinged by <@${interaction.user.id}>" + } + } + + "Edit Message" -> { + val system = database.fetchSystemFromUser(interaction.user) ?: return + if (databaseMessage.systemId == system.id) { + interaction.modal("Message Edit Screen", "MessageEdit:${message.id}") { + components.add(ActionRowBuilder().apply { + textInput(TextInputStyle.Paragraph, "MessageEdit", "Message") {} + }) + } + return + } + interaction.respondEphemeral { + content = "You're not the original author of the message" + } + } + } +} + +suspend fun ChatInputCommandInteractionCreateEvent.onInteract() { + try { + when (interaction.invokedCommandName) { + "member" -> { + val command = interaction.command as? SubCommand ?: return + MemberCommands.interactionExecutors[command.name]?.let { it(InteractionCommandContext(this)) } + } + + "system" -> { + val command = interaction.command as? SubCommand ?: return + SystemCommands.interactionExecutors[command.name]?.let { it(InteractionCommandContext(this)) } + } + + "switch" -> { + val command = interaction.command as? SubCommand ?: return + SwitchCommands.interactionExecutors[command.name]?.let { it(InteractionCommandContext(this)) } + } + + else -> { + val command = interaction.command as? SubCommand ?: return + when (command.rootName) { + "info" -> MiscCommands.infoInteractionExecutors + "moderation" -> MiscCommands.moderationInteractionExecutors + "management" -> MiscCommands.managementInteractionExecutors + "pluralkit" -> MiscCommands.pluralkitInteractionExecutors + else -> return + }[command.name]?.let { it(InteractionCommandContext(this)) } + } + } + } catch (err: Throwable) { + handleError(err, this) + } +} diff --git a/modules/bot/src/main/kotlin/dev/proxyfox/bot/SchedulerUtil.kt b/modules/bot/src/main/kotlin/dev/proxyfox/bot/SchedulerUtil.kt index 2003e502..45581f2d 100644 --- a/modules/bot/src/main/kotlin/dev/proxyfox/bot/SchedulerUtil.kt +++ b/modules/bot/src/main/kotlin/dev/proxyfox/bot/SchedulerUtil.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this diff --git a/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/CommandRegistrar.kt b/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/CommandRegistrar.kt new file mode 100644 index 00000000..6c831695 --- /dev/null +++ b/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/CommandRegistrar.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2023, The ProxyFox Group + * + * This Source Code is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.proxyfox.bot.command + +import dev.proxyfox.bot.command.context.DiscordContext +import dev.proxyfox.command.CommandParser + +interface CommandRegistrar { + val displayName: String + + suspend fun CommandParser>.registerTextCommands() + suspend fun registerSlashCommands() +} diff --git a/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/Commands.kt b/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/Commands.kt index 17b3920a..f2150099 100644 --- a/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/Commands.kt +++ b/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/Commands.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -8,7 +8,19 @@ package dev.proxyfox.bot.command +import dev.kord.rest.builder.interaction.* +import dev.proxyfox.bot.command.context.DiscordContext +import dev.proxyfox.bot.command.context.InteractionCommandContext +import dev.proxyfox.command.CommandParser +import dev.proxyfox.common.applyAsync import dev.proxyfox.common.printStep +import dev.proxyfox.database.database +import dev.proxyfox.database.records.group.GroupRecord +import dev.proxyfox.database.records.member.MemberRecord +import dev.proxyfox.database.records.system.SystemRecord +import dev.proxyfox.database.records.system.SystemSwitchRecord +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.contract /** * General utilities relating to commands @@ -16,11 +28,127 @@ import dev.proxyfox.common.printStep * */ object Commands { - suspend fun register() { - printStep("Registering commands",1) - SystemCommands.register() - MemberCommands.register() - SwitchCommands.register() - MiscCommands.register() - } -} \ No newline at end of file + val parser = CommandParser>() + + suspend operator fun invoke(action: suspend Commands.() -> Unit) { + applyAsync(action) + } + + suspend operator fun CommandRegistrar.unaryPlus() { + printStep("Registering $displayName commands", 3) + parser.registerTextCommands() + registerSlashCommands() + } +} + +fun SubCommandBuilder.guild() { + integer("server-id", "The ID for the server") { + required = false + } +} + +fun SubCommandBuilder.name(name: String = "name", required: Boolean = true) { + string(name, "The $name to use") { + this.required = required + } +} + +fun SubCommandBuilder.enum(name: String, required: Boolean = true, enum: ArrayList) { + string(name, "The $name to use") { + this.required = required + for (value in enum) { + choice(value, value) + } + } +} + +fun SubCommandBuilder.system(name: String = "system") { + string(name, "The $name to use") { + required = false + } +} + +fun SubCommandBuilder.avatar(name: String = "avatar") { + attachment(name, "The $name to set") { + required = false + } +} +fun SubCommandBuilder.bool(name: String, desc: String) { + boolean(name, desc) { + required = false + } +} +fun SubCommandBuilder.raw() = bool("raw", "Whether to fetch the raw data") +fun SubCommandBuilder.clear() = bool("clear", "Whether to clear the data") +fun SubCommandBuilder.member() = name("member") +fun GlobalChatInputCreateBuilder.access(type: String, name: String, builder: SubCommandBuilder.() -> Unit) { + subCommand(name, "Accesses the $type's $name", builder) +} + +@OptIn(ExperimentalContracts::class) +suspend fun checkSystem(ctx: DiscordContext, system: SystemRecord?, private: Boolean = false): Boolean { + contract { + returns(true) implies (system != null) + } + system ?: run { + ctx.respondFailure(CommonMessages.NOT_FOUND("System"), private) + return false + } + return true +} + +@OptIn(ExperimentalContracts::class) +suspend fun checkGroup(ctx: DiscordContext, group: GroupRecord?, private: Boolean = false): Boolean { + contract { + returns(true) implies (group != null) + } + group ?: run { + ctx.respondFailure(CommonMessages.NOT_FOUND("Group"), private) + return false + } + return true +} + +@OptIn(ExperimentalContracts::class) +suspend fun checkMember(ctx: DiscordContext, member: MemberRecord?, private: Boolean = false): Boolean { + contract { + returns(true) implies (member != null) + } + + member ?: run { + ctx.respondFailure(CommonMessages.NOT_FOUND("Member"), private) + return false + } + return true +} + +@OptIn(ExperimentalContracts::class) +suspend fun checkSwitch(ctx: DiscordContext, switch: SystemSwitchRecord?): Boolean { + contract { + returns(true) implies (switch != null) + } + + switch ?: run { + ctx.respondFailure( + "Looks like you haven't registered any switches yet. Create one using `/switch create` or ${ + CommonMessages.TEXT_COMMAND( + "switch" + ) + }" + ) + return false + } + return true +} + +suspend fun InteractionCommandContext.getSystem(): SystemRecord? { + val id = value.interaction.command.strings["system"] + return if (id == null) + database.fetchSystemFromUser(getUser()) + else + (database.fetchSystemFromId(id) ?: database.fetchSystemFromUser(id.toULongOrNull() ?: return null))?.let { + if (!it.canAccess(getUser().id.value)) + return null + return it + } +} diff --git a/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/CommonMessages.kt b/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/CommonMessages.kt new file mode 100644 index 00000000..03bee5d0 --- /dev/null +++ b/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/CommonMessages.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2023, The ProxyFox Group + * + * This Source Code is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.proxyfox.bot.command + +enum class CommonMessages(val builder: (Array) -> String) { + TEXT_COMMAND({ + "`${dev.proxyfox.bot.prefix}>${it[0]}`" + }), + NOT_FOUND({ + "${it[0]} not found. Create one using `/system create` or ${TEXT_COMMAND("system new")}" + }), + NOT_FOUND_WITH_NAME({ + "${it[0]} ${it[1]} not found. Create one using `/member create` or ${TEXT_COMMAND("member new")}" + }) +} + +operator fun CommonMessages.invoke(vararg strings: String): String = builder(strings) diff --git a/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/GroupCommands.kt b/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/GroupCommands.kt new file mode 100644 index 00000000..db0250af --- /dev/null +++ b/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/GroupCommands.kt @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2023, The ProxyFox Group + * + * This Source Code is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.proxyfox.bot.command + +import dev.kord.rest.builder.interaction.SubCommandBuilder +import dev.kord.rest.builder.interaction.subCommand +import dev.proxyfox.bot.command.context.DiscordContext +import dev.proxyfox.bot.command.context.InteractionCommandContext +import dev.proxyfox.bot.command.context.runs +import dev.proxyfox.bot.deferChatInputCommand +import dev.proxyfox.bot.kordColor +import dev.proxyfox.command.CommandParser +import dev.proxyfox.command.NodeHolder +import dev.proxyfox.command.node.builtin.literal +import dev.proxyfox.command.node.builtin.string +import dev.proxyfox.database.database +import dev.proxyfox.database.records.group.GroupRecord +import dev.proxyfox.database.records.system.SystemRecord + +object GroupCommands : CommandRegistrar { + var interactionExecutors: HashMap Boolean> = hashMapOf() + + fun SubCommandBuilder.runs(action: suspend InteractionCommandContext.() -> Boolean) { + interactionExecutors[name] = action + } + + override suspend fun registerSlashCommands() { + deferChatInputCommand("group", "Manage a group") { + subCommand("access", "View the group") { + name() + system() + runs { + val system = getSystem() + if (!checkSystem(this, system)) return@runs false + val group = database.findGroup(system.id, value.interaction.command.strings["name"]!!) + if (!checkGroup(this, group)) return@runs false + access(this, system, group) + } + } + } + } + + override val displayName: String = "Group" + + override suspend fun CommandParser>.registerTextCommands() { + registerGroupCommands { + database.fetchSystemFromUser(getUser()) + } + } + + suspend fun > NodeHolder.registerGroupCommands(getSys: suspend DiscordContext.() -> SystemRecord?) { + literal("group", "g") { + string("group") { getGroup -> + runs { + val system = getSys() + if (!checkSystem(this, system)) return@runs false + val group = database.findGroup(system.id, getGroup()) + if (!checkGroup(this, group)) return@runs false + + access(this, system, group) + } + } + } + } + + suspend fun access(ctx: DiscordContext, system: SystemRecord, group: GroupRecord): Boolean { + val members = database.fetchMembersFromGroup(group).size + ctx.respondEmbed { + title = group.name + color = group.color.kordColor() + group.avatarUrl?.let { + thumbnail { url = it } + } + group.tag?.let { + field { + name = "Tag" + value = "$it\n**Display Mode:${group.tagMode.getDisplayString()}**" + inline = true + } + } + field { + name = "Members (`${members}`)" + value = "See ${CommonMessages.TEXT_COMMAND("group ${group.id} list")}" + inline = true + } + group.description?.let { + field { + name = "Description" + value = it + } + } + footer { + text = + "Group ID \u2009• \u2009${group.id}\u2007|\u2007System ID \u2009• \u2009${system.id}\u2007|\u2007Created " + } + timestamp = group.timestamp + } + + return true + } +} \ No newline at end of file diff --git a/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/MemberCommands.kt b/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/MemberCommands.kt index cafee2cf..c5fd3244 100644 --- a/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/MemberCommands.kt +++ b/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/MemberCommands.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -8,117 +8,324 @@ package dev.proxyfox.bot.command -import dev.kord.common.entity.ButtonStyle +import dev.kord.common.entity.Snowflake +import dev.kord.rest.builder.interaction.SubCommandBuilder +import dev.kord.rest.builder.interaction.subCommand import dev.proxyfox.bot.* -import dev.proxyfox.bot.prompts.Button -import dev.proxyfox.bot.prompts.TimedYesNoPrompt -import dev.proxyfox.bot.string.dsl.greedy -import dev.proxyfox.bot.string.dsl.literal -import dev.proxyfox.bot.string.dsl.string -import dev.proxyfox.bot.string.dsl.unixLiteral -import dev.proxyfox.bot.string.parser.MessageHolder -import dev.proxyfox.bot.string.parser.registerCommand -import dev.proxyfox.common.* +import dev.proxyfox.bot.command.context.DiscordContext +import dev.proxyfox.bot.command.context.InteractionCommandContext +import dev.proxyfox.bot.command.text.MemberTextCommands +import dev.proxyfox.command.CommandParser +import dev.proxyfox.common.fromColor +import dev.proxyfox.common.ifBlankThenNull +import dev.proxyfox.common.notBlank +import dev.proxyfox.common.toColor import dev.proxyfox.database.database import dev.proxyfox.database.displayDate +import dev.proxyfox.database.records.member.MemberProxyTagRecord +import dev.proxyfox.database.records.member.MemberRecord +import dev.proxyfox.database.records.member.MemberServerSettingsRecord +import dev.proxyfox.database.records.system.SystemRecord import dev.proxyfox.database.tryParseLocalDate +import kotlinx.datetime.LocalDate +import kotlinx.datetime.toJavaLocalDate /** * Commands for accessing and changing system settings * @author Oliver * */ -object MemberCommands { - suspend fun register() { - printStep("Registering commands", 2) - registerCommand(literal(arrayOf("member", "m"), ::empty) { - string("member", ::access) { - literal(arrayOf("rename", "name"), ::renameEmpty) { - greedy("name", ::rename) - } +object MemberCommands : CommandRegistrar { + var interactionExecutors: HashMap Boolean> = hashMapOf() - literal(arrayOf("nickname", "nick", "displayname", "dn"), ::nicknameEmpty) { - unixLiteral("clear", ::nicknameClear) - greedy("name", ::nickname) - } + fun SubCommandBuilder.runs(action: suspend InteractionCommandContext.() -> Boolean) { + interactionExecutors[name] = action + } - literal(arrayOf("servername", "servernick", "sn"), ::servernameEmpty) { - unixLiteral("clear", ::servernameClear) - greedy("name", ::servername) + override suspend fun registerSlashCommands() { + deferChatInputCommand("member", "Manage or create a system member!") { + subCommand("create", "Create a member") { + name() + system() + runs { + val system = getSystem() + if (!checkSystem(this, system)) return@runs false + val name = value.interaction.command.strings["name"]!! + + create(this, system, name) } - literal(arrayOf("description", "desc", "d"), ::descriptionEmpty) { - unixLiteral("clear", ::descriptionClear) - unixLiteral("raw", ::descriptionRaw) - greedy("desc", ::description) + } + subCommand("delete", "Delete a member") { + member() + system() + runs { + val system = getSystem() + if (!checkSystem(this, system)) return@runs false + val member = database.findMember(system.id, value.interaction.command.strings["member"]!!) + if (!checkMember(this, member)) return@runs false + + delete(this, system, member) } - - literal(arrayOf("avatar", "pfp"), ::avatar) { - unixLiteral("clear", ::avatarClear) - greedy("avatar", ::avatarLinked) + } + subCommand("fetch", "Fetches the member's card") { + member() + system() + runs { + val system = getSystem() + if (!checkSystem(this, system)) return@runs false + val member = database.findMember(system.id, value.interaction.command.strings["member"]!!) + if (!checkMember(this, member)) return@runs false + + access(this, system, member) } - - literal(arrayOf("serveravatar", "serverpfp", "sp", "sa"), ::serverAvatar) { - unixLiteral("clear", ::serverAvatarClear) - greedy("avatar", ::serverAvatarLinked) + } + access("member", "name") { + member() + name(required = false) + raw() + system() + runs { + val system = getSystem() + if (!checkSystem(this, system)) return@runs false + val member = database.findMember(system.id, value.interaction.command.strings["member"]!!) + if (!checkMember(this, member)) return@runs false + val name = value.interaction.command.strings["name"] + val raw = value.interaction.command.booleans["raw"] ?: false + + rename(this, system, member, name, raw) } - - literal(arrayOf("autoproxy", "ap"), ::apEmpty) { - literal(arrayOf("disable", "off", "false", "0"), ::apDisable) - literal(arrayOf("enable", "on", "true", "1"), ::apEnable) + } + access("member", "nickname") { + member() + name(required = false) + raw() + clear() + system() + runs { + val system = getSystem() + if (!checkSystem(this, system)) return@runs false + val member = database.findMember(system.id, value.interaction.command.strings["member"]!!) + if (!checkMember(this, member)) return@runs false + val name = value.interaction.command.strings["name"] + val raw = value.interaction.command.booleans["raw"] ?: false + val clear = value.interaction.command.booleans["clear"] ?: false + + nickname(this, system, member, name, raw, clear) } - - literal(arrayOf("proxy", "p"), ::proxyEmpty) { - literal("remove", ::removeProxyEmpty) { - greedy("proxy", ::removeProxy) + } + access("member", "servernick") { + member() + name(required = false) + guild() + raw() + clear() + system() + runs { + val system = getSystem() + if (!checkSystem(this, system)) return@runs false + val member = database.findMember(system.id, value.interaction.command.strings["member"]!!) + if (!checkMember(this, member)) return@runs false + val guildId = + value.interaction.command.integers["server"]?.toULong()?.let { Snowflake(it) } ?: getGuild()?.id + guildId ?: run { + respondFailure("Command not ran in server.") + return@runs false } - literal("add", ::proxyEmpty) { - greedy("proxy", ::proxy) + val guild = kord.getGuildOrNull(guildId) ?: run { + respondFailure("Cannot find server. Am I in it?") + return@runs false } - greedy("proxy", ::proxy) - } + val serverMember = + database.fetchMemberServerSettingsFromSystemAndMember(guild, system.id, member.id) + val name = value.interaction.command.strings["name"] + val raw = value.interaction.command.booleans["raw"] ?: false + val clear = value.interaction.command.booleans["clear"] ?: false - literal("pronouns", ::pronounsEmpty) { - unixLiteral("clear", ::pronounsClear) - unixLiteral("raw", ::pronounsRaw) - greedy("pronouns", ::pronouns) + servername(this, system, serverMember!!, name, raw, clear) } - - literal("color", ::colorEmpty) { - greedy("color", ::color) + } + access("member", "description") { + member() + raw() + clear() + name("description", required = false) + system() + runs { + val system = getSystem() + if (!checkSystem(this, system)) return@runs false + val member = database.findMember(system.id, value.interaction.command.strings["member"]!!) + if (!checkMember(this, member)) return@runs false + val desc = value.interaction.command.strings["description"] + val raw = value.interaction.command.booleans["raw"] ?: false + val clear = value.interaction.command.booleans["clear"] ?: false + + description(this, system, member, desc, raw, clear) } - - literal(arrayOf("birthday", "bd"), ::birthEmpty) { - unixLiteral("clear", ::birthClear) - greedy("birthday", ::birth) + } + access("member", "avatar") { + member() + avatar() + clear() + system() + runs { + val system = getSystem() + if (!checkSystem(this, system)) return@runs false + val member = database.findMember(system.id, value.interaction.command.strings["member"]!!) + if (!checkMember(this, member)) return@runs false + val avatar = value.interaction.command.attachments["avatar"]?.data?.url + val clear = value.interaction.command.booleans["clear"] ?: false + + avatar(this, system, member, avatar, clear) } - - literal(arrayOf("delete", "remove", "del"), ::delete) } + access("member", "serveravatar") { + member() + avatar() + guild() + system() + runs { + val system = getSystem() + if (!checkSystem(this, system)) return@runs false + val member = database.findMember(system.id, value.interaction.command.strings["member"]!!) + if (!checkMember(this, member)) return@runs false + val guildId = + value.interaction.command.integers["server"]?.toULong()?.let { Snowflake(it) } ?: getGuild()?.id + guildId ?: run { + respondFailure("Command not ran in server.") + return@runs false + } + val guild = kord.getGuildOrNull(guildId) ?: run { + respondFailure("Cannot find server. Am I in it?") + return@runs false + } + val serverMember = + database.fetchMemberServerSettingsFromSystemAndMember(guild, system.id, member.id) + + val avatar = value.interaction.command.attachments["avatar"]?.data?.url + val clear = value.interaction.command.booleans["clear"] ?: false - literal(arrayOf("delete", "remove", "del"), ::deleteEmpty) { - greedy("member", ::delete) + serverAvatar(this, system, serverMember!!, avatar, clear) + } + } + access("member", "pronouns") { + member() + name("pronouns", required = false) + raw() + clear() + system() + runs { + val system = getSystem() + if (!checkSystem(this, system)) return@runs false + val member = database.findMember(system.id, value.interaction.command.strings["member"]!!) + if (!checkMember(this, member)) return@runs false + val pro = value.interaction.command.strings["pronouns"] + val raw = value.interaction.command.booleans["raw"] ?: false + val clear = value.interaction.command.booleans["clear"] ?: false + + pronouns(this, system, member, pro, raw, clear) + } + } + access("member", "color") { + member() + name("color", required = false) + system() + runs { + val system = getSystem() + if (!checkSystem(this, system)) return@runs false + val member = database.findMember(system.id, value.interaction.command.strings["member"]!!) + if (!checkMember(this, member)) return@runs false + val color = value.interaction.command.strings["color"] + + color(this, system, member, color?.toColor()) + } + } + access("member", "birthday") { + member() + name("birthday", required = false) + clear() + system() + runs { + val system = getSystem() + if (!checkSystem(this, system)) return@runs false + val member = database.findMember(system.id, value.interaction.command.strings["member"]!!) + if (!checkMember(this, member)) return@runs false + val birthday = value.interaction.command.strings["birthday"] + val clear = value.interaction.command.booleans["clear"] ?: false + + birthday(this, system, member, tryParseLocalDate(birthday)?.first, clear) + } + } + subCommand("proxy-add", "Adds a proxy") { + member() + name("prefix", required = false) + name("suffix", required = false) + system() + runs { + val system = getSystem() + if (!checkSystem(this, system)) return@runs false + val member = database.findMember(system.id, value.interaction.command.strings["member"]!!) + if (!checkMember(this, member)) return@runs false + val prefix = value.interaction.command.strings["prefix"] + val suffix = value.interaction.command.strings["suffix"] + val proxy = if (prefix == null && suffix == null) null else Pair(prefix, suffix) + + proxy(this, system, member, proxy) + } + } + subCommand("proxy-delete", "Delete a proxy") { + member() + name("prefix", required = false) + name("suffix", required = false) + system() + runs { + val system = getSystem() + if (!checkSystem(this, system)) return@runs false + val member = database.findMember(system.id, value.interaction.command.strings["member"]!!) + if (!checkMember(this, member)) return@runs false + val prefix = value.interaction.command.strings["prefix"] + val suffix = value.interaction.command.strings["suffix"] + val proxy = if (prefix == null && suffix == null) null else Pair(prefix, suffix) + val exists = proxy != null + val proxyTag = + if (exists) database.fetchProxyTagFromMessage(getUser(), "${prefix}text$suffix") else null + removeProxy(this, system, member, exists, proxyTag) + } } - literal(arrayOf("new", "n", "create"), ::createEmpty) { - greedy("name", ::create) + access("member", "autoproxy") { + member() + bool("value", "The value to set") + system() + runs { + val system = getSystem() + if (!checkSystem(this, system)) return@runs false + val member = database.findMember(system.id, value.interaction.command.strings["member"]!!) + if (!checkMember(this, member)) return@runs false + val value = value.interaction.command.booleans["value"] + + autoproxy(this, system, member, value) + } } + } + } - }) + override val displayName: String = "Member" + + override suspend fun CommandParser>.registerTextCommands() { + this += MemberTextCommands } - private fun empty(ctx: MessageHolder): String { - return "Make sure to provide a member command!" + suspend fun empty(ctx: DiscordContext): Boolean { + ctx.respondWarning("Make sure to provide a member command!") + return false } - private suspend fun access(ctx: MessageHolder): String { - val guild = ctx.message.getGuildOrNull() - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - val member = database.findMember(system.id, ctx.params["member"]!![0]) - ?: return "Member does not exist. Create one using `pf>member new`" + suspend fun access(ctx: DiscordContext, system: SystemRecord, member: MemberRecord): Boolean { + val guild = ctx.getGuild() val settings = database.fetchMemberServerSettingsFromSystemAndMember(guild, system.id, member.id) - ctx.respond { + ctx.respondEmbed { val systemName = system.name ?: system.id author { - name = member.displayName?.let { "$it (${member.name})\u2007•\u2007$systemName" } ?: "${member.name}\u2007•\u2007$systemName" + name = member.displayName?.let { "$it (${member.name})\u2007•\u2007$systemName" } + ?: "${member.name}\u2007•\u2007$systemName" icon = member.avatarUrl.ifBlankThenNull().httpUri() } member.avatarUrl?.let { @@ -145,7 +352,7 @@ object MemberCommands { member.birthday?.let { field { name = "Birthday" - value = it.displayDate() + value = it.toJavaLocalDate().displayDate() inline = true } } @@ -169,441 +376,528 @@ object MemberCommands { } } footer { - text = "Member ID \u2009• \u2009${member.id}\u2007|\u2007System ID \u2009• \u2009${system.id}\u2007|\u2007Created " + text = + "Member ID \u2009• \u2009${member.id}\u2007|\u2007System ID \u2009• \u2009${system.id}\u2007|\u2007Created " } - timestamp = member.timestamp.toKtInstant() + timestamp = member.timestamp + } + return true + } + + suspend fun rename( + ctx: DiscordContext, + system: SystemRecord, + member: MemberRecord, + name: String?, + raw: Boolean + ): Boolean { + name ?: run { + if (raw) + ctx.respondPlain("`${member.name}`") + else ctx.respondSuccess("Member's name is `${member.name}`!") + + return true } - return "" - } - private suspend fun renameEmpty(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - val member = database.findMember(system.id, ctx.params["member"]!![0]) - ?: return "Member does not exist. Create one using `pf>member new`" - return "Member's name is `${member.name}`" - } + if (!system.canEditMembers(ctx.getUser()!!.id.value)) { + ctx.respondFailure("You don't have access to edit this information.") + return false + } - private suspend fun rename(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - val member = database.findMember(system.id, ctx.params["member"]!![0]) - ?: return "Member does not exist. Create one using `pf>member new`" - member.name = ctx.params["name"]!![0] + member.name = name database.updateMember(member) - return "Updated member's name!" - } - private suspend fun nicknameEmpty(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - val member = database.findMember(system.id, ctx.params["member"]!![0]) - ?: return "Member does not exist. Create one using `pf>member new`" - return if (member.displayName != null) - "Member's display name is `${member.displayName}`" - else "Member doesn't have a display name." - } + ctx.respondSuccess("Member's name is now `$name`!") - private suspend fun nickname(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - val member = database.findMember(system.id, ctx.params["member"]!![0]) - ?: return "Member does not exist. Create one using `pf>member new`" - member.displayName = ctx.params["name"]!![0] - database.updateMember(member) - return "Member displayname updated!" + return true } - private suspend fun nicknameClear(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - val member = database.findMember(system.id, ctx.params["member"]!![0]) - ?: return "Member does not exist. Create one using `pf>member new`" - member.displayName = null + suspend fun nickname( + ctx: DiscordContext, + system: SystemRecord, + member: MemberRecord, + name: String?, + raw: Boolean, + clear: Boolean + ): Boolean { + if (clear) { + if (!system.canEditMembers(ctx.getUser()!!.id.value)) { + ctx.respondFailure("You don't have access to edit this information.") + return false + } + + member.displayName = null + database.updateMember(member) + + ctx.respondSuccess("Member display name cleared!") + + return true + } + + name ?: run { + member.displayName ?: run { + ctx.respondWarning("Member doesn't have a display name.") + return true + } + + if (raw) + ctx.respondPlain("`${member.displayName}`") + else ctx.respondSuccess("Member's display name is `${member.displayName}`!") + + return true + } + + if (!system.canEditMembers(ctx.getUser()!!.id.value)) { + ctx.respondFailure("You don't have access to edit this information.") + return false + } + + member.displayName = name database.updateMember(member) - return "Member displayname cleared!" - } - private suspend fun servernameEmpty(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - val member = database.findMember(system.id, ctx.params["member"]!![0]) - ?: return "Member does not exist. Create one using `pf>member new`" - val serverMember = - database.fetchMemberServerSettingsFromSystemAndMember(ctx.message.getGuild(), system.id, member.id)!! - return if (serverMember.nickname != null) - "Member's server nickname is `${serverMember.nickname}`" - else "Member doesn't have a server nickname" - } + ctx.respondSuccess("Member's display name is now `$name`!") - private suspend fun servername(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - val member = database.findMember(system.id, ctx.params["member"]!![0]) - ?: return "Member does not exist. Create one using `pf>member new`" - val serverMember = - database.fetchMemberServerSettingsFromSystemAndMember(ctx.message.getGuild(), system.id, member.id)!! - serverMember.nickname = ctx.params["name"]!![0] - database.updateMemberServerSettings(serverMember) - return "Member's server nickname updated!" + return true } - private suspend fun servernameClear(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - val member = database.findMember(system.id, ctx.params["member"]!![0]) - ?: return "Member does not exist. Create one using `pf>member new`" - val serverMember = - database.fetchMemberServerSettingsFromSystemAndMember(ctx.message.getGuild(), system.id, member.id)!! - serverMember.nickname = null + suspend fun servername( + ctx: DiscordContext, + system: SystemRecord, + serverMember: MemberServerSettingsRecord, + name: String?, + raw: Boolean, + clear: Boolean + ): Boolean { + if (clear) { + if (!system.canEditMembers(ctx.getUser()!!.id.value)) { + ctx.respondFailure("You don't have access to edit this information.") + return false + } + + serverMember.nickname = null + database.updateMemberServerSettings(serverMember) + + ctx.respondSuccess("Member's server name cleared!") + return true + } + + name ?: run { + serverMember.nickname ?: run { + ctx.respondWarning("Member doesn't have a server nickname.") + + return true + } + + if (raw) + ctx.respondPlain("`${serverMember.nickname}`") + else ctx.respondSuccess("Member's server nickname is `${serverMember.nickname}`!") + + return true + } + + if (!system.canEditMembers(ctx.getUser()!!.id.value)) { + ctx.respondFailure("You don't have access to edit this information.") + return false + } + + serverMember.nickname = name database.updateMemberServerSettings(serverMember) - return "Member's server nickname removed!" - } - private suspend fun descriptionEmpty(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - val member = database.findMember(system.id, ctx.params["member"]!![0]) - ?: return "Member does not exist. Create one using `pf>member new`" - return if (member.description != null) - "Member's description is ${member.description}" - else "Member doesn't have a description" - } + ctx.respondSuccess("Member's server nickname is now $name!") - private suspend fun descriptionRaw(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - val member = database.findMember(system.id, ctx.params["member"]!![0]) - ?: return "Member does not exist. Create one using `pf>member new`" - return member.description?.let { "```md\n$it```" } ?: "There's no description set." + return true } - private suspend fun description(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - val member = database.findMember(system.id, ctx.params["member"]!![0]) - ?: return "Member does not exist. Create one using `pf>member new`" - member.description = ctx.params["desc"]!![0] - database.updateMember(member) - return "Member description updated!" - } + suspend fun description( + ctx: DiscordContext, + system: SystemRecord, + member: MemberRecord, + description: String?, + raw: Boolean, + clear: Boolean + ): Boolean { + if (clear) { + if (!system.canEditMembers(ctx.getUser()!!.id.value)) { + ctx.respondFailure("You don't have access to edit this information.") + return false + } + + member.description = null + database.updateMember(member) + ctx.respondSuccess("Member's description cleared!") + return true + } + + description ?: run { + member.description ?: run { + ctx.respondWarning("Member has no description set") + return true + } + + if (raw) + ctx.respondPlain("```md\n${member.description}```") + else ctx.respondSuccess("Member's description is ${member.description}") - private suspend fun descriptionClear(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - val member = database.findMember(system.id, ctx.params["member"]!![0]) - ?: return "Member does not exist. Create one using `pf>member new`" - member.description = null + return true + } + + if (!system.canEditMembers(ctx.getUser()!!.id.value)) { + ctx.respondFailure("You don't have access to edit this information.") + return false + } + + member.description = description database.updateMember(member) - return "Member description cleared!" - } + ctx.respondSuccess("Member description updated!") + + return true + } + + suspend fun avatar( + ctx: DiscordContext, + system: SystemRecord, + member: MemberRecord, + avatar: String?, + clear: Boolean + ): Boolean { + if (clear) { + if (!system.canEditMembers(ctx.getUser()!!.id.value)) { + ctx.respondFailure("You don't have access to edit this information.") + return false + } - private suspend fun avatarLinked(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - val memberInput = ctx.params["member"]!![0] - val member = database.findMember(system.id, memberInput) - ?: return "Member does not exist. Create one using `pf>member new`" + member.avatarUrl = null + database.updateMember(member) + ctx.respondSuccess("Member's avatar cleared!") + return true + } - val uri = ctx.params["avatar"]!![0].uri() + avatar ?: run { + member.avatarUrl ?: run { + ctx.respondWarning("Member doesn't have an avatar set.") + return true + } - uri.invalidUrlMessage("member $memberInput avatar")?.let { return it } + ctx.respondEmbed { + image = member.avatarUrl + color = member.color.kordColor() + } + return true + } + + if (!system.canEditMembers(ctx.getUser()!!.id.value)) { + ctx.respondFailure("You don't have access to edit this information.") + return false + } + + val uri = avatar.uri() + + uri.invalidUrlMessage("system avatar")?.let { + ctx.respondFailure(it) + return false + } member.avatarUrl = uri.toString() database.updateMember(member) - return "Member avatar updated!" - } + ctx.respondSuccess("Member's avatar updated!") + + return true + } + + suspend fun serverAvatar( + ctx: DiscordContext, + system: SystemRecord, + serverMember: MemberServerSettingsRecord, + avatar: String?, + clear: Boolean + ): Boolean { + if (clear) { + if (!system.canEditMembers(ctx.getUser()!!.id.value)) { + ctx.respondFailure("You don't have access to edit this information.") + return false + } - private suspend fun avatar(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - val member = database.findMember(system.id, ctx.params["member"]!![0]) - ?: return "Member does not exist. Create one using `pf>member new`" - val attachments = ctx.message.attachments - if (attachments.isEmpty()) - return if (member.avatarUrl != null) - member.avatarUrl!! - else "Member doesn't have an avatar" - member.avatarUrl = attachments.first().url - database.updateMember(member) - return "Member avatar updated!" - } + serverMember.avatarUrl = null + database.updateMemberServerSettings(serverMember) + ctx.respondSuccess("Member's server avatar cleared!") + return true + } - private suspend fun avatarClear(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - val member = database.findMember(system.id, ctx.params["member"]!![0]) - ?: return "Member does not exist. Create one using `pf>member new`" - member.avatarUrl = null - database.updateMember(member) - return "Member avatar cleared!" - } + avatar ?: run { + serverMember.avatarUrl ?: run { + ctx.respondWarning("Member doesn't have a server avatar set.") + return true + } - private suspend fun serverAvatarLinked(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - val memberInput = ctx.params["member"]!![0] - val member = database.findMember(system.id, memberInput) - ?: return "Member does not exist. Create one using `pf>member new`" - val serverMember = - database.fetchMemberServerSettingsFromSystemAndMember(ctx.message.getGuild(), system.id, member.id)!! + ctx.respondEmbed { + image = serverMember.avatarUrl + } + return true + } - val uri = ctx.params["avatar"]!![0].uri() + if (!system.canEditMembers(ctx.getUser()!!.id.value)) { + ctx.respondFailure("You don't have access to edit this information.") + return false + } - uri.invalidUrlMessage("member $memberInput serveravatar")?.let { return it } + val uri = avatar.uri() - member.avatarUrl = uri.toString() - database.updateMemberServerSettings(serverMember) - return "Member server avatar updated!" - } + uri.invalidUrlMessage("system avatar")?.let { + ctx.respondFailure(it) + return false + } - private suspend fun serverAvatar(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - val member = database.findMember(system.id, ctx.params["member"]!![0]) - ?: return "Member does not exist. Create one using `pf>member new`" - val serverMember = - database.fetchMemberServerSettingsFromSystemAndMember(ctx.message.getGuild(), system.id, member.id)!! - val attachments = ctx.message.attachments - if (attachments.isEmpty()) - return if (serverMember.avatarUrl != null) - serverMember.avatarUrl!! - else "Member doesn't have a server avatar" - serverMember.avatarUrl = attachments.first().url + serverMember.avatarUrl = uri.toString() database.updateMemberServerSettings(serverMember) - return "Member server avatar updated!" - } + ctx.respondSuccess("Member's server avatar updated!") - private suspend fun serverAvatarClear(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - val member = database.findMember(system.id, ctx.params["member"]!![0]) - ?: return "Member does not exist. Create one using `pf>member new`" - val serverMember = - database.fetchMemberServerSettingsFromSystemAndMember(ctx.message.getGuild(), system.id, member.id)!! - serverMember.avatarUrl = null - database.updateMemberServerSettings(serverMember) - return "Member server avatar cleared!" + return true } - private suspend fun removeProxyEmpty(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - database.findMember(system.id, ctx.params["member"]!![0]) - ?: return "Member does not exist. Create one using `pf>member new`" - return "Please provide a proxy tag to remove" - } + suspend fun removeProxy( + ctx: DiscordContext, + system: SystemRecord, + member: MemberRecord, + exists: Boolean, + proxy: MemberProxyTagRecord? + ): Boolean { + if (!system.canEditMembers(ctx.getUser()!!.id.value)) { + ctx.respondFailure("You don't have access to edit this information.") + return false + } - private suspend fun removeProxy(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - val member = database.findMember(system.id, ctx.params["member"]!![0]) - ?: return "Member does not exist. Create one using `pf>member new`" - val proxy = ctx.params["proxy"]!![0] - val proxyTag = database.fetchProxyTagFromMessage(ctx.message.author, proxy) - ?: return "Proxy tag doesn't exist in this member" - if (proxyTag.memberId != member.id) return "Proxy tag doens't exist in this member" - database.dropProxyTag(proxyTag) - return "Proxy removed." - } + if (!exists) { + ctx.respondWarning("Please provide a proxy tag to remove.") + return true + } - private suspend fun apEmpty(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - val member = database.findMember(system.id, ctx.params["member"]!![0]) - ?: return "Member does not exist. Create one using `pf>member new`" - return "AutoProxy for ${member.showDisplayName()} is set to ${if (member.autoProxy) "on" else "off"}" - } + proxy ?: run { + ctx.respondFailure("Proxy tag doesn't exist in this member.") + return false + } - private suspend fun apEnable(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - val member = database.findMember(system.id, ctx.params["member"]!![0]) - ?: return "Member does not exist. Create one using `pf>member new`" - database.updateMember(member.apply { autoProxy = true }) - return "Enabled front & latch autproxy for ${member.showDisplayName()}." - } + if (proxy.memberId != member.id) { + ctx.respondFailure("Proxy tag doesn't exist in this member.") + return false + } + + database.dropProxyTag(proxy) + ctx.respondSuccess("Proxy removed!") - private suspend fun apDisable(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - val member = database.findMember(system.id, ctx.params["member"]!![0]) - ?: return "Member does not exist. Create one using `pf>member new`" - database.updateMember(member.apply { autoProxy = false }) - return "Disabled front & latch autproxy for ${member.showDisplayName()}." + return true } - private suspend fun proxyEmpty(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - val member = database.findMember(system.id, ctx.params["member"]!![0]) - ?: return "Member does not exist. Create one using `pf>member new`" - ctx.respond { - member(member, ctx.message.getGuildOrNull()?.id?.value ?: 0UL) - title = "${member.name}'s proxy tags" - description = database.fetchProxiesFromSystemAndMember(system.id, member.id).run { - if (isNullOrEmpty()) - "${member.name} has no tags set." - else - joinToString( - separator = "\n", - limit = 20, - truncated = "\n\u2026" - ) { "``$it``" } - } + suspend fun autoproxy( + ctx: DiscordContext, + system: SystemRecord, + member: MemberRecord, + enabled: Boolean? + ): Boolean { + enabled ?: run { + ctx.respondSuccess("AutoProxy for ${member.showDisplayName()} is set to ${if (member.autoProxy) "on" else "off"}!") + return true + } + + if (!system.canEditMembers(ctx.getUser()!!.id.value)) { + ctx.respondFailure("You don't have access to edit this information.") + return false } - return "" + database.updateMember(member.apply { autoProxy = enabled }) + ctx.respondSuccess("${if (enabled) "Enabled" else "Disabled"} front & latch autproxy for ${member.showDisplayName()}!") + return true } - private suspend fun proxy(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - val member = database.findMember(system.id, ctx.params["member"]!![0]) - ?: return "Member does not exist. Create one using `pf>member new`" - val proxy = ctx.params["proxy"]!![0] - if (!proxy.contains("text")) return "Given proxy tag does not contain `text`" + suspend fun extractProxyFromTag(ctx: DiscordContext, proxy: String): Pair? { + if (!proxy.contains("text")) { + ctx.respondFailure("Given proxy tag does not contain `text`.") + return null + } val prefix = proxy.substring(0, proxy.indexOf("text")) val suffix = proxy.substring(4 + prefix.length, proxy.length) - if (prefix.isEmpty() && suffix.isEmpty()) return "Proxy tag must contain either a prefix or a suffix" - database.createProxyTag(system.id, member.id, prefix, suffix) - ?: return "Proxy tag already exists in this system" - return "Proxy tag created!" - } + if (prefix.isEmpty() && suffix.isEmpty()) { + ctx.respondFailure("Proxy tag must contain either a prefix or a suffix.") + return null + } + return Pair(prefix, suffix) + } + + suspend fun proxy( + ctx: DiscordContext, + system: SystemRecord, + member: MemberRecord, + proxy: Pair? + ): Boolean { + proxy ?: run { + ctx.respondEmbed { + member(member, ctx.getGuild()?.id?.value ?: 0UL) + title = "${member.name}'s proxy tags" + description = database.fetchProxiesFromSystemAndMember(member.systemId, member.id).run { + if (isNullOrEmpty()) + "${member.name} has no tags set." + else + joinToString( + separator = "\n", + limit = 20, + truncated = "\n\u2026" + ) { "``$it``" } + } + } - private suspend fun pronounsEmpty(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - val member = database.findMember(system.id, ctx.params["member"]!![0]) - ?: return "Member does not exist. Create one using `pf>member new`" - return if (member.pronouns == null) - "Member does not have pronouns set" - else "Member's pronouns are ${member.pronouns}" - } + return true + } - private suspend fun pronounsRaw(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - val member = database.findMember(system.id, ctx.params["member"]!![0]) - ?: return "Member does not exist. Create one using `pf>member new`" - return if (member.pronouns == null) - "Member does not have pronouns set" - else "`${member.pronouns}`" - } + if (!system.canEditMembers(ctx.getUser()!!.id.value)) { + ctx.respondFailure("You don't have access to edit this information.") + return false + } - private suspend fun pronouns(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - val member = database.findMember(system.id, ctx.params["member"]!![0]) - ?: return "Member does not exist. Create one using `pf>member new`" - member.pronouns = ctx.params["pronouns"]!![0] - database.updateMember(member) - return "Member's pronouns updated!" - } + database.createProxyTag(member.systemId, member.id, proxy.first, proxy.second) ?: run { + ctx.respondFailure("Proxy tag already exists in this system.") + return false + } + ctx.respondSuccess("Proxy tag `${proxy.first ?: ""}text${proxy.second ?: ""}` created!") + return true + } + + suspend fun pronouns( + ctx: DiscordContext, + system: SystemRecord, + member: MemberRecord, + pronouns: String?, + raw: Boolean, + clear: Boolean + ): Boolean { + if (clear) { + if (!system.canEditMembers(ctx.getUser()!!.id.value)) { + ctx.respondFailure("You don't have access to edit this information.") + return false + } - private suspend fun pronounsClear(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - val member = database.findMember(system.id, ctx.params["member"]!![0]) - ?: return "Member does not exist. Create one using `pf>member new`" - member.pronouns = null - database.updateMember(member) - return "Member's pronouns cleared!" - } + member.pronouns = null + database.updateMember(member) + ctx.respondSuccess("Member's pronouns cleared!") + return true + } - private suspend fun colorEmpty(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - val member = database.findMember(system.id, ctx.params["member"]!![0]) - ?: return "Member does not exist. Create one using `pf>member new`" - return "Member's color is `${member.color.fromColor()}`" + pronouns ?: run { + member.pronouns ?: run { + ctx.respondWarning("Member has no pronouns set") + return true + } - } + if (raw) + ctx.respondPlain("`${member.pronouns}`") + else ctx.respondSuccess("Member's pronouns are ${member.pronouns}") - private suspend fun color(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - val member = database.findMember(system.id, ctx.params["member"]!![0]) - ?: return "Member does not exist. Create one using `pf>member new`" - member.color = ctx.params["color"]!![0].toColor() + return true + } + + if (!system.canEditMembers(ctx.getUser()!!.id.value)) { + ctx.respondFailure("You don't have access to edit this information.") + return false + } + + member.pronouns = pronouns database.updateMember(member) - return "Member's color updated!" + ctx.respondSuccess("Member's pronouns are now $pronouns!") + return true } - private suspend fun birthEmpty(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - val member = database.findMember(system.id, ctx.params["member"]!![0]) - ?: return "Member does not exist. Create one using `pf>member new`" - return if (member.birthday == null) - "Member does not have a birthday reigstered" - else "Member's birthday is ${member.birthday}" - } + suspend fun color(ctx: DiscordContext, system: SystemRecord, member: MemberRecord, color: Int?): Boolean { + color ?: run { + ctx.respondSuccess("Member's color is `${member.color.fromColor()}`") + return true + } - private suspend fun birth(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - val member = database.findMember(system.id, ctx.params["member"]!![0]) - ?: return "Member does not exist. Create one using `pf>member new`" - member.birthday = tryParseLocalDate(ctx.params["birthday"]!![0])?.first + if (!system.canEditMembers(ctx.getUser()!!.id.value)) { + ctx.respondFailure("You don't have access to edit this information.") + return false + } + + member.color = color database.updateMember(member) - return "Member's birthday updated!" - } + ctx.respondSuccess("Member's color is now `${color.fromColor()}!") + return true + } + + suspend fun birthday( + ctx: DiscordContext, + system: SystemRecord, + member: MemberRecord, + birthday: LocalDate?, + clear: Boolean + ): Boolean { + if (clear) { + if (!system.canEditMembers(ctx.getUser()!!.id.value)) { + ctx.respondFailure("You don't have access to edit this information.") + return false + } + + member.birthday = null + database.updateMember(member) + ctx.respondSuccess("Member's birthday cleared!") + return true + } + + birthday ?: run { + member.birthday ?: run { + ctx.respondWarning("Member does not have a birthday.") + return true + } + ctx.respondSuccess("Member's birthday is ${member.birthday}!") + return true + } + + if (!system.canEditMembers(ctx.getUser()!!.id.value)) { + ctx.respondFailure("You don't have access to edit this information.") + return false + } - private suspend fun birthClear(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - val member = database.findMember(system.id, ctx.params["member"]!![0]) - ?: return "Member does not exist. Create one using `pf>member new`" - member.birthday = null + member.birthday = birthday database.updateMember(member) - return "Member's birthday cleared!" + ctx.respondSuccess("Member's birthday is now $birthday!") + return true } - private suspend fun delete(ctx: MessageHolder): String { - val author = ctx.message.author!! - val system = database.fetchSystemFromUser(author) - ?: return "System does not exist. Create one using `pf>system new`" - val member = database.findMember(system.id, ctx.params["member"]!![0]) - ?: return "Member does not exist. Create one using `pf>member new`" + suspend fun delete(ctx: DiscordContext, system: SystemRecord, member: MemberRecord?): Boolean { + if (!system.canEditMembers(ctx.getUser()!!.id.value)) { + ctx.respondFailure("You don't have access to edit this information.") + return false + } + + member ?: run { + ctx.respondFailure("Make sure to provide the name of the member to delete!") + return false + } - TimedYesNoPrompt.build( - runner = author.id, - channel = ctx.message.channel, + ctx.timedYesNoPrompt( message = "Are you sure you want to delete member `${member.asString()}`?\n" + "Their data will be lost forever (A long time!)", - yes = Button("Delete Member", Button.wastebasket, ButtonStyle.Danger) { - database.dropMember(system.id, member.id) + yes = "Delete Member" to { + database.dropMember(member.systemId, member.id) content = "Member deleted" }, + yesEmoji = Emojis.wastebasket ) - return "" + return true } - private suspend fun deleteEmpty(ctx: MessageHolder): String { - database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - return "Make sure to tell me which member you want to delete!" - } + suspend fun create(ctx: DiscordContext, system: SystemRecord, name: String?, ): Boolean { + if (!system.canEditMembers(ctx.getUser()!!.id.value)) { + ctx.respondFailure("You don't have access to edit this information.") + return false + } - private suspend fun createEmpty(ctx: MessageHolder): String { - database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - return "Make sure to provide a name for the new member!" - } + name ?: run { + ctx.respondFailure("Make sure to provide a name for the new member!") + return false + } - private suspend fun create(ctx: MessageHolder): String { - val message = ctx.message - val author = message.author!! - val system = database.fetchSystemFromUser(author) - ?: return "System does not exist. Create one using `pf>system new`" - val name = ctx.params["name"]!![0] val member = database.fetchMemberFromSystemAndName(system.id, name, false) if (member != null) { - TimedYesNoPrompt.build( - runner = author.id, - channel = ctx.message.channel, + ctx.timedYesNoPrompt( message = "You already have a member named \"${member.name}\" (`${member.id}`)." + "\nDo you want to create another member with the same name?", yes = "Create $name" to { @@ -617,14 +911,12 @@ object MemberCommands { ) } else { val newMember = database.createMember(system.id, name) - ctx.respond( - if (newMember != null) { - "Member created with ID `${newMember.id}`." - } else { - "Something went wrong while creating your member. Try again?" - } - ) + newMember ?: run { + ctx.respondFailure("Something went wrong while creating your member. Try again?") + return false + } + ctx.respondSuccess("Member created with ID `${newMember.id}`.") } - return "" + return true } } diff --git a/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/MiscCommands.kt b/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/MiscCommands.kt index 5f25465a..45ee6a3a 100644 --- a/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/MiscCommands.kt +++ b/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/MiscCommands.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -8,133 +8,613 @@ package dev.proxyfox.bot.command +import dev.kord.common.entity.ButtonStyle import dev.kord.common.entity.Permission import dev.kord.common.entity.Snowflake +import dev.kord.core.behavior.channel.MessageChannelBehavior import dev.kord.core.behavior.channel.asChannelOf import dev.kord.core.behavior.channel.threads.ThreadChannelBehavior -import dev.kord.core.entity.Message import dev.kord.rest.NamedFile +import dev.kord.rest.builder.component.option +import dev.kord.rest.builder.interaction.* +import dev.kord.rest.builder.message.modify.actionRow import dev.proxyfox.bot.* -import dev.proxyfox.bot.string.dsl.greedy -import dev.proxyfox.bot.string.dsl.literal -import dev.proxyfox.bot.string.dsl.string -import dev.proxyfox.bot.string.dsl.unixLiteral -import dev.proxyfox.bot.string.parser.MessageHolder -import dev.proxyfox.bot.string.parser.registerCommand +import dev.proxyfox.bot.command.context.* +import dev.proxyfox.bot.command.menu.DiscordScreen +import dev.proxyfox.bot.command.text.MiscTextCommands +import dev.proxyfox.bot.command.text.PkCommands +import dev.proxyfox.bot.command.text.TrustCommands import dev.proxyfox.bot.webhook.GuildMessage import dev.proxyfox.bot.webhook.WebhookUtil +import dev.proxyfox.command.CommandParser import dev.proxyfox.common.* +import dev.proxyfox.common.annotations.DontExpose import dev.proxyfox.database.database import dev.proxyfox.database.displayDate import dev.proxyfox.database.etc.exporter.Exporter import dev.proxyfox.database.etc.importer.ImporterException import dev.proxyfox.database.etc.importer.import -import dev.proxyfox.database.records.misc.AutoProxyMode -import dev.proxyfox.database.records.misc.ProxiedMessageRecord +import dev.proxyfox.database.records.member.MemberRecord +import dev.proxyfox.database.records.misc.* import dev.proxyfox.database.records.system.SystemRecord +import dev.proxyfox.database.records.system.SystemServerSettingsRecord +import dev.proxyfox.sync.PkSync +import io.ktor.client.request.forms.* +import io.ktor.utils.io.jvm.javaio.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.withContext import kotlinx.datetime.Clock -import java.net.URL +import kotlinx.datetime.toJavaLocalDate import kotlin.math.floor /** * Miscellaneous commands * @author Oliver * */ -object MiscCommands { - private val roleMatcher = Regex("\\d+") +object MiscCommands : CommandRegistrar { + val roleMatcher = Regex("\\d+") + var infoInteractionExecutors: HashMap Boolean> = hashMapOf() + var moderationInteractionExecutors: HashMap Boolean> = hashMapOf() + var managementInteractionExecutors: HashMap Boolean> = hashMapOf() + var pluralkitInteractionExecutors: HashMap Boolean> = hashMapOf() + + fun SubCommandBuilder.runs(rootName: String, action: suspend InteractionCommandContext.() -> Boolean) { + when (rootName) { + "info" -> infoInteractionExecutors + "moderation" -> moderationInteractionExecutors + "management" -> managementInteractionExecutors + "pluralkit" -> pluralkitInteractionExecutors + else -> return + }[name] = action + } - suspend fun register() { - printStep("Registering misc commands", 2) - registerCommand(literal("import", ::importEmpty) { - greedy("url", ::import) - }) - //TODO: export --full - registerCommand(literal("export", ::export)) - registerCommand(literal("time", ::time)) - registerCommand(literal("help", ::help)) - registerCommand(literal("explain", ::explain)) - registerCommand(literal("invite", ::invite)) - registerCommand(literal("source", ::source)) - registerCommand(literal(arrayOf("proxy", "p"), ::serverProxyEmpty) { - literal(arrayOf("off", "disable"), ::serverProxyOff) - literal(arrayOf("on", "enable"), ::serverProxyOn) - }) - registerCommand(literal(arrayOf("autoproxy", "ap"), ::proxyEmpty) { - literal(arrayOf("off", "disable"), ::proxyOff) - literal(arrayOf("latch", "l"), ::proxyLatch) - literal(arrayOf("front", "f"), ::proxyFront) - greedy("member", MiscCommands::proxyMember) - }) + override val displayName: String = "Misc" - registerCommand(literal(arrayOf("serverautoproxy", "sap"), ::serverAutoProxyEmpty) { - literal(arrayOf("off", "disable"), ::serverAutoProxyOff) - literal(arrayOf("latch", "l"), ::serverAutoProxyLatch) - literal(arrayOf("front", "f"), ::serverAutoProxyFront) - literal(arrayOf("on", "enable", "fallback", "fb"), ::serverAutoProxyFallback) - greedy("member", MiscCommands::serverAutoProxyMember) - }) + override suspend fun registerSlashCommands() { + deferChatInputCommand("info", "Fetches info about the bot") { + subCommand("debug", "Fetch debug information about the bot") { + runs("info") { + debug(this) + } + } + subCommand("help", "Get help information") { + runs("info") { + respondSuccess(help) + true + } + } + subCommand("about", "Get about information") { + runs("info") { + respondSuccess(explain) + true + } + } + subCommand("source", "Get the source code") { + runs("info") { + respondSuccess(source) + true + } + } + subCommand("invite", "Get the bot invite and the support server invite") { + runs("info") { + respondSuccess(invite) + true + } + } + subCommand("fox", "Gets a random fox picture") { + runs("info") { + getFox(this) + } + } + subCommand("time", "Displays the current time") { + runs("info") { + time(this) + } + } + } + deferChatInputCommand("moderation", "Moderator-only commands") { + subCommand("role", "Access the role required for proxying") { + role("role", "The role required for proxying") { + required = false + } + clear() + runs("moderation") { + val role = value.interaction.command.roles["role"] + val clear = value.interaction.command.booleans["clear"] ?: false + role(this, role?.id?.value?.toString(), clear) + } + } + subCommand("mod-delay", "The amount of time to delay proxying for moderation bots") { + name("delay") + runs("moderation") { + val delay = value.interaction.command.strings["delay"] + val guild = getGuild() ?: run { + respondFailure("Command not ran in server.") + return@runs false + } + delay(this, database.getOrCreateServerSettings(guild), delay) + } + } + subCommand("channel-proxy", "Toggle proxying for a specific channel") { + channel("channel", "The channel to set") { + required = true + } + bool("value", "The value to set") + runs("moderation") { + val channel = value.interaction.command.channels["channel"]!!.id.value.toString() + val enabled = value.interaction.command.booleans["value"] + channelProxy(this, channel, enabled) + } + } + subCommand("force-tag", "Toggle the enforcement of a system tag for this server") { + bool("value", "The value to set") + runs("moderation") { + val enabled = value.interaction.command.booleans["value"] + forceTag(this, enabled) + } + } + } + deferChatInputCommand("management", "Other management commands that don't fit in a category") { + subCommand("import-file", "Import a system") { + attachment("import", "The file to import") { + required = true + } + runs("management") { + import(this, value.interaction.command.attachments["import"]!!.url) + } + } + subCommand("import-url", "Import a system") { + name("import") + runs("management") { + import(this, value.interaction.command.strings["import"]!!) + } + } + subCommand("export", "Export your system") { + runs("management") { + val system = database.fetchSystemFromUser(getUser()) + if (!checkSystem(this, system)) return@runs false + export(this) + } + } + subCommand("autoproxy", "Changes the autoproxy type") { + enum("value", enum = arrayListOf("off", "latch", "front")) + runs("management") { + val system = database.fetchSystemFromUser(getUser()) + if (!checkSystem(this, system)) return@runs false + val type: AutoProxyMode? = when (value.interaction.command.strings["value"]) { + null -> null + "off" -> AutoProxyMode.OFF + "latch" -> AutoProxyMode.LATCH + "front" -> AutoProxyMode.FRONT + else -> AutoProxyMode.MEMBER + } + val member = if (type == AutoProxyMode.MEMBER) { + val mem = database.findMember(system.id, value.interaction.command.strings["member"]!!) + if (!checkMember(this, mem)) return@runs false + mem + } else null + proxy(this, system, type, member) + } + } + subCommand("proxy", "Toggles proxying for this server") { + bool("value", "the value to set") + guild() + runs("management") { + val system = database.fetchSystemFromUser(getUser()) + if (!checkSystem(this, system)) return@runs false + val enabled = value.interaction.command.booleans["value"] + val guildId = + value.interaction.command.integers["server"]?.toULong()?.let { Snowflake(it) } ?: getGuild()?.id + guildId ?: run { + respondFailure("Command not ran in server.") + return@runs false + } + val guild = kord.getGuildOrNull(guildId) ?: run { + respondFailure("Cannot find server. Am I in it?") + return@runs false + } + val serverSystem = database.getOrCreateServerSettingsFromSystem(guild, system.id) + serverProxy(this, serverSystem, enabled) + } + } + subCommand("serverautoproxy", "Changes the autoproxy type for the server") { + enum("value", enum = arrayListOf("off", "latch", "front", "on")) + guild() + runs("management") { + val system = database.fetchSystemFromUser(getUser()) + if (!checkSystem(this, system)) return@runs false + val type: AutoProxyMode? = when (value.interaction.command.strings["value"]) { + null -> null + "off" -> AutoProxyMode.OFF + "latch" -> AutoProxyMode.LATCH + "front" -> AutoProxyMode.FRONT + "on" -> AutoProxyMode.FALLBACK + else -> AutoProxyMode.MEMBER + } + val member = if (type == AutoProxyMode.MEMBER) { + val mem = database.findMember(system.id, value.interaction.command.strings["member"]!!) + if (!checkMember(this, mem)) return@runs false + mem + } else null + val guildId = value.interaction.command.integers["server"]?.toULong()?.let { Snowflake(it) } ?: getGuild()?.id + guildId ?: run { + respondFailure("Command not ran in server.") + return@runs false + } + val guild = kord.getGuildOrNull(guildId) ?: run { + respondFailure("Cannot find server. Am I in it?") + return@runs false + } + val serverSystem = database.getOrCreateServerSettingsFromSystem(guild, system.id) + serverAutoProxy(this, serverSystem, type, member) + } + } + subCommand("edit", "Edit a message proxied by ProxyFox") { + string("content", "The content to replace with") { + required = true + } + integer("message", "The message ID to edit") { + required = false + } + runs("management") { + val system = database.fetchSystemFromUser(getUser()) + if (!checkSystem(this, system, true)) return@runs false + val message = value.interaction.command.integers["message"]?.toULong()?.let { Snowflake(it) } + editMessage(this, system, message, value.interaction.command.strings["content"]!!) + } + } + subCommand("token", "Manage tokens for your system!") { + runs("management") { + val system = database.fetchSystemFromUser(getUser()) + if (!checkSystem(this, system, true)) return@runs false + token(this, system) + } + } + } + deferChatInputCommand("pluralkit", "Commands for PluralKit integration with ProxyFox") { + subCommand("set-token", "Store your PluralKit token") { + name("token", required = true) + runs("pluralkit") { + val system = database.fetchSystemFromUser(getUser()) + if (!checkSystem(this, system)) return@runs false + pkToken(this, system, value.interaction.command.strings["token"]!!, false) + } + } + subCommand("clear-token", "Removes your PluralKit token") { + runs("pluralkit") { + val system = database.fetchSystemFromUser(getUser()) + if (!checkSystem(this, system)) return@runs false + pkToken(this, system, null, true) + } + } + subCommand("import", "Imports your system from PluralKit's API") { + runs("pluralkit") { + val system = database.fetchSystemFromUser(getUser()) + if (!checkSystem(this, system)) return@runs false + syncPk(this, system, false) + } + } + subCommand("export", "Exports your system to PluralKit's API") { + runs("pluralkit") { + val system = database.fetchSystemFromUser(getUser()) + if (!checkSystem(this, system)) return@runs false + syncPk(this, system, true) + } + } + } + } - registerCommand(literal("role", ::roleEmpty) { - unixLiteral("clear", ::roleClear) - greedy("role", ::role) - }) + override suspend fun CommandParser>.registerTextCommands() { + this += MiscTextCommands + this += TrustCommands + this += PkCommands + } - registerCommand(literal("moddelay", ::delayEmpty) { - greedy("delay", ::delay) - }) + suspend fun transfer(ctx: DiscordContext, token: TokenRecord): Boolean { + val system = database.fetchSystemFromId(token.systemId)!! + system.users.forEach { + val user = database.getOrCreateUser(it) + user.systemId = null + database.updateUser(user) + } + val id = ctx.getUser()!!.id.value + system.users = arrayListOf(id) + database.updateSystem(system) + val user = database.getOrCreateUser(id) + user.systemId = system.id + database.updateUser(user) + database.dropToken(token.token) + ctx.respondSuccess("System successfully transferred!") + return true + } - registerCommand(literal(arrayOf("delete", "del"), ::deleteMessage) { - greedy("message", ::deleteMessage) - }) + class CommUpdater(val channel: MessageChannelBehavior) : PkSync.ProgressUpdater { + override suspend fun update(type: String, from: String, to: String) { + channel.createMessage("$type: $from -> $to") + } + } - registerCommand(literal(arrayOf("reproxy", "rp"), ::reproxyMessage) { - greedy("member", ::reproxyMessage) - }) + @OptIn(DontExpose::class) + suspend fun syncPk(ctx: DiscordContext, system: SystemRecord, upload: Boolean): Boolean { + ctx.respondPlain("Pk sync progress:", true) - registerCommand(literal(arrayOf("info", "i"), ::fetchMessageInfo) { - greedy("message", MiscCommands::fetchMessageInfo) - }) + val updater = CommUpdater(ctx.getChannel(true)) - registerCommand(literal(arrayOf("ping", "p"), ::pingMessageAuthor) { - greedy("message", ::pingMessageAuthor) - }) + val res = if (upload) { + PkSync.push(system, updater) + } else { + PkSync.pull(system, updater) + } - registerCommand(literal(arrayOf("edit", "e"), ::editMessage) { - greedy("content", ::editMessage) - }) + if (res.getA() == false) { + updater.channel.createMessage("You don't have a PluralKit token registered.") + return false + } - registerCommand(literal(arrayOf("channel", "c"), ::channelEmpty) { - literal(arrayOf("proxy", "p"), ::channelProxy) { - string("channel", ::channelProxy) { - literal(arrayOf("on", "enable"), ::channelProxyEnable) - literal(arrayOf("of", "disable"), ::channelProxyDisable) - } + if (res.getB() != null) { + val b = res.getB()!! + val message = b.message.replace(system.pkToken!!, "[pktoken]", true) + updater.channel.createMessage(""" + PluralKit returned an error: + `$message` + """.trimIndent()) + return false + } + + val message = if (upload) "exported" to "to" else "imported" to "from" + + updater.channel.createMessage("Successfully ${message.first} system data ${message.second} PluralKit") + + return true + } + + @OptIn(DontExpose::class) + suspend fun pkToken( + ctx: DiscordContext, + system: SystemRecord, + token: String?, + clear: Boolean + ): Boolean { + if (clear) { + if (system.pkToken == null) { + ctx.respondFailure("You don't have a PluralKit token registered.", true) + return false } + + system.pkToken = null + database.updateSystem(system) + ctx.respondSuccess("Cleared your PluralKit token!", true) + return true + } + token ?: run { + ctx.respondSuccess("You have a PluralKit token registered.", true) + return true + } + + if (ctx.getGuild() != null && ctx is DiscordMessageContext) { + ctx.respondFailure("Please do not send your PluralKit token in public.\nI advise you reset it immediately and run this command in DMs") + return false + } + + system.pkToken = token + database.updateSystem(system) + ctx.respondSuccess("Successfully updated your PluralKit token", true) + + return true + } + + // TODO: Allow user to specify guild + suspend fun forceTag(ctx: DiscordContext, enabled: Boolean?): Boolean { + val server = database.getOrCreateServerSettings(ctx.getGuild() ?: run { + ctx.respondFailure("You are not in a server.") + return false }) - registerCommand(literal("debug", ::debug)) + enabled ?: let { + ctx.respondPlain("System tag force is currently ${if (server.enforceTag) "enabled" else "disabled"} for this server.") + return true + } + + if (!ctx.hasRequired(Permission.ManageGuild)) { + ctx.respondFailure("You do not have the proper permissions to run this command.") + return false + } + + server.enforceTag = enabled + database.updateServerSettings(server) + + ctx.respondPlain("System tag force is now ${if (server.enforceTag) "enabled" else "disabled"} for this server.") + return true + } - registerCommand(literal("fox", ::getFox)) + suspend fun getFox(ctx: DiscordContext): Boolean { + ctx.respondEmbed { + val fox = FoxFetch.fetch() + title = "**Link**" + url = fox + image = fox + } + return true } - private suspend fun getFox(ctx: MessageHolder): String { - return FoxFetch.fetch() + suspend fun trust( + ctx: DiscordContext, + system: SystemRecord, + user: ULong, + trustLevel: TrustLevel? + ): Boolean { + trustLevel ?: run { + val trust = system.trust[user] ?: TrustLevel.NONE + ctx.respondPlain("User's trust level is currently `${trust.name}`") + return true + } + + ctx.timedYesNoPrompt( + message = "Are you sure you want to trust this user with level `${trustLevel.name}`?\nThis can be changed at any time.", + yes = "Trust user" to { + system.trust[user] = trustLevel + database.updateSystem(system) + content = "User trust updated." + } + ) + + return true } - private suspend fun debug(ctx: MessageHolder): String { - val shardid = ctx.message.getGuildOrNull()?.id?.value?.toShard() ?: 0 - ctx.respond { + suspend fun token(ctx: DiscordContext, system: SystemRecord): Boolean { + ctx.interactionMenu(true) { + val create = "create" { + this as DiscordScreen + button("api:access") { + val token = database.createToken(system.id, TokenType.API_ACCESS) + edit { + content = "Token created!\nID: ${token.id}\n${token.token}" + components = mutableListOf() + } + close() + } + + button("api:edit") { + val token = database.createToken(system.id, TokenType.API_EDIT) + edit { + content = "Token created!\nID: ${token.id}\n${token.token}" + components = mutableListOf() + } + close() + } + + button("system:transfer") { + val token = database.createToken(system.id, TokenType.SYSTEM_TRANSFER) + edit { + content = "Token created!\nID: ${token.id}\n${token.token}" + components = mutableListOf() + } + close() + } + + onInit { + edit { + content = "What type of token do you want to create?" + components = null + actionRow { + interactionButton(ButtonStyle.Secondary, "api:access") { + label = "API Access" + } + interactionButton(ButtonStyle.Secondary, "api:edit") { + label = "API Edit" + } + interactionButton(ButtonStyle.Secondary, "system:transfer") { + label = "System Transfer" + } + } + } + } + } + val delete = "delete" { + this as DiscordScreen + select("token") { + it.forEach { token -> + database.dropTokenById(system.id, token) + } + edit { + content = "Deleted tokens." + components = mutableListOf() + } + close() + } + button("all") { + database.dropTokens(system.id) + edit { + content = "Deleted all tokens." + components = mutableListOf() + } + close() + } + + onInit { + edit { + content = "Which tokens do you want to delete?" + components = null + actionRow { + stringSelect("token") { + placeholder = "Select tokens to delete" + database.fetchTokens(system.id).forEach { + option("${it.type} - ${it.id}", it.id) + } + } + } + } + } + } + default { + this as DiscordScreen + + button("create") { + setScreen(create) + } + + button("delete") { + setScreen(delete) + } + + onInit { + edit { + content = "What do you want to do?" + components = null + actionRow { + interactionButton(ButtonStyle.Primary, "create") { + emoji = Emojis.check + label = "Create a token" + } + interactionButton(ButtonStyle.Danger, "delete") { + emoji = Emojis.wastebasket + label = "Delete tokens" + } + } + } + } + } + } + return true + } + + suspend fun debug(ctx: DiscordContext): Boolean { + val shardid = ctx.getGuild()?.id?.value?.toShard() ?: 0 + ctx.respondEmbed { title = "ProxyFox Debug" - val gatewayPing = ctx.message.kord.gateway.gateways[shardid]!!.ping.value!! + field { + inline = true + name = "Version" + value = version + } + + field { + inline = true + name = "Git Branch" + value = branch + } + + field { + inline = true + name = "Commit Hash" + value = hash + } + + field { + inline = true + name = "Uptime" + value = "${(Clock.System.now() - startTime).inWholeHours} hours" + } + + val gatewayPing = kord.gateway.gateways[shardid]!!.ping.value!! field { inline = true name = "Shard ID" value = "$shardid" } + field { inline = true name = "Gateway Ping" @@ -157,22 +637,10 @@ object MiscCommands { field { inline = true - name = "Uptime" - value = "${(Clock.System.now() - startTime).inWholeHours} hours" - } - - field { - inline = true - name = "Database Implementation" + name = "Database" value = database.getDatabaseName() } - field { - inline = true - name = "Commit Hash" - value = hash - } - field { inline = true name = "JVM Version" @@ -195,368 +663,278 @@ object MiscCommands { throw DebugException() } - private suspend fun importEmpty(ctx: MessageHolder): String { - return try { - if (ctx.message.attachments.isEmpty()) return "Please attach a file or link to import" - val attach = URL(ctx.message.attachments.toList()[0].url) - val importer = withContext(Dispatchers.IO) { - attach.openStream().reader().use { import(it, ctx.message.author) } - } - "File imported. created ${importer.createdMembers} member(s), updated ${importer.updatedMembers} member(s)" - } catch (exception: ImporterException) { - "Failed to import file: ${exception.message}" - } - } + suspend fun import(ctx: DiscordContext, url: String?): Boolean { + val uri = url.uri() - private suspend fun import(ctx: MessageHolder): String { - return try { - val uri = ctx.params["url"]!![0].uri() + ctx.deferResponse() - uri.invalidUrlMessage("import", exampleExport)?.let { return it } + uri.invalidUrlMessage("import", exampleExport)?.let { + ctx.respondFailure(it) + return false + } + return try { val importer = withContext(Dispatchers.IO) { - uri!!.toURL().openStream().reader().use { import(it, ctx.message.author) } + uri!!.toURL().openStream().reader().use { import(it, ctx.getUser()) } } - "File imported. created ${importer.createdMembers} member(s), updated ${importer.updatedMembers} member(s)" + ctx.respondSuccess("File imported. created ${importer.createdMembers} member(s), updated ${importer.updatedMembers} member(s)") + true } catch (exception: ImporterException) { - "Failed to import file: ${exception.message}" + ctx.respondFailure("Failed to import file: ${exception.message}") + false } } - private suspend fun export(ctx: MessageHolder): String { - database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - val export = Exporter.export(ctx.message.author!!.id.value) - ctx.sendFiles(NamedFile("system.json", export.byteInputStream())) - return "Check your DMs~" + suspend fun export(ctx: DiscordContext): Boolean { + val export = Exporter.export(ctx.getUser()!!.id.value) + val message = ctx.respondFiles( + null, + NamedFile("system.json", ChannelProvider { export.byteInputStream().toByteReadChannel() }) + ) + message.channel.createMessage(message.attachments.first().url) + ctx.respondSuccess("Check your DMs~") + return true } - private fun time(ctx: MessageHolder): String { + suspend fun time(ctx: DiscordContext): Boolean { val date = System.currentTimeMillis() / 1000 - return "It is currently " + ctx.respondSuccess("It is currently ") + return true } - private fun help(ctx: MessageHolder): String = + // TODO: Provide better help + const val help: String = """To view commands for ProxyFox, visit For quick setup: -- pf>system new name -- pf>member new John Doe -- pf>member "John Doe" proxy j:text""" +- /system create +- /member create +- /member proxy-add""" - private fun explain(ctx: MessageHolder): String = + const val explain: String = """ProxyFox is modern Discord bot designed to help systems communicate. It uses discord's webhooks to generate "pseudo-users" which different members of the system can use. Someone will likely be willing to explain further if need be.""" - private fun invite(ctx: MessageHolder): String = - """Use to invite ProxyFox to your server! + val invite: String = + """Use to invite ProxyFox to your server! To get support, head on over to https://discord.gg/q3yF8ay9V7""" - private fun source(ctx: MessageHolder): String = - "Source code for ProxyFox is available at https://github.com/The-ProxyFox-Group/ProxyFox!" - - private suspend fun proxyEmpty(ctx: MessageHolder): String { - database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - return "Please provide whether you want autoproxy set to `off`, `latch`, `front`, or a member" - } + const val source: String = + "Source code for ProxyFox is available at !" - private suspend fun proxyLatch(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - system.autoType = AutoProxyMode.LATCH - database.updateSystem(system) - return "Autoproxy mode is now set to `latch`" - } - - private suspend fun proxyFront(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - system.autoType = AutoProxyMode.FRONT - database.updateSystem(system) - return "Autoproxy mode is now set to `front`" - } - - private suspend fun proxyMember(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - val member = database.findMember(system.id, ctx.params["member"]!![0]) - ?: return "Member does not exist. Create one using `pf>member new`" - system.autoType = AutoProxyMode.MEMBER - system.autoProxy = member.id - database.updateSystem(system) - return "Autoproxy mode is now set to ${member.name}" - } + suspend fun proxy(ctx: DiscordContext, system: SystemRecord, mode: AutoProxyMode?, member: MemberRecord?): Boolean { + mode ?: run { + val currMember = system.autoProxy?.let { database.fetchMemberFromSystem(system.id, it) } + ctx.respondSuccess("Autoproxy is set to ${currMember?.showDisplayName() ?: system.autoType.name}") + return true + } - private suspend fun proxyOff(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - system.autoType = AutoProxyMode.OFF + system.autoType = mode + val response = if (member != null) {system.autoProxy = member.id; "Now autoproxying as ${member.showDisplayName()}"} else "Autoproxy mode is now set to ${mode.name}" database.updateSystem(system) - return "Autoproxy disabled" + ctx.respondSuccess(response) + return true } - private suspend fun serverAutoProxyEmpty(ctx: MessageHolder): String { - database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - return "Please provide whether you want autoproxy set to `off`, `latch`, `front`, or a member" - } - - private suspend fun serverAutoProxyLatch(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - val systemServer = database.getOrCreateServerSettingsFromSystem(ctx.message.getGuild(), system.id) - systemServer.autoProxyMode = AutoProxyMode.LATCH - database.updateSystemServerSettings(systemServer) - return "Autoproxy mode for this server is now set to `latch`" - } - - private suspend fun serverAutoProxyFront(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - val systemServer = database.getOrCreateServerSettingsFromSystem(ctx.message.getGuild(), system.id) - systemServer.autoProxyMode = AutoProxyMode.FRONT - database.updateSystemServerSettings(systemServer) - return "Autoproxy mode for this server is now set to `front`" - } - - private suspend fun serverAutoProxyFallback(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - val systemServer = database.getOrCreateServerSettingsFromSystem(ctx.message.getGuild(), system.id) - systemServer.autoProxyMode = AutoProxyMode.FALLBACK - database.updateSystemServerSettings(systemServer) - return "Autoproxy for this server is now using your global settings." - } - - private suspend fun serverAutoProxyMember(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - val member = database.findMember(system.id, ctx.params["member"]!![0]) - ?: return "Member does not exist. Create one using `pf>member new`" - val systemServer = database.getOrCreateServerSettingsFromSystem(ctx.message.getGuild(), system.id) - systemServer.autoProxyMode = AutoProxyMode.MEMBER - systemServer.autoProxy = member.id - database.updateSystemServerSettings(systemServer) - return "Autoproxy mode for this server is now set to ${member.name}" - } + suspend fun serverAutoProxy(ctx: DiscordContext, systemServer: SystemServerSettingsRecord, mode: AutoProxyMode?, member: MemberRecord?): Boolean { + mode ?: run { + val currMember = systemServer.autoProxy?.let { database.fetchMemberFromSystem(systemServer.systemId, it) } + ctx.respondSuccess("Autoproxy is set to ${currMember?.showDisplayName() ?: systemServer.autoProxyMode.name}") + return true + } - private suspend fun serverAutoProxyOff(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - val systemServer = database.getOrCreateServerSettingsFromSystem(ctx.message.getGuild(), system.id) - systemServer.autoProxyMode = AutoProxyMode.OFF + systemServer.autoProxyMode = mode + val response = if (member != null) {systemServer.autoProxy = member.id; "Now autoproxying as ${member.showDisplayName()}"} else "Autoproxy mode is now set to ${mode.name}" database.updateSystemServerSettings(systemServer) - return "Autoproxy disabled for this server." - } - - private suspend fun serverProxyEmpty(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - val systemServer = database.getOrCreateServerSettingsFromSystem(ctx.message.getGuild(), system.id) - return "Proxy for this server is currently ${if (systemServer.proxyEnabled) "enabled" else "disabled"}." + ctx.respondSuccess(response) + return true } - private suspend fun serverProxyOn(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - val systemServer = database.getOrCreateServerSettingsFromSystem(ctx.message.getGuild(), system.id) - systemServer.proxyEnabled = true - database.updateSystemServerSettings(systemServer) - return "Proxy for this server has been enabled" - } + suspend fun serverProxy(ctx: DiscordContext, systemServer: SystemServerSettingsRecord, enabled: Boolean?): Boolean { + enabled ?: run { + ctx.respondSuccess("Proxy for this server is currently ${if (systemServer.proxyEnabled) "enabled" else "disabled"}.") + return false + } - private suspend fun serverProxyOff(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - val systemServer = database.getOrCreateServerSettingsFromSystem(ctx.message.getGuild(), system.id) - systemServer.proxyEnabled = false + systemServer.proxyEnabled = enabled database.updateSystemServerSettings(systemServer) - return "Proxy for this server has been disabled" - } - - private suspend fun roleEmpty(ctx: MessageHolder): String { - val server = database.getOrCreateServerSettings(ctx.message.getGuild()) - if (server.proxyRole == 0UL) return "There is no proxy role set." - return "Current role is <@&${server.proxyRole}>" + ctx.respondSuccess("Proxy for this server has been ${if (enabled) "enabled" else "disabled"}") + return true } - private suspend fun role(ctx: MessageHolder): String { - if (!ctx.hasRequired(Permission.ManageGuild)) - return "You do not have the proper permissions to run this command" - val server = database.getOrCreateServerSettings(ctx.message.getGuild()) - val roleRaw = ctx.params["role"]!![0] + // TODO: Allow user to specify guild + suspend fun role(ctx: DiscordContext, roleRaw: String?, clear: Boolean): Boolean { + val server = database.getOrCreateServerSettings(ctx.getGuild() ?: run { + ctx.respondFailure("You are not in a server.") + return false + }) + if (!ctx.hasRequired(Permission.ManageGuild)) { + ctx.respondFailure("You do not have the proper permissions to run this command.") + return false + } + if (clear) { + server.proxyRole = 0UL + database.updateServerSettings(server) + ctx.respondSuccess("Role cleared!") + } + roleRaw ?: run { + if (server.proxyRole == 0UL) { + ctx.respondFailure("There is no proxy role set.") + return false + } + ctx.respondSuccess("Current role is <@&${server.proxyRole}>") + return true + } val role = roleMatcher.find(roleRaw)?.value?.toULong() - ?: ctx.message.getGuild().roles.filter { it.name == roleRaw }.firstOrNull()?.id?.value - ?: return "Please provide a role to set" + ?: ctx.getGuild()!!.roles.filter { it.name == roleRaw }.firstOrNull()?.id?.value + ?: run { + ctx.respondFailure("Please provide a role to set.") + return false + } server.proxyRole = role database.updateServerSettings(server) - return "Role updated!" - } - - private suspend fun roleClear(ctx: MessageHolder): String { - if (!ctx.hasRequired(Permission.ManageGuild)) - return "You do not have the proper permissions to run this command" - val server = database.getOrCreateServerSettings(ctx.message.getGuild()) - server.proxyRole = 0UL - database.updateServerSettings(server) - return "Role removed!" + ctx.respondSuccess("Role updated!") + return true } - private suspend fun delayEmpty(ctx: MessageHolder): String { - val server = database.getOrCreateServerSettings(ctx.message.getGuild()) - return if (server.moderationDelay <= 0) { - "There is no moderation delay present." - } else { - "Current moderation delay is ${server.moderationDelay}ms" + suspend fun delay(ctx: DiscordContext, server: ServerSettingsRecord, delayStr: String?): Boolean { + if (!ctx.hasRequired(Permission.ManageGuild)) { + ctx.respondFailure("You do not have the proper permissions to run this command") + return false + } + delayStr ?: run { + if (server.moderationDelay <= 0) { + ctx.respondFailure("There is no moderation delay present.") + return false + } + ctx.respondSuccess("Current moderation delay is ${server.moderationDelay}ms") + return true + } + val delay = delayStr.parseDuration() + delay.right?.let { + ctx.respondFailure(it) + return false } - } - - private suspend fun delay(ctx: MessageHolder): String { - if (!ctx.hasRequired(Permission.ManageGuild)) - return "You do not have the proper permissions to run this command" - val server = database.getOrCreateServerSettings(ctx.message.getGuild()) - val delay = ctx.params["delay"]!![0].parseDuration() - delay.right?.let { return it } var millis = delay.left!!.inWholeMilliseconds if (millis > 30000L) { millis = 30000L } server.moderationDelay = millis.toShort() database.updateServerSettings(server) - return "Moderation delay set to ${millis}ms" - } - - private suspend fun getMessageFromContext(system: SystemRecord, ctx: MessageHolder): Pair { - val messageIdString = ctx.params["message"]?.get(0) - val messageSnowflake: Snowflake? = messageIdString?.let { Snowflake(it) } - val channelId = ctx.message.channelId - val channel = ctx.message.channel.fetchChannelOrNull() - var message = if (messageSnowflake != null) - channel?.getMessage(messageSnowflake) - else ctx.message.referencedMessage - - val databaseMessage = if (message != null) - database.fetchMessage(message.id) - else { - val m = database.fetchLatestMessage(system.id, channelId) - message = m?.newMessageId?.let { Snowflake(it) }?.let { nullOn404 { channel?.getMessage(it) } } - m - } - - return message to databaseMessage - } - private suspend fun getSystemlessMessage(ctx: MessageHolder): Pair { - val messageIdString = ctx.params["message"]?.get(0) - val messageSnowflake: Snowflake? = messageIdString?.let { Snowflake(it) } - val channel = ctx.message.channel.fetchChannelOrNull() - val message = if (messageSnowflake != null) - channel?.getMessage(messageSnowflake) - else ctx.message.referencedMessage - if (message == null) return null to null - val databaseMessage = database.fetchMessage(message.id) - return message to databaseMessage + ctx.respondSuccess("Moderation delay set to ${millis}ms!") + return true } - private suspend fun deleteMessage(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - if (system == null) { - ctx.respond("System does not exist. Create one using `pf>system new`", true) - return "" - } - val messages = getMessageFromContext(system, ctx) - val message = messages.first - if (message == null) { - ctx.respond("Unable to find message to delete.", true) - return "" + suspend fun deleteMessage(ctx: DiscordContext, system: SystemRecord, message: Snowflake?): Boolean { + val messages = ctx.getDatabaseMessage(system, message) + val discordMessage = messages.first + discordMessage ?: run { + ctx.respondFailure("Unable to find message to delete.", true) + return false } val databaseMessage = messages.second - if (databaseMessage == null) { - ctx.respond("This message is either too old or wasn't proxied by ProxyFox", true) - return "" + databaseMessage ?: run { + ctx.respondFailure("This message is either too old or wasn't proxied by ProxyFox", true) + return false } if (databaseMessage.systemId != system.id) { - ctx.respond("You weren't the original creator of this message.", true) - return "" + ctx.respondFailure("You weren't the original creator of this message.", true) + return false } - message.delete("User requested message deletion.") - ctx.message.delete("User requested message deletion") + discordMessage.delete("User requested message deletion.") + ctx.tryDeleteTrigger("User requested message deletion") databaseMessage.deleted = true database.updateMessage(databaseMessage) - return "" + ctx.optionalSuccess("Message deleted.") + return true } - private suspend fun reproxyMessage(ctx: MessageHolder): String { - val guild = ctx.message.getGuildOrNull() ?: return "Run this in a server." - val system = database.fetchSystemFromUser(ctx.message.author) - if (system == null) { - ctx.respond("System does not exist. Create one using `pf>system new`", true) - return "" + suspend fun reproxyMessage(ctx: DiscordContext, system: SystemRecord, message: Snowflake?, member: MemberRecord?): Boolean { + member ?: run { + ctx.respondFailure("Please provide the member to reproxy as.") + return false } - val messages = getMessageFromContext(system, ctx) - val message = messages.first - if (message == null) { - ctx.respond("Unable to find message to delete.", true) - return "" + + val messages = ctx.getDatabaseMessage(system, message) + val discordMessage = messages.first + discordMessage ?: run { + ctx.respondFailure("Unable to find message to delete.", true) + return false } val databaseMessage = messages.second - if (databaseMessage == null) { - ctx.respond("Targeted message is either too old or wasn't proxied by ProxyFox", true) - return "" + databaseMessage ?: run { + ctx.respondFailure("This message is either too old or wasn't proxied by ProxyFox", true) + return false } if (databaseMessage.systemId != system.id) { - ctx.respond("You weren't the original creator of the targeted message.", true) - return "" - } - val member = database.findMember(system.id, ctx.params["member"]?.get(0)!!) - if (member == null) { - ctx.respond("Couldn't find member to proxy as", true) - return "" + ctx.respondFailure("You weren't the original creator of this message.", true) + return false } - val serverMember = database.fetchMemberServerSettingsFromSystemAndMember(guild, system.id, member.id) + val server = database.getOrCreateServerSettings(discordMessage.getGuild()) - val guildMessage = GuildMessage(message, guild, message.channel.asChannelOf(), ctx.message.author!!) + val serverSystem = database.getOrCreateServerSettingsFromSystem(databaseMessage.guildId, system.id) + + if (serverSystem.autoProxyMode == AutoProxyMode.LATCH) { + serverSystem.autoProxy = member.id + database.updateSystemServerSettings(serverSystem) + } else if (serverSystem.autoProxyMode == AutoProxyMode.FALLBACK && system.autoType == AutoProxyMode.LATCH) { + system.autoProxy = member.id + database.updateSystem(system) + } - WebhookUtil.prepareMessage(guildMessage, message.content, system, member, null, serverMember)?.send(true) + val serverMember = database.fetchMemberServerSettingsFromSystemAndMember(ctx.getGuild(), system.id, member.id) + + val guildMessage = + GuildMessage(discordMessage, ctx.getGuild()!!, discordMessage.channel.asChannelOf(), ctx.getUser()!!) + + WebhookUtil.prepareMessage( + guildMessage, + discordMessage.content, + system, + member, + null, + serverMember, + server.moderationDelay.toLong(), + server.enforceTag + )?.send(true) ?: throw AssertionError("Message could not be reproxied. Is the contents empty?") databaseMessage.deleted = true database.updateMessage(databaseMessage) - ctx.message.delete("User requested message deletion") - - return "" + ctx.tryDeleteTrigger("User requested message deletion") + ctx.optionalSuccess("Message reproxied.") + return true } - private suspend fun fetchMessageInfo(ctx: MessageHolder): String { - val messages = getSystemlessMessage(ctx) + suspend fun fetchMessageInfo(ctx: DiscordContext, message: Snowflake?): Boolean { + val messages = ctx.getDatabaseMessage(null, message) val discordMessage = messages.first - if (discordMessage == null) { - ctx.respond("Unable to find message to fetch info of", true) - return "" + discordMessage ?: run { + ctx.respondFailure("Unable to find message to delete.", true) + return false } - val databaseMessage = messages.second - if (databaseMessage == null) { - ctx.respond("Targeted message is either too old or wasn't proxied by ProxyFox", true) - return "" + databaseMessage ?: run { + ctx.respondFailure("This message is either too old or wasn't proxied by ProxyFox", true) + return false } val system = database.fetchSystemFromId(databaseMessage.systemId) if (system == null) { - ctx.respond("Targeted message's system has since been deleted.", true) - return "" + ctx.respondFailure("Targeted message's system has since been deleted.", true) + return false } val member = database.fetchMemberFromSystem(databaseMessage.systemId, databaseMessage.memberId) if (member == null) { - ctx.respond("Targeted message's member has since been deleted.", true) - return "" + ctx.respondFailure("Targeted message's member has since been deleted.", true) + return false } val guild = discordMessage.getGuild() val settings = database.fetchMemberServerSettingsFromSystemAndMember(guild, system.id, member.id) - ctx.respond(dm=true) { + ctx.respondEmbed(true) { val systemName = system.name ?: system.id author { name = member.displayName?.let { "$it (${member.name})\u2007•\u2007$systemName" } ?: "${member.name}\u2007•\u2007$systemName" @@ -586,122 +964,97 @@ To get support, head on over to https://discord.gg/q3yF8ay9V7""" member.birthday?.let { field { name = "Birthday" - value = it.displayDate() + value = it.toJavaLocalDate().displayDate() inline = true } } footer { text = "Member ID \u2009• \u2009${member.id}\u2007|\u2007System ID \u2009• \u2009${system.id}\u2007|\u2007Created " } - timestamp = system.timestamp.toKtInstant() + timestamp = system.timestamp } - ctx.message.delete("User requested message deletion") - - return "" + ctx.tryDeleteTrigger("User requested message deletion") + ctx.optionalSuccess("Info fetched.") + return true } - private suspend fun editMessage(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - if (system == null) { - ctx.respond("System does not exist. Create one using `pf>system new`", true) - return "" - } - val messages = getMessageFromContext(system, ctx) - val message = messages.first - if (message == null) { - ctx.respond("Unable to find message to edit.", true) - return "" + suspend fun editMessage(ctx: DiscordContext, system: SystemRecord, message: Snowflake?, content: String?): Boolean { + val messages = ctx.getDatabaseMessage(system, message) + val discordMessage = messages.first + discordMessage ?: run { + ctx.respondFailure("Unable to find message to edit.", true) + return false } - val channel = message.getChannel() val databaseMessage = messages.second - if (databaseMessage == null) { - ctx.respond("Targeted message is either too old or wasn't proxied by ProxyFox", true) - return "" - } - if (databaseMessage.systemId != system.id) { - ctx.respond("You weren't the original creator of the targeted message.", true) - return "" + databaseMessage ?: run { + ctx.respondFailure("This message is either too old or wasn't proxied by ProxyFox", true) + return false } - val content = ctx.params["content"]?.get(0) - if (content == null) { - ctx.respond( + content ?: run { + ctx.respondFailure( "Please provide message content to edit with.\n" + "To delete the message, run `pf>delete`", true ) - return "" + return false } - - val webhook = WebhookUtil.createOrFetchWebhookFromCache(channel) - webhook.edit(message.id, if (channel is ThreadChannelBehavior) channel.id else null) { + val channel = ctx.getChannel() + val webhook = WebhookUtil.createOrFetchWebhookFromCache(channel.fetchChannel()) + webhook.edit(discordMessage.id, if (channel is ThreadChannelBehavior) channel.id else null) { this.content = content } - ctx.message.delete("User requested message deletion") - - return "" + ctx.tryDeleteTrigger("User requested message deletion") + ctx.optionalSuccess("Edited message") + return true } - private suspend fun pingMessageAuthor(ctx: MessageHolder): String { - val messages = getSystemlessMessage(ctx) + suspend fun pingMessageAuthor(ctx: DiscordContext, message: Snowflake?): Boolean { + val messages = ctx.getDatabaseMessage(null, message) val discordMessage = messages.first if (discordMessage == null) { - ctx.respond("Targeted message doesn't exist.", true) - return "" + ctx.respondFailure("Targeted message doesn't exist.", true) + return false } val databaseMessage = messages.second if (databaseMessage == null) { - ctx.respond("Targeted message is either too old or wasn't proxied by ProxyFox") - return "" + ctx.respondFailure("Targeted message is either too old or wasn't proxied by ProxyFox") + return false } - ctx.message.delete("User requested message deletion") + ctx.tryDeleteTrigger("User requested message deletion") // TODO: Add a jump to message embed - ctx.respond("Psst.. ${databaseMessage.memberName} (<@${databaseMessage.userId}>)$ellipsis You were pinged by <@${ctx.message.author!!.id}>") - return "" - } - - private suspend fun channelEmpty(ctx: MessageHolder): String { - return "Please provide a channel command" - } - - private suspend fun channelProxy(ctx: MessageHolder): String { - val channel = ctx.params["channel"]?.get(0) - ?: ctx.message.channelId.value.toString() - val channelId = channel.toULongOrNull() - ?: channel.substring(2, channel.length-1).toULongOrNull() - ?: return "Provided string is not a valid channel" - val channelSettings = database.getOrCreateChannel(ctx.message.getGuild().id.value, channelId) - return "Proxying is currently ${if (channelSettings.proxyEnabled) "enabled" else "disabled"} for <#$channelId>." - } - - private suspend fun channelProxyEnable(ctx: MessageHolder): String { - if (!ctx.hasRequired(Permission.ManageChannels)) - return "You do not have the proper permissions to run this command" - val channel = ctx.params["channel"]?.get(0) - ?: ctx.message.channelId.value.toString() - val channelId = channel.toULongOrNull() - ?: channel.substring(2, channel.length - 1).toULongOrNull() - ?: return "Provided string is not a valid channel" - val channelSettings = database.getOrCreateChannel(ctx.message.getGuild().id.value, channelId) - if (channelSettings.proxyEnabled) return "Proxying is already enabled for <#$channelId>" - channelSettings.proxyEnabled = true - database.updateChannel(channelSettings) - return "Proxying is now enabled for <#$channelId>" + ctx.getChannel().createMessage("Psst.. ${databaseMessage.memberName} (<@${databaseMessage.userId}>)$ellipsis You were pinged by <@${ctx.getUser()!!.id}>") + ctx.optionalSuccess("Author pinged.") + return true } - private suspend fun channelProxyDisable(ctx: MessageHolder): String { - if (!ctx.hasRequired(Permission.ManageChannels)) - return "You do not have the proper permissions to run this command" - val channel = ctx.params["channel"]?.get(0) - ?: ctx.message.channelId.value.toString() + suspend fun channelProxy(ctx: DiscordContext, channel: String?, value: Boolean?): Boolean { + if (!ctx.hasRequired(Permission.ManageChannels)) { + ctx.respondFailure("You do not have the proper permissions to run this command") + return false + } + ctx.getGuild() ?: run { + ctx.respondFailure("You need to run this command in a server.") + } + channel ?: run { + ctx.respondFailure("Please provide a channel to change") + return false + } val channelId = channel.toULongOrNull() ?: channel.substring(2, channel.length - 1).toULongOrNull() - ?: return "Provided string is not a valid channel" - val channelSettings = database.getOrCreateChannel(ctx.message.getGuild().id.value, channelId) - if (!channelSettings.proxyEnabled) return "Proxying is already disabled for <#$channelId>" - channelSettings.proxyEnabled = false + ?: run { + ctx.respondFailure("Provided string is not a valid channel") + return false + } + val channelSettings = database.getOrCreateChannel(ctx.getChannel().id.value, channelId) + value ?: run { + ctx.respondSuccess("Proxying is currently ${if (channelSettings.proxyEnabled) "enabled" else "disabled"} for <#$channelId>.") + return true + } + channelSettings.proxyEnabled = value database.updateChannel(channelSettings) - return "Proxying is now disabled for <#$channelId>" + ctx.respondSuccess("Proxying is now ${if (value) "enabled" else "disabled"} for <#$channelId>") + return true } } \ No newline at end of file diff --git a/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/SwitchCommands.kt b/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/SwitchCommands.kt index 95a7cc5b..563aeaf4 100644 --- a/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/SwitchCommands.kt +++ b/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/SwitchCommands.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -8,139 +8,280 @@ package dev.proxyfox.bot.command -import dev.kord.common.entity.ButtonStyle +import dev.kord.rest.builder.interaction.SubCommandBuilder +import dev.kord.rest.builder.interaction.string +import dev.kord.rest.builder.interaction.subCommand +import dev.proxyfox.bot.Emojis +import dev.proxyfox.bot.command.context.DiscordContext +import dev.proxyfox.bot.command.context.InteractionCommandContext +import dev.proxyfox.bot.command.context.runs +import dev.proxyfox.bot.deferChatInputCommand import dev.proxyfox.bot.parseDuration -import dev.proxyfox.bot.prompts.Button -import dev.proxyfox.bot.prompts.Pager -import dev.proxyfox.bot.prompts.TimedYesNoPrompt -import dev.proxyfox.bot.string.dsl.greedy -import dev.proxyfox.bot.string.dsl.literal -import dev.proxyfox.bot.string.dsl.stringList -import dev.proxyfox.bot.string.parser.MessageHolder -import dev.proxyfox.bot.string.parser.registerCommand -import dev.proxyfox.common.printStep +import dev.proxyfox.command.CommandParser +import dev.proxyfox.command.NodeHolder +import dev.proxyfox.command.node.builtin.greedy +import dev.proxyfox.command.node.builtin.literal +import dev.proxyfox.command.node.builtin.stringList +import dev.proxyfox.common.trimEach import dev.proxyfox.database.database +import dev.proxyfox.database.records.system.SystemRecord import dev.proxyfox.database.records.system.SystemSwitchRecord -import java.time.Instant - -object SwitchCommands { - suspend fun register() { - printStep("Registering switch commands", 2) - registerCommand(literal(arrayOf("switch", "sw"), ::empty) { - literal(arrayOf("out", "o"), ::out) - literal(arrayOf("move", "mv", "m"), ::moveEmpty) { - greedy("time", ::move) +import kotlinx.datetime.Clock +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.minus + +object SwitchCommands : CommandRegistrar { + var interactionExecutors: HashMap Boolean> = hashMapOf() + + fun SubCommandBuilder.runs(action: suspend InteractionCommandContext.() -> Boolean) { + interactionExecutors[name] = action + } + + override val displayName: String = "Switch" + + override suspend fun registerSlashCommands() { + deferChatInputCommand("switch", "Create or manage switches!") { + subCommand("create", "Create a switch") { + string("members", "The members to use, comma separated") { + required = true + } + system() + runs { + val system = getSystem() + if (!checkSystem(this, system)) return@runs false + val members = value.interaction.command.strings["members"]!!.split(",").toTypedArray() + members.trimEach() + switch(this, system, members) + } } - literal(arrayOf("delete", "del", "remove"), ::delete) - literal(arrayOf("list", "l"), ::list) - stringList("members", ::switch) - }) + subCommand("out", "Marks that no-one's fronting") { + system() + runs { + val system = getSystem() + if (!checkSystem(this, system)) return@runs false + out(this, system) + } + } + subCommand("delete", "Deletes the latest switch") { + system() + runs { + val system = getSystem() + if (!checkSystem(this, system)) return@runs false + val switch = database.fetchLatestSwitch(system.id) + if (!checkSwitch(this, switch)) return@runs false + val oldSwitch = database.fetchSecondLatestSwitch(system.id) + delete(this, system, switch, oldSwitch) + } + } + subCommand("move", "Moves the latest switch") { + name("time") + system() + runs { + val system = getSystem() + if (!checkSystem(this, system)) return@runs false + val switch = database.fetchLatestSwitch(system.id) + if (!checkSwitch(this, switch)) return@runs false + val oldSwitch = database.fetchSecondLatestSwitch(system.id) + val time = value.interaction.command.strings["time"]!! + move(this, system, switch, oldSwitch, time) + } + } + subCommand("list", "Lists your switches") { + system() + runs { + val system = getSystem() + if (!checkSystem(this, system)) return@runs false + list(this, system) + } + } + } } - private suspend fun empty(ctx: MessageHolder): String = "Make sure to provide a switch command!" + suspend fun > NodeHolder.registerSwitchCommands(getSys: suspend DiscordContext.() -> SystemRecord?) { + literal("switch", "sw") { + runs { + val system = getSys() + if (!checkSystem(this, system)) return@runs false + respondFailure("Please provide a switch subcommand.") + false + } + literal("out", "o") { + runs { + val system = getSys() + if (!checkSystem(this, system)) return@runs false + out(this, system) + } + } + literal("delete", "del", "remove", "rem") { + runs { + val system = getSys() + if (!checkSystem(this, system)) return@runs false + val switch = database.fetchLatestSwitch(system.id) + if (!checkSwitch(this, switch)) return@runs false + val oldSwitch = database.fetchSecondLatestSwitch(system.id) + delete(this, system, switch, oldSwitch) + } + } + literal("move","mv","m") { + runs { + val system = getSys() + if (!checkSystem(this, system)) return@runs false + val switch = database.fetchLatestSwitch(system.id) + if (!checkSwitch(this, switch)) return@runs false + val oldSwitch = database.fetchSecondLatestSwitch(system.id) + move(this, system, switch, oldSwitch, null) + } + greedy("time") { getTime -> + runs { + val system = getSys() + if (!checkSystem(this, system)) return@runs false + val switch = database.fetchLatestSwitch(system.id) + if (!checkSwitch(this, switch)) return@runs false + val oldSwitch = database.fetchSecondLatestSwitch(system.id) + move(this, system, switch, oldSwitch, getTime()) + } + } + } + literal("list", "l") { + runs { + val system = getSys() + if (!checkSystem(this, system)) return@runs false + list(this, system) + } + } + stringList("members") { getMembers -> + runs { + val system = getSys() + if (!checkSystem(this, system)) return@runs false + switch(this, system, getMembers().toTypedArray()) + } + } + } + } + + override suspend fun CommandParser>.registerTextCommands() { + registerSwitchCommands { + database.fetchSystemFromUser(getUser()) + } + } + + private suspend fun out(ctx: DiscordContext, system: SystemRecord): Boolean { + if (!system.canEditSwitches(ctx.getUser()!!.id.value)) { + ctx.respondFailure("You don't have access to edit this information.") + return false + } - private suspend fun out(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" database.createSwitch(system.id, listOf()) - return "Switch registered." + ctx.respondSuccess("Switch registered. Take care!") + return true } - private suspend fun moveEmpty(ctx: MessageHolder): String = "Please provide a time to move the switch back" - private suspend fun move(ctx: MessageHolder): String { - val author = ctx.message.author!! - val system = database.fetchSystemFromUser(author) - ?: return "System does not exist. Create one using `pf>system new`" - val switch = database.fetchLatestSwitch(system.id) - ?: return "It looks like you haven't registered any switches yet" - val oldSwitch = database.fetchSecondLatestSwitch(system.id) + private suspend fun move(ctx: DiscordContext, system: SystemRecord, switch: SystemSwitchRecord, oldSwitch: SystemSwitchRecord?, time: String?): Boolean { + if (!system.canEditSwitches(ctx.getUser()!!.id.value)) { + ctx.respondFailure("You don't have access to edit this information.") + return false + } - val either = ctx.params["time"]!![0].parseDuration() + time ?: run { + ctx.respondFailure("Please provide a time to move the switch back") + return false + } + + val either = time.parseDuration() either.right?.let { - return it + ctx.respondFailure(it) + return false } - val nowMinus = Instant.now().minusMillis(either.left!!.inWholeMilliseconds) + val nowMinus = Clock.System.now().minus(either.left!!.inWholeMilliseconds, DateTimeUnit.MILLISECOND) if (oldSwitch != null && oldSwitch.timestamp > nowMinus) { - return "It looks like you're trying to break the space-time continuum..\n" + - "The provided time is set before the previous switch" + ctx.respondFailure("It looks like you're trying to break the space-time continuum..\n" + + "The provided time is set before the previous switch") + return false } val members = switch.memberIds.map { database.fetchMemberFromSystem(system.id, it)?.showDisplayName() ?: "*Unknown*" }.joinToString(", ") - TimedYesNoPrompt.build( - runner = author.id, - channel = ctx.message.channel, - message = "Are you sure you want to move the switch $members back to ?", - yes = Button("Move switch", Button.move, ButtonStyle.Primary) { + ctx.timedYesNoPrompt( + message = "Are you sure you want to move the switch $members back to ?", + yes = "Move switch" to { switch.timestamp = nowMinus database.updateSwitch(switch) content = "Switch updated." - } + }, + yesEmoji = Emojis.move ) - return "" + return true } - private suspend fun delete(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - val switch = database.fetchLatestSwitch(system.id) - ?: return "No switches registered" - - val switchBefore = database.fetchSecondLatestSwitch(system.id)?.let { - "The next latest switch is ${it.membersAsString()} ()." - } ?: "There is no previous switch." + private suspend fun delete(ctx: DiscordContext, system: SystemRecord, switch: SystemSwitchRecord, oldSwitch: SystemSwitchRecord?): Boolean { + if (!system.canEditSwitches(ctx.getUser()!!.id.value)) { + ctx.respondFailure("You don't have access to edit this information.") + return false + } - val epoch = switch.timestamp.epochSecond + val epoch = switch.timestamp.epochSeconds - TimedYesNoPrompt.build( - runner = ctx.message.author!!.id, - channel = ctx.message.channel, + ctx.timedYesNoPrompt( message = """ - Are you sure you want to delete the latest switch (${switch.membersAsString()}, )? - $switchBefore + Are you sure you want to delete the latest switch (${switch.membersAsString()}, )? ${if (oldSwitch != null) "\nThe previous switch would be at " else ""} The data will be lost forever (A long time!) """.trimIndent(), - yes = Button("Delete switch", Button.wastebasket, ButtonStyle.Danger) { + yes = "Delete switch" to { database.dropSwitch(switch) content = "Switch deleted." }, + yesEmoji = Emojis.wastebasket, + danger = true ) - return "" + return true } - private suspend fun list(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" + private suspend fun list(ctx: DiscordContext, system: SystemRecord): Boolean { + if (!system.canAccess(ctx.getUser()!!.id.value)) { + // Force the bot to treat the system as nonexistent + return checkSystem(ctx, null) + } + // We know the system exists here, will be non-null val switches = database.fetchSortedSwitchesFromSystem(system.id)!! - Pager.build(ctx.message.author!!.id, ctx.message.channel, switches, 20, { - title = "[$it] Front history of ${system.showName}" - }, { it.membersAsString("**", "**") + " ()\n" }) + ctx.pager( + switches, + 20, + { title = "[$it] Front history of ${system.name ?: system.id}" }, + { membersAsString("**", "**") + " ()\n" }, + false + ) - return "" + return true } - private suspend fun switch(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - val members = ArrayList() + private suspend fun switch(ctx: DiscordContext, system: SystemRecord, members: Array): Boolean { + if (!system.canEditSwitches(ctx.getUser()!!.id.value)) { + ctx.respondFailure("You don't have access to edit this information.") + return false + } + + val membersOut = ArrayList() var memberString = "" - ctx.params["members"]!!.forEach { - val member = database.findMember(system.id, it) ?: return "Couldn't find member `$it`, do they exist?" - members += member.id + members.forEach { + val member = database.findMember(system.id, it) ?: run { + ctx.respondFailure("Couldn't find member `$it`, do they exist?") + return false + } + membersOut += member.id memberString += "`${member.showDisplayName()}`, " } memberString = memberString.substring(0, memberString.length - 2) - database.createSwitch(system.id, members) - + database.createSwitch(system.id, membersOut) - return "Switch registered, current fronters: $memberString" + ctx.respondSuccess("Switch registered! Current fronters: $memberString") + return true } private suspend fun SystemSwitchRecord.membersAsString(prefix: String = "", postfix: String = ""): String { diff --git a/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/SystemCommands.kt b/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/SystemCommands.kt index 31fb7f98..11481ce4 100644 --- a/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/SystemCommands.kt +++ b/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/SystemCommands.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -8,86 +8,385 @@ package dev.proxyfox.bot.command -import dev.kord.common.entity.ButtonStyle import dev.kord.rest.NamedFile +import dev.kord.rest.builder.interaction.SubCommandBuilder +import dev.kord.rest.builder.interaction.subCommand import dev.proxyfox.bot.* -import dev.proxyfox.bot.prompts.Button -import dev.proxyfox.bot.prompts.Pager -import dev.proxyfox.bot.prompts.TimedYesNoPrompt -import dev.proxyfox.bot.string.dsl.greedy -import dev.proxyfox.bot.string.dsl.literal -import dev.proxyfox.bot.string.dsl.unixLiteral -import dev.proxyfox.bot.string.parser.MessageHolder -import dev.proxyfox.bot.string.parser.registerCommand +import dev.proxyfox.bot.command.MemberCommands.registerBaseMemberCommands +import dev.proxyfox.bot.command.SwitchCommands.registerSwitchCommands +import dev.proxyfox.bot.command.context.DiscordContext +import dev.proxyfox.bot.command.context.InteractionCommandContext +import dev.proxyfox.bot.command.context.runs +import dev.proxyfox.bot.command.context.system +import dev.proxyfox.bot.command.types.attachment +import dev.proxyfox.command.CommandParser +import dev.proxyfox.command.node.builtin.* import dev.proxyfox.common.fromColor -import dev.proxyfox.common.printStep import dev.proxyfox.common.toColor import dev.proxyfox.database.database import dev.proxyfox.database.etc.exporter.Exporter +import dev.proxyfox.database.records.system.SystemRecord +import io.ktor.client.request.forms.* +import io.ktor.utils.io.jvm.javaio.* /** * Commands for accessing and changing system settings * @author Oliver * */ -object SystemCommands { - suspend fun register() { - printStep("Registering system commands", 2) - registerCommand(literal(arrayOf("system", "s"), ::empty) { - literal(arrayOf("new", "n", "create", "add"), ::createEmpty) { - greedy("name", ::create) - } +object SystemCommands : CommandRegistrar { + var interactionExecutors: HashMap Boolean> = hashMapOf() - literal(arrayOf("name", "rename"), ::accessName) { - greedy("name", ::rename) - } + fun SubCommandBuilder.runs(action: suspend InteractionCommandContext.() -> Boolean) { + interactionExecutors[name] = action + } - literal(arrayOf("list", "l"), ::list) { - unixLiteral(arrayOf("by-message-count", "bmc"), ::listByMessage) - unixLiteral(arrayOf("verbose", "v"), ::listVerbose) - } + override val displayName: String = "System" - literal(arrayOf("color", "colour"), ::colorEmpty) { - greedy("color", ::color) + override suspend fun registerSlashCommands() { + deferChatInputCommand("system", "Manage or create a system!") { + subCommand("fetch", "Fetch a system card!") { + system() + runs { + val system = getSystem() + if (!checkSystem(this, system)) return@runs false + access(this, system) + } } - - literal(arrayOf("pronouns", "p"), ::pronounsEmpty) { - unixLiteral("raw", ::pronounsRaw) - greedy("pronouns", ::pronouns) + subCommand("create", "Create a system") { + name(required = false) + runs { + val name = value.interaction.command.strings["name"] + create(this, name) + } } + subCommand("delete", "Delete the system") { + runs { + val system = database.fetchSystemFromUser(getUser()) + if (!checkSystem(this, system)) return@runs false - literal(arrayOf("description", "desc", "d"), ::descriptionEmpty) { - unixLiteral("raw", ::descriptionRaw) - greedy("desc", ::description) + delete(this) + } } - - literal(arrayOf("avatar", "pfp"), ::avatarEmpty) { - unixLiteral("raw", ::avatarRaw) - unixLiteral("clear", ::avatarClear) - unixLiteral("delete", ::avatarClear) - greedy("avatar", ::avatar) + access("system", "name") { + name(required = false) + raw() + clear() + system() + runs { + val system = getSystem() + if (!checkSystem(this, system)) return@runs false + val name = value.interaction.command.strings["name"] + val raw = value.interaction.command.booleans["raw"] ?: false + val clear = value.interaction.command.booleans["clear"] ?: false + name(this, system, name, raw, clear) + } } - - literal("tag", ::tagEmpty) { - unixLiteral("raw", ::tagRaw) - unixLiteral("clear", ::tagClear) - unixLiteral("delete", ::tagClear) - greedy("tag", ::tag) + subCommand("list", "List your system members") { + system() + bool("by-message", "Whether to sort by message count") + bool("verbose", "Whether to display information verbosely") + runs { + val system = getSystem() + if (!checkSystem(this, system)) return@runs false + val byMessage = value.interaction.command.booleans["by-message"] ?: false + val verbose = value.interaction.command.booleans["verbose"] ?: false + list(this, system, byMessage, verbose) + } + } + access("system", "color") { + name("color", required = false) + system() + runs { + val system = getSystem() + if (!checkSystem(this, system)) return@runs false + val color = value.interaction.command.strings["color"] + + color(this, system, color?.toColor()) + } + } + access("system", "pronouns") { + name("pronouns", required = false) + raw() + clear() + system() + runs { + val system = getSystem() + if (!checkSystem(this, system)) return@runs false + val pronouns = value.interaction.command.strings["pronouns"] + val raw = value.interaction.command.booleans["raw"] ?: false + val clear = value.interaction.command.booleans["clear"] ?: false + pronouns(this, system, pronouns, raw, clear) + } + } + access("system", "description") { + system() + name("description", required = false) + raw() + clear() + runs { + val system = getSystem() + if (!checkSystem(this, system)) return@runs false + val desc = value.interaction.command.strings["description"] + val raw = value.interaction.command.booleans["raw"] ?: false + val clear = value.interaction.command.booleans["clear"] ?: false + + description(this, system, desc, raw, clear) + } + } + access("system", "avatar") { + avatar() + clear() + system() + runs { + val system = getSystem() + if (!checkSystem(this, system)) return@runs false + val avatar = value.interaction.command.attachments["avatar"]?.data?.url + val clear = value.interaction.command.booleans["clear"] ?: false + + avatar(this, system, avatar, clear) + } + } + access("system", "tag") { + name("tag", required = false) + raw() + clear() + system() + runs { + val system = getSystem() + if (!checkSystem(this, system)) return@runs false + val tag = value.interaction.command.strings["tag"] + val raw = value.interaction.command.booleans["raw"] ?: false + val clear = value.interaction.command.booleans["clear"] ?: false + + tag(this, system, tag, raw, clear) + } } + } + } - literal(arrayOf("delete", "del", "remove"), ::delete) - }) + override suspend fun CommandParser>.registerTextCommands() { + literal("list", "l") { + runs { + val system = database.fetchSystemFromUser(getUser()) + if (!checkSystem(this, system)) return@runs false + list(this, system, false, false) + } + unix("params") { getParams -> + runs { + val system = database.fetchSystemFromUser(getUser()) + if (!checkSystem(this, system)) return@runs false + val params = getParams().toTypedArray() + val byMessage = hasUnixValue(params, "by-message-count") || hasUnixValue(params, "bmc") + val verbose = hasUnixValue(params, "verbose") || hasUnixValue(params, "v") + list(this, system, byMessage, verbose) + } + } + } + literal("system", "sys", "s") { + literal("new", "n", "create", "add") { + runs { + create(this, null) + } + greedy("name") { getName -> + runs { + create(this, getName()) + } + } + } + literal("delete", "del", "remove", "rem") { + runs { + val system = database.fetchSystemFromUser(getUser()) + if (!checkSystem(this, system)) return@runs false + delete(this) + } + } + system { getSys -> + runs { + val system = getSys() + if (!checkSystem(this, system)) return@runs false + access(this, system) + } + literal("name", "rename") { + runs { + val system = getSys() + if (!checkSystem(this, system)) return@runs false + name(this, system, null, false, false) + } + unixLiteral("raw") { + runs { + val system = getSys() + if (!checkSystem(this, system)) return@runs false + name(this, system, null, true, false) + } + } + unixLiteral("clear", "remove") { + runs { + val system = getSys() + if (!checkSystem(this, system)) return@runs false + name(this, system, null, false, true) + } + } + greedy("name") { getName -> + runs { + val system = getSys() + if (!checkSystem(this, system)) return@runs false + name(this, system, getName(), false, false) + } + } + } + literal("list", "l") { + runs { + val system = getSys() + if (!checkSystem(this, system)) return@runs false + list(this, system, false, false) + } + unix("params") { getParams -> + runs { + val system = getSys() + if (!checkSystem(this, system)) return@runs false + val params = getParams().toTypedArray() + val byMessage = hasUnixValue(params, "by-message-count") || hasUnixValue(params, "bmc") + val verbose = hasUnixValue(params, "verbose") || hasUnixValue(params, "v") + list(this, system, byMessage, verbose) + } + } + } + literal("color", "colour", "c") { + runs { + val system = getSys() + if (!checkSystem(this, system)) return@runs false + color(this, system, null) + } + greedy("color") { getColor -> + runs { + val system = getSys() + if (!checkSystem(this, system)) return@runs false + color(this, system, getColor().toColor()) + } + } + } + literal("pronouns", "p") { + runs { + val system = getSys() + if (!checkSystem(this, system)) return@runs false + pronouns(this, system, null, false, false) + } + unixLiteral("raw") { + runs { + val system = getSys() + if (!checkSystem(this, system)) return@runs false + pronouns(this, system, null, true, false) + } + } + unixLiteral("clear", "remove") { + runs { + val system = getSys() + if (!checkSystem(this, system)) return@runs false + pronouns(this, system, null, false, true) + } + } + greedy("pronouns") { getPronouns -> + runs { + val system = getSys() + if (!checkSystem(this, system)) return@runs false + pronouns(this, system, getPronouns(), false, false) + } + } + } + literal("description", "desc", "d") { + runs { + val system = getSys() + if (!checkSystem(this, system)) return@runs false + description(this, system, null, false, false) + } + unixLiteral("raw") { + runs { + val system = getSys() + if (!checkSystem(this, system)) return@runs false + description(this, system, null, true, false) + } + } + unixLiteral("clear", "remove") { + runs { + val system = getSys() + if (!checkSystem(this, system)) return@runs false + description(this, system, null, false, true) + } + } + greedy("description") { getDesc -> + runs { + val system = getSys() + if (!checkSystem(this, system)) return@runs false + description(this, system, getDesc(), false, false) + } + } + } + literal("avatar", "pfp") { + runs { + val system = getSys() + if (!checkSystem(this, system)) return@runs false + avatar(this, system, null, false) + } + unixLiteral("clear", "remove") { + runs { + val system = getSys() + if (!checkSystem(this, system)) return@runs false + avatar(this, system, null, true) + } + } + attachment("avatar") { getAvatar -> + runs { + val system = getSys() + if (!checkSystem(this, system)) return@runs false + avatar(this, system, getAvatar().url, false) + } + } + string("avatar") { getAvatar -> + runs { + val system = getSys() + if (!checkSystem(this, system)) return@runs false + avatar(this, system, getAvatar(), false) + } + } + } + literal("tag", "t") { + runs { + val system = getSys() + if (!checkSystem(this, system)) return@runs false + tag(this, system, null, false, false) + } + unixLiteral("raw") { + runs { + val system = getSys() + if (!checkSystem(this, system)) return@runs false + tag(this, system, null, true, false) + } + } + unixLiteral("clear", "remove") { + runs { + val system = getSys() + if (!checkSystem(this, system)) return@runs false + tag(this, system, null, false, true) + } + } + greedy("description") { getTag -> + runs { + val system = getSys() + if (!checkSystem(this, system)) return@runs false + tag(this, system, getTag(), false, false) + } + } + } - registerCommand(literal(arrayOf("list", "l"), ::list) { - unixLiteral(arrayOf("by-message-count", "bmc"), ::listByMessage) - unixLiteral(arrayOf("verbose", "v"), ::listVerbose) - }) + registerBaseMemberCommands(getSys) + registerSwitchCommands(getSys) + } + } } - private suspend fun empty(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - val members = database.fetchTotalMembersFromUser(ctx.message.author) - ctx.respond { + private suspend fun access(ctx: DiscordContext, system: SystemRecord): Boolean { + val members = database.fetchTotalMembersFromSystem(system.id) + ctx.respondEmbed { title = system.name ?: system.id color = system.color.kordColor() system.avatarUrl?.let { @@ -121,217 +420,301 @@ object SystemCommands { footer { text = "ID \u2009• \u2009${system.id}\u2007|\u2007Created " } - timestamp = system.timestamp.toKtInstant() + timestamp = system.timestamp } - return "" + return true } - private suspend fun createEmpty(ctx: MessageHolder): String { - database.getOrCreateSystem(ctx.message.author!!) - return "System created! See `pf>help` for how to set up your system further!" - } + private suspend fun create(ctx: DiscordContext, name: String?): Boolean { + if (database.fetchSystemFromUser(ctx.getUser()) != null) { + + return false + } - private suspend fun create(ctx: MessageHolder): String { - val system = database.getOrCreateSystem(ctx.message.author!!) - system.name = ctx.params["name"]!![0] + val system = database.getOrCreateSystem(ctx.getUser()!!) + system.name = name database.updateSystem(system) - return "System created with name ${system.name}! See `pf>help` for how to set up your system further!" + val add = if (name != null) " with name $name" else "" + ctx.respondSuccess("System created$add! See `pf>help` or `/info help` for how to set up your system further.") + return true } - private suspend fun renameEmpty(ctx: MessageHolder): String { - database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - return "Make sure to provide me with a name to update your system!" - } + private suspend fun name(ctx: DiscordContext, system: SystemRecord, name: String?, raw: Boolean, clear: Boolean): Boolean { + if (clear) { + if (!system.hasFullAccess(ctx.getUser()!!.id.value)) { + ctx.respondFailure("You don't have access to edit this information.") + return false + } + + system.name = null + database.updateSystem(system) + ctx.respondSuccess("System name cleared!") + } + + name ?: run { + system.name ?: run { + ctx.respondFailure("System doesn't have a name set.") + return false + } - private suspend fun rename(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - system.name = ctx.params["name"]!![0] + if (raw) + ctx.respondPlain("`${system.name}`") + else ctx.respondSuccess("System's name is ${system.name}") + } + + if (!system.hasFullAccess(ctx.getUser()!!.id.value)) { + ctx.respondFailure("You don't have access to edit this information.") + return false + } + + system.name = name database.updateSystem(system) - return "System name updated to ${system.name}!" + ctx.respondSuccess("System name updated to ${system.name}!") + return true } - private suspend fun accessName(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - return "System's name is ${system.name}" - } + private suspend fun list(ctx: DiscordContext, system: SystemRecord, byMessage: Boolean, verbose: Boolean): Boolean { + if (verbose) { + ctx.respondEmbed { + system(system, nameTransformer = { "Members of $it" }) + val proxies = database.fetchProxiesFromSystem(system.id) + for (m in database.fetchMembersFromSystem(system.id)!!.sortedBy { + if (byMessage) it.messageCount + it.name + }) { + val memberProxies = proxies?.filter { it.memberId == m.id } + field { + name = "${m.asString()} [`${m.id}`]" + value = + if (memberProxies.isNullOrEmpty()) "*No proxy tags set.*" else memberProxies.joinToString( + "\uFEFF``\n``\uFEFF", + "``\uFEFF", + "\uFEFF``" + ) + inline = true + } + } + } + return true + } - private suspend fun list(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" val proxies = database.fetchProxiesFromSystem(system.id)!! - Pager.build( - ctx.message.author!!.id, - ctx.message.channel, - database.fetchMembersFromSystem(system.id)!!.map { m -> m to proxies.filter { it.memberId == m.id } }, + + ctx.pager( + database.fetchMembersFromSystem(system.id)!!.sortedBy { + if (byMessage) it.messageCount + it.name + }.map { m -> m to proxies.filter { it.memberId == m.id } }, 20, - { page -> system(system, nameTransformer = { "[$page] Members of $it" }) }, + { page -> system(system, nameTransformer = { "[$page] Members of ${system.name ?: system.id}" }) }, { - val str = if (it.second.isNotEmpty()) it.second.joinToString("\uFEFF``, ``\uFEFF", " (``\uFEFF", "\uFEFF``)") else "" - "`${it.first.id}`\u2007•\u2007**${it.first.name}**${str}\n" + val str = if (second.isNotEmpty()) second.joinToString( + "\uFEFF``, ``\uFEFF", + " (``\uFEFF", + "\uFEFF``)" + ) else "" + "`${first.id}`\u2007•\u2007**${first.name}**${str}\n" }, + false ) - return "" + return true } - private suspend fun listByMessage(ctx: MessageHolder): String { - // TODO: Make it sort by message count - return list(ctx) + suspend fun color(ctx: DiscordContext, system: SystemRecord, color: Int?): Boolean { + color ?: run { + ctx.respondSuccess("Member's color is `${system.color.fromColor()}`") + return true + } + + if (!system.hasFullAccess(ctx.getUser()!!.id.value)) { + ctx.respondFailure("You don't have access to edit this information.") + return false + } + + system.color = color + database.updateSystem(system) + ctx.respondSuccess("Member's color is now `${color.fromColor()}!") + return true } - private suspend fun listVerbose(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - ctx.respond { - system(system, nameTransformer = { "Members of $it" }) - val proxies = database.fetchProxiesFromSystem(system.id) - for (m in database.fetchMembersFromSystem(system.id)!!) { - val memberProxies = proxies?.filter { it.memberId == m.id } - field { - name = "${m.asString()} [`${m.id}`]" - value = if (memberProxies.isNullOrEmpty()) "*No proxy tags set.*" else memberProxies.joinToString("\uFEFF``\n``\uFEFF", "``\uFEFF", "\uFEFF``") - inline = true - } + private suspend fun pronouns(ctx: DiscordContext, system: SystemRecord, pronouns: String?, raw: Boolean, clear: Boolean): Boolean { + if (clear) { + if (!system.hasFullAccess(ctx.getUser()!!.id.value)) { + ctx.respondFailure("You don't have access to edit this information.") + return false } + + system.pronouns = null + database.updateSystem(system) + ctx.respondSuccess("System pronouns cleared!") + return true } - return "" - } - private suspend fun colorEmpty(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - return system.color.fromColor()?.let { "System's color is `$it` " } ?: "There's no color set." + pronouns ?: run { + system.pronouns ?: run { + ctx.respondFailure("System doesn't have pronouns set") + return false + } - } + if (raw) { + ctx.respondPlain("`${system.pronouns}`") + return true + } - private suspend fun color(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - system.color = ctx.params["color"]!![0].toColor() - database.updateSystem(system) - return "Member's color updated!" - } + ctx.respondSuccess("System's pronouns are ${system.pronouns}") + return true + } + + if (!system.hasFullAccess(ctx.getUser()!!.id.value)) { + ctx.respondFailure("You don't have access to edit this information.") + return false + } - private suspend fun pronouns(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - system.pronouns = ctx.params["pronouns"]!![0] + system.pronouns = pronouns database.updateSystem(system) - return "Pronouns updated!" + ctx.respondSuccess("System pronouns updated to $pronouns!") + return true } - private suspend fun pronounsRaw(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - return system.pronouns?.let { "``$it``" } ?: "There's no pronouns set." - } + suspend fun description(ctx: DiscordContext, system: SystemRecord, description: String?, raw: Boolean, clear: Boolean): Boolean { + if (clear) { + if (!system.hasFullAccess(ctx.getUser()!!.id.value)) { + ctx.respondFailure("You don't have access to edit this information.") + return false + } - private suspend fun pronounsEmpty(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - return system.pronouns?.let { "System's pronouns are set to $it" } ?: "There's no pronouns set." - } + system.description = null + database.updateSystem(system) + ctx.respondSuccess("System's description cleared!") + return true + } + + description ?: run { + system.description ?: run { + ctx.respondWarning("System has no description set") + return true + } + + if (raw) + ctx.respondPlain("```md\n${system.description}```") + else ctx.respondSuccess("System's description is ${system.description}") + + return true + } + + if (!system.hasFullAccess(ctx.getUser()!!.id.value)) { + ctx.respondFailure("You don't have access to edit this information.") + return false + } - private suspend fun description(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - system.description = ctx.params["desc"]!![0] + system.description = description database.updateSystem(system) - return "Description updated!" - } + ctx.respondSuccess("System description updated!") - private suspend fun descriptionRaw(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - return system.description?.let { "```md\n$it```" } ?: "There's no description set." + return true } - private suspend fun descriptionEmpty(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - return system.description ?: "Description not set." - } + suspend fun avatar(ctx: DiscordContext, system: SystemRecord, avatar: String?, clear: Boolean): Boolean { + if (clear) { + if (!system.hasFullAccess(ctx.getUser()!!.id.value)) { + ctx.respondFailure("You don't have access to edit this information.") + return false + } - private suspend fun avatar(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" + system.avatarUrl = null + database.updateSystem(system) + ctx.respondSuccess("System's avatar cleared!") + return true + } - val uri = ctx.params["avatar"]!![0].uri() + avatar ?: run { + system.avatarUrl ?: run { + ctx.respondWarning("Member doesn't have an avatar set.") + return true + } - uri.invalidUrlMessage("system avatar")?.let { return it } + ctx.respondEmbed { + image = system.avatarUrl + color = system.color.kordColor() + } + return true + } + + if (!system.hasFullAccess(ctx.getUser()!!.id.value)) { + ctx.respondFailure("You don't have access to edit this information.") + return false + } + + val uri = avatar.uri() + + uri.invalidUrlMessage("system avatar")?.let { + ctx.respondFailure(it) + return false + } system.avatarUrl = uri.toString() database.updateSystem(system) - return "System avatar updated!" - } + ctx.respondSuccess("Member's avatar updated!") - private suspend fun avatarClear(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - system.avatarUrl = null - database.updateSystem(system) - return "System avatar cleared!" + return true } - private suspend fun avatarRaw(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - return "`${system.avatarUrl}`" - } + private suspend fun tag(ctx: DiscordContext, system: SystemRecord, tag: String?, raw: Boolean, clear: Boolean): Boolean { + if (clear) { + if (!system.hasFullAccess(ctx.getUser()!!.id.value)) { + ctx.respondFailure("You don't have access to edit this information.") + return false + } + system.tag = null + database.updateSystem(system) + ctx.respondSuccess("System tag cleared!") + return true + } - private suspend fun avatarEmpty(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - return system.avatarUrl ?: "System avatar not set." - } + tag ?: run { + system.tag ?: run { + ctx.respondFailure("System doesn't have a tag set.") + return false + } - private suspend fun tag(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - system.tag = ctx.params["tag"]!![0] - database.updateSystem(system) - return "System tag updated!" - } + if (raw) { + ctx.respondPlain("`${system.tag}`") + return true + } - private suspend fun tagClear(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - system.tag = null - database.updateSystem(system) - return "System tag cleared!" - } + ctx.respondSuccess("System's tag is ${system.tag}") - private suspend fun tagRaw(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - return "`${system.tag}`" - } + return true + } - private suspend fun tagEmpty(ctx: MessageHolder): String { - val system = database.fetchSystemFromUser(ctx.message.author) - ?: return "System does not exist. Create one using `pf>system new`" - return system.tag ?: "System tag not set." - } + if (!system.hasFullAccess(ctx.getUser()!!.id.value)) { + ctx.respondFailure("You don't have access to edit this information.") + return false + } - private suspend fun delete(ctx: MessageHolder): String { - val author = ctx.message.author!! - database.fetchSystemFromUser(author) - ?: return "System does not exist. Create one using `pf>system new`" + system.tag = tag + database.updateSystem(system) + ctx.respondSuccess("System tag updated to $tag!") + return true + } - TimedYesNoPrompt.build( - runner = author.id, - channel = ctx.message.channel, + private suspend fun delete(ctx: DiscordContext): Boolean { + ctx.timedYesNoPrompt( message = "Are you sure you want to delete your system?\n" + "The data will be lost forever (A long time!)", - yes = Button("Delete system", Button.wastebasket, ButtonStyle.Danger) { - val export = Exporter.export(author.id.value) - ctx.sendFiles(NamedFile("system.json", export.byteInputStream())) - database.dropSystem(author) + yes = "Delete system" to { + val export = Exporter.export(ctx.getUser()!!.id.value) + val message = ctx.respondFiles( + null, + NamedFile("system.json", ChannelProvider { export.byteInputStream().toByteReadChannel() }) + ) + ctx.getChannel(true).createMessage(message.attachments.first().url) + database.dropSystem(ctx.getUser()!!) content = "System deleted." }, + yesEmoji = Emojis.wastebasket, + danger = true ) - return "" + return true } } \ No newline at end of file diff --git a/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/context/DiscordContext.kt b/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/context/DiscordContext.kt new file mode 100644 index 00000000..818dad77 --- /dev/null +++ b/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/context/DiscordContext.kt @@ -0,0 +1,305 @@ +/* + * Copyright (c) 2022-2023, The ProxyFox Group + * + * This Source Code is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.proxyfox.bot.command.context + +import dev.kord.common.entity.ButtonStyle +import dev.kord.common.entity.DiscordPartialEmoji +import dev.kord.common.entity.Permission +import dev.kord.common.entity.Snowflake +import dev.kord.core.behavior.channel.MessageChannelBehavior +import dev.kord.core.behavior.channel.createMessage +import dev.kord.core.entity.* +import dev.kord.rest.NamedFile +import dev.kord.rest.builder.component.option +import dev.kord.rest.builder.message.EmbedBuilder +import dev.kord.rest.builder.message.modify.MessageModifyBuilder +import dev.kord.rest.builder.message.modify.actionRow +import dev.kord.rest.builder.message.modify.embed +import dev.proxyfox.bot.Emojis +import dev.proxyfox.bot.command.menu.DiscordMenu +import dev.proxyfox.bot.command.menu.DiscordScreen +import dev.proxyfox.bot.schedule +import dev.proxyfox.bot.scheduler +import dev.proxyfox.command.CommandContext +import dev.proxyfox.command.NodeActionParam +import dev.proxyfox.command.menu.CommandMenu +import dev.proxyfox.command.menu.CommandScreen +import dev.proxyfox.command.node.CommandNode +import dev.proxyfox.command.node.builtin.int +import dev.proxyfox.command.node.builtin.string +import dev.proxyfox.common.ceilDiv +import dev.proxyfox.database.database +import dev.proxyfox.database.records.misc.ProxiedMessageRecord +import dev.proxyfox.database.records.system.SystemRecord +import kotlin.math.min +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes + +abstract class DiscordContext(override val value: T) : CommandContext() { + override suspend fun respondFailure(text: String, private: Boolean): T = respondPlain("❌ $text", private) + override suspend fun respondSuccess(text: String, private: Boolean): T = respondPlain("✅️ $text", private) + override suspend fun respondWarning(text: String, private: Boolean): T = respondPlain("⚠️ $text", private) + + abstract fun getAttachment(): Attachment? + abstract suspend fun getChannel(private: Boolean = false): MessageChannelBehavior + abstract suspend fun getGuild(): Guild? + abstract suspend fun getUser(): User? + abstract suspend fun getMember(): Member? + abstract suspend fun respondEmbed( + private: Boolean = false, + text: String? = null, + embed: suspend EmbedBuilder.() -> Unit + ): T + + abstract suspend fun deferResponse(private: Boolean = false) + + abstract suspend fun tryDeleteTrigger(reason: String? = null) + + abstract suspend fun optionalSuccess(text: String): T + + suspend fun respondFiles(text: String? = null, vararg files: NamedFile): Message = getChannel(true).createMessage { + content = text + this.files.addAll(files) + } + + suspend fun hasRequired(permission: Permission): Boolean { + val author = getMember() ?: return false + return author.getPermissions().contains(permission) + } + + abstract suspend fun getDatabaseMessage( + system: SystemRecord?, + messageId: Snowflake? + ): Pair + + override suspend fun menu(action: suspend CommandMenu.() -> Unit) { + interactionMenu { + action() + } + } + + abstract suspend fun interactionMenu(private: Boolean = false, action: suspend DiscordMenu.() -> Unit) + + suspend fun getSys(system: String? = null): SystemRecord? = + if (system == null) + database.fetchSystemFromUser(getUser()!!.id) + else database.fetchSystemFromId(system) + suspend fun timedYesNoPrompt( + message: String, + yes: Pair Unit>, + no: Pair Unit> = "Cancel" to { + content = "Action cancelled." + }, + timeout: Duration = 1.minutes, + yesEmoji: DiscordPartialEmoji = Emojis.check, + noEmoji: DiscordPartialEmoji = Emojis.multiply, + timeoutAction: suspend MessageModifyBuilder.() -> Unit = no.second, + danger: Boolean = false, + private: Boolean = false + ) { + interactionMenu(private) { + default { + this as DiscordScreen + onInit { + edit { + content = message + actionRow { + interactionButton(if (danger) ButtonStyle.Danger else ButtonStyle.Primary, "yes") { + emoji = yesEmoji + label = yes.first + } + interactionButton(ButtonStyle.Secondary, "no") { + emoji = noEmoji + label = no.first + } + } + } + scheduler.schedule(timeout) { + if (!closed) { + edit { + components = arrayListOf() + timeoutAction() + } + close() + } + } + } + button("yes") { + edit { + components = arrayListOf() + yes.second(this) + } + close() + } + button("no") { + edit { + components = arrayListOf() + no.second(this) + } + close() + } + } + } + } + + suspend fun pager( + values: List, + pageSize: Int, + embedBuilder: suspend EmbedBuilder.(String) -> Unit, + getString: suspend T.() -> String, + private: Boolean + ) { + val pages = ceilDiv(values.size, pageSize) + var page = 0 + + interactionMenu(private) { + lateinit var default: CommandScreen + val select = "select" { + this as DiscordScreen + select("page") { + page = it[0].toInt() + setScreen(default) + } + + onInit { + edit { + content = "Select a page" + embeds = mutableListOf() + actionRow { + stringSelect("page") { + for (i in 0 until pages) { + option("${i+1}", "$i") + } + } + } + } + } + } + default = default { + this as DiscordScreen + val update: suspend () -> Unit = { + edit { + content = null + embeds = mutableListOf() + embed { + embedBuilder("${page+1} / $pages") + var str = "" + for (i in page * pageSize until min(page * pageSize + pageSize, values.size)) { + str += values[i]!!.getString() + } + description = str + } + actionRow { + interactionButton(ButtonStyle.Primary, "skipToFirst") { + emoji = Emojis.rewind + } + interactionButton(ButtonStyle.Primary, "back") { + emoji = Emojis.last + } + interactionButton(ButtonStyle.Primary, "next") { + emoji = Emojis.next + } + interactionButton(ButtonStyle.Primary, "skipToLast") { + emoji = Emojis.fastforward + } + } + actionRow { + interactionButton(ButtonStyle.Secondary, "select") { + emoji = Emojis.numbers + label = "Select Page" + } + interactionButton(ButtonStyle.Danger, "close") { + emoji = Emojis.multiply + label = "Close" + } + } + } + } + + button("skipToFirst") { + page = 0 + update() + } + button("back") { + page-- + if (page < 0) page = 0 + update() + } + button("next") { + page++ + if (page >= pages) page = pages-1 + update() + } + button("skipToLast") { + page = pages-1 + update() + } + button("select") { + setScreen(select) + } + button("close") { + edit { + content = "Pager closed." + embeds = mutableListOf() + components = mutableListOf() + } + close() + } + + onInit(update) + } + } + } +} + +// Get a DiscordContext. +fun > CommandNode.runs(action: suspend DiscordContext.() -> Boolean) { + executes { + if (this !is DiscordContext) return@executes false + action() + } +} + +suspend fun > CommandNode.guild(action: NodeActionParam) { + action { + if (this !is DiscordContext) return@action null + getGuild()?.id + } + int("server") { + action { + Snowflake(it()) + } + } +} + +suspend fun > CommandNode.system(action: NodeActionParam) { + action { + if (this !is DiscordContext) return@action null + database.fetchSystemFromUser(getUser()) + } + string("sysid") { + action { + val id = it() + (database.fetchSystemFromId(id) ?: database.fetchSystemFromUser( + id.toULongOrNull() ?: return@action null + ))?.let { + if (!it.canAccess((this@action as DiscordContext).getUser()!!.id.value)) + return@action null + return@action it + } + } + } +} + +suspend fun > CommandNode.responds(content: String) { + runs { + respondPlain(content) + true + } +} diff --git a/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/context/DiscordMessageContext.kt b/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/context/DiscordMessageContext.kt new file mode 100644 index 00000000..b0ed038d --- /dev/null +++ b/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/context/DiscordMessageContext.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2022-2023, The ProxyFox Group + * + * This Source Code is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.proxyfox.bot.command.context + +import dev.kord.common.entity.Snowflake +import dev.kord.core.behavior.channel.MessageChannelBehavior +import dev.kord.core.behavior.channel.createMessage +import dev.kord.core.entity.* +import dev.kord.rest.builder.message.EmbedBuilder +import dev.proxyfox.bot.command.menu.DiscordMenu +import dev.proxyfox.bot.command.menu.DiscordMessageMenu +import dev.proxyfox.common.applyAsync +import dev.proxyfox.database.database +import dev.proxyfox.database.records.misc.ProxiedMessageRecord +import dev.proxyfox.database.records.system.SystemRecord +import kotlin.jvm.optionals.getOrNull + +class DiscordMessageContext(message: Message, override val command: String): DiscordContext(message) { + override fun getAttachment(): Attachment? { + return value.attachments.stream().findFirst().getOrNull() + } + + override suspend fun getChannel(private: Boolean): MessageChannelBehavior { + return if (private) + value.author?.getDmChannelOrNull() + ?: value.channel + else value.channel + } + + override suspend fun getGuild(): Guild? { + return value.getGuildOrNull() + } + + override suspend fun getUser(): User? { + return value.author + } + + override suspend fun getMember(): Member? { + return value.getAuthorAsMemberOrNull() + } + + override suspend fun respondEmbed( + private: Boolean, + text: String?, + embed: suspend EmbedBuilder.() -> Unit + ): Message { + return getChannel(private).createMessage { + content = text + + embeds.add(EmbedBuilder().applyAsync(embed)) + } + } + + override suspend fun deferResponse(private: Boolean) = Unit + + override suspend fun tryDeleteTrigger(reason: String?) { + if (value.getGuildOrNull() != null) value.delete(reason) + } + + override suspend fun optionalSuccess(text: String): Message { + return value + } + + override suspend fun getDatabaseMessage( + system: SystemRecord?, + messageId: Snowflake? + ): Pair { + val databaseMessage = if (messageId != null) { + database.fetchMessage(messageId) + } else if (value.referencedMessage != null) { + database.fetchMessage(value.referencedMessage!!.id) + } else if (system != null) { + database.fetchLatestMessage(system.id, getChannel().id) + } else null + databaseMessage ?: return null to null + val message = getChannel().getMessageOrNull(Snowflake(databaseMessage.newMessageId)) + return message to databaseMessage + } + + override suspend fun interactionMenu(private: Boolean, action: suspend DiscordMenu.() -> Unit) { + val message = getChannel(private).createMessage("Thinking...") + val menu = DiscordMessageMenu(message, getUser()?.id ?: Snowflake(0)) + menu.action() + menu.init() + } + + override suspend fun respondPlain(text: String, private: Boolean): Message { + return getChannel(private).createMessage(text) + } +} \ No newline at end of file diff --git a/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/context/InteractionCommandContext.kt b/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/context/InteractionCommandContext.kt new file mode 100644 index 00000000..8d71c01f --- /dev/null +++ b/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/context/InteractionCommandContext.kt @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2022-2023, The ProxyFox Group + * + * This Source Code is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.proxyfox.bot.command.context + +import dev.kord.common.entity.Snowflake +import dev.kord.core.behavior.channel.GuildChannelBehavior +import dev.kord.core.behavior.channel.MessageChannelBehavior +import dev.kord.core.behavior.interaction.respondEphemeral +import dev.kord.core.behavior.interaction.respondPublic +import dev.kord.core.behavior.interaction.response.DeferredMessageInteractionResponseBehavior +import dev.kord.core.behavior.interaction.response.respond +import dev.kord.core.entity.* +import dev.kord.core.event.interaction.ChatInputCommandInteractionCreateEvent +import dev.kord.rest.builder.message.EmbedBuilder +import dev.kord.rest.builder.message.create.embed +import dev.kord.rest.builder.message.modify.embed +import dev.proxyfox.bot.command.menu.DiscordMenu +import dev.proxyfox.bot.command.menu.InteractionCommandMenu +import dev.proxyfox.database.database +import dev.proxyfox.database.records.misc.ProxiedMessageRecord +import dev.proxyfox.database.records.system.SystemRecord +import kotlin.jvm.optionals.getOrNull + +class InteractionCommandContext(value: ChatInputCommandInteractionCreateEvent) : + DiscordContext(value) { + override val command: String = "" + + var deferred: DeferredMessageInteractionResponseBehavior? = null + + @OptIn(ExperimentalStdlibApi::class) + override fun getAttachment(): Attachment? { + return value.interaction.command.attachments.values.stream().findFirst().getOrNull() + } + + override suspend fun respondPlain(text: String, private: Boolean): ChatInputCommandInteractionCreateEvent { + if (deferred != null) + deferred!!.respond { + content = text + } + else if (private) + value.interaction.respondEphemeral { + content = text + } + else value.interaction.respondPublic { + content = text + } + return value + } + + override suspend fun getChannel(private: Boolean): MessageChannelBehavior { + return if (private) + value.interaction.user.getDmChannelOrNull() + ?: value.interaction.channel + else value.interaction.channel + } + + override suspend fun getGuild(): Guild? { + return (value.interaction.channel as? GuildChannelBehavior)?.getGuildOrNull() + } + + override suspend fun getUser(): User { + return value.interaction.user + } + + override suspend fun getMember(): Member? { + return getGuild()?.getMemberOrNull(getUser().id) + } + + override suspend fun respondEmbed( + private: Boolean, + text: String?, + embed: suspend EmbedBuilder.() -> Unit + ): ChatInputCommandInteractionCreateEvent { + if (deferred != null) + deferred!!.respond { + embed { + embed() + } + } + else if (private) + value.interaction.respondEphemeral { + embed { + embed() + } + } + else value.interaction.respondPublic { + embed { + embed() + } + } + return value + } + + override suspend fun deferResponse(private: Boolean) { + deferred = if (private) + value.interaction.deferEphemeralResponse() + else value.interaction.deferPublicResponse() + } + + override suspend fun tryDeleteTrigger(reason: String?) { + } + + override suspend fun optionalSuccess(text: String): ChatInputCommandInteractionCreateEvent { + respondSuccess(text, true) + return value + } + + override suspend fun getDatabaseMessage( + system: SystemRecord?, + messageId: Snowflake? + ): Pair { + val databaseMessage = if (messageId != null) { + database.fetchMessage(messageId) + } else if (system != null) { + database.fetchLatestMessage(system.id, getChannel().id) + } else null + databaseMessage ?: return null to null + val message = getChannel().getMessageOrNull(Snowflake(databaseMessage.newMessageId)) + return message to databaseMessage + } + + override suspend fun interactionMenu(private: Boolean, action: suspend DiscordMenu.() -> Unit) { + + val message = + deferred + ?: if (private) value.interaction.deferEphemeralResponse() else value.interaction.deferPublicResponse() + val menu = InteractionCommandMenu(message.respond { + content = "Thinking..." + }, value.interaction.user.id) + menu.action() + menu.init() + } +} diff --git a/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/interaction/ProxyFoxApplicationCommandModifyStateHolder.kt b/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/interaction/ProxyFoxApplicationCommandModifyStateHolder.kt new file mode 100644 index 00000000..50babd4e --- /dev/null +++ b/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/interaction/ProxyFoxApplicationCommandModifyStateHolder.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2023, The ProxyFox Group + * + * This Source Code is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.proxyfox.bot.command.interaction + +import dev.kord.common.Locale +import dev.kord.common.entity.Permissions +import dev.kord.common.entity.optional.Optional +import dev.kord.common.entity.optional.OptionalBoolean +import dev.kord.rest.builder.interaction.OptionsBuilder +import kotlinx.serialization.SerialName + +/** + * The needed class is internal, so we're keeping our on impl + * */ +class ProxyFoxApplicationCommandModifyStateHolder { + + var name: Optional = Optional.Missing() + var nameLocalizations: Optional?> = Optional.Missing() + + var description: Optional = Optional.Missing() + var descriptionLocalizations: Optional?> = Optional.Missing() + + var options: Optional> = Optional.Missing() + + var defaultMemberPermissions: Optional = Optional.Missing() + var dmPermission: OptionalBoolean? = OptionalBoolean.Missing + + + @Deprecated("'defaultPermission' is deprecated in favor of 'defaultMemberPermissions' and 'dmPermission'. Setting 'defaultPermission' to false can be replaced by setting 'defaultMemberPermissions' to empty Permissions and 'dmPermission' to false ('dmPermission' is only available for global commands).") + @SerialName("default_permission") + var defaultPermission: OptionalBoolean = OptionalBoolean.Missing +} diff --git a/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/interaction/ProxyFoxChatInputCreateBuilderImpl.kt b/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/interaction/ProxyFoxChatInputCreateBuilderImpl.kt new file mode 100644 index 00000000..5b39d00c --- /dev/null +++ b/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/interaction/ProxyFoxChatInputCreateBuilderImpl.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2023, The ProxyFox Group + * + * This Source Code is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.proxyfox.bot.command.interaction + +import dev.kord.common.Locale +import dev.kord.common.entity.ApplicationCommandType +import dev.kord.common.entity.Permissions +import dev.kord.common.entity.optional.Optional +import dev.kord.common.entity.optional.delegate.delegate +import dev.kord.common.entity.optional.mapList +import dev.kord.rest.builder.interaction.GlobalChatInputCreateBuilder +import dev.kord.rest.builder.interaction.OptionsBuilder +import dev.kord.rest.json.request.ApplicationCommandCreateRequest + +/** + * The needed class is internal, so we're keeping our own impl + * */ +class ProxyFoxChatInputCreateBuilderImpl( + override var name: String, + override var description: String +) : GlobalChatInputCreateBuilder { + override var nsfw: Boolean? = false + private val state = ProxyFoxApplicationCommandModifyStateHolder() + + override var nameLocalizations: MutableMap? by state::nameLocalizations.delegate() + override var descriptionLocalizations: MutableMap? by state::descriptionLocalizations.delegate() + + override val type: ApplicationCommandType + get() = ApplicationCommandType.ChatInput + + override var options: MutableList? by state::options.delegate() + override var defaultMemberPermissions: Permissions? by state::defaultMemberPermissions.delegate() + override var dmPermission: Boolean? by state::dmPermission.delegate() + + @Deprecated("'defaultPermission' is deprecated in favor of 'defaultMemberPermissions' and 'dmPermission'. Setting 'defaultPermission' to false can be replaced by setting 'defaultMemberPermissions' to empty Permissions and 'dmPermission' to false ('dmPermission' is only available for global commands).") + override var defaultPermission: Boolean? by @Suppress("DEPRECATION") state::defaultPermission.delegate() + + + override fun toRequest(): ApplicationCommandCreateRequest { + return ApplicationCommandCreateRequest( + name, + state.nameLocalizations, + type, + Optional.Value(description), + state.descriptionLocalizations, + state.options.mapList { it.toRequest() }, + state.defaultMemberPermissions, + state.dmPermission, + @Suppress("DEPRECATION") state.defaultPermission, + ) + } + +} diff --git a/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/interaction/ProxyFoxMessageCommandCreateBuilderImpl.kt b/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/interaction/ProxyFoxMessageCommandCreateBuilderImpl.kt new file mode 100644 index 00000000..e790ef16 --- /dev/null +++ b/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/interaction/ProxyFoxMessageCommandCreateBuilderImpl.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2023, The ProxyFox Group + * + * This Source Code is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.proxyfox.bot.command.interaction + +import dev.kord.common.Locale +import dev.kord.common.entity.ApplicationCommandType +import dev.kord.common.entity.Permissions +import dev.kord.common.entity.optional.delegate.delegate +import dev.kord.rest.builder.interaction.GlobalMessageCommandCreateBuilder +import dev.kord.rest.json.request.ApplicationCommandCreateRequest + +class ProxyFoxMessageCommandCreateBuilderImpl(override var name: String) : GlobalMessageCommandCreateBuilder { + override val type: ApplicationCommandType + get() = ApplicationCommandType.Message + + override var nsfw: Boolean? = false + + + private val state = ProxyFoxApplicationCommandModifyStateHolder() + + override var nameLocalizations: MutableMap? by state::nameLocalizations.delegate() + + override var defaultMemberPermissions: Permissions? by state::defaultMemberPermissions.delegate() + override var dmPermission: Boolean? by state::dmPermission.delegate() + + @Deprecated("'defaultPermission' is deprecated in favor of 'defaultMemberPermissions' and 'dmPermission'. Setting 'defaultPermission' to false can be replaced by setting 'defaultMemberPermissions' to empty Permissions and 'dmPermission' to false ('dmPermission' is only available for global commands).") + override var defaultPermission: Boolean? by @Suppress("DEPRECATION") state::defaultPermission.delegate() + + override fun toRequest(): ApplicationCommandCreateRequest { + return ApplicationCommandCreateRequest( + name = name, + nameLocalizations = state.nameLocalizations, + type = type, + dmPermission = state.dmPermission, + defaultMemberPermissions = state.defaultMemberPermissions, + defaultPermission = @Suppress("DEPRECATION") state.defaultPermission, + ) + } +} \ No newline at end of file diff --git a/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/menu/DiscordMenu.kt b/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/menu/DiscordMenu.kt new file mode 100644 index 00000000..3429e7ff --- /dev/null +++ b/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/menu/DiscordMenu.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2023, The ProxyFox Group + * + * This Source Code is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.proxyfox.bot.command.menu + +import dev.kord.rest.builder.message.modify.MessageModifyBuilder +import dev.proxyfox.command.menu.CommandMenu +import dev.proxyfox.command.menu.CommandScreen +import kotlinx.coroutines.Job + +abstract class DiscordMenu : CommandMenu() { + internal val jobs = arrayListOf() + + var closed = false + + override suspend fun close() { + jobs.forEach { + it.cancel() + } + closed = true + } + + override suspend fun createScreen(name: String): CommandScreen { + return DiscordScreen(name) + } + + abstract suspend fun edit(builder: suspend MessageModifyBuilder.() -> Unit) +} diff --git a/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/menu/DiscordMessageMenu.kt b/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/menu/DiscordMessageMenu.kt new file mode 100644 index 00000000..ae4bd375 --- /dev/null +++ b/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/menu/DiscordMessageMenu.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2023, The ProxyFox Group + * + * This Source Code is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.proxyfox.bot.command.menu + +import dev.kord.common.entity.Snowflake +import dev.kord.core.behavior.edit +import dev.kord.core.entity.Message +import dev.kord.core.event.interaction.ButtonInteractionCreateEvent +import dev.kord.core.event.interaction.SelectMenuInteractionCreateEvent +import dev.kord.rest.builder.message.modify.MessageModifyBuilder +import dev.proxyfox.bot.kord +import dev.proxyfox.common.onlyIf + +class DiscordMessageMenu(val message: Message, val userId: Snowflake) : DiscordMenu() { + override suspend fun edit(builder: suspend MessageModifyBuilder.() -> Unit) { + message.edit { + builder() + } + } + + override suspend fun init() { + jobs.addAll( + arrayListOf( + kord.onlyIf({ interaction.message.id }, message.id) { + buttonInteract(this) + }, + kord.onlyIf({ interaction.message.id }, message.id) { + selectInteract(this) + } + ) + ) + super.init() + } + + private suspend fun buttonInteract(button: ButtonInteractionCreateEvent) { + if (button.interaction.user.id != userId) return + button.interaction.deferPublicMessageUpdate() + active!!.click(button.interaction.componentId) + } + + private suspend fun selectInteract(select: SelectMenuInteractionCreateEvent) { + if (select.interaction.user.id != userId) return + select.interaction.deferPublicMessageUpdate() + (active!! as DiscordScreen).selects[select.interaction.componentId]?.let { it(select.interaction.values) } + } +} diff --git a/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/menu/DiscordScreen.kt b/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/menu/DiscordScreen.kt new file mode 100644 index 00000000..03bcc0ce --- /dev/null +++ b/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/menu/DiscordScreen.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2023, The ProxyFox Group + * + * This Source Code is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.proxyfox.bot.command.menu + +import dev.proxyfox.command.menu.CommandScreen + +typealias SelectAction = suspend (List) -> Unit + +class DiscordScreen(name: String) : CommandScreen(name) { + private var initializer: suspend () -> Unit = {} + + var selects = HashMap() + + fun onInit(action: suspend () -> Unit) { + initializer = action + } + + fun select(name: String, action: SelectAction) { + selects[name] = action + } + + override suspend fun init() { + initializer() + } +} diff --git a/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/menu/InteractionCommandMenu.kt b/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/menu/InteractionCommandMenu.kt new file mode 100644 index 00000000..d8a294ef --- /dev/null +++ b/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/menu/InteractionCommandMenu.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2023, The ProxyFox Group + * + * This Source Code is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.proxyfox.bot.command.menu + +import dev.kord.common.entity.Snowflake +import dev.kord.core.behavior.interaction.response.edit +import dev.kord.core.entity.interaction.response.EphemeralMessageInteractionResponse +import dev.kord.core.entity.interaction.response.MessageInteractionResponse +import dev.kord.core.event.interaction.ButtonInteractionCreateEvent +import dev.kord.core.event.interaction.SelectMenuInteractionCreateEvent +import dev.kord.rest.builder.message.modify.MessageModifyBuilder +import dev.proxyfox.bot.kord +import dev.proxyfox.common.onlyIf + +class InteractionCommandMenu(val interaction: MessageInteractionResponse, val userId: Snowflake) : DiscordMenu() { + override suspend fun edit(builder: suspend MessageModifyBuilder.() -> Unit) { + interaction.edit { + builder() + } + } + + override suspend fun init() { + jobs.addAll( + arrayListOf( + kord.onlyIf({ interaction.message.id }, interaction.message.id) { + buttonInteract(this) + }, + kord.onlyIf({ interaction.message.id }, interaction.message.id) { + selectInteract(this) + } + ) + ) + super.init() + } + + + private suspend fun buttonInteract(button: ButtonInteractionCreateEvent) { + if (interaction is EphemeralMessageInteractionResponse) + button.interaction.deferEphemeralMessageUpdate() + else button.interaction.deferPublicMessageUpdate() + active!!.click(button.interaction.componentId) + } + + private suspend fun selectInteract(select: SelectMenuInteractionCreateEvent) { + if (interaction is EphemeralMessageInteractionResponse) + select.interaction.deferEphemeralMessageUpdate() + else select.interaction.deferPublicMessageUpdate() + (active!! as DiscordScreen).selects[select.interaction.componentId]?.let { it(select.interaction.values) } + } +} diff --git a/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/text/MemberTextCommands.kt b/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/text/MemberTextCommands.kt new file mode 100644 index 00000000..94186f23 --- /dev/null +++ b/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/text/MemberTextCommands.kt @@ -0,0 +1,386 @@ +/* + * Copyright (c) 2022-2023, The ProxyFox Group + * + * This Source Code is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.proxyfox.bot.command.text + +import dev.proxyfox.bot.command.MemberCommands +import dev.proxyfox.bot.command.checkMember +import dev.proxyfox.bot.command.checkSystem +import dev.proxyfox.bot.command.context.DiscordContext +import dev.proxyfox.bot.command.types.CommandAttachment +import dev.proxyfox.bot.command.types.CommandBoolean +import dev.proxyfox.bot.command.types.CommandSnowflake +import dev.proxyfox.bot.kord +import dev.proxyfox.command.Command +import dev.proxyfox.command.Context +import dev.proxyfox.command.LiteralArgument +import dev.proxyfox.command.types.GreedyString +import dev.proxyfox.command.types.UnixList +import dev.proxyfox.common.find +import dev.proxyfox.common.toColor +import dev.proxyfox.database.database +import dev.proxyfox.database.tryParseLocalDate + + +@Suppress("UNUSED") +@LiteralArgument("member", "mem", "m") +object MemberTextCommands { + @Command + suspend fun delete( + @Context ctx: DiscordContext, + @LiteralArgument("delete", "remove", "del") literal: Unit, + memberId: GreedyString? + ) { + val system = ctx.getSys() + if (!checkSystem(ctx, system)) return + if (memberId == null) { + MemberCommands.delete(ctx, system, null) + return + } + val member = database.fetchMemberFromSystem(system.id, memberId.value) + if (!checkMember(ctx, member)) return + MemberCommands.delete(ctx, system, member) + } + + @Command + suspend fun delete( + @Context ctx: DiscordContext, + memberId: GreedyString?, + @LiteralArgument("delete", "remove", "del") literal: Unit + ) = delete(ctx, Unit, memberId) + + @Command + suspend fun create( + @Context ctx: DiscordContext, + @LiteralArgument("create", "c", "new", "add") literal: Unit, + name: GreedyString? + ) { + val system = ctx.getSys() + if (!checkSystem(ctx, system)) return + MemberCommands.create(ctx, system, name?.value) + } + + @Command + suspend fun name( + @Context ctx: DiscordContext, + memberId: String, + @LiteralArgument("name", "rename") literal: Unit, + unixValues: UnixList?, + name: GreedyString? + ) { + val raw = unixValues.find("raw") + + val name = name?.value?.ifEmpty { null } + + val system = ctx.getSys() + if (!checkSystem(ctx, system)) return + val member = database.fetchMemberFromSystem(system.id, memberId) + if (!checkMember(ctx, member)) return + + MemberCommands.rename(ctx, system, member, name, raw) + } + + @Command + suspend fun nickname( + @Context ctx: DiscordContext, + memberId: String, + @LiteralArgument("nickname", "nick", "displayname", "dn") literal: Unit, + unixValues: UnixList?, + name: GreedyString? + ) { + val raw = unixValues.find("raw") + val clear = unixValues.find("clear") || unixValues.find("remove") + + val name = name?.value?.ifEmpty { null } + + val system = ctx.getSys() + if (!checkSystem(ctx, system)) return + val member = database.fetchMemberFromSystem(system.id, memberId) + if (!checkMember(ctx, member)) return + + MemberCommands.nickname(ctx, system, member, name, raw, clear) + } + + @Command + suspend fun serverNick( + @Context ctx: DiscordContext, + memberId: String, + @LiteralArgument("servername", "servernick", "sn") literal: Unit, + guildId: CommandSnowflake?, + unixValues: UnixList?, + name: GreedyString? + ) { + val raw = unixValues.find("raw") + val clear = unixValues.find("clear") || unixValues.find("remove") + + val name = name?.value?.ifEmpty { null } + + val system = ctx.getSys() + if (!checkSystem(ctx, system)) return + val member = database.fetchMemberFromSystem(system.id, memberId) + if (!checkMember(ctx, member)) return + + val guild = guildId?.let { kord.getGuild(guildId.snowflake) } ?: ctx.getGuild() ?: run { + ctx.respondFailure("Cannot find guild.") + return@serverNick + } + + val serverMember = + database.fetchMemberServerSettingsFromSystemAndMember(guild, system.id, member.id)!! + + MemberCommands.servername(ctx, system, serverMember, name, raw, clear) + } + + @Command + suspend fun description( + @Context ctx: DiscordContext, + memberId: String, + @LiteralArgument("description", "desc") literal: Unit, + unixValues: UnixList?, + desc: GreedyString? + ) { + val raw = unixValues.find("raw") + val clear = unixValues.find("clear") || unixValues.find("remove") + + val desc = desc?.value?.ifEmpty { null } + + val system = ctx.getSys() + if (!checkSystem(ctx, system)) return + val member = database.fetchMemberFromSystem(system.id, memberId) + if (!checkMember(ctx, member)) return + + MemberCommands.description(ctx, system, member, desc, raw, clear) + } + + @Command + suspend fun avatar( + @Context ctx: DiscordContext, + memberId: String, + @LiteralArgument("avatar", "pfp") literal: Unit, + unixValues: UnixList?, + attachment: CommandAttachment?, + url: GreedyString? + ) { + val clear = unixValues.find("clear") || unixValues.find("remove") + + val url = url?.value?.ifEmpty { null } ?: attachment?.attachment?.url + + val system = ctx.getSys() + if (!checkSystem(ctx, system)) return + val member = database.fetchMemberFromSystem(system.id, memberId) + if (!checkMember(ctx, member)) return + + MemberCommands.avatar(ctx, system, member, url, clear) + } + + @Command + suspend fun serverAvatar( + @Context ctx: DiscordContext, + memberId: String, + @LiteralArgument("serveravatar", "sa", "serverpfp", "sp") literal: Unit, + guildId: CommandSnowflake?, + unixValues: UnixList?, + attachment: CommandAttachment?, + url: GreedyString? + ) { + val clear = unixValues.find("clear") || unixValues.find("remove") + + val url = url?.value?.ifEmpty { null } ?: attachment?.attachment?.url + + val system = ctx.getSys() + if (!checkSystem(ctx, system)) return + val member = database.fetchMemberFromSystem(system.id, memberId) + if (!checkMember(ctx, member)) return + + val guild = guildId?.let { kord.getGuild(guildId.snowflake) } ?: ctx.getGuild() ?: run { + ctx.respondFailure("Cannot find guild.") + return@serverAvatar + } + + val serverMember = + database.fetchMemberServerSettingsFromSystemAndMember(guild, system.id, member.id)!! + + MemberCommands.serverAvatar(ctx, system, serverMember, url, clear) + } + + @Command + suspend fun autoproxy( + @Context ctx: DiscordContext, + memberId: String, + @LiteralArgument("autoproxy", "ap") literal: Unit, + boolean: CommandBoolean? + ) { + val system = ctx.getSys() + if (!checkSystem(ctx, system)) return + val member = database.fetchMemberFromSystem(system.id, memberId) + if (!checkMember(ctx, member)) return + + MemberCommands.autoproxy(ctx, system, member, boolean?.value) + } + + @Command + suspend fun proxyAdd( + @Context ctx: DiscordContext, + memberId: String, + @LiteralArgument("autoproxy", "ap") literal1: Unit, + @LiteralArgument("add", "create", "new") literal2: Unit, + proxy: GreedyString? + ) { + val proxytag = proxy?.value + + val system = ctx.getSys() + if (!checkSystem(ctx, system)) return + val member = database.fetchMemberFromSystem(system.id, memberId) + if (!checkMember(ctx, member)) return + + if (proxytag == null) { + MemberCommands.proxy(ctx, system, member, null) + return + } + + val proxy = extractProxyFromTag(ctx, proxytag) ?: return + + MemberCommands.proxy(ctx, system, member, proxy) + } + + @Command + suspend fun proxyDelete( + @Context ctx: DiscordContext, + memberId: String, + @LiteralArgument("autoproxy", "ap") literal1: Unit, + @LiteralArgument("remove", "rem", "delete", "del") literal2: Unit, + proxy: GreedyString? + ) { + val proxytag = proxy?.value + + val system = ctx.getSys() + if (!checkSystem(ctx, system)) return + val member = database.fetchMemberFromSystem(system.id, memberId) + if (!checkMember(ctx, member)) return + + if (proxytag == null || !proxytag.contains("text") ) { + MemberCommands.removeProxy(ctx, system, member, false, null) + return + } + + val proxyDb = database.fetchProxyTagFromMessage(ctx.getUser(), proxytag) + proxyDb ?: run { + ctx.respondFailure("Proxy tag doesn't exist in this member.") + return@proxyDelete + } + if (proxyDb.memberId != member.id) { + ctx.respondFailure("Proxy tag doesn't exist in this member.") + return + } + + MemberCommands.removeProxy(ctx, system, member, true, proxyDb) + } + + @Command + suspend fun proxy( + @Context ctx: DiscordContext, + memberId: String, + @LiteralArgument("autoproxy", "ap") literal: Unit, + proxy: GreedyString? + ) = proxyAdd(ctx, memberId, Unit, Unit, proxy) + + @Command + suspend fun pronouns( + @Context ctx: DiscordContext, + memberId: String, + @LiteralArgument("pronouns", "bluehair") pronounsliteral: Unit, + unixValues: UnixList?, + pronouns: GreedyString? + ) { + val raw = unixValues.find("raw") + val clear = unixValues.find("clear") || unixValues.find("remove") + + val pronouns = pronouns?.value?.ifEmpty { null } + + val system = ctx.getSys() + if (!checkSystem(ctx, system)) return + val member = database.fetchMemberFromSystem(system.id, memberId) + if (!checkMember(ctx, member)) return + + MemberCommands.pronouns(ctx, system, member, pronouns, raw, clear) + } + + @Command + suspend fun color( + @Context ctx: DiscordContext, + memberId: String, + @LiteralArgument("color", "colour") literal: Unit, + color: GreedyString? + ) { + val color = color?.value?.ifEmpty { null } + + val system = ctx.getSys() + if (!checkSystem(ctx, system)) return + val member = database.fetchMemberFromSystem(system.id, memberId) + if (!checkMember(ctx, member)) return + + MemberCommands.color(ctx, system, member, color?.toColor()) + } + + @Command + suspend fun birthday( + @Context ctx: DiscordContext, + memberId: String, + @LiteralArgument("birthday", "bday", "birth", "bd") literal: Unit, + unixValues: UnixList?, + birthday: GreedyString? + ) { + val clear = unixValues.find("clear") + + val birthday = birthday?.value?.ifEmpty { null } + + val system = ctx.getSys() + if (!checkSystem(ctx, system)) return + val member = database.fetchMemberFromSystem(system.id, memberId) + if (!checkMember(ctx, member)) return + + MemberCommands.birthday(ctx, system, member, tryParseLocalDate(birthday)?.first, clear) + } + + @Command + suspend fun access( + @Context ctx: DiscordContext, + memberId: String + ) { + val system = ctx.getSys() + if (!checkSystem(ctx, system)) return + val member = database.fetchMemberFromSystem(system.id, memberId) + if (!checkMember(ctx, member)) return + + MemberCommands.access(ctx, system, member) + } + + @Command + suspend fun empty( + @Context ctx: DiscordContext + ) { + val system = ctx.getSys() + if (!checkSystem(ctx, system)) return + + MemberCommands.empty(ctx) + } + + suspend fun extractProxyFromTag(ctx: DiscordContext, proxy: String): Pair? { + if (!proxy.contains("text")) { + ctx.respondFailure("Given proxy tag does not contain `text`.") + return null + } + val prefix = proxy.substring(0, proxy.indexOf("text")) + val suffix = proxy.substring(4 + prefix.length, proxy.length) + if (prefix.isEmpty() && suffix.isEmpty()) { + ctx.respondFailure("Proxy tag must contain either a prefix or a suffix.") + return null + } + return Pair(prefix, suffix) + } +} diff --git a/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/text/MiscTextCommands.kt b/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/text/MiscTextCommands.kt new file mode 100644 index 00000000..3685b622 --- /dev/null +++ b/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/text/MiscTextCommands.kt @@ -0,0 +1,305 @@ +/* + * Copyright (c) 2022-2023, The ProxyFox Group + * + * This Source Code is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.proxyfox.bot.command.text + +import dev.proxyfox.bot.command.MiscCommands +import dev.proxyfox.bot.command.checkMember +import dev.proxyfox.bot.command.checkSystem +import dev.proxyfox.bot.command.context.DiscordContext +import dev.proxyfox.bot.command.types.CommandAttachment +import dev.proxyfox.bot.command.types.CommandBoolean +import dev.proxyfox.bot.command.types.CommandProxyMode +import dev.proxyfox.bot.command.types.CommandSnowflake +import dev.proxyfox.bot.kord +import dev.proxyfox.command.Command +import dev.proxyfox.command.Context +import dev.proxyfox.command.LiteralArgument +import dev.proxyfox.command.types.GreedyString +import dev.proxyfox.command.types.UnixList +import dev.proxyfox.common.find +import dev.proxyfox.database.database +import dev.proxyfox.database.records.misc.AutoProxyMode +import dev.proxyfox.database.records.misc.TokenType +import dev.proxyfox.database.records.misc.TrustLevel + +@Suppress("UNUSED") +object MiscTextCommands { + @Command + suspend fun import( + @Context ctx: DiscordContext, + @LiteralArgument("import") literal: Unit, + attachment: CommandAttachment?, + url: GreedyString? + ) { + val url = url?.value?.ifEmpty { null } ?: attachment?.attachment?.url + + MiscCommands.import(ctx, url) + } + + @Command + suspend fun export( + @Context ctx: DiscordContext, + @LiteralArgument("export") literal: Unit + ) { + val system = ctx.getSys() + if (!checkSystem(ctx, system)) return + MiscCommands.export(ctx) + } + + @Command + suspend fun time( + @Context ctx: DiscordContext, + @LiteralArgument("time") literal: Unit + ) = MiscCommands.time(ctx) + + @Command + suspend fun help( + @Context ctx: DiscordContext, + @LiteralArgument("help") literal: Unit + ) = ctx.respondPlain(MiscCommands.help) + + @Command + suspend fun explain( + @Context ctx: DiscordContext, + @LiteralArgument("help") literal: Unit + ) = ctx.respondPlain(MiscCommands.explain) + + @Command + suspend fun invite( + @Context ctx: DiscordContext, + @LiteralArgument("help") literal: Unit + ) = ctx.respondPlain(MiscCommands.invite) + + @Command + suspend fun source( + @Context ctx: DiscordContext, + @LiteralArgument("help") literal: Unit + ) = ctx.respondPlain(MiscCommands.source) + + @Command + suspend fun debug( + @Context ctx: DiscordContext, + @LiteralArgument("debug") literal: Unit + ) = MiscCommands.debug(ctx) + + @Command + suspend fun fox( + @Context ctx: DiscordContext, + @LiteralArgument("fox") literal: Unit + ) = MiscCommands.getFox(ctx) + + @Command + suspend fun proxy( + @Context ctx: DiscordContext, + @LiteralArgument("proxy", "p") literal: Unit, + guildId: CommandSnowflake?, + boolean: CommandBoolean? + ) { + val system = ctx.getSys() + if (!checkSystem(ctx, system)) return + + val guild = guildId?.let { kord.getGuild(guildId.snowflake) } ?: ctx.getGuild() ?: run { + ctx.respondFailure("Cannot find guild.") + return@proxy + } + + val systemServer = database.getOrCreateServerSettingsFromSystem(guild, system.id) + + MiscCommands.serverProxy(ctx, systemServer, boolean?.value) + } + + @Command + suspend fun autoproxy( + @Context ctx: DiscordContext, + @LiteralArgument("autoproxy", "ap") literal: Unit, + mode: CommandProxyMode?, + member: GreedyString? + ) { + val system = ctx.getSys() + if (!checkSystem(ctx, system)) return + + val member = member?.value?.let { + val member = database.fetchMemberFromSystem(system.id, it) + if (!checkMember(ctx, member)) return@autoproxy + member + } + + MiscCommands.proxy(ctx, system, mode?.value, member) + } + + @Command + suspend fun serverAutoproxy( + @Context ctx: DiscordContext, + @LiteralArgument("serverautoproxy", "sap") literal: Unit, + guildId: CommandSnowflake?, + mode: CommandProxyMode?, + member: GreedyString? + ) { + val system = ctx.getSys() + if (!checkSystem(ctx, system)) return + + val guild = guildId?.let { kord.getGuild(guildId.snowflake) } ?: ctx.getGuild() ?: run { + ctx.respondFailure("Cannot find guild.") + return@serverAutoproxy + } + + val systemServer = database.getOrCreateServerSettingsFromSystem(guild, system.id) + + val member = member?.value?.let { + val member = database.fetchMemberFromSystem(system.id, it) + if (!checkMember(ctx, member)) return@serverAutoproxy + member + } + + val mode = mode?.value?.let { if (it == AutoProxyMode.OFF) AutoProxyMode.FALLBACK else it } + + MiscCommands.serverAutoProxy(ctx, systemServer, mode, member) + } + + @Command + suspend fun role( + @Context ctx: DiscordContext, + @LiteralArgument("role") literal: Unit, + unixValues: UnixList?, + role: GreedyString? + ) { + val clear = unixValues.find("clear") + + MiscCommands.role(ctx, role?.value, clear) + } + + @Command + suspend fun modDelay( + @Context ctx: DiscordContext, + @LiteralArgument("role") literal: Unit, + guildId: CommandSnowflake?, + delay: GreedyString? + ) { + val guild = guildId?.let { kord.getGuild(guildId.snowflake) } ?: ctx.getGuild() ?: run { + ctx.respondFailure("Cannot find guild.") + return@modDelay + } + + val settings = database.getOrCreateServerSettings(guild) + + MiscCommands.delay(ctx, settings, delay?.value) + } + + @Command + suspend fun forceTag( + @Context ctx: DiscordContext, + @LiteralArgument("forcetag", "requiretag") literal: Unit, + boolean: CommandBoolean? + ) = MiscCommands.forceTag(ctx, boolean?.value) + + @Command + suspend fun deleteMessage( + @Context ctx: DiscordContext, + @LiteralArgument("delete", "del") literal: Unit, + messageId: CommandSnowflake? + ) { + val system = ctx.getSys() + if (!checkSystem(ctx, system)) return + + MiscCommands.deleteMessage(ctx, system, messageId?.snowflake) + } + + @Command + suspend fun reproxyMessage( + @Context ctx: DiscordContext, + @LiteralArgument("reproxy", "rp") literal: Unit, + messageId: CommandSnowflake?, + member: GreedyString? + ) { + val system = ctx.getSys() + if (!checkSystem(ctx, system)) return + + val member = member?.value?.let { + val member = database.fetchMemberFromSystem(system.id, it) + if (!checkMember(ctx, member)) return@reproxyMessage + member + } + + MiscCommands.reproxyMessage(ctx, system, messageId?.snowflake, member) + } + + @Command + suspend fun fetchMessage( + @Context ctx: DiscordContext, + @LiteralArgument("info", "i") literal: Unit, + messageId: CommandSnowflake? + ) = MiscCommands.fetchMessageInfo(ctx, messageId?.snowflake) + + @Command + suspend fun pingMessage( + @Context ctx: DiscordContext, + @LiteralArgument("ping") literal: Unit, + messageId: CommandSnowflake? + ) = MiscCommands.pingMessageAuthor(ctx, messageId?.snowflake) + + @Command + suspend fun editMessage( + @Context ctx: DiscordContext, + @LiteralArgument("edit", "e") literal: Unit, + messageId: CommandSnowflake?, + content: GreedyString? + ) { + val system = ctx.getSys() + if (!checkSystem(ctx, system)) return + + MiscCommands.editMessage(ctx, system, messageId?.snowflake, content?.value) + } + + @Command + suspend fun channelProxy( + @Context ctx: DiscordContext, + @LiteralArgument("channelproxy", "cp") literal1: Unit, + channelId: CommandSnowflake?, + boolean: CommandBoolean? + ) = MiscCommands.channelProxy(ctx,(channelId?.snowflake ?: ctx.getChannel(false).id).toString() , boolean?.value) + + @Command + suspend fun token( + @Context ctx: DiscordContext, + @LiteralArgument("token", "t") literal: Unit + ) { + val system = ctx.getSys() + if (!checkSystem(ctx, system)) return + + MiscCommands.token(ctx, system) + } + + suspend fun transfer( + @Context ctx: DiscordContext, + @LiteralArgument("transfer") literal: Unit, + token: GreedyString? + ) { + if (token == null) { + ctx.respondFailure("Please provide a token to transfer from") + return + } + + if (ctx.getSys() != null) { + ctx.respondFailure("You can only run this command when you have no system registered.") + return + } + + val token = database.fetchToken(token.value) + if (token == null) { + ctx.respondFailure("Token not found.") + return + } + if (token.type != TokenType.SYSTEM_TRANSFER) { + ctx.respondFailure("Token isn't a transfer token.") + return + } + + MiscCommands.transfer(ctx, token) + } +} diff --git a/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/text/PkCommands.kt b/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/text/PkCommands.kt new file mode 100644 index 00000000..dea947f7 --- /dev/null +++ b/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/text/PkCommands.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2022-2023, The ProxyFox Group + * + * This Source Code is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.proxyfox.bot.command.text + +import dev.proxyfox.bot.command.MiscCommands +import dev.proxyfox.bot.command.checkSystem +import dev.proxyfox.bot.command.context.DiscordContext +import dev.proxyfox.command.Command +import dev.proxyfox.command.Context +import dev.proxyfox.command.LiteralArgument +import dev.proxyfox.command.types.GreedyString +import dev.proxyfox.command.types.UnixList +import dev.proxyfox.common.find + +@Suppress("UNUSED") +@LiteralArgument("pluralkit", "pk") +object PkCommands { + @Command + suspend fun pull( + @Context ctx: DiscordContext, + @LiteralArgument("pull", "get", "download", "import") literal: Unit + ) { + val system = ctx.getSys() + if (!checkSystem(ctx, system)) return + + MiscCommands.syncPk(ctx, system, false) + } + @Command + suspend fun push( + @Context ctx: DiscordContext, + @LiteralArgument("push", "set", "upload", "export") literal: Unit + ) { + val system = ctx.getSys() + if (!checkSystem(ctx, system)) return + + MiscCommands.syncPk(ctx, system, true) + } + + @Command + suspend fun token( + @Context ctx: DiscordContext, + @LiteralArgument("token") literal: Unit, + unixValues: UnixList?, + token: GreedyString? + ) { + val clear = unixValues.find("clear") || unixValues.find("remove") + + val system = ctx.getSys() + if (!checkSystem(ctx, system)) return + + MiscCommands.pkToken(ctx, system, token?.value, clear) + } +} \ No newline at end of file diff --git a/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/text/TrustCommands.kt b/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/text/TrustCommands.kt new file mode 100644 index 00000000..270ae868 --- /dev/null +++ b/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/text/TrustCommands.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2022-2023, The ProxyFox Group + * + * This Source Code is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.proxyfox.bot.command.text + +import dev.proxyfox.bot.command.MiscCommands +import dev.proxyfox.bot.command.checkSystem +import dev.proxyfox.bot.command.context.DiscordContext +import dev.proxyfox.bot.command.types.CommandSnowflake +import dev.proxyfox.command.Command +import dev.proxyfox.command.Context +import dev.proxyfox.command.LiteralArgument +import dev.proxyfox.database.records.misc.TrustLevel + +@Suppress("UNUSED") +@LiteralArgument("trust") +object TrustCommands { + @Command + suspend fun none( + @Context ctx: DiscordContext, + userId: CommandSnowflake, + @LiteralArgument("none", "remove", "clear") literal: Unit + ) { + val system = ctx.getSys() + if (!checkSystem(ctx, system)) return + + MiscCommands.trust(ctx, system, userId.snowflake.value, TrustLevel.NONE) + } + + @Command + suspend fun access( + @Context ctx: DiscordContext, + userId: CommandSnowflake, + @LiteralArgument("access", "see", "view") literal: Unit + ) { + val system = ctx.getSys() + if (!checkSystem(ctx, system)) return + + MiscCommands.trust(ctx, system, userId.snowflake.value, TrustLevel.ACCESS) + } + + @Command + suspend fun switch( + @Context ctx: DiscordContext, + userId: CommandSnowflake, + @LiteralArgument("switch", "sw") literal: Unit + ) { + val system = ctx.getSys() + if (!checkSystem(ctx, system)) return + + MiscCommands.trust(ctx, system, userId.snowflake.value, TrustLevel.SWITCH) + } + + @Command + suspend fun member( + @Context ctx: DiscordContext, + userId: CommandSnowflake, + @LiteralArgument("member", "mem", "m") literal: Unit + ) { + val system = ctx.getSys() + if (!checkSystem(ctx, system)) return + + MiscCommands.trust(ctx, system, userId.snowflake.value, TrustLevel.MEMBER) + } + + @Command + suspend fun full( + @Context ctx: DiscordContext, + userId: CommandSnowflake, + @LiteralArgument("full", "all", "everything") literal: Unit + ) { + val system = ctx.getSys() + if (!checkSystem(ctx, system)) return + + MiscCommands.trust(ctx, system, userId.snowflake.value, TrustLevel.FULL) + } + + @Command + suspend fun noLiteral( + @Context ctx: DiscordContext, + userId: CommandSnowflake + ) { + val system = ctx.getSys() + if (!checkSystem(ctx, system)) return + + MiscCommands.trust(ctx, system, userId.snowflake.value, null) + } + + @Command + suspend fun noId( + @Context ctx: DiscordContext + ) { + val system = ctx.getSys() + if (!checkSystem(ctx, system)) return + + ctx.respondFailure("Please provide a user to perform this action on") + } +} \ No newline at end of file diff --git a/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/types/CommandAttachment.kt b/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/types/CommandAttachment.kt new file mode 100644 index 00000000..1c588408 --- /dev/null +++ b/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/types/CommandAttachment.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2022-2023, The ProxyFox Group + * + * This Source Code is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.proxyfox.bot.command.types + +import dev.kord.core.entity.Attachment +import dev.proxyfox.bot.command.context.DiscordContext +import dev.proxyfox.command.CommandDecoder +import dev.proxyfox.command.types.CommandSerializer +import kotlinx.serialization.InternalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.serializer + +@JvmInline +@Serializable(with = CommandAttachmentSerializer::class) +value class CommandAttachment(val attachment: Attachment) + +@OptIn(InternalSerializationApi::class) +private class CommandAttachmentSerializer : CommandSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("CommandAttachment") + + override fun decodeCommand(decoder: CommandDecoder): CommandAttachment { + val context = decoder.context as? DiscordContext ?: decoder.fails("Not discord context") + val attachment = context.getAttachment() ?: decoder.fails("No attachment") + return CommandAttachment(attachment) + } + + override fun decodeRegular(decoder: Decoder): CommandAttachment = + CommandAttachment(Attachment::class.serializer().deserialize(decoder)) + + override fun serialize(encoder: Encoder, value: CommandAttachment) = + Attachment::class.serializer().serialize(encoder, value.attachment) +} diff --git a/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/types/CommandBoolean.kt b/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/types/CommandBoolean.kt new file mode 100644 index 00000000..b3186312 --- /dev/null +++ b/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/types/CommandBoolean.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2022-2023, The ProxyFox Group + * + * This Source Code is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.proxyfox.bot.command.types + +import dev.proxyfox.command.CommandDecoder +import dev.proxyfox.command.types.CommandSerializer +import kotlinx.serialization.InternalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.serializer + +@JvmInline +@Serializable(with = CommandBooleanSerializer::class) +value class CommandBoolean(val value: Boolean) + +@OptIn(InternalSerializationApi::class) +private class CommandBooleanSerializer : CommandSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("CommandBoolean") + + override fun decodeCommand(decoder: CommandDecoder): CommandBoolean { + decoder.cursor.checkout() + val bool = decoder.cursor.extractString(false).lowercase() + if (arrayOf("true", "false", "enable", "disable", "on", "off", "1", "0").contains(bool)) { + decoder.cursor.rollback() + decoder.fails("Not boolean") + } + decoder.cursor.commit() + val value = arrayOf("true", "enable", "on", "1").contains(bool) + return CommandBoolean(value) + } + + override fun decodeRegular(decoder: Decoder): CommandBoolean = + CommandBoolean(Boolean::class.serializer().deserialize(decoder)) + + override fun serialize(encoder: Encoder, value: CommandBoolean) = + Boolean::class.serializer().serialize(encoder, value.value) +} diff --git a/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/types/CommandProxyMode.kt b/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/types/CommandProxyMode.kt new file mode 100644 index 00000000..a1b815b6 --- /dev/null +++ b/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/types/CommandProxyMode.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2022-2023, The ProxyFox Group + * + * This Source Code is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.proxyfox.bot.command.types + +import dev.proxyfox.command.CommandDecoder +import dev.proxyfox.command.types.CommandSerializer +import dev.proxyfox.database.records.misc.AutoProxyMode +import kotlinx.serialization.InternalSerializationApi +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.serializer + +@JvmInline +value class CommandProxyMode(val value: AutoProxyMode) + +@OptIn(InternalSerializationApi::class) +private class CommandProxyModeSerializer : CommandSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("CommandProxyMode") + + override fun decodeCommand(decoder: CommandDecoder): CommandProxyMode { + decoder.cursor.checkout() + val string = decoder.cursor.extractString(false).lowercase() + decoder.cursor.inc() + val mode = when (string) { + "off", "disable", "o" -> AutoProxyMode.OFF + "latch", "l", -> AutoProxyMode.LATCH + "front", "f" -> AutoProxyMode.FRONT + "fallback", "fb" -> AutoProxyMode.FALLBACK + "member", "m" -> AutoProxyMode.MEMBER + else -> { + decoder.cursor.rollback() + decoder.fails("Not AutoProxyMode") + } + } + decoder.cursor.inc() + return CommandProxyMode(mode) + } + + override fun decodeRegular(decoder: Decoder): CommandProxyMode = + CommandProxyMode(AutoProxyMode::class.serializer().deserialize(decoder)) + + override fun serialize(encoder: Encoder, value: CommandProxyMode) = + AutoProxyMode::class.serializer().serialize(encoder, value.value) +} diff --git a/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/types/CommandSnowflake.kt b/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/types/CommandSnowflake.kt new file mode 100644 index 00000000..c7cb9830 --- /dev/null +++ b/modules/bot/src/main/kotlin/dev/proxyfox/bot/command/types/CommandSnowflake.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2022-2023, The ProxyFox Group + * + * This Source Code is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.proxyfox.bot.command.types + +import dev.kord.common.entity.Snowflake +import dev.proxyfox.command.CommandDecoder +import dev.proxyfox.command.types.CommandSerializer +import kotlinx.serialization.InternalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.serializer + +@JvmInline +@Serializable(with = CommandSnowflakeSerializer::class) +value class CommandSnowflake(val snowflake: Snowflake) + +@OptIn(InternalSerializationApi::class) +private class CommandSnowflakeSerializer : CommandSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("CommandSnowflake") + + override fun decodeCommand(decoder: CommandDecoder): CommandSnowflake { + decoder.cursor.checkout() + val num = decoder.cursor.extractString(false).toULongOrNull() + decoder.cursor.inc() + if (num == null) { + decoder.cursor.rollback() + decoder.fails("Not ULong") + } + decoder.cursor.commit() + return CommandSnowflake(Snowflake(num)) + } + + override fun decodeRegular(decoder: Decoder): CommandSnowflake = + CommandSnowflake(Snowflake::class.serializer().deserialize(decoder)) + + override fun serialize(encoder: Encoder, value: CommandSnowflake) = + Snowflake::class.serializer().serialize(encoder, value.snowflake) +} diff --git a/modules/bot/src/main/kotlin/dev/proxyfox/bot/md/Markdown.kt b/modules/bot/src/main/kotlin/dev/proxyfox/bot/md/Markdown.kt deleted file mode 100644 index d0458b96..00000000 --- a/modules/bot/src/main/kotlin/dev/proxyfox/bot/md/Markdown.kt +++ /dev/null @@ -1,124 +0,0 @@ -/* - * Copyright (c) 2022, The ProxyFox Group - * - * This Source Code is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -package dev.proxyfox.bot.md - -interface MarkdownNode { - val length: Int - val trueLength: Int - - override fun toString(): String - fun substring(len: Int): MarkdownNode -} - -class MarkdownString(val string: String) : MarkdownNode { - override val length: Int - get() = string.length - override val trueLength: Int - get() = string.length - - override fun toString(): String = string - override fun substring(len: Int): MarkdownNode { - return MarkdownString(string.substring(0, len.coerceAtMost(string.length))) - } -} - -class BaseMarkdown(val symbol: String) : MarkdownNode { - val values = ArrayList() - - override val length: Int - get() { - var int = 0 - for (value in values) { - int += value.length - } - return int - } - override val trueLength: Int - get() { - var int = symbol.length + symbol.length - for (value in values) { - int += value.length - } - return int - } - - override fun toString(): String { - var out = "" - out += symbol - for (value in values) { - out += value.toString() - } - out += symbol - return out - } - - override fun substring(len: Int): MarkdownNode { - if (trueLength < len) return this - var i = 0 - val out = BaseMarkdown(symbol) - for (value in values) { - if (i + value.length > len) { - out.values.add(value.substring(len - i)) - break - } - out.values.add(value) - i += value.length - } - return out - } -} - -enum class MarkdownSymbols(val symbol: String) { - CODE_MULTILINE("```"), - CODE_DOUBLE("``"), - SPOILER("||"), - BOLD("**"), - STRIKETHROUGH("~~"), - UNDERLINE("__"), - ITALIC_STAR("*"), - ITALIC_UNDER("_"), - CODE("`") -} - -// TODO: Parse out more complex markdowns -fun parseMarkdown(string: String, symbol: String = ""): BaseMarkdown { - val base = BaseMarkdown(symbol) - var idx = 0 - var lastIdx = 0 - while (idx < string.length) { - val substr = string.substring(idx) - for (sym in MarkdownSymbols.values()) { - if (substr.startsWith(sym.symbol)) { - var currIdx = sym.symbol.length - while (currIdx < substr.length) { - val subsubstr = substr.substring(currIdx) - if (subsubstr.startsWith(sym.symbol)) { - base.values.add(MarkdownString(string.substring(lastIdx, idx))) - val md = parseMarkdown( - substr.substring( - sym.symbol.length, - (currIdx).coerceAtMost(substr.length) - ), - sym.symbol - ) - base.values.add(md) - idx += md.trueLength - lastIdx = idx - break - } - currIdx++ - } - break - } - } - idx++ - } - base.values.add(MarkdownString(string.substring(lastIdx, idx.coerceAtMost(string.length)))) - return base -} \ No newline at end of file diff --git a/modules/bot/src/main/kotlin/dev/proxyfox/bot/prompts/Button.kt b/modules/bot/src/main/kotlin/dev/proxyfox/bot/prompts/Button.kt deleted file mode 100644 index 984054b1..00000000 --- a/modules/bot/src/main/kotlin/dev/proxyfox/bot/prompts/Button.kt +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright (c) 2022, The ProxyFox Group - * - * This Source Code is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -package dev.proxyfox.bot.prompts - -import dev.kord.common.entity.ButtonStyle -import dev.kord.common.entity.DiscordPartialEmoji -import dev.kord.common.entity.optional.optional -import dev.kord.core.entity.GuildEmoji -import dev.kord.core.entity.ReactionEmoji -import dev.kord.rest.builder.message.modify.MessageModifyBuilder - -@JvmRecord -data class Button( - val label: String? = "Confirm", - val emoji: DiscordPartialEmoji? = null, - val style: ButtonStyle = ButtonStyle.Secondary, - val action: suspend MessageModifyBuilder.() -> Unit, -) { - companion object { - val check = DiscordPartialEmoji(name = "✅") - val multiply = DiscordPartialEmoji(name = "✖") - val wastebasket = DiscordPartialEmoji(name = "🗑") - val move = DiscordPartialEmoji(name = "\uD83D\uDD00") - - val ReactionEmoji.Unicode.partial get() = DiscordPartialEmoji(name = name) - - val ReactionEmoji.Custom.partial get() = DiscordPartialEmoji(id = id, name = name, animated = isAnimated.optional()) - - val GuildEmoji.partial get() = DiscordPartialEmoji(id = id, animated = isAnimated.optional()) - } -} \ No newline at end of file diff --git a/modules/bot/src/main/kotlin/dev/proxyfox/bot/prompts/Pager.kt b/modules/bot/src/main/kotlin/dev/proxyfox/bot/prompts/Pager.kt deleted file mode 100644 index c8b91cca..00000000 --- a/modules/bot/src/main/kotlin/dev/proxyfox/bot/prompts/Pager.kt +++ /dev/null @@ -1,319 +0,0 @@ -/* - * Copyright (c) 2022, The ProxyFox Group - * - * This Source Code is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -package dev.proxyfox.bot.prompts - -import dev.kord.common.entity.ButtonStyle -import dev.kord.common.entity.Snowflake -import dev.kord.core.behavior.MessageBehavior -import dev.kord.core.behavior.channel.MessageChannelBehavior -import dev.kord.core.behavior.channel.createMessage -import dev.kord.core.behavior.edit -import dev.kord.core.behavior.interaction.respondEphemeral -import dev.kord.core.behavior.interaction.response.EphemeralMessageInteractionResponseBehavior -import dev.kord.core.behavior.interaction.response.edit -import dev.kord.core.behavior.interaction.updateEphemeralMessage -import dev.kord.core.behavior.interaction.updatePublicMessage -import dev.kord.core.behavior.reply -import dev.kord.core.builder.components.emoji -import dev.kord.core.entity.Message -import dev.kord.core.entity.ReactionEmoji -import dev.kord.core.event.interaction.ButtonInteractionCreateEvent -import dev.kord.core.event.interaction.SelectMenuInteractionCreateEvent -import dev.kord.core.event.message.MessageCreateEvent -import dev.kord.core.event.message.ReactionAddEvent -import dev.kord.core.on -import dev.kord.rest.builder.component.ActionRowBuilder -import dev.kord.rest.builder.component.SelectOptionBuilder -import dev.kord.rest.builder.message.EmbedBuilder -import dev.kord.rest.builder.message.create.embed -import dev.kord.rest.builder.message.modify.embed -import dev.proxyfox.bot.kord -import dev.proxyfox.common.ceilDiv -import java.util.* -import kotlin.math.max -import kotlin.math.min -import kotlin.time.Duration.Companion.minutes - -// Created 2022-15-10T08:37:40 - -/** - * @author Ampflower - * @since ${version} - **/ -class Pager( - runner: Snowflake, - reference: Message, - private val list: List, - private val pageSize: Int, - private val pages: Int = ceilDiv(list.size, pageSize), - private val embed: suspend EmbedBuilder.(String) -> Unit, - private val transform: suspend (T) -> String, -) : TimedPrompt( - runner, - reference, - 5.minutes, -) { - private var page = 0 - private var selector: ActiveSelector? = null - - init { - jobs = listOf( - kord.on(consumer = this::onInteraction), - kord.on(consumer = this::onReaction), - ) - } - - private suspend fun onInteraction(event: ButtonInteractionCreateEvent) = event.run { - val message = interaction.message - val user = interaction.user - if (user.id == runner && message == this@Pager.reference) { - when (interaction.componentId) { - "skipToFirst" -> page(0) - "back" -> page(page - 1) - "next" -> page(page + 1) - "skipToLast" -> page(pages - 1) - "selection" -> { - val uuid = UUID.randomUUID().toString() - selector = EphemeralSelector(this@Pager, interaction.respondEphemeral { - content = "Which page would you like to go to?" - components += ActionRowBuilder().apply { - selectMenu(uuid) { - val opts = pages / 25 - options += if (opts != 0) { - (0..24).asSequence().map { (it * opts).toString() }.map { SelectOptionBuilder(it, it) } - } else { - (1..pages).asSequence().map(Int::toString).map { SelectOptionBuilder(it, it) } - } - placeholder = (page + 1).toString() - } - } - }, uuid) - } - - "close" -> { - // Kord's updatePublicMessage implementation is broken, see kordlib/kord#701 - interaction.deferPublicMessageUpdate() - close() - } - } - } - } - - private suspend fun onReaction(event: ReactionAddEvent) = event.run { - if (userId == runner && messageId == reference.id && channelId == reference.channelId) { - when (emoji.name) { - "⏪" -> page(0) - // It's the same emoji twice for both branches, - // just the other has `\uFEOF` at the end. - "⬅", "⬅\uFE0F" -> page(page - 1) - "➡", "➡\uFE0F" -> page(page + 1) - "⏩" -> page(pages - 1) - "\uD83D\uDD22" -> { - selector = LegacySelector(this@Pager, reference.reply { - content = "Which page would you like to go to?" - components += ActionRowBuilder().apply { - selectMenu("menu") { - val opts = pages / 25 - options += if (opts != 0) { - (0..24).asSequence().map { (it * opts + 1).toString() }.map { SelectOptionBuilder(it, it) } - } else { - (1..pages).asSequence().map(Int::toString).map { SelectOptionBuilder(it, it) } - } - placeholder = (page + 1).toString() - } - } - components += cancel - }) - } - - "❌", "✖", "✖\uFE0F" -> { - close() - } - } - if (guildId != null) message.deleteReaction(userId, emoji) - } - } - - private suspend fun ButtonInteractionCreateEvent.page(inPage: Int) { - // Kord's updatePublicMessage implementation is broken, see kordlib/kord#701 - interaction.deferPublicMessageUpdate() - this@Pager.page(inPage) - } - - private suspend fun page(inPage: Int) { - bump() - page = min(max(0, inPage), pages - 1) - reference.edit { - embed { - embed("${page + 1} / $pages") - description = buildString(list, page, pageSize, transform) - } - } - } - - override suspend fun close() { - reference.edit { components = mutableListOf() } - closeInternal() - } - - override suspend fun closeInternal() { - selector?.close() - super.closeInternal() - } - - private interface ActiveSelector { - suspend fun close() - } - - private class EphemeralSelector( - val pager: Pager<*>, - val message: EphemeralMessageInteractionResponseBehavior, - val `discord is a massive pain by not returning the message that would eliminate the need for this`: String, - ) : ActiveSelector { - private val ephemeralListener = message.kord.on { - if (interaction.componentId == `discord is a massive pain by not returning the message that would eliminate the need for this`) { - pager.page(interaction.values[0].toInt() - 1) - interaction.updateEphemeralMessage { content = "Pager changed to page ${pager.page + 1}." } - closeInternal() - } - } - - private val messageListener = message.kord.on { - if (pager.runner == message.author?.id && pager.reference.channel == message.channel) { - val page = message.content.toIntOrNull() ?: return@on - pager.page(page - 1) - message.delete("Intercepted by pager.") - this@EphemeralSelector.message.edit { - content = "Pager changed to page ${pager.page + 1}." - components = mutableListOf() - } - closeInternal() - } - } - - override suspend fun close() { - message.edit { - content = "Pager closed." - components = mutableListOf() - } - closeInternal() - } - - fun closeInternal() { - ephemeralListener.cancel() - messageListener.cancel() - pager.selector = null - } - } - - private class LegacySelector( - val pager: Pager<*>, - val message: MessageBehavior, - ) : ActiveSelector { - private val primaryListener = message.kord.on { - val message = interaction.message - val user = interaction.user - if (user.id == pager.runner && message == this@LegacySelector.message) { - pager.page(interaction.values[0].toInt() - 1) - interaction.updatePublicMessage { - content = "Pager changed to page ${pager.page + 1}." - components += freeDelete - } - closeInternal() - } - } - - private val messageListener = message.kord.on { - if (pager.runner == message.author?.id && pager.reference.channel == message.channel) { - val page = message.content.toIntOrNull() ?: return@on - pager.page(page - 1) - message.delete("Intercepted by pager.") - this@LegacySelector.message.edit { - content = "Pager changed to page ${pager.page + 1}." - components = mutableListOf(freeDelete) - } - closeInternal() - } - } - - override suspend fun close() { - message.delete() - closeInternal() - } - - fun closeInternal() { - primaryListener.cancel() - messageListener.cancel() - pager.selector = null - } - } - - companion object { - private val cancel = ActionRowBuilder().apply { - interactionButton(ButtonStyle.Secondary, "cancel") { - emoji(ReactionEmoji.Unicode("✖")) - label = "Cancel" - } - } - - private val freeDelete = ActionRowBuilder().apply { - interactionButton(ButtonStyle.Secondary, "free-delete") { - emoji(ReactionEmoji.Unicode("✖")) - label = "Delete message" - } - } - - suspend fun build( - runner: Snowflake, - channel: MessageChannelBehavior, - list: List, - pageSize: Int, - embed: suspend EmbedBuilder.(String) -> Unit, - transform: suspend (T) -> String, - ): Pager { - val pages = ceilDiv(list.size, pageSize) - val message = channel.createMessage { - embed { - embed("1 / $pages") - description = buildString(list, 0, pageSize, transform) - } - components += ActionRowBuilder().apply { - interactionButton(ButtonStyle.Primary, "skipToFirst") { emoji(ReactionEmoji.Unicode("⏪")) } - interactionButton(ButtonStyle.Primary, "back") { emoji(ReactionEmoji.Unicode("⬅")) } - interactionButton(ButtonStyle.Primary, "next") { emoji(ReactionEmoji.Unicode("➡")) } - interactionButton(ButtonStyle.Primary, "skipToLast") { emoji(ReactionEmoji.Unicode("⏩")) } - } - components += ActionRowBuilder().apply { - interactionButton(ButtonStyle.Secondary, "selection") { - emoji(ReactionEmoji.Unicode("\uD83D\uDD22")) - label = "Select Page" - } - interactionButton(ButtonStyle.Danger, "close") { - // :heavy_multiplication_x: is used instead of :x: - // due to providing better contrast in almost every case. - emoji(ReactionEmoji.Unicode("✖️")) - label = "Close" - } - } - } - return Pager(runner, message, list, pageSize, pages, embed, transform) - } - - private suspend fun buildString( - list: List, - page: Int, - size: Int, - transform: suspend (T) -> String, - ) = buildString { - for (i in page * size until min(page * size + size, list.size)) { - append(transform(list[i])) - } - } - } -} \ No newline at end of file diff --git a/modules/bot/src/main/kotlin/dev/proxyfox/bot/prompts/TimedPrompt.kt b/modules/bot/src/main/kotlin/dev/proxyfox/bot/prompts/TimedPrompt.kt deleted file mode 100644 index f470af31..00000000 --- a/modules/bot/src/main/kotlin/dev/proxyfox/bot/prompts/TimedPrompt.kt +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright (c) 2022, The ProxyFox Group - * - * This Source Code is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -package dev.proxyfox.bot.prompts - -import dev.kord.common.entity.Snowflake -import dev.kord.core.entity.Message -import dev.proxyfox.bot.scope -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.datetime.Clock -import kotlin.time.Duration -import kotlin.time.Duration.Companion.minutes - -// Created 2022-17-10T05:20:36 - -/** - * @author Ampflower - * @since ${version} - **/ -abstract class TimedPrompt( - protected val runner: Snowflake, - protected val reference: Message, - timeout: Duration = oneMinute, -) { - protected lateinit var jobs: Collection - private var lastUpdate = Clock.System.now() - - private val timerJob = scope.launch { - val delay = maxOf(timeout, oneMinute) - var now = Clock.System.now() - var comparison = lastUpdate + delay - - // Busy wait for cancelling the prompt. - while (comparison > now) { - delay(maxOf(comparison - now, oneMinute)) - now = Clock.System.now() - comparison = lastUpdate + delay - } - - close() - } - - abstract suspend fun close() - - protected fun bump() { - lastUpdate = Clock.System.now() - } - - protected open suspend fun closeInternal() { - jobs.forEach(Job::cancel) - timerJob.cancel() - } - - companion object { - val oneMinute = 1.minutes - } -} \ No newline at end of file diff --git a/modules/bot/src/main/kotlin/dev/proxyfox/bot/prompts/TimedYesNoPrompt.kt b/modules/bot/src/main/kotlin/dev/proxyfox/bot/prompts/TimedYesNoPrompt.kt deleted file mode 100644 index 32be2676..00000000 --- a/modules/bot/src/main/kotlin/dev/proxyfox/bot/prompts/TimedYesNoPrompt.kt +++ /dev/null @@ -1,152 +0,0 @@ -/* - * Copyright (c) 2022, The ProxyFox Group - * - * This Source Code is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -package dev.proxyfox.bot.prompts - -import dev.kord.common.entity.ButtonStyle -import dev.kord.common.entity.Snowflake -import dev.kord.core.behavior.channel.MessageChannelBehavior -import dev.kord.core.behavior.channel.createMessage -import dev.kord.core.behavior.edit -import dev.kord.core.behavior.interaction.response.edit -import dev.kord.core.entity.Message -import dev.kord.core.event.interaction.ButtonInteractionCreateEvent -import dev.kord.core.event.message.ReactionAddEvent -import dev.kord.core.on -import dev.kord.rest.builder.component.ActionRowBuilder -import dev.kord.rest.builder.message.modify.MessageModifyBuilder -import dev.proxyfox.bot.kord -import dev.proxyfox.bot.prompts.Button.Companion.check -import dev.proxyfox.bot.prompts.Button.Companion.multiply -import kotlin.time.Duration -import kotlin.time.Duration.Companion.minutes - -/** - * @author Ampflower - * @since ${version} - **/ -class TimedYesNoPrompt( - runner: Snowflake, - reference: Message, - timeout: Duration = 1.minutes, - private val yes: suspend MessageModifyBuilder.() -> Unit, - private val no: suspend MessageModifyBuilder.() -> Unit, -) : TimedPrompt( - runner, - reference, - timeout -) { - init { - jobs = listOf( - kord.on(consumer = this::onInteraction), - kord.on(consumer = this::onReaction), - ) - } - - private suspend fun onInteraction(event: ButtonInteractionCreateEvent) = event.run { - if (interaction.message == reference && interaction.user.id == runner) { - when (interaction.componentId) { - "yes" -> { - interaction.deferPublicMessageUpdate().edit { yes(); components = mutableListOf() } - closeInternal() - } - - "no" -> { - interaction.deferPublicMessageUpdate().edit { no(); components = mutableListOf() } - closeInternal() - } - } - } - } - - private suspend fun onReaction(event: ReactionAddEvent) = event.run { - if (message == reference && userId == runner) { - when (emoji.name) { - "✅" -> { - reference.edit { - yes() - components = mutableListOf() - } - closeInternal() - } - - "❌" -> { - reference.edit { no(); components = mutableListOf() } - closeInternal() - } - } - } - } - - override suspend fun close() { - reference.edit { no(); components = mutableListOf() } - closeInternal() - } - - companion object { - suspend fun build( - runner: Snowflake, - channel: MessageChannelBehavior, - timeout: Duration = 1.minutes, - message: String, - yes: Pair Unit>, - no: Pair Unit> = "Cancel" to { content = "Action cancelled." }, - ): TimedYesNoPrompt { - val msg = channel.createMessage { - content = message - components += ActionRowBuilder().apply { - interactionButton(ButtonStyle.Primary, "yes") { - emoji = check - label = yes.first - } - interactionButton(ButtonStyle.Secondary, "no") { - emoji = multiply - label = no.first - } - } - } - return TimedYesNoPrompt( - runner, - msg, - timeout, - yes.second, - no.second, - ) - } - - suspend fun build( - runner: Snowflake, - channel: MessageChannelBehavior, - timeout: Duration = 1.minutes, - message: String, - yes: Button, - no: Button = Button("Cancel", multiply) { content = "Action cancelled." }, - ): TimedYesNoPrompt { - val msg = channel.createMessage { - content = message - components += ActionRowBuilder().apply { - interactionButton(yes.style, "yes") { - emoji = yes.emoji - label = yes.label - } - interactionButton(no.style, "no") { - emoji = no.emoji - label = no.label - } - } - } - return TimedYesNoPrompt( - runner, - msg, - timeout, - yes.action, - no.action, - ) - } - } -} \ No newline at end of file diff --git a/modules/bot/src/main/kotlin/dev/proxyfox/bot/string/dsl/CommandDsl.kt b/modules/bot/src/main/kotlin/dev/proxyfox/bot/string/dsl/CommandDsl.kt deleted file mode 100644 index d0f5720c..00000000 --- a/modules/bot/src/main/kotlin/dev/proxyfox/bot/string/dsl/CommandDsl.kt +++ /dev/null @@ -1,146 +0,0 @@ -/* - * Copyright (c) 2022, The ProxyFox Group - * - * This Source Code is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -package dev.proxyfox.bot.string.dsl - -import dev.proxyfox.bot.string.node.* -import dev.proxyfox.bot.string.parser.MessageHolder -import dev.proxyfox.common.applyAsync - -suspend fun literal( - name: String, - executor: suspend MessageHolder.() -> String, - action: suspend Node.() -> Unit -): LiteralNode = LiteralNode(arrayOf(name), executor).applyAsync(action) - -fun literal( - name: String, - executor: suspend MessageHolder.() -> String -): LiteralNode = LiteralNode(arrayOf(name), executor) - -suspend fun Node.literal( - name: String, - executor: suspend MessageHolder.() -> String, - action: suspend Node.() -> Unit -): LiteralNode { - val node = LiteralNode(arrayOf(name), executor).applyAsync(action) - addSubNode(node) - return node -} - -fun Node.literal( - name: String, - executor: suspend suspend MessageHolder.() -> String -): LiteralNode { - val node = LiteralNode(arrayOf(name), executor) - addSubNode(node) - return node -} - -suspend fun literal( - names: Array, - executor: suspend MessageHolder.() -> String, - action: suspend Node.() -> Unit -): LiteralNode = LiteralNode(names, executor).applyAsync(action) - -fun literal( - names: Array, - executor: suspend MessageHolder.() -> String -): LiteralNode = LiteralNode(names, executor) - -suspend fun Node.literal( - names: Array, - executor: suspend MessageHolder.() -> String, - action: suspend Node.() -> Unit -): LiteralNode { - val node = LiteralNode(names, executor).applyAsync(action) - addSubNode(node) - return node -} - -fun Node.literal( - names: Array, - executor: suspend suspend MessageHolder.() -> String -): LiteralNode { - val node = LiteralNode(names, executor) - addSubNode(node) - return node -} - -suspend fun Node.unix( - name: String, - executor: suspend MessageHolder.() -> String, - action: suspend Node.() -> Unit -): UnixNode { - val node = UnixNode(name, executor).applyAsync(action) - addSubNode(node) - return node -} -suspend fun Node.unix( - name: String, - executor: suspend MessageHolder.() -> String -): UnixNode { - val node = UnixNode(name, executor) - addSubNode(node) - return node -} - -fun Node.unixLiteral( - name: Array, - executor: suspend MessageHolder.() -> String -): LiteralNode { - val node = LiteralNode(name.flatMap { listOf("-$it", "--$it") }.toTypedArray(), executor) - addSubNode(node) - return node -} - -fun Node.unixLiteral( - name: String, - executor: suspend MessageHolder.() -> String -): LiteralNode { - val node = LiteralNode(arrayOf("-$name", "--$name"), executor) - addSubNode(node) - return node -} - -fun Node.greedy( - name: String, - executor: suspend MessageHolder.() -> String -): GreedyNode { - val node = GreedyNode(name, executor) - addSubNode(node) - return node -} - -fun Node.stringList( - name: String, - executor: suspend MessageHolder.() -> String -): StringListNode { - val node = StringListNode(name, executor) - addSubNode(node) - return node -} - -suspend fun Node.string( - name: String, - executor: suspend MessageHolder.() -> String, - action: suspend Node.() -> Unit -): StringNode { - val node = StringNode(name, executor).applyAsync(action) - addSubNode(node) - return node -} - -fun Node.string( - name: String, - executor: suspend MessageHolder.() -> String -): StringNode { - val node = StringNode(name, executor) - addSubNode(node) - return node -} \ No newline at end of file diff --git a/modules/bot/src/main/kotlin/dev/proxyfox/bot/string/node/GreedyNode.kt b/modules/bot/src/main/kotlin/dev/proxyfox/bot/string/node/GreedyNode.kt deleted file mode 100644 index f8295f91..00000000 --- a/modules/bot/src/main/kotlin/dev/proxyfox/bot/string/node/GreedyNode.kt +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright (c) 2022, The ProxyFox Group - * - * This Source Code is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -package dev.proxyfox.bot.string.node - -import dev.proxyfox.bot.string.parser.MessageHolder - -class GreedyNode(val name: String, val executor: suspend MessageHolder.() -> String) : Node { - override val type: NodeType = NodeType.GREEDY - - override fun parse(string: String, holder: MessageHolder): Int { - if (string.isEmpty()) return 0 - holder.params[name] = arrayOf(string) - return string.length - } - - override fun getSubNodes(): Array = arrayOf() - - override fun addSubNode(node: Node) = Unit - - override suspend fun execute(holder: MessageHolder) = holder.executor() -} \ No newline at end of file diff --git a/modules/bot/src/main/kotlin/dev/proxyfox/bot/string/node/LiteralNode.kt b/modules/bot/src/main/kotlin/dev/proxyfox/bot/string/node/LiteralNode.kt deleted file mode 100644 index 6007f33f..00000000 --- a/modules/bot/src/main/kotlin/dev/proxyfox/bot/string/node/LiteralNode.kt +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright (c) 2022, The ProxyFox Group - * - * This Source Code is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -package dev.proxyfox.bot.string.node - -import dev.proxyfox.bot.string.parser.MessageHolder - -class LiteralNode(private val literals: Array, val executor: suspend MessageHolder.() -> String) : Node { - override val type: NodeType = NodeType.LITERAL - - private val literalNodes: ArrayList = ArrayList() - private val stringNodes: ArrayList = ArrayList() - private val greedyNodes: ArrayList = ArrayList() - - override fun parse(string: String, holder: MessageHolder): Int { - for (literal in literals) { - if (string.length < literal.length) continue - if (string.length == literal.length && string.lowercase() == literal.lowercase()) - return literal.length - if (string.lowercase().startsWith(literal.lowercase() + " ")) - return literal.length - } - return 0 - } - - override fun getSubNodes(): Array { - val literalArray: Array = literalNodes.toTypedArray() - val stringArray: Array = stringNodes.toTypedArray() - val greedyArray: Array = greedyNodes.toTypedArray() - return literalArray + stringArray + greedyArray - } - - override fun addSubNode(node: Node) { - when (node.type) { - NodeType.LITERAL -> literalNodes.add(node) - NodeType.VARIABLE -> stringNodes.add(node) - NodeType.GREEDY -> greedyNodes.add(node) - } - } - - override suspend fun execute(holder: MessageHolder) = holder.executor() -} \ No newline at end of file diff --git a/modules/bot/src/main/kotlin/dev/proxyfox/bot/string/node/Node.kt b/modules/bot/src/main/kotlin/dev/proxyfox/bot/string/node/Node.kt deleted file mode 100644 index 43403558..00000000 --- a/modules/bot/src/main/kotlin/dev/proxyfox/bot/string/node/Node.kt +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright (c) 2022, The ProxyFox Group - * - * This Source Code is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -package dev.proxyfox.bot.string.node - -import dev.proxyfox.bot.string.parser.MessageHolder - -interface Node { - val type: NodeType - fun parse(string: String, holder: MessageHolder): Int - fun getSubNodes(): Array - fun addSubNode(node: Node) - suspend fun execute(holder: MessageHolder): String -} \ No newline at end of file diff --git a/modules/bot/src/main/kotlin/dev/proxyfox/bot/string/node/StringListNode.kt b/modules/bot/src/main/kotlin/dev/proxyfox/bot/string/node/StringListNode.kt deleted file mode 100644 index f6539625..00000000 --- a/modules/bot/src/main/kotlin/dev/proxyfox/bot/string/node/StringListNode.kt +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Copyright (c) 2022, The ProxyFox Group - * - * This Source Code is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -package dev.proxyfox.bot.string.node - -import dev.proxyfox.bot.string.parser.MessageHolder - -class StringListNode(val name: String, val executor: suspend MessageHolder.() -> String) : Node { - override val type: NodeType = NodeType.GREEDY - - override fun parse(string: String, holder: MessageHolder): Int { - if (string.isEmpty()) return 0 - var i = 0 - val arr = ArrayList() - while (i < string.length) { - if (string[i] == ' ') { - i++ - continue - } - when (string[i]) { - '"' -> { - var out = "" - val substr = string.substring(i + 1) - for (j in substr.indices) { - if (substr[j] == '"') - break - out += substr[j].toString() - } - if (out.isNotEmpty()) { - arr.add(out) - i += out.length + 2 - continue - } - if (out.isNotEmpty()) arr.add(out) - } - - '\'' -> { - var out = "" - val substr = string.substring(i + 1) - for (j in substr.indices) { - if (substr[j] == '\'') - break - out += substr[j].toString() - } - if (out.isNotEmpty()) { - arr.add(out) - i += out.length + 2 - continue - } - } - - else -> { - var out = "" - val substr = string.substring(i) - for (j in substr.indices) { - if (substr[j] == ' ') - break - out += substr[j].toString() - } - if (out.isNotEmpty()) { - arr.add(out) - i += out.length - continue - } - } - } - i++ - } -// for (s in arr) { -// logger.info(s) -// } - holder.params[name] = arr.toTypedArray() - return string.length - } - - override fun getSubNodes(): Array = arrayOf() - - override fun addSubNode(node: Node) = Unit - - override suspend fun execute(holder: MessageHolder): String = holder.executor() -} \ No newline at end of file diff --git a/modules/bot/src/main/kotlin/dev/proxyfox/bot/string/node/StringNode.kt b/modules/bot/src/main/kotlin/dev/proxyfox/bot/string/node/StringNode.kt deleted file mode 100644 index 29a25a85..00000000 --- a/modules/bot/src/main/kotlin/dev/proxyfox/bot/string/node/StringNode.kt +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright (c) 2022, The ProxyFox Group - * - * This Source Code is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -package dev.proxyfox.bot.string.node - -import dev.proxyfox.bot.string.parser.MessageHolder - -class StringNode(val name: String, val executor: suspend MessageHolder.() -> String) : Node { - override val type: NodeType = NodeType.VARIABLE - - private val literalNodes: ArrayList = ArrayList() - private val stringNodes: ArrayList = ArrayList() - private val greedyNodes: ArrayList = ArrayList() - - override fun parse(string: String, holder: MessageHolder): Int { - if (string.isEmpty()) return 0 - when (string[0]) { - '"' -> { - var out = "" - for (i in string.substring(1).indices) { - if (string[i+1] == '"') { - holder.params[name] = arrayOf(out) - return i + 2 - } - out += string[i+1].toString() - } - holder.params[name] = arrayOf(out) - } - - '\'' -> { - var out = "" - for (i in string.substring(1).indices) { - if (string[i+1] == '\'') { - holder.params[name] = arrayOf(out) - return i + 2 - } - out += string[i+1].toString() - } - holder.params[name] = arrayOf(out) - } - - else -> { - var out = "" - for (i in string.indices) { - if (string[i] == ' ') { - holder.params[name] = arrayOf(out) - return i - } - out += string[i].toString() - } - holder.params[name] = arrayOf(out) - } - } - return string.length - } - - override fun getSubNodes(): Array { - val literalArray: Array = literalNodes.toTypedArray() - val stringArray: Array = stringNodes.toTypedArray() - val greedyArray: Array = greedyNodes.toTypedArray() - return literalArray + stringArray + greedyArray - } - - override fun addSubNode(node: Node) { - when (node.type) { - NodeType.LITERAL -> literalNodes.add(node) - NodeType.VARIABLE -> stringNodes.add(node) - NodeType.GREEDY -> greedyNodes.add(node) - } - } - - override suspend fun execute(holder: MessageHolder): String = holder.executor() -} \ No newline at end of file diff --git a/modules/bot/src/main/kotlin/dev/proxyfox/bot/string/node/UnixNode.kt b/modules/bot/src/main/kotlin/dev/proxyfox/bot/string/node/UnixNode.kt deleted file mode 100644 index 6458a594..00000000 --- a/modules/bot/src/main/kotlin/dev/proxyfox/bot/string/node/UnixNode.kt +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright (c) 2022, The ProxyFox Group - * - * This Source Code is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -package dev.proxyfox.bot.string.node - -import dev.proxyfox.bot.string.parser.MessageHolder - -class UnixNode(val name: String, val executor: suspend MessageHolder.() -> String) : Node { - override val type: NodeType = NodeType.VARIABLE - - private val literalNodes: ArrayList = ArrayList() - private val stringNodes: ArrayList = ArrayList() - private val greedyNodes: ArrayList = ArrayList() - - override fun parse(string: String, holder: MessageHolder): Int { - if (string.isEmpty()) return 0 - var idx = 0 - val arr = ArrayList() - while (idx < string.length) { - while (idx < string.length) { - if (string[idx] != ' ') break - idx++ - } - var substr = string.substring(idx) - val start = - if (substr.startsWith("--")) "--" - else if (substr.startsWith("-")) "-" - else break - idx += start.length - substr = string.substring(idx) - var out = "" - for (i in substr) { - if (i == ' ') - break - out += i - } - arr.add(out) - idx += out.length - } - holder.params[name] = arr.toTypedArray() - return idx - } - - override fun getSubNodes(): Array { - val literalArray: Array = literalNodes.toTypedArray() - val stringArray: Array = stringNodes.toTypedArray() - val greedyArray: Array = greedyNodes.toTypedArray() - return literalArray + stringArray + greedyArray - } - - override fun addSubNode(node: Node) { - when (node.type) { - NodeType.LITERAL -> literalNodes.add(node) - NodeType.VARIABLE -> stringNodes.add(node) - NodeType.GREEDY -> greedyNodes.add(node) - } - } - - override suspend fun execute(holder: MessageHolder): String = holder.executor() -} diff --git a/modules/bot/src/main/kotlin/dev/proxyfox/bot/string/parser/MessageHolder.kt b/modules/bot/src/main/kotlin/dev/proxyfox/bot/string/parser/MessageHolder.kt deleted file mode 100644 index 40db8645..00000000 --- a/modules/bot/src/main/kotlin/dev/proxyfox/bot/string/parser/MessageHolder.kt +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright (c) 2022, The ProxyFox Group - * - * This Source Code is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -package dev.proxyfox.bot.string.parser - -import dev.kord.common.entity.Permission -import dev.kord.core.behavior.channel.createMessage -import dev.kord.core.entity.Message -import dev.kord.rest.NamedFile -import dev.kord.rest.builder.message.EmbedBuilder -import dev.proxyfox.common.applyAsync - -data class MessageHolder( - val message: Message, - val params: HashMap> -) { - // TODO: Check if can send in channels - suspend fun respond(msg: String = "", dm: Boolean = false, embed: (suspend EmbedBuilder.() -> Unit)? = null): Message { - val channel = if (dm) - message.author?.getDmChannelOrNull() - ?: message.channel - else message.channel - - return channel.createMessage { - if (msg.isNotBlank()) content = msg - // TODO: an `embedAsync` helper function - if (embed != null) embeds.add(EmbedBuilder().applyAsync(embed)) - } - } - - suspend fun sendFiles(vararg files: NamedFile) { - message.author!!.getDmChannel().createMessage { - this.files.addAll(files) - } - } - - suspend fun hasRequired(permission: Permission): Boolean { - val author = message.getAuthorAsMember() ?: return false - return author.getPermissions().contains(permission) - } -} \ No newline at end of file diff --git a/modules/bot/src/main/kotlin/dev/proxyfox/bot/string/parser/StringParser.kt b/modules/bot/src/main/kotlin/dev/proxyfox/bot/string/parser/StringParser.kt deleted file mode 100644 index 4981c1d2..00000000 --- a/modules/bot/src/main/kotlin/dev/proxyfox/bot/string/parser/StringParser.kt +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright (c) 2022, The ProxyFox Group - * - * This Source Code is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -package dev.proxyfox.bot.string.parser - -import dev.kord.core.entity.Message -import dev.proxyfox.bot.string.node.LiteralNode -import dev.proxyfox.bot.string.node.Node -import dev.proxyfox.common.logger - -val nodes: ArrayList = ArrayList() - -fun registerCommand(node: LiteralNode) { - nodes.add(node) -} - -suspend fun parseString(input: String, message: Message): String? { - for (node in nodes) { - val str = tryExecuteNode(input, node, MessageHolder(message, HashMap())) - if (str != null) return str - } - return null -} - -suspend fun tryExecuteNode(input: String, node: Node, holder: MessageHolder): String? { - // Parse out the command node - val idx = node.parse(input, holder) - // Check if returned index is greater than the string length or zero - if (idx == 0) return null - if (idx >= input.length) return execute(node, holder) - // Get a substring with the index - val subStr = input.substring(idx).trim() - // Loop through sub nodes and try to execute - for (subNode in node.getSubNodes()) { - val str = tryExecuteNode(subStr, subNode, holder) - if (str != null) return str - } - // Try to run the executor - return execute(node, holder) -} - -suspend fun execute(node: Node, holder: MessageHolder): String { - return node.execute(holder) -} \ No newline at end of file diff --git a/modules/bot/src/main/kotlin/dev/proxyfox/bot/terminal/TerminalCommands.kt b/modules/bot/src/main/kotlin/dev/proxyfox/bot/terminal/TerminalCommands.kt index 5378fd54..378ca03a 100644 --- a/modules/bot/src/main/kotlin/dev/proxyfox/bot/terminal/TerminalCommands.kt +++ b/modules/bot/src/main/kotlin/dev/proxyfox/bot/terminal/TerminalCommands.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -8,7 +8,10 @@ package dev.proxyfox.bot.terminal +import dev.proxyfox.command.CommandParser +import dev.proxyfox.command.node.builtin.literal import dev.proxyfox.common.printStep +import kotlinx.coroutines.runBlocking import kotlin.concurrent.thread import kotlin.system.exitProcess @@ -16,19 +19,27 @@ import kotlin.system.exitProcess * Terminal related functions and variables * @author Oliver * */ - object TerminalCommands { + val parser = CommandParser() + suspend fun start() { printStep("Start reading console input", 1) + parser.literal("exit", "stop", "quit") { + executes { + exitProcess(0) + } + } startThread() } - suspend fun startThread() { + private fun startThread() { printStep("Launching thread", 2) thread { - while (true) { - val input = readln() - if (input.lowercase() == "exit") exitProcess(0) + runBlocking { + while (true) { + val input = readln() + parser.parse(TerminalContext(input)) + } } } } diff --git a/modules/bot/src/main/kotlin/dev/proxyfox/bot/terminal/TerminalContext.kt b/modules/bot/src/main/kotlin/dev/proxyfox/bot/terminal/TerminalContext.kt new file mode 100644 index 00000000..29770d0f --- /dev/null +++ b/modules/bot/src/main/kotlin/dev/proxyfox/bot/terminal/TerminalContext.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2023, The ProxyFox Group + * + * This Source Code is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.proxyfox.bot.terminal + +import dev.proxyfox.command.CommandContext +import dev.proxyfox.command.menu.CommandMenu +import dev.proxyfox.common.logger + +class TerminalContext(override val command: String) : CommandContext() { + override val value: String = command + + override suspend fun menu(action: suspend CommandMenu.() -> Unit) { + + } + + override suspend fun respondFailure(text: String, private: Boolean): String { + logger.error(text) + return text + } + + override suspend fun respondPlain(text: String, private: Boolean): String { + logger.info(text) + return text + } + + override suspend fun respondSuccess(text: String, private: Boolean): String { + logger.info(text) + return text + } + + override suspend fun respondWarning(text: String, private: Boolean): String { + logger.warn(text) + return text + } +} \ No newline at end of file diff --git a/modules/bot/src/main/kotlin/dev/proxyfox/bot/webhook/GuildMessage.kt b/modules/bot/src/main/kotlin/dev/proxyfox/bot/webhook/GuildMessage.kt index ef0ebf1b..a906cb98 100644 --- a/modules/bot/src/main/kotlin/dev/proxyfox/bot/webhook/GuildMessage.kt +++ b/modules/bot/src/main/kotlin/dev/proxyfox/bot/webhook/GuildMessage.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -8,6 +8,7 @@ package dev.proxyfox.bot.webhook +import dev.kord.common.entity.MessageFlags import dev.kord.common.entity.Snowflake import dev.kord.core.behavior.MessageBehavior import dev.kord.core.behavior.channel.MessageChannelBehavior @@ -30,6 +31,7 @@ data class GuildMessage( val embeds: Collection, val referencedMessage: Message?, val rawBehaviour: MessageBehavior, + val flags: MessageFlags? ) { constructor( message: Message, @@ -46,5 +48,6 @@ data class GuildMessage( embeds = message.embeds, referencedMessage = message.referencedMessage, rawBehaviour = message, + flags = message.flags ) } diff --git a/modules/bot/src/main/kotlin/dev/proxyfox/bot/webhook/ProxyContext.kt b/modules/bot/src/main/kotlin/dev/proxyfox/bot/webhook/ProxyContext.kt index fd8d5a6e..f28e350d 100644 --- a/modules/bot/src/main/kotlin/dev/proxyfox/bot/webhook/ProxyContext.kt +++ b/modules/bot/src/main/kotlin/dev/proxyfox/bot/webhook/ProxyContext.kt @@ -9,27 +9,29 @@ package dev.proxyfox.bot.webhook import dev.kord.common.Color +import dev.kord.common.entity.MessageFlag +import dev.kord.common.entity.MessageFlags import dev.kord.common.entity.Snowflake import dev.kord.core.behavior.channel.threads.ThreadChannelBehavior import dev.kord.core.entity.User -import dev.kord.rest.NamedFile import dev.kord.rest.builder.message.create.embed import dev.kord.rest.request.KtorRequestException import dev.proxyfox.bot.http import dev.proxyfox.bot.kord -import dev.proxyfox.bot.md.BaseMarkdown -import dev.proxyfox.bot.md.MarkdownString -import dev.proxyfox.bot.md.parseMarkdown +import dev.proxyfox.bot.markdownParser import dev.proxyfox.common.ellipsis +import dev.proxyfox.common.useragent import dev.proxyfox.database.database import dev.proxyfox.database.records.member.MemberProxyTagRecord import dev.proxyfox.database.records.member.MemberRecord import dev.proxyfox.database.records.system.SystemRecord +import dev.proxyfox.markt.RootNode +import dev.proxyfox.markt.StringNode import io.ktor.client.request.* +import io.ktor.client.request.forms.* import io.ktor.client.statement.* import io.ktor.http.* import io.ktor.util.* -import io.ktor.utils.io.jvm.javaio.* import kotlinx.coroutines.delay /** @@ -48,11 +50,14 @@ data class ProxyContext( val resolvedUsername: String, val resolvedAvatar: String?, val moderationDelay: Long, + val enforceTag: Boolean ) { private fun buildAndSanitiseName(): String { val builder = StringBuilder(resolvedUsername) - system.tag?.let { builder.append(' ').append(it) } + system.tag?.let { builder.append(' ').append(it) } ?: run { + if(enforceTag) builder.append(' ').append("| ${message.author.username}#${message.author.discriminator}") + } builder.scanAndSpace("clyde") builder.scanAndSpace("discord") @@ -80,44 +85,57 @@ data class ProxyContext( if (messageContent.isNotBlank()) content = messageContent username = buildAndSanitiseName() avatarUrl = resolvedAvatar + + if (message.flags?.contains(MessageFlag.IsVoiceMessage) == true) { + flags = MessageFlags(MessageFlag.IsVoiceMessage) + } + for (attachment in message.attachments) { val response: HttpResponse = http.get(urlString = attachment.url) { - headers { append(HttpHeaders.UserAgent, "ProxyFox/2.0.0 (+https://github.com/The-ProxyFox-Group/ProxyFox/; +https://proxyfox.dev/)") } + headers { + append( + HttpHeaders.UserAgent, + useragent + ) + } } - files.add(NamedFile(attachment.filename, response.content.toInputStream())) + addFile( + attachment.filename, + ChannelProvider { response.content } + ) } if (reproxy) { - message.embeds.forEach { - if (it.author?.name?.endsWith(" ↩️") == true) { - embed { - color = Color(member.color) - author { - name = it.author?.name - icon = it.author?.iconUrl + message.embeds.forEach { + if (it.author?.name?.endsWith(" ↩️") == true) { + embed { + color = Color(member.color) + author { + name = it.author?.name + icon = it.author?.iconUrl + } + description = it.description } - description = it.description } } - } - } else message.referencedMessage?.let { ref -> - // Kord's official methods don't return a user if it's a webhook - val user = User(ref.data.author, kord) - val link = "https://discord.com/channels/${ref.getGuild().id}/${ref.channelId}/${ref.id}" - embed { - color = Color(member.color) - author { - name = (ref.getAuthorAsMember()?.displayName ?: user.username) + " ↩️" - icon = user.avatar?.url ?: user.defaultAvatar.url - url = link - } - var msgRef = parseMarkdown(ref.content) - if (msgRef.length > 100) { - // We know it's gonna be a BaseMarkdown so - msgRef = msgRef.substring(100) as BaseMarkdown - msgRef.values.add(MarkdownString(ellipsis)) + } else message.referencedMessage?.let { ref -> + // Kord's official methods don't return a user if it's a webhook + val user = User(ref.data.author, kord) + val link = "https://discord.com/channels/${ref.getGuild().id}/${ref.channelId}/${ref.id}" + embed { + color = Color(member.color) + author { + name = (ref.getAuthorAsMemberOrNull()?.displayName ?: user.username) + " ↩️" + icon = (user.avatar?.cdnUrl ?: user.defaultAvatar.cdnUrl).toUrl() + url = link + } + var msgRef = markdownParser.parse(ref.content) + if (msgRef.length > 100) { + // We should be getting a RootNode returned here. + msgRef = msgRef.truncate(100) as RootNode + msgRef.nodes.add(StringNode(ellipsis)) + } + description = "**[Reply to:]($link)** $msgRef" } - description = "**[Reply to:]($link)** $msgRef" - } } } } catch (e: KtorRequestException) { diff --git a/modules/bot/src/main/kotlin/dev/proxyfox/bot/webhook/WebhookCache.kt b/modules/bot/src/main/kotlin/dev/proxyfox/bot/webhook/WebhookCache.kt index 331815dd..109d9f2f 100644 --- a/modules/bot/src/main/kotlin/dev/proxyfox/bot/webhook/WebhookCache.kt +++ b/modules/bot/src/main/kotlin/dev/proxyfox/bot/webhook/WebhookCache.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this diff --git a/modules/bot/src/main/kotlin/dev/proxyfox/bot/webhook/WebhookHolder.kt b/modules/bot/src/main/kotlin/dev/proxyfox/bot/webhook/WebhookHolder.kt index bb71fcf9..b75f258e 100644 --- a/modules/bot/src/main/kotlin/dev/proxyfox/bot/webhook/WebhookHolder.kt +++ b/modules/bot/src/main/kotlin/dev/proxyfox/bot/webhook/WebhookHolder.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this diff --git a/modules/bot/src/main/kotlin/dev/proxyfox/bot/webhook/WebhookUtil.kt b/modules/bot/src/main/kotlin/dev/proxyfox/bot/webhook/WebhookUtil.kt index 7819f826..63d7ac63 100644 --- a/modules/bot/src/main/kotlin/dev/proxyfox/bot/webhook/WebhookUtil.kt +++ b/modules/bot/src/main/kotlin/dev/proxyfox/bot/webhook/WebhookUtil.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -35,6 +35,7 @@ object WebhookUtil { proxy: MemberProxyTagRecord?, serverMember: MemberServerSettingsRecord?, moderationDelay: Long = 500L, + enforceTag: Boolean = false ): ProxyContext? { var messageContent = content if (!member.keepProxy && proxy != null) @@ -53,6 +54,7 @@ object WebhookUtil { resolvedUsername = serverMember?.nickname ?: member.displayName ?: member.name, resolvedAvatar = serverMember?.avatarUrl.httpUriOrNull() ?: member.avatarUrl.httpUriOrNull() ?: system.avatarUrl?.httpUri(), moderationDelay = max(moderationDelay, 0L), + enforceTag = enforceTag ) } diff --git a/modules/common/build.gradle.kts b/modules/common/build.gradle.kts index 7c226cd7..677004b9 100644 --- a/modules/common/build.gradle.kts +++ b/modules/common/build.gradle.kts @@ -7,7 +7,7 @@ */ import java.io.ByteArrayOutputStream -import java.nio.charset.* +import java.nio.charset.Charset plugins { alias(libs.plugins.kotlin.jvm) @@ -15,24 +15,39 @@ plugins { dependencies { api(libs.bundles.base) - api(libs.gson) api(kotlin("stdlib")) } tasks.withType { val hash = getCommitHash() + val branch = getBranch() inputs.property("hash", hash) - filesMatching("commit_hash.txt") { - expand("hash" to hash) + inputs.property("branch", branch) + inputs.property("version", rootProject.version) + filesMatching("git.properties") { + expand( + "hash" to hash, + "branch" to branch, + "version" to rootProject.version + ) } } -fun getCommitHash(): String? { +fun getCommitHash(): String { val stdout = ByteArrayOutputStream() exec { commandLine("git", "log", "-n", "1", "--pretty=format:\"%h\"", "--encoding=UTF-8") standardOutput = stdout } val str = stdout.toString(Charset.defaultCharset()) - return str.substring(1, str.length-1) + return str.substring(1, str.length - 1) +} + +fun getBranch(): String { + val stdout = ByteArrayOutputStream() + exec { + commandLine("git", "rev-parse", "--abbrev-ref", "HEAD") + standardOutput = stdout + } + return stdout.toString(Charset.defaultCharset()) } \ No newline at end of file diff --git a/modules/common/src/main/kotlin/dev/proxyfox/common/FoxFetch.kt b/modules/common/src/main/kotlin/dev/proxyfox/common/FoxFetch.kt index 16c9ab10..1a035728 100644 --- a/modules/common/src/main/kotlin/dev/proxyfox/common/FoxFetch.kt +++ b/modules/common/src/main/kotlin/dev/proxyfox/common/FoxFetch.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -8,15 +8,35 @@ package dev.proxyfox.common -import com.google.gson.* -import kotlinx.coroutines.* -import java.net.* +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.plugins.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.request.* +import io.ktor.serialization.kotlinx.json.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json object FoxFetch { private val baseUrl = "https://api.tinyfox.dev" - private val url = URL("https://api.tinyfox.dev/img?animal=fox&json") + private val requestUrl = "https://api.tinyfox.dev/img?animal=fox&json" + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + } + private val client = HttpClient { + install(UserAgent) { + agent = useragent + } + install(ContentNegotiation) { + json(json) + } + } - suspend fun fetch() = withContext(Dispatchers.IO) { - baseUrl + url.openStream().reader().use { JsonParser.parseReader(it).asJsonObject["loc"].asString } + suspend fun fetch() = withContext(Dispatchers.IO) { + baseUrl + client.get(requestUrl).body().loc } + + data class Response(val loc: String) } diff --git a/modules/common/src/main/kotlin/dev/proxyfox/common/Util.kt b/modules/common/src/main/kotlin/dev/proxyfox/common/Util.kt index 1dca6acf..0e06b799 100644 --- a/modules/common/src/main/kotlin/dev/proxyfox/common/Util.kt +++ b/modules/common/src/main/kotlin/dev/proxyfox/common/Util.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -8,9 +8,17 @@ package dev.proxyfox.common +import dev.kord.common.entity.Snowflake +import dev.kord.core.Kord +import dev.kord.core.event.Event +import dev.kord.core.on +import dev.proxyfox.command.types.UnixList +import org.slf4j.Logger import org.slf4j.LoggerFactory +import java.io.StringReader import java.lang.management.* import java.nio.charset.Charset +import java.util.* import kotlin.contracts.ExperimentalContracts import kotlin.contracts.InvocationKind import kotlin.contracts.contract @@ -27,16 +35,20 @@ const val ellipsis = "…" fun printFancy(input: String) { val edges = "*".repeat(input.length + 4) - logger.info(edges) - logger.info("* $input *") - logger.info(edges) + logger..edges.."* $input *"..edges } fun printStep(input: String, step: Int) { - val add = " ".repeat(step) - logger.info(step.toString() + add + input) + logger.." " * step + input } +operator fun Logger.rangeTo(string: String): Logger { + info(string) + return this +} + +operator fun String.times(n: Int) = repeat(n) + fun String?.toColor(): Int { return if (this == null || this == "") -1 else (toUIntOrNull(16)?.toInt() ?: Integer.decode(this)) and 0xFFFFFF } @@ -66,10 +78,25 @@ suspend inline fun T.applyAsync(block: suspend T.() -> Unit): T { return this } -//We just need a classloader to get a resource -val hash = object {}.javaClass.getResource("/commit_hash.txt")?.readText(Charset.defaultCharset()) ?: "Unknown Hash" +fun getGit(): Properties { + val input = logger.javaClass.getResource("/git.properties")?.readText(Charset.defaultCharset())!! + val properties = Properties() + properties.load(StringReader(input)) + return properties +} + +val gitProperties = getGit() + +val hash = gitProperties["hash"] as String + +val branch = gitProperties["branch"] as String + +val version = gitProperties["version"] as String + +val useragent = + "ProxyFox/$version@$branch#$hash (+https://github.com/The-ProxyFox-Group/ProxyFox/; +https://proxyfox.dev/)" -class DebugException: Exception("Debug Exception - Do Not Report") +class DebugException : Exception("Debug Exception - Do Not Report") val threadMXBean = ManagementFactory.getThreadMXBean() @@ -82,3 +109,27 @@ fun getRamUsage(): Long = getMaxRam() - getFreeRam() fun getRamUsagePercentage(): Double = (getRamUsage().toDouble() / getMaxRam().toDouble()) * 100 fun getThreadCount() = threadMXBean.threadCount + +fun Array.trimEach() { + forEachIndexed { i, s -> + this[i] = s.trim() + } +} + +fun Throwable?.throwIfPresent() { + throw this ?: return +} + +inline fun Kord.onlyIf( + crossinline getter: E.() -> Any?, + compare: Any?, + crossinline executor: suspend E.() -> Unit +) = on { + if (getter() == compare) executor() +} + +fun UnixList?.find(value: String) = + this?.list?.contains(value) ?: false + +val ULong.snowflake: Snowflake + get() = Snowflake(this) diff --git a/modules/common/src/main/kotlin/dev/proxyfox/common/annotations/DontExpose.kt b/modules/common/src/main/kotlin/dev/proxyfox/common/annotations/DontExpose.kt new file mode 100644 index 00000000..e835a989 --- /dev/null +++ b/modules/common/src/main/kotlin/dev/proxyfox/common/annotations/DontExpose.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2023, The ProxyFox Group + * + * This Source Code is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.proxyfox.common.annotations + +@Retention(AnnotationRetention.BINARY) +@RequiresOptIn("You shouldn't expose this publicly!") +annotation class DontExpose(val reason: String) diff --git a/modules/common/src/main/resources/commit_hash.txt b/modules/common/src/main/resources/commit_hash.txt deleted file mode 100644 index ce8eab44..00000000 --- a/modules/common/src/main/resources/commit_hash.txt +++ /dev/null @@ -1 +0,0 @@ -${hash} \ No newline at end of file diff --git a/modules/common/src/main/resources/git.properties b/modules/common/src/main/resources/git.properties new file mode 100644 index 00000000..9ac77a9e --- /dev/null +++ b/modules/common/src/main/resources/git.properties @@ -0,0 +1,3 @@ +hash=${hash} +branch=${branch} +version=${version} \ No newline at end of file diff --git a/modules/conversion/src/main/kotlin/dev/proxyfox/conversion/ConversionMain.kt b/modules/conversion/src/main/kotlin/dev/proxyfox/conversion/ConversionMain.kt index 7068e75c..c397742d 100644 --- a/modules/conversion/src/main/kotlin/dev/proxyfox/conversion/ConversionMain.kt +++ b/modules/conversion/src/main/kotlin/dev/proxyfox/conversion/ConversionMain.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -8,22 +8,21 @@ package dev.proxyfox.conversion -import com.google.gson.stream.JsonReader -import com.google.gson.stream.JsonToken import dev.proxyfox.database.Database -import dev.proxyfox.database.JsonDatabase import dev.proxyfox.database.databaseFromString -import dev.proxyfox.database.gson -import dev.proxyfox.database.records.misc.ServerSettingsRecord import dev.proxyfox.database.etc.importer.PluralKitImporter import dev.proxyfox.database.etc.types.PkSystem +import dev.proxyfox.database.records.misc.ServerSettingsRecord +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream import org.slf4j.LoggerFactory import java.io.File -import kotlin.io.path.Path import kotlin.system.exitProcess private val logger = LoggerFactory.getLogger("Converter") +@OptIn(ExperimentalSerializationApi::class) suspend fun main(args: Array) { var from: String? = null var to: String? = null @@ -55,13 +54,6 @@ suspend fun main(args: Array) { val systems = if (file.isFile) file else file.resolve("systems.json") val roles = (if (file.isFile) file.parentFile else file).resolve("roles.json") - // NIO is used here as it has a much stronger guarantee that ./systems.json will match correctly. - if (outputDatabase is JsonDatabase && Path("./systems.json") == systems.toPath()) { - logger.warn("JSON database selected as output while trying to import legacy database from current directory.") - logger.warn("Select either a different database or directory before importing.") - exit(2) - } - if (!systems.exists() || !roles.exists()) exit(1, "No data to import at $file; does `systems.json` or `roles.json` exist?") logger.info("Converting legacy database... this may take a while.") @@ -70,12 +62,8 @@ suspend fun main(args: Array) { if (systems.exists()) { logger.info("Importing systems.json...") - JsonReader(systems.reader()).use { - val sysAdaptor = gson.getAdapter(PkSystem::class.java) - it.beginObject() - while (it.peek() != JsonToken.END_OBJECT) { - val id = it.nextName().toULong() - val obj = sysAdaptor.read(it) as PkSystem + systems.inputStream().use { input -> + Json.decodeFromStream>(input).forEach { (id, obj) -> try { output.bulk { val pki = object : PluralKitImporter(directAllocation = true, ignoreUnfinished = true) {} @@ -89,24 +77,19 @@ suspend fun main(args: Array) { throw Exception("Failed with $id -> $obj", e) } } - it.endObject() } } if (roles.exists()) { logger.info("Importing roles.json...") output.bulk { - JsonReader(roles.reader()).use { - it.beginObject() - while (it.peek() != JsonToken.END_OBJECT) { - val id = it.nextName().toULong() - val role = it.nextLong().toULong() + roles.inputStream().use { input -> + Json.decodeFromStream>(input).forEach { (id, role) -> createServerSettings(ServerSettingsRecord().apply { serverId = id proxyRole = role }) } - it.endObject() } } } diff --git a/modules/database/build.gradle.kts b/modules/database/build.gradle.kts index e557907a..c9a49514 100644 --- a/modules/database/build.gradle.kts +++ b/modules/database/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.serialization) } dependencies { diff --git a/modules/database/src/main/kotlin/dev/proxyfox/database/Database.kt b/modules/database/src/main/kotlin/dev/proxyfox/database/Database.kt index 5ca72308..2ff7c2be 100644 --- a/modules/database/src/main/kotlin/dev/proxyfox/database/Database.kt +++ b/modules/database/src/main/kotlin/dev/proxyfox/database/Database.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -13,6 +13,7 @@ import dev.kord.core.behavior.GuildBehavior import dev.kord.core.behavior.UserBehavior import dev.kord.core.behavior.channel.ChannelBehavior import dev.proxyfox.database.records.DatabaseException +import dev.proxyfox.database.records.group.GroupRecord import dev.proxyfox.database.records.member.MemberProxyTagRecord import dev.proxyfox.database.records.member.MemberRecord import dev.proxyfox.database.records.member.MemberServerSettingsRecord @@ -21,8 +22,8 @@ import dev.proxyfox.database.records.system.SystemChannelSettingsRecord import dev.proxyfox.database.records.system.SystemRecord import dev.proxyfox.database.records.system.SystemServerSettingsRecord import dev.proxyfox.database.records.system.SystemSwitchRecord +import kotlinx.datetime.Instant import org.jetbrains.annotations.TestOnly -import java.time.Instant import kotlin.contracts.ExperimentalContracts import kotlin.contracts.contract import kotlin.time.Duration @@ -35,6 +36,10 @@ import kotlin.time.Duration * @author Ampflower **/ // Suppression since unused warnings aren't useful for an API. +/* +* TODO: Move methods that require an id for an object in the database to use that object. +* ex: systemId: String, memberName: String -> system: SystemRecord, memberName: String +* */ @Suppress("unused") abstract class Database : AutoCloseable { abstract suspend fun setup(): Database @@ -312,6 +317,30 @@ abstract class Database : AutoCloseable { open suspend fun createMessage(message: ProxiedMessageRecord) = updateMessage(message) abstract suspend fun fetchMessage(messageId: Snowflake): ProxiedMessageRecord? abstract suspend fun fetchLatestMessage(systemId: String, channelId: Snowflake): ProxiedMessageRecord? + abstract suspend fun dropMessage(messageId: Snowflake) + + open suspend fun createToken(systemId: String, type: TokenType): TokenRecord { + val token = TokenRecord(generateUniqueToken(), firstFreeTokenId(systemId), systemId, type) + updateToken(token) + return token + } + private suspend fun firstFreeTokenId(systemId: String): String = + fetchTokens(systemId).map(TokenRecord::id).firstFree() + abstract suspend fun fetchToken(token: String): TokenRecord? + abstract suspend fun fetchTokenFromId(systemId: String, id: String): TokenRecord? + abstract suspend fun fetchTokens(systemId: String): List + abstract suspend fun updateToken(token: TokenRecord) + abstract suspend fun dropToken(token: String) + abstract suspend fun dropTokenById(systemId: String, id: String) + abstract suspend fun dropTokens(systemId: String) + open suspend fun containsToken(token: String): Boolean = fetchToken(token) != null + open suspend fun generateUniqueToken(): String { + var token = generateToken() + while (containsToken(token)) { + token = generateToken() + } + return token + } /** * Allocates a proxy tag @@ -454,7 +483,7 @@ abstract class Database : AutoCloseable { suspend inline fun fetchTotalMembersFromUser(user: UserBehavior?) = fetchUser(user)?.systemId?.let { fetchTotalMembersFromSystem(it) } ?: -1 /** - * Gets the total number of members registered in a system by discord ID. + * Gets the total number of members registered in a system. * * Implementation requirements: return an int with the total members registered * */ @@ -465,6 +494,64 @@ abstract class Database : AutoCloseable { * */ abstract suspend fun fetchMemberFromSystemAndName(systemId: String, memberName: String, caseSensitive: Boolean = true): MemberRecord? + /** + * Gets the groups a member is a part of + * */ + abstract suspend fun fetchGroupsFromMember(member: MemberRecord): List + + /** + * Gets the members that are a part of a group + * */ + abstract suspend fun fetchMembersFromGroup(group: GroupRecord): List + + /** + * Fetches a group + * */ + abstract suspend fun fetchGroupFromSystem(system: PkId, groupId: PkId): GroupRecord? + abstract suspend fun fetchGroupsFromSystem(system: PkId): List? + abstract suspend fun fetchGroupFromSystemAndName( + system: PkId, + name: String, + caseSensitive: Boolean = false + ): GroupRecord? + + /** + * Updates a group + * */ + abstract suspend fun updateGroup(group: GroupRecord) + + /** + * Creates a group + * */ + suspend fun createGroup(system: PkId, name: String): GroupRecord? { + return fetchGroupFromSystemAndName(system, name) ?: createGroup(system, name, null) + } + + suspend fun createGroup(group: GroupRecord) = updateGroup(group) + + open suspend fun createGroup(systemId: String, name: String, id: String? = null): GroupRecord? { + fetchSystemFromId(systemId) ?: return null + val group = GroupRecord( + id = firstFreeMemberId(systemId, id), + systemId = systemId, + name = name, + ) + createGroup(group) + return group + } + + /** + * Gets a group by system ID and either group ID or name. + * */ + suspend fun findGroup(system: PkId, group: String): GroupRecord? = + if (group.startsWith("id:")) + fetchGroupFromSystem(system, group.substring(3)) + ?: fetchGroupFromSystemAndName(system, group, false) + else + fetchGroupFromSystemAndName(system, group, true) + ?: fetchGroupFromSystemAndName(system, group, false) + ?: fetchGroupFromSystem(system, group) + /** * Gets a member by system ID and either member ID or name. * */ @@ -545,6 +632,22 @@ abstract class Database : AutoCloseable { } open suspend fun firstFreeMemberId(systemId: String, id: String? = null): String { - return if (isMemberIdReserved(systemId, id)) fetchMembersFromSystem(systemId)?.map(MemberRecord::id)?.firstFree() ?: "aaaaa" else id + return if (isMemberIdReserved(systemId, id)) fetchMembersFromSystem(systemId)?.map(MemberRecord::id) + ?.firstFree() ?: "aaaaa" else id + } + + open suspend fun containsGroup(systemId: String, groupId: String) = fetchGroupFromSystem(systemId, groupId) != null + + @OptIn(ExperimentalContracts::class) + protected suspend fun isGroupIdReserved(systemId: String, groupId: String?): Boolean { + contract { + returns(false) implies (groupId != null) + } + return !systemId.isValidPkString() || !groupId.isValidPkString() || containsGroup(systemId, groupId) + } + + open suspend fun firstFreeGroupId(systemId: String, id: String? = null): String { + return if (isGroupIdReserved(systemId, id)) fetchGroupsFromSystem(systemId)?.map(GroupRecord::id)?.firstFree() + ?: "aaaaa" else id } } \ No newline at end of file diff --git a/modules/database/src/main/kotlin/dev/proxyfox/database/DatabaseMain.kt b/modules/database/src/main/kotlin/dev/proxyfox/database/DatabaseMain.kt index eeb84723..d2b59954 100644 --- a/modules/database/src/main/kotlin/dev/proxyfox/database/DatabaseMain.kt +++ b/modules/database/src/main/kotlin/dev/proxyfox/database/DatabaseMain.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -20,8 +20,8 @@ object DatabaseMain { database = try { databaseFromString(db) } catch (err: Throwable) { - printStep("Database setup failed. Falling back to JSON", 2) - JsonDatabase() + printStep("Database setup failed. Falling back to in-memory database", 2) + InMemoryDatabase() }.setup() printStep("Registering shutdown hook for database", 2) // Allows the database to shut down & save correctly. diff --git a/modules/database/src/main/kotlin/dev/proxyfox/database/DatabaseUtil.kt b/modules/database/src/main/kotlin/dev/proxyfox/database/DatabaseUtil.kt index c5eeca3e..cdd8c5b2 100644 --- a/modules/database/src/main/kotlin/dev/proxyfox/database/DatabaseUtil.kt +++ b/modules/database/src/main/kotlin/dev/proxyfox/database/DatabaseUtil.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -8,18 +8,14 @@ package dev.proxyfox.database -import com.google.gson.* import com.mongodb.reactivestreams.client.MongoCollection -import dev.proxyfox.database.etc.gson.* import dev.proxyfox.database.etc.importer.ImporterException import kotlinx.coroutines.reactive.awaitFirst -import org.bson.types.ObjectId import org.litote.kmongo.coroutine.toList import org.litote.kmongo.reactivestreams.getCollection import org.litote.kmongo.util.KMongoUtil -import java.time.Instant -import java.time.LocalDate -import java.time.OffsetDateTime +import java.security.SecureRandom +import java.util.* import kotlin.contracts.ExperimentalContracts import kotlin.contracts.contract @@ -27,18 +23,9 @@ import kotlin.contracts.contract typealias PkId = String +private val secureRandom = SecureRandom() const val pkIdBound = 11881376 -val gson = GsonBuilder() - .registerTypeAdapter(OffsetDateTime::class.java, OffsetDateTimeAdaptor) - .registerTypeAdapter(LocalDate::class.java, LocalDateAdaptor) - .registerTypeAdapter(ObjectId::class.java, ObjectIdNullifier) - .registerTypeAdapter(Instant::class.java, InstantAdaptor) - .registerTypeAdapter(ULong::class.java, ULongAdaptor) - .registerTypeAdapter(Void::class.java, VoidAdaptor) - .registerTypeAdapterFactory(RecordAdapterFactory) - .create()!! - fun String.sanitise(): String { return replace("\u0000", "").trim() } @@ -126,14 +113,15 @@ fun Collection.firstFree(): String { fun databaseFromString(db: String?) = when (db) { "nop" -> NopDatabase() - "json" -> JsonDatabase() - "postgres" -> TODO("Postgres db isn't implemented yet!") + "in-memory" -> InMemoryDatabase() "mongo", null -> MongoDatabase() else -> throw IllegalArgumentException("Unknown database $db") } +@Suppress("NOTHING_TO_INLINE", "UNUSED") inline fun unsupported(message: String = "Not implemented"): Nothing = throw UnsupportedOperationException(message) +@Suppress("UNUSED") inline fun Array.mapArray(action: (T) -> R): Array { return Array(size) { action(this[it]) } } @@ -146,3 +134,9 @@ suspend inline fun Mongo.getOrCreateCollection(): MongoCollect } return getCollection() } + +fun generateToken(): String { + val buffer = ByteArray(96) + secureRandom.nextBytes(buffer) + return Base64.getUrlEncoder().encodeToString(buffer) +} diff --git a/modules/database/src/main/kotlin/dev/proxyfox/database/InMemoryDatabase.kt b/modules/database/src/main/kotlin/dev/proxyfox/database/InMemoryDatabase.kt new file mode 100644 index 00000000..75f5a59f --- /dev/null +++ b/modules/database/src/main/kotlin/dev/proxyfox/database/InMemoryDatabase.kt @@ -0,0 +1,419 @@ +/* + * Copyright (c) 2022-2023, The ProxyFox Group + * + * This Source Code is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.proxyfox.database + +import dev.kord.common.entity.Snowflake +import dev.kord.core.behavior.channel.ChannelBehavior +import dev.proxyfox.database.records.group.GroupRecord +import dev.proxyfox.database.records.member.MemberProxyTagRecord +import dev.proxyfox.database.records.member.MemberRecord +import dev.proxyfox.database.records.member.MemberServerSettingsRecord +import dev.proxyfox.database.records.misc.* +import dev.proxyfox.database.records.system.SystemChannelSettingsRecord +import dev.proxyfox.database.records.system.SystemRecord +import dev.proxyfox.database.records.system.SystemServerSettingsRecord +import dev.proxyfox.database.records.system.SystemSwitchRecord +import kotlinx.datetime.Instant +import java.util.concurrent.ConcurrentHashMap +import kotlin.time.Duration + +class InMemoryDatabase : Database() { + private lateinit var users: MutableMap + + private lateinit var messages: ArrayList + + private lateinit var servers: MutableMap + private lateinit var channels: MutableMap> + + private lateinit var systems: MutableMap + private lateinit var systemSwitches: MutableMap> + private lateinit var systemTokens: MutableMap + + private lateinit var systemServers: MutableMap> + private lateinit var systemChannels: MutableMap> + + private lateinit var members: MutableMap> + private lateinit var memberProxies: MutableMap> + + private lateinit var memberServers: MutableMap> + + private lateinit var groups: MutableMap> + + override suspend fun setup(): InMemoryDatabase { + users = ConcurrentHashMap() + messages = ArrayList() + servers = ConcurrentHashMap() + channels = ConcurrentHashMap() + systems = ConcurrentHashMap() + systemSwitches = ConcurrentHashMap() + systemTokens = ConcurrentHashMap() + systemServers = ConcurrentHashMap() + systemChannels = ConcurrentHashMap() + members = ConcurrentHashMap() + memberProxies = ConcurrentHashMap() + memberServers = ConcurrentHashMap() + groups = ConcurrentHashMap() + + return this + } + override suspend fun ping(): Duration { + return Duration.ZERO + } + + override suspend fun getDatabaseName() = "In-Memory" + + override suspend fun fetchUser(userId: ULong): UserRecord? = users[userId] + + override suspend fun getOrCreateUser(userId: ULong): UserRecord { + if (!users.containsKey(userId)) { + users[userId] = UserRecord() + } + + return users[userId]!! + } + + override suspend fun fetchSystemFromId(systemId: String): SystemRecord? = systems[systemId] + + override suspend fun fetchMembersFromSystem(systemId: String): List? = members[systemId]?.values?.toList() + + override suspend fun fetchMemberFromSystem(systemId: String, memberId: String): MemberRecord? = fetchMembersFromSystem(systemId)?.find { it.id == memberId } + + override suspend fun fetchProxiesFromSystem(systemId: String): List? { + val members = fetchMembersFromSystem(systemId) + return members?.mapNotNull { member -> memberProxies[member.id] }?.flatten() + } + + override suspend fun fetchProxiesFromSystemAndMember(systemId: String, memberId: String): List? { + val member = fetchMemberFromSystem(systemId, memberId) + return memberProxies[member?.id] + } + + override suspend fun fetchMemberServerSettingsFromSystemAndMember( + serverId: ULong, + systemId: String, + memberId: String + ): MemberServerSettingsRecord? = memberServers[fetchMemberFromSystem(systemId, memberId)?.id]?.get(serverId) + + override suspend fun getOrCreateServerSettingsFromSystem(serverId: ULong, systemId: String): SystemServerSettingsRecord { + if (!systemServers.containsKey(systemId)) { + systemServers[systemId] = HashMap() + } + + if (!systemServers[systemId]!!.containsKey(serverId)) { + systemServers[systemId]!![serverId] = SystemServerSettingsRecord() + } + + return systemServers[systemId]!![serverId]!! + } + + override suspend fun getOrCreateServerSettings(serverId: ULong): ServerSettingsRecord { + if (!servers.containsKey(serverId)) { + servers[serverId] = ServerSettingsRecord(serverId) + } + + return servers[serverId]!! + } + + override suspend fun updateServerSettings(serverSettings: ServerSettingsRecord) { + servers[serverSettings.serverId] = serverSettings + } + + override suspend fun getOrCreateChannelSettingsFromSystem(channelId: ULong, systemId: String): SystemChannelSettingsRecord { + if (!systemChannels.containsKey(systemId)) { + systemChannels[systemId] = HashMap() + } + + if (!systemChannels[systemId]!!.containsKey(channelId)) { + systemChannels[systemId]!![channelId] = SystemChannelSettingsRecord() + } + + return systemChannels[systemId]!![channelId]!! + } + + override suspend fun getOrCreateChannel(serverId: ULong, channelId: ULong): ChannelSettingsRecord { + getOrCreateServerSettings(serverId) + if (!channels.containsKey(serverId)) { + channels[serverId] = HashMap() + } + + if (!channels[serverId]!!.containsKey(channelId)) { + channels[serverId]!![channelId] = ChannelSettingsRecord(serverId, channelId) + } + + return channels[serverId]!![channelId]!! + } + + override suspend fun updateChannel(channel: ChannelSettingsRecord) { + getOrCreateChannel(channel.serverId, channel.channelId) + channels[channel.serverId]!![channel.channelId] = channel + } + + override suspend fun getOrCreateSystem(userId: ULong, id: String?): SystemRecord { + return fetchSystemFromUser(userId) ?: run { + val system = SystemRecord() + + system.id = if (!id.isValidPkString() || systems.containsKey(id)) systems.keys.firstFree() else id + system.users.add(userId) + + systems[system.id] = system + systemSwitches[system.id] = ArrayList() + systemServers[system.id] = HashMap() + systemChannels[system.id] = HashMap() + members[system.id] = HashMap() + + val user = getOrCreateUser(userId) + user.systemId = system.id + + system + } + } + + override suspend fun dropSystem(userId: ULong): Boolean { + val user = users[userId] ?: return false + + val system = systems[user.systemId] ?: return false + assert(user.systemId == system.id) { "User $userId's system ID ${user.systemId} does not match ${system.id}" } + systems.remove(system.id) + systemSwitches.remove(system.id) + systemServers.remove(system.id) + systemChannels.remove(system.id) + members.remove(system.id) + dropTokens(system.id) + + for (systemUserId in system.users) { + users.remove(systemUserId) + } + + return true + } + + override suspend fun dropMember(systemId: String, memberId: String): Boolean { + val member = members[systemId]?.remove(memberId) + return member != null + } + + override suspend fun updateMember(member: MemberRecord) { + members[member.systemId]?.set(member.id, member) + } + + override suspend fun updateMemberServerSettings(serverSettings: MemberServerSettingsRecord) { + memberServers[serverSettings.memberId]?.set(serverSettings.serverId, serverSettings) + } + + override suspend fun updateSystem(system: SystemRecord) { + systems[system.id] = system + } + + override suspend fun updateSystemServerSettings(serverSettings: SystemServerSettingsRecord) { + systemServers[serverSettings.systemId]?.set(serverSettings.serverId, serverSettings) + } + override suspend fun updateSystemChannelSettings(channelSettings: SystemChannelSettingsRecord) { + systemChannels[channelSettings.systemId]?.set(channelSettings.channelId, channelSettings) + } + + override suspend fun updateUser(user: UserRecord) { + users[user.id] = user + } + + override suspend fun createMessage( + userId: Snowflake, + oldMessageId: Snowflake, + newMessageId: Snowflake, + channelBehavior: ChannelBehavior, + memberId: String, + systemId: String, + memberName: String + ) { + val message = ProxiedMessageRecord() + message.userId = userId.value + message.oldMessageId = oldMessageId.value + message.newMessageId = newMessageId.value + message.channelId = channelBehavior.id.value + message.memberId = memberId + message.systemId = systemId + message.memberName = memberName + messages.add(message) + } + + override suspend fun updateMessage(message: ProxiedMessageRecord) { + val idx = messages.indexOfFirst { it.oldMessageId == message.oldMessageId } + messages[idx] = message + } + + override suspend fun fetchMessage(messageId: Snowflake): ProxiedMessageRecord? { + return messages.find { (it.oldMessageId == messageId.value || it.newMessageId == messageId.value) && !it.deleted } + } + + override suspend fun fetchLatestMessage( + systemId: String, + channelId: Snowflake + ): ProxiedMessageRecord? { + return messages.findLast { it.systemId == systemId && it.channelId == channelId.value && !it.deleted } + } + + override suspend fun dropMessage(messageId: Snowflake) { + messages.removeIf { + it.newMessageId == messageId.value + } + } + + override suspend fun fetchToken(token: String): TokenRecord? { + if (!systemTokens.containsKey(token)) return null + return systemTokens[token] + } + + override suspend fun fetchTokenFromId(systemId: String, id: String): TokenRecord? { + return systemTokens.values.firstNotNullOfOrNull { + if (it.systemId == systemId && it.id == id) + it + null + } + } + + override suspend fun fetchTokens(systemId: String): List { + val out = ArrayList() + for (token in systemTokens.values) { + if (token.systemId == systemId) { + out.add(token) + } + } + return out + } + + override suspend fun updateToken(token: TokenRecord) { + systemTokens[token.token] = token + } + + override suspend fun dropToken(token: String) { + systemTokens.remove(token) + } + + override suspend fun dropTokenById(systemId: String, id: String) { + dropToken(fetchTokenFromId(systemId, id)?.token ?: return) + } + + override suspend fun dropTokens(systemId: String) { + for (token in systemTokens.values) { + if (token.systemId == systemId) { + systemTokens.remove(token.token) + } + } + } + + override suspend fun createProxyTag(record: MemberProxyTagRecord): Boolean { + systems[record.systemId] ?: return false + memberProxies[record.systemId] ?: memberProxies.set(record.systemId, arrayListOf()) + memberProxies[record.systemId]!!.add(record) + return true + } + + override suspend fun createSwitch(systemId: String, memberId: List, timestamp: Instant?): SystemSwitchRecord { + val switches = fetchSwitchesFromSystem(systemId) + if (switches == null) { + systemSwitches[systemId] = ArrayList() + } + val id = ((switches!!.maxOfOrNull { it.id.fromPkString() } ?: 0) + 1).toPkString() + val switch = SystemSwitchRecord(systemId, id, memberId, timestamp) + systemSwitches[systemId]!!.add(switch) + return switch + } + + override suspend fun dropSwitch(switch: SystemSwitchRecord) { + systemSwitches[switch.systemId]?.remove(switch) + } + override suspend fun updateSwitch(switch: SystemSwitchRecord) { + systemSwitches[switch.systemId]?.add(switch) + } + + override suspend fun fetchSwitchesFromSystem(systemId: String): List? = systemSwitches[systemId] + + override suspend fun dropProxyTag(proxyTag: MemberProxyTagRecord) { + memberProxies[proxyTag.memberId]?.remove(proxyTag) + } + + override suspend fun updateTrustLevel(systemId: String, trustee: ULong, level: TrustLevel): Boolean { + systems[systemId]?.trust?.set(trustee, level) + return true + } + + override suspend fun fetchTrustLevel(systemId: String, trustee: ULong): TrustLevel { + return systems[systemId]?.trust?.get(trustee) ?: TrustLevel.NONE + } + + override suspend fun fetchTotalSystems(): Int = systems.size + + override suspend fun fetchTotalMembersFromSystem(systemId: String): Int? = members[systemId]?.size + + override suspend fun fetchMemberFromSystemAndName(systemId: String, memberName: String, caseSensitive: Boolean): MemberRecord? { + return members[systemId]?.values?.run { + find { it.name == memberName } + ?: if (caseSensitive) { + null + } else { + find { it.name.lowercase() == memberName.lowercase() } + } + } + } + + override suspend fun fetchGroupsFromMember(member: MemberRecord): List { + return groups[member.systemId]?.values?.filter { it.members.contains(member.id) } ?: emptyList() + } + + override suspend fun fetchMembersFromGroup(group: GroupRecord): List { + return members[group.systemId]?.let { group.members.mapNotNull(it::get) } ?: emptyList() + } + + override suspend fun fetchGroupFromSystem(system: PkId, groupId: String): GroupRecord? { + return groups[system]?.values?.find { it.id == groupId } + } + + override suspend fun fetchGroupsFromSystem(system: PkId): List? { + return groups[system]?.values?.toList() + } + + override suspend fun fetchGroupFromSystemAndName( + system: PkId, + name: String, + caseSensitive: Boolean + ): GroupRecord? { + return groups[system]?.values?.find { if (caseSensitive) name == it.name else name.lowercase() == it.name.lowercase() } + } + + override suspend fun updateGroup(group: GroupRecord) { + systems[group.systemId] ?: return + groups[group.systemId] ?: groups.set(group.systemId, hashMapOf()) + groups[group.systemId]?.set(group.id, group) + } + + override suspend fun export(other: Database) { + TODO("Not yet implemented") + } + + @Deprecated("Not for regular use.", level = DeprecationLevel.ERROR) + override suspend fun drop() { + users.clear() + messages.clear() + servers.clear() + channels.clear() + systems.clear() + systemSwitches.clear() + systemTokens.clear() + systemServers.clear() + systemChannels.clear() + members.clear() + memberProxies.clear() + memberServers.clear() + } + + override suspend fun firstFreeSystemId(id: String?): String { + return "aaaaa" // my mental state + } + + override fun close() {} +} \ No newline at end of file diff --git a/modules/database/src/main/kotlin/dev/proxyfox/database/JsonDatabase.kt b/modules/database/src/main/kotlin/dev/proxyfox/database/JsonDatabase.kt deleted file mode 100644 index 5024448a..00000000 --- a/modules/database/src/main/kotlin/dev/proxyfox/database/JsonDatabase.kt +++ /dev/null @@ -1,793 +0,0 @@ -/* - * Copyright (c) 2022, The ProxyFox Group - * - * This Source Code is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -package dev.proxyfox.database - -import com.google.gson.JsonObject -import com.google.gson.JsonParser -import com.google.gson.reflect.TypeToken -import dev.kord.common.entity.Snowflake -import dev.kord.core.behavior.channel.ChannelBehavior -import dev.kord.core.behavior.channel.asChannelOf -import dev.kord.core.entity.channel.Channel -import dev.proxyfox.common.ellipsis -import dev.proxyfox.common.fromColor -import dev.proxyfox.common.logger -import dev.proxyfox.common.toColor -import dev.proxyfox.database.records.member.MemberProxyTagRecord -import dev.proxyfox.database.records.member.MemberRecord -import dev.proxyfox.database.records.member.MemberServerSettingsRecord -import dev.proxyfox.database.records.misc.* -import dev.proxyfox.database.records.system.SystemChannelSettingsRecord -import dev.proxyfox.database.records.system.SystemRecord -import dev.proxyfox.database.records.system.SystemServerSettingsRecord -import dev.proxyfox.database.records.system.SystemSwitchRecord -import org.jetbrains.annotations.TestOnly -import java.io.File -import java.time.Instant -import java.time.LocalDate -import java.time.OffsetDateTime -import java.time.ZoneOffset -import java.util.concurrent.ConcurrentHashMap -import kotlin.time.Duration - -// Created 2022-26-05T19:47:37 - -/** - * JSON flat file database. Warning, all mutations *will* immediately take effect and are inherently unsafe. - * - * ## Expected File Structure - * ```json5 - * { - * "systems": { - * "sysid": { - * // here for redundancy purposes - * "id": "sysid", - * // list of user snowflakes of users that own the system (have it set as 'system' in their user object) - * "accounts": [], - * // name of the system - * "name": "name", - * // description that the user set - * "description": "description", - * // tag that shows when proxying to identify systems - * "tag": "tag", - * // default avatar for when a member doesn't have an avatar set - * "avatarUrl": "avatar url", - * // timestamp of creation - * "timestamp": "timestamp", - * // the member to autoproxy - * "auto", "memid", - * // the autoproxy type - * "autoType": "autoproxy type", - * "members": { - * "memid": { - * // here for redundancy - * "id": "memid", - * // name of the member, can be used to index - * "name": "member name", - * // display namme of the member, shown when proxying - * "displayName": "display name", - * // description that the member set - * "description": "member description", - * // birth date of the member - * "birthday": "member birthday" - * // age of the member - * "age": "0", - * // role of the member - * "role": "role", - * // pronouns of the member - * "pronouns": "pronouns", - * // member's avatar - * "avatarUrl": "avatar url", - * // whether to keep proxy tags - * "keepProxy": false, - * // the amount of messages sent by the member - * "messageCount": 0, - * // timestamp of when the member was created - * "timestamp": "timestamp", - * // per server settings - * "serverSettings": { - * "server snowflake": { - * // display name - * "displayName": "display name", - * // avatar - * "avatarUrl": "avatar URL", - * // whether to proxy or not - * "proxy": true - * } - * } - * } - * }, - * // per server - * "serverSettings": { - * "server snowflake": { - * // autoproxy type - * "autoType": "autoproxy type", - * // whether or not to proxy - * "proxyEnabled": true - * } - * }, - * // array of proxy tags (each prefix/suffix pair will be unique) - * "proxyTags": [ - * { - * "prefix": "prefix" - * "suffix": "suffix", - * "member": "memid" - * } - * ], - * // array of switches - * "switches": { - * "switch": { - * // here for redundancy - * "id": "switch" - * // timestamp of the switch - * "timestamp": "timestamp", - * // array of fronting members - * "members": [], - * } - * } - * } - * }, - * "users": { - * "user snowflake": { - * "system": "sysid", - * // appended to when systems grant trust - * "trusted": { - * "sysid": "LEVEL" - * } - * } - * }, - * "servers": { - * "server snowflake": { - * "channels": { - * // false if disabled - * "channel snowflake": true - * }, - * // role to enable proxying - * "role": "role snowflake" - * } - * } - * } - * ``` - * - * @author Ampflower, Ram - * @since ${version} - **/ -class JsonDatabase(val file: File = File("systems.json")) : Database() { - private lateinit var systems: MutableMap - private lateinit var servers: MutableMap - private lateinit var channels: MutableMap - private lateinit var messages: MutableSet - - @Transient - private lateinit var users: MutableMap - - @Transient - private lateinit var messageMap: MutableMap - - @Volatile - private var dropped: Boolean = false - - override suspend fun setup(): JsonDatabase { - users = ConcurrentHashMap() - messageMap = ConcurrentHashMap() - if (file.exists()) { - val db = file.reader().use(JsonParser::parseReader) - if (db != null && db.isJsonObject) { - read(db.asJsonObject) - - return this - } - } - init() - - return this - } - - @TestOnly - fun read(dbObject: JsonObject) { - if (!dbObject.has("schema")) { - throw IllegalStateException("JSON Database missing schema.") - } - if (dbObject["schema"].asInt == 1) { - systems = gson.fromJson(dbObject.getAsJsonObject("systems"), systemMapToken.type) ?: ConcurrentHashMap() - servers = gson.fromJson(dbObject.getAsJsonObject("servers"), serverMapToken.type) ?: ConcurrentHashMap() - channels = gson.fromJson(dbObject.getAsJsonObject("channels"), channelMapToken.type) ?: ConcurrentHashMap() - messages = gson.fromJson(dbObject.getAsJsonArray("messages"), messageSetToken.type) ?: HashSet() - for ((_, system) in systems) { - system.init() - } - for (message in messages) { - messageMap[message.oldMessageId] = message - messageMap[message.newMessageId] = message - } - } - - for (system in systems.values) { - for (account in system.accounts) { - users[account] = system - } - } - OffsetDateTime.now().offset.totalSeconds - } - - @TestOnly - fun init() { - systems = ConcurrentHashMap() - servers = ConcurrentHashMap() - channels = ConcurrentHashMap() - messages = HashSet() - } - - override suspend fun ping(): Duration { - return Duration.ZERO - } - - override suspend fun getDatabaseName() = "JSON" - - @Deprecated(level = DeprecationLevel.ERROR, message = "Non-native method") - override suspend fun fetchUser(userId: ULong): UserRecord? { - return users[userId]?.let { UserRecord().apply { id = userId; systemId = it.id } } - } - - @Deprecated(level = DeprecationLevel.ERROR, message = "Non-native method") - override suspend fun fetchSystemFromId(systemId: String): SystemRecord? { - return systems[systemId]?.view() - } - - @Deprecated(level = DeprecationLevel.ERROR, message = "Non-native method") - override suspend fun fetchMembersFromSystem(systemId: String): List? { - return systems[systemId]?.members?.values?.map(JsonMemberStruct::view) - } - - @Deprecated(level = DeprecationLevel.ERROR, message = "Non-native method") - override suspend fun fetchMemberFromSystem(systemId: String, memberId: String): MemberRecord? { - return systems[systemId]?.members?.get(memberId)?.view() - } - - @Deprecated(level = DeprecationLevel.ERROR, message = "Non-native method") - override suspend fun fetchFrontingMembersFromSystem(systemId: String): List? { - val system = systems[systemId] ?: return null - return fetchLatestSwitch(systemId)?.memberIds?.mapNotNull { system.members[it]?.view() } - } - - @Deprecated(level = DeprecationLevel.ERROR, message = "Non-native method") - override suspend fun fetchProxiesFromSystem(systemId: String): List? { - return systems[systemId]?.proxyTags?.toList() - } - - @Deprecated(level = DeprecationLevel.ERROR, message = "Non-native method") - override suspend fun fetchProxiesFromSystemAndMember(systemId: String, memberId: String): List? { - return systems[systemId]?.proxyTags?.filter { it.memberId == memberId } - } - - override suspend fun fetchMemberFromMessage(userId: ULong, message: String): MemberRecord? { - val system = users[userId] ?: return null - return system.members[fetchProxyTagFromMessage(userId, message)?.memberId]?.view() - } - - override suspend fun fetchProxyTagFromMessage(userId: ULong, message: String): MemberProxyTagRecord? { - return users[userId]?.proxyTags?.find { it.test(message) } - } - - override suspend fun fetchMemberServerSettingsFromSystemAndMember( - serverId: ULong, - systemId: String, - memberId: String - ): MemberServerSettingsRecord? { - return systems[systemId]?.members?.get(memberId)?.serverSettings?.run { - get(serverId) ?: MemberServerSettingsRecord().apply { - this.serverId = serverId - this.systemId = systemId - this.memberId = memberId - } - } - } - - override suspend fun getOrCreateServerSettingsFromSystem(serverId: ULong, systemId: String): SystemServerSettingsRecord { - return systems[systemId]?.serverSettings?.let { - it[serverId] ?: SystemServerSettingsRecord().apply { - this.serverId = serverId - this.systemId = systemId - } - } ?: fail("No such system $systemId") - } - - override suspend fun getOrCreateServerSettings(serverId: ULong): ServerSettingsRecord { - return servers[serverId] ?: ServerSettingsRecord().apply { - this.serverId = serverId - } - } - - override suspend fun updateServerSettings(serverSettings: ServerSettingsRecord) { - servers[serverSettings.serverId] = serverSettings - } - - override suspend fun getOrCreateChannelSettingsFromSystem(channelId: ULong, systemId: String): SystemChannelSettingsRecord { - return systems[systemId]?.channelSettings?.let { - it[channelId] ?: SystemChannelSettingsRecord().apply { - this.channelId = channelId - this.systemId = systemId - } - } ?: fail("No such system $systemId") - } - - override suspend fun getOrCreateChannel(serverId: ULong, channelId: ULong): ChannelSettingsRecord { - return channels[channelId] ?: ChannelSettingsRecord().apply { - this.serverId = serverId - this.channelId = channelId - } - } - - override suspend fun updateChannel(channel: ChannelSettingsRecord) { - channels[channel.channelId] = channel - } - - @Deprecated(level = DeprecationLevel.ERROR, message = "Non-native method") - override suspend fun getOrCreateSystem(userId: ULong, id: String?): SystemRecord { - return fetchSystemFromUser(userId) ?: run { - val sysId = if (!id.isValidPkString() || systems.containsKey(id)) systems.keys.firstFree() else id - val struct = JsonSystemStruct(sysId) - struct.accounts.add(userId) - initSystem(struct).view() - } - } - - private fun initSystem(struct: JsonSystemStruct): JsonSystemStruct { - for (user in struct.accounts) users[user] = struct - systems[struct.id] = struct - struct.init() - return struct - } - - override suspend fun containsSystem(systemId: String): Boolean { - return systems.containsKey(systemId) - } - - override suspend fun dropSystem(userId: ULong): Boolean { - val system = users[userId] ?: return false - - system.accounts.remove(userId) - users.remove(userId, system) - - if (system.accounts.isEmpty()) { - systems.remove(system.id) - } - - return true - } - - @Deprecated(level = DeprecationLevel.ERROR, message = "Non-native method") - override suspend fun getOrCreateMember(systemId: String, name: String, id: String?): MemberRecord? { - return fetchMemberFromSystemAndName(systemId, name) ?: run { - val system = systems[systemId] ?: return null - val memId = if (!id.isValidPkString() || system.members.containsKey(id)) system.members.keys.firstFree() else id - system.putMember(JsonMemberStruct(id = memId, systemId = systemId, name = name)).view() - } - } - - override suspend fun containsMember(systemId: String, memberId: String): Boolean { - return systems[systemId]?.members?.containsKey(memberId) ?: false - } - - override suspend fun dropMember(systemId: String, memberId: String): Boolean { - return systems[systemId]?.removeMember(memberId) ?: false - } - - override suspend fun updateMember(member: MemberRecord) { - systems[member.systemId]!!.run { - members[member.id]?.from(member, this) ?: run { putMember(JsonMemberStruct(member)) } - } - } - - override suspend fun updateMemberServerSettings(serverSettings: MemberServerSettingsRecord) { - systems[serverSettings.systemId]!! - .members[serverSettings.memberId]!! - .serverSettings[serverSettings.serverId] = serverSettings - } - - override suspend fun updateSystem(system: SystemRecord) { - systems[system.id]?.from(system) ?: initSystem(JsonSystemStruct(system)) - } - - override suspend fun updateSystemServerSettings(serverSettings: SystemServerSettingsRecord) { - systems[serverSettings.systemId]!! - .serverSettings[serverSettings.serverId] = serverSettings - } - - override suspend fun updateSystemChannelSettings(channelSettings: SystemChannelSettingsRecord) { - systems[channelSettings.systemId]!! - .channelSettings[channelSettings.channelId] = channelSettings - } - - @Deprecated(level = DeprecationLevel.ERROR, message = "Non-native method") - override suspend fun updateUser(user: UserRecord) { - if (user.systemId == null) { - dropSystem(user.id) - } else { - val system = systems[user.systemId] ?: throw IllegalArgumentException("No such system ${user.systemId}") - users[user.id] = system - system.accounts.add(user.id) - } - } - - override suspend fun createMessage( - userId: Snowflake, - oldMessageId: Snowflake, - newMessageId: Snowflake, - channelBehavior: ChannelBehavior, - memberId: String, - systemId: String, - memberName: String - ) { - val message = ProxiedMessageRecord() - message.memberName = memberName - message.userId = userId.value - message.oldMessageId = oldMessageId.value - message.newMessageId = newMessageId.value - val channel = channelBehavior.asChannelOf() - message.channelId = channel.id.value - message.guildId = channel.data.guildId.value?.value ?: 0UL - message.memberId = memberId - message.systemId = systemId - messages.add(message) - messageMap[oldMessageId.value] = message - messageMap[newMessageId.value] = message - } - - override suspend fun updateMessage(message: ProxiedMessageRecord) { - messageMap[message.oldMessageId]?.let { old -> - messageMap.remove(old.newMessageId) - messages.remove(message) - } - messageMap[message.oldMessageId] = message - messages.add(message) - } - - override suspend fun fetchMessage(messageId: Snowflake): ProxiedMessageRecord? { - return messageMap[messageId.value] - } - - override suspend fun fetchLatestMessage( - systemId: String, - channelId: Snowflake - ): ProxiedMessageRecord? { - // TODO: Better caching logic - return messages.firstOrNull { it.channelId == channelId.value && it.systemId == systemId } - } - - override suspend fun createProxyTag(record: MemberProxyTagRecord): Boolean { - return systems[record.systemId]!!.proxyTags.add(record) - } - - override suspend fun createSwitch(systemId: String, memberId: List, timestamp: Instant?): SystemSwitchRecord? { - val system = systems[systemId] ?: return null - val switch = SystemSwitchRecord() - val id = ((system.switches.keys.maxOfOrNull { it.fromPkString() } ?: 0) + 1).toPkString() - switch.id = id - switch.systemId = systemId - switch.memberIds = memberId - timestamp?.let { switch.timestamp = it } - system.switches[id] = switch - return switch - } - - override suspend fun dropSwitch(switch: SystemSwitchRecord) { - systems[switch.systemId]?.run { switches.remove(switch.id) } - } - - override suspend fun updateSwitch(switch: SystemSwitchRecord) { - systems[switch.systemId]?.run { switches[switch.id] = switch } - } - - override suspend fun fetchSwitchesFromSystem(systemId: String): List? { - return systems[systemId]?.switches?.values?.toList() - } - - override suspend fun dropProxyTag(proxyTag: MemberProxyTagRecord) { - systems[proxyTag.systemId]!!.proxyTags.remove(proxyTag) - } - - override suspend fun updateTrustLevel(systemId: String, trustee: ULong, level: TrustLevel): Boolean { - val system = systems[systemId] ?: return false - system.trust[trustee] = level - return true - } - - override suspend fun fetchTrustLevel(systemId: String, trustee: ULong): TrustLevel { - return systems[systemId]?.trust?.get(trustee) ?: TrustLevel.NONE - } - - override suspend fun fetchTotalSystems() = systems.size - - override suspend fun fetchTotalMembersFromSystem(systemId: String): Int? { - return systems[systemId]?.members?.size - } - - override suspend fun fetchMemberFromSystemAndName(systemId: String, memberName: String, caseSensitive: Boolean): MemberRecord? { - return systems[systemId]?.membersByName?.get(memberName)?.view() - } - - override suspend fun export(other: Database) { - val memberLookup = HashMap() - logger.info("Migrating {} systems$ellipsis", systems.size) - for ((sid, system) in systems) { - memberLookup.clear() - logger.info("Migrating {}: {}", sid, system.name) - val newSystem = other.getOrCreateSystem(system.accounts[0], sid) - system.writeTo(newSystem) - other.updateSystem(newSystem) - - val nsid = newSystem.id - - logger.info("Migrating {} members$ellipsis", system.members.size) - for ((mid, member) in system.members) { - logger.info("Migrating {}: {}", mid, member.name) - val newMember = other.getOrCreateMember(sid, member.name, mid) - if (newMember == null) { - logger.warn("Unable to import {}: {} didn't return a member for {}/{} ({}/???)?", member.name, other, sid, mid, nsid) - } else { - memberLookup[mid] = newMember.id - member.writeTo(newMember) - other.updateMember(newMember) - } - } - - for ((id, serverSettings) in system.serverSettings) { - val newSettings = other.getOrCreateServerSettingsFromSystem(id, nsid) - serverSettings.writeTo(newSettings, memberLookup[serverSettings.autoProxy]) - other.updateSystemServerSettings(newSettings) - logger.info("Written server settings for {}", id) - } - - for ((id, channelSettings) in system.channelSettings) { - val newSettings = other.getOrCreateChannelSettingsFromSystem(id, nsid) - channelSettings.writeTo(newSettings) - updateSystemChannelSettings(newSettings) - logger.info("Written channel settings for {}", id) - } - - for (proxyTag in system.proxyTags) { - val member = memberLookup[proxyTag.memberId] - if (member == null) { - logger.warn("Couldn't write proxy tag {}text{} for {}/{} ({}/???)", proxyTag.prefix, proxyTag.suffix, sid, proxyTag.memberId, nsid) - } else { - other.createProxyTag(nsid, member, proxyTag.prefix, proxyTag.suffix) - logger.info("Written proxy tag {}text{} for {}", proxyTag.prefix, proxyTag.suffix, member) - } - } - - for ((id, switch) in system.switches) { - val newSwitch = other.createSwitch(nsid, switch.memberIds.mapNotNull(memberLookup::get), switch.timestamp) - if (newSwitch == null) { - logger.warn("Couldn't write switch {}/{} to {}", sid, id, nsid) - } else { - logger.info("Written switch {}/{} to {}/{}", sid, id, nsid, newSwitch.id) - } - } - } - logger.info("Migrating server settings$ellipsis") - for ((sid, server) in servers) { - val newSettings = other.getOrCreateServerSettings(sid) - server.writeTo(newSettings) - other.updateServerSettings(newSettings) - logger.info("Written server settings for {}", sid) - } - } - - @Deprecated("Not for regular use.", level = DeprecationLevel.ERROR) - override suspend fun drop() { - dropped = true - - systems.clear() - servers.clear() - channels.clear() - messages.clear() - users.clear() - messageMap.clear() - - if (file.exists()) { - file.delete() - } - } - - override suspend fun firstFreeSystemId(id: String?): String { - return if (!id.isValidPkString() || systems.containsKey(id)) systems.keys.firstFree() else id - } - - override suspend fun firstFreeMemberId(systemId: String, id: String?): String { - return systems[systemId]?.let { system -> if (!id.isValidPkString() || system.members.containsKey(id)) system.members.keys.firstFree() else id } ?: fail("No such system") - } - - override fun close() { - if (dropped) return - val obj = JsonObject() - obj.addProperty("schema", 1) - obj.add("systems", gson.toJsonTree(systems)) - obj.add("servers", gson.toJsonTree(servers)) - obj.add("channels", gson.toJsonTree(channels)) - obj.add("messages", gson.toJsonTree(messages)) - file.writer().use { gson.toJson(obj, it) } - } - - data class JsonSystemStruct( - val id: String, - /** The user must have their snowflake bound to `system` to be included here. */ - val accounts: ArrayList = ArrayList(), - var name: String? = null, - var description: String? = null, - var tag: String? = null, - var pronouns: String? = null, - var color: Int = -1, - var avatarUrl: String? = null, - var timezone: String? = null, - var timestamp: OffsetDateTime? = OffsetDateTime.now(ZoneOffset.UTC), - var auto: String? = null, - var autoType: AutoProxyMode? = AutoProxyMode.OFF, - - val members: MutableMap = HashMap(), - val serverSettings: MutableMap = HashMap(), - val channelSettings: MutableMap = HashMap(), - val proxyTags: MutableSet = HashSet(), - val switches: MutableMap = HashMap(), - val trust: HashMap = HashMap() - ) { - constructor(record: SystemRecord) : this(record.id) { - from(record) - } - - /** - * Cached lookup of name to member. - * */ - @Transient - lateinit var membersByName: HashMap - - fun init() { - val membersByName = HashMap() - for ((_, member) in members) { - val old = membersByName.put(member.name, member) - member.systemId = id - if (old != null) logger.warn("Member {} collided with {} from {}", member, old, id) - } - this.membersByName = membersByName - for (proxyTag in proxyTags) { - proxyTag.systemId = id - } - } - - fun view(): SystemRecord { - val record = SystemRecord() - record.id = id - writeTo(record) - return record - } - - fun writeTo(record: SystemRecord) { - record.users.addAll(accounts) - record.name = name - record.description = description - record.tag = tag - record.pronouns = pronouns - record.color = color - record.avatarUrl = avatarUrl - record.timezone = timezone - timestamp?.let { record.timestamp = it } - record.autoProxy = auto - autoType?.let { - record.autoType = it - } - record.trust = trust - } - - fun from(record: SystemRecord) { - accounts.clear() - accounts.addAll(record.users) - name = record.name - description = record.description - tag = record.tag - pronouns = record.pronouns - color = record.color - avatarUrl = record.avatarUrl - timezone = record.timezone - timestamp = record.timestamp - auto = record.autoProxy - autoType = record.autoType - } - - fun putMember(member: JsonMemberStruct): JsonMemberStruct { - assert(member.systemId == id) { "systemId != id" } - members[member.id] = member - membersByName[member.name] = member - return member - } - - fun removeMember(member: String): Boolean { - val struct = members.remove(member) ?: return false - membersByName.remove(struct.name) - - proxyTags.removeIf { it.memberId == member } - - return true - } - } - - data class JsonMemberStruct( - val id: String, - var systemId: String, - var name: String, - var displayName: String? = null, - var description: String? = null, - var birthday: LocalDate? = null, - var age: String? = null, - var role: String? = null, - var pronouns: String? = null, - var color: String? = null, - var avatarUrl: String? = null, - var keepProxy: Boolean = false, - var messageCount: ULong = 0UL, - var timestamp: OffsetDateTime? = OffsetDateTime.now(ZoneOffset.UTC), - - val serverSettings: MutableMap = HashMap() - ) { - - constructor(record: MemberRecord) : this(record.id, record.systemId, record.name) { - from(record) - } - - fun view(): MemberRecord { - val record = MemberRecord() - record.id = id - record.systemId = systemId - writeTo(record) - return record - } - - fun writeTo(record: MemberRecord) { - record.name = name - record.displayName = displayName - record.description = description - record.birthday = birthday - record.age = age - record.role = role - record.pronouns = pronouns - record.color = color.toColor() - record.avatarUrl = avatarUrl - record.keepProxy = keepProxy - record.messageCount = messageCount - timestamp?.let { record.timestamp = it } - } - - fun JsonSystemStruct.from(record: MemberRecord) = from(record, this) - - fun from(record: MemberRecord, system: JsonSystemStruct? = null) { - if (name != record.name && system != null) { - system.membersByName.remove(name) - system.membersByName[record.name] = this - name = record.name - } - displayName = record.displayName - description = record.description - birthday = record.birthday - age = record.age - role = record.role - pronouns = record.pronouns - color = record.color.fromColor() - avatarUrl = record.avatarUrl - keepProxy = record.keepProxy - messageCount = record.messageCount - timestamp = record.timestamp - } - } - - companion object { - private val systemMapToken = object : TypeToken>() {} - private val serverMapToken = object : TypeToken>() {} - private val channelMapToken = object : TypeToken>() {} - private val messageSetToken = object : TypeToken>() {} - } -} \ No newline at end of file diff --git a/modules/database/src/main/kotlin/dev/proxyfox/database/MongoDatabase.kt b/modules/database/src/main/kotlin/dev/proxyfox/database/MongoDatabase.kt index 0d2408e6..6c0ac1f4 100644 --- a/modules/database/src/main/kotlin/dev/proxyfox/database/MongoDatabase.kt +++ b/modules/database/src/main/kotlin/dev/proxyfox/database/MongoDatabase.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -16,6 +16,7 @@ import dev.kord.core.behavior.channel.ChannelBehavior import dev.kord.core.behavior.channel.asChannelOf import dev.kord.core.entity.channel.Channel import dev.proxyfox.database.records.MongoRecord +import dev.proxyfox.database.records.group.GroupRecord import dev.proxyfox.database.records.member.MemberProxyTagRecord import dev.proxyfox.database.records.member.MemberRecord import dev.proxyfox.database.records.member.MemberServerSettingsRecord @@ -27,6 +28,8 @@ import dev.proxyfox.database.records.system.SystemSwitchRecord import kotlinx.coroutines.reactive.awaitFirst import kotlinx.coroutines.reactive.awaitFirstOrElse import kotlinx.coroutines.reactive.awaitFirstOrNull +import kotlinx.datetime.Instant +import kotlinx.serialization.json.JsonObject import org.bson.conversions.Bson import org.litote.kmongo.and import org.litote.kmongo.coroutine.toList @@ -35,7 +38,6 @@ import org.litote.kmongo.path import org.litote.kmongo.reactivestreams.* import org.litote.kmongo.util.KMongoUtil import org.slf4j.LoggerFactory -import java.time.Instant import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.TimeUnit import kotlin.reflect.KProperty0 @@ -66,6 +68,7 @@ class MongoDatabase(private val dbName: String = "ProxyFox") : Database() { private lateinit var systems: KCollection private lateinit var systemSwitches: KCollection + private lateinit var systemTokens: KCollection private lateinit var systemServers: KCollection private lateinit var systemChannels: KCollection @@ -75,6 +78,8 @@ class MongoDatabase(private val dbName: String = "ProxyFox") : Database() { private lateinit var memberServers: KCollection + private lateinit var groups: KCollection + override suspend fun setup(): MongoDatabase { val connectionString = System.getenv("PROXYFOX_MONGO") kmongo = @@ -96,6 +101,7 @@ class MongoDatabase(private val dbName: String = "ProxyFox") : Database() { channels = db.getOrCreateCollection() systems = db.getOrCreateCollection() + systemTokens = db.getOrCreateCollection() systemSwitches = db.getOrCreateCollection() systemServers = db.getOrCreateCollection() @@ -106,13 +112,17 @@ class MongoDatabase(private val dbName: String = "ProxyFox") : Database() { memberServers = db.getOrCreateCollection() + groups = db.getOrCreateCollection() + + ping() + return this } @OptIn(ExperimentalTime::class) override suspend fun ping(): Duration { return measureTime { - db.runCommand("{ping: 1}").awaitFirst() + db.runCommand("{ping: 1}").awaitFirst() } } @@ -196,6 +206,7 @@ class MongoDatabase(private val dbName: String = "ProxyFox") : Database() { memberProxies.deleteMany(filter).awaitFirst() memberServers.deleteMany(filter).awaitFirst() members.deleteMany(filter).awaitFirst() + dropTokens(system.id) systems.deleteOneById(system._id).awaitFirst() users.deleteMany(filter).awaitFirst() return true @@ -261,13 +272,51 @@ class MongoDatabase(private val dbName: String = "ProxyFox") : Database() { } override suspend fun fetchMessage(messageId: Snowflake): ProxiedMessageRecord? = - messages.findFirstOrNull(or("newMessageId" eq messageId, "oldMessageId" eq messageId)) + messages.findFirstOrNull(or( + "newMessageId" eq messageId, + "oldMessageId" eq messageId, + "deleted" eq false + )) override suspend fun fetchLatestMessage( - systemId: String, - channelId: Snowflake + systemId: String, + channelId: Snowflake ): ProxiedMessageRecord? = - messages.find("systemId" eq systemId, "channelId" eq channelId).sort("{'creationDate':-1}").limit(1).awaitFirstOrNull() + messages.find( + "systemId" eq systemId, + "channelId" eq channelId, + "deleted" eq false + ).sort("{'creationDate':-1}").limit(1) + .awaitFirstOrNull() + + override suspend fun dropMessage(messageId: Snowflake) { + messages.deleteOne("oldMessageId" eq messageId.value) + } + + override suspend fun fetchToken(token: String): TokenRecord? = + systemTokens.findFirstOrNull("token" eq token) + + override suspend fun fetchTokenFromId(systemId: String, id: String): TokenRecord? = + systemTokens.findFirstOrNull("systemId" eq systemId, "id" eq id) + + override suspend fun fetchTokens(systemId: String): List = + systemTokens.find("systemId" eq systemId).toList() + + override suspend fun updateToken(token: TokenRecord) { + systemTokens.replaceOneById(token._id, token, upsert()).awaitFirst() + } + + override suspend fun dropToken(token: String) { + systemTokens.deleteOne("token" eq token).awaitFirst() + } + + override suspend fun dropTokenById(systemId: String, id: String) { + systemTokens.deleteOne("systemId" eq systemId, "id" eq id).awaitFirst() + } + + override suspend fun dropTokens(systemId: String) { + systemTokens.deleteMany("systemId" eq systemId).awaitFirst() + } override suspend fun createProxyTag(record: MemberProxyTagRecord): Boolean { memberProxies.insertOne(record).awaitFirst() @@ -334,6 +383,56 @@ class MongoDatabase(private val dbName: String = "ProxyFox") : Database() { return search.awaitFirstOrNull() } + override suspend fun fetchGroupsFromMember(member: MemberRecord): List { + return groups.find( + "systemId" eq member.systemId, + "members" eq member.id + ).toList() + } + + override suspend fun fetchMembersFromGroup(group: GroupRecord): List { + val out = arrayListOf() + group.members.forEach { + out.add(fetchMemberFromSystem(group.systemId, it) ?: return@forEach) + } + return out + } + + override suspend fun fetchGroupFromSystem(system: PkId, groupId: String): GroupRecord? { + return groups.find( + "systemId" eq system, + "id" eq groupId + ).awaitFirstOrNull() + } + + override suspend fun fetchGroupsFromSystem(system: PkId): List? { + if (!containsSystem(system)) return null + return groups.find( + "systemId" eq system, + ).toList() + } + + override suspend fun fetchGroupFromSystemAndName( + system: PkId, + name: String, + caseSensitive: Boolean + ): GroupRecord? { + var search = groups.find( + "systemId" eq system, + "name" eq name + ) + if (!caseSensitive) search = search.collation(Collation.builder().apply { + collationStrength(CollationStrength.SECONDARY) + caseLevel(false) + locale("en_US") + }.build()) + return search.awaitFirstOrNull() + } + + override suspend fun updateGroup(group: GroupRecord) { + groups.replaceOneById(group._id, group, upsert()).awaitFirst() + } + override suspend fun export(other: Database) { TODO("Not yet implemented") } @@ -393,9 +492,7 @@ class MongoDatabase(private val dbName: String = "ProxyFox") : Database() { } override suspend fun updateSystem(system: SystemRecord) { - if (witness.add(system)) { - systemQueue += system.replace() - } + if (witness.add(system)) systemQueue += system.replace() } override suspend fun updateSystemServerSettings(serverSettings: SystemServerSettingsRecord) { @@ -503,6 +600,7 @@ class MongoDatabase(private val dbName: String = "ProxyFox") : Database() { memberQueue += DeleteManyModel(filter) systemQueue += system.delete() userQueue += DeleteManyModel(filter) + dropTokens(system.id) return true } diff --git a/modules/database/src/main/kotlin/dev/proxyfox/database/NopDatabase.kt b/modules/database/src/main/kotlin/dev/proxyfox/database/NopDatabase.kt index bf4e056b..fcb275cc 100644 --- a/modules/database/src/main/kotlin/dev/proxyfox/database/NopDatabase.kt +++ b/modules/database/src/main/kotlin/dev/proxyfox/database/NopDatabase.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -10,6 +10,7 @@ package dev.proxyfox.database import dev.kord.common.entity.Snowflake import dev.kord.core.behavior.channel.ChannelBehavior +import dev.proxyfox.database.records.group.GroupRecord import dev.proxyfox.database.records.member.MemberProxyTagRecord import dev.proxyfox.database.records.member.MemberRecord import dev.proxyfox.database.records.member.MemberServerSettingsRecord @@ -18,7 +19,7 @@ import dev.proxyfox.database.records.system.SystemChannelSettingsRecord import dev.proxyfox.database.records.system.SystemRecord import dev.proxyfox.database.records.system.SystemServerSettingsRecord import dev.proxyfox.database.records.system.SystemSwitchRecord -import java.time.Instant +import kotlinx.datetime.Instant import kotlin.time.Duration class NopDatabase : Database() { @@ -97,7 +98,8 @@ class NopDatabase : Database() { memberId: String, systemId: String, memberName: String - ) {} + ) { + } override suspend fun updateMessage(message: ProxiedMessageRecord) {} @@ -108,9 +110,27 @@ class NopDatabase : Database() { channelId: Snowflake ): ProxiedMessageRecord? = null + override suspend fun dropMessage(messageId: Snowflake) {} + + override suspend fun fetchToken(token: String): TokenRecord? = null + override suspend fun fetchTokenFromId(systemId: String, id: String): TokenRecord? = null + + override suspend fun fetchTokens(systemId: String): List = listOf() + + override suspend fun updateToken(token: TokenRecord) = fail("Cannot store token for ${token.systemId}.") + override suspend fun dropToken(token: String) {} + override suspend fun dropTokenById(systemId: String, id: String) {} + + override suspend fun dropTokens(systemId: String) {} + override suspend fun createProxyTag(record: MemberProxyTagRecord): Boolean = false - override suspend fun createSwitch(systemId: String, memberId: List, timestamp: Instant?): SystemSwitchRecord? = null + override suspend fun createSwitch( + systemId: String, + memberId: List, + timestamp: Instant? + ): SystemSwitchRecord? = null + override suspend fun dropSwitch(switch: SystemSwitchRecord) {} override suspend fun updateSwitch(switch: SystemSwitchRecord) {} @@ -126,7 +146,39 @@ class NopDatabase : Database() { override suspend fun fetchTotalMembersFromSystem(systemId: String): Int? = null - override suspend fun fetchMemberFromSystemAndName(systemId: String, memberName: String, caseSensitive: Boolean): MemberRecord? = null + override suspend fun fetchMemberFromSystemAndName( + systemId: String, + memberName: String, + caseSensitive: Boolean + ): MemberRecord? = null + + override suspend fun fetchGroupsFromMember(member: MemberRecord): List { + return emptyList() + } + + override suspend fun fetchMembersFromGroup(group: GroupRecord): List { + return emptyList() + } + + override suspend fun fetchGroupFromSystem(system: PkId, groupId: String): GroupRecord? { + return null + } + + override suspend fun fetchGroupsFromSystem(system: PkId): List? { + return null + } + + override suspend fun fetchGroupFromSystemAndName( + system: PkId, + name: String, + caseSensitive: Boolean + ): GroupRecord? { + return null + } + + override suspend fun updateGroup(group: GroupRecord) { + + } override suspend fun export(other: Database) {} diff --git a/modules/database/src/main/kotlin/dev/proxyfox/database/ProxyDatabase.kt b/modules/database/src/main/kotlin/dev/proxyfox/database/ProxyDatabase.kt index f74dd2b6..d233ce48 100644 --- a/modules/database/src/main/kotlin/dev/proxyfox/database/ProxyDatabase.kt +++ b/modules/database/src/main/kotlin/dev/proxyfox/database/ProxyDatabase.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -10,6 +10,7 @@ package dev.proxyfox.database import dev.kord.common.entity.Snowflake import dev.kord.core.behavior.channel.ChannelBehavior +import dev.proxyfox.database.records.group.GroupRecord import dev.proxyfox.database.records.member.MemberProxyTagRecord import dev.proxyfox.database.records.member.MemberRecord import dev.proxyfox.database.records.member.MemberServerSettingsRecord @@ -18,7 +19,7 @@ import dev.proxyfox.database.records.system.SystemChannelSettingsRecord import dev.proxyfox.database.records.system.SystemRecord import dev.proxyfox.database.records.system.SystemServerSettingsRecord import dev.proxyfox.database.records.system.SystemSwitchRecord -import java.time.Instant +import kotlinx.datetime.Instant import kotlin.time.Duration // Created 2022-07-10T23:56:55 @@ -149,6 +150,38 @@ open class ProxyDatabase(protected val proxy: T) : Database() { return proxy.fetchLatestMessage(systemId, channelId) } + override suspend fun dropMessage(messageId: Snowflake) { + proxy.dropMessage(messageId) + } + + override suspend fun fetchToken(token: String): TokenRecord? { + return proxy.fetchToken(token) + } + + override suspend fun fetchTokenFromId(systemId: String, id: String): TokenRecord? { + return proxy.fetchTokenFromId(systemId, id) + } + + override suspend fun fetchTokens(systemId: String): List { + return proxy.fetchTokens(systemId) + } + + override suspend fun updateToken(token: TokenRecord) { + return proxy.updateToken(token) + } + + override suspend fun dropToken(token: String) { + proxy.dropToken(token) + } + + override suspend fun dropTokenById(systemId: String, id: String) { + proxy.dropTokenById(systemId, id) + } + + override suspend fun dropTokens(systemId: String) { + proxy.dropTokens(systemId) + } + override suspend fun createProxyTag(record: MemberProxyTagRecord): Boolean { return proxy.createProxyTag(record) } @@ -193,6 +226,34 @@ open class ProxyDatabase(protected val proxy: T) : Database() { return proxy.fetchMemberFromSystemAndName(systemId, memberName) } + override suspend fun fetchGroupsFromMember(member: MemberRecord): List { + return proxy.fetchGroupsFromMember(member) + } + + override suspend fun fetchMembersFromGroup(group: GroupRecord): List { + return proxy.fetchMembersFromGroup(group) + } + + override suspend fun fetchGroupFromSystem(system: PkId, groupId: String): GroupRecord? { + return proxy.fetchGroupFromSystem(system, groupId) + } + + override suspend fun fetchGroupsFromSystem(system: PkId): List? { + TODO("Not yet implemented") + } + + override suspend fun fetchGroupFromSystemAndName( + system: PkId, + name: String, + caseSensitive: Boolean + ): GroupRecord? { + return proxy.fetchGroupFromSystemAndName(system, name, caseSensitive) + } + + override suspend fun updateGroup(group: GroupRecord) { + proxy.updateGroup(group) + } + override suspend fun export(other: Database) { proxy.export(other) } diff --git a/modules/database/src/main/kotlin/dev/proxyfox/database/TimeUtil.kt b/modules/database/src/main/kotlin/dev/proxyfox/database/TimeUtil.kt index 38baf1e1..1161af34 100644 --- a/modules/database/src/main/kotlin/dev/proxyfox/database/TimeUtil.kt +++ b/modules/database/src/main/kotlin/dev/proxyfox/database/TimeUtil.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -8,11 +8,13 @@ package dev.proxyfox.database +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate import java.text.ParsePosition -import java.time.Instant -import java.time.LocalDate -import java.time.OffsetDateTime -import java.time.format.* +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeFormatterBuilder +import java.time.format.SignStyle +import java.time.format.TextStyle import java.time.temporal.ChronoField import java.time.temporal.TemporalAccessor import java.util.* @@ -263,7 +265,9 @@ fun tryParseLocalDate(str: String?, preferMonthDay: Boolean = true): Pair 12) - LocalDate.of(year, day, month) to if (preferMonthDay) DDMMuuuu else MMDDuuuu + LocalDate(year, day, month) to if (preferMonthDay) DDMMuuuu else MMDDuuuu else - LocalDate.of(year, month, day) to parser + LocalDate(year, month, day) to parser } -fun String?.tryParseOffsetTimestamp(): OffsetDateTime? = if (this == null) null else try { - OffsetDateTime.parse(this) -} catch (ignored: DateTimeParseException) { - null +/** + * Helper method to try to parse an instant if the string is not blank. + * */ +fun String?.tryParseInstant() = sanitise()?.run { + if (isNullOrBlank()) { + null + } else { + Instant.parse(this) + } } -// This redirects to tryParseOffsetTimestamp as for some reason, -// it's perfectly valid for OffsetDateTime to omit seconds, but it's -// not fine for Instant to parse an ISO8601 timestamp with missing seconds -fun String?.tryParseInstant(): Instant? = if (this == null) null else tryParseOffsetTimestamp()?.toInstant() - fun shouldPreferMonthDay(timezone: String?) = timezone != null && (mmddTimezones.contains(timezone) || mmddTimezonesStartOf.any(timezone::startsWith)) @@ -297,23 +301,13 @@ fun TemporalAccessor.displayDate(): String = if (get(ChronoField.YEAR) == 1 || g } @OptIn(ExperimentalContracts::class) -fun TemporalAccessor?.pkValid(): Boolean { +fun LocalDate?.pkValid(): Boolean { contract { returns(true) implies (this@pkValid != null) } - return this != null && get(ChronoField.YEAR) in 1..9999 -} - -@OptIn(ExperimentalContracts::class) -fun TemporalAccessor?.pkInvalid(): Boolean { - contract { - returns(true) implies (this@pkInvalid != null) - } - return this != null && get(ChronoField.YEAR) !in 1..9999 + return this != null && this.year in 1..9999 } -fun TemporalAccessor.pkCompatibleIso8601() = if (this is Instant) toString() else DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(this) - private fun TemporalAccessor?.validate(): TemporalAccessor? { if (this == null) return null if (getLong(ChronoField.DAY_OF_MONTH) > 31) return null diff --git a/modules/database/src/main/kotlin/dev/proxyfox/database/etc/exporter/Exporter.kt b/modules/database/src/main/kotlin/dev/proxyfox/database/etc/exporter/Exporter.kt index 99948a1d..383260d7 100644 --- a/modules/database/src/main/kotlin/dev/proxyfox/database/etc/exporter/Exporter.kt +++ b/modules/database/src/main/kotlin/dev/proxyfox/database/etc/exporter/Exporter.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -11,16 +11,22 @@ package dev.proxyfox.database.etc.exporter import dev.proxyfox.database.Database import dev.proxyfox.database.database import dev.proxyfox.database.etc.types.* -import dev.proxyfox.database.gson -import dev.proxyfox.database.pkCompatibleIso8601 import dev.proxyfox.database.records.member.MemberRecord import dev.proxyfox.database.records.system.SystemSwitchRecord +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.jetbrains.annotations.TestOnly object Exporter { suspend inline fun export(userId: ULong) = export(database, userId) suspend fun export(database: Database, userId: ULong): String { - val system = database.fetchSystemFromUser(userId) ?: return "" + return exportToPkObject(database, userId)?.let { Json.Default.encodeToString(it) } ?: "" + } + + @TestOnly + suspend fun exportToPkObject(database: Database, userId: ULong): PkSystem? { + val system = database.fetchSystemFromUser(userId) ?: return null val members = database.fetchMembersFromSystem(system.id) val memberIds = members?.mapTo(HashSet(), MemberRecord::id) ?: setOf() @@ -32,7 +38,7 @@ object Exporter { // If retainAll modifies the list, take the slow route. if (existing.retainAll(memberIds)) { return PkSwitch( - timestamp = record.timestamp.pkCompatibleIso8601(), + timestamp = record.timestamp.toString(), members = existing.toList(), proxyfox = PfSwitchExtension( @@ -51,6 +57,6 @@ object Exporter { }, switches = database.fetchSwitchesFromSystem(system.id)?.map(::toPkSwitch), ) - return gson.toJson(pkSystem) + return pkSystem } } \ No newline at end of file diff --git a/modules/database/src/main/kotlin/dev/proxyfox/database/etc/gson/InstantAdaptor.kt b/modules/database/src/main/kotlin/dev/proxyfox/database/etc/gson/InstantAdaptor.kt deleted file mode 100644 index da199fbb..00000000 --- a/modules/database/src/main/kotlin/dev/proxyfox/database/etc/gson/InstantAdaptor.kt +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright (c) 2022, The ProxyFox Group - * - * This Source Code is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -package dev.proxyfox.database.etc.gson - -import com.google.gson.* -import dev.proxyfox.database.sanitise -import java.lang.reflect.Type -import java.time.Instant -import java.time.format.DateTimeFormatter - -object InstantAdaptor : JsonSerializer, JsonDeserializer { - override fun serialize(src: Instant?, typeOfSrc: Type, context: JsonSerializationContext): JsonElement { - return if (src == null) - JsonNull.INSTANCE - else - JsonPrimitive(DateTimeFormatter.ISO_INSTANT.format(src)) - } - - override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): Instant? { - return json.asString.sanitise().run { - if (isNullOrBlank()) { - null - } else { - DateTimeFormatter.ISO_INSTANT.parse(this, Instant::from) - } - } - } -} \ No newline at end of file diff --git a/modules/database/src/main/kotlin/dev/proxyfox/database/etc/gson/LocalDateAdaptor.kt b/modules/database/src/main/kotlin/dev/proxyfox/database/etc/gson/LocalDateAdaptor.kt deleted file mode 100644 index 0afd6f02..00000000 --- a/modules/database/src/main/kotlin/dev/proxyfox/database/etc/gson/LocalDateAdaptor.kt +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright (c) 2022, The ProxyFox Group - * - * This Source Code is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -package dev.proxyfox.database.etc.gson - -import com.google.gson.* -import dev.proxyfox.database.sanitise -import java.lang.reflect.Type -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import java.time.temporal.TemporalQueries - -// Created 2022-01-10T20:32:28 - -/** - * @author Ampflower - * @since ${version} - **/ -object LocalDateAdaptor : JsonSerializer, JsonDeserializer { - override fun serialize(src: LocalDate?, typeOfSrc: Type, context: JsonSerializationContext): JsonElement { - return if (src == null) { - JsonNull.INSTANCE - } else { - JsonPrimitive(DateTimeFormatter.ISO_DATE.format(src)) - } - } - - override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): LocalDate? { - return json.asString.sanitise().run { - if (isNullOrBlank()) { - null - } else { - DateTimeFormatter.ISO_DATE.parse(this, TemporalQueries.localDate()) - } - } - } -} \ No newline at end of file diff --git a/modules/database/src/main/kotlin/dev/proxyfox/database/etc/gson/NullValueProcessor.kt b/modules/database/src/main/kotlin/dev/proxyfox/database/etc/gson/NullValueProcessor.kt deleted file mode 100644 index c974e570..00000000 --- a/modules/database/src/main/kotlin/dev/proxyfox/database/etc/gson/NullValueProcessor.kt +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright (c) 2022, The ProxyFox Group - * - * This Source Code is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -package dev.proxyfox.database.etc.gson - -import com.google.gson.stream.JsonReader - -// Created 2022-01-10T17:04:01 - -/** - * @author Ampflower - * @since ${version} - **/ -object NullValueProcessor : ValueProcessor { - override fun ifArray(reader: JsonReader) = reader.skip() - override fun ifBoolean(reader: JsonReader) = reader.skip() - override fun ifString(reader: JsonReader) = reader.skip() - override fun ifNumber(reader: JsonReader) = reader.skip() - - private fun JsonReader.skip(): Any? { - skipValue() - return null - } -} \ No newline at end of file diff --git a/modules/database/src/main/kotlin/dev/proxyfox/database/etc/gson/ObjectIdNullifier.kt b/modules/database/src/main/kotlin/dev/proxyfox/database/etc/gson/ObjectIdNullifier.kt deleted file mode 100644 index bb79d417..00000000 --- a/modules/database/src/main/kotlin/dev/proxyfox/database/etc/gson/ObjectIdNullifier.kt +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright (c) 2022, The ProxyFox Group - * - * This Source Code is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -package dev.proxyfox.database.etc.gson - -import com.google.gson.* -import org.bson.types.ObjectId -import java.lang.reflect.Type - -object ObjectIdNullifier : JsonSerializer, JsonDeserializer { - override fun serialize(src: ObjectId?, typeOfSrc: Type?, context: JsonSerializationContext?): JsonElement { - return JsonNull.INSTANCE - } - - override fun deserialize(json: JsonElement?, typeOfT: Type?, context: JsonDeserializationContext?): ObjectId { - return ObjectId() - } -} \ No newline at end of file diff --git a/modules/database/src/main/kotlin/dev/proxyfox/database/etc/gson/OffsetDateTimeAdaptor.kt b/modules/database/src/main/kotlin/dev/proxyfox/database/etc/gson/OffsetDateTimeAdaptor.kt deleted file mode 100644 index c71029b6..00000000 --- a/modules/database/src/main/kotlin/dev/proxyfox/database/etc/gson/OffsetDateTimeAdaptor.kt +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright (c) 2022, The ProxyFox Group - * - * This Source Code is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -package dev.proxyfox.database.etc.gson - -import com.google.gson.* -import dev.proxyfox.database.sanitise -import java.lang.reflect.Type -import java.time.OffsetDateTime -import java.time.format.DateTimeFormatter - -object OffsetDateTimeAdaptor : JsonSerializer, JsonDeserializer { - override fun serialize(src: OffsetDateTime?, typeOfSrc: Type, context: JsonSerializationContext): JsonElement { - return if (src == null) - JsonNull.INSTANCE - else - JsonPrimitive(DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(src)) - } - - override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): OffsetDateTime? { - return json.asString.sanitise().run { - if (isNullOrBlank()) { - null - } else { - DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(this, OffsetDateTime::from) - } - } - } -} \ No newline at end of file diff --git a/modules/database/src/main/kotlin/dev/proxyfox/database/etc/gson/RecordAdapter.kt b/modules/database/src/main/kotlin/dev/proxyfox/database/etc/gson/RecordAdapter.kt deleted file mode 100644 index 5a29cc41..00000000 --- a/modules/database/src/main/kotlin/dev/proxyfox/database/etc/gson/RecordAdapter.kt +++ /dev/null @@ -1,119 +0,0 @@ -/* - * Copyright (c) 2022, The ProxyFox Group - * - * This Source Code is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -package dev.proxyfox.database.etc.gson - -import com.google.gson.Gson -import com.google.gson.JsonElement -import com.google.gson.TypeAdapter -import com.google.gson.annotations.SerializedName -import com.google.gson.internal.`$Gson$Types` -import com.google.gson.reflect.TypeToken -import com.google.gson.stream.JsonReader -import com.google.gson.stream.JsonToken -import com.google.gson.stream.JsonWriter -import dev.proxyfox.database.mapArray -import dev.proxyfox.database.etc.importer.ImporterException -import org.slf4j.LoggerFactory -import java.lang.reflect.RecordComponent -import java.lang.reflect.Type -import kotlin.reflect.full.primaryConstructor - -class RecordAdapter(private val gson: Gson, private val type: Type, private val rawType: Class) : TypeAdapter() { - - private val componentMap = HashMap() - private val serialisedName = HashMap() - - init { - assert(Record::class.java.isAssignableFrom(rawType)) { "Invalid class $rawType ($type)" } - for (component in rawType.recordComponents) { - componentMap[component.name] = component - - rawType.getDeclaredField(component.name).getAnnotation(SerializedName::class.java)?.let { - componentMap[it.value] = component - serialisedName[component] = it.value - for (alt in it.alternate) componentMap[alt] = component - } - } - } - - override fun write(out: JsonWriter, value: T) { - out.beginObject() - for (component in value.javaClass.recordComponents!!) { - out.name(serialisedName[component] ?: component.name) - component.accessor.invoke(value)?.also { - gson.getAdapter(it.javaClass).write(out, it) - } ?: out.nullValue() - } - out.endObject() - } - - override fun read(reader: JsonReader): T { - val list = ArrayList() - val map = HashMap() - val generic = gson.getAdapter(JsonElement::class.java) - - try { - if (reader.peek() != JsonToken.BEGIN_OBJECT) { - @Suppress("UNCHECKED_CAST") - val primitiveProcessor = rawType.getAnnotation(UnexpectedValueProcessor::class.java) as? UnexpectedValueProcessor - ?: throw ImporterException("Unable to import $rawType @ $reader as token is ${reader.peek()}") - - val obj = primitiveProcessor.value.objectInstance ?: primitiveProcessor.value.primaryConstructor?.call() - ?: throw ImporterException("Unable to import $rawType @ $reader as token is ${reader.peek()} and $primitiveProcessor returned an unconstructable class ${primitiveProcessor.value}") - return when (reader.peek()) { - JsonToken.BEGIN_ARRAY -> obj.ifArray(reader) - JsonToken.STRING -> obj.ifString(reader) - JsonToken.BOOLEAN -> obj.ifBoolean(reader) - JsonToken.NUMBER -> obj.ifNumber(reader) - else -> throw ImporterException("Unable to import $rawType @ $reader as token is ${reader.peek()}") - } - } else { - reader.beginObject() - - while (reader.peek() == JsonToken.NAME) { - val name = reader.nextName() - val component = componentMap[name] - val path = reader.path - - if (component == null) { - val output = generic.read(reader) - if (output != null && !output.isJsonNull && !(output.isJsonArray && output.asJsonArray.isEmpty) && !(output.isJsonObject && output.asJsonObject.size() == 0)) { - val location = reader.toString() - list.add(ImporterException("Bad entry at $path: $name -> $output @ $location")) - } - } else try { - map[name] = gson.getAdapter(TypeToken.get(`$Gson$Types`.resolve(type, rawType, componentMap[name]!!.genericType))).read(reader) - } catch (e: Exception) { - throw ImporterException("Unexpected exception processing $component @ $path - ${reader.path}", e) - } - } - - reader.endObject() - } - } catch (e: Throwable) { - list.forEach(e::addSuppressed) - throw e - } - - if (list.isNotEmpty()) { - val e = ImporterException("Errors encountered around ${reader.previousPath} - ${reader.path}") - list.forEach(e::addSuppressed) - logger.warn("Record reader traces", e) - } - - - return rawType - .getDeclaredConstructor(*rawType.recordComponents.mapArray(RecordComponent::getType)) - .newInstance(*rawType.recordComponents.mapArray { map[it.name] }) - } - - companion object { - private val logger = LoggerFactory.getLogger(RecordAdapter::class.java) - } -} \ No newline at end of file diff --git a/modules/database/src/main/kotlin/dev/proxyfox/database/etc/gson/RecordAdapterFactory.kt b/modules/database/src/main/kotlin/dev/proxyfox/database/etc/gson/RecordAdapterFactory.kt deleted file mode 100644 index db3a0de0..00000000 --- a/modules/database/src/main/kotlin/dev/proxyfox/database/etc/gson/RecordAdapterFactory.kt +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright (c) 2022, The ProxyFox Group - * - * This Source Code is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -package dev.proxyfox.database.etc.gson - -import com.google.gson.Gson -import com.google.gson.TypeAdapter -import com.google.gson.TypeAdapterFactory -import com.google.gson.reflect.TypeToken - -object RecordAdapterFactory : TypeAdapterFactory { - @Suppress("UNCHECKED_CAST") - override fun create(gson: Gson, type: TypeToken): TypeAdapter? { - if (Record::class.java.isAssignableFrom(type.rawType)) { - return RecordAdapter(gson, type.type, type.rawType as Class) as TypeAdapter - } - return null - } -} \ No newline at end of file diff --git a/modules/database/src/main/kotlin/dev/proxyfox/database/etc/gson/ULongAdaptor.kt b/modules/database/src/main/kotlin/dev/proxyfox/database/etc/gson/ULongAdaptor.kt deleted file mode 100644 index 8382fd31..00000000 --- a/modules/database/src/main/kotlin/dev/proxyfox/database/etc/gson/ULongAdaptor.kt +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright (c) 2022, The ProxyFox Group - * - * This Source Code is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -package dev.proxyfox.database.etc.gson - -import com.google.gson.* -import java.lang.reflect.Type - -object ULongAdaptor : JsonSerializer, JsonDeserializer { - override fun serialize(src: ULong?, typeOfSrc: Type?, context: JsonSerializationContext?): JsonElement { - return if (src == null) JsonNull.INSTANCE else JsonPrimitive(src.toLong()) - } - - override fun deserialize(json: JsonElement, typeOfT: Type?, context: JsonDeserializationContext?): ULong { - return json.asLong.toULong() - } -} \ No newline at end of file diff --git a/modules/database/src/main/kotlin/dev/proxyfox/database/etc/gson/UnexpectedValueProcessor.kt b/modules/database/src/main/kotlin/dev/proxyfox/database/etc/gson/UnexpectedValueProcessor.kt deleted file mode 100644 index 03e9c772..00000000 --- a/modules/database/src/main/kotlin/dev/proxyfox/database/etc/gson/UnexpectedValueProcessor.kt +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright (c) 2022, The ProxyFox Group - * - * This Source Code is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -package dev.proxyfox.database.etc.gson - -import kotlin.reflect.KClass - -// Created 2022-01-10T03:36:52 - -/** - * @author Ampflower - * @since ${version} - **/ -@Target(AnnotationTarget.FIELD, AnnotationTarget.CLASS, AnnotationTarget.PROPERTY, AnnotationTarget.VALUE_PARAMETER) -@Retention(AnnotationRetention.RUNTIME) -annotation class UnexpectedValueProcessor( - val value: KClass> -) diff --git a/modules/database/src/main/kotlin/dev/proxyfox/database/etc/gson/ValueProcessor.kt b/modules/database/src/main/kotlin/dev/proxyfox/database/etc/gson/ValueProcessor.kt deleted file mode 100644 index 9c45d2d3..00000000 --- a/modules/database/src/main/kotlin/dev/proxyfox/database/etc/gson/ValueProcessor.kt +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright (c) 2022, The ProxyFox Group - * - * This Source Code is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -package dev.proxyfox.database.etc.gson - -import com.google.gson.stream.JsonReader -import dev.proxyfox.database.unsupported - -// Created 2022-01-10T03:41:13 - -/** - * @author Ampflower - * @since ${version} - **/ -interface ValueProcessor { - fun ifArray(reader: JsonReader): T = unsupported() - fun ifBoolean(reader: JsonReader): T = unsupported() - fun ifString(reader: JsonReader): T = unsupported() - fun ifNumber(reader: JsonReader): T = unsupported() -} \ No newline at end of file diff --git a/modules/database/src/main/kotlin/dev/proxyfox/database/etc/gson/VoidAdaptor.kt b/modules/database/src/main/kotlin/dev/proxyfox/database/etc/gson/VoidAdaptor.kt deleted file mode 100644 index 6a79ce0f..00000000 --- a/modules/database/src/main/kotlin/dev/proxyfox/database/etc/gson/VoidAdaptor.kt +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright (c) 2022, The ProxyFox Group - * - * This Source Code is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -package dev.proxyfox.database.etc.gson - -import com.google.gson.TypeAdapter -import com.google.gson.stream.JsonReader -import com.google.gson.stream.JsonWriter - -object VoidAdaptor : TypeAdapter() { - override fun write(out: JsonWriter, value: Void?) { - out.nullValue() - } - - override fun read(input: JsonReader): Void? { - input.skipValue() - return null - } -} \ No newline at end of file diff --git a/modules/database/src/main/kotlin/dev/proxyfox/database/etc/importer/Importer.kt b/modules/database/src/main/kotlin/dev/proxyfox/database/etc/importer/Importer.kt index 38710cce..84020693 100644 --- a/modules/database/src/main/kotlin/dev/proxyfox/database/etc/importer/Importer.kt +++ b/modules/database/src/main/kotlin/dev/proxyfox/database/etc/importer/Importer.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -8,16 +8,16 @@ package dev.proxyfox.database.etc.importer -import com.google.gson.JsonElement -import com.google.gson.JsonObject -import com.google.gson.JsonParser -import com.google.gson.JsonSyntaxException import dev.kord.core.entity.Entity import dev.proxyfox.database.Database import dev.proxyfox.database.database import dev.proxyfox.database.records.member.MemberProxyTagRecord import dev.proxyfox.database.records.member.MemberRecord import dev.proxyfox.database.records.system.SystemRecord +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject import java.io.InputStreamReader import java.io.Reader @@ -51,9 +51,10 @@ suspend fun import(reader: Reader, user: Entity?) = import(database, reader, use * */ suspend fun import(database: Database, string: String, user: Entity?): Importer { try { - return import(database, JsonParser.parseString(string), user) - } catch (syntax: JsonSyntaxException) { - throw ImporterException("Not a JSON file", syntax) + println(string) + return import(database, Json.parseToJsonElement(string), user) + } catch (reason: Throwable) { + throw ImporterException("Not a JSON file $reason", reason) } } @@ -66,11 +67,7 @@ suspend fun import(database: Database, string: String, user: Entity?): Importer * @author Oliver * */ suspend fun import(database: Database, reader: Reader, user: Entity?): Importer { - try { - return import(database, JsonParser.parseReader(reader), user) - } catch (syntax: JsonSyntaxException) { - throw ImporterException("Not a JSON file", syntax) - } + return import(database, reader.readText(), user) } /** @@ -82,13 +79,12 @@ suspend fun import(database: Database, reader: Reader, user: Entity?): Importer * @author Oliver * */ suspend fun import(database: Database, element: JsonElement, user: Entity?): Importer { - if (!element.isJsonObject) throw ImporterException("Not a JSON object") - val map = element.asJsonObject - if (map.size() == 0) throw ImporterException("No data to import.") - if (map.has("type") && map.has("uri") && map.size() == 2) { + val map = element.jsonObject + if (map.isEmpty()) throw ImporterException("No data to import.") + if (map.contains("type") && map.contains("uri") && map.size == 2) { throw ImporterException("Your system file is invalid; try fetching directly from ${map["uri"]}?") } - val importer = if (map.has("tuppers")) TupperBoxImporter() else PluralKitImporter() + val importer = if (map.contains("tuppers")) TupperBoxImporter() else PluralKitImporter() val bulk = database.bulkInserter() importer.import(bulk, map, user!!.id.value) bulk.commit() diff --git a/modules/database/src/main/kotlin/dev/proxyfox/database/etc/importer/ImporterException.kt b/modules/database/src/main/kotlin/dev/proxyfox/database/etc/importer/ImporterException.kt index 3de03613..8a0b0d07 100644 --- a/modules/database/src/main/kotlin/dev/proxyfox/database/etc/importer/ImporterException.kt +++ b/modules/database/src/main/kotlin/dev/proxyfox/database/etc/importer/ImporterException.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this diff --git a/modules/database/src/main/kotlin/dev/proxyfox/database/etc/importer/PluralKitImporter.kt b/modules/database/src/main/kotlin/dev/proxyfox/database/etc/importer/PluralKitImporter.kt index 11ce74af..ceabcad0 100644 --- a/modules/database/src/main/kotlin/dev/proxyfox/database/etc/importer/PluralKitImporter.kt +++ b/modules/database/src/main/kotlin/dev/proxyfox/database/etc/importer/PluralKitImporter.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -8,7 +8,6 @@ package dev.proxyfox.database.etc.importer -import com.google.gson.JsonObject import dev.proxyfox.common.toColor import dev.proxyfox.database.* import dev.proxyfox.database.etc.types.PkMember @@ -20,8 +19,12 @@ import dev.proxyfox.database.records.misc.AutoProxyMode import dev.proxyfox.database.records.system.SystemRecord import dev.proxyfox.database.records.system.SystemServerSettingsRecord import dev.proxyfox.database.records.system.SystemSwitchRecord -import java.time.Instant -import java.time.LocalDate +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate +import kotlinx.datetime.toInstant +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.decodeFromJsonElement import java.time.format.DateTimeFormatter import java.util.* @@ -45,7 +48,9 @@ open class PluralKitImporter protected constructor( constructor() : this(false, false) override suspend fun import(database: Database, json: JsonObject, userId: ULong) { - import(database, gson.fromJson(json, PkSystem::class.java), userId) + // Do lenient decoding + val decoder = Json { isLenient = true; coerceInputValues = true; ignoreUnknownKeys = true } + import(database, decoder.decodeFromJsonElement(json), userId) } suspend fun import(database: Database, pkSystem: PkSystem, userId: ULong) { @@ -102,7 +107,7 @@ open class PluralKitImporter protected constructor( trust?.let(system.trust::putAll) } pkSystem.accounts?.let(system.users::addAll) - pkSystem.created.tryParseOffsetTimestamp()?.let { system.timestamp = it } + system.timestamp = pkSystem.created?.toInstant() ?: system.timestamp } database.updateSystem(system) @@ -124,8 +129,8 @@ open class PluralKitImporter protected constructor( val memberName = pkMember.name.sanitise().ifEmptyThen(pkMember.id) ?: findNextId(allocatedIds) val member = run { if (!fresh) { - val record = pkMember.id?.let { database.fetchMemberFromSystem(system.id, it) } - ?: database.fetchMemberFromSystemAndName(system.id, memberName, caseSensitive = false) + val record = database.fetchMemberFromSystemAndName(system.id, memberName, caseSensitive = true) + ?: pkMember.id?.let { database.fetchMemberFromSystem(system.id, it) } if (record != null && seenMemberIds.add(record.id)) { assert(record.name == memberName) { "$record did not match $pkMember" } freshMember = false @@ -180,7 +185,7 @@ open class PluralKitImporter protected constructor( memberTags.forEach { database.dropProxyTag(it) } } if (directAllocation) { - pkMember.created.tryParseOffsetTimestamp()?.let { member.timestamp = it } + member.timestamp = pkMember.created?.let { Instant.parse(it) } ?: member.timestamp } val memberServerSettings = HashMap() @@ -307,7 +312,7 @@ open class PluralKitImporter protected constructor( if (otherCount > expectedCount) { for (pkMember in ambiguousBirthdays) { // Not null assertion as it was already parsed successfully once. - birthdays.computeIfPresent(pkMember) { _, (date, _) -> LocalDate.of(date.year, date.dayOfMonth, date.monthValue) to otherFormat } + birthdays.computeIfPresent(pkMember) { _, (date, _) -> LocalDate(date.year, date.dayOfMonth, date.monthNumber) to otherFormat } } } } diff --git a/modules/database/src/main/kotlin/dev/proxyfox/database/etc/importer/TupperBoxImporter.kt b/modules/database/src/main/kotlin/dev/proxyfox/database/etc/importer/TupperBoxImporter.kt index c8da2b2d..9b39ef18 100644 --- a/modules/database/src/main/kotlin/dev/proxyfox/database/etc/importer/TupperBoxImporter.kt +++ b/modules/database/src/main/kotlin/dev/proxyfox/database/etc/importer/TupperBoxImporter.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -8,14 +8,15 @@ package dev.proxyfox.database.etc.importer -import com.google.gson.JsonObject import dev.proxyfox.database.Database -import dev.proxyfox.database.gson +import dev.proxyfox.database.etc.types.TbSystem import dev.proxyfox.database.records.member.MemberProxyTagRecord import dev.proxyfox.database.records.member.MemberRecord import dev.proxyfox.database.records.system.SystemRecord import dev.proxyfox.database.validate -import dev.proxyfox.database.etc.types.TbSystem +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.decodeFromJsonElement /** * [Importer] to import a JSON with a TupperBox format @@ -26,7 +27,9 @@ class TupperBoxImporter : Importer { private var proxies = HashMap() override suspend fun import(database: Database, json: JsonObject, userId: ULong) { - val tbSystem = gson.fromJson(json, TbSystem::class.java) + // Do lenient decoding + val decoder = Json { isLenient = true; coerceInputValues = true; ignoreUnknownKeys = true } + val tbSystem = decoder.decodeFromJsonElement(json) system = database.getOrCreateSystem(userId) tbSystem.tuppers?.let { tbMembers -> diff --git a/modules/database/src/main/kotlin/dev/proxyfox/database/etc/jackson/InstantAdaptor.kt b/modules/database/src/main/kotlin/dev/proxyfox/database/etc/jackson/InstantAdaptor.kt deleted file mode 100644 index c0a17f74..00000000 --- a/modules/database/src/main/kotlin/dev/proxyfox/database/etc/jackson/InstantAdaptor.kt +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright (c) 2022, The ProxyFox Group - * - * This Source Code is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -package dev.proxyfox.database.etc.jackson - -import com.fasterxml.jackson.core.JsonGenerator -import com.fasterxml.jackson.core.JsonParser -import com.fasterxml.jackson.core.JsonToken -import com.fasterxml.jackson.databind.DeserializationContext -import com.fasterxml.jackson.databind.JsonDeserializer -import com.fasterxml.jackson.databind.JsonSerializer -import com.fasterxml.jackson.databind.SerializerProvider -import de.undercouch.bson4jackson.BsonGenerator -import java.time.Instant -import java.util.* - -// Created 2022-09-10T19:50:39 - -/** - * @author Ampflower - * @since ${version} - **/ -object InstantDeserializer : JsonDeserializer() { - override fun deserialize(p: JsonParser, ctxt: DeserializationContext): Instant? { - return when (val obj = p.embeddedObject) { - is Long -> Instant.ofEpochSecond(obj / 1000000, obj.mod(1000000L) * 1000L) - is Date -> obj.toInstant() - is String -> Instant.parse(obj) - else -> when (p.currentToken) { - JsonToken.VALUE_STRING -> Instant.parse(p.valueAsString) - JsonToken.VALUE_NUMBER_INT -> p.valueAsLong.let { Instant.ofEpochSecond(it / 100000, (it / 1000L).mod(1000000L)) } - else -> { - p.skipChildren() - null - } - } - } - } -} - -object InstantSerializer : JsonSerializer() { - override fun serialize(value: Instant?, gen: JsonGenerator, serializers: SerializerProvider) { - if (value == null) { - gen.writeNull() - return - } - if (gen is BsonGenerator) { - gen.writeNumber((value.epochSecond * 1000000) + (value.nano / 1000)) - return - } - gen.writeString(value.toString()) - } -} \ No newline at end of file diff --git a/modules/database/src/main/kotlin/dev/proxyfox/database/etc/ktx/serializaton/InstantLongMicrosecondSerializer.kt b/modules/database/src/main/kotlin/dev/proxyfox/database/etc/ktx/serializaton/InstantLongMicrosecondSerializer.kt new file mode 100644 index 00000000..0f75a2e9 --- /dev/null +++ b/modules/database/src/main/kotlin/dev/proxyfox/database/etc/ktx/serializaton/InstantLongMicrosecondSerializer.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2023, The ProxyFox Group + * + * This Source Code is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.proxyfox.database.etc.ktx.serializaton + +import com.github.jershell.kbson.BsonFlexibleDecoder +import kotlinx.datetime.Instant +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import org.bson.BsonType + +// Created 2023-08-01T21:50:05 + +/** + * [Instant] serializer with the resolution of microseconds, used for + * preserving the timestamps of switches from PluralKit imports, and to + * not mangle the exports to where importing into PluralKit doubles + * most of the history. We do have a deduplicator for on-import for in case, + * but we shouldn't be mangling the data to begin with. + * + * @author Ampflower + * @since 2.1 + **/ +object InstantLongMicrosecondSerializer : KSerializer { + private const val microsecondReference = 1_000_000L + + override val descriptor: SerialDescriptor + get() = PrimitiveSerialDescriptor("Instant", PrimitiveKind.LONG) + + /** + * Bypass the decoder API in the case of BSON. + * */ + override fun deserialize(decoder: Decoder): Instant { + val long = if (decoder is BsonFlexibleDecoder && decoder.reader.currentBsonType == BsonType.DATE_TIME) { + // This evidently should've been millisecond, but we've been + // storing the microsecond precision, so this must be done. + decoder.reader.readDateTime() + } else { + decoder.decodeLong() + } + return Instant.fromEpochSeconds(long / microsecondReference, long.mod(microsecondReference) * 1000L) + } + + override fun serialize(encoder: Encoder, value: Instant) { + encoder.encodeLong(value.epochMicroseconds) + } + + val Instant.epochMicroseconds + get() = epochSeconds * microsecondReference + (nanosecondsOfSecond / 1000L) +} \ No newline at end of file diff --git a/modules/database/src/main/kotlin/dev/proxyfox/database/etc/ktx/serializaton/InstantLongMillisecondSerializer.kt b/modules/database/src/main/kotlin/dev/proxyfox/database/etc/ktx/serializaton/InstantLongMillisecondSerializer.kt new file mode 100644 index 00000000..fc92ceae --- /dev/null +++ b/modules/database/src/main/kotlin/dev/proxyfox/database/etc/ktx/serializaton/InstantLongMillisecondSerializer.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2023, The ProxyFox Group + * + * This Source Code is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.proxyfox.database.etc.ktx.serializaton + +import com.github.jershell.kbson.BsonEncoder +import com.github.jershell.kbson.BsonFlexibleDecoder +import kotlinx.datetime.Instant +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import org.bson.BsonType + +// Created 2023-08-01T21:50:05 + +/** + * [Instant] serializer with the resolution of milliseconds, used for + * storing non-critical timestamps such as when a system was created. + * + * Non-critical, as it doesn't risk mangling PluralKit data if reimported + * from our exports. + * + * @author Ampflower + * @since 2.1 + **/ +object InstantLongMillisecondSerializer : KSerializer { + private const val microsecondReference = 1_000_000L + + override val descriptor: SerialDescriptor + get() = PrimitiveSerialDescriptor("Instant", PrimitiveKind.LONG) + + /** + * Bypass the decoder API in the case of BSON. + * */ + override fun deserialize(decoder: Decoder): Instant { + val long = if (decoder is BsonFlexibleDecoder && decoder.reader.currentBsonType == BsonType.DATE_TIME) { + // This evidently should've been millisecond, but we've been + // storing the microsecond precision, so this must be done. + decoder.reader.readDateTime() + } else { + decoder.decodeLong() + } + return Instant.fromEpochSeconds(long / 1000L, long.mod(1000L) * microsecondReference) + } + + override fun serialize(encoder: Encoder, value: Instant) { + if (encoder is BsonEncoder) { + encoder.encodeDateTime(value.epochMilliseconds) + } else { + encoder.encodeLong(value.epochMilliseconds) + } + } + + val Instant.epochMilliseconds + get() = epochSeconds * 1000L + (nanosecondsOfSecond / microsecondReference) +} \ No newline at end of file diff --git a/modules/database/src/main/kotlin/dev/proxyfox/database/etc/ktx/serializaton/LocalDateLongMillisecondSerializer.kt b/modules/database/src/main/kotlin/dev/proxyfox/database/etc/ktx/serializaton/LocalDateLongMillisecondSerializer.kt new file mode 100644 index 00000000..b0129d28 --- /dev/null +++ b/modules/database/src/main/kotlin/dev/proxyfox/database/etc/ktx/serializaton/LocalDateLongMillisecondSerializer.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2023, The ProxyFox Group + * + * This Source Code is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.proxyfox.database.etc.ktx.serializaton + +import com.github.jershell.kbson.BsonEncoder +import com.github.jershell.kbson.BsonFlexibleDecoder +import kotlinx.datetime.LocalDate +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import org.bson.BsonType +import java.util.concurrent.TimeUnit + +// Created 2023-08-01T21:50:05 + +/** + * [LocalDate] serializer with the resolution of milliseconds, used for + * retrieving and storing timestamps from the Mongo database. + * + * @author Ampflower + * @since 2.1 + **/ +object LocalDateLongMillisecondSerializer : KSerializer { + + override val descriptor: SerialDescriptor + get() = PrimitiveSerialDescriptor("LocalDate", PrimitiveKind.LONG) + + /** + * Bypass the decoder API in the case of BSON. + * */ + override fun deserialize(decoder: Decoder): LocalDate { + val long = if (decoder is BsonFlexibleDecoder && decoder.reader.currentBsonType == BsonType.DATE_TIME) { + // This evidently should've been millisecond, but we've been + // storing the microsecond precision, so this must be done. + decoder.reader.readDateTime() + } else { + decoder.decodeLong() + } + return LocalDate.fromEpochDays((long / TimeUnit.DAYS.toMillis(1)).toInt()) + } + + override fun serialize(encoder: Encoder, value: LocalDate) { + if (encoder is BsonEncoder) { + encoder.encodeDateTime(value.epochMilliseconds) + } else { + encoder.encodeLong(value.epochMilliseconds) + } + } + + val LocalDate.epochMilliseconds + get() = toEpochDays() * TimeUnit.DAYS.toMillis(1L) +} \ No newline at end of file diff --git a/modules/database/src/main/kotlin/dev/proxyfox/database/etc/ktx/serializaton/PolyIgnorePrimitive.kt b/modules/database/src/main/kotlin/dev/proxyfox/database/etc/ktx/serializaton/PolyIgnorePrimitive.kt new file mode 100644 index 00000000..e6dbf875 --- /dev/null +++ b/modules/database/src/main/kotlin/dev/proxyfox/database/etc/ktx/serializaton/PolyIgnorePrimitive.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2023, The ProxyFox Group + * + * This Source Code is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.proxyfox.database.etc.ktx.serializaton + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonTransformingSerializer + +// Created 2023-07-01T23:33:13 + +/** + * @author Ampflower + * @since ${version} + **/ +open class PolyIgnorePrimitive(tSerializer: KSerializer) : JsonTransformingSerializer(tSerializer) { + override fun transformDeserialize(element: JsonElement): JsonElement { + if (element !is JsonObject) { + // Null isn't valid here. Deal with it and complain to Jetbrains. + return JsonObject(mapOf()) + } + + return element + } +} \ No newline at end of file diff --git a/modules/database/src/main/kotlin/dev/proxyfox/database/etc/types/PkTypes.kt b/modules/database/src/main/kotlin/dev/proxyfox/database/etc/types/PkTypes.kt index a737ff99..e09edc50 100644 --- a/modules/database/src/main/kotlin/dev/proxyfox/database/etc/types/PkTypes.kt +++ b/modules/database/src/main/kotlin/dev/proxyfox/database/etc/types/PkTypes.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -8,22 +8,23 @@ package dev.proxyfox.database.etc.types -import com.google.gson.annotations.SerializedName import dev.proxyfox.common.fromColorForExport import dev.proxyfox.database.* -import dev.proxyfox.database.etc.gson.NullValueProcessor -import dev.proxyfox.database.etc.gson.UnexpectedValueProcessor +import dev.proxyfox.database.etc.ktx.serializaton.PolyIgnorePrimitive import dev.proxyfox.database.records.member.MemberProxyTagRecord import dev.proxyfox.database.records.member.MemberRecord import dev.proxyfox.database.records.misc.AutoProxyMode import dev.proxyfox.database.records.misc.TrustLevel import dev.proxyfox.database.records.system.SystemRecord import dev.proxyfox.database.records.system.SystemSwitchRecord -import java.time.LocalDate -import java.time.OffsetDateTime +import kotlinx.datetime.LocalDate +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient import java.time.format.DateTimeFormatter @JvmRecord +@Serializable data class PkSystem( val id: String? = null, val name: String? = null, @@ -36,6 +37,7 @@ data class PkSystem( val created: String? = null, val webhook_url: String? = null, + @Serializable(PkSystemPrivacyIgnorePrimitive::class) val privacy: PkSystemPrivacy? = null, val config: PkConfig? = null, @@ -49,19 +51,20 @@ data class PkSystem( // Required for PFv1 database imports @Deprecated("PFv1 database imports only") - @SerializedName("auto_bool") + @SerialName("auto_bool") val autoBool: Boolean? = null, @Deprecated("PFv1 database imports only") val auto: String? = null, @Deprecated("PFv1 database imports only") - @SerializedName("server_proxy") + @SerialName("server_proxy") val serverProxyEnabled: Map? = null, // Ignored @Deprecated("PFv1 database imports only") - val subsystems: Void? = null, + @Transient + val subsystems: Unit? = null, // Required for PK to accept the export. // ProxyFox however will accept any export that vaguely matches PK's. @@ -69,19 +72,29 @@ data class PkSystem( // The following are ignored for as we don't support these yet, // at least at this location. - val tz: Void? = null, - val timezone: Void? = null, - - val description_privacy: Void? = null, - val pronoun_privacy: Void? = null, - val member_list_privacy: Void? = null, - val group_list_privacy: Void? = null, - val front_privacy: Void? = null, - val front_history_privacy: Void? = null, + @Transient + val tz: Unit? = null, + @Transient + val timezone: Unit? = null, + + @Transient + val description_privacy: Unit? = null, + @Transient + val pronoun_privacy: Unit? = null, + @Transient + val member_list_privacy: Unit? = null, + @Transient + val group_list_privacy: Unit? = null, + @Transient + val front_privacy: Unit? = null, + @Transient + val front_history_privacy: Unit? = null, // The following are ignored entirely. We don't use these. - val uuid: Void? = null, - val version: Void? = null, + @Transient + val uuid: Unit? = null, + @Transient + val version: Unit? = null, ) { constructor( record: SystemRecord, @@ -97,7 +110,7 @@ data class PkSystem( pronouns = record.pronouns, color = record.color.fromColorForExport(), avatar_url = record.avatarUrl, - created = record.timestamp.pkCompatibleIso8601(), + created = record.timestamp.toString(), config = PkConfig( timezone = record.timezone, ), @@ -114,6 +127,7 @@ data class PkSystem( } @JvmRecord +@Serializable data class PkMember( val id: String? = null, val name: String? = null, @@ -141,6 +155,7 @@ data class PkMember( val proxy_tags: Set? = emptySet(), // Some data structures from here will need to be flattened in. + @Serializable(PkMemberPrivacyIgnorePrimitive::class) val privacy: PkMemberPrivacy? = null, // ProxyFox-specific extensions. @@ -149,26 +164,35 @@ data class PkMember( // Required for PFv1 database imports @Deprecated("PFv1 database imports only") - @SerializedName("server_nick") + @SerialName("server_nick") val serverNicknames: Map? = null, @Deprecated("PFv1 database imports only") - @SerializedName("server_avatar") + @SerialName("server_avatar") val serverAvatars: Map? = null, // The following are ignored for as we don't support these yet, // at least at this location. - val visibility: Void? = null, - val name_privacy: Void? = null, - val description_privacy: Void? = null, - val birthday_privacy: Void? = null, - val pronoun_privacy: Void? = null, - val avatar_privacy: Void? = null, - val metadata_privacy: Void? = null, - val last_message_timestamp: Void? = null, + @Transient + val visibility: Unit? = null, + @Transient + val name_privacy: Unit? = null, + @Transient + val description_privacy: Unit? = null, + @Transient + val birthday_privacy: Unit? = null, + @Transient + val pronoun_privacy: Unit? = null, + @Transient + val avatar_privacy: Unit? = null, + @Transient + val metadata_privacy: Unit? = null, + @Transient + val last_message_timestamp: Unit? = null, // The following are ignored. We don't use these. - val uuid: Void? = null, + @Transient + val uuid: Unit? = null, ) { constructor(record: MemberRecord, proxyTags: Set?) : this( id = record.id, @@ -180,12 +204,12 @@ data class PkMember( keep_proxy = record.keepProxy, autoproxy_enabled = record.autoProxy, message_count = record.messageCount.toLong(), - birthday = record.birthday?.run { if (pkValid()) toString() else "0004-${monthValue.paddedString(2)}-${dayOfMonth.paddedString(2)}" }, - created = record.timestamp.pkCompatibleIso8601(), + birthday = record.birthday?.run { if (pkValid()) toString() else "0004-${monthNumber.paddedString(2)}-${dayOfMonth.paddedString(2)}" }, + created = record.timestamp.toString(), proxy_tags = proxyTags, avatar_url = record.avatarUrl, proxyfox = PfMemberExtension( - birthday = record.birthday.run { if (pkInvalid()) toString() else null }, + birthday = record.birthday.run { if (!pkValid()) toString() else null }, age = record.age, role = record.role ) @@ -198,6 +222,7 @@ data class PkMember( } @JvmRecord +@Serializable data class PkGroup( val id: String? = null, val name: String? = null, @@ -206,37 +231,42 @@ data class PkGroup( val icon: String? = null, val banner: String? = null, val color: String? = null, - val created: OffsetDateTime? = null, + val created: String? = null, val members: List? = null, + @Serializable(PkGroupPrivacyIgnorePrimitive::class) val privacy: PkGroupPrivacy? = null, // The following are ignored. We don't use these. - val uuid: Void? = null, + @Transient + val uuid: Unit? = null, ) @JvmRecord +@Serializable data class PkSwitch( - val timestamp: String?, - val members: List?, + val timestamp: String? = null, + val members: List? = null, /** Allows for storing missing member data */ val proxyfox: PfSwitchExtension? = null, // Ignored for PFv1 database imports @Deprecated("PFv1 database imports only") - val id: Void? = null, + @Transient + val id: Unit? = null, ) { constructor(record: SystemSwitchRecord) : this( - timestamp = record.timestamp.pkCompatibleIso8601(), + timestamp = record.timestamp.toString(), members = record.memberIds, ) } @JvmRecord +@Serializable data class PkProxy( - val prefix: String?, - val suffix: String? + val prefix: String? = null, + val suffix: String? = null ) { constructor(record: MemberProxyTagRecord) : this( prefix = record.prefix, @@ -244,15 +274,15 @@ data class PkProxy( ) } -@UnexpectedValueProcessor(NullValueProcessor::class) @JvmRecord +@Serializable data class PkSystemPrivacy( - val description_privacy: PkPrivacyEnum?, - val pronoun_privacy: PkPrivacyEnum?, - val member_list_privacy: PkPrivacyEnum?, - val group_list_privacy: PkPrivacyEnum?, - val front_privacy: PkPrivacyEnum?, - val front_history_privacy: PkPrivacyEnum?, + val description_privacy: PkPrivacyEnum? = null, + val pronoun_privacy: PkPrivacyEnum? = null, + val member_list_privacy: PkPrivacyEnum? = null, + val group_list_privacy: PkPrivacyEnum? = null, + val front_privacy: PkPrivacyEnum? = null, + val front_history_privacy: PkPrivacyEnum? = null, ) { constructor(privacy: PkPrivacyEnum) : this( description_privacy = privacy, @@ -264,16 +294,18 @@ data class PkSystemPrivacy( ) } -@UnexpectedValueProcessor(NullValueProcessor::class) +object PkSystemPrivacyIgnorePrimitive : PolyIgnorePrimitive(PkSystemPrivacy.serializer()) + @JvmRecord +@Serializable data class PkMemberPrivacy( - val visibility: PkPrivacyEnum?, - val name_privacy: PkPrivacyEnum?, - val description_privacy: PkPrivacyEnum?, - val birthday_privacy: PkPrivacyEnum?, - val pronoun_privacy: PkPrivacyEnum?, - val avatar_privacy: PkPrivacyEnum?, - val metadata_privacy: PkPrivacyEnum?, + val visibility: PkPrivacyEnum? = null, + val name_privacy: PkPrivacyEnum? = null, + val description_privacy: PkPrivacyEnum? = null, + val birthday_privacy: PkPrivacyEnum? = null, + val pronoun_privacy: PkPrivacyEnum? = null, + val avatar_privacy: PkPrivacyEnum? = null, + val metadata_privacy: PkPrivacyEnum? = null, ) { constructor(privacy: PkPrivacyEnum) : this( visibility = privacy, @@ -286,15 +318,17 @@ data class PkMemberPrivacy( ) } -@UnexpectedValueProcessor(NullValueProcessor::class) +object PkMemberPrivacyIgnorePrimitive : PolyIgnorePrimitive(PkMemberPrivacy.serializer()) + @JvmRecord +@Serializable data class PkGroupPrivacy( - val name_privacy: PkPrivacyEnum?, - val description_privacy: PkPrivacyEnum?, - val icon_privacy: PkPrivacyEnum?, - val list_privacy: PkPrivacyEnum?, - val metadata_privacy: PkPrivacyEnum?, - val visibility: PkPrivacyEnum?, + val name_privacy: PkPrivacyEnum? = null, + val description_privacy: PkPrivacyEnum? = null, + val icon_privacy: PkPrivacyEnum? = null, + val list_privacy: PkPrivacyEnum? = null, + val metadata_privacy: PkPrivacyEnum? = null, + val visibility: PkPrivacyEnum? = null, ) { constructor(privacy: PkPrivacyEnum) : this( name_privacy = privacy, @@ -306,7 +340,10 @@ data class PkGroupPrivacy( ) } +object PkGroupPrivacyIgnorePrimitive : PolyIgnorePrimitive(PkGroupPrivacy.serializer()) + @JvmRecord +@Serializable data class PkConfig( val timezone: String? = null, val pings_enabled: Boolean? = true, @@ -322,24 +359,27 @@ data class PkConfig( ) @JvmRecord +@Serializable data class PfSystemExtension( - val trust: Map?, - val autoType: AutoProxyMode?, - val autoProxy: String?, + val trust: Map? = null, + val autoType: AutoProxyMode? = null, + val autoProxy: String? = null, ) @JvmRecord +@Serializable data class PfMemberExtension( - val birthday: String?, - val age: String?, - val role: String?, + val birthday: String? = null, + val age: String? = null, + val role: String? = null, val autoProxy: Boolean? = null, ) @JvmRecord +@Serializable data class PfSwitchExtension( /** Note: It is *not* possible to reimport this. */ - val allMembers: List?, + val allMembers: List? = null, ) @Suppress("EnumEntryName") diff --git a/modules/database/src/main/kotlin/dev/proxyfox/database/etc/types/TbTypes.kt b/modules/database/src/main/kotlin/dev/proxyfox/database/etc/types/TbTypes.kt index 99d9fe0e..7b236905 100644 --- a/modules/database/src/main/kotlin/dev/proxyfox/database/etc/types/TbTypes.kt +++ b/modules/database/src/main/kotlin/dev/proxyfox/database/etc/types/TbTypes.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -11,6 +11,7 @@ package dev.proxyfox.database.etc.types import dev.proxyfox.database.records.member.MemberRecord import dev.proxyfox.database.sanitise import dev.proxyfox.database.tryParseLocalDate +import kotlinx.serialization.Serializable // Created 2022-29-09T22:20:20 @@ -18,11 +19,13 @@ import dev.proxyfox.database.tryParseLocalDate * @author Ampflower * @since ${version} **/ +@Serializable class TbSystem { var tuppers: List? = null var groups: List? = null } +@Serializable class TbMember { var id: Int = 0 var name: String = "" @@ -46,6 +49,7 @@ class TbMember { } } +@Serializable class TbGroup { var id: Int = 0 var name: String = "" diff --git a/modules/database/src/main/kotlin/dev/proxyfox/database/records/DatabaseException.kt b/modules/database/src/main/kotlin/dev/proxyfox/database/records/DatabaseException.kt index 35b050f1..961a20d4 100644 --- a/modules/database/src/main/kotlin/dev/proxyfox/database/records/DatabaseException.kt +++ b/modules/database/src/main/kotlin/dev/proxyfox/database/records/DatabaseException.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this diff --git a/modules/database/src/main/kotlin/dev/proxyfox/database/records/MongoRecord.kt b/modules/database/src/main/kotlin/dev/proxyfox/database/records/MongoRecord.kt index 3177cc90..ca1e8af5 100644 --- a/modules/database/src/main/kotlin/dev/proxyfox/database/records/MongoRecord.kt +++ b/modules/database/src/main/kotlin/dev/proxyfox/database/records/MongoRecord.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this diff --git a/modules/database/src/main/kotlin/dev/proxyfox/database/records/group/GroupRecord.kt b/modules/database/src/main/kotlin/dev/proxyfox/database/records/group/GroupRecord.kt new file mode 100644 index 00000000..eb0baa60 --- /dev/null +++ b/modules/database/src/main/kotlin/dev/proxyfox/database/records/group/GroupRecord.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2022-2023, The ProxyFox Group + * + * This Source Code is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.proxyfox.database.records.group + +import dev.proxyfox.database.PkId +import dev.proxyfox.database.etc.ktx.serializaton.InstantLongMillisecondSerializer +import dev.proxyfox.database.records.MongoRecord +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlinx.serialization.Serializable +import org.bson.types.ObjectId + +class GroupRecord() : MongoRecord { + constructor(id: PkId, systemId: PkId, name: String) : this() { + this.id = id + this.systemId = systemId + this.name = name + } + + override var _id: ObjectId = ObjectId() + + var id: PkId = "" + var systemId: PkId = "" + var members: ArrayList = arrayListOf() + var name: String = "" + var description: String? = null + var color: Int = -1 + var avatarUrl: String? = null + + @Serializable(InstantLongMillisecondSerializer::class) + var timestamp: Instant = Clock.System.now() + var tag: String? = null + var tagMode: TagMode = TagMode.HIDDEN +} \ No newline at end of file diff --git a/modules/database/src/main/kotlin/dev/proxyfox/database/records/group/TagMode.kt b/modules/database/src/main/kotlin/dev/proxyfox/database/records/group/TagMode.kt new file mode 100644 index 00000000..49954f1a --- /dev/null +++ b/modules/database/src/main/kotlin/dev/proxyfox/database/records/group/TagMode.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2022-2023, The ProxyFox Group + * + * This Source Code is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.proxyfox.database.records.group + +enum class TagMode { + HIDDEN, + BEFORE, + AFTER + ; + + fun getDisplayString(): String { + return when (this) { + HIDDEN -> "None" + BEFORE -> "Before System" + AFTER -> "After System" + } + } +} diff --git a/modules/database/src/main/kotlin/dev/proxyfox/database/records/member/MemberProxyTagRecord.kt b/modules/database/src/main/kotlin/dev/proxyfox/database/records/member/MemberProxyTagRecord.kt index 005bf838..f7126ea5 100644 --- a/modules/database/src/main/kotlin/dev/proxyfox/database/records/member/MemberProxyTagRecord.kt +++ b/modules/database/src/main/kotlin/dev/proxyfox/database/records/member/MemberProxyTagRecord.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -8,10 +8,10 @@ package dev.proxyfox.database.records.member -import com.google.gson.annotations.Expose -import com.google.gson.annotations.SerializedName import dev.proxyfox.database.PkId import dev.proxyfox.database.records.MongoRecord +import kotlinx.serialization.Contextual +import kotlinx.serialization.Serializable import org.bson.types.ObjectId // Created 2022-09-04T15:17:43 @@ -21,23 +21,18 @@ import org.bson.types.ObjectId * * @author Ampflower **/ -class MemberProxyTagRecord : MongoRecord { +@Serializable +class MemberProxyTagRecord(): MongoRecord { + @Contextual override var _id: ObjectId = ObjectId() - // GSON-specific annotation for JSON database - @Expose(serialize = false, deserialize = false) var systemId: PkId = "" - - // GSON-specific annotation for JSON database - @SerializedName(value = "memberId", alternate = ["member"]) var memberId: PkId = "" var prefix: String? = null var suffix: String? = null - constructor() - - constructor(systemId: PkId, memberId: PkId, prefix: String?, suffix: String?) { + constructor(systemId: PkId, memberId: PkId, prefix: String?, suffix: String?): this() { this.systemId = systemId this.memberId = memberId this.prefix = if (prefix == "") null else prefix diff --git a/modules/database/src/main/kotlin/dev/proxyfox/database/records/member/MemberRecord.kt b/modules/database/src/main/kotlin/dev/proxyfox/database/records/member/MemberRecord.kt index 83ae4673..bfb5b64f 100644 --- a/modules/database/src/main/kotlin/dev/proxyfox/database/records/member/MemberRecord.kt +++ b/modules/database/src/main/kotlin/dev/proxyfox/database/records/member/MemberRecord.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -8,12 +8,17 @@ package dev.proxyfox.database.records.member -import dev.proxyfox.database.* +import dev.proxyfox.database.PkId +import dev.proxyfox.database.database +import dev.proxyfox.database.etc.ktx.serializaton.InstantLongMillisecondSerializer +import dev.proxyfox.database.etc.ktx.serializaton.LocalDateLongMillisecondSerializer import dev.proxyfox.database.records.MongoRecord +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate +import kotlinx.serialization.Contextual +import kotlinx.serialization.Serializable import org.bson.types.ObjectId -import java.time.LocalDate -import java.time.OffsetDateTime -import java.time.ZoneOffset // Created 2022-09-04T14:12:07 @@ -22,6 +27,7 @@ import java.time.ZoneOffset * * @author Ampflower **/ +@Serializable class MemberRecord() : MongoRecord { constructor(id: PkId, systemId: PkId, name: String) : this() { this.id = id @@ -29,6 +35,7 @@ class MemberRecord() : MongoRecord { this.name = name } + @Contextual override var _id: ObjectId = ObjectId() var id: PkId = "" @@ -44,7 +51,11 @@ class MemberRecord() : MongoRecord { var keepProxy: Boolean = false var autoProxy: Boolean = true var messageCount: ULong = 0UL - var timestamp: OffsetDateTime = OffsetDateTime.now(ZoneOffset.UTC) + + @Serializable(InstantLongMillisecondSerializer::class) + var timestamp: Instant = Clock.System.now() + + @Serializable(LocalDateLongMillisecondSerializer::class) var birthday: LocalDate? = null var age: String? = null var role: String? = null diff --git a/modules/database/src/main/kotlin/dev/proxyfox/database/records/member/MemberServerSettingsRecord.kt b/modules/database/src/main/kotlin/dev/proxyfox/database/records/member/MemberServerSettingsRecord.kt index 250a6f2f..35e4c0f4 100644 --- a/modules/database/src/main/kotlin/dev/proxyfox/database/records/member/MemberServerSettingsRecord.kt +++ b/modules/database/src/main/kotlin/dev/proxyfox/database/records/member/MemberServerSettingsRecord.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -8,8 +8,10 @@ package dev.proxyfox.database.records.member -import dev.proxyfox.database.* +import dev.proxyfox.database.PkId import dev.proxyfox.database.records.MongoRecord +import kotlinx.serialization.Contextual +import kotlinx.serialization.Serializable import org.bson.types.ObjectId // Created 2022-09-04T14:16:19 @@ -19,7 +21,9 @@ import org.bson.types.ObjectId * * @author Ampflower **/ +@Serializable class MemberServerSettingsRecord : MongoRecord { + @Contextual override var _id: ObjectId = ObjectId() var serverId: ULong = 0UL var systemId: PkId = "" diff --git a/modules/database/src/main/kotlin/dev/proxyfox/database/records/misc/AutoProxyMode.kt b/modules/database/src/main/kotlin/dev/proxyfox/database/records/misc/AutoProxyMode.kt index cc56d6c5..8d6b86df 100644 --- a/modules/database/src/main/kotlin/dev/proxyfox/database/records/misc/AutoProxyMode.kt +++ b/modules/database/src/main/kotlin/dev/proxyfox/database/records/misc/AutoProxyMode.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -8,6 +8,8 @@ package dev.proxyfox.database.records.misc +import kotlinx.serialization.Serializable + // Created 2022-11-04T12:10:37 /** @@ -15,6 +17,7 @@ package dev.proxyfox.database.records.misc * * @author Ampflower **/ +@Serializable enum class AutoProxyMode { /** AutoProxy is disabled; it will not automatically switch via use of tags or switch. */ OFF, diff --git a/modules/database/src/main/kotlin/dev/proxyfox/database/records/misc/ChannelSettingsRecord.kt b/modules/database/src/main/kotlin/dev/proxyfox/database/records/misc/ChannelSettingsRecord.kt index a41c4e17..f5d2c35e 100644 --- a/modules/database/src/main/kotlin/dev/proxyfox/database/records/misc/ChannelSettingsRecord.kt +++ b/modules/database/src/main/kotlin/dev/proxyfox/database/records/misc/ChannelSettingsRecord.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -9,20 +9,19 @@ package dev.proxyfox.database.records.misc import dev.proxyfox.database.records.MongoRecord +import kotlinx.serialization.Contextual +import kotlinx.serialization.Serializable import org.bson.types.ObjectId -class ChannelSettingsRecord : MongoRecord { +@Serializable +class ChannelSettingsRecord() : MongoRecord { + @Contextual override var _id: ObjectId = ObjectId() var serverId: ULong = 0UL var channelId: ULong = 0UL var proxyEnabled: Boolean = true - constructor() - - constructor( - serverId: ULong, - channelId: ULong, - ) { + constructor(serverId: ULong, channelId: ULong) : this() { this.serverId = serverId this.channelId = channelId } diff --git a/modules/database/src/main/kotlin/dev/proxyfox/database/records/misc/ProxiedMessageRecord.kt b/modules/database/src/main/kotlin/dev/proxyfox/database/records/misc/ProxiedMessageRecord.kt index 298a1b72..8801c80e 100644 --- a/modules/database/src/main/kotlin/dev/proxyfox/database/records/misc/ProxiedMessageRecord.kt +++ b/modules/database/src/main/kotlin/dev/proxyfox/database/records/misc/ProxiedMessageRecord.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -8,15 +8,20 @@ package dev.proxyfox.database.records.misc -import dev.proxyfox.database.* +import dev.proxyfox.database.PkId +import dev.proxyfox.database.etc.ktx.serializaton.InstantLongMillisecondSerializer import dev.proxyfox.database.records.MongoRecord +import kotlinx.datetime.Clock +import kotlinx.serialization.Contextual +import kotlinx.serialization.Serializable import org.bson.types.ObjectId -import java.time.OffsetDateTime -import java.time.ZoneOffset +@Serializable class ProxiedMessageRecord : MongoRecord { + @Contextual override var _id: ObjectId = ObjectId() - var creationDate = OffsetDateTime.now(ZoneOffset.UTC) + @Serializable(InstantLongMillisecondSerializer::class) + var creationDate = Clock.System.now() var memberName: String = "" var userId: ULong = 0UL var oldMessageId: ULong = 0UL diff --git a/modules/database/src/main/kotlin/dev/proxyfox/database/records/misc/ServerSettingsRecord.kt b/modules/database/src/main/kotlin/dev/proxyfox/database/records/misc/ServerSettingsRecord.kt index fd702d8e..9fb33927 100644 --- a/modules/database/src/main/kotlin/dev/proxyfox/database/records/misc/ServerSettingsRecord.kt +++ b/modules/database/src/main/kotlin/dev/proxyfox/database/records/misc/ServerSettingsRecord.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -9,6 +9,8 @@ package dev.proxyfox.database.records.misc import dev.proxyfox.database.records.MongoRecord +import kotlinx.serialization.Contextual +import kotlinx.serialization.Serializable import org.bson.types.ObjectId // Created 2022-10-04T21:06:30 @@ -17,15 +19,16 @@ import org.bson.types.ObjectId * @author Ampflower * @since ${version} **/ -class ServerSettingsRecord : MongoRecord { +@Serializable +class ServerSettingsRecord() : MongoRecord { + @Contextual override var _id: ObjectId = ObjectId() var serverId: ULong = 0UL var proxyRole: ULong = 0UL var moderationDelay: Short = 250 + var enforceTag: Boolean = false - constructor() - - constructor(serverId: ULong) { + constructor(serverId: ULong) : this() { this.serverId = serverId } diff --git a/modules/database/src/main/kotlin/dev/proxyfox/database/records/misc/TokenRecord.kt b/modules/database/src/main/kotlin/dev/proxyfox/database/records/misc/TokenRecord.kt new file mode 100644 index 00000000..e7d0192e --- /dev/null +++ b/modules/database/src/main/kotlin/dev/proxyfox/database/records/misc/TokenRecord.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2022-2023, The ProxyFox Group + * + * This Source Code is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.proxyfox.database.records.misc + +import dev.proxyfox.database.PkId +import dev.proxyfox.database.records.MongoRecord +import kotlinx.serialization.Contextual +import kotlinx.serialization.Serializable +import org.bson.types.ObjectId + +@Serializable +class TokenRecord : MongoRecord { + @Contextual + override var _id: ObjectId = ObjectId() + var id: PkId + var token: String + var systemId: PkId + var type: TokenType + + constructor(token: String, id: PkId, systemId: PkId, type: TokenType) { + this.token = token + this.id = id + this.systemId = systemId + this.type = type + } +} diff --git a/modules/database/src/main/kotlin/dev/proxyfox/database/records/misc/TokenType.kt b/modules/database/src/main/kotlin/dev/proxyfox/database/records/misc/TokenType.kt new file mode 100644 index 00000000..79a46748 --- /dev/null +++ b/modules/database/src/main/kotlin/dev/proxyfox/database/records/misc/TokenType.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2023, The ProxyFox Group + * + * This Source Code is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.proxyfox.database.records.misc + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +@Serializable(with = TokenType.Serializer::class) +enum class TokenType(private val actualName: String) { + SYSTEM_TRANSFER("system:transfer"), + API_ACCESS("api:access"), + API_EDIT("api:edit"); + + override fun toString(): String { + return actualName + } + + fun canViewApi() = this == API_ACCESS || canEditApi() + fun canEditApi() = this == API_EDIT + + companion object { + fun of(name: String): TokenType? { + for (type in entries) { + if (type.toString() == name) return type + } + return null + } + } + class Serializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("tokenType") + override fun serialize(encoder: Encoder, value: TokenType) = encoder.encodeString(value.toString()) + override fun deserialize(decoder: Decoder): TokenType = TokenType.of(decoder.decodeString())!! + } +} diff --git a/modules/database/src/main/kotlin/dev/proxyfox/database/records/misc/TrustLevel.kt b/modules/database/src/main/kotlin/dev/proxyfox/database/records/misc/TrustLevel.kt index 3b33c372..b9d5ec73 100644 --- a/modules/database/src/main/kotlin/dev/proxyfox/database/records/misc/TrustLevel.kt +++ b/modules/database/src/main/kotlin/dev/proxyfox/database/records/misc/TrustLevel.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this diff --git a/modules/database/src/main/kotlin/dev/proxyfox/database/records/misc/UserRecord.kt b/modules/database/src/main/kotlin/dev/proxyfox/database/records/misc/UserRecord.kt index f65a7d88..1805bdd2 100644 --- a/modules/database/src/main/kotlin/dev/proxyfox/database/records/misc/UserRecord.kt +++ b/modules/database/src/main/kotlin/dev/proxyfox/database/records/misc/UserRecord.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -10,9 +10,13 @@ package dev.proxyfox.database.records.misc import dev.proxyfox.database.* import dev.proxyfox.database.records.MongoRecord +import kotlinx.serialization.Contextual +import kotlinx.serialization.Serializable import org.bson.types.ObjectId +@Serializable class UserRecord : MongoRecord { + @Contextual override var _id: ObjectId = ObjectId() var id: ULong = 0UL var systemId: PkId? = null diff --git a/modules/database/src/main/kotlin/dev/proxyfox/database/records/system/SystemChannelSettingsRecord.kt b/modules/database/src/main/kotlin/dev/proxyfox/database/records/system/SystemChannelSettingsRecord.kt index 8069b6bd..979bd2a5 100644 --- a/modules/database/src/main/kotlin/dev/proxyfox/database/records/system/SystemChannelSettingsRecord.kt +++ b/modules/database/src/main/kotlin/dev/proxyfox/database/records/system/SystemChannelSettingsRecord.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -8,23 +8,22 @@ package dev.proxyfox.database.records.system -import dev.proxyfox.database.* +import dev.proxyfox.database.PkId import dev.proxyfox.database.records.MongoRecord +import kotlinx.serialization.Contextual +import kotlinx.serialization.Serializable import org.bson.types.ObjectId -class SystemChannelSettingsRecord : MongoRecord { +@Serializable +class SystemChannelSettingsRecord() : MongoRecord { + @Contextual override var _id: ObjectId = ObjectId() var serverId: ULong = 0UL var channelId: ULong = 0UL var systemId: PkId = "" var proxyEnabled: Boolean = true - constructor() - - constructor( - channelId: ULong, - systemId: PkId, - ) { + constructor(channelId: ULong, systemId: PkId) : this() { this.channelId = channelId this.systemId = systemId } diff --git a/modules/database/src/main/kotlin/dev/proxyfox/database/records/system/SystemRecord.kt b/modules/database/src/main/kotlin/dev/proxyfox/database/records/system/SystemRecord.kt index 9da2d237..b8109341 100644 --- a/modules/database/src/main/kotlin/dev/proxyfox/database/records/system/SystemRecord.kt +++ b/modules/database/src/main/kotlin/dev/proxyfox/database/records/system/SystemRecord.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -8,13 +8,17 @@ package dev.proxyfox.database.records.system -import dev.proxyfox.database.* +import dev.proxyfox.common.annotations.DontExpose +import dev.proxyfox.database.PkId +import dev.proxyfox.database.etc.ktx.serializaton.InstantLongMillisecondSerializer import dev.proxyfox.database.records.MongoRecord import dev.proxyfox.database.records.misc.AutoProxyMode import dev.proxyfox.database.records.misc.TrustLevel +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlinx.serialization.Contextual +import kotlinx.serialization.Serializable import org.bson.types.ObjectId -import java.time.OffsetDateTime -import java.time.ZoneOffset // Created 2022-09-04T14:07:21 @@ -23,7 +27,9 @@ import java.time.ZoneOffset * * @author Ampflower **/ -class SystemRecord : MongoRecord { +@Serializable +open class SystemRecord : MongoRecord { + @Contextual override var _id: ObjectId = ObjectId() var id: PkId = "" var users: ArrayList = ArrayList() @@ -34,12 +40,43 @@ class SystemRecord : MongoRecord { var color: Int = -1 var avatarUrl: String? = null var timezone: String? = null - var timestamp: OffsetDateTime = OffsetDateTime.now(ZoneOffset.UTC) + + @Serializable(InstantLongMillisecondSerializer::class) + var timestamp: Instant = Clock.System.now() /** The ID of the member that's currently being auto-proxied. */ var autoProxy: PkId? = null var autoType: AutoProxyMode = AutoProxyMode.OFF var trust: HashMap = HashMap() + @DontExpose("PluralKit Tokens grant access to edit systems in PK's API!") + var pkToken: String? = null + val showName get() = name?.let { "$it [`$id`]" } ?: "`$id`" + + fun canAccess(user: ULong): Boolean { + if (users.contains(user)) return true + val trust = trust[user] ?: return false + return trust != TrustLevel.NONE + } + + fun canEditSwitches(user: ULong): Boolean { + if (users.contains(user)) return true + val trust = trust[user] ?: return false + if (trust == TrustLevel.SWITCH) return true + return trust == TrustLevel.FULL + } + + fun canEditMembers(user: ULong): Boolean { + if (users.contains(user)) return true + val trust = trust[user] ?: return false + if (trust == TrustLevel.MEMBER) return true + return trust == TrustLevel.FULL + } + + fun hasFullAccess(user: ULong): Boolean { + if (users.contains(user)) return true + val trust = trust[user] ?: return false + return trust == TrustLevel.FULL + } } diff --git a/modules/database/src/main/kotlin/dev/proxyfox/database/records/system/SystemServerSettingsRecord.kt b/modules/database/src/main/kotlin/dev/proxyfox/database/records/system/SystemServerSettingsRecord.kt index 2f640be4..fb7f246e 100644 --- a/modules/database/src/main/kotlin/dev/proxyfox/database/records/system/SystemServerSettingsRecord.kt +++ b/modules/database/src/main/kotlin/dev/proxyfox/database/records/system/SystemServerSettingsRecord.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -8,9 +8,11 @@ package dev.proxyfox.database.records.system -import dev.proxyfox.database.* +import dev.proxyfox.database.PkId import dev.proxyfox.database.records.MongoRecord import dev.proxyfox.database.records.misc.AutoProxyMode +import kotlinx.serialization.Contextual +import kotlinx.serialization.Serializable import org.bson.types.ObjectId // Created 2022-09-04T15:13:09 @@ -21,7 +23,9 @@ import org.bson.types.ObjectId * @author Ampflower * @since ${version} **/ -class SystemServerSettingsRecord : MongoRecord { +@Serializable +class SystemServerSettingsRecord() : MongoRecord { + @Contextual override var _id: ObjectId = ObjectId() var serverId: ULong = 0UL var systemId: PkId = "" @@ -31,9 +35,7 @@ class SystemServerSettingsRecord : MongoRecord { var autoProxy: PkId? = null var autoProxyMode: AutoProxyMode = AutoProxyMode.FALLBACK - constructor() - - constructor(serverId: ULong, systemId: PkId) { + constructor(serverId: ULong, systemId: PkId) : this() { this.serverId = serverId this.systemId = systemId } diff --git a/modules/database/src/main/kotlin/dev/proxyfox/database/records/system/SystemSwitchRecord.kt b/modules/database/src/main/kotlin/dev/proxyfox/database/records/system/SystemSwitchRecord.kt index 670c6fd0..894b58a9 100644 --- a/modules/database/src/main/kotlin/dev/proxyfox/database/records/system/SystemSwitchRecord.kt +++ b/modules/database/src/main/kotlin/dev/proxyfox/database/records/system/SystemSwitchRecord.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -8,14 +8,16 @@ package dev.proxyfox.database.records.system -import com.fasterxml.jackson.databind.annotation.JsonDeserialize -import com.fasterxml.jackson.databind.annotation.JsonSerialize -import dev.proxyfox.database.* +import dev.proxyfox.database.PkId +import dev.proxyfox.database.etc.ktx.serializaton.InstantLongMicrosecondSerializer import dev.proxyfox.database.records.MongoRecord -import dev.proxyfox.database.etc.jackson.InstantDeserializer -import dev.proxyfox.database.etc.jackson.InstantSerializer +import kotlinx.datetime.Clock +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.Instant +import kotlinx.datetime.minus +import kotlinx.serialization.Contextual +import kotlinx.serialization.Serializable import org.bson.types.ObjectId -import java.time.Instant // Created 2022-09-04T15:18:49 @@ -24,25 +26,25 @@ import java.time.Instant * * @author Ampflower **/ +@Serializable class SystemSwitchRecord : MongoRecord { + @Contextual override var _id: ObjectId = ObjectId() var systemId: PkId var id: PkId var memberIds: List - @JsonDeserialize(using = InstantDeserializer::class) - @JsonSerialize(using = InstantSerializer::class) + @Serializable(InstantLongMicrosecondSerializer::class) var timestamp: Instant set(inst) { - field = inst.minusNanos(inst.nano.mod(1000).toLong()) + field = inst.minus(inst.nanosecondsOfSecond % 1000, DateTimeUnit.NANOSECOND) } - @Suppress("ConvertSecondaryConstructorToPrimary") constructor(systemId: PkId = "", id: PkId = "", memberIds: List = ArrayList(), timestamp: Instant? = null) { this.systemId = systemId this.id = id this.memberIds = memberIds - this.timestamp = timestamp ?: Instant.now() + this.timestamp = timestamp ?: Clock.System.now() } override fun equals(other: Any?): Boolean { diff --git a/modules/database/src/test/kotlin/dev/proxyfox/BlackHole.kt b/modules/database/src/test/kotlin/dev/proxyfox/BlackHole.kt index b145a5c9..30558fd0 100644 --- a/modules/database/src/test/kotlin/dev/proxyfox/BlackHole.kt +++ b/modules/database/src/test/kotlin/dev/proxyfox/BlackHole.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this diff --git a/modules/database/src/test/kotlin/dev/proxyfox/database/DatabaseTest.kt b/modules/database/src/test/kotlin/dev/proxyfox/database/DatabaseTest.kt index 653af46f..8e40ab10 100644 --- a/modules/database/src/test/kotlin/dev/proxyfox/database/DatabaseTest.kt +++ b/modules/database/src/test/kotlin/dev/proxyfox/database/DatabaseTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -15,8 +15,7 @@ import dev.proxyfox.database.DatabaseTestUtil.seeded import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest -import org.testng.Assert.assertNotNull -import org.testng.Assert.assertNull +import org.testng.Assert.* import org.testng.annotations.* import java.nio.file.Files @@ -176,7 +175,7 @@ class DatabaseTest @Factory(dataProvider = "constructorParameters") constructor( @DataProvider @JvmStatic fun constructorParameters() = arrayOf( - arrayOf("JSON", { JsonDatabase(test.resolve("systems-${System.nanoTime()}.json").toFile()) }), + arrayOf("InMemory", { InMemoryDatabase() }), arrayOf("MongoDB", { MongoDatabase("TestFoxy-" + System.nanoTime()) }), ) diff --git a/modules/database/src/test/kotlin/dev/proxyfox/database/DatabaseTestUtil.kt b/modules/database/src/test/kotlin/dev/proxyfox/database/DatabaseTestUtil.kt index f5b3fd06..5a8e9a5c 100644 --- a/modules/database/src/test/kotlin/dev/proxyfox/database/DatabaseTestUtil.kt +++ b/modules/database/src/test/kotlin/dev/proxyfox/database/DatabaseTestUtil.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -12,10 +12,8 @@ import dev.kord.common.entity.Snowflake import dev.kord.core.entity.Entity import io.mockk.every import io.mockk.mockk +import kotlinx.datetime.Instant import org.testng.annotations.DataProvider -import java.time.Instant -import java.time.OffsetDateTime -import java.time.ZoneOffset import java.util.* import java.util.concurrent.TimeUnit import java.util.stream.IntStream @@ -37,12 +35,13 @@ object DatabaseTestUtil { private val seed = System.getenv("TEST_SEED")?.toLongOrNull() private val rng = Random() - const val offsetDateTimeEpochString = "1970-01-01T00:00:00Z" - val offsetDateTimeEpoch = OffsetDateTime.of(1970, 1, 1, 0, 0, 0, 0, ZoneOffset.UTC)!! - val offsetDateTimeLastMicroOfEpochDay = OffsetDateTime.of(1970, 1, 1, 23, 59, 59, 999_999_000, ZoneOffset.UTC)!! + val instantEpoch = Instant.fromEpochSeconds(0L) + val instantLastMicroOfEpochDay = Instant.fromEpochSeconds(TimeUnit.DAYS.toSeconds(1) - 1L, 999_999_000L) + val instantLastNanoOfEpochDay = Instant.fromEpochSeconds(TimeUnit.DAYS.toSeconds(1) - 1L, 999_999_999L) - val instantEpoch = Instant.EPOCH!! - val instantLastMicroOfEpochDay = Instant.ofEpochSecond(TimeUnit.DAYS.toSeconds(1) - 1L, 999_999_000L)!! + val stringEpoch = "1970-01-01T00:00:00Z" + val stringLastMicroOfEpochDay = "1970-01-01T23:59:59.999999Z" + val stringLastNanoOfEpochDay = "1970-01-01T23:59:59.999999999Z" inline fun entity(ret: ULong): T { return mockk { diff --git a/modules/database/src/test/kotlin/dev/proxyfox/database/DatabaseUtilTest.kt b/modules/database/src/test/kotlin/dev/proxyfox/database/DatabaseUtilTest.kt index 3e3f24ec..4c2e1d4b 100644 --- a/modules/database/src/test/kotlin/dev/proxyfox/database/DatabaseUtilTest.kt +++ b/modules/database/src/test/kotlin/dev/proxyfox/database/DatabaseUtilTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -8,7 +8,6 @@ package dev.proxyfox.database -import com.google.gson.reflect.TypeToken import dev.proxyfox.database.DatabaseTestUtil.pkIdStream import org.testng.Assert.* import org.testng.annotations.DataProvider @@ -59,66 +58,6 @@ class DatabaseUtilTest { assertEquals(list.firstFree(), expected) } - @Test - fun `RecordAdapter(GenericStore) - expect list`() { - val record = readJson>>("""{"value":["a","b","c"]}""") - assertEquals(record.value, listOf("a", "b", "c")) - } - - @Test - fun `RecordAdapter(GenericStore) - expect array`() { - val record = readJson>>("""{"value":["a","b","c"]}""") - assertEquals(record.value, arrayOf("a", "b", "c")) - } - - @Test - fun `RecordAdapter(GenericStore) - expect map A`() { - val record = readJson>>("""{"value":{"integer":123,"string":"Hi!","integerMap":{"a":1,"b":2,"c":3}}}""") - assertEquals( - record.value, mapOf( - "integer" to 123.0, - "string" to "Hi!", - "integerMap" to mapOf("a" to 1.0, "b" to 2.0, "c" to 3.0) - ) - ) - } - - @Test - fun `RecordAdapter(GenericStore) - expect map B`() { - val record = readJson>>("""{"value":{"integer":123,"string":"Hi!","integerMap":{"1":"a","2":"b","3":"c"}}}""") - assertEquals( - record.value, mapOf( - "integer" to 123.0, - "string" to "Hi!", - "integerMap" to mapOf("1" to "a", "2" to "b", "3" to "c") - ) - ) - } - - @Test - fun `RecordAdapter(GenericStore) - expect ComplexStore`() { - val record = readJson>("""{"value":{"integer":123,"string":"Hi!","integerMap":{"1":"a","2":"b","3":"c"}}}""") - assertEquals( - record.value, ComplexStore( - integer = 123, - string = "Hi!", - integerMap = mapOf(1 to "a", 2 to "b", 3 to "c") - ) - ) - } - - @Test - fun `RecordAdapter(ComplexStore) - expect working`() { - val record = readJson("""{"integer":123,"string":"Hi!","integerMap":{"1":"a","2":"b","3":"c"}}""") - assertEquals( - record, ComplexStore( - integer = 123, - string = "Hi!", - integerMap = mapOf(1 to "a", 2 to "b", 3 to "c") - ) - ) - } - @DataProvider fun knownFirstFrees() = arrayOf>( arrayOf(listOf("aaaaa", "aaaab", "aaaac"), "aaaad"), @@ -141,18 +80,4 @@ class DatabaseUtilTest { @DataProvider fun randomIds(): Iterator> = pkIdStream(100).mapToObj { arrayOf(it.toPkString(), it) }.iterator() - - private inline fun readJson(str: String): T { - return gson.fromJson(str, object : TypeToken() {}.type) - } - - @JvmRecord - data class GenericStore(val value: T) - - @JvmRecord - data class ComplexStore( - val integer: Int, - val string: String, - val integerMap: Map - ) } \ No newline at end of file diff --git a/modules/database/src/test/kotlin/dev/proxyfox/database/GeneratingIterator.kt b/modules/database/src/test/kotlin/dev/proxyfox/database/GeneratingIterator.kt index 2aea16c1..01510bfd 100644 --- a/modules/database/src/test/kotlin/dev/proxyfox/database/GeneratingIterator.kt +++ b/modules/database/src/test/kotlin/dev/proxyfox/database/GeneratingIterator.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this diff --git a/modules/database/src/test/kotlin/dev/proxyfox/database/ProxyTagTest.kt b/modules/database/src/test/kotlin/dev/proxyfox/database/ProxyTagTest.kt index 4849f191..83ef3691 100644 --- a/modules/database/src/test/kotlin/dev/proxyfox/database/ProxyTagTest.kt +++ b/modules/database/src/test/kotlin/dev/proxyfox/database/ProxyTagTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this diff --git a/modules/database/src/test/kotlin/dev/proxyfox/database/TimeUtilTest.kt b/modules/database/src/test/kotlin/dev/proxyfox/database/TimeUtilTest.kt index 18a50580..d70e16ba 100644 --- a/modules/database/src/test/kotlin/dev/proxyfox/database/TimeUtilTest.kt +++ b/modules/database/src/test/kotlin/dev/proxyfox/database/TimeUtilTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -8,10 +8,10 @@ package dev.proxyfox.database +import kotlinx.datetime.LocalDate import org.testng.Assert.assertEquals import org.testng.annotations.DataProvider import org.testng.annotations.Test -import java.time.LocalDate // Created 2022-02-10T23:23:07 @@ -37,9 +37,9 @@ class TimeUtilTest { } companion object { - val dec25 = LocalDate.of(1, 12, 25)!! - val jan01 = LocalDate.of(65535, 1, 1)!! - val jan01neg = LocalDate.of(-65536, 1, 1)!! + val dec25 = LocalDate(1, 12, 25) + val jan01 = LocalDate(65535, 1, 1) + val jan01neg = LocalDate(-65536, 1, 1) @JvmStatic @DataProvider diff --git a/modules/database/src/test/kotlin/dev/proxyfox/exporter/ExporterTest.kt b/modules/database/src/test/kotlin/dev/proxyfox/exporter/ExporterTest.kt index 351365a4..e4f82e43 100644 --- a/modules/database/src/test/kotlin/dev/proxyfox/exporter/ExporterTest.kt +++ b/modules/database/src/test/kotlin/dev/proxyfox/exporter/ExporterTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -9,14 +9,17 @@ package dev.proxyfox.exporter import dev.proxyfox.database.DatabaseTestUtil.instantEpoch -import dev.proxyfox.database.DatabaseTestUtil.offsetDateTimeEpoch -import dev.proxyfox.database.DatabaseTestUtil.offsetDateTimeEpochString -import dev.proxyfox.database.records.member.MemberRecord -import dev.proxyfox.database.records.system.SystemRecord -import dev.proxyfox.database.records.system.SystemSwitchRecord +import dev.proxyfox.database.DatabaseTestUtil.instantLastMicroOfEpochDay +import dev.proxyfox.database.DatabaseTestUtil.instantLastNanoOfEpochDay +import dev.proxyfox.database.DatabaseTestUtil.stringEpoch +import dev.proxyfox.database.DatabaseTestUtil.stringLastMicroOfEpochDay +import dev.proxyfox.database.DatabaseTestUtil.stringLastNanoOfEpochDay import dev.proxyfox.database.etc.types.PkMember import dev.proxyfox.database.etc.types.PkSwitch import dev.proxyfox.database.etc.types.PkSystem +import dev.proxyfox.database.records.member.MemberRecord +import dev.proxyfox.database.records.system.SystemRecord +import dev.proxyfox.database.records.system.SystemSwitchRecord import org.testng.Assert import org.testng.annotations.Test @@ -30,17 +33,50 @@ class ExporterTest { @Test fun `Exporter(System) - retain seconds`() { val system = PkSystem(SystemRecord().apply { - timestamp = offsetDateTimeEpoch + timestamp = instantEpoch }) - Assert.assertEquals(system.created, offsetDateTimeEpochString) + Assert.assertEquals(system.created, stringEpoch) + } + + + @Test + fun `Exporter(System) - retain microseconds`() { + val system = PkSystem(SystemRecord().apply { + timestamp = instantLastMicroOfEpochDay + }, null) + Assert.assertEquals(system.created, stringLastMicroOfEpochDay) + } + + @Test + fun `Exporter(System) - retain nanoseconds`() { + val system = PkSystem(SystemRecord().apply { + timestamp = instantLastNanoOfEpochDay + }, null) + Assert.assertEquals(system.created, stringLastNanoOfEpochDay) } @Test fun `Exporter(Member) - retain seconds`() { val member = PkMember(MemberRecord().apply { - timestamp = offsetDateTimeEpoch + timestamp = instantEpoch }, null) - Assert.assertEquals(member.created, offsetDateTimeEpochString) + Assert.assertEquals(member.created, stringEpoch) + } + + @Test + fun `Exporter(Member) - retain microseconds`() { + val member = PkMember(MemberRecord().apply { + timestamp = instantLastMicroOfEpochDay + }, null) + Assert.assertEquals(member.created, stringLastMicroOfEpochDay) + } + + @Test + fun `Exporter(Member) - retain nanoseconds`() { + val member = PkMember(MemberRecord().apply { + timestamp = instantLastNanoOfEpochDay + }, null) + Assert.assertEquals(member.created, stringLastNanoOfEpochDay) } @Test @@ -48,6 +84,22 @@ class ExporterTest { val switch = PkSwitch(SystemSwitchRecord().apply { timestamp = instantEpoch }) - Assert.assertEquals(switch.timestamp, offsetDateTimeEpochString) + Assert.assertEquals(switch.timestamp, stringEpoch) + } + + @Test + fun `Exporter(Switch) - retain microseconds`() { + val switch = PkSwitch(SystemSwitchRecord().apply { + timestamp = instantLastMicroOfEpochDay + }) + Assert.assertEquals(switch.timestamp, stringLastMicroOfEpochDay) + } + + @Test + fun `Exporter(Switch) - truncate nanoseconds to microseconds`() { + val switch = PkSwitch(SystemSwitchRecord().apply { + timestamp = instantLastNanoOfEpochDay + }) + Assert.assertEquals(switch.timestamp, stringLastMicroOfEpochDay) } } \ No newline at end of file diff --git a/modules/database/src/test/kotlin/dev/proxyfox/importer/ImporterTest.kt b/modules/database/src/test/kotlin/dev/proxyfox/importer/ImporterTest.kt index 7b5ac3d4..217fbcee 100644 --- a/modules/database/src/test/kotlin/dev/proxyfox/importer/ImporterTest.kt +++ b/modules/database/src/test/kotlin/dev/proxyfox/importer/ImporterTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, The ProxyFox Group + * Copyright (c) 2022-2023, The ProxyFox Group * * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -15,18 +15,22 @@ import dev.proxyfox.database.DatabaseTestUtil.entity import dev.proxyfox.database.DatabaseTestUtil.instantEpoch import dev.proxyfox.database.DatabaseTestUtil.instantLastMicroOfEpochDay import dev.proxyfox.database.DatabaseTestUtil.seeded -import dev.proxyfox.database.JsonDatabase +import dev.proxyfox.database.DatabaseTestUtil.stringEpoch +import dev.proxyfox.database.DatabaseTestUtil.stringLastMicroOfEpochDay +import dev.proxyfox.database.InMemoryDatabase import dev.proxyfox.database.MongoDatabase +import dev.proxyfox.database.etc.exporter.Exporter import dev.proxyfox.database.etc.importer.* +import dev.proxyfox.database.isValidPkString import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest +import kotlinx.datetime.LocalDate import org.slf4j.LoggerFactory import org.testng.Assert.* import org.testng.annotations.* import java.io.Reader import java.net.URL import java.nio.file.Files -import java.time.LocalDate // Created 2022-29-09T22:17:51 @@ -55,41 +59,67 @@ constructor(private val name: String, databaseFactory: () -> Database) { @Test(dataProvider = "passImporters") fun `Importer - expect pass`(url: URL) = runTest { val user = entity(prng.nextLong().toULong()) - assertNull(database.fetchSystemFromUser(user), "$user already has system bound?") + try { + database.fetchSystemFromUser(user).let { + assertNull(it, "${user.id} already has system bound at `${it?.id}`?") + } - val importer1 = import(database, url.readText(), user) - assertEquals(importer1.updatedMembers, 0, "Somehow updated existing member") + val importer1 = import(database, url.readText(), user) + assertEquals(importer1.updatedMembers, 0, "Somehow updated existing member") - assertNotNull(database.fetchMemberFromUserAndName(user, "Azalea"), "No such Azalea for $user") + val system = importer1.system - if (!url.file.contains("Tupperbox")) { - assertEquals("| flwr", database.fetchSystemFromUser(user)?.tag, "Tag didn't get imported correctly.") - } + assertTrue(system.users.contains(user.id.value), "System not owned by ${user.id} despite being allocated") + assertTrue(system.id.isValidPkString(), "`${system.id}` is not a valid PK ID") - extraResource("PluralKit-v1-Case-Sensitivity-Test.json") { - val pkImporter = import(database, it, user) - assertEquals(pkImporter.createdMembers, 1, "`azalea` was not counted.") - } + database.fetchMemberFromUserAndName(user, "Azalea").let { + assertNotNull(it, "No such Azalea for $user") + assertTrue(it!!.id.isValidPkString(), "`${it.id}` is not a valid PK ID") + } + + if (!url.file.contains("Tupperbox")) { + assertEquals("| flwr", database.fetchSystemFromUser(user)?.tag, "Tag didn't get imported correctly.") + } + + extraResource("PluralKit-v1-Case-Sensitivity-Test.json") { + val pkImporter = import(database, it, user) + assertEquals(pkImporter.createdMembers, 1, "`azalea` was not counted.") + } + + assertEquals(database.fetchMemberFromUserAndName(user, "azalea")?.name, "azalea") + + database.dropSystem(user) + database.fetchSystemFromUser(user).let { + assertNull(it, "${user.id} still has system bound at `${it?.id}` after explicit drop") + } - assertEquals(database.fetchMemberFromUserAndName(user, "azalea")?.name, "azalea") + database.getOrCreateSystem(user) - database.dropSystem(user) - database.getOrCreateSystem(user) + val importer2 = import(database, url.readText(), user) + assertEquals(importer2.updatedMembers, 0, "Somehow updated existing member") + assertEquals(importer2.createdMembers, importer1.createdMembers, "Unexpected behaviour change") - val importer2 = import(database, url.readText(), user) - assertEquals(importer2.updatedMembers, 0, "Somehow updated existing member") - assertEquals(importer2.createdMembers, importer1.createdMembers, "Unexpected behaviour change") + database.dropSystem(user) + database.fetchSystemFromUser(user).let { + assertNull(it, "${user.id} still has system bound at `${it?.id}` after explicit drop") + } - database.dropSystem(user) - val id = database.getOrCreateSystem(user).id - database.getOrCreateMember(id, "Azalea") + val id = database.getOrCreateSystem(user).id + assertTrue(id.isValidPkString(), "`$id` is not a valid PK ID") - val importer3 = import(database, url.readText(), user) - assertEquals(importer3.updatedMembers, 1, "Updated more than Azalea") - assertEquals(importer3.createdMembers, importer1.createdMembers - 1, "Unexpected behaviour change") + database.getOrCreateMember(id, "Azalea") - // Somehow the ID manages to get reused in some implementations - database.dropSystem(user) + val importer3 = import(database, url.readText(), user) + assertEquals(importer3.updatedMembers, 1, "Updated more than Azalea") + assertEquals(importer3.createdMembers, importer1.createdMembers - 1, "Unexpected behaviour change") + + } finally { + // Somehow the ID manages to get reused in some implementations + database.dropSystem(user) + database.fetchSystemFromUser(user).let { + assertNull(it, "${user.id} still has system bound at `${it?.id}` after explicit drop") + } + } } @Test @@ -99,12 +129,12 @@ constructor(private val name: String, databaseFactory: () -> Database) { import(database, it, user) } - assertEquals(database.fetchMemberFromUserAndName(user, "Azalea")!!.birthday, LocalDate.of(1, 12, 25)) - assertEquals(database.fetchMemberFromUserAndName(user, "Berry")!!.birthday, LocalDate.of(1, 1, 2)) - assertEquals(database.fetchMemberFromUserAndName(user, "Cherry")!!.birthday, LocalDate.of(1, 4, 10)) - assertEquals(database.fetchMemberFromUserAndName(user, "Hibiscus")!!.birthday, LocalDate.of(1990, 7, 4)) - assertEquals(database.fetchMemberFromUserAndName(user, "Zinnia")!!.birthday, LocalDate.of(2000, 2, 4)) - assertEquals(database.fetchMemberFromUserAndName(user, "Ivy")!!.birthday, LocalDate.of(1995, 8, 24)) + assertEquals(database.fetchMemberFromUserAndName(user, "Azalea")!!.birthday, LocalDate(1, 12, 25)) + assertEquals(database.fetchMemberFromUserAndName(user, "Berry")!!.birthday, LocalDate(1, 1, 2)) + assertEquals(database.fetchMemberFromUserAndName(user, "Cherry")!!.birthday, LocalDate(1, 4, 10)) + assertEquals(database.fetchMemberFromUserAndName(user, "Hibiscus")!!.birthday, LocalDate(1990, 7, 4)) + assertEquals(database.fetchMemberFromUserAndName(user, "Zinnia")!!.birthday, LocalDate(2000, 2, 4)) + assertEquals(database.fetchMemberFromUserAndName(user, "Ivy")!!.birthday, LocalDate(1995, 8, 24)) } @Test @@ -114,12 +144,12 @@ constructor(private val name: String, databaseFactory: () -> Database) { import(database, it, user) } - assertEquals(database.fetchMemberFromUserAndName(user, "Azalea")!!.birthday, LocalDate.of(1, 12, 25)) - assertEquals(database.fetchMemberFromUserAndName(user, "Berry")!!.birthday, LocalDate.of(1, 2, 1)) - assertEquals(database.fetchMemberFromUserAndName(user, "Cherry")!!.birthday, LocalDate.of(1, 10, 4)) - assertEquals(database.fetchMemberFromUserAndName(user, "Hibiscus")!!.birthday, LocalDate.of(1990, 4, 7)) - assertEquals(database.fetchMemberFromUserAndName(user, "Zinnia")!!.birthday, LocalDate.of(2000, 2, 4)) - assertEquals(database.fetchMemberFromUserAndName(user, "Ivy")!!.birthday, LocalDate.of(1995, 8, 24)) + assertEquals(database.fetchMemberFromUserAndName(user, "Azalea")!!.birthday, LocalDate(1, 12, 25)) + assertEquals(database.fetchMemberFromUserAndName(user, "Berry")!!.birthday, LocalDate(1, 2, 1)) + assertEquals(database.fetchMemberFromUserAndName(user, "Cherry")!!.birthday, LocalDate(1, 10, 4)) + assertEquals(database.fetchMemberFromUserAndName(user, "Hibiscus")!!.birthday, LocalDate(1990, 4, 7)) + assertEquals(database.fetchMemberFromUserAndName(user, "Zinnia")!!.birthday, LocalDate(2000, 2, 4)) + assertEquals(database.fetchMemberFromUserAndName(user, "Ivy")!!.birthday, LocalDate(1995, 8, 24)) } @Test @@ -134,6 +164,13 @@ constructor(private val name: String, databaseFactory: () -> Database) { val sorted = switches!!.sortedBy { it.timestamp } assertEquals(sorted[0].timestamp, instantEpoch) assertEquals(sorted[1].timestamp, instantLastMicroOfEpochDay) + + val system = Exporter.exportToPkObject(database, user.id.value)!! + + val sortedExport = system.switches!!.sortedBy { it.timestamp } + + assertEquals(sortedExport[0].timestamp, stringEpoch) + assertEquals(sortedExport[1].timestamp, stringLastMicroOfEpochDay) } @Test @@ -189,7 +226,7 @@ constructor(private val name: String, databaseFactory: () -> Database) { @DataProvider @JvmStatic fun constructorParameters() = arrayOf( - arrayOf("JSON", { JsonDatabase(test.resolve("systems-${System.nanoTime()}.json").toFile()) }), + arrayOf("InMemory", { InMemoryDatabase() }), arrayOf("MongoDB", { MongoDatabase("TestFoxy-" + System.nanoTime()) }), ) diff --git a/modules/api/server/build.gradle.kts b/modules/sync/build.gradle.kts similarity index 79% rename from modules/api/server/build.gradle.kts rename to modules/sync/build.gradle.kts index ad0a1888..b816886c 100644 --- a/modules/api/server/build.gradle.kts +++ b/modules/sync/build.gradle.kts @@ -12,18 +12,17 @@ plugins { alias(libs.plugins.shadow) } -application { - mainClass.set("dev.proxyfox.api.server.ServerMainKt") -} - dependencies { implementation(project(":modules:common")) implementation(project(":modules:database")) } +application.mainClass.set("dev.proxyfox.bot.BotMainKt") + tasks { shadowJar { - archiveClassifier.set("shadow") + archiveBaseName.set("proxyfox") + archiveClassifier.set("") mergeServiceFiles() } -} +} \ No newline at end of file diff --git a/modules/sync/src/main/kotlin/dev/proxyfox/sync/PkSync.kt b/modules/sync/src/main/kotlin/dev/proxyfox/sync/PkSync.kt new file mode 100644 index 00000000..b17ca67a --- /dev/null +++ b/modules/sync/src/main/kotlin/dev/proxyfox/sync/PkSync.kt @@ -0,0 +1,223 @@ +/* + * Copyright (c) 2023, The ProxyFox Group + * + * This Source Code is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.proxyfox.sync + +import com.google.common.collect.HashBiMap +import dev.proxyfox.common.annotations.DontExpose +import dev.proxyfox.common.throwIfPresent +import dev.proxyfox.database.database +import dev.proxyfox.database.records.system.SystemRecord +import dev.proxyfox.pluralkt.PluralKt +import dev.proxyfox.pluralkt.Response +import dev.proxyfox.pluralkt.types.PkColor +import dev.proxyfox.pluralkt.types.PkError +import dev.proxyfox.pluralkt.types.PkMember +import dev.proxyfox.pluralkt.types.PkProxyTag + +@OptIn(DontExpose::class) +object PkSync { + sealed interface Either { + fun getA(): A? + fun getB(): B? + + class EitherA(private val a: A) : Either { + override fun getA() = a + override fun getB() = null + } + + class EitherB(private val b: B) : Either { + override fun getA() = null + override fun getB() = b + } + } + + interface ProgressUpdater { + suspend fun update(type: String, from: String, to: String) + } + + private fun Response.getSuccessOrNull() = if (isSuccess()) getSuccess() else null + + suspend fun pull(system: SystemRecord, updater: ProgressUpdater): Either { + val token = system.pkToken ?: return Either.EitherA(false) + + updater.update("Start", "pk", "pf") + + val systemResp = PluralKt.System.getMe(token).await() + systemResp.getException().throwIfPresent() + val pkSystem = systemResp.getSuccessOrNull() ?: return Either.EitherB(systemResp.getError()) + updater.update("System", pkSystem.id, system.id) + + system.name = pkSystem.name ?: system.name + system.description = pkSystem.description ?: system.description + system.tag = pkSystem.tag ?: system.tag + system.avatarUrl = pkSystem.avatarUrl ?: system.avatarUrl + system.color = pkSystem.color?.color ?: system.color + system.pronouns = pkSystem.pronouns ?: system.pronouns + + val membersResp = PluralKt.Member.getMembers(pkSystem.id, token).await() + membersResp.getException().throwIfPresent() + val pkMembers = membersResp.getSuccessOrNull() ?: return Either.EitherB(membersResp.getError()) + + val memberToIdLookup = HashBiMap.create() + val idToIdLookup = HashBiMap.create() + + for (pkMember in pkMembers) { + val member = database.fetchMemberFromSystem(system.id, pkMember.id) ?: database.getOrCreateMember( + system.id, + pkMember.name + )!! + + memberToIdLookup[pkMember] = member.id + idToIdLookup[pkMember.id] = member.id + + val proxies = database.fetchProxiesFromSystemAndMember(system.id, member.id)!! + var updatedProxies = false + + for (pkProxy in pkMember.proxyTags) { + var hasProxy = false + for (proxy in proxies) { + if (proxy.prefix == pkProxy.prefix && proxy.suffix == pkProxy.suffix) { + hasProxy = true + } + } + if (!hasProxy) { + updatedProxies = true + database.createProxyTag(system.id, member.id, pkProxy.prefix, pkProxy.suffix) + } + } + + if ( + pkMember.name != member.name || + pkMember.pronouns != member.pronouns || + pkMember.avatarUrl != member.avatarUrl || + (pkMember.color?.color ?: -1) != member.color || + pkMember.description != member.description || + pkMember.displayName != member.displayName || + pkMember.keepProxy != member.keepProxy + ) { + member.name = pkMember.name + member.pronouns = pkMember.pronouns + member.avatarUrl = pkMember.avatarUrl + member.color = pkMember.color?.color ?: member.color + member.description = pkMember.description + member.displayName = pkMember.displayName + member.keepProxy = pkMember.keepProxy + database.updateMember(member) + updater.update("Member", pkMember.id, member.id) + } else if (updatedProxies) { + updater.update("Member", pkMember.id, member.id) + } + } + + // TODO: groups once implemented + + return Either.EitherA(true) + } + + fun Array.getOrNull(id: String, name: String): PkMember? { + for (member in this) + if (member.id == id) return member + for (member in this) + if (member.name == name) return member + return null + } + + suspend fun push(system: SystemRecord, updater: ProgressUpdater): Either { + val token = system.pkToken ?: return Either.EitherA(false) + + updater.update("Start", "pf", "pk") + + val systemResp = PluralKt.System.getMe(token).await() + systemResp.getException().throwIfPresent() + val pkSystem = systemResp.getSuccessOrNull() ?: return Either.EitherB(systemResp.getError()) + + updater.update("System", system.id, pkSystem.id) + + pkSystem.name = system.name + pkSystem.description = system.description ?: pkSystem.description + pkSystem.tag = system.tag ?: pkSystem.tag + pkSystem.color = PkColor(system.color) + pkSystem.pronouns = system.pronouns ?: pkSystem.pronouns + + val systemPushResp = PluralKt.System.updateSystem(pkSystem, token).await() + systemPushResp.getException().throwIfPresent() + if (systemPushResp.isError()) return Either.EitherB(systemPushResp.getError()) + + val memberResp = PluralKt.Member.getMembers(pkSystem.id, token).await() + memberResp.getException().throwIfPresent() + val pkMembers = memberResp.getSuccessOrNull() ?: return Either.EitherB(memberResp.getError()) + + val members = database.fetchMembersFromSystem(system.id)!! + + val memberToIdLookup = HashBiMap.create() + val idToIdLookup = HashBiMap.create() + + val newMems = ArrayList() + + for (member in members) { + var new = false + val pkMember = pkMembers.getOrNull(member.id, member.name) ?: let { + new = true + PkMember() + } + val proxies = database.fetchProxiesFromSystemAndMember(system.id, member.id)!! + val proxyList = ArrayList>() + var proxySame = true + for (proxy in pkMember.proxyTags) { + proxyList.add(Pair(proxy.prefix, proxy.suffix)) + } + + for (proxy in proxies) { + if (!proxyList.contains(Pair(proxy.prefix, proxy.suffix))) { + val pkProxy = PkProxyTag() + pkProxy.prefix = proxy.prefix + pkProxy.suffix = proxy.suffix + pkMember.proxyTags.add(pkProxy) + proxySame = false + } + } + + if ( + pkMember.name == member.name && + pkMember.pronouns == member.pronouns && + pkMember.avatarUrl == member.avatarUrl && + (pkMember.color?.color ?: -1) == member.color && + pkMember.description == member.description && + pkMember.displayName == member.displayName && + pkMember.keepProxy == member.keepProxy && + proxySame + ) continue + + pkMember.name = member.name + pkMember.pronouns = member.pronouns ?: pkMember.pronouns + pkMember.avatarUrl = member.avatarUrl ?: pkMember.avatarUrl + if (member.color != -1) + pkMember.color = PkColor(member.color) + pkMember.description = member.description ?: pkMember.description + pkMember.displayName = member.displayName ?: pkMember.displayName + pkMember.keepProxy = member.keepProxy + + val newMem = if (new) { + PluralKt.Member.createMember(pkMember, token) + } else { + PluralKt.Member.updateMember(pkMember.id, pkMember, token) + }.await().getSuccessOrNull() ?: continue + + updater.update("Member", member.id, newMem.id) + + newMems.add(newMem) + memberToIdLookup[newMem] = member.id + idToIdLookup[newMem.id] = member.id + } + + // TODO: groups once implemented + + return Either.EitherA(true) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index be8b3d1d..b28c2cfd 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -24,5 +24,5 @@ include(":modules:common") include(":modules:database") include(":modules:conversion") include(":modules:api") -include(":modules:api:server") +include(":modules:sync") include(":modules:patch") \ No newline at end of file