Compare commits

..

No commits in common. "main" and "v0.1.0" have entirely different histories.
main ... v0.1.0

53 changed files with 644 additions and 4621 deletions

View File

@ -1,2 +0,0 @@
**/
!scalabot-app/build/install/

View File

@ -1,33 +0,0 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "gradle"
directory: "/"
schedule:
interval: "daily"
# - package-ecosystem: "gradle"
# directory: "/scalabot-app"
# schedule:
# interval: "weekly"
# - package-ecosystem: "gradle"
# directory: "/scalabot-meta"
# schedule:
# interval: "weekly"
# - package-ecosystem: "gradle"
# directory: "/scalabot-extension"
# schedule:
# interval: "weekly"
# - package-ecosystem: "gradle"
# directory: "/scalabot-ext-example"
# schedule:
# interval: "weekly"

View File

@ -1,33 +0,0 @@
name: Binary compatibility verification (for API)
on:
push:
paths:
- 'scalabot-meta/**'
- 'scalabot-extension/**'
pull_request:
paths:
- 'scalabot-meta/**'
- 'scalabot-extension/**'
permissions:
contents: read
jobs:
apiCompatibilityCheck:
timeout-minutes: 8
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'adopt-hotspot'
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Build and run binary compatibility verification
run: ./gradlew apiCheck

View File

@ -1,32 +0,0 @@
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
# This workflow will build a Java project with Gradle and cache/restore any dependencies to improve the workflow execution time
# For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-gradle
name: Build and test project
on: [push, pull_request]
permissions:
contents: read
jobs:
build:
timeout-minutes: 8
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'adopt-hotspot'
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Build and test
run: ./gradlew clean test

View File

@ -1,62 +0,0 @@
name: Create release draft
on:
push:
tags:
- 'v[0-9]+.[0-9]+.[0-9]+*'
# 该 Action 有以下步骤:
# 1. 拉取并构建代码, 然后生成 Application 发行包;
# 2. 创建 Release, 并标记为 Draft(草稿);
# 3. 上传 Application 发行包;
permissions:
contents: write
jobs:
create-release:
timeout-minutes: 10
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# 创建更新日志.
- name: 'Get Previous tag'
id: previous-tag
uses: younited/get-previous-tag-action@v1.1.0
with:
match: "v*.*.*"
- name: Set up Python 3
uses: actions/setup-python@v5
with:
python-version: '3.10'
- name: Install Commitizen
run: pip install -U commitizen
- name: Create Change log
run: cz ch --start-rev ${{ steps.previous-tag.outputs.previous-tag }} --file-name ${{ github.workspace }}/CURRENT_CHANGELOG.md
# 开始构建项目.
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'adopt-hotspot'
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Build and test
run: ./gradlew clean test assembleDist
# 创建新的发行版本
- name: Create Release
uses: softprops/action-gh-release@v2
with:
draft: true
body_path: ${{ github.workspace }}/CURRENT_CHANGELOG.md
files: |
*/build/distributions/*
*/build/libs/*

View File

@ -1,46 +0,0 @@
name: Publish artifacts
on:
release:
types:
- published
env:
IMAGE_NAME: lamgc/scalabot
jobs:
publish-container-image:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'adopt-hotspot'
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Build and test
run: ./gradlew clean test installDist
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker BuildX
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push container image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ env.IMAGE_NAME }}:latest, ${{ env.IMAGE_NAME }}:${{ github.ref_name }}
platforms: linux/amd64,linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@ -1,46 +0,0 @@
name: Build development version container image
on:
push:
branches:
- "main"
permissions:
contents: read
jobs:
build:
timeout-minutes: 15
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'adopt-hotspot'
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Build project and install Distribution package
run: ./gradlew clean test installDist
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker BuildX
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push container image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: lamgc/scalabot:dev
platforms: linux/amd64,linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@ -1,8 +0,0 @@
FROM eclipse-temurin:21-jdk-alpine
ENV BOT_DATA_PATH /scalabot/data/
WORKDIR /scalabot/run/
CMD ["/scalabot/app/bin/scalabot-app"]
COPY scalabot-app/build/install/scalabot-app/ /scalabot/app/

View File

@ -1,11 +1,7 @@
<div style="text-align: center;">
# ScalaBot # ScalaBot
基于 [rubenlagus/TelegramBots](https://github.com/rubenlagus/TelegramBots) 的可扩展机器人服务器。 基于 [rubenlagus/TelegramBots](https://github.com/rubenlagus/TelegramBots) 的可扩展机器人服务器。 Extensible robot server based
Extensible robot server based on [rubenlagus/TelegramBots](https://github.com/rubenlagus/TelegramBots). on [rubenlagus/TelegramBots](https://github.com/rubenlagus/TelegramBots).
</div>
## 背景 ## 背景

View File

@ -1,9 +1,3 @@
plugins {
kotlin("jvm") version "2.1.0" apply false
id("org.jetbrains.kotlinx.kover") version "0.8.3" apply false
id("org.jetbrains.kotlinx.binary-compatibility-validator") version "0.16.3" apply false
}
allprojects { allprojects {
repositories { repositories {
mavenCentral() mavenCentral()
@ -13,5 +7,5 @@ allprojects {
} }
group = "net.lamgc" group = "net.lamgc"
version = "0.8.0-1" version = "0.1.0"
} }

Binary file not shown.

View File

@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists

10
gradlew vendored Executable file → Normal file
View File

@ -1,7 +1,7 @@
#!/bin/sh #!/bin/sh
# #
# Copyright © 2015-2021 the original authors. # Copyright ? 2015-2021 the original authors.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@ -32,10 +32,10 @@
# Busybox and similar reduced shells will NOT work, because this script # Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features: # requires all of these POSIX shell features:
# * functions; # * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», # * expansions ?$var?, ?${var}?, ?${var:-default}?, ?${var+SET}?,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»; # ?${var#prefix}?, ?${var%suffix}?, and ?$( cmd )?;
# * compound commands having a testable exit status, especially «case»; # * compound commands having a testable exit status, especially ?case?;
# * various built-in commands including «command», «set», and «ulimit». # * various built-in commands including ?command?, ?set?, and ?ulimit?.
# #
# Important for patching: # Important for patching:
# #

0
gradlew.bat vendored Executable file → Normal file
View File

View File

@ -1,19 +1,17 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins { plugins {
kotlin("jvm") kotlin("jvm") version "1.6.10"
application application
id("org.jetbrains.kotlinx.kover") // id("org.jetbrains.kotlin") version "1.6.10"
} }
dependencies { dependencies {
implementation(project(":scalabot-meta"))
implementation(project(":scalabot-extension")) implementation(project(":scalabot-extension"))
implementation("org.slf4j:slf4j-api:2.0.11") implementation("org.slf4j:slf4j-api:1.7.36")
implementation("io.github.microutils:kotlin-logging:3.0.5") implementation("io.github.microutils:kotlin-logging:2.1.21")
implementation("ch.qos.logback:logback-classic:1.5.12") implementation("ch.qos.logback:logback-classic:1.2.10")
val aetherVersion = "1.1.0" val aetherVersion = "1.1.0"
implementation("org.eclipse.aether:aether-api:$aetherVersion") implementation("org.eclipse.aether:aether-api:$aetherVersion")
@ -23,35 +21,28 @@ dependencies {
implementation("org.eclipse.aether:aether-transport-http:$aetherVersion") implementation("org.eclipse.aether:aether-transport-http:$aetherVersion")
implementation("org.eclipse.aether:aether-connector-basic:$aetherVersion") implementation("org.eclipse.aether:aether-connector-basic:$aetherVersion")
implementation("org.apache.maven:maven-aether-provider:3.3.9") implementation("org.apache.maven:maven-aether-provider:3.3.9")
implementation("org.codehaus.plexus:plexus-utils:3.5.1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0")
implementation("org.jetbrains.kotlin:kotlin-reflect:2.1.0") implementation("com.google.code.gson:gson:2.9.0")
implementation("com.google.code.gson:gson:2.11.0")
implementation("org.jdom:jdom2:2.0.6.1") implementation("org.jdom:jdom2:2.0.6.1")
implementation("org.telegram:telegrambots-abilities:8.0.0") implementation("org.telegram:telegrambots-abilities:5.6.0")
implementation("org.telegram:telegrambots-longpolling:8.0.0") implementation("org.telegram:telegrambots:5.6.0")
implementation("org.telegram:telegrambots-client:8.0.0")
implementation("io.prometheus:simpleclient:0.16.0") implementation("io.prometheus:simpleclient:0.15.0")
implementation("io.prometheus:simpleclient_httpserver:0.16.0") implementation("io.prometheus:simpleclient_httpserver:0.15.0")
testImplementation(kotlin("test")) testImplementation(kotlin("test"))
testImplementation("io.mockk:mockk:1.13.13")
testImplementation("com.github.stefanbirkner:system-lambda:1.2.1")
} }
tasks.test { tasks.test {
useJUnitPlatform() useJUnitPlatform()
jvmArgs("--add-opens", "java.base/java.util=ALL-UNNAMED", "--add-opens", "java.base/java.lang=ALL-UNNAMED")
} }
tasks.withType<KotlinCompile> { tasks.withType<KotlinCompile> {
compilerOptions { kotlinOptions.jvmTarget = "11"
jvmTarget = JvmTarget.JVM_17
}
} }
application { application {
@ -61,8 +52,3 @@ application {
tasks.jar.configure { tasks.jar.configure {
exclude("**/logback-test.xml") exclude("**/logback-test.xml")
} }
tasks.withType<AbstractArchiveTask>().configureEach {
isPreserveFileTimestamps = false
isReproducibleFileOrder = true
}

View File

@ -0,0 +1,17 @@
package net.lamgc.scalabot.util;
final class ByteUtils {
private ByteUtils() {
}
public static String bytesToHexString(byte[] bytes) {
StringBuilder builder = new StringBuilder();
for (byte aByte : bytes) {
String hexBit = Integer.toHexString(aByte & 0xFF);
builder.append(hexBit.length() == 1 ? "0" + hexBit : hexBit);
}
return builder.toString();
}
}

View File

@ -3,104 +3,136 @@ package net.lamgc.scalabot
import ch.qos.logback.core.PropertyDefinerBase import ch.qos.logback.core.PropertyDefinerBase
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.GsonBuilder import com.google.gson.GsonBuilder
import com.google.gson.JsonArray import com.google.gson.reflect.TypeToken
import mu.KotlinLogging import mu.KotlinLogging
import net.lamgc.scalabot.config.* import net.lamgc.scalabot.util.ArtifactSerializer
import net.lamgc.scalabot.config.serializer.* import net.lamgc.scalabot.util.AuthenticationSerializer
import net.lamgc.scalabot.util.MavenRepositoryConfigSerializer
import net.lamgc.scalabot.util.ProxyTypeSerializer
import org.eclipse.aether.artifact.Artifact import org.eclipse.aether.artifact.Artifact
import org.eclipse.aether.repository.Authentication import org.eclipse.aether.repository.Authentication
import org.eclipse.aether.repository.Proxy import org.eclipse.aether.repository.Proxy
import org.eclipse.aether.repository.RemoteRepository import org.eclipse.aether.repository.RemoteRepository
import org.eclipse.aether.repository.RepositoryPolicy import org.telegram.telegrambots.bots.DefaultBotOptions
import org.slf4j.event.Level import org.telegram.telegrambots.meta.ApiConstants
import java.io.File import java.io.File
import java.net.URL import java.net.URL
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import java.util.function.Supplier
import kotlin.reflect.KProperty
private val log = KotlinLogging.logger { } private val log = KotlinLogging.logger { }
internal fun ProxyType.toJavaProxyType(): java.net.Proxy.Type? { /**
return when (this) { * 机器人帐号信息.
ProxyType.NO_PROXY -> null * @property name 机器人名称, 建议与实际设定的名称相同.
ProxyType.HTTP -> java.net.Proxy.Type.HTTP * @property token 机器人 API Token.
ProxyType.HTTPS -> java.net.Proxy.Type.HTTP * @property creatorId 机器人创建者, 管理机器人需要使用该信息.
ProxyType.SOCKS4 -> java.net.Proxy.Type.SOCKS */
ProxyType.SOCKS5 -> java.net.Proxy.Type.SOCKS internal data class BotAccount(
} val name: String,
} val token: String,
val creatorId: Long = -1
)
internal fun ProxyConfig.toAetherProxy(): Proxy? { /**
val typeStr = when (type) { * 机器人配置.
ProxyType.HTTP -> Proxy.TYPE_HTTP * @property account 机器人帐号信息, 用于访问 API.
ProxyType.HTTPS -> Proxy.TYPE_HTTPS * @property disableBuiltInAbility 是否禁用 AbilityBot 自带命令.
else -> return null * @property extensions 该机器人启用的扩展.
} * @property proxy 为该机器人单独设置的代理配置, 如无设置, 则使用 AppConfig 中的代理配置.
return Proxy(typeStr, host, port) */
} internal data class BotConfig(
val enabled: Boolean = true,
val account: BotAccount,
val disableBuiltInAbility: Boolean = false,
val autoUpdateCommandList: Boolean = false,
/*
* 使用构件坐标来选择机器人所使用的扩展包.
* 这么做的原因是我暂时没找到一个合适的方法来让开发者方便地设定自己的扩展 Id,
* 而构件坐标(POM Reference 或者叫 GAV 坐标)是开发者创建 Maven/Gradle 项目时一定会设置的,
* 所以就直接用了. :P
*/
val extensions: Set<Artifact>,
val proxy: ProxyConfig? = ProxyConfig(),
val baseApiUrl: String? = ApiConstants.BASE_URL
)
internal fun MavenRepositoryConfig.toRemoteRepository(proxyConfig: ProxyConfig? = null): RemoteRepository { /**
val repositoryId = if (id == null) { * 代理配置.
val generatedRepoId = createDefaultRepositoryId() * @property type 代理类型.
log.debug { "仓库 Url `$url` 未设置仓库 Id, 已分配缺省 Id: $generatedRepoId" } * @property host 代理服务端地址.
generatedRepoId * @property port 代理服务端端口.
} else if ("local".contentEquals(id, ignoreCase = true)) { */
val generatedRepoId = createDefaultRepositoryId() internal data class ProxyConfig(
log.debug { "仓库 Url `$url` 不允许使用 `local` 作为仓库 Id, 已分配缺省 Id: $generatedRepoId" } val type: DefaultBotOptions.ProxyType = DefaultBotOptions.ProxyType.NO_PROXY,
generatedRepoId val host: String = "127.0.0.1",
} else { val port: Int = 1080
id ) {
}
val builder = RemoteRepository.Builder(repositoryId, checkRepositoryLayout(layout), url.toString()) fun toAetherProxy(): Proxy? {
if (proxy != null) { return if (type == DefaultBotOptions.ProxyType.HTTP) {
val selfProxy = proxy!! Proxy(Proxy.TYPE_HTTP, host, port)
builder.setProxy(selfProxy)
log.debug { "仓库 $repositoryId 已使用独立的代理配置: ${selfProxy.type}://${selfProxy.host}:${selfProxy.port}" }
} else if (proxyConfig != null) {
if (proxyConfig.type in (ProxyType.HTTP..ProxyType.HTTPS)) {
builder.setProxy(proxyConfig.toAetherProxy())
log.debug { "仓库 $repositoryId 已使用 全局/Bot 代理配置: $proxyConfig" }
} else { } else {
log.debug { "仓库 $repositoryId 不支持 全局/Bot 的代理配置: `$proxyConfig` (仅支持 HTTP 和 HTTPS)" } null
} }
} else {
log.debug { "仓库 $repositoryId 不使用代理." }
} }
builder.setReleasePolicy(
RepositoryPolicy(
enableReleases,
RepositoryPolicy.UPDATE_POLICY_NEVER,
RepositoryPolicy.CHECKSUM_POLICY_FAIL
)
)
builder.setSnapshotPolicy(
RepositoryPolicy(
enableSnapshots,
RepositoryPolicy.UPDATE_POLICY_ALWAYS,
RepositoryPolicy.CHECKSUM_POLICY_WARN
)
)
return builder.build()
} }
private fun checkRepositoryLayout(layoutType: String): String { internal data class MetricsConfig(
val type = layoutType.trim().lowercase() val enable: Boolean = false,
if (type != "default" && type != "legacy") { val port: Int = 9386,
throw IllegalArgumentException("Invalid layout type (expecting 'default' or 'legacy')") val bindAddress: String? = "0.0.0.0"
)
/**
* Maven 远端仓库配置.
* @property url 仓库地址.
* @property proxy 访问仓库所使用的代理, 仅支持 http/https 代理.
* @property layout 仓库布局版本, Maven 2 及以上使用 `default`, Maven 1 使用 `legacy`.
*/
internal data class MavenRepositoryConfig(
val url: URL,
val proxy: Proxy? = Proxy("http", "127.0.0.1", 1080),
val layout: String = "default",
// 可能要设计个 type 来判断解析成什么类型的 Authentication.
val authentication: Authentication? = null
) {
fun toRemoteRepository(): RemoteRepository {
val builder = RemoteRepository.Builder(null, checkRepositoryLayout(layout), url.toString())
if (proxy != null) {
builder.setProxy(proxy)
} else if (Const.config.proxy.type == DefaultBotOptions.ProxyType.HTTP) {
builder.setProxy(Const.config.proxy.toAetherProxy())
}
return builder.build()
}
private companion object {
fun checkRepositoryLayout(layoutType: String): String {
val type = layoutType.trim().lowercase()
if (type != "default" && type != "legacy") {
throw IllegalArgumentException("Invalid layout type (expecting 'default' or 'legacy')")
}
return type
}
} }
return type
} }
private val repoNumberGenerator = AtomicInteger(1) /**
* ScalaBot App 配置.
private fun createDefaultRepositoryId(): String { *
return "Repository-${repoNumberGenerator.getAndIncrement()}" * App 配置信息与 BotConfig 分开, 分别存储在各自单独的文件中.
} * @property proxy Telegram API 代理配置.
* @property metrics 运行指标数据配置. 可通过时序数据库记录运行数据.
* @property mavenRepositories Maven 远端仓库配置.
*/
internal data class AppConfig(
val proxy: ProxyConfig = ProxyConfig(),
val metrics: MetricsConfig = MetricsConfig(),
val mavenRepositories: List<MavenRepositoryConfig> = emptyList()
)
/** /**
* 需要用到的路径. * 需要用到的路径.
@ -108,9 +140,9 @@ private fun createDefaultRepositoryId(): String {
* 必须提供 `pathSupplier` `fileSupplier` 其中一个, 才能正常提供路径. * 必须提供 `pathSupplier` `fileSupplier` 其中一个, 才能正常提供路径.
*/ */
internal enum class AppPaths( internal enum class AppPaths(
private val pathSupplier: PathSupplier, private val pathSupplier: () -> String = { fileSupplier.invoke().canonicalPath },
private val initializer: AppPaths.() -> Unit = AppPaths::defaultInitializer, private val initializer: AppPaths.() -> Unit = AppPaths::defaultInitializer,
private val fileSupplier: FileSupplier, private val fileSupplier: () -> File = { File(pathSupplier()) }
) { ) {
/** /**
* 数据根目录. * 数据根目录.
@ -119,11 +151,8 @@ internal enum class AppPaths(
* *
* 提示: 结尾不带 `/`. * 提示: 结尾不带 `/`.
*/ */
DATA_ROOT(fileSupplier = FileSupplier { DATA_ROOT(fileSupplier = {
File( File(System.getProperty(PathConst.PROP_DATA_PATH) ?: System.getenv(PathConst.ENV_DATA_PATH) ?: ".")
System.getProperty(PathConst.PROP_DATA_PATH) ?: System.getenv(PathConst.ENV_DATA_PATH)
?: System.getProperty("user.dir") ?: "."
)
}, initializer = { }, initializer = {
val f = file val f = file
if (!f.exists()) { if (!f.exists()) {
@ -131,15 +160,14 @@ internal enum class AppPaths(
} }
}), }),
CONFIG_APPLICATION(PathSupplier { "$DATA_ROOT/config.json" }, { DEFAULT_CONFIG_APPLICATION({ "$DATA_ROOT/config.json" }, {
if (!file.exists()) { if (!file.exists()) {
file.bufferedWriter(StandardCharsets.UTF_8).use { file.bufferedWriter(StandardCharsets.UTF_8).use {
GsonConst.appConfigGson.toJson( GsonConst.botConfigGson.toJson(
AppConfig( AppConfig(
mavenRepositories = listOf( mavenRepositories = listOf(
MavenRepositoryConfig( MavenRepositoryConfig(
id = "central", URL(MavenRepositoryExtensionFinder.MAVEN_CENTRAL_URL)
url = URL(MavenRepositoryExtensionFinder.MAVEN_CENTRAL_URL)
) )
) )
), it ), it
@ -147,13 +175,13 @@ internal enum class AppPaths(
} }
} }
}), }),
CONFIG_BOT(PathSupplier { "$DATA_ROOT/bot.json" }, { DEFAULT_CONFIG_BOT({ "$DATA_ROOT/bot.json" }, {
if (!file.exists()) { if (!file.exists()) {
file.bufferedWriter(StandardCharsets.UTF_8).use { file.bufferedWriter(StandardCharsets.UTF_8).use {
GsonConst.botConfigGson.toJson( GsonConst.botConfigGson.toJson(
setOf( setOf(
BotConfig( BotConfig(
enabled = true, enabled = false,
proxy = ProxyConfig(), proxy = ProxyConfig(),
account = BotAccount( account = BotAccount(
"Bot Username", "Bot Username",
@ -173,25 +201,10 @@ internal enum class AppPaths(
TEMP({ "$DATA_ROOT/tmp/" }) TEMP({ "$DATA_ROOT/tmp/" })
; ;
constructor(pathSupplier: PathSupplier, initializer: AppPaths.() -> Unit = AppPaths::defaultInitializer) : this( val file: File
fileSupplier = FileSupplier { File(pathSupplier.path).canonicalFile }, get() = fileSupplier.invoke()
pathSupplier = pathSupplier, val path: String
initializer = initializer get() = pathSupplier.invoke()
)
constructor(fileSupplier: FileSupplier, initializer: AppPaths.() -> Unit = AppPaths::defaultInitializer) : this(
fileSupplier = fileSupplier,
pathSupplier = PathSupplier { fileSupplier.file.canonicalPath },
initializer = initializer
)
constructor(pathSupplier: () -> String) : this(
fileSupplier = FileSupplier { File(pathSupplier.invoke()).canonicalFile },
pathSupplier = PathSupplier { pathSupplier.invoke() }
)
val file: File by fileSupplier
val path: String by pathSupplier
private val initialized = AtomicBoolean(false) private val initialized = AtomicBoolean(false)
@ -203,42 +216,13 @@ internal enum class AppPaths(
} }
} }
/**
* 一个内部方法, 用于将 [initialized] 状态重置.
*
* 如果不重置该状态, 将使得单元测试无法让 AppPath 重新初始化文件.
*
* 警告: 该方法不应该被非测试代码调用.
*/
@Suppress("unused")
private fun reset() {
log.warn {
"初始化状态已重置: `${this.name}`, 如果在非测试环境中重置状态, 请报告该问题."
}
initialized.set(false)
}
override fun toString(): String { override fun toString(): String {
return path return path
} }
object PathConst { private companion object PathConst {
const val PROP_DATA_PATH = "bot.path.data" private const val PROP_DATA_PATH = "bot.path.data"
const val ENV_DATA_PATH = "BOT_DATA_PATH" private const val ENV_DATA_PATH = "BOT_DATA_PATH"
}
private class FileSupplier(private val supplier: Supplier<File>) {
operator fun getValue(appPaths: AppPaths, property: KProperty<*>): File = supplier.get()
val file: File
get() = supplier.get()
}
private class PathSupplier(private val supplier: Supplier<String>) {
operator fun getValue(appPaths: AppPaths, property: KProperty<*>): String = supplier.get()
val path: String
get() = supplier.get()
} }
} }
@ -252,37 +236,8 @@ internal class LogDirectorySupplier : PropertyDefinerBase() {
} }
} }
internal class LogLevelSupplier : PropertyDefinerBase() {
override fun getPropertyValue(): String {
val property = System.getProperty("scalabot.log.level", System.getenv("BOT_LOG_LEVEL"))
val level = if (property != null) {
try {
Level.valueOf(property.uppercase())
} catch (e: IllegalArgumentException) {
addWarn("Invalid log level: `$property`, the log will be output using the Info log level.")
Level.INFO
}
} else {
Level.INFO
}
return level.name
}
}
internal class NetworkVerboseLogSupplier : PropertyDefinerBase() {
override fun getPropertyValue(): String {
val propertyValue = System.getProperty("scalabot.log.network.verbose", "false")
return if (propertyValue.toBoolean()) {
"DEBUG"
} else {
"INFO"
}
}
}
internal object Const { internal object Const {
val config = loadAppConfig() val config = loadAppConfig()
const val METRICS_NAMESPACE = "scalabot"
} }
private fun AppPaths.defaultInitializer() { private fun AppPaths.defaultInitializer() {
@ -300,65 +255,38 @@ private fun AppPaths.defaultInitializer() {
} }
} }
/** internal fun initialFiles() {
* 执行 AppPaths 所有项目的初始化, 并检查是否停止运行, 让用户编辑配置. for (path in AppPaths.values()) {
*
* @return 如果需要让用户编辑配置, 则返回 `true`.
*/
internal fun initialFiles(): Boolean {
val configFilesNotInitialized = !AppPaths.CONFIG_APPLICATION.file.exists()
&& !AppPaths.CONFIG_BOT.file.exists()
for (path in AppPaths.entries) {
path.initial() path.initial()
} }
if (configFilesNotInitialized) {
log.warn { "配置文件已初始化, 请根据需要修改配置文件后重新启动本程序." }
return true
}
return false
} }
internal object GsonConst { private object GsonConst {
private val baseGson: Gson = GsonBuilder() val baseGson: Gson = GsonBuilder()
.setPrettyPrinting() .setPrettyPrinting()
.serializeNulls() .serializeNulls()
.create() .create()
val appConfigGson: Gson = baseGson.newBuilder() val appConfigGson: Gson = baseGson.newBuilder()
.registerTypeAdapter(ProxyType::class.java, ProxyTypeSerializer) .registerTypeAdapter(DefaultBotOptions.ProxyType::class.java, ProxyTypeSerializer)
.registerTypeAdapter(MavenRepositoryConfig::class.java, MavenRepositoryConfigSerializer) .registerTypeAdapter(MavenRepositoryConfig::class.java, MavenRepositoryConfigSerializer)
.registerTypeAdapter(Authentication::class.java, AuthenticationSerializer) .registerTypeAdapter(Authentication::class.java, AuthenticationSerializer)
.registerTypeAdapter(UsernameAuthenticator::class.java, UsernameAuthenticatorSerializer)
.create() .create()
val botConfigGson: Gson = baseGson.newBuilder() val botConfigGson: Gson = baseGson.newBuilder()
.registerTypeAdapter(ProxyType::class.java, ProxyTypeSerializer) .registerTypeAdapter(DefaultBotOptions.ProxyType::class.java, ProxyTypeSerializer)
.registerTypeAdapter(BotConfig::class.java, BotConfigSerializer)
.registerTypeAdapter(Artifact::class.java, ArtifactSerializer) .registerTypeAdapter(Artifact::class.java, ArtifactSerializer)
.registerTypeAdapter(BotAccount::class.java, BotAccountSerializer)
.create() .create()
} }
internal fun loadAppConfig(configFile: File = AppPaths.CONFIG_APPLICATION.file): AppConfig { internal fun loadAppConfig(configFile: File = AppPaths.DEFAULT_CONFIG_APPLICATION.file): AppConfig {
try { configFile.bufferedReader(StandardCharsets.UTF_8).use {
configFile.bufferedReader(StandardCharsets.UTF_8).use { return GsonConst.appConfigGson.fromJson(it, AppConfig::class.java)!!
return GsonConst.appConfigGson.fromJson(it, AppConfig::class.java)!!
}
} catch (e: Exception) {
log.error { "读取 config.json 时发生错误, 请检查配置格式是否正确." }
throw e
} }
} }
internal fun loadBotConfigJson(botConfigFile: File = AppPaths.CONFIG_BOT.file): JsonArray? { internal fun loadBotConfig(botConfigFile: File = AppPaths.DEFAULT_CONFIG_BOT.file): Set<BotConfig> {
try { botConfigFile.bufferedReader(StandardCharsets.UTF_8).use {
botConfigFile.bufferedReader(StandardCharsets.UTF_8).use { return GsonConst.botConfigGson.fromJson(it, object : TypeToken<Set<BotConfig>>() {}.type)!!
return GsonConst.botConfigGson.fromJson(it, JsonArray::class.java)!!
}
} catch (e: Exception) {
log.error(e) { "读取 Bot 配置文件 (bot.json) 时发生错误, 请检查配置格式是否正确." }
return null
} }
} }

View File

@ -1,44 +1,28 @@
package net.lamgc.scalabot package net.lamgc.scalabot
import com.google.gson.JsonParseException
import io.prometheus.client.exporter.HTTPServer import io.prometheus.client.exporter.HTTPServer
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import mu.KotlinLogging import mu.KotlinLogging
import net.lamgc.scalabot.config.*
import net.lamgc.scalabot.util.registerShutdownHook import net.lamgc.scalabot.util.registerShutdownHook
import okhttp3.OkHttpClient import org.telegram.telegrambots.bots.DefaultBotOptions
import org.eclipse.aether.repository.LocalRepository import org.telegram.telegrambots.meta.TelegramBotsApi
import org.telegram.telegrambots.client.okhttp.OkHttpTelegramClient import org.telegram.telegrambots.meta.generics.BotSession
import org.telegram.telegrambots.longpolling.BotSession import org.telegram.telegrambots.updatesreceivers.DefaultBotSession
import org.telegram.telegrambots.longpolling.TelegramBotsLongPollingApplication
import org.telegram.telegrambots.meta.api.methods.GetMe
import org.telegram.telegrambots.meta.exceptions.TelegramApiRequestException
import java.io.File
import java.io.IOException
import java.net.InetSocketAddress
import java.net.Proxy
import java.nio.file.attribute.PosixFilePermission
import java.nio.file.attribute.PosixFilePermissions
import kotlin.io.path.createDirectories
import kotlin.io.path.exists
import kotlin.io.path.isReadable
import kotlin.io.path.isWritable
import kotlin.system.exitProcess import kotlin.system.exitProcess
private val log = KotlinLogging.logger { } private val log = KotlinLogging.logger { }
private val launcher = Launcher()
.registerShutdownHook()
fun main(args: Array<String>): Unit = runBlocking { fun main(args: Array<String>): Unit = runBlocking {
log.info { "ScalaBot 正在启动中..." } log.info { "ScalaBot 正在启动中..." }
log.info { "数据目录: ${AppPaths.DATA_ROOT}" } log.info { "数据目录: ${AppPaths.DATA_ROOT}" }
log.debug { "Kotlin: ${KotlinVersion.CURRENT}, JVM: ${Runtime.version()}" }
log.debug { "启动参数: ${args.joinToString(prefix = "[", postfix = "]")}" } log.debug { "启动参数: ${args.joinToString(prefix = "[", postfix = "]")}" }
if (initialFiles()) { initialFiles()
exitProcess(1) if (Const.config.metrics.enable) {
startMetricsServer()
} }
val launcher = Launcher()
.registerShutdownHook()
startMetricsServer()?.registerShutdownHook()
if (!launcher.launch()) { if (!launcher.launch()) {
exitProcess(1) exitProcess(1)
} }
@ -48,115 +32,43 @@ fun main(args: Array<String>): Unit = runBlocking {
* 启动运行指标服务器. * 启动运行指标服务器.
* 使用 Prometheus 指标格式. * 使用 Prometheus 指标格式.
*/ */
internal fun startMetricsServer(config: MetricsConfig = Const.config.metrics): HTTPServer? { fun startMetricsServer() {
if (!config.enable) {
log.debug { "运行指标服务器已禁用." }
return null
}
val builder = HTTPServer.Builder() val builder = HTTPServer.Builder()
.withDaemonThreads(true) .withDaemonThreads(true)
.withAuthenticator(config.authenticator) .withPort(Const.config.metrics.port)
.withPort(config.port) .withHostname(Const.config.metrics.bindAddress)
.withHostname(config.bindAddress)
val httpServer = builder val httpServer = builder
.build() .build()
.registerShutdownHook()
log.info { "运行指标服务器已启动. (Port: ${httpServer.port})" } log.info { "运行指标服务器已启动. (Port: ${httpServer.port})" }
return httpServer
} }
internal class Launcher( internal class Launcher : AutoCloseable {
private val config: AppConfig = Const.config,
private val configFile: File = AppPaths.CONFIG_APPLICATION.file,
) : AutoCloseable {
companion object { companion object {
@JvmStatic @JvmStatic
private val log = KotlinLogging.logger { } private val log = KotlinLogging.logger { }
} }
private val botApi = TelegramBotsLongPollingApplication() private val botApi = TelegramBotsApi(DefaultBotSession::class.java)
private val botSessionMap = mutableMapOf<ScalaBot, BotSession>() private val botSessionMap = mutableMapOf<ScalaBot, BotSession>()
private val mavenLocalRepository = getMavenLocalRepository()
private fun getMavenLocalRepository(): LocalRepository {
val localPath =
if (config.mavenLocalRepository != null && config.mavenLocalRepository!!.isNotEmpty()) {
val repoPath = configFile.toPath().resolve(config.mavenLocalRepository!!).apply {
if (!exists()) {
if (!parent.isWritable() || !parent.isReadable()) {
throw IOException("Unable to read and write the directory where Maven repository is located.")
}
if (System.getProperty("os.name").lowercase().startsWith("windows")) {
createDirectories()
} else {
val fileAttributes = setOf(
PosixFilePermission.OWNER_READ,
PosixFilePermission.OWNER_WRITE,
PosixFilePermission.GROUP_READ,
PosixFilePermission.GROUP_WRITE,
PosixFilePermission.OTHERS_READ,
)
createDirectories(PosixFilePermissions.asFileAttribute(fileAttributes))
}
}
}
.toRealPath()
.toFile()
repoPath
} else {
File("${System.getProperty("user.home")}/.m2/repository")
}
if (!localPath.exists()) {
localPath.mkdirs()
}
return LocalRepository(localPath)
}
@Synchronized @Synchronized
fun launch(): Boolean { fun launch(): Boolean {
val botConfigs = loadBotConfigJson() ?: return false val botConfigs = loadBotConfig()
if (botConfigs.isEmpty) { if (botConfigs.isEmpty()) {
log.warn { "尚未配置任何机器人, 请先配置机器人后再启动本程序." } log.warn { "尚未配置任何机器人, 请先配置机器人后再启动本程序." }
return false return false
} }
var launchedCounts = 0 for (botConfig in botConfigs) {
for (botConfigJson in botConfigs) {
val botConfig = try {
GsonConst.botConfigGson.fromJson(botConfigJson, BotConfig::class.java)
} catch (e: JsonParseException) {
val botName = try {
botConfigJson.asJsonObject.get("account")?.asJsonObject?.get("name")?.asString ?: "Unknown"
} catch (e: Exception) {
"Unknown"
}
log.error(e) { "机器人 `$botName` 配置有误, 跳过该机器人的启动." }
continue
}
try { try {
launchBot(botConfig) launchBot(botConfig)
launchedCounts++
} catch (e: Exception) { } catch (e: Exception) {
if (e is TelegramApiRequestException && e.errorCode == 401) { log.error(e) { "机器人 `${botConfig.account.name}` 启动时发生错误." }
log.error { "机器人 `${botConfig.account.name}` 的 Bot Token 无效, 请检查配置: [${e.errorCode}] ${e.apiResponse}" }
} else {
log.error(e) { "机器人 `${botConfig.account.name}` 启动时发生错误." }
}
} }
} }
return true
botApi.start()
botApi.registerShutdownHook()
return if (launchedCounts != 0) {
log.info { "已启动 $launchedCounts 个机器人." }
true
} else {
log.warn { "未启动任何机器人, 请检查配置并至少启用一个机器人." }
false
}
} }
private fun launchBot(botConfig: BotConfig) { private fun launchBot(botConfig: BotConfig) {
@ -165,59 +77,47 @@ internal class Launcher(
return return
} }
log.info { "正在启动机器人 `${botConfig.account.name}`..." } log.info { "正在启动机器人 `${botConfig.account.name}`..." }
val proxyConfig = val botOption = DefaultBotOptions().apply {
if (botConfig.proxy.type != ProxyType.NO_PROXY) { val proxyConfig =
log.debug { "[Bot ${botConfig.account.name}] 使用独立代理: ${botConfig.proxy.type}" } if (botConfig.proxy != null && botConfig.proxy.type != DefaultBotOptions.ProxyType.NO_PROXY) {
botConfig.proxy botConfig.proxy
} else if (config.proxy.type != ProxyType.NO_PROXY) { } else if (Const.config.proxy.type != DefaultBotOptions.ProxyType.NO_PROXY) {
log.debug { "[Bot ${botConfig.account.name}] 使用全局代理: ${botConfig.proxy.type}" } Const.config.proxy
config.proxy } else {
} else { null
log.debug { "[Bot ${botConfig.account.name}] 不使用代理." } }
ProxyConfig(type = ProxyType.NO_PROXY) if (proxyConfig != null) {
proxyType = proxyConfig.type
proxyHost = Const.config.proxy.host
proxyPort = Const.config.proxy.port
log.debug { "机器人 `${botConfig.account.name}` 已启用代理配置: $proxyConfig" }
} }
val okhttpClientBuilder = OkHttpClient.Builder() if (botConfig.baseApiUrl != null) {
baseUrl = botConfig.baseApiUrl
if (proxyConfig.type != ProxyType.NO_PROXY) { }
val proxyType = proxyConfig.type.toJavaProxyType()
val proxyAddress = InetSocketAddress.createUnresolved(proxyConfig.host, proxyConfig.port)
okhttpClientBuilder.proxy(Proxy(proxyType, proxyAddress))
} }
val account = botConfig.account val account = botConfig.account
val telegramClient =
OkHttpTelegramClient(okhttpClientBuilder.build(), account.token, botConfig.getBaseApiTelegramUrl())
val remoteRepositories = config.mavenRepositories val remoteRepositories = Const.config.mavenRepositories
.map { it.toRemoteRepository(proxyConfig) } .map(MavenRepositoryConfig::toRemoteRepository)
.toMutableList().apply { .toMutableList().apply {
if (this.none { add(MavenRepositoryExtensionFinder.getMavenCentralRepository(proxy = Const.config.proxy.toAetherProxy()))
it.url == MavenRepositoryExtensionFinder.MAVEN_CENTRAL_URL
|| it.url == MavenRepositoryExtensionFinder.MAVEN_CENTRAL_URL.trimEnd('/')
}) {
add(MavenRepositoryExtensionFinder.getMavenCentralRepository(proxy = proxyConfig.toAetherProxy()))
}
}.toList() }.toList()
val extensionPackageFinders = setOf( val extensionPackageFinders = setOf(
MavenRepositoryExtensionFinder( MavenRepositoryExtensionFinder(
localRepository = mavenLocalRepository,
remoteRepositories = remoteRepositories, remoteRepositories = remoteRepositories,
proxy = config.proxy.toAetherProxy() proxy = Const.config.proxy.toAetherProxy()
) )
) )
val bot = ScalaBot( val bot = ScalaBot(
BotDBMaker.getBotDbInstance(account), BotDBMaker.getBotMaker(account),
telegramClient, botOption,
extensionPackageFinders, extensionPackageFinders,
botConfig botConfig
) )
botSessionMap[bot] = botApi.registerBot(bot)
val botUser = bot.telegramClient.execute(GetMe())
log.debug { "已验证 Bot Token 有效性, Bot Username: ${botUser.userName}" }
botSessionMap[bot] = botApi.registerBot(botConfig.account.token, bot)
log.info { "机器人 `${bot.botUsername}` 已启动." } log.info { "机器人 `${bot.botUsername}` 已启动." }
if (botConfig.autoUpdateCommandList) { if (botConfig.autoUpdateCommandList) {
@ -238,16 +138,9 @@ internal class Launcher(
@Synchronized @Synchronized
override fun close() { override fun close() {
botSessionMap.forEach { botSessionMap.forEach {
try { log.info { "正在关闭机器人 `${it.key.botUsername}` ..." }
if (!it.value.isRunning) { it.value.stop()
return@forEach log.info { "已关闭机器人 `${it.key.botUsername}`." }
}
log.info { "正在关闭机器人 `${it.key.botUsername}` ..." }
it.value.stop()
log.info { "已关闭机器人 `${it.key.botUsername}`." }
} catch (e: Exception) {
log.error(e) { "机器人 `${it.key.botUsername}` 关闭时发生异常." }
}
} }
} }

View File

@ -1,192 +1,23 @@
package net.lamgc.scalabot package net.lamgc.scalabot
import com.google.common.io.Files import net.lamgc.scalabot.util.toHaxString
import mu.KotlinLogging
import net.lamgc.scalabot.config.BotAccount
import net.lamgc.scalabot.util.toHexString
import org.mapdb.DB
import org.mapdb.DBException
import org.mapdb.DBMaker import org.mapdb.DBMaker
import org.telegram.telegrambots.abilitybots.api.db.DBContext import org.telegram.abilitybots.api.db.DBContext
import org.telegram.telegrambots.abilitybots.api.db.MapDBContext import org.telegram.abilitybots.api.db.MapDBContext
import java.io.File
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.security.MessageDigest import java.security.MessageDigest
/**
* 数据库适配器列表.
* 应按照新到旧的顺序放置, 新的适配器应该在上面.
* @suppress 由于本列表需要设置已弃用的适配器以保证旧版数据库的正常使用, 故忽略弃用警告.
*/
@Suppress("DEPRECATION")
private val adapters = arrayListOf<DbAdapter>(
BotAccountIdDbAdapter, // since [v0.2.0 ~ latest)
BotTokenDbAdapter // since [v0.0.1 ~ v0.2.0)
)
private const val FIELD_DB_VERSION = "::DB_VERSION"
internal object BotDBMaker { internal object BotDBMaker {
private val logger = KotlinLogging.logger { } fun getBotMaker(botAccount: BotAccount): DBContext {
val digest: MessageDigest = MessageDigest.getInstance("SHA-256")
fun getBotDbInstance(botAccount: BotAccount): DBContext { val digestBytes = digest.digest(botAccount.token.toByteArray(StandardCharsets.UTF_8))
for (adapter in adapters) { val dbPath = AppPaths.DATA_DB.path + "${digestBytes.toHaxString()}.db"
val botDb = try { val db = DBMaker.fileDB(dbPath)
adapter.getBotDb(botAccount, create = false) ?: continue
} catch (e: Exception) {
logger.error(e) { "适配器 ${adapter::class.java} 打开数据库时发生异常." }
continue
}
if (!adapter.dbVersionMatches(botDb)) {
logger.warn {
"数据库版本号与适配器不符. " +
"(Adapter: ${adapter::class.java};(${adapter.dbVersion})," +
" DatabaseVer: ${adapter.getDbVersion(botDb)})"
}
botDb.close()
continue
} else {
if (adapter != adapters[0]) {
logger.debug {
"数据库适配器不是最新的, 正在升级数据库... " +
"(Old: ${adapter::class.java}; New: ${adapters[0]::class.java})"
}
val db = try {
botDb.close()
val newDb = adapters[0].migrateDb(botAccount, adapter)
logger.debug { "数据库版本升级完成." }
newDb
} catch (e: Exception) {
logger.warn(e) { "Bot 数据库版本升级失败, 将继续使用旧版数据库." }
adapter.getBotDb(botAccount, create = false) ?: continue
}
return MapDBContext(db)
}
return MapDBContext(botDb)
}
}
logger.debug { "没有适配器成功打开数据库, 使用最新的适配器创建数据库. (Adapter: ${adapters[0]::class.java})" }
val newDb = adapters[0].getBotDb(botAccount, create = true)
?: throw IllegalStateException("No adapter is available to get the database.")
adapters[0].setDbVersion(newDb, adapters[0].dbVersion)
return MapDBContext(newDb)
}
}
/**
* 数据库适配器.
*
* 用于解决数据库格式更新带来的问题, 通过迁移机制, 将数据库从旧版本迁移到新版本, 或者只通过旧版本适配器访问而不迁移.
* @param dbVersion 数据库格式版本. 格式为: `{格式标识}_{最后使用的版本号}`, 如果为最新版适配器, 则不需要填写最后使用的版本号.
*/
private abstract class DbAdapter(val dbVersion: String) {
/**
* 获取 Bot 专有的 [DBContext].
* @param botAccount Bot 账号信息.
*/
abstract fun getBotDb(botAccount: BotAccount, create: Boolean = false): DB?
/**
* 通过 Bot 账号信息获取数据库文件.
*/
abstract fun getBotDbFile(botAccount: BotAccount): File
/**
* 将旧版数据库迁移到当前版本.
*
* 实现时请注意不要直接修改原数据库, 以防升级过程出错导致无法回退到旧版本.
*/
abstract fun migrateDb(botAccount: BotAccount, oldDbAdapter: DbAdapter): DB
/**
* 数据库版本是否匹配.
*/
open fun dbVersionMatches(db: DB): Boolean {
return getDbVersion(db) == dbVersion
}
fun getDbVersion(db: DB): String? {
if (!db.exists(FIELD_DB_VERSION)) {
return null
}
val dbVersionField = try {
db.atomicString(FIELD_DB_VERSION).open()
} catch (e: DBException.WrongConfiguration) {
return null
}
return dbVersionField.get()
}
fun setDbVersion(db: DB, version: String) {
db.atomicString(FIELD_DB_VERSION).createOrOpen().set(version)
}
}
/**
* 抽象文件数据库适配器.
*
* 只有文件有变化的适配器.
*/
private abstract class FileDbAdapter(
dbVersion: String,
private val fileProvider: (BotAccount) -> File
) : DbAdapter(dbVersion) {
@Suppress("unused")
constructor(dbVersion: String) : this(dbVersion,
{ throw NotImplementedError("When using this constructor, the \"getBotDbFile\" method must be implemented") })
override fun getBotDb(botAccount: BotAccount, create: Boolean): DB? {
val dbFile = getBotDbFile(botAccount)
if (!dbFile.exists() && !create) {
return null
}
return DBMaker.fileDB(dbFile)
.closeOnJvmShutdownWeakReference() .closeOnJvmShutdownWeakReference()
.checksumStoreEnable() .checksumStoreEnable()
.fileChannelEnable() .fileChannelEnable()
.make() .make()
return MapDBContext(db)
} }
override fun getBotDbFile(botAccount: BotAccount): File = fileProvider(botAccount)
override fun migrateDb(botAccount: BotAccount, oldDbAdapter: DbAdapter): DB {
val oldFile = oldDbAdapter.getBotDbFile(botAccount)
val newFile = getBotDbFile(botAccount)
try {
Files.copy(oldFile, newFile)
} catch (e: Exception) {
if (newFile.exists()) {
// 删除新文件以防止异常退出后直接读取新文件.
newFile.delete()
}
throw e
}
oldFile.delete()
return getBotDb(botAccount)!!.apply {
setDbVersion(this, this@FileDbAdapter.dbVersion)
}
}
} }
/**
* 使用 Bot Token 中的 Account Id 命名数据库文件名.
*/
private object BotAccountIdDbAdapter : FileDbAdapter("BotAccountId", { botAccount ->
File(AppPaths.DATA_DB.file, "${botAccount.id}.db")
})
/**
* 使用 Bot Token, 经过 Sha256 加密后得到文件名.
*
* **已弃用**: 由于 Token 可以重新生成, Token 改变后数据库文件名也会改变, 故弃用该方法.
*/
@Deprecated(message = "由于 BotToken 可变, 故不再使用该适配器.", level = DeprecationLevel.WARNING)
private object BotTokenDbAdapter : FileDbAdapter("BotToken_v0.1.0", { botAccount ->
val digest: MessageDigest = MessageDigest.getInstance("SHA-256")
val digestBytes = digest.digest(botAccount.token.toByteArray(StandardCharsets.UTF_8))
File(AppPaths.DATA_DB.file, "${digestBytes.toHexString()}.db")
})

View File

@ -1,11 +1,10 @@
package net.lamgc.scalabot package net.lamgc.scalabot
import mu.KotlinLogging import mu.KotlinLogging
import net.lamgc.scalabot.extension.BotExtensionCreateOptions
import net.lamgc.scalabot.extension.BotExtensionFactory import net.lamgc.scalabot.extension.BotExtensionFactory
import net.lamgc.scalabot.util.getPriority import net.lamgc.scalabot.util.getPriority
import org.eclipse.aether.artifact.Artifact import org.eclipse.aether.artifact.Artifact
import org.telegram.telegrambots.abilitybots.api.util.AbilityExtension import org.telegram.abilitybots.api.util.AbilityExtension
import java.io.File import java.io.File
import java.io.FileNotFoundException import java.io.FileNotFoundException
import java.net.URL import java.net.URL
@ -15,18 +14,6 @@ import java.nio.charset.StandardCharsets
import java.util.* import java.util.*
import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicInteger
/**
* 扩展加载器.
*
* 扩展加载器并非负责加载扩展的 Class, 而是委派搜索器发现并获取扩展, 然后加载扩展实例.
*
* 注意, 扩展加载器将内置两个 Finder: [FileNameFinder] [MavenMetaInformationFinder].
*
* @param bot 扩展加载器所负责的 ScalaBot 实例.
* @param extensionsDataFolder 提供给扩展用于数据存储的根目录(实际目录为 `{root}/{group...}/{artifact}`).
* @param extensionsPath 提供给 Finder 用于搜索扩展的本地扩展包存放路径.
* @param extensionFinders 加载器所使用的搜索器集合. 加载扩展时将使用所提供的的加载器.
*/
internal class ExtensionLoader( internal class ExtensionLoader(
private val bot: ScalaBot, private val bot: ScalaBot,
private val extensionsDataFolder: File = AppPaths.DATA_EXTENSIONS.file, private val extensionsDataFolder: File = AppPaths.DATA_EXTENSIONS.file,
@ -40,13 +27,6 @@ internal class ExtensionLoader(
MavenMetaInformationFinder MavenMetaInformationFinder
).apply { addAll(extensionFinders) }.toSet() ).apply { addAll(extensionFinders) }.toSet()
/**
* 加载扩展, 并返回扩展项.
*
* 调用本方法后, 将会指派提供的 Finder 搜索 ScalaBot 配置的扩展包.
*
* @return 返回存放了所有已加载扩展项的 Set. 可通过 [LoadedExtensionEntry] 获取扩展的有关信息.
*/
fun getExtensions(): Set<LoadedExtensionEntry> { fun getExtensions(): Set<LoadedExtensionEntry> {
val extensionEntries = mutableSetOf<LoadedExtensionEntry>() val extensionEntries = mutableSetOf<LoadedExtensionEntry>()
for (extensionArtifact in bot.extensions) { for (extensionArtifact in bot.extensions) {
@ -72,17 +52,6 @@ internal class ExtensionLoader(
/** /**
* 检查是否发生冲突. * 检查是否发生冲突.
*
* 扩展包冲突有两种情况:
* 1. 有多个同为最高优先级的搜索器搜索到了扩展包.
* 2. 唯一的最高优先级搜索器搜索到了多个扩展包.
*
* 扩展包冲突指的是**有多个具有相同构件坐标的扩展包被搜索到**,
* 如果不顾扩展包冲突直接加载的话, 将会出现安全隐患,
* 因此在加载器发现冲突的情况下将输出相关信息, 提示用户进行排查.
*
* @param foundResult 扩展包搜索结果.
*
* @return 如果出现冲突, 返回 `true`. * @return 如果出现冲突, 返回 `true`.
*/ */
private fun checkConflict(foundResult: Map<ExtensionPackageFinder, Set<FoundExtensionPackage>>): Boolean { private fun checkConflict(foundResult: Map<ExtensionPackageFinder, Set<FoundExtensionPackage>>): Boolean {
@ -99,9 +68,6 @@ internal class ExtensionLoader(
} }
} }
/**
* 从结果中过滤出由最高优先级的搜索器搜索到的扩展包.
*/
private fun filterHighPriorityResult(foundResult: Map<ExtensionPackageFinder, Set<FoundExtensionPackage>>) private fun filterHighPriorityResult(foundResult: Map<ExtensionPackageFinder, Set<FoundExtensionPackage>>)
: Map<ExtensionPackageFinder, Set<FoundExtensionPackage>> { : Map<ExtensionPackageFinder, Set<FoundExtensionPackage>> {
val finders: List<ExtensionPackageFinder> = foundResult.keys val finders: List<ExtensionPackageFinder> = foundResult.keys
@ -123,17 +89,7 @@ internal class ExtensionLoader(
for (factory in extClassLoader.serviceLoader) { for (factory in extClassLoader.serviceLoader) {
try { try {
val extension = val extension =
factory.createExtensionInstance( factory.createExtensionInstance(bot, getExtensionDataFolder(extensionArtifact))
bot, getExtensionDataFolder(extensionArtifact),
BotExtensionCreateOptions(
bot.accountId,
bot.botConfig.proxy.copy()
)
)
if (extension == null) {
log.debug { "Factory ${factory::class.java} 创建插件时返回了 null, 已跳过. (BotName: ${bot.botUsername})" }
continue
}
factories.add(LoadedExtensionEntry(extensionArtifact, factory::class.java, extension)) factories.add(LoadedExtensionEntry(extensionArtifact, factory::class.java, extension))
} catch (e: Exception) { } catch (e: Exception) {
log.error(e) { "创建扩展时发生异常. (ExtArtifact: `$extensionArtifact`, Factory: ${factory::class.java.name})" } log.error(e) { "创建扩展时发生异常. (ExtArtifact: `$extensionArtifact`, Factory: ${factory::class.java.name})" }
@ -142,11 +98,6 @@ internal class ExtensionLoader(
return factories.toSet() return factories.toSet()
} }
/**
* 只是用来统计扩展包搜索结果的数量而已.
*
* @return 返回扩展包的数量.
*/
private fun allFoundedPackageNumber(filesMap: Map<ExtensionPackageFinder, Set<FoundExtensionPackage>>): Int { private fun allFoundedPackageNumber(filesMap: Map<ExtensionPackageFinder, Set<FoundExtensionPackage>>): Int {
var number = 0 var number = 0
for (files in filesMap.values) { for (files in filesMap.values) {
@ -155,14 +106,6 @@ internal class ExtensionLoader(
return number return number
} }
/**
* 搜索指定构件坐标的依赖包.
*
* 搜索扩展包将根据搜索器优先级从高到低依次搜索, 当某一个优先级的搜索器搜到扩展包后将停止搜索.
* 可以根据不同优先级的搜索器, 配置扩展包的主用和备用文件.
*
* @return 返回各个搜索器返回的搜索结果.
*/
private fun findExtensionPackage( private fun findExtensionPackage(
extensionArtifact: Artifact, extensionArtifact: Artifact,
): Map<ExtensionPackageFinder, Set<FoundExtensionPackage>> { ): Map<ExtensionPackageFinder, Set<FoundExtensionPackage>> {
@ -185,51 +128,37 @@ internal class ExtensionLoader(
result[finder] = artifacts result[finder] = artifacts
} }
} catch (e: Exception) { } catch (e: Exception) {
log.error(e) { "搜索器 ${finder::class.java.name} 在搜索扩展 `$extensionArtifact` 时发生错误." } log.error { "搜索器 ${finder::class.java.name} 在搜索扩展 `$extensionArtifact` 时发生错误:" }
} }
} }
return result return result
} }
/**
* 检查扩展包搜索器是否设置了 [FinderRules] 注解.
* @return 如果已设置注解, 则返回 `true`.
*/
private fun checkExtensionPackageFinder(finder: ExtensionPackageFinder): Boolean = private fun checkExtensionPackageFinder(finder: ExtensionPackageFinder): Boolean =
finder::class.java.getDeclaredAnnotation(FinderRules::class.java) != null finder::class.java.getDeclaredAnnotation(FinderRules::class.java) != null
/**
* 在日志中输出有关扩展包冲突的错误信息.
*/
private fun printExtensionFileConflictError( private fun printExtensionFileConflictError(
extensionArtifact: Artifact, extensionArtifact: Artifact,
foundResult: Map<ExtensionPackageFinder, Set<FoundExtensionPackage>> foundResult: Map<ExtensionPackageFinder, Set<FoundExtensionPackage>>
) { ) {
log.error { val errMessage = StringBuilder(
val errMessage = StringBuilder( """
""" [Bot ${bot.botUsername}] 扩展包 $extensionArtifact 存在多个文件, 为防止安全问题, 已禁止加载该扩展包:
[Bot ${bot.botUsername}] 扩展包 $extensionArtifact 存在多个文件, 为防止安全问题, 已禁止加载该扩展包: """.trimIndent()
""".trimIndent() ).append('\n')
).append('\n')
foundResult.forEach { (finder, files) -> foundResult.forEach { (finder, files) ->
errMessage.append("\t- 搜索器 `").append(finder::class.simpleName).append("`") errMessage.append("\t- 搜索器 `").append(finder::class.simpleName).append("`")
.append("(Priority: ${finder.getPriority()})") .append("(Priority: ${finder.getPriority()})")
.append(" 找到了以下扩展包: \n") .append(" 找到了以下扩展包: \n")
for (file in files) { for (file in files) {
errMessage.append("\t\t* ") errMessage.append("\t\t* ")
.append(URLDecoder.decode(file.getRawUrl().toString(), StandardCharsets.UTF_8)).append('\n') .append(URLDecoder.decode(file.getRawUrl().toString(), StandardCharsets.UTF_8)).append('\n')
}
} }
errMessage
} }
log.error { errMessage }
} }
/**
* 创建扩展数据目录, 并返回 [File] 对象.
* @param extensionArtifact 扩展包构件坐标.
* @return 返回对应的数据存储目录.
*/
private fun getExtensionDataFolder(extensionArtifact: Artifact): File { private fun getExtensionDataFolder(extensionArtifact: Artifact): File {
val dataFolder = val dataFolder =
File(extensionsDataFolder, "${extensionArtifact.groupId}/${extensionArtifact.artifactId}") File(extensionsDataFolder, "${extensionArtifact.groupId}/${extensionArtifact.artifactId}")
@ -239,12 +168,6 @@ internal class ExtensionLoader(
return dataFolder return dataFolder
} }
/**
* 已加载扩展项.
* @property extensionArtifact 扩展的构件坐标([Artifact]).
* @property factoryClass 扩展的工厂类.
* @property extension 扩展实例.
*/
data class LoadedExtensionEntry( data class LoadedExtensionEntry(
val extensionArtifact: Artifact, val extensionArtifact: Artifact,
val factoryClass: Class<out BotExtensionFactory>, val factoryClass: Class<out BotExtensionFactory>,
@ -254,10 +177,6 @@ internal class ExtensionLoader(
} }
/** /**
* 扩展的类加载器清除器.
*
* 原计划是用来通过关闭 ClassLoader 来卸载扩展的, 但似乎并没有这么做.
*
* 该类为保留措施, 尚未启用. * 该类为保留措施, 尚未启用.
*/ */
internal object ExtensionClassLoaderCleaner { internal object ExtensionClassLoaderCleaner {
@ -334,7 +253,7 @@ internal interface ExtensionPackageFinder {
/** /**
* 已找到的扩展包信息. * 已找到的扩展包信息.
* 通过实现该接口, 以寻找远端文件的 [ExtensionPackageFinder]; * 通过实现该接口, 以寻找远端文件的 [ExtensionPackageFinder]
* 可以在适当的时候将扩展包下载到本地, 而无需在搜索阶段下载扩展包. * 可以在适当的时候将扩展包下载到本地, 而无需在搜索阶段下载扩展包.
*/ */
internal interface FoundExtensionPackage { internal interface FoundExtensionPackage {
@ -373,7 +292,6 @@ private fun FoundExtensionPackage.createClassLoader(): ExtensionClassLoader =
* 已找到的扩展包文件. * 已找到的扩展包文件.
* @param artifact 扩展包构件坐标. * @param artifact 扩展包构件坐标.
* @param file 已找到的扩展包文件. * @param file 已找到的扩展包文件.
* @param finder 搜索到该扩展包的搜索器.
*/ */
internal class FileFoundExtensionPackage( internal class FileFoundExtensionPackage(
private val artifact: Artifact, private val artifact: Artifact,
@ -420,14 +338,14 @@ internal class ExtensionClassLoader(urls: Array<URL>, dependencyLoader: ClassLoa
// 以免使用了不来自扩展包的机器人扩展. // 以免使用了不来自扩展包的机器人扩展.
override fun getResources(name: String?): Enumeration<URL> { override fun getResources(name: String?): Enumeration<URL> {
if ("META-INF/services/${BotExtensionFactory::class.java.name}" == name) { if (BotExtensionFactory::class.java.equals(name)) {
return findResources(name) return findResources(name)
} }
return super.getResources(name) return super.getResources(name)
} }
override fun getResource(name: String?): URL? { override fun getResource(name: String?): URL? {
if ("META-INF/services/${BotExtensionFactory::class.java}" == name) { if (BotExtensionFactory::class.java.equals(name)) {
return findResource(name) return findResource(name)
} }
return super.getResource(name) return super.getResource(name)

View File

@ -26,7 +26,6 @@ import org.jdom2.input.SAXBuilder
import org.jdom2.xpath.XPathFactory import org.jdom2.xpath.XPathFactory
import java.io.File import java.io.File
import java.io.InputStream import java.io.InputStream
import java.net.URI
import java.net.URL import java.net.URL
import java.net.URLClassLoader import java.net.URLClassLoader
import java.util.* import java.util.*
@ -258,25 +257,11 @@ internal class MavenRepositoryExtensionFinder(
} }
override fun findByArtifact(extensionArtifact: Artifact, extensionsPath: File): Set<FoundExtensionPackage> { override fun findByArtifact(extensionArtifact: Artifact, extensionsPath: File): Set<FoundExtensionPackage> {
val repositories = repositorySystem.newResolutionRepositories(repoSystemSession, remoteRepositories).toList()
log.debug {
StringBuilder().apply {
append("构件 $extensionArtifact 将在以下仓库拉取: \n")
repositories.forEach {
append("\t- $it\n")
}
}
}
val extensionArtifactResult = repositorySystem.resolveArtifact( val extensionArtifactResult = repositorySystem.resolveArtifact(
repoSystemSession, repoSystemSession,
ArtifactRequest( ArtifactRequest(extensionArtifact, remoteRepositories, null)
extensionArtifact,
repositories,
null
)
) )
val resolvedArtifact: Artifact? = extensionArtifactResult.artifact val extResolvedArtifact = extensionArtifactResult.artifact
if (!extensionArtifactResult.isResolved) { if (!extensionArtifactResult.isResolved) {
if (extensionArtifactResult.isMissing) { if (extensionArtifactResult.isMissing) {
log.warn { "在指定的仓库中找不到构件: ${extensionArtifactResult.artifact}" } log.warn { "在指定的仓库中找不到构件: ${extensionArtifactResult.artifact}" }
@ -284,26 +269,17 @@ internal class MavenRepositoryExtensionFinder(
printArtifactResultExceptions(extensionArtifactResult.exceptions) printArtifactResultExceptions(extensionArtifactResult.exceptions)
} }
return emptySet() return emptySet()
} else if (resolvedArtifact == null) {
log.warn { "无法在指定的仓库中解析构件: $extensionArtifact" }
return emptySet()
}
log.info {
"已从 Maven 仓库 `${extensionArtifactResult.repository.id}` 中找到" +
"扩展包 `${resolvedArtifact.groupId}:${resolvedArtifact.artifactId}` " +
"版本号 `${resolvedArtifact.version}`."
} }
val request = DependencyRequest( val request = DependencyRequest(
CollectRequest(Dependency(resolvedArtifact, null), repositories), CollectRequest(Dependency(extResolvedArtifact, null), remoteRepositories),
ScopeDependencyFilter(setOf("runtime", "compile", "provided"), null) ScopeDependencyFilter(setOf("runtime", "compile", "provided"), null)
) )
val dependencyResult = repositorySystem.resolveDependencies(repoSystemSession, request) val dependencyResult = repositorySystem.resolveDependencies(repoSystemSession, request)
val dependencies = checkAndCollectDependencyArtifacts(extensionArtifact, dependencyResult.artifactResults) val dependencies = checkAndCollectDependencyArtifacts(extensionArtifact, dependencyResult.artifactResults)
?: return emptySet() ?: return emptySet()
return setOf(MavenExtensionPackage(this, resolvedArtifact, extensionArtifactResult.repository, dependencies)) return setOf(MavenExtensionPackage(this, extResolvedArtifact, extensionArtifactResult.repository, dependencies))
} }
private fun checkAndCollectDependencyArtifacts( private fun checkAndCollectDependencyArtifacts(
@ -397,6 +373,7 @@ internal class MavenRepositoryExtensionFinder(
/** /**
* Maven 中央仓库 Url. * Maven 中央仓库 Url.
*/ */
@Suppress("MemberVisibilityCanBePrivate")
const val MAVEN_CENTRAL_URL = "https://repo1.maven.org/maven2/" const val MAVEN_CENTRAL_URL = "https://repo1.maven.org/maven2/"
/** /**
@ -466,19 +443,17 @@ internal class MavenRepositoryExtensionFinder(
throw IllegalArgumentException("Unsupported FoundExtensionPackage type: $foundExtensionPackage") throw IllegalArgumentException("Unsupported FoundExtensionPackage type: $foundExtensionPackage")
} }
val urls = mutableSetOf<URI>() val urls = mutableSetOf<URL>()
for (dependency in foundExtensionPackage.dependencies) { for (dependency in foundExtensionPackage.dependencies) {
val dependencyFile = dependency.file ?: continue val dependencyFile = dependency.file ?: continue
urls.add(dependencyFile.toURI()) urls.add(dependencyFile.toURI().toURL())
} }
// 将依赖的 ClassLoader 与 ExtensionPackage 的 ClassLoader 分开 // 将依赖的 ClassLoader 与 ExtensionPackage 的 ClassLoader 分开
// 这么做可以防范依赖中隐藏的 SPI 注册, 避免安全隐患. // 这么做可以防范依赖中隐藏的 SPI 注册, 避免安全隐患.
val dependenciesUrlArray = urls.toTypedArray() val dependenciesUrlArray = urls.toTypedArray()
val dependenciesClassLoader = URLClassLoader( val dependenciesClassLoader = URLClassLoader(dependenciesUrlArray)
dependenciesUrlArray.map { it.toURL() }.toTypedArray()
)
return ExtensionClassLoader( return ExtensionClassLoader(
arrayOf(foundExtensionPackage.getPackageFile().toURI().toURL()), arrayOf(foundExtensionPackage.getPackageFile().toURI().toURL()),

View File

@ -4,44 +4,43 @@ import io.prometheus.client.Counter
import io.prometheus.client.Gauge import io.prometheus.client.Gauge
import io.prometheus.client.Summary import io.prometheus.client.Summary
import mu.KotlinLogging import mu.KotlinLogging
import net.lamgc.scalabot.config.BotConfig
import org.eclipse.aether.artifact.Artifact import org.eclipse.aether.artifact.Artifact
import org.telegram.telegrambots.abilitybots.api.bot.AbilityBot import org.telegram.abilitybots.api.bot.AbilityBot
import org.telegram.telegrambots.abilitybots.api.db.DBContext import org.telegram.abilitybots.api.db.DBContext
import org.telegram.telegrambots.abilitybots.api.objects.Ability import org.telegram.abilitybots.api.objects.Ability
import org.telegram.telegrambots.abilitybots.api.toggle.BareboneToggle import org.telegram.abilitybots.api.toggle.BareboneToggle
import org.telegram.telegrambots.abilitybots.api.toggle.DefaultToggle import org.telegram.abilitybots.api.toggle.DefaultToggle
import org.telegram.telegrambots.bots.DefaultBotOptions
import org.telegram.telegrambots.meta.api.methods.commands.DeleteMyCommands import org.telegram.telegrambots.meta.api.methods.commands.DeleteMyCommands
import org.telegram.telegrambots.meta.api.methods.commands.SetMyCommands import org.telegram.telegrambots.meta.api.methods.commands.SetMyCommands
import org.telegram.telegrambots.meta.api.objects.Update import org.telegram.telegrambots.meta.api.objects.Update
import org.telegram.telegrambots.meta.api.objects.commands.BotCommand import org.telegram.telegrambots.meta.api.objects.commands.BotCommand
import org.telegram.telegrambots.meta.generics.TelegramClient
/** /**
* 可扩展 Bot. * 可扩展 Bot.
* @property creatorId 机器人所有人的 Telegram 用户 ID. 可通过联系部分机器人来获取该信息. * @property creatorId 机器人所有人的 Telegram 用户 Id. 可通过联系部分机器人来获取该信息.
* (e.g. [@userinfobot](http://t.me/userinfobot)) * (e.g. [@userinfobot](http://t.me/userinfobot))
* @param db 机器人数据库对象. 用于状态机等用途. * @param db 机器人数据库对象. 用于状态机等用途.
* @param options AbilityBot 设置对象.
* @property extensions 扩展坐标集合. * @property extensions 扩展坐标集合.
*/ */
@Suppress("CanBeParameter", "MemberVisibilityCanBePrivate")
internal class ScalaBot( internal class ScalaBot(
db: DBContext, db: DBContext,
client: TelegramClient, options: DefaultBotOptions,
extensionFinders: Set<ExtensionPackageFinder>, extensionFinders: Set<ExtensionPackageFinder>,
val botConfig: BotConfig, botConfig: BotConfig,
val accountId: Long = botConfig.account.id,
private val creatorId: Long = botConfig.account.creatorId, private val creatorId: Long = botConfig.account.creatorId,
val extensions: Set<Artifact> = botConfig.extensions val extensions: Set<Artifact> = botConfig.extensions
) : ) :
AbilityBot( AbilityBot(
client, botConfig.account.token,
botConfig.account.name, botConfig.account.name,
db, db,
if (botConfig.disableBuiltInAbility) if (botConfig.disableBuiltInAbility)
BareboneToggle() BareboneToggle()
else else
DefaultToggle() DefaultToggle(),
options
) { ) {
private val extensionLoader = ExtensionLoader( private val extensionLoader = ExtensionLoader(
@ -49,8 +48,6 @@ internal class ScalaBot(
extensionFinders = extensionFinders extensionFinders = extensionFinders
) )
private val accountIdString = accountId.toString()
init { init {
log.info { "[Bot $botUsername] 正在加载扩展..." } log.info { "[Bot $botUsername] 正在加载扩展..." }
val extensionEntries = extensionLoader.getExtensions() val extensionEntries = extensionLoader.getExtensions()
@ -66,19 +63,19 @@ internal class ScalaBot(
override fun creatorId(): Long = creatorId override fun creatorId(): Long = creatorId
override fun consume(update: Update?) { override fun onUpdateReceived(update: Update?) {
botUpdateCounter.labels(botUsername, accountIdString).inc() botUpdateCounter.labels(botUsername).inc()
botUpdateGauge.labels(botUsername, accountIdString).inc() botUpdateGauge.labels(botUsername).inc()
val timer = updateProcessTime.labels(botUsername, accountIdString).startTimer() val timer = updateProcessTime.labels(botUsername).startTimer()
try { try {
super.consume(update) super.onUpdateReceived(update)
} catch (e: Exception) { } catch (e: Exception) {
exceptionHandlingCounter.labels(botUsername, accountIdString).inc() exceptionHandlingCounter.labels(botUsername).inc()
throw e throw e
} finally { } finally {
timer.observeDuration() timer.observeDuration()
botUpdateGauge.labels(botUsername, accountIdString).dec() botUpdateGauge.labels(botUsername).dec()
} }
} }
@ -91,11 +88,11 @@ internal class ScalaBot(
* @return 更新成功返回 `true`. * @return 更新成功返回 `true`.
*/ */
fun updateCommandList(): Boolean { fun updateCommandList(): Boolean {
if (abilities == null) { if (abilities() == null) {
throw IllegalStateException("Abilities has not been initialized.") throw IllegalStateException("Abilities has not been initialized.")
} }
val botCommands = abilities.values.map { val botCommands = abilities().values.map {
val abilityInfo = if (it.info() == null || it.info().trim().isEmpty()) { val abilityInfo = if (it.info() == null || it.info().trim().isEmpty()) {
log.warn { "[Bot $botUsername] Ability `${it.name()}` 没有说明信息." } log.warn { "[Bot $botUsername] Ability `${it.name()}` 没有说明信息." }
"(The command has no description)" "(The command has no description)"
@ -105,16 +102,9 @@ internal class ScalaBot(
} }
BotCommand(it.name(), abilityInfo) BotCommand(it.name(), abilityInfo)
} }
val setMyCommands = SetMyCommands()
if (botCommands.isEmpty()) { setMyCommands.commands = botCommands
log.info { "Bot 没有任何命令, 命令列表更新已跳过." } return execute(DeleteMyCommands()) && execute(setMyCommands)
return true
}
val setMyCommands = SetMyCommands.builder()
.commands(botCommands)
.build()
return telegramClient.execute(DeleteMyCommands()) && telegramClient.execute(setMyCommands)
} }
override fun onRegister() { override fun onRegister() {
@ -122,6 +112,11 @@ internal class ScalaBot(
onlineBotGauge.inc() onlineBotGauge.inc()
} }
override fun onClosing() {
super.onClosing()
onlineBotGauge.dec()
}
companion object { companion object {
@JvmStatic @JvmStatic
private val log = KotlinLogging.logger { } private val log = KotlinLogging.logger { }
@ -132,8 +127,7 @@ internal class ScalaBot(
private val botUpdateCounter = Counter.build() private val botUpdateCounter = Counter.build()
.name("updates_total") .name("updates_total")
.help("Total number of updates received by all bots.") .help("Total number of updates received by all bots.")
.labelNames("bot_name", "bot_id") .labelNames("bot_name")
.namespace(Const.METRICS_NAMESPACE)
.subsystem("telegrambots") .subsystem("telegrambots")
.register() .register()
@ -141,8 +135,7 @@ internal class ScalaBot(
private val botUpdateGauge = Gauge.build() private val botUpdateGauge = Gauge.build()
.name("updates_in_progress") .name("updates_in_progress")
.help("Number of updates in process by all bots.") .help("Number of updates in process by all bots.")
.labelNames("bot_name", "bot_id") .labelNames("bot_name")
.namespace(Const.METRICS_NAMESPACE)
.subsystem("telegrambots") .subsystem("telegrambots")
.register() .register()
@ -150,7 +143,6 @@ internal class ScalaBot(
private val onlineBotGauge = Gauge.build() private val onlineBotGauge = Gauge.build()
.name("bots_online") .name("bots_online")
.help("Number of bots Online.") .help("Number of bots Online.")
.namespace(Const.METRICS_NAMESPACE)
.subsystem("telegrambots") .subsystem("telegrambots")
.register() .register()
@ -158,12 +150,11 @@ internal class ScalaBot(
private val updateProcessTime = Summary.build() private val updateProcessTime = Summary.build()
.name("update_process_duration_seconds") .name("update_process_duration_seconds")
.help( .help(
"Time to process update. (This indicator includes the pre-processing of update by TelegramBots, " + "Time to process update. (This indicator includes the pre-processing of update by TelegrammBots, " +
"so it may be different from the actual execution time of ability. " + "so it may be different from the actual execution time of ability. " +
"It is not recommended to use it as the accurate execution time of ability)" "It is not recommended to use it as the accurate execution time of ability)"
) )
.labelNames("bot_name", "bot_id") .labelNames("bot_name")
.namespace(Const.METRICS_NAMESPACE)
.subsystem("telegrambots") .subsystem("telegrambots")
.register() .register()
@ -171,8 +162,7 @@ internal class ScalaBot(
private val exceptionHandlingCounter = Counter.build() private val exceptionHandlingCounter = Counter.build()
.name("updates_exception_handling") .name("updates_exception_handling")
.help("Number of exceptions during processing.") .help("Number of exceptions during processing.")
.labelNames("bot_name", "bot_id") .labelNames("bot_name")
.namespace(Const.METRICS_NAMESPACE)
.subsystem("telegrambots") .subsystem("telegrambots")
.register() .register()
} }

View File

@ -0,0 +1,165 @@
package net.lamgc.scalabot.util
import com.google.gson.*
import mu.KotlinLogging
import net.lamgc.scalabot.MavenRepositoryConfig
import org.eclipse.aether.artifact.Artifact
import org.eclipse.aether.artifact.DefaultArtifact
import org.eclipse.aether.repository.Authentication
import org.eclipse.aether.repository.Proxy
import org.eclipse.aether.util.repository.AuthenticationBuilder
import org.telegram.telegrambots.bots.DefaultBotOptions
import java.lang.reflect.Type
import java.net.URL
internal object ProxyTypeSerializer : JsonDeserializer<DefaultBotOptions.ProxyType>,
JsonSerializer<DefaultBotOptions.ProxyType> {
override fun deserialize(
json: JsonElement,
typeOfT: Type?,
context: JsonDeserializationContext?
): DefaultBotOptions.ProxyType {
if (!json.isJsonPrimitive) {
throw JsonParseException("Wrong configuration value type.")
}
val value = json.asString.trim()
try {
return DefaultBotOptions.ProxyType.valueOf(value.uppercase())
} catch (e: IllegalArgumentException) {
throw JsonParseException("Invalid value: $value")
}
}
override fun serialize(
src: DefaultBotOptions.ProxyType,
typeOfSrc: Type?,
context: JsonSerializationContext?
): JsonElement {
return JsonPrimitive(src.toString())
}
}
internal object ArtifactSerializer : JsonSerializer<Artifact>, JsonDeserializer<Artifact> {
override fun serialize(src: Artifact, typeOfSrc: Type?, context: JsonSerializationContext?): JsonElement {
val gavBuilder = StringBuilder("${src.groupId}:${src.artifactId}")
if (!src.extension.equals("jar")) {
gavBuilder.append(':').append(src.extension)
}
if (src.classifier.isNotEmpty()) {
gavBuilder.append(':').append(src.classifier)
}
return JsonPrimitive(gavBuilder.append(':').append(src.version).toString())
}
override fun deserialize(json: JsonElement?, typeOfT: Type?, context: JsonDeserializationContext?): Artifact {
if (!json!!.isJsonPrimitive) {
throw JsonParseException("Wrong configuration value type.")
}
return DefaultArtifact(json.asString.trim())
}
}
internal object AuthenticationSerializer : JsonDeserializer<Authentication> {
private val log = KotlinLogging.logger { }
override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): Authentication? {
val builder = AuthenticationBuilder()
when (json) {
is JsonArray -> {
for (element in json) {
if (element is JsonArray) {
builder.addCustom(jsonArrayToAuthentication(element))
} else if (element is JsonObject) {
jsonToAuthentication(element, builder)
}
}
}
is JsonObject -> {
jsonToAuthentication(json, builder)
}
else -> {
throw JsonParseException("Unsupported JSON data type: ${json::class.java}")
}
}
return builder.build()
}
private fun jsonArrayToAuthentication(jsonArray: JsonArray): Authentication {
val builder = AuthenticationBuilder()
for (element in jsonArray) {
when (element) {
is JsonObject -> jsonToAuthentication(element, builder)
is JsonArray -> builder.addCustom(jsonArrayToAuthentication(element))
else -> log.warn { "不支持的 Json 类型: ${element::class.java}" }
}
}
return builder.build()
}
private const val KEY_TYPE = "type"
private fun jsonToAuthentication(json: JsonObject, builder: AuthenticationBuilder) {
if (!json.has(KEY_TYPE)) {
log.warn { "缺少 type 字段, 无法判断 Maven 认证信息类型." }
return
} else if (!json.get(KEY_TYPE).isJsonPrimitive) {
log.warn { "type 字段类型错误(应为 Primitive 类型), 无法判断 Maven 认证信息类型.(实际类型: `${json::class.java}`)" }
return
}
when (json.get(KEY_TYPE).asString.trim().lowercase()) {
"string" -> {
builder.addString(checkJsonKey(json, "key"), checkJsonKey(json, "value"))
}
"secret" -> {
builder.addSecret(checkJsonKey(json, "key"), checkJsonKey(json, "value"))
}
}
}
}
private fun checkJsonKey(json: JsonObject, key: String): String {
if (!json.has(key)) {
throw JsonParseException("Required field does not exist: $key")
} else if (!json.get(key).isJsonPrimitive) {
throw JsonParseException("Wrong field `$key` type: ${json.get(key)::class.java}")
}
return json.get(key).asString
}
internal object MavenRepositoryConfigSerializer
: JsonDeserializer<MavenRepositoryConfig> {
override fun deserialize(
json: JsonElement,
typeOfT: Type,
context: JsonDeserializationContext
): MavenRepositoryConfig {
return when (json) {
is JsonObject -> {
MavenRepositoryConfig(
url = URL(checkJsonKey(json, "url")),
proxy = if (json.has("proxy") && json.get("proxy").isJsonObject)
context.deserialize<Proxy>(
json.getAsJsonObject("proxy"), Proxy::class.java
) else null,
layout = json.get("layout").asString ?: "default",
authentication = if (json.has("authentication") && json.get("authentication").isJsonObject)
context.deserialize<Authentication>(
json.getAsJsonObject("authentication"), Authentication::class.java
) else null
)
}
is JsonPrimitive -> {
MavenRepositoryConfig(URL(json.asString))
}
else -> {
throw JsonParseException("Unsupported Maven warehouse configuration type.")
}
}
}
}

View File

@ -1,29 +0,0 @@
package net.lamgc.scalabot.util
import java.util.regex.Matcher
import java.util.regex.Pattern
object TelegramBotAccounts {
private val botTokenPattern: Pattern = Pattern.compile("([1-9]\\d+):([A-Za-z\\d_-]{35,})")
/**
* 获取 AbilityBot 的账户 Id.
*
*
* 账户 Id 来自于 botToken , token 的格式为 "{AccountId}:{Secret}".
*
* 账户 Id 的真实性与 botToken 的有效性有关, 本方法并不会确保 botToken 的有效性, 一般情况下也无需考虑 Id 的有效性,
* 如果有需要, 可尝试通过调用 [org.telegram.telegrambots.meta.api.methods.GetMe] 来确保 botToken 的有效性.
*
* @param botToken 要获取账户 Id botToken 字符串.
* @return 返回 AbilityBot 的账户 Id.
* @throws IllegalArgumentException AbilityBot botToken 格式错误时抛出该异常.
*/
fun getBotAccountId(botToken: String): Long {
val matcher: Matcher = botTokenPattern.matcher(botToken)
require(matcher.matches()) { "Invalid token format." }
return matcher.group(1).toLong()
}
}

View File

@ -7,17 +7,20 @@ import org.eclipse.aether.artifact.Artifact
import java.io.File import java.io.File
import java.io.FileFilter import java.io.FileFilter
import java.io.FilenameFilter import java.io.FilenameFilter
import java.net.URL
internal fun ByteArray.toHexString(): String = joinToString("") { it.toString(16) } internal fun ByteArray.toHaxString(): String = ByteUtils.bytesToHexString(this)
internal fun Artifact.equalsArtifact(that: Artifact, checkProperties: Boolean = false): Boolean = internal fun Artifact.equalsArtifact(that: Artifact): Boolean =
this.groupId.equals(that.groupId) && this.groupId.equals(that.groupId) &&
this.artifactId.equals(that.artifactId) && this.artifactId.equals(that.artifactId) &&
this.version.equals(that.version) && this.version.equals(that.version) &&
this.baseVersion.equals(that.baseVersion) &&
this.isSnapshot == that.isSnapshot &&
this.classifier.equals(that.classifier) && this.classifier.equals(that.classifier) &&
this.extension.equals(that.extension) && this.extension.equals(that.extension) &&
(if (this.file == null) that.file == null else this.file.equals(that.file)) && (if (this.file == null) that.file == null else this.file.equals(that.file)) &&
(!checkProperties || this.properties.equals(that.properties)) this.properties.equals(that.properties)
internal fun File.deepListFiles( internal fun File.deepListFiles(
addSelf: Boolean = false, addSelf: Boolean = false,
@ -25,13 +28,17 @@ internal fun File.deepListFiles(
fileFilter: FileFilter? = null, fileFilter: FileFilter? = null,
filenameFilter: FilenameFilter? = null filenameFilter: FilenameFilter? = null
): Array<File>? { ): Array<File>? {
val files = (if (fileFilter != null) { val files = if (fileFilter != null) {
this.listFiles(fileFilter) this.listFiles(fileFilter)
} else if (filenameFilter != null) { } else if (filenameFilter != null) {
this.listFiles(filenameFilter) this.listFiles(filenameFilter)
} else { } else {
this.listFiles() this.listFiles()
}) ?: return null }
if (files == null) {
return null
}
val result = if (addSelf) mutableSetOf(this) else mutableSetOf() val result = if (addSelf) mutableSetOf(this) else mutableSetOf()
for (file in files) { for (file in files) {
@ -41,8 +48,10 @@ internal fun File.deepListFiles(
if (!onlyFile) { if (!onlyFile) {
result.add(file) result.add(file)
} }
val subFiles = file.deepListFiles(false, onlyFile, fileFilter, filenameFilter) ?: continue val subFiles = file.deepListFiles(false, onlyFile, fileFilter, filenameFilter)
result.addAll(subFiles) if (subFiles != null) {
result.addAll(subFiles)
}
} }
} }
return result.toTypedArray() return result.toTypedArray()
@ -53,14 +62,9 @@ internal fun File.deepListFiles(
* @return 获取 Finder 的优先级. * @return 获取 Finder 的优先级.
* @throws NoSuchFieldException 如果 Finder 没有添加 [FinderRules] 注解时抛出该异常. * @throws NoSuchFieldException 如果 Finder 没有添加 [FinderRules] 注解时抛出该异常.
*/ */
internal fun ExtensionPackageFinder.getPriority(): Int { internal fun ExtensionPackageFinder.getPriority() =
val value = this::class.java.getDeclaredAnnotation(FinderRules::class.java)?.priority this::class.java.getDeclaredAnnotation(FinderRules::class.java)?.priority
?: throw NoSuchFieldException("Finder did not add `FinderRules` annotation") ?: throw NoSuchFieldException("Finder did not add `FinderRules` annotation")
if (value < 0) {
throw IllegalArgumentException("Priority cannot be lower than 0. (Class: ${this::class.java})")
}
return value
}
/** /**
* [AutoCloseable] 对象注册 Jvm Shutdown 钩子. * [AutoCloseable] 对象注册 Jvm Shutdown 钩子.
@ -71,25 +75,37 @@ fun <T : AutoCloseable> T.registerShutdownHook(): T {
return this return this
} }
private val log = KotlinLogging.logger { }
private object UtilsInternal { private object UtilsInternal {
val autoCloseableSet = mutableSetOf<AutoCloseable>() val autoCloseableSet = mutableSetOf<AutoCloseable>()
private val log = KotlinLogging.logger(UtilsInternal::class.java.name)
init { init {
Runtime.getRuntime().addShutdownHook(Thread(this::doCloseResources, "Shutdown-AutoCloseable")) Runtime.getRuntime().addShutdownHook(Thread({
} log.debug { "Closing registered hook resources..." }
autoCloseableSet.forEach {
fun doCloseResources() { try {
log.debug { "Closing registered hook resources..." } it.close()
autoCloseableSet.removeIf { } catch (e: Exception) {
try { log.error(e) { "An exception occurred while closing the resource. (Resource: `$it`)" }
it.close() }
} catch (e: Exception) {
log.error(e) { "An exception occurred while closing the resource. (Resource: `$it`)" }
} }
true log.debug { "All registered hook resources have been closed." }
} }, "Shutdown-AutoCloseable"))
log.debug { "All registered hook resources have been closed." } }
}
fun URL.resolveToFile(canonical: Boolean = true): File {
if ("file" != protocol) {
throw ClassCastException("Only the URL of the `file` protocol can be converted into a File object.")
}
val urlString = toString().substringAfter(':')
val file = File(urlString)
return if (canonical) {
file.canonicalFile
} else {
file
} }
} }

View File

@ -1,8 +1,6 @@
<?xml version="1.0" encoding="UTF-8" ?> <?xml version="1.0" encoding="UTF-8" ?>
<included> <included>
<define name="DATA_LOGS" class="net.lamgc.scalabot.LogDirectorySupplier"/> <define name="DATA_LOGS" class="net.lamgc.scalabot.LogDirectorySupplier"/>
<define name="LOG_LEVEL" class="net.lamgc.scalabot.LogLevelSupplier"/>
<define name="NETWORK_LOG_LEVEL" class="net.lamgc.scalabot.NetworkVerboseLogSupplier"/>
<appender name="STD_OUT" class="ch.qos.logback.core.ConsoleAppender"> <appender name="STD_OUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder> <encoder>
@ -26,7 +24,7 @@
<appender name="FILE_OUT" class="ch.qos.logback.core.rolling.RollingFileAppender"> <appender name="FILE_OUT" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${DATA_LOGS}/latest.log</file> <file>${DATA_LOGS}/latest.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${DATA_LOGS}/%d{yyyy-MM-dd}.log.gz</fileNamePattern> <fileNamePattern>data/logs/%d{yyyy-MM-dd}.log.gz</fileNamePattern>
<maxHistory>30</maxHistory> <maxHistory>30</maxHistory>
</rollingPolicy> </rollingPolicy>
<encoder> <encoder>

View File

@ -0,0 +1,14 @@
<configuration scan="false" debug="false">
<include resource="base-logback.xml"/>
<logger name="org.apache.http" level="INFO"/>
<logger name="org.eclipse.aether.internal.impl.DefaultTransporterProvider" level="INFO"/>
<logger name="org.eclipse.aether.internal.impl.DefaultRepositoryConnectorProvider" level="INFO"/>
<logger name="org.eclipse.aether.internal.impl.EnhancedLocalRepositoryManager" level="INFO"/>
<root level="DEBUG">
<appender-ref ref="FILE_OUT"/>
<appender-ref ref="STD_ERR"/>
<appender-ref ref="STD_OUT"/>
</root>
</configuration>

View File

@ -1,14 +1,7 @@
<configuration scan="false" debug="false"> <configuration scan="false" debug="false">
<include resource="base-logback.xml"/> <include resource="base-logback.xml"/>
<logger name="org.apache.http" level="${NETWORK_LOG_LEVEL}"/> <root level="INFO">
<logger name="org.eclipse.aether.internal.impl.DefaultTransporterProvider" level="${NETWORK_LOG_LEVEL}"/>
<logger name="org.eclipse.aether.internal.impl.DefaultRepositoryConnectorProvider" level="${NETWORK_LOG_LEVEL}"/>
<logger name="org.eclipse.aether.internal.impl.EnhancedLocalRepositoryManager" level="${NETWORK_LOG_LEVEL}"/>
<logger name="org.telegram.telegrambots.facilities.proxysocketfactorys" level="${NETWORK_LOG_LEVEL}"/>
<logger name="org.eclipse.aether.internal.impl.DefaultUpdateCheckManager" level="${NETWORK_LOG_LEVEL}"/>
<root level="${LOG_LEVEL}">
<appender-ref ref="FILE_OUT"/> <appender-ref ref="FILE_OUT"/>
<appender-ref ref="STD_ERR"/> <appender-ref ref="STD_ERR"/>
<appender-ref ref="STD_OUT"/> <appender-ref ref="STD_OUT"/>

View File

@ -1,416 +0,0 @@
package net.lamgc.scalabot
import com.github.stefanbirkner.systemlambda.SystemLambda
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import mu.KotlinLogging
import net.lamgc.scalabot.config.MavenRepositoryConfig
import net.lamgc.scalabot.config.ProxyConfig
import net.lamgc.scalabot.config.ProxyType
import org.junit.jupiter.api.assertThrows
import org.junit.jupiter.api.io.TempDir
import java.io.File
import java.io.IOException
import java.net.Proxy
import java.net.URL
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.deleteExisting
import kotlin.test.*
internal class AppPathsTest {
@Test
fun `Consistency check`() {
for (path in AppPaths.entries) {
assertEquals(
File(path.path).canonicalPath,
path.file.canonicalPath,
"路径 File 与 Path 不一致: ${path.name}"
)
}
}
@Test
fun `Data root path priority`() {
System.setProperty(AppPaths.PathConst.PROP_DATA_PATH, "fromSystemProperties")
assertEquals("fromSystemProperties", AppPaths.DATA_ROOT.file.path, "`DATA_ROOT`没有优先返回 Property 的值.")
System.getProperties().remove(AppPaths.PathConst.PROP_DATA_PATH)
val expectEnvValue = "fromEnvironmentVariable"
SystemLambda.withEnvironmentVariable(AppPaths.PathConst.ENV_DATA_PATH, expectEnvValue).execute {
assertEquals(
expectEnvValue, AppPaths.DATA_ROOT.file.path,
"`DATA_ROOT`没有优先返回 env 的值."
)
}
SystemLambda.withEnvironmentVariable(AppPaths.PathConst.ENV_DATA_PATH, null).execute {
assertEquals(
System.getProperty("user.dir"), AppPaths.DATA_ROOT.file.path,
"`DATA_ROOT`没有返回 System.properties `user.dir` 的值."
)
val userDir = System.getProperty("user.dir")
System.getProperties().remove("user.dir")
assertEquals(".", AppPaths.DATA_ROOT.file.path, "`DATA_ROOT`没有返回替补值 `.`(当前目录).")
System.setProperty("user.dir", userDir)
assertNotNull(System.getProperty("user.dir"), "环境还原失败!")
}
}
@Test
fun `default initializer`(@TempDir testDir: File) {
val defaultInitializerMethod = Class.forName("net.lamgc.scalabot.AppConfigsKt")
.getDeclaredMethod("defaultInitializer", AppPaths::class.java)
.apply { isAccessible = true }
val dirPath = "${testDir.canonicalPath}/directory/"
val dirFile = File(dirPath)
mockk<AppPaths> {
every { file }.returns(File(dirPath))
every { path }.returns(dirPath)
every { initial() }.answers {
defaultInitializerMethod.invoke(null, this@mockk)
}
}.initial()
assertTrue(dirFile.exists() && dirFile.isDirectory, "默认初始器未正常初始化【文件夹】.")
File(testDir, "test.txt").apply {
mockk<AppPaths> {
every { file }.returns(this@apply)
every { path }.returns(this@apply.canonicalPath)
every { initial() }.answers {
defaultInitializerMethod.invoke(null, this@mockk)
}
}.initial()
assertTrue(this@apply.exists() && this@apply.isFile, "默认初始器未正常初始化【文件】.")
}
val alreadyExistsFile = File("${testDir.canonicalPath}/alreadyExists.txt").apply {
if (!exists()) {
createNewFile()
}
}
assertTrue(alreadyExistsFile.exists(), "文件状态与预期不符.")
mockk<File> {
every { exists() }.returns(true)
every { canonicalPath }.answers { alreadyExistsFile.canonicalPath }
every { createNewFile() }.answers { alreadyExistsFile.createNewFile() }
every { mkdirs() }.answers { alreadyExistsFile.mkdirs() }
every { mkdir() }.answers { alreadyExistsFile.mkdir() }
}.apply {
mockk<AppPaths> {
every { file }.returns(this@apply)
every { path }.returns(this@apply.canonicalPath)
every { initial() }.answers {
defaultInitializerMethod.invoke(null, this@mockk)
}
}.initial()
verify(exactly = 0) { createNewFile() }
verify(exactly = 0) { mkdir() }
verify(exactly = 0) { mkdirs() }
}
mockk<File> {
every { exists() }.returns(false)
every { canonicalPath }.answers { alreadyExistsFile.canonicalPath }
every { createNewFile() }.answers { false }
every { mkdirs() }.answers { false }
every { mkdir() }.answers { false }
}.apply {
mockk<AppPaths> {
every { file }.returns(this@apply)
every { path }.returns(this@apply.canonicalPath)
every { initial() }.answers {
defaultInitializerMethod.invoke(null, this@mockk)
}
}.initial()
verify(exactly = 1) { createNewFile() }
verify(exactly = 0) { mkdir() }
verify(exactly = 0) { mkdirs() }
}
defaultInitializerMethod.isAccessible = false
}
@Test
fun `loadBotConfig test`(@TempDir testDir: File) {
assertNull(loadBotConfigJson(File("/NOT_EXISTS_FILE")), "加载 BotConfigs 失败时应该返回 null.")
SystemLambda.withEnvironmentVariable(AppPaths.PathConst.ENV_DATA_PATH, testDir.canonicalPath).execute {
assertNull(loadBotConfigJson(), "加载 BotConfigs 失败时应该返回 null.")
File(testDir, "bot.json").apply {
//language=JSON5
writeText(
"""
[
{
"enabled": false,
"account": {
"name": "TestBot",
"token": "123456789:AAHErDroUTznQsOd_oZPJ6cQEj4Z5mGHO10",
"creatorId": 123456789
},
"proxy": {
"host": "localhost",
"port": 8080,
"type": "HTTP"
},
"disableBuiltInAbility": false,
"autoUpdateCommandList": true,
"extensions": [
"org.example.test:test-extension:1.0.0"
],
"baseApiUrl": "http://localhost:8080"
}
]
""".trimIndent()
)
}
val botConfigJsons = loadBotConfigJson()
assertNotNull(botConfigJsons)
assertEquals(1, botConfigJsons.size())
}
}
@Test
fun `loadAppConfig test`(@TempDir testDir: File) {
assertThrows<IOException>("加载失败时应该抛出 IOException.") {
loadAppConfig(File("/NOT_EXISTS_FILE"))
}
SystemLambda.withEnvironmentVariable(AppPaths.PathConst.ENV_DATA_PATH, testDir.canonicalPath).execute {
assertThrows<IOException>("加载失败时应该抛出 IOException.") {
loadAppConfig()
}
File(testDir, "config.json").apply {
//language=JSON5
writeText(
"""
{
"proxy": {
"type": "HTTP",
"host": "localhost",
"port": 8080
},
"metrics": {
"enable": true,
"port": 8800,
"bindAddress": "127.0.0.1",
"authenticator": {
"username": "username",
"password": "password"
}
},
"mavenRepositories": [
{
"url": "https://repository.maven.apache.org/maven2/"
}
],
"mavenLocalRepository": "file:///tmp/maven-local-repository"
}
""".trimIndent()
)
}
val appConfigs = loadAppConfig()
assertNotNull(appConfigs)
}
}
@Test
fun `ProxyType_toTelegramBotsType test`() {
val expectTypeMapping = mapOf(
ProxyType.NO_PROXY to null,
ProxyType.SOCKS5 to Proxy.Type.SOCKS,
ProxyType.SOCKS4 to Proxy.Type.SOCKS,
ProxyType.HTTP to Proxy.Type.HTTP,
ProxyType.HTTPS to Proxy.Type.HTTP
)
for (proxyType in ProxyType.entries) {
assertEquals(
expectTypeMapping[proxyType],
proxyType.toJavaProxyType(),
"ProxyType 转换失败."
)
}
}
@Test
fun `ProxyConfig_toAetherProxy test`() {
val host = "proxy.example.org"
val port = 1080
val expectNotNullProxyType = setOf(
ProxyType.HTTP,
ProxyType.HTTPS
)
for (proxyType in ProxyType.entries) {
val proxyConfig = ProxyConfig(proxyType, host, port)
val aetherProxy = proxyConfig.toAetherProxy()
if (expectNotNullProxyType.contains(proxyType)) {
assertNotNull(aetherProxy, "支持的代理类型应该不为 null.")
assertEquals(host, aetherProxy.host)
assertEquals(port, aetherProxy.port)
} else {
assertNull(aetherProxy, "不支持的代理类型应该返回 null.")
}
}
}
@Test
fun `MavenRepositoryConfig_toRemoteRepository test`() {
val defaultMavenRepositoryConfig = MavenRepositoryConfig(
url = URL(MavenRepositoryExtensionFinder.MAVEN_CENTRAL_URL),
enableReleases = true,
enableSnapshots = false
)
val remoteRepositoryWithoutId = defaultMavenRepositoryConfig.toRemoteRepository(
ProxyConfig(ProxyType.NO_PROXY, "", 0)
)
assertEquals(MavenRepositoryExtensionFinder.MAVEN_CENTRAL_URL, remoteRepositoryWithoutId.url.toString())
assertNotNull(remoteRepositoryWithoutId.id)
assertTrue(remoteRepositoryWithoutId.getPolicy(false).isEnabled)
assertFalse(remoteRepositoryWithoutId.getPolicy(true).isEnabled)
val remoteRepositoryWithId = defaultMavenRepositoryConfig.copy(id = "test-repo").toRemoteRepository(
ProxyConfig(ProxyType.HTTP, "127.0.0.1", 1080)
)
assertEquals("test-repo", remoteRepositoryWithId.id)
assertEquals(MavenRepositoryExtensionFinder.MAVEN_CENTRAL_URL, remoteRepositoryWithId.url.toString())
assertEquals("http", remoteRepositoryWithId.proxy.type)
assertEquals("127.0.0.1", remoteRepositoryWithId.proxy.host)
assertEquals(1080, remoteRepositoryWithId.proxy.port)
assertEquals(remoteRepositoryWithId.id, remoteRepositoryWithId.id)
val remoteRepositoryWithProxy = defaultMavenRepositoryConfig.copy(
id = "test-repo",
proxy = ProxyConfig(ProxyType.HTTP, "example.org", 1080).toAetherProxy()
).toRemoteRepository(ProxyConfig(ProxyType.HTTP, "localhost", 8080))
assertEquals("http", remoteRepositoryWithProxy.proxy.type)
assertEquals("example.org", remoteRepositoryWithProxy.proxy.host, "未优先使用 MavenRepositoryConfig 中的 proxy 属性.")
assertEquals(1080, remoteRepositoryWithProxy.proxy.port, "未优先使用 MavenRepositoryConfig 中的 proxy 属性.")
}
@Test
fun `checkRepositoryLayout test`() {
val noProxyConfig = ProxyConfig(ProxyType.NO_PROXY, "", 0)
assertEquals(
"default", MavenRepositoryConfig(url = URL("https://repo.example.org"))
.toRemoteRepository(noProxyConfig).contentType
)
assertEquals(
"legacy", MavenRepositoryConfig(url = URL("https://repo.example.org"), layout = "LEgaCY")
.toRemoteRepository(noProxyConfig).contentType
)
assertThrows<IllegalArgumentException> {
MavenRepositoryConfig(
url = URL("https://repo.example.org"),
layout = "NOT_EXISTS_LAYOUT"
).toRemoteRepository(noProxyConfig)
}
}
@Test
fun `initialFiles test`(@TempDir testDir: Path) {
// 这么做是为了让日志文件创建在其他地方, 由于日志文件在运行时会持续占用, 在 windows 中文件会被锁定,
// 导致测试框架无法正常清除测试所使用的临时文件夹.
val logsDir = Files.createTempDirectory("ammmmmm-logs-")
System.setProperty(AppPaths.PathConst.PROP_DATA_PATH, logsDir.toString())
assertEquals(logsDir.toString(), AppPaths.DATA_ROOT.path, "日志目录设定失败.")
KotlinLogging.logger("TEST").error { "日志占用.(无需理会), 日志目录: $logsDir" }
AppPaths.DATA_LOGS.file.listFiles { _, name -> name.endsWith(".log") }?.forEach {
it.deleteOnExit()
}
val fullInitializeDir = Files.createTempDirectory(testDir, "fullInitialize")
fullInitializeDir.deleteExisting()
System.setProperty(AppPaths.PathConst.PROP_DATA_PATH, fullInitializeDir.toString())
assertEquals(fullInitializeDir.toString(), AppPaths.DATA_ROOT.path, "测试路径设定失败.")
assertTrue(initialFiles(), "方法未能提醒用户编辑初始配置文件.")
for (path in AppPaths.entries) {
assertTrue(path.file.exists(), "文件未初始化成功: ${path.path}")
if (path.file.isFile) {
assertNotEquals(0, path.file.length(), "文件未初始化成功(大小为 0): ${path.path}")
}
path.reset()
}
assertFalse(initialFiles(), "方法试图在配置已初始化的情况下提醒用户编辑初始配置文件.")
for (path in AppPaths.entries) {
assertTrue(path.file.exists(), "文件未初始化成功: ${path.path}")
if (path.file.isFile) {
assertNotEquals(0, path.file.length(), "文件未初始化成功(大小为 0): ${path.path}")
}
path.reset()
}
assertTrue(AppPaths.CONFIG_APPLICATION.file.delete(), "config.json 删除失败.")
assertFalse(initialFiles(), "方法试图在部分配置已初始化的情况下提醒用户编辑初始配置文件.")
for (path in AppPaths.entries) {
assertTrue(path.file.exists(), "文件未初始化成功: ${path.path}")
if (path.file.isFile) {
assertNotEquals(0, path.file.length(), "文件未初始化成功(大小为 0): ${path.path}")
}
path.reset()
}
assertTrue(AppPaths.CONFIG_BOT.file.delete(), "bot.json 删除失败.")
assertFalse(initialFiles(), "方法试图在部分配置已初始化的情况下提醒用户编辑初始配置文件.")
for (path in AppPaths.entries) {
assertTrue(path.file.exists(), "文件未初始化成功: ${path.path}")
if (path.file.isFile) {
assertNotEquals(0, path.file.length(), "文件未初始化成功(大小为 0): ${path.path}")
}
path.reset()
}
assertTrue(AppPaths.CONFIG_APPLICATION.file.delete(), "config.json 删除失败.")
assertTrue(AppPaths.CONFIG_BOT.file.delete(), "bot.json 删除失败.")
assertTrue(
initialFiles(),
"在主要配置文件(config.json 和 bot.json)不存在的情况下初始化文件后, 方法未能提醒用户编辑初始配置文件."
)
for (path in AppPaths.entries) {
assertTrue(path.file.exists(), "文件未初始化成功: ${path.path}")
if (path.file.isFile) {
assertNotEquals(0, path.file.length(), "文件未初始化成功(大小为 0): ${path.path}")
}
}
AppPaths.CONFIG_APPLICATION.file.writeText("Test-APPLICATION")
AppPaths.CONFIG_BOT.file.writeText("Test-BOT")
assertFalse(initialFiles(), "方法试图在部分配置已初始化的情况下提醒用户编辑初始配置文件.")
assertEquals(
"Test-APPLICATION", AppPaths.CONFIG_APPLICATION.file.readText(),
"config.json 被覆盖. initialized 并未阻止重复初始化."
)
assertEquals(
"Test-BOT", AppPaths.CONFIG_BOT.file.readText(),
"bot.json 被覆盖. initialized 并未阻止重复初始化."
)
System.getProperties().remove(AppPaths.PathConst.PROP_DATA_PATH)
}
private fun AppPaths.reset() {
val method = AppPaths::class.java.getDeclaredMethod("reset")
method.isAccessible = true
method.invoke(this)
method.isAccessible = false
}
}

View File

@ -0,0 +1,35 @@
@file:Suppress("PackageDirectoryMismatch")
package net.lamgc.scalabot.util
import com.google.gson.JsonObject
import com.google.gson.JsonParseException
import com.google.gson.JsonPrimitive
import org.eclipse.aether.artifact.DefaultArtifact
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
internal class ArtifactSerializerTest {
@Test
fun badJsonType() {
assertFailsWith<JsonParseException> { ArtifactSerializer.deserialize(JsonObject(), null, null) }
}
@Test
fun serialize() {
val gav = "org.example.software:test:1.0.0-SNAPSHOT"
val expectArtifact = DefaultArtifact(gav)
val actualArtifact = DefaultArtifact(ArtifactSerializer.serialize(expectArtifact, null, null).asString)
assertEquals(expectArtifact, actualArtifact)
}
@Test
fun deserialize() {
val gav = "org.example.software:test:1.0.0-SNAPSHOT"
val expectArtifact = DefaultArtifact(gav)
val actualArtifact = ArtifactSerializer.deserialize(JsonPrimitive(gav), null, null)
assertEquals(expectArtifact, actualArtifact)
}
}

View File

@ -1,40 +0,0 @@
package net.lamgc.scalabot.util
import ch.qos.logback.classic.Level
import ch.qos.logback.classic.spi.LoggingEvent
import ch.qos.logback.core.spi.FilterReply
import io.mockk.every
import io.mockk.mockk
import kotlin.test.Test
import kotlin.test.assertEquals
class StdOutFilterTest {
@Test
fun filterTest() {
val filter = StdOutFilter()
for (level in listOf(
Level.ALL,
Level.TRACE,
Level.DEBUG,
Level.INFO
)) {
val loggingEvent = mockk<LoggingEvent> {
every { this@mockk.level }.returns(level)
}
assertEquals(FilterReply.ACCEPT, filter.decide(loggingEvent))
}
for (level in listOf(
Level.WARN,
Level.ERROR
)) {
val loggingEvent = mockk<LoggingEvent> {
every { this@mockk.level }.returns(level)
}
assertEquals(FilterReply.DENY, filter.decide(loggingEvent))
}
}
}

View File

@ -1,27 +0,0 @@
package util
import net.lamgc.scalabot.util.TelegramBotAccounts
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Test
class TelegramBotAccountsTest {
@Test
fun getBotAccountIdTest() {
val expectToken = "1234567890:AAHXcNDBRZTKfyPED5Gi3PZDIKPOM6xhxwo"
val actual: Long = TelegramBotAccounts.getBotAccountId(expectToken)
assertEquals(1234567890, actual)
val badTokenA = "12c34d56a7890:AAHXcNDBRZTKfyPED5Gi3PZDIKPOM6xhxwo"
assertThrows(
IllegalArgumentException::class.java
) { TelegramBotAccounts.getBotAccountId(badTokenA) }
val badTokenB = "12c34d56a7890AAHXcNDBRZTKfyPED5Gi3PZDIKPOM6xhxwo"
assertThrows(
IllegalArgumentException::class.java
) { TelegramBotAccounts.getBotAccountId(badTokenB) }
}
}

View File

@ -1,20 +1,9 @@
package net.lamgc.scalabot.util package net.lamgc.scalabot.util
import io.mockk.*
import net.lamgc.scalabot.ExtensionPackageFinder
import net.lamgc.scalabot.FinderPriority
import net.lamgc.scalabot.FinderRules
import net.lamgc.scalabot.FoundExtensionPackage
import org.eclipse.aether.artifact.Artifact
import org.eclipse.aether.artifact.DefaultArtifact import org.eclipse.aether.artifact.DefaultArtifact
import org.junit.jupiter.api.assertDoesNotThrow import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.assertThrows import org.junit.jupiter.api.Assertions.assertTrue
import java.io.File import org.junit.jupiter.api.Test
import java.io.FileFilter
import java.io.FilenameFilter
import java.lang.reflect.InvocationTargetException
import java.nio.charset.StandardCharsets
import kotlin.test.*
internal class UtilsKtTest { internal class UtilsKtTest {
@ -27,291 +16,4 @@ internal class UtilsKtTest {
.equalsArtifact(DefaultArtifact("com.example:demo-2:1.0.0-SNAPSHOT")) .equalsArtifact(DefaultArtifact("com.example:demo-2:1.0.0-SNAPSHOT"))
) )
} }
@Test
fun `bytes to hex`() {
assertEquals("48656c6c6f20576f726c64", "Hello World".toByteArray(StandardCharsets.UTF_8).toHexString())
}
@Test
fun `ExtensionPackageFinder - getPriority`() {
open class BaseTestFinder : ExtensionPackageFinder {
override fun findByArtifact(extensionArtifact: Artifact, extensionsPath: File): Set<FoundExtensionPackage> {
throw IllegalStateException("Calling this class is not allowed.")
}
}
@FinderRules(FinderPriority.ALTERNATE)
class StandardTestFinder : BaseTestFinder()
assertEquals(
FinderPriority.ALTERNATE, StandardTestFinder().getPriority(),
"获取到的优先值与预期不符"
)
@FinderRules(-1)
class OutOfRangePriorityFinder : BaseTestFinder()
assertThrows<IllegalArgumentException>("getPriority 方法没有对超出范围的优先值抛出异常.") {
OutOfRangePriorityFinder().getPriority()
}
class NoAnnotationFinder : BaseTestFinder()
assertThrows<NoSuchFieldException> {
NoAnnotationFinder().getPriority()
}
}
@Test
fun `AutoCloseable shutdown hook`() {
val utilsInternalClass = Class.forName("net.lamgc.scalabot.util.UtilsInternal")
val utilsInternalObject = utilsInternalClass.getDeclaredField("INSTANCE").get(null)
?: fail("无法获取 UtilsInternal 对象.")
val doCloseResourcesMethod = utilsInternalClass.getDeclaredMethod("doCloseResources")
.apply {
isAccessible = true
}
// 正常的运行过程.
val mockResource = mockk<AutoCloseable> {
justRun { close() }
}.registerShutdownHook()
doCloseResourcesMethod.invoke(utilsInternalObject)
verify { mockResource.close() }
// 异常捕获检查.
val exceptionMockResource = mockk<AutoCloseable> {
every { close() } throws RuntimeException("Expected exception.")
}.registerShutdownHook()
assertDoesNotThrow("在关闭资源时出现未捕获异常.") {
doCloseResourcesMethod.invoke(utilsInternalObject)
}
verify { exceptionMockResource.close() }
// 错误抛出检查.
val errorMockResource = mockk<AutoCloseable> {
every { close() } throws Error("Expected error.")
}.registerShutdownHook()
assertThrows<Error>("关闭资源时捕获了不该捕获的 Error.") {
try {
doCloseResourcesMethod.invoke(utilsInternalObject)
} catch (e: InvocationTargetException) {
throw e.targetException
}
}
verify { errorMockResource.close() }
@Suppress("UNCHECKED_CAST")
val resourceSet = utilsInternalClass.getDeclaredMethod("getAutoCloseableSet").invoke(utilsInternalObject)
as MutableSet<AutoCloseable>
resourceSet.clear()
val closeRef = mockk<AutoCloseable> {
justRun { close() }
}
resourceSet.add(closeRef)
assertTrue(resourceSet.contains(closeRef), "测试用资源虚引用添加失败.")
doCloseResourcesMethod.invoke(utilsInternalObject)
assertFalse(resourceSet.contains(closeRef), "资源虚引用未从列表中删除.")
resourceSet.clear()
}
@Test
fun `Artifact equals`() {
val artifact = DefaultArtifact("org.example:artifact:jar:0.0.1")
assertFalse(artifact.isSnapshot, "Release artifact is snapshot.")
assertTrue(artifact.equalsArtifact(artifact))
assertTrue(artifact.setFile(File(".")).equalsArtifact(artifact.setFile(File("."))))
val snapshotArtifact = DefaultArtifact("org.example:artifact:jar:0.0.1-SNAPSHOT")
val snapshotTimestampArtifact = DefaultArtifact("org.example:artifact:jar:0.0.1-20220605.130047-1")
assertTrue(snapshotArtifact.isSnapshot, "SnapshotArtifact not snapshot.")
assertNotEquals(artifact.isSnapshot, snapshotArtifact.isSnapshot)
assertNotEquals(artifact.baseVersion, snapshotArtifact.baseVersion)
assertFalse(artifact.equalsArtifact(snapshotArtifact))
assertFalse(snapshotArtifact.equalsArtifact(snapshotTimestampArtifact))
assertFalse(artifact.equalsArtifact(DefaultArtifact("org.example:artifact:0.0.2")))
assertFalse(artifact.equalsArtifact(DefaultArtifact("org.example.test:artifact:0.0.1")))
assertFalse(artifact.equalsArtifact(DefaultArtifact("org.example:artifact-a:0.0.1")))
assertFalse(artifact.equalsArtifact(DefaultArtifact("org.example:artifact:war:0.0.1")))
assertFalse(artifact.equalsArtifact(DefaultArtifact("org.example:artifact:war:javadoc:0.0.1")))
assertFalse(artifact.equalsArtifact(DefaultArtifact("org.example:artifact:rar:source:0.0.1")))
assertFalse(
artifact.equalsArtifact(
DefaultArtifact("org.example:artifact:jar:0.0.1")
.setFile(File("./xxx01.jar"))
)
)
val artifactWithExtension = DefaultArtifact("org.example:artifact:jar:0.0.1")
assertFalse(artifactWithExtension.equalsArtifact(DefaultArtifact("org.example:artifact:war:0.0.1")))
assertTrue(artifact.equalsArtifact(artifact.setProperties(mapOf(Pair("a", "b"))), checkProperties = false))
assertFalse(artifact.equalsArtifact(artifact.setProperties(mapOf(Pair("a", "b"))), checkProperties = true))
assertTrue(
artifact.setProperties(mapOf(Pair("a", "b")))
.equalsArtifact(artifact.setProperties(mapOf(Pair("a", "b"))), checkProperties = true)
)
}
@Test
fun `deepListFile Test - Basics`() {
assertNull(mockk<File> {
every { listFiles() } returns null
}.deepListFiles())
assertNull(mockk<File> {
every { listFiles(ofType(FileFilter::class)) } returns null
}.deepListFiles(fileFilter = { true }))
assertNull(mockk<File> {
every { listFiles(ofType(FilenameFilter::class)) } returns null
}.deepListFiles(filenameFilter = { _, _ -> true }))
val listFileMock = mockk<File> {
every { listFiles() } returns arrayOf()
every { listFiles(ofType(FileFilter::class)) } returns arrayOf()
every { listFiles(ofType(FilenameFilter::class)) } returns arrayOf()
}
assertNotNull(listFileMock.deepListFiles())
verify(exactly = 1) { listFileMock.listFiles() }
verify(exactly = 0) { listFileMock.listFiles(ofType(FilenameFilter::class)) }
verify(exactly = 0) { listFileMock.listFiles(ofType(FileFilter::class)) }
clearMocks(listFileMock, answers = false)
assertNotNull(listFileMock.deepListFiles(filenameFilter = { _, _ -> true }))
verify(exactly = 0) { listFileMock.listFiles() }
verify(exactly = 1) { listFileMock.listFiles(ofType(FilenameFilter::class)) }
verify(exactly = 0) { listFileMock.listFiles(ofType(FileFilter::class)) }
clearMocks(listFileMock, answers = false)
assertNotNull(listFileMock.deepListFiles(fileFilter = { true }))
verify(exactly = 0) { listFileMock.listFiles() }
verify(exactly = 0) { listFileMock.listFiles(ofType(FilenameFilter::class)) }
verify(exactly = 1) { listFileMock.listFiles(ofType(FileFilter::class)) }
clearMocks(listFileMock, answers = false)
assertNotNull(listFileMock.deepListFiles(fileFilter = { true }, filenameFilter = { _, _ -> true }))
verify(exactly = 0) { listFileMock.listFiles() }
verify(exactly = 1) { listFileMock.listFiles(ofType(FileFilter::class)) }
verify(exactly = 0) { listFileMock.listFiles(ofType(FilenameFilter::class)) }
clearMocks(listFileMock, answers = false)
val addSelfResult = listFileMock.deepListFiles(addSelf = true)
assertNotNull(addSelfResult)
assertEquals(1, addSelfResult.size)
assertTrue(addSelfResult.contains(listFileMock))
verify(exactly = 1) { listFileMock.listFiles() }
verify(exactly = 0) { listFileMock.listFiles(ofType(FilenameFilter::class)) }
verify(exactly = 0) { listFileMock.listFiles(ofType(FileFilter::class)) }
val addSelfWithoutDirMock = createDirectory(
"root", arrayOf(
createDirectory(
"dir01", arrayOf(
createFile("test01")
)
),
createDirectory(
"dir02", arrayOf(
createFile("test02")
)
),
createDirectory(
"dir03", arrayOf(
createFile("test03")
)
)
)
)
val addSelfWithoutDirResult = addSelfWithoutDirMock.deepListFiles(addSelf = true, onlyFile = true)
assertNotNull(addSelfWithoutDirResult)
assertFalse(addSelfWithoutDirResult.isEmpty())
assertEquals(1, addSelfWithoutDirResult.filter { it.isDirectory }.size)
assertEquals(addSelfWithoutDirMock, addSelfWithoutDirResult.find { it.isDirectory })
}
@Test
fun `deepListFile Test - Complex`() {
val mock = createDirectory(
"root", arrayOf(
createFile("test"),
createFile("test02"),
createDirectory("dir01"),
createDirectory("dir02")
)
)
val withDirResult = mock.deepListFiles(onlyFile = false)
assertNotNull(withDirResult)
assertEquals(4, withDirResult.size)
assertEquals(2, withDirResult.filter { it.isFile }.size)
assertEquals(2, withDirResult.filter { it.isDirectory }.size)
val withoutDirResult = mock.deepListFiles(onlyFile = true)
assertNotNull(withoutDirResult)
assertEquals(2, withoutDirResult.filter { it.isFile }.size)
assertNull(withoutDirResult.find { it.isDirectory })
val subDirFailedMock = createDirectory(
"root", arrayOf(
mockk(name = "dir::cannotReadableDirectory") {
every { isFile } returns false
every { isDirectory } returns true
every { name } returns "cannotReadableDirectory"
every { listFiles() } returns null
every { listFiles(ofType(FileFilter::class)) } returns null
every { listFiles(ofType(FilenameFilter::class)) } returns null
},
createDirectory(
"dir2", arrayOf(
createFile("test")
)
)
)
)
val subDirFailedWithDirResult = subDirFailedMock.deepListFiles(onlyFile = false)
assertNotNull(subDirFailedWithDirResult)
assertEquals(3, subDirFailedWithDirResult.size)
assertNotNull(subDirFailedWithDirResult.find { it.isDirectory && it.name == "cannotReadableDirectory" })
assertNotNull(subDirFailedWithDirResult.find { it.isDirectory && it.name == "dir2" })
assertNotNull(subDirFailedWithDirResult.find { it.isFile && it.name == "test" })
val subDirFailedWithoutDirResult = subDirFailedMock.deepListFiles(onlyFile = true)
assertNotNull(subDirFailedWithoutDirResult)
assertEquals(1, subDirFailedWithoutDirResult.size)
assertEquals(0, subDirFailedWithoutDirResult.filter { it.isDirectory }.size)
assertNotNull(subDirFailedWithoutDirResult.find { it.isFile && it.name == "test" })
assertNull(subDirFailedWithoutDirResult.find { it.isDirectory && it.name == "cannotReadableDirectory" })
assertNull(subDirFailedWithoutDirResult.find { it.isDirectory && it.name == "dir2" })
}
private fun createFile(path: String): File {
val file = File(path)
return mockk(name = "file::$path") {
every { isFile } returns true
every { isDirectory } returns false
every { name } returns file.name
every { listFiles() } returns null
every { listFiles(ofType(FileFilter::class)) } returns null
every { listFiles(ofType(FilenameFilter::class)) } returns null
}
}
private fun createDirectory(path: String, subFiles: Array<File> = arrayOf()): File {
val file = File(path)
return mockk(name = "dir::$path") {
every { isFile } returns false
every { isDirectory } returns true
every { name } returns file.name
every { listFiles() } returns subFiles
every { listFiles(ofType(FileFilter::class)) } answers {
subFiles.filter { (firstArg() as FileFilter).accept(it) }.toTypedArray()
}
every { listFiles(ofType(FilenameFilter::class)) } answers {
subFiles.filter { (firstArg() as FilenameFilter).accept(file.parentFile, file.name) }.toTypedArray()
}
}
}
} }

View File

@ -2,19 +2,17 @@ plugins {
java java
} }
repositories {
mavenCentral()
}
dependencies { dependencies {
compileOnly(project(":scalabot-extension")) compileOnly(project(":scalabot-extension"))
testImplementation("org.junit.jupiter:junit-jupiter-api:5.10.1") testImplementation("org.junit.jupiter:junit-jupiter-api:5.8.2")
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine") testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")
} }
tasks.getByName<Test>("test") { tasks.getByName<Test>("test") {
useJUnitPlatform() useJUnitPlatform()
} }
tasks.withType<Javadoc> {
options {
encoding = "UTF-8"
}
}

View File

@ -1,8 +1,8 @@
package net.lamgc.scalabot.simple; package net.lamgc.scalabot.simple;
import org.telegram.telegrambots.abilitybots.api.bot.BaseAbilityBot; import org.telegram.abilitybots.api.bot.BaseAbilityBot;
import org.telegram.telegrambots.abilitybots.api.objects.*; import org.telegram.abilitybots.api.objects.*;
import org.telegram.telegrambots.abilitybots.api.util.AbilityExtension; import org.telegram.abilitybots.api.util.AbilityExtension;
public class SayHelloExtension implements AbilityExtension { public class SayHelloExtension implements AbilityExtension {
@ -27,7 +27,7 @@ public class SayHelloExtension implements AbilityExtension {
String msg = "Hello! " + ctx.user().getUserName() + String msg = "Hello! " + ctx.user().getUserName() +
" ( " + ctx.user().getId() + " ) [ " + ctx.user().getLanguageCode() + " ]" + "\n" + " ( " + ctx.user().getId() + " ) [ " + ctx.user().getLanguageCode() + " ]" + "\n" +
"Current Chat ID: " + ctx.chatId(); "Current Chat ID: " + ctx.chatId();
ctx.bot().getSilent().send(msg, ctx.chatId()); ctx.bot().silent().send(msg, ctx.chatId());
}) })
.build(); .build();
} }
@ -36,17 +36,12 @@ public class SayHelloExtension implements AbilityExtension {
* 更具特色的 `Say hello`. * 更具特色的 `Say hello`.
*/ */
public Ability test() { public Ability test() {
ReplyFlow botHello = ReplyFlow.builder(bot.getDb()) ReplyFlow botHello = ReplyFlow.builder(bot.db())
.enableStats("say_hello") .action((bot, upd) -> bot.silent().send("What is u name?", upd.getMessage().getChatId()))
.action((bot, upd) -> bot.getSilent().send("What is u name?", upd.getMessage().getChatId())) .onlyIf(update -> "hello".equalsIgnoreCase(update.getMessage().getText()))
.onlyIf(update -> update.hasMessage() .next(Reply.of((bot, upd) -> bot.silent()
&& update.getMessage().hasText()
&& "hello".equalsIgnoreCase(update.getMessage().getText()))
.next(Reply.of((bot, upd) -> bot.getSilent()
.send("OK! You name is " + upd.getMessage().getText().substring("my name is ".length()), upd.getMessage().getChatId()), .send("OK! You name is " + upd.getMessage().getText().substring("my name is ".length()), upd.getMessage().getChatId()),
upd -> upd.hasMessage() upd -> upd.getMessage().getText().startsWith("my name is ")))
&& upd.getMessage().hasText()
&& upd.getMessage().getText().startsWith("my name is ")))
.build(); .build();
return Ability.builder() return Ability.builder()
@ -55,7 +50,7 @@ public class SayHelloExtension implements AbilityExtension {
.locality(Locality.ALL) .locality(Locality.ALL)
.privacy(Privacy.PUBLIC) .privacy(Privacy.PUBLIC)
.enableStats() .enableStats()
.action(ctx -> ctx.bot().getSilent().send("Hello!", ctx.chatId())) .action(ctx -> ctx.bot().silent().send("Hello!", ctx.chatId()))
.reply(botHello) .reply(botHello)
.build(); .build();
} }

View File

@ -1,16 +1,15 @@
package net.lamgc.scalabot.simple; package net.lamgc.scalabot.simple;
import net.lamgc.scalabot.extension.BotExtensionCreateOptions;
import net.lamgc.scalabot.extension.BotExtensionFactory; import net.lamgc.scalabot.extension.BotExtensionFactory;
import org.telegram.telegrambots.abilitybots.api.bot.BaseAbilityBot; import org.telegram.abilitybots.api.bot.BaseAbilityBot;
import org.telegram.telegrambots.abilitybots.api.util.AbilityExtension; import org.telegram.abilitybots.api.util.AbilityExtension;
import java.io.File; import java.io.File;
public class SimpleExtensionFactory implements BotExtensionFactory { public class SimpleExtensionFactory implements BotExtensionFactory {
@Override @Override
public AbilityExtension createExtensionInstance(BaseAbilityBot bot, File shareDataFolder, BotExtensionCreateOptions options) { public AbilityExtension createExtensionInstance(BaseAbilityBot bot, File shareDataFolder) {
return new SayHelloExtension(bot); return new SayHelloExtension(bot);
} }

View File

@ -1,19 +1,17 @@
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins { plugins {
`java-library` kotlin("jvm") version "1.6.10"
jacoco java
`maven-publish` `maven-publish`
signing
} }
dependencies { dependencies {
implementation("commons-codec:commons-codec:1.16.1") api("org.telegram:telegrambots-abilities:5.6.0")
api("org.telegram:telegrambots-abilities:8.0.0") api("org.slf4j:slf4j-api:1.7.36")
api(project(":scalabot-meta"))
testImplementation("org.junit.jupiter:junit-jupiter-api:5.10.1") // There is nothing to test.
testImplementation("org.mockito:mockito-core:5.11.0") // testImplementation(kotlin("test"))
testImplementation("org.telegram:telegrambots-client:8.0.0")
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")
} }
tasks.withType<Javadoc> { tasks.withType<Javadoc> {
@ -25,32 +23,36 @@ tasks.withType<Javadoc> {
java { java {
withJavadocJar() withJavadocJar()
withSourcesJar() withSourcesJar()
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
} }
tasks.test { tasks.test {
useJUnitPlatform() useJUnitPlatform()
finalizedBy(tasks.jacocoTestReport)
} }
tasks.jacocoTestReport { tasks.withType<KotlinCompile> {
dependsOn(tasks.test) kotlinOptions.jvmTarget = "11"
}
tasks.withType<AbstractArchiveTask>().configureEach {
isPreserveFileTimestamps = false
isReproducibleFileOrder = true
} }
publishing { publishing {
repositories { repositories {
maven("https://git.lamgc.me/api/packages/LamGC/maven") { val repoRootKey = "maven.repo.local.root"
credentials { val snapshot = project.version.toString().endsWith("-SNAPSHOT")
username = project.properties["repo.credentials.self-git.username"].toString() val repoRoot = System.getProperty(repoRootKey)?.trim()
password = project.properties["repo.credentials.self-git.password"].toString() if (repoRoot == null || repoRoot.isEmpty()) {
} logger.warn(
"\"$repoRootKey\" configuration item is not specified, " +
"please add start parameter \"-D$repoRootKey {localPublishRepo}\"" +
" (if you are not currently executing the publish task, " +
"you can ignore this information)"
)
return@repositories
} }
val repoUri = if (snapshot) {
uri("$repoRoot/snapshots")
} else {
uri("$repoRoot/releases")
}
maven(repoUri)
} }
publications { publications {
@ -92,8 +94,3 @@ publishing {
} }
} }
signing {
useGpgCmd()
sign(publishing.publications["maven"])
}

View File

@ -1,44 +0,0 @@
package net.lamgc.scalabot.extension;
import net.lamgc.scalabot.config.ProxyConfig;
/**
* BotExtension 创建参数.
* <p>
* 通过该类可向 {@link BotExtensionFactory} 提供更多创建 BotExtension 时可用的参数.
*/
@SuppressWarnings("unused")
public class BotExtensionCreateOptions {
private final long botAccountId;
private final ProxyConfig proxy;
/**
* 构造新的 BotExtensionCreateOptions.
*
* @param botAccountId 创建扩展的 Bot 账户 Id.
* @param proxy Bot 所使用的代理配置.
*/
public BotExtensionCreateOptions(long botAccountId, ProxyConfig proxy) {
this.botAccountId = botAccountId;
this.proxy = proxy;
}
/**
* 获取 Bot 使用的代理信息.
*
* @return 返回 Bot TelegramClient 所使用的代理配置.
*/
public ProxyConfig getProxy() {
return proxy;
}
/**
* 获取 Bot 的账户 Id.
*
* @return 返回 Bot 的账户 Id.
*/
public long getBotAccountId() {
return botAccountId;
}
}

View File

@ -1,8 +1,7 @@
package net.lamgc.scalabot.extension; package net.lamgc.scalabot.extension;
import org.telegram.telegrambots.abilitybots.api.bot.BaseAbilityBot; import org.telegram.abilitybots.api.bot.BaseAbilityBot;
import org.telegram.telegrambots.abilitybots.api.db.DBContext; import org.telegram.abilitybots.api.util.AbilityExtension;
import org.telegram.telegrambots.abilitybots.api.util.AbilityExtension;
import java.io.File; import java.io.File;
@ -10,31 +9,24 @@ import java.io.File;
* 该接口用于为指定的 {@link BaseAbilityBot} 创建扩展. * 该接口用于为指定的 {@link BaseAbilityBot} 创建扩展.
* *
* <p> 由于 AbilityExtension 无法直接获取 {@link BaseAbilityBot} * <p> 由于 AbilityExtension 无法直接获取 {@link BaseAbilityBot}
* 数据库对象 {@link DBContext}, * 数据库对象 {@link org.telegram.abilitybots.api.db.DBContext},
* 所以将通过该接口工厂来创建扩展对象. * 所以将通过该接口工厂来创建扩展对象.
* *
* @author LamGC * @author LamGC
* @since 0.0.1
*/ */
public interface BotExtensionFactory { public interface BotExtensionFactory {
/** /**
* 为给定的 {@link BaseAbilityBot} 对象创建扩展. * 为给定的 {@link BaseAbilityBot} 对象创建扩展.
* *
* <p> 如扩展无使用 {@link DBContext} 的话, * <p> 如扩展无使用 {@link org.telegram.abilitybots.api.db.DBContext} 的话,
* 也可以返回扩展单例, 因为 AbilityBot 本身并不禁止多个机器人共用一个扩展对象 * 也可以返回扩展单例, 因为 AbilityBot 本身并不禁止多个机器人共用一个扩展对象
* (AbilityBot 只是调用了扩展中的方法来创建 Ability 对象). * (因为 AbilityBot 只是调用了扩展中的方法来创建了功能对象).
* *
* @param bot 机器人对象. * @param bot 机器人对象.
* @param shareDataFolder ScalaBot App 为扩展提供的共享数据目录. * @param shareDataFolder ScalaBot App 为扩展提供的数据目录, 建议存储在数据目录中, 便于数据的存储管理.
* <p>路径格式为: * @return 返回为该 Bot 对象创建的扩展对象.
* <pre> $DATA_ROOT/data/extensions/{GroupId}/{ArtifactId}</pre>
* <b>同一个扩展包的 Factory</b> 接收到的共享数据目录<b>都是一样的</b>,
* 建议将数据存储在数据目录中, 便于数据的存储管理.
* @param options 创建扩展时可用的参数.
* @return 返回为该 Bot 对象创建的扩展对象, 如果不希望为该机器人提供扩展, 可返回 {@code null}.
* @since 0.7.0
*/ */
AbilityExtension createExtensionInstance(BaseAbilityBot bot, File shareDataFolder, BotExtensionCreateOptions options); AbilityExtension createExtensionInstance(BaseAbilityBot bot, File shareDataFolder);
} }

View File

@ -0,0 +1,32 @@
package net.lamgc.scalabot.extension;
import org.telegram.abilitybots.api.bot.BaseAbilityBot;
import org.telegram.abilitybots.api.db.DBContext;
import org.telegram.abilitybots.api.sender.MessageSender;
import org.telegram.abilitybots.api.util.AbilityExtension;
/**
*
*/
public abstract class ScalaBotExtension implements AbilityExtension {
/**
* 扩展所属的机器人对象.
*
* <p> 不要给该属性添加 Getter, 会被当成 Ability 添加, 导致出现异常.
*/
protected final BaseAbilityBot bot;
public ScalaBotExtension(BaseAbilityBot bot) {
this.bot = bot;
}
protected MessageSender getSender() {
return bot.sender();
}
protected DBContext getDBContext() {
return bot.db();
}
}

View File

@ -1,31 +0,0 @@
package net.lamgc.scalabot.extension.util;
import org.telegram.telegrambots.abilitybots.api.bot.BaseAbilityBot;
import java.util.Map;
/**
* 一些开发扩展中可以用到的工具类.
*/
public final class AbilityBots {
private AbilityBots() {
}
/**
* 取消某一对话的状态机.
*
* @param bot AbilityBot 实例.
* @param chatId 要删除状态机的聊天 Id.
* @return 如果状态机存在, 则删除后返回 true, 不存在(未开启任何状态机, 即没有触发任何 Reply)则返回 false.
*/
public static boolean cancelReplyState(BaseAbilityBot bot, long chatId) {
Map<Long, Integer> stateMap = bot.getDb().getMap("user_state_replies");
if (!stateMap.containsKey(chatId)) {
return false;
}
stateMap.remove(chatId);
return true;
}
}

View File

@ -1,128 +0,0 @@
package net.lamgc.scalabot.extension.util;
import org.junit.jupiter.api.Test;
import org.mapdb.DBMaker;
import org.telegram.telegrambots.abilitybots.api.bot.AbilityBot;
import org.telegram.telegrambots.abilitybots.api.bot.BaseAbilityBot;
import org.telegram.telegrambots.abilitybots.api.db.MapDBContext;
import org.telegram.telegrambots.abilitybots.api.objects.*;
import org.telegram.telegrambots.abilitybots.api.sender.SilentSender;
import org.telegram.telegrambots.meta.api.objects.Update;
import org.telegram.telegrambots.meta.api.objects.User;
import org.telegram.telegrambots.meta.api.objects.message.Message;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.*;
public class AbilityBotsTest {
public static final User USER = User.builder()
.userName("username")
.id(1L)
.firstName("first")
.lastName("last")
.isBot(false)
.build();
public static final User CREATOR = User.builder()
.userName("creatorUsername")
.id(1337L)
.firstName("creatorFirst")
.lastName("creatorLast")
.isBot(false)
.build();
static Update mockFullUpdate(BaseAbilityBot bot, User user, String args) {
bot.users().put(USER.getId(), USER);
bot.users().put(CREATOR.getId(), CREATOR);
bot.userIds().put(CREATOR.getUserName(), CREATOR.getId());
bot.userIds().put(USER.getUserName(), USER.getId());
bot.admins().add(CREATOR.getId());
Update update = mock(Update.class);
when(update.hasMessage()).thenReturn(true);
Message message = mock(Message.class);
when(message.getFrom()).thenReturn(user);
when(message.getText()).thenReturn(args);
when(message.hasText()).thenReturn(true);
when(message.isUserMessage()).thenReturn(true);
when(message.getChatId()).thenReturn(user.getId());
when(update.getMessage()).thenReturn(message);
return update;
}
@Test
void cancelReplyStateTest() {
User userA = User.builder()
.id(10001L)
.firstName("first")
.lastName("last")
.userName("username")
.isBot(false)
.build();
User userB = User.builder()
.id(10101L)
.firstName("first")
.lastName("last")
.userName("username")
.isBot(false)
.build();
SilentSender silent = mock(SilentSender.class);
BaseAbilityBot bot = new TestingAbilityBot("", silent);
bot.onRegister();
bot.consume(mockFullUpdate(bot, userA, "/set_reply"));
verify(silent, times(1)).send("Reply set!", userA.getId());
bot.consume(mockFullUpdate(bot, userA, "reply_01"));
verify(silent, times(1)).send("Reply 01", userA.getId());
assertTrue(AbilityBots.cancelReplyState(bot, userA.getId()));
bot.consume(mockFullUpdate(bot, userA, "reply_02"));
verify(silent, never()).send("Reply 02", userA.getId());
assertFalse(AbilityBots.cancelReplyState(bot, userB.getId()));
silent = mock(SilentSender.class);
bot = new TestingAbilityBot("", silent);
bot.onRegister();
bot.consume(mockFullUpdate(bot, userA, "/set_reply"));
verify(silent, times(1)).send("Reply set!", userA.getId());
bot.consume(mockFullUpdate(bot, userA, "reply_01"));
verify(silent, times(1)).send("Reply 01", userA.getId());
bot.consume(mockFullUpdate(bot, userA, "reply_02"));
verify(silent, times(1)).send("Reply 02", userA.getId());
}
public static class TestingAbilityBot extends AbilityBot {
public TestingAbilityBot(String botUsername, SilentSender silentSender) {
super(new NoOpTelegramClient(), botUsername, new MapDBContext(DBMaker.heapDB().make()));
this.silent = silentSender;
}
@SuppressWarnings("unused")
public Ability setReply() {
return Ability.builder()
.name("set_reply")
.enableStats()
.locality(Locality.ALL)
.privacy(Privacy.PUBLIC)
.action(ctx -> ctx.bot().getSilent().send("Reply set!", ctx.chatId()))
.reply(ReplyFlow.builder(getDb())
.action((bot, upd) -> bot.getSilent().send("Reply 01", upd.getMessage().getChatId()))
.onlyIf(upd -> upd.hasMessage() && upd.getMessage().getText().equals("reply_01"))
.next(Reply.of((bot, upd) ->
bot.getSilent().send("Reply 02", upd.getMessage().getChatId()),
upd -> upd.hasMessage() && upd.getMessage().getText().equals("reply_02")))
.build()
)
.build();
}
@Override
public long creatorId() {
return 0;
}
}
}

View File

@ -1,229 +0,0 @@
package net.lamgc.scalabot.extension.util;
import org.telegram.telegrambots.meta.api.methods.botapimethods.BotApiMethod;
import org.telegram.telegrambots.meta.api.methods.groupadministration.SetChatPhoto;
import org.telegram.telegrambots.meta.api.methods.send.*;
import org.telegram.telegrambots.meta.api.methods.stickers.*;
import org.telegram.telegrambots.meta.api.methods.updates.SetWebhook;
import org.telegram.telegrambots.meta.api.methods.updatingmessages.EditMessageMedia;
import org.telegram.telegrambots.meta.api.objects.File;
import org.telegram.telegrambots.meta.api.objects.message.Message;
import org.telegram.telegrambots.meta.generics.TelegramClient;
import java.io.InputStream;
import java.io.Serializable;
import java.util.List;
import java.util.concurrent.CompletableFuture;
class NoOpTelegramClient implements TelegramClient {
@Override
public <T extends Serializable, Method extends BotApiMethod<T>> CompletableFuture<T> executeAsync(Method method) {
return null;
}
@Override
public <T extends Serializable, Method extends BotApiMethod<T>> T execute(Method method) {
return null;
}
@Override
public Message execute(SendDocument sendDocument) {
return null;
}
@Override
public Message execute(SendPhoto sendPhoto) {
return null;
}
@Override
public Boolean execute(SetWebhook setWebhook) {
return null;
}
@Override
public Message execute(SendVideo sendVideo) {
return null;
}
@Override
public Message execute(SendVideoNote sendVideoNote) {
return null;
}
@Override
public Message execute(SendSticker sendSticker) {
return null;
}
@Override
public Message execute(SendAudio sendAudio) {
return null;
}
@Override
public Message execute(SendVoice sendVoice) {
return null;
}
@Override
public List<Message> execute(SendMediaGroup sendMediaGroup) {
return List.of();
}
@Override
public List<Message> execute(SendPaidMedia sendPaidMedia) {
return List.of();
}
@Override
public Boolean execute(SetChatPhoto setChatPhoto) {
return null;
}
@Override
public Boolean execute(AddStickerToSet addStickerToSet) {
return null;
}
@Override
public Boolean execute(ReplaceStickerInSet replaceStickerInSet) {
return null;
}
@Override
public Boolean execute(SetStickerSetThumbnail setStickerSetThumbnail) {
return null;
}
@Override
public Boolean execute(CreateNewStickerSet createNewStickerSet) {
return null;
}
@Override
public File execute(UploadStickerFile uploadStickerFile) {
return null;
}
@Override
public Serializable execute(EditMessageMedia editMessageMedia) {
return null;
}
@Override
public java.io.File downloadFile(File file) {
return null;
}
@Override
public InputStream downloadFileAsStream(File file) {
return null;
}
@Override
public Message execute(SendAnimation sendAnimation) {
return null;
}
@Override
public CompletableFuture<Message> executeAsync(SendDocument sendDocument) {
return null;
}
@Override
public CompletableFuture<Message> executeAsync(SendPhoto sendPhoto) {
return null;
}
@Override
public CompletableFuture<Boolean> executeAsync(SetWebhook setWebhook) {
return null;
}
@Override
public CompletableFuture<Message> executeAsync(SendVideo sendVideo) {
return null;
}
@Override
public CompletableFuture<Message> executeAsync(SendVideoNote sendVideoNote) {
return null;
}
@Override
public CompletableFuture<Message> executeAsync(SendSticker sendSticker) {
return null;
}
@Override
public CompletableFuture<Message> executeAsync(SendAudio sendAudio) {
return null;
}
@Override
public CompletableFuture<Message> executeAsync(SendVoice sendVoice) {
return null;
}
@Override
public CompletableFuture<List<Message>> executeAsync(SendMediaGroup sendMediaGroup) {
return null;
}
@Override
public CompletableFuture<List<Message>> executeAsync(SendPaidMedia sendPaidMedia) {
return null;
}
@Override
public CompletableFuture<Boolean> executeAsync(SetChatPhoto setChatPhoto) {
return null;
}
@Override
public CompletableFuture<Boolean> executeAsync(AddStickerToSet addStickerToSet) {
return null;
}
@Override
public CompletableFuture<Boolean> executeAsync(ReplaceStickerInSet replaceStickerInSet) {
return null;
}
@Override
public CompletableFuture<Boolean> executeAsync(SetStickerSetThumbnail setStickerSetThumbnail) {
return null;
}
@Override
public CompletableFuture<Boolean> executeAsync(CreateNewStickerSet createNewStickerSet) {
return null;
}
@Override
public CompletableFuture<File> executeAsync(UploadStickerFile uploadStickerFile) {
return null;
}
@Override
public CompletableFuture<Serializable> executeAsync(EditMessageMedia editMessageMedia) {
return null;
}
@Override
public CompletableFuture<Message> executeAsync(SendAnimation sendAnimation) {
return null;
}
@Override
public CompletableFuture<java.io.File> downloadFileAsync(File file) {
return null;
}
@Override
public CompletableFuture<InputStream> downloadFileAsStreamAsync(File file) {
return null;
}
}

View File

@ -1,13 +0,0 @@
# scalabot-meta
本模块用于将 ScalaBot 的一些配置相关内容发布出去,以便于其他项目使用。
主要是配置类和相应的 Gson 序列化器(如果有,或者必要)。
## 关于序列化器
强烈建议使用序列化器!由于 Kotlin 与 Gson 之间的一些兼容性问题
(参见[本提交](https://github.com/LamGC/ScalaBot/commit/084280564af58d1af22db5b57c67577d93bd820e)
如果直接让 Gson 解析 Kotlin Data 类,将会出现一些潜在的问题(比如无法使用默认值)。
部分序列化器也可以帮助检查字段值是否合法,以防止因字段值不正确导致出现更多的问题
(例如 BotAccount 中,如果 `token` 的格式有误,那么获取 `id` 时将引发 `NumberFormatException` 异常)。

View File

@ -1,201 +0,0 @@
public final class net/lamgc/scalabot/config/AppConfig {
public fun <init> ()V
public fun <init> (Lnet/lamgc/scalabot/config/ProxyConfig;Lnet/lamgc/scalabot/config/MetricsConfig;Ljava/util/List;Ljava/lang/String;)V
public synthetic fun <init> (Lnet/lamgc/scalabot/config/ProxyConfig;Lnet/lamgc/scalabot/config/MetricsConfig;Ljava/util/List;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun component1 ()Lnet/lamgc/scalabot/config/ProxyConfig;
public final fun component2 ()Lnet/lamgc/scalabot/config/MetricsConfig;
public final fun component3 ()Ljava/util/List;
public final fun component4 ()Ljava/lang/String;
public final fun copy (Lnet/lamgc/scalabot/config/ProxyConfig;Lnet/lamgc/scalabot/config/MetricsConfig;Ljava/util/List;Ljava/lang/String;)Lnet/lamgc/scalabot/config/AppConfig;
public static synthetic fun copy$default (Lnet/lamgc/scalabot/config/AppConfig;Lnet/lamgc/scalabot/config/ProxyConfig;Lnet/lamgc/scalabot/config/MetricsConfig;Ljava/util/List;Ljava/lang/String;ILjava/lang/Object;)Lnet/lamgc/scalabot/config/AppConfig;
public fun equals (Ljava/lang/Object;)Z
public final fun getMavenLocalRepository ()Ljava/lang/String;
public final fun getMavenRepositories ()Ljava/util/List;
public final fun getMetrics ()Lnet/lamgc/scalabot/config/MetricsConfig;
public final fun getProxy ()Lnet/lamgc/scalabot/config/ProxyConfig;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}
public final class net/lamgc/scalabot/config/BotAccount {
public fun <init> (Ljava/lang/String;Ljava/lang/String;J)V
public final fun component1 ()Ljava/lang/String;
public final fun component2 ()Ljava/lang/String;
public final fun component3 ()J
public final fun copy (Ljava/lang/String;Ljava/lang/String;J)Lnet/lamgc/scalabot/config/BotAccount;
public static synthetic fun copy$default (Lnet/lamgc/scalabot/config/BotAccount;Ljava/lang/String;Ljava/lang/String;JILjava/lang/Object;)Lnet/lamgc/scalabot/config/BotAccount;
public fun equals (Ljava/lang/Object;)Z
public final fun getCreatorId ()J
public final fun getId ()J
public final fun getName ()Ljava/lang/String;
public final fun getToken ()Ljava/lang/String;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}
public final class net/lamgc/scalabot/config/BotConfig {
public fun <init> (ZLnet/lamgc/scalabot/config/BotAccount;ZZLjava/util/Set;Lnet/lamgc/scalabot/config/ProxyConfig;Ljava/lang/String;)V
public synthetic fun <init> (ZLnet/lamgc/scalabot/config/BotAccount;ZZLjava/util/Set;Lnet/lamgc/scalabot/config/ProxyConfig;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun component1 ()Z
public final fun component2 ()Lnet/lamgc/scalabot/config/BotAccount;
public final fun component3 ()Z
public final fun component4 ()Z
public final fun component5 ()Ljava/util/Set;
public final fun component6 ()Lnet/lamgc/scalabot/config/ProxyConfig;
public final fun component7 ()Ljava/lang/String;
public final fun copy (ZLnet/lamgc/scalabot/config/BotAccount;ZZLjava/util/Set;Lnet/lamgc/scalabot/config/ProxyConfig;Ljava/lang/String;)Lnet/lamgc/scalabot/config/BotConfig;
public static synthetic fun copy$default (Lnet/lamgc/scalabot/config/BotConfig;ZLnet/lamgc/scalabot/config/BotAccount;ZZLjava/util/Set;Lnet/lamgc/scalabot/config/ProxyConfig;Ljava/lang/String;ILjava/lang/Object;)Lnet/lamgc/scalabot/config/BotConfig;
public fun equals (Ljava/lang/Object;)Z
public final fun getAccount ()Lnet/lamgc/scalabot/config/BotAccount;
public final fun getAutoUpdateCommandList ()Z
public final fun getBaseApiTelegramUrl ()Lorg/telegram/telegrambots/meta/TelegramUrl;
public final fun getBaseApiUrl ()Ljava/lang/String;
public final fun getDisableBuiltInAbility ()Z
public final fun getEnabled ()Z
public final fun getExtensions ()Ljava/util/Set;
public final fun getProxy ()Lnet/lamgc/scalabot/config/ProxyConfig;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}
public final class net/lamgc/scalabot/config/ConfigsKt {
public static final fun getDefaultTelegramApiUrl ()Ljava/lang/String;
}
public final class net/lamgc/scalabot/config/MavenRepositoryConfig {
public fun <init> (Ljava/lang/String;Ljava/net/URL;Lorg/eclipse/aether/repository/Proxy;Ljava/lang/String;ZZLorg/eclipse/aether/repository/Authentication;)V
public synthetic fun <init> (Ljava/lang/String;Ljava/net/URL;Lorg/eclipse/aether/repository/Proxy;Ljava/lang/String;ZZLorg/eclipse/aether/repository/Authentication;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun component1 ()Ljava/lang/String;
public final fun component2 ()Ljava/net/URL;
public final fun component3 ()Lorg/eclipse/aether/repository/Proxy;
public final fun component4 ()Ljava/lang/String;
public final fun component5 ()Z
public final fun component6 ()Z
public final fun component7 ()Lorg/eclipse/aether/repository/Authentication;
public final fun copy (Ljava/lang/String;Ljava/net/URL;Lorg/eclipse/aether/repository/Proxy;Ljava/lang/String;ZZLorg/eclipse/aether/repository/Authentication;)Lnet/lamgc/scalabot/config/MavenRepositoryConfig;
public static synthetic fun copy$default (Lnet/lamgc/scalabot/config/MavenRepositoryConfig;Ljava/lang/String;Ljava/net/URL;Lorg/eclipse/aether/repository/Proxy;Ljava/lang/String;ZZLorg/eclipse/aether/repository/Authentication;ILjava/lang/Object;)Lnet/lamgc/scalabot/config/MavenRepositoryConfig;
public fun equals (Ljava/lang/Object;)Z
public final fun getAuthentication ()Lorg/eclipse/aether/repository/Authentication;
public final fun getEnableReleases ()Z
public final fun getEnableSnapshots ()Z
public final fun getId ()Ljava/lang/String;
public final fun getLayout ()Ljava/lang/String;
public final fun getProxy ()Lorg/eclipse/aether/repository/Proxy;
public final fun getUrl ()Ljava/net/URL;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}
public final class net/lamgc/scalabot/config/MetricsConfig {
public fun <init> ()V
public fun <init> (ZILjava/lang/String;Lnet/lamgc/scalabot/config/UsernameAuthenticator;)V
public synthetic fun <init> (ZILjava/lang/String;Lnet/lamgc/scalabot/config/UsernameAuthenticator;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun component1 ()Z
public final fun component2 ()I
public final fun component3 ()Ljava/lang/String;
public final fun component4 ()Lnet/lamgc/scalabot/config/UsernameAuthenticator;
public final fun copy (ZILjava/lang/String;Lnet/lamgc/scalabot/config/UsernameAuthenticator;)Lnet/lamgc/scalabot/config/MetricsConfig;
public static synthetic fun copy$default (Lnet/lamgc/scalabot/config/MetricsConfig;ZILjava/lang/String;Lnet/lamgc/scalabot/config/UsernameAuthenticator;ILjava/lang/Object;)Lnet/lamgc/scalabot/config/MetricsConfig;
public fun equals (Ljava/lang/Object;)Z
public final fun getAuthenticator ()Lnet/lamgc/scalabot/config/UsernameAuthenticator;
public final fun getBindAddress ()Ljava/lang/String;
public final fun getEnable ()Z
public final fun getPort ()I
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}
public final class net/lamgc/scalabot/config/ProxyConfig {
public fun <init> ()V
public fun <init> (Lnet/lamgc/scalabot/config/ProxyType;Ljava/lang/String;I)V
public synthetic fun <init> (Lnet/lamgc/scalabot/config/ProxyType;Ljava/lang/String;IILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun component1 ()Lnet/lamgc/scalabot/config/ProxyType;
public final fun component2 ()Ljava/lang/String;
public final fun component3 ()I
public final fun copy (Lnet/lamgc/scalabot/config/ProxyType;Ljava/lang/String;I)Lnet/lamgc/scalabot/config/ProxyConfig;
public static synthetic fun copy$default (Lnet/lamgc/scalabot/config/ProxyConfig;Lnet/lamgc/scalabot/config/ProxyType;Ljava/lang/String;IILjava/lang/Object;)Lnet/lamgc/scalabot/config/ProxyConfig;
public fun equals (Ljava/lang/Object;)Z
public final fun getHost ()Ljava/lang/String;
public final fun getPort ()I
public final fun getType ()Lnet/lamgc/scalabot/config/ProxyType;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}
public final class net/lamgc/scalabot/config/ProxyType : java/lang/Enum {
public static final field HTTP Lnet/lamgc/scalabot/config/ProxyType;
public static final field HTTPS Lnet/lamgc/scalabot/config/ProxyType;
public static final field NO_PROXY Lnet/lamgc/scalabot/config/ProxyType;
public static final field SOCKS4 Lnet/lamgc/scalabot/config/ProxyType;
public static final field SOCKS5 Lnet/lamgc/scalabot/config/ProxyType;
public static fun getEntries ()Lkotlin/enums/EnumEntries;
public static fun valueOf (Ljava/lang/String;)Lnet/lamgc/scalabot/config/ProxyType;
public static fun values ()[Lnet/lamgc/scalabot/config/ProxyType;
}
public final class net/lamgc/scalabot/config/UsernameAuthenticator : com/sun/net/httpserver/BasicAuthenticator {
public fun <init> (Ljava/lang/String;Ljava/lang/String;)V
public fun checkCredentials (Ljava/lang/String;Ljava/lang/String;)Z
public fun equals (Ljava/lang/Object;)Z
public fun hashCode ()I
public final fun toJsonObject ()Lcom/google/gson/JsonObject;
}
public final class net/lamgc/scalabot/config/serializer/ArtifactSerializer : com/google/gson/JsonDeserializer, com/google/gson/JsonSerializer {
public static final field INSTANCE Lnet/lamgc/scalabot/config/serializer/ArtifactSerializer;
public synthetic fun deserialize (Lcom/google/gson/JsonElement;Ljava/lang/reflect/Type;Lcom/google/gson/JsonDeserializationContext;)Ljava/lang/Object;
public fun deserialize (Lcom/google/gson/JsonElement;Ljava/lang/reflect/Type;Lcom/google/gson/JsonDeserializationContext;)Lorg/eclipse/aether/artifact/Artifact;
public synthetic fun serialize (Ljava/lang/Object;Ljava/lang/reflect/Type;Lcom/google/gson/JsonSerializationContext;)Lcom/google/gson/JsonElement;
public fun serialize (Lorg/eclipse/aether/artifact/Artifact;Ljava/lang/reflect/Type;Lcom/google/gson/JsonSerializationContext;)Lcom/google/gson/JsonElement;
}
public final class net/lamgc/scalabot/config/serializer/AuthenticationSerializer : com/google/gson/JsonDeserializer {
public static final field INSTANCE Lnet/lamgc/scalabot/config/serializer/AuthenticationSerializer;
public synthetic fun deserialize (Lcom/google/gson/JsonElement;Ljava/lang/reflect/Type;Lcom/google/gson/JsonDeserializationContext;)Ljava/lang/Object;
public fun deserialize (Lcom/google/gson/JsonElement;Ljava/lang/reflect/Type;Lcom/google/gson/JsonDeserializationContext;)Lorg/eclipse/aether/repository/Authentication;
}
public final class net/lamgc/scalabot/config/serializer/BotAccountSerializer : com/google/gson/JsonDeserializer {
public static final field INSTANCE Lnet/lamgc/scalabot/config/serializer/BotAccountSerializer;
public synthetic fun deserialize (Lcom/google/gson/JsonElement;Ljava/lang/reflect/Type;Lcom/google/gson/JsonDeserializationContext;)Ljava/lang/Object;
public fun deserialize (Lcom/google/gson/JsonElement;Ljava/lang/reflect/Type;Lcom/google/gson/JsonDeserializationContext;)Lnet/lamgc/scalabot/config/BotAccount;
}
public final class net/lamgc/scalabot/config/serializer/BotConfigSerializer : com/google/gson/JsonDeserializer, com/google/gson/JsonSerializer {
public static final field INSTANCE Lnet/lamgc/scalabot/config/serializer/BotConfigSerializer;
public synthetic fun deserialize (Lcom/google/gson/JsonElement;Ljava/lang/reflect/Type;Lcom/google/gson/JsonDeserializationContext;)Ljava/lang/Object;
public fun deserialize (Lcom/google/gson/JsonElement;Ljava/lang/reflect/Type;Lcom/google/gson/JsonDeserializationContext;)Lnet/lamgc/scalabot/config/BotConfig;
public synthetic fun serialize (Ljava/lang/Object;Ljava/lang/reflect/Type;Lcom/google/gson/JsonSerializationContext;)Lcom/google/gson/JsonElement;
public fun serialize (Lnet/lamgc/scalabot/config/BotConfig;Ljava/lang/reflect/Type;Lcom/google/gson/JsonSerializationContext;)Lcom/google/gson/JsonElement;
}
public final class net/lamgc/scalabot/config/serializer/MavenRepositoryConfigSerializer : com/google/gson/JsonDeserializer {
public static final field INSTANCE Lnet/lamgc/scalabot/config/serializer/MavenRepositoryConfigSerializer;
public synthetic fun deserialize (Lcom/google/gson/JsonElement;Ljava/lang/reflect/Type;Lcom/google/gson/JsonDeserializationContext;)Ljava/lang/Object;
public fun deserialize (Lcom/google/gson/JsonElement;Ljava/lang/reflect/Type;Lcom/google/gson/JsonDeserializationContext;)Lnet/lamgc/scalabot/config/MavenRepositoryConfig;
}
public final class net/lamgc/scalabot/config/serializer/ProxyConfigSerializer : com/google/gson/JsonDeserializer, com/google/gson/JsonSerializer {
public static final field INSTANCE Lnet/lamgc/scalabot/config/serializer/ProxyConfigSerializer;
public synthetic fun deserialize (Lcom/google/gson/JsonElement;Ljava/lang/reflect/Type;Lcom/google/gson/JsonDeserializationContext;)Ljava/lang/Object;
public fun deserialize (Lcom/google/gson/JsonElement;Ljava/lang/reflect/Type;Lcom/google/gson/JsonDeserializationContext;)Lnet/lamgc/scalabot/config/ProxyConfig;
public synthetic fun serialize (Ljava/lang/Object;Ljava/lang/reflect/Type;Lcom/google/gson/JsonSerializationContext;)Lcom/google/gson/JsonElement;
public fun serialize (Lnet/lamgc/scalabot/config/ProxyConfig;Ljava/lang/reflect/Type;Lcom/google/gson/JsonSerializationContext;)Lcom/google/gson/JsonElement;
}
public final class net/lamgc/scalabot/config/serializer/ProxyTypeSerializer : com/google/gson/JsonDeserializer, com/google/gson/JsonSerializer {
public static final field INSTANCE Lnet/lamgc/scalabot/config/serializer/ProxyTypeSerializer;
public synthetic fun deserialize (Lcom/google/gson/JsonElement;Ljava/lang/reflect/Type;Lcom/google/gson/JsonDeserializationContext;)Ljava/lang/Object;
public fun deserialize (Lcom/google/gson/JsonElement;Ljava/lang/reflect/Type;Lcom/google/gson/JsonDeserializationContext;)Lnet/lamgc/scalabot/config/ProxyType;
public synthetic fun serialize (Ljava/lang/Object;Ljava/lang/reflect/Type;Lcom/google/gson/JsonSerializationContext;)Lcom/google/gson/JsonElement;
public fun serialize (Lnet/lamgc/scalabot/config/ProxyType;Ljava/lang/reflect/Type;Lcom/google/gson/JsonSerializationContext;)Lcom/google/gson/JsonElement;
}
public final class net/lamgc/scalabot/config/serializer/UsernameAuthenticatorSerializer : com/google/gson/JsonDeserializer, com/google/gson/JsonSerializer {
public static final field INSTANCE Lnet/lamgc/scalabot/config/serializer/UsernameAuthenticatorSerializer;
public synthetic fun deserialize (Lcom/google/gson/JsonElement;Ljava/lang/reflect/Type;Lcom/google/gson/JsonDeserializationContext;)Ljava/lang/Object;
public fun deserialize (Lcom/google/gson/JsonElement;Ljava/lang/reflect/Type;Lcom/google/gson/JsonDeserializationContext;)Lnet/lamgc/scalabot/config/UsernameAuthenticator;
public synthetic fun serialize (Ljava/lang/Object;Ljava/lang/reflect/Type;Lcom/google/gson/JsonSerializationContext;)Lcom/google/gson/JsonElement;
public fun serialize (Lnet/lamgc/scalabot/config/UsernameAuthenticator;Ljava/lang/reflect/Type;Lcom/google/gson/JsonSerializationContext;)Lcom/google/gson/JsonElement;
}

View File

@ -1,107 +0,0 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
kotlin("jvm")
id("org.jetbrains.kotlinx.kover")
id("org.jetbrains.dokka") version "1.9.20"
`maven-publish`
signing
id("org.jetbrains.kotlinx.binary-compatibility-validator")
}
dependencies {
val aetherVersion = "1.1.0"
api("org.eclipse.aether:aether-api:$aetherVersion")
implementation("org.eclipse.aether:aether-util:$aetherVersion")
implementation("org.telegram:telegrambots-meta:8.0.0")
api("com.google.code.gson:gson:2.11.0")
testImplementation(kotlin("test"))
testImplementation("io.mockk:mockk:1.13.13")
testImplementation("org.junit.jupiter:junit-jupiter-api:5.10.1")
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.10.1")
dokkaHtmlPlugin("org.jetbrains.dokka:javadoc-plugin:2.0.0")
}
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
compilerOptions {
jvmTarget = JvmTarget.JVM_17
}
}
java {
withJavadocJar()
withSourcesJar()
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
tasks.withType<AbstractArchiveTask>().configureEach {
isPreserveFileTimestamps = false
isReproducibleFileOrder = true
}
tasks.getByName<Test>("test") {
useJUnitPlatform()
}
val javadocJar = tasks.named<Jar>("javadocJar") {
from(tasks.named("dokkaJavadoc"))
}
publishing {
repositories {
maven("https://git.lamgc.me/api/packages/LamGC/maven") {
credentials {
username = project.properties["repo.credentials.self-git.username"].toString()
password = project.properties["repo.credentials.self-git.password"].toString()
}
}
}
publications {
create<MavenPublication>("maven") {
from(components["kotlin"])
artifact(javadocJar)
artifact(tasks.named("sourcesJar"))
pom {
name.set("ScalaBot-meta")
description.set(
"Shared components used by scalabot (such as configuration classes)"
)
url.set("https://github.com/LamGC/ScalaBot")
licenses {
license {
name.set("The MIT License")
url.set("https://www.opensource.org/licenses/mit-license.php")
}
}
developers {
developer {
id.set("LamGC")
name.set("LamGC")
email.set("lam827@lamgc.net")
url.set("https://github.com/LamGC")
}
}
scm {
connection.set("scm:git:https://github.com/LamGC/ScalaBot.git")
developerConnection.set("scm:git:https://github.com/LamGC/ScalaBot.git")
url.set("https://github.com/LamGC/ScalaBot")
}
issueManagement {
url.set("https://github.com/LamGC/ScalaBot/issues")
system.set("Github Issues")
}
}
}
}
}
signing {
useGpgCmd()
sign(publishing.publications["maven"])
}

View File

@ -1,175 +0,0 @@
package net.lamgc.scalabot.config
import org.eclipse.aether.artifact.Artifact
import org.eclipse.aether.repository.Authentication
import org.eclipse.aether.repository.Proxy
import org.telegram.telegrambots.meta.TelegramUrl
import java.net.URI
import java.net.URL
/**
* 机器人帐号信息.
* @property name 机器人名称, 建议与实际设定的名称相同.
* @property token 机器人 API Token.
* @property creatorId 机器人创建者, 管理机器人需要使用该信息.
* @property id 机器人账号 ID.
*/
data class BotAccount(
val name: String,
val token: String,
val creatorId: Long
) {
val id
// 不要想着每次获取都要从 token 里取出有性能损耗.
// 由于 Gson 解析方式, 如果不这么做, 会出现 token 设置前 id 初始化完成, 就只有"0"了,
// 虽然能过单元测试, 但实际使用过程是不能正常用的.
get() = token.substringBefore(":").toLong()
}
val defaultTelegramApiUrl: String = URL(
TelegramUrl.DEFAULT_URL.schema,
TelegramUrl.DEFAULT_URL.host,
TelegramUrl.DEFAULT_URL.port,
"/"
).toExternalForm()
/**
* 机器人配置.
*
* 使用 Gson 解析时, 请添加以下类型适配器:
* - [net.lamgc.scalabot.config.serializer.ProxyTypeSerializer]
* - [net.lamgc.scalabot.config.serializer.BotConfigSerializer]
* - [net.lamgc.scalabot.config.serializer.BotAccountSerializer]
* - [net.lamgc.scalabot.config.serializer.ArtifactSerializer]
*
* @property enabled 是否启用机器人.
* @property account 机器人帐号信息, 用于访问 API.
* @property disableBuiltInAbility 是否禁用 AbilityBot 自带命令.
* @property autoUpdateCommandList 是否自动更新机器人在 Telegram 的命令列表.
* @property extensions 该机器人启用的扩展.
* @property proxy 为该机器人单独设置的代理配置, 如无设置, 则使用 AppConfig 中的代理配置.
* @property baseApiUrl 机器人所使用的 API 地址, 适用于自建 Telegram Bot API 端点.
*/
data class BotConfig(
val enabled: Boolean = false,
val account: BotAccount,
val disableBuiltInAbility: Boolean = false,
val autoUpdateCommandList: Boolean = false,
/*
* 使用构件坐标来选择机器人所使用的扩展包.
* 这么做的原因是我暂时没找到一个合适的方法来让开发者方便地设定自己的扩展 Id,
* 而构件坐标(POM Reference 或者叫 GAV 坐标)是开发者创建 Maven/Gradle 项目时一定会设置的,
* 所以就直接用了. :P
*/
val extensions: Set<Artifact> = emptySet(),
val proxy: ProxyConfig = ProxyConfig(type = ProxyType.NO_PROXY),
val baseApiUrl: String = defaultTelegramApiUrl
) {
fun getBaseApiTelegramUrl(): TelegramUrl {
if (this.baseApiUrl == defaultTelegramApiUrl) {
return TelegramUrl.DEFAULT_URL
} else {
URI.create(baseApiUrl).let {
return TelegramUrl.builder()
.host(it.host)
.port(it.port)
.schema(it.scheme)
.build()
}
}
}
}
/**
* 代理类型.
*/
enum class ProxyType {
NO_PROXY,
HTTP,
HTTPS,
SOCKS4,
SOCKS5
}
/**
* 代理配置.
* @property type 代理类型.
* @property host 代理服务端地址.
* @property port 代理服务端端口.
*/
data class ProxyConfig(
val type: ProxyType = ProxyType.NO_PROXY,
val host: String = "127.0.0.1",
val port: Int = 1080,
) {
override fun toString(): String {
return if (type != ProxyType.NO_PROXY) {
"$type://$host:$port"
} else {
"NO_PROXY"
}
}
}
/**
* ScalaBot 的运行指标公开配置.
*
* ScalaBot 内置了用于公开运行指标的服务端,
* 该指标遵循 Prometheus 的标准, 可以通过 Prometheus 的工具来查看.
*
* @property enable 是否启用运行指标服务端.
* @property port 运行指标服务端的端口.
* @property bindAddress 运行指标服务端的绑定地址, 绑定后只有该地址可以访问.
* @property authenticator 运行指标服务端的 HTTP 认证配置.
*/
data class MetricsConfig(
val enable: Boolean = false,
val port: Int = 9386,
val bindAddress: String? = "0.0.0.0",
val authenticator: UsernameAuthenticator? = null
)
/**
* Maven 远端仓库配置.
* @property id 远端仓库 ID, 如果该属性未配置 (null), 那么运行时将会自动分配一个 Id.
* @property url 仓库地址.
* @property proxy 访问仓库所使用的代理, 仅支持 http/https 代理.
* @property layout 仓库布局版本, Maven 2 及以上使用 `default`, Maven 1 使用 `legacy`.
* @property enableReleases 是否在该远端仓库获取发布版本.
* @property enableSnapshots 是否在该远端仓库获取快照版本.
* @property authentication 访问该远端仓库所使用的认证配置.
*/
data class MavenRepositoryConfig(
val id: String? = null,
val url: URL,
val proxy: Proxy? = null,
val layout: String = "default",
val enableReleases: Boolean = true,
val enableSnapshots: Boolean = true,
// 可能要设计个 type 来判断解析成什么类型的 Authentication.
val authentication: Authentication? = null
)
/**
* ScalaBot App 配置.
*
* App 配置信息与 BotConfig 分开, 分别存储在各自单独的文件中.
*
* 使用 Gson 解析时, 请添加以下类型适配器:
* - [net.lamgc.scalabot.config.serializer.ProxyTypeSerializer]
* - [net.lamgc.scalabot.config.serializer.MavenRepositoryConfigSerializer]
* - [net.lamgc.scalabot.config.serializer.AuthenticationSerializer]
* - [net.lamgc.scalabot.config.serializer.UsernameAuthenticatorSerializer]
*
* @property proxy Telegram API 代理配置.
* @property metrics 运行指标数据配置. 可通过时序数据库记录运行数据.
* @property mavenRepositories Maven 远端仓库配置.
* @property mavenLocalRepository Maven 本地仓库路径. 相对于运行目录 (而不是 DATA_ROOT 目录)
*/
data class AppConfig(
val proxy: ProxyConfig = ProxyConfig(),
val metrics: MetricsConfig = MetricsConfig(),
val mavenRepositories: List<MavenRepositoryConfig> = emptyList(),
val mavenLocalRepository: String? = null
)

View File

@ -1,26 +0,0 @@
package net.lamgc.scalabot.config
import com.google.gson.JsonObject
import com.sun.net.httpserver.BasicAuthenticator
class UsernameAuthenticator(private val username: String, private val password: String) :
BasicAuthenticator("metrics") {
override fun checkCredentials(username: String?, password: String?): Boolean =
this.username == username && this.password == password
fun toJsonObject(): JsonObject = JsonObject().apply {
addProperty("username", username)
addProperty("password", password)
}
override fun equals(other: Any?): Boolean {
return other is UsernameAuthenticator && this.username == other.username && this.password == other.password
}
override fun hashCode(): Int {
var result = username.hashCode()
result = 31 * result + password.hashCode()
return result
}
}

View File

@ -1,300 +0,0 @@
package net.lamgc.scalabot.config.serializer
import com.google.gson.*
import com.google.gson.reflect.TypeToken
import net.lamgc.scalabot.config.*
import net.lamgc.scalabot.config.serializer.SerializeUtils.getPrimitiveValueOrThrow
import org.eclipse.aether.artifact.AbstractArtifact
import org.eclipse.aether.artifact.Artifact
import org.eclipse.aether.artifact.DefaultArtifact
import org.eclipse.aether.repository.Authentication
import org.eclipse.aether.repository.Proxy
import org.eclipse.aether.util.repository.AuthenticationBuilder
import java.lang.reflect.Type
import java.net.MalformedURLException
import java.net.URL
import java.util.regex.Pattern
object ProxyTypeSerializer : JsonDeserializer<ProxyType>,
JsonSerializer<ProxyType> {
override fun deserialize(
json: JsonElement,
typeOfT: Type?,
context: JsonDeserializationContext?
): ProxyType {
if (json.isJsonNull) {
return ProxyType.NO_PROXY
}
if (!json.isJsonPrimitive) {
throw JsonParseException("Wrong configuration value type.")
}
val value = json.asString.trim()
try {
return ProxyType.valueOf(value.uppercase())
} catch (e: IllegalArgumentException) {
throw JsonParseException("Invalid value: $value")
}
}
override fun serialize(
src: ProxyType,
typeOfSrc: Type?,
context: JsonSerializationContext?
): JsonElement {
return JsonPrimitive(src.toString())
}
}
object ArtifactSerializer : JsonSerializer<Artifact>, JsonDeserializer<Artifact> {
override fun serialize(src: Artifact, typeOfSrc: Type?, context: JsonSerializationContext?): JsonElement {
return if (src is AbstractArtifact) {
JsonPrimitive(src.toString())
} else {
JsonPrimitive(
DefaultArtifact(
src.groupId,
src.artifactId,
src.classifier,
src.extension,
src.version
).toString()
)
}
}
override fun deserialize(json: JsonElement, typeOfT: Type?, context: JsonDeserializationContext?): Artifact {
if (!json.isJsonPrimitive) {
throw JsonParseException("Wrong configuration value type.")
}
val artifactStr = json.asString.trim()
try {
return DefaultArtifact(artifactStr)
} catch (e: IllegalArgumentException) {
throw JsonParseException("Invalid artifact format: `${artifactStr}`.")
}
}
}
object AuthenticationSerializer : JsonDeserializer<Authentication> {
override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): Authentication {
if (json !is JsonObject) {
throw JsonParseException("Unsupported JSON type.")
}
val username = json.getPrimitiveValueOrThrow("username").asString
val password = json.getPrimitiveValueOrThrow("password").asString
val builder = AuthenticationBuilder()
builder.addUsername(username)
builder.addPassword(password)
return builder.build()
}
}
internal object SerializeUtils {
fun JsonObject.getPrimitiveValueOrThrow(fieldName: String): JsonPrimitive {
val value = get(fieldName) ?: throw JsonParseException("Missing `$fieldName` field.")
if (value !is JsonPrimitive) {
throw JsonParseException("Invalid `account` field type.")
}
return value
}
}
object MavenRepositoryConfigSerializer
: JsonDeserializer<MavenRepositoryConfig> {
override fun deserialize(
json: JsonElement,
typeOfT: Type,
context: JsonDeserializationContext
): MavenRepositoryConfig {
return when (json) {
is JsonObject -> {
MavenRepositoryConfig(
id = json.get("id")?.asString,
url = URL(json.getPrimitiveValueOrThrow("url").asString),
proxy = if (json.has("proxy"))
context.deserialize<Proxy>(
json.get("proxy"), Proxy::class.java
) else null,
layout = json.get("layout")?.asString ?: "default",
enableReleases = json.get("enableReleases")?.asBoolean ?: true,
enableSnapshots = json.get("enableSnapshots")?.asBoolean ?: true,
authentication = if (json.has("authentication"))
context.deserialize<Authentication>(
json.get("authentication"), Authentication::class.java
) else null
)
}
is JsonPrimitive -> {
try {
return MavenRepositoryConfig(url = URL(json.asString))
} catch (e: MalformedURLException) {
throw JsonParseException("Invalid URL: ${json.asString}", e)
}
}
else -> {
throw JsonParseException("Unsupported Maven repository configuration type. (Only support JSON object or url string)")
}
}
}
}
object UsernameAuthenticatorSerializer : JsonSerializer<UsernameAuthenticator>,
JsonDeserializer<UsernameAuthenticator> {
override fun serialize(
src: UsernameAuthenticator,
typeOfSrc: Type?,
context: JsonSerializationContext?
): JsonElement {
return src.toJsonObject()
}
override fun deserialize(
json: JsonElement,
typeOfT: Type?,
context: JsonDeserializationContext?
): UsernameAuthenticator? {
if (json.isJsonNull) {
return null
} else if (!json.isJsonObject) {
throw JsonParseException("Invalid attribute value type.")
}
val jsonObj = json.asJsonObject
if (jsonObj["username"]?.isJsonPrimitive != true) {
throw JsonParseException("Invalid attribute value: username")
} else if (jsonObj["password"]?.isJsonPrimitive != true) {
throw JsonParseException("Invalid attribute value: password")
}
if (jsonObj["username"].asString.isEmpty() || jsonObj["password"].asString.isEmpty()) {
throw JsonParseException("`username` or `password` is empty.")
}
return UsernameAuthenticator(jsonObj["username"].asString, jsonObj["password"].asString)
}
}
object ProxyConfigSerializer : JsonSerializer<ProxyConfig>, JsonDeserializer<ProxyConfig> {
override fun serialize(src: ProxyConfig?, typeOfSrc: Type?, context: JsonSerializationContext?): JsonElement {
if (src == null) {
return JsonNull.INSTANCE
}
return JsonObject().apply {
addProperty("type", src.type.name)
addProperty("host", src.host)
addProperty("port", src.port)
}
}
override fun deserialize(json: JsonElement?, typeOfT: Type?, context: JsonDeserializationContext?): ProxyConfig {
if (json == null || json.isJsonNull) {
return ProxyConfig()
} else if (json !is JsonObject) {
throw JsonParseException("Invalid json type.")
}
val typeStr = json["type"]?.asString ?: return ProxyConfig()
val type = try {
ProxyType.valueOf(typeStr)
} catch (e: IllegalArgumentException) {
throw JsonParseException("Invalid proxy type: `$typeStr`")
}
if (!json.has("host") || !json.has("port")) {
throw JsonParseException("Missing `host` field or `port` field.")
}
return ProxyConfig(
type = type,
host = json["host"].asString,
port = json["port"].asInt
)
}
}
object BotConfigSerializer : JsonSerializer<BotConfig>, JsonDeserializer<BotConfig> {
private val defaultConfig = BotConfig(account = BotAccount("__Default__", "__Default__", 0))
override fun serialize(src: BotConfig, typeOfSrc: Type, context: JsonSerializationContext): JsonElement {
return JsonObject().apply {
addProperty("enabled", src.enabled)
add("account", context.serialize(src.account))
addProperty("disableBuiltInAbility", src.disableBuiltInAbility)
addProperty("autoUpdateCommandList", src.autoUpdateCommandList)
add("extensions", context.serialize(src.extensions))
add("proxy", ProxyConfigSerializer.serialize(src.proxy, ProxyConfig::class.java, context))
addProperty("baseApiUrl", src.baseApiUrl)
}
}
override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): BotConfig {
if (json !is JsonObject) {
throw JsonParseException("Unsupported JSON type.")
}
if (!json.has("account")) {
throw JsonParseException("Missing `account` field.")
} else if (!json.get("account").isJsonObject) {
throw JsonParseException("Invalid `account` field type.")
}
// 从 json 反序列化 BotConfig使用构造函数
return BotConfig(
enabled = json.get("enabled")?.asBoolean ?: defaultConfig.enabled,
account = context.deserialize(json.get("account"), BotAccount::class.java)!!,
disableBuiltInAbility = json.get("disableBuiltInAbility")?.asBoolean ?: defaultConfig.disableBuiltInAbility,
autoUpdateCommandList = json.get("autoUpdateCommandList")?.asBoolean ?: defaultConfig.autoUpdateCommandList,
extensions = context.deserialize(json.get("extensions"), object : TypeToken<Set<Artifact>>() {}.type)
?: defaultConfig.extensions,
proxy = context.deserialize(json.get("proxy"), ProxyConfig::class.java) ?: defaultConfig.proxy,
baseApiUrl = json.get("baseApiUrl")?.asString ?: defaultConfig.baseApiUrl
)
}
}
object BotAccountSerializer : JsonDeserializer<BotAccount> {
private val tokenCheckRegex = Pattern.compile("\\d+:[a-zA-Z\\d_-]{35}")
override fun deserialize(json: JsonElement?, typeOfT: Type?, context: JsonDeserializationContext?): BotAccount {
if (json == null || json.isJsonNull) {
throw JsonParseException("Missing `account` field.")
} else if (!json.isJsonObject) {
throw JsonParseException("Invalid `account` field type.")
}
val jsonObj = json.asJsonObject
val name = jsonObj.getPrimitiveValueOrThrow("name").asString
val token = jsonObj.getPrimitiveValueOrThrow("token").asString.let {
if (it.isEmpty()) {
throw JsonParseException("`token` cannot be empty.")
} else if (!tokenCheckRegex.matcher(it).matches()) {
throw JsonParseException("`token` is invalid.")
} else {
it
}
}
val creatorId = try {
jsonObj.getPrimitiveValueOrThrow("creatorId").asLong
} catch (e: NumberFormatException) {
throw JsonParseException("`creatorId` must be a number.")
}.apply {
if (this < 0) {
throw JsonParseException("`creatorId` must be a positive number.")
}
}
return BotAccount(name, token, creatorId)
}
}

View File

@ -1,522 +0,0 @@
package net.lamgc.scalabot.config
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.JsonObject
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import net.lamgc.scalabot.config.serializer.*
import org.eclipse.aether.artifact.Artifact
import org.eclipse.aether.artifact.DefaultArtifact
import org.eclipse.aether.repository.Authentication
import org.eclipse.aether.repository.AuthenticationContext
import org.eclipse.aether.repository.Proxy
import org.intellij.lang.annotations.Language
import org.junit.jupiter.api.Assertions
import java.net.URL
import java.util.*
import kotlin.math.abs
import kotlin.test.*
internal class BotAccountTest {
@Test
fun `id getter`() {
val accountId = abs(Random().nextInt()).toLong()
Assertions.assertEquals(accountId, BotAccount("Test", "${accountId}:AAHErDroUTznQsOd_oZPJ6cQEj4Z5mGHO10", 0).id)
}
private val gson = GsonBuilder()
.create()
@Test
fun deserializerTest() {
val accountId = abs(Random().nextInt()).toLong()
val creatorId = abs(Random().nextInt()).toLong()
val botAccountJsonObject = gson.fromJson(
"""
{
"name": "TestBot",
"token": "${accountId}:AAHErDroUTznQsOd_oZPJ6cQEj4Z5mGHO10",
"creatorId": $creatorId
}
""".trimIndent(), JsonObject::class.java
)
val botAccount = Gson().fromJson(botAccountJsonObject, BotAccount::class.java)
assertEquals(accountId, botAccount.id)
assertEquals("TestBot", botAccount.name)
assertEquals(creatorId, botAccount.creatorId)
assertEquals("${accountId}:AAHErDroUTznQsOd_oZPJ6cQEj4Z5mGHO10", botAccount.token)
}
@Test
fun serializerTest() {
val accountId = abs(Random().nextInt()).toLong()
val creatorId = abs(Random().nextInt()).toLong()
val botAccount = BotAccount("TestBot", "${accountId}:AAHErDroUTznQsOd_oZPJ6cQEj4Z5mGHO10", creatorId)
val botAccountJsonObject = gson.toJsonTree(botAccount)
assertTrue(botAccountJsonObject is JsonObject)
assertEquals(botAccount.name, botAccountJsonObject["name"].asString)
assertEquals(botAccount.token, botAccountJsonObject["token"].asString)
assertNull(botAccountJsonObject["id"])
Assertions.assertEquals(creatorId, botAccountJsonObject["creatorId"].asLong)
}
}
internal class BotConfigTest {
private val gson = GsonBuilder()
.registerTypeAdapter(BotConfig::class.java, BotConfigSerializer)
.registerTypeAdapter(Artifact::class.java, ArtifactSerializer)
.registerTypeAdapter(ProxyConfig::class.java, ProxyConfigSerializer)
.create()
@Test
fun `json serialize`() {
val minimumExpectConfig = BotConfig(
account = BotAccount(
name = "TestBot",
token = "123456789:AAHErDroUTznQsOd_oZPJ6cQEj4Z5mGHO10",
creatorId = 123456789L
),
)
val json = gson.toJsonTree(minimumExpectConfig)
assertTrue(json is JsonObject)
assertEquals(minimumExpectConfig.enabled, json.get("enabled").asBoolean)
assertEquals(minimumExpectConfig.account.name, json.get("account").asJsonObject.get("name").asString)
assertEquals(minimumExpectConfig.account.token, json.get("account").asJsonObject.get("token").asString)
assertEquals(minimumExpectConfig.account.creatorId, json.get("account").asJsonObject.get("creatorId").asLong)
assertNull(json.get("account").asJsonObject.get("id"))
assertEquals(minimumExpectConfig.proxy.host, json.get("proxy").asJsonObject.get("host").asString)
assertEquals(minimumExpectConfig.proxy.port, json.get("proxy").asJsonObject.get("port").asInt)
assertEquals(minimumExpectConfig.proxy.type.name, json.get("proxy").asJsonObject.get("type").asString)
assertEquals(minimumExpectConfig.disableBuiltInAbility, json.get("disableBuiltInAbility").asBoolean)
assertEquals(minimumExpectConfig.autoUpdateCommandList, json.get("autoUpdateCommandList").asBoolean)
assertNotNull(json.get("extensions"))
assertTrue(json.get("extensions").isJsonArray)
assertTrue(json.get("extensions").asJsonArray.isEmpty)
assertEquals(minimumExpectConfig.baseApiUrl, json.get("baseApiUrl").asString)
}
@Test
fun `json deserialize`() {
val expectExtensionArtifact = DefaultArtifact("org.example.test:test-extension:1.0.0")
@Language("JSON5") val looksGoodJson = """
{
"enabled": false,
"account": {
"name": "TestBot",
"token": "123456789:AAHErDroUTznQsOd_oZPJ6cQEj4Z5mGHO10",
"creatorId": 123456789
},
"proxy": {
"host": "localhost",
"port": 8080,
"type": "HTTP"
},
"disableBuiltInAbility": false,
"autoUpdateCommandList": true,
"extensions": [
"$expectExtensionArtifact"
],
"baseApiUrl": "http://localhost:8080"
}
""".trimIndent()
val actualConfig = gson.fromJson(looksGoodJson, BotConfig::class.java)
assertEquals(false, actualConfig.enabled)
assertEquals("TestBot", actualConfig.account.name)
assertEquals("123456789:AAHErDroUTznQsOd_oZPJ6cQEj4Z5mGHO10", actualConfig.account.token)
assertEquals(123456789L, actualConfig.account.creatorId)
assertEquals("localhost", actualConfig.proxy.host)
assertEquals(8080, actualConfig.proxy.port)
assertEquals(ProxyType.HTTP, actualConfig.proxy.type)
assertEquals(false, actualConfig.disableBuiltInAbility)
assertEquals(true, actualConfig.autoUpdateCommandList)
assertEquals(1, actualConfig.extensions.size)
assertEquals(expectExtensionArtifact, actualConfig.extensions.first())
assertEquals("http://localhost:8080", actualConfig.baseApiUrl)
}
@Test
fun `json deserialize - minimum parameters`() {
@Language("JSON5") val minimumLooksGoodJson = """
{
"account": {
"name": "TestBot",
"token": "123456789:AAHErDroUTznQsOd_oZPJ6cQEj4Z5mGHO10",
"creatorId": 123456789
}
}
""".trimIndent()
val expectDefaultConfig = BotConfig(account = BotAccount("Test", "Test", 0))
val actualMinimumConfig = gson.fromJson(minimumLooksGoodJson, BotConfig::class.java)
assertNotNull(actualMinimumConfig)
assertEquals("TestBot", actualMinimumConfig.account.name)
assertEquals("123456789:AAHErDroUTznQsOd_oZPJ6cQEj4Z5mGHO10", actualMinimumConfig.account.token)
assertEquals(123456789, actualMinimumConfig.account.creatorId)
assertEquals(expectDefaultConfig.enabled, actualMinimumConfig.enabled)
assertEquals(expectDefaultConfig.disableBuiltInAbility, actualMinimumConfig.disableBuiltInAbility)
assertEquals(expectDefaultConfig.autoUpdateCommandList, actualMinimumConfig.autoUpdateCommandList)
assertEquals(expectDefaultConfig.proxy, actualMinimumConfig.proxy)
assertEquals(expectDefaultConfig.baseApiUrl, actualMinimumConfig.baseApiUrl)
assertTrue(expectDefaultConfig.extensions.containsAll(actualMinimumConfig.extensions))
assertTrue(actualMinimumConfig.extensions.containsAll(expectDefaultConfig.extensions))
}
}
internal class ProxyConfigTest {
private val gson = GsonBuilder()
.registerTypeAdapter(ProxyConfig::class.java, ProxyConfigSerializer)
.registerTypeAdapter(ProxyType::class.java, ProxyTypeSerializer)
.create()
@Test
fun `json serialize`() {
val proxyConfig = ProxyConfig(
host = "localhost",
port = 8080,
type = ProxyType.HTTP
)
val json = gson.toJsonTree(proxyConfig)
assertTrue(json is JsonObject)
assertEquals(proxyConfig.host, json.get("host").asString)
assertEquals(proxyConfig.port, json.get("port").asInt)
assertEquals(proxyConfig.type.name, json.get("type").asString)
}
@Test
fun `json deserialize`() {
@Language("JSON5") val looksGoodJson = """
{
"host": "localhost",
"port": 8080,
"type": "HTTP"
}
""".trimIndent()
val actualConfig = gson.fromJson(looksGoodJson, ProxyConfig::class.java)
assertEquals("localhost", actualConfig.host)
assertEquals(8080, actualConfig.port)
assertEquals(ProxyType.HTTP, actualConfig.type)
}
@Test
fun `toString test`() {
assertEquals("NO_PROXY", ProxyConfig(ProxyType.NO_PROXY).toString())
assertEquals("HTTP://example.org:1008", ProxyConfig(ProxyType.HTTP, "example.org", 1008).toString())
}
}
internal class MetricsConfigTest {
private val gson = GsonBuilder()
.registerTypeAdapter(UsernameAuthenticator::class.java, UsernameAuthenticatorSerializer)
.create()
@Test
fun `json serializer`() {
val config = MetricsConfig(
enable = true,
port = 8800,
bindAddress = "127.0.0.1",
authenticator = UsernameAuthenticator(
username = "username",
password = "password"
)
)
val json = gson.toJsonTree(config).asJsonObject
assertEquals(config.enable, json.get("enable").asBoolean)
assertEquals(config.port, json.get("port").asInt)
assertEquals(config.bindAddress, json.get("bindAddress").asString)
assertNotNull(config.authenticator)
assertTrue(
config.authenticator!!.checkCredentials(
json.get("authenticator").asJsonObject.get("username").asString,
json.get("authenticator").asJsonObject.get("password").asString
)
)
val expectDefaultValueConfig = MetricsConfig()
val defaultValueJson = gson.toJsonTree(expectDefaultValueConfig).asJsonObject
assertEquals(expectDefaultValueConfig.enable, defaultValueJson.get("enable").asBoolean)
assertEquals(expectDefaultValueConfig.port, defaultValueJson.get("port").asInt)
assertEquals(expectDefaultValueConfig.bindAddress, defaultValueJson.get("bindAddress").asString)
assertNull(defaultValueJson.get("authenticator"))
}
@Test
fun `json deserializer`() {
val json = """
{
"enable": true,
"port": 8800,
"bindAddress": "127.0.0.1",
"authenticator": {
"username": "username",
"password": "password"
}
}
""".trimIndent()
val config = gson.fromJson(json, MetricsConfig::class.java)
assertEquals(true, config.enable)
assertEquals(8800, config.port)
assertEquals("127.0.0.1", config.bindAddress)
assertNotNull(config.authenticator)
assertTrue(config.authenticator!!.checkCredentials("username", "password"))
val defaultValueConfig = MetricsConfig()
val defaultValueJson = gson.toJsonTree(defaultValueConfig).asJsonObject
assertEquals(defaultValueConfig.enable, defaultValueJson.get("enable").asBoolean)
assertEquals(defaultValueConfig.port, defaultValueJson.get("port").asInt)
assertEquals(defaultValueConfig.bindAddress, defaultValueJson.get("bindAddress").asString)
assertNull(defaultValueJson.get("authenticator"))
}
@Test
fun `json deserializer - default value`() {
val actualConfig = gson.fromJson("{}", MetricsConfig::class.java)
val expectConfig = MetricsConfig()
assertEquals(expectConfig, actualConfig)
}
}
internal class MavenRepositoryConfigTest {
private val gson = GsonBuilder()
.registerTypeAdapter(MavenRepositoryConfig::class.java, MavenRepositoryConfigSerializer)
.registerTypeAdapter(Authentication::class.java, AuthenticationSerializer)
.create()
@Test
fun `json serializer`() {
val config = MavenRepositoryConfig(
id = "test",
url = URL("http://localhost:8080/repository"),
proxy = Proxy(
"http",
"localhost",
8080,
),
layout = "legacy",
enableReleases = false,
enableSnapshots = true,
authentication = null
)
val json = gson.toJsonTree(config).asJsonObject
assertEquals(config.id, json.get("id").asString)
assertEquals(config.url.toString(), json.get("url").asString)
assertEquals(config.proxy!!.host, json.get("proxy").asJsonObject.get("host").asString)
assertEquals(config.proxy!!.port, json.get("proxy").asJsonObject.get("port").asInt)
assertEquals(config.proxy!!.type, json.get("proxy").asJsonObject.get("type").asString)
assertEquals(config.layout, json.get("layout").asString)
assertEquals(config.enableReleases, json.get("enableReleases").asBoolean)
assertEquals(config.enableSnapshots, json.get("enableSnapshots").asBoolean)
assertNull(json.get("authentication"))
}
@Test
fun `json deserializer`() {
@Language("JSON5")
val json = """
{
"id": "test",
"url": "http://localhost:8080/repository",
"proxy": {
"host": "localhost",
"port": 8080,
"type": "HTTP"
},
"layout": "legacy",
"enableReleases": false,
"enableSnapshots": true,
"authentication": {
"username": "testUser",
"password": "testPassword"
}
}
""".trimIndent()
val config = gson.fromJson(json, MavenRepositoryConfig::class.java)
assertEquals("test", config.id)
assertEquals(URL("http://localhost:8080/repository"), config.url)
assertEquals(
Proxy(
"HTTP",
"localhost",
8080
), config.proxy
)
assertEquals("legacy", config.layout)
assertEquals(false, config.enableReleases)
assertEquals(true, config.enableSnapshots)
assertNotNull(config.authentication)
val authContext = mockk<AuthenticationContext> {
every { put(ofType(String::class), any()) } answers { }
}
config.authentication!!.fill(authContext, null, emptyMap())
verify {
authContext.put(any(), "testUser")
authContext.put(any(), "testPassword".toCharArray())
}
}
}
internal class AppConfigTest {
private val gson = GsonBuilder()
.registerTypeAdapter(ProxyType::class.java, ProxyTypeSerializer)
.registerTypeAdapter(ProxyConfig::class.java, ProxyConfigSerializer)
.registerTypeAdapter(UsernameAuthenticator::class.java, UsernameAuthenticatorSerializer)
.registerTypeAdapter(MavenRepositoryConfig::class.java, MavenRepositoryConfigSerializer)
.create()
@Test
fun `json serializer - default value`() {
val config = AppConfig()
val json = gson.toJsonTree(config).asJsonObject
assertEquals(config.proxy.type.name, json.get("proxy").asJsonObject.get("type").asString)
assertEquals(config.proxy.host, json.get("proxy").asJsonObject.get("host").asString)
assertEquals(config.proxy.port, json.get("proxy").asJsonObject.get("port").asInt)
assertEquals(config.metrics.enable, json.get("metrics").asJsonObject.get("enable").asBoolean)
assertEquals(config.metrics.port, json.get("metrics").asJsonObject.get("port").asInt)
assertEquals(config.metrics.bindAddress, json.get("metrics").asJsonObject.get("bindAddress").asString)
assertNull(json["metrics"].asJsonObject.get("authenticator"))
assertTrue(json["mavenRepositories"].asJsonArray.isEmpty)
assertNull(json["mavenLocalRepository"])
}
@Test
fun `json serializer - Provide values`() {
val config = AppConfig(
proxy = ProxyConfig(
type = ProxyType.HTTP,
host = "localhost",
port = 8080
),
metrics = MetricsConfig(
enable = true,
port = 8800,
bindAddress = "127.0.0.1",
authenticator = UsernameAuthenticator(
username = "username",
password = "password"
)
),
mavenRepositories = listOf(
MavenRepositoryConfig(
url = URL("https://repository.maven.apache.org/maven2/")
)
),
mavenLocalRepository = "file:///tmp/maven-local-repository"
)
val json = gson.toJsonTree(config).asJsonObject
assertEquals(config.proxy.type.name, json.get("proxy").asJsonObject.get("type").asString)
assertEquals(config.proxy.host, json.get("proxy").asJsonObject.get("host").asString)
assertEquals(config.proxy.port, json.get("proxy").asJsonObject.get("port").asInt)
assertEquals(config.metrics.enable, json.get("metrics").asJsonObject.get("enable").asBoolean)
assertEquals(config.metrics.port, json.get("metrics").asJsonObject.get("port").asInt)
assertEquals(config.metrics.bindAddress, json.get("metrics").asJsonObject.get("bindAddress").asString)
assertNotNull(config.metrics.authenticator)
assertTrue(
config.metrics.authenticator!!.checkCredentials(
json.get("metrics").asJsonObject.get("authenticator").asJsonObject.get("username").asString,
json.get("metrics").asJsonObject.get("authenticator").asJsonObject.get("password").asString
)
)
assertEquals(1, json["mavenRepositories"].asJsonArray.size())
assertEquals(
config.mavenRepositories[0].url.toString(),
json["mavenRepositories"].asJsonArray[0].asJsonObject.get("url").asString
)
assertEquals(config.mavenLocalRepository, json["mavenLocalRepository"].asString)
}
@Test
fun `json deserializer - complete`() {
val json = """
{
"proxy": {
"type": "HTTP",
"host": "localhost",
"port": 8080
},
"metrics": {
"enable": true,
"port": 8800,
"bindAddress": "127.0.0.1",
"authenticator": {
"username": "username",
"password": "password"
}
},
"mavenRepositories": [
{
"url": "https://repository.maven.apache.org/maven2/"
}
],
"mavenLocalRepository": "file:///tmp/maven-local-repository"
}
""".trimIndent()
val config = gson.fromJson(json, AppConfig::class.java)
assertEquals(ProxyType.HTTP, config.proxy.type)
assertEquals("localhost", config.proxy.host)
assertEquals(8080, config.proxy.port)
assertEquals(true, config.metrics.enable)
assertEquals(8800, config.metrics.port)
assertEquals("127.0.0.1", config.metrics.bindAddress)
assertNotNull(config.metrics.authenticator)
assertTrue(config.metrics.authenticator!!.checkCredentials("username", "password"))
assertEquals(1, config.mavenRepositories.size)
assertEquals(URL("https://repository.maven.apache.org/maven2/"), config.mavenRepositories[0].url)
assertEquals("file:///tmp/maven-local-repository", config.mavenLocalRepository)
}
@Test
fun `json deserializer - default value`() {
val actualConfig = gson.fromJson("{}", AppConfig::class.java)
val expectConfig = AppConfig()
assertEquals(expectConfig, actualConfig)
}
}

View File

@ -1,38 +0,0 @@
package net.lamgc.scalabot.config
import kotlin.test.*
internal class UsernameAuthenticatorTest {
@Test
fun checkCredentialsTest() {
val authenticator = UsernameAuthenticator("testUser", "testPassword")
assertTrue(authenticator.checkCredentials("testUser", "testPassword"))
assertFalse(authenticator.checkCredentials("falseUser", "testPassword"))
assertFalse(authenticator.checkCredentials("testUser", "falsePassword"))
assertFalse(authenticator.checkCredentials("falseUser", "falsePassword"))
}
@Test
fun toJsonObjectTest() {
val authenticator = UsernameAuthenticator("testUser", "testPassword")
val jsonObject = authenticator.toJsonObject()
assertEquals("testUser", jsonObject["username"]?.asString)
assertEquals("testPassword", jsonObject["password"]?.asString)
}
@Test
fun equalsTest() {
val authenticator = UsernameAuthenticator("testUser", "testPassword")
assertEquals(authenticator, UsernameAuthenticator("testUser", "testPassword"))
assertEquals(authenticator.hashCode(), UsernameAuthenticator("testUser", "testPassword").hashCode())
assertNotEquals(authenticator, UsernameAuthenticator("testUser", "falsePassword"))
assertNotEquals(authenticator.hashCode(), UsernameAuthenticator("testUser", "falsePassword").hashCode())
assertNotEquals(authenticator, UsernameAuthenticator("falseUser", "testPassword"))
assertNotEquals(authenticator.hashCode(), UsernameAuthenticator("falseUser", "testPassword").hashCode())
assertNotEquals(authenticator, UsernameAuthenticator("falseUser", "falsePassword"))
assertNotEquals(authenticator.hashCode(), UsernameAuthenticator("falseUser", "falsePassword").hashCode())
assertFalse(authenticator.equals(null))
}
}

View File

@ -1,852 +0,0 @@
package net.lamgc.scalabot.config.serializer
import com.google.gson.*
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import net.lamgc.scalabot.config.*
import net.lamgc.scalabot.config.serializer.SerializeUtils.getPrimitiveValueOrThrow
import org.eclipse.aether.artifact.Artifact
import org.eclipse.aether.artifact.DefaultArtifact
import org.eclipse.aether.repository.Authentication
import org.eclipse.aether.repository.AuthenticationContext
import org.eclipse.aether.repository.Proxy
import org.intellij.lang.annotations.Language
import org.junit.jupiter.api.Assertions.assertThrows
import java.lang.reflect.Type
import java.net.URL
import kotlin.test.*
internal class SerializeUtilsTest {
@Test
fun `getPrimitiveValueOrThrow test`() {
assertThrows(JsonParseException::class.java) {
JsonObject().getPrimitiveValueOrThrow("NOT_EXIST_KEY")
}
assertThrows(JsonParseException::class.java) {
JsonObject().apply {
add("testKey", JsonArray())
}.getPrimitiveValueOrThrow("testKey")
}
assertThrows(JsonParseException::class.java) {
JsonObject().apply {
add("testKey", JsonObject())
}.getPrimitiveValueOrThrow("testKey")
}
assertThrows(JsonParseException::class.java) {
JsonObject().apply {
add("testKey", JsonNull.INSTANCE)
}.getPrimitiveValueOrThrow("testKey")
}
val expectKey = "STRING_KEY"
val expectValue = JsonPrimitive("A STRING")
assertEquals(expectValue, JsonObject()
.apply { add(expectKey, expectValue) }
.getPrimitiveValueOrThrow(expectKey))
}
}
internal class ProxyTypeSerializerTest {
@Test
fun `serialize test`() {
for (type in ProxyType.entries) {
assertEquals(
JsonPrimitive(type.name), ProxyTypeSerializer.serialize(type, null, null),
"ProxyType 序列化结果与预期不符."
)
}
}
@Test
fun `deserialize test`() {
assertThrows(JsonParseException::class.java) {
ProxyTypeSerializer.deserialize(JsonObject(), null, null)
}
assertThrows(JsonParseException::class.java) {
ProxyTypeSerializer.deserialize(JsonArray(), null, null)
}
assertThrows(JsonParseException::class.java) {
ProxyTypeSerializer.deserialize(JsonPrimitive("NOT_IN_ENUM_VALUE"), null, null)
}
assertEquals(
ProxyType.NO_PROXY,
ProxyTypeSerializer.deserialize(JsonNull.INSTANCE, null, null)
)
for (type in ProxyType.entries) {
assertEquals(
type, ProxyTypeSerializer.deserialize(JsonPrimitive(type.name), null, null),
"ProxyType 反序列化结果与预期不符."
)
assertEquals(
type, ProxyTypeSerializer.deserialize(JsonPrimitive(" ${type.name} "), null, null),
"ProxyType 反序列化时未对 Json 字符串进行修剪(trim)."
)
}
}
}
internal class MavenRepositoryConfigSerializerTest {
@Test
fun `unsupported json type deserialize test`() {
assertThrows(JsonParseException::class.java) {
MavenRepositoryConfigSerializer.deserialize(
JsonArray(),
MavenRepositoryConfig::class.java,
TestJsonSerializationContext.default()
)
}
assertThrows(JsonParseException::class.java) {
MavenRepositoryConfigSerializer.deserialize(
JsonNull.INSTANCE,
MavenRepositoryConfig::class.java,
TestJsonSerializationContext.default()
)
}
}
@Test
fun `json primitive deserialize test`() {
assertThrows(JsonParseException::class.java) {
MavenRepositoryConfigSerializer.deserialize(
JsonPrimitive("NOT A URL."),
MavenRepositoryConfig::class.java,
TestJsonSerializationContext.default()
)
}
val expectRepoUrl = "https://repo.example.org/maven"
val config = MavenRepositoryConfigSerializer.deserialize(
JsonPrimitive(expectRepoUrl),
MavenRepositoryConfig::class.java,
TestJsonSerializationContext.default()
)
assertNull(config.id)
assertEquals(URL(expectRepoUrl), config.url)
assertNull(config.proxy, "Proxy 默认值不为 null.")
assertEquals("default", config.layout)
assertTrue(config.enableReleases)
assertTrue(config.enableSnapshots)
assertNull(config.authentication)
}
@Test
fun `json object default deserialize test`() {
val expectRepoUrl = "https://repo.example.org/maven"
val jsonObject = JsonObject()
jsonObject.addProperty("url", expectRepoUrl)
val config = MavenRepositoryConfigSerializer.deserialize(
jsonObject,
MavenRepositoryConfig::class.java,
TestJsonSerializationContext.default()
)
assertNull(config.id)
assertEquals(URL(expectRepoUrl), config.url)
assertNull(config.proxy, "Proxy 默认值不为 null.")
assertEquals("default", config.layout)
assertTrue(config.enableReleases)
assertTrue(config.enableSnapshots)
assertNull(config.authentication)
}
@Test
fun `json object deserialize test`() {
@Language("JSON5")
val looksGoodJsonString = """
{
"id": "test-repository",
"url": "https://repo.example.org/maven",
"proxy": {
"type": "http",
"host": "127.0.1.1",
"port": 10800
},
"layout": "default",
"enableReleases": false,
"enableSnapshots": true
}
""".trimIndent()
val jsonObject = Gson().fromJson(looksGoodJsonString, JsonObject::class.java)
var config = MavenRepositoryConfigSerializer.deserialize(
jsonObject,
MavenRepositoryConfig::class.java,
TestJsonSerializationContext.default()
)
assertEquals(jsonObject["id"].asString, config.id)
assertEquals(URL(jsonObject["url"].asString), config.url)
assertEquals(Proxy("http", "127.0.1.1", 10800), config.proxy)
assertEquals(jsonObject["layout"].asString, config.layout)
assertEquals(jsonObject["enableReleases"].asBoolean, config.enableReleases)
assertEquals(jsonObject["enableSnapshots"].asBoolean, config.enableSnapshots)
// ------------------------------------
jsonObject.add("proxy", JsonNull.INSTANCE)
jsonObject.remove("layout")
config = MavenRepositoryConfigSerializer.deserialize(
jsonObject,
MavenRepositoryConfig::class.java,
TestJsonSerializationContext.default()
)
assertEquals(jsonObject["id"].asString, config.id)
assertEquals(URL(jsonObject["url"].asString), config.url)
assertNull(config.proxy)
assertEquals("default", config.layout)
assertEquals(jsonObject["enableReleases"].asBoolean, config.enableReleases)
assertEquals(jsonObject["enableSnapshots"].asBoolean, config.enableSnapshots)
// ------------------------------------
jsonObject.add("layout", mockk<JsonPrimitive> {
every { asString }.returns(null)
})
config = MavenRepositoryConfigSerializer.deserialize(
jsonObject,
MavenRepositoryConfig::class.java,
TestJsonSerializationContext.default()
)
assertEquals(jsonObject["id"].asString, config.id)
assertEquals(URL(jsonObject["url"].asString), config.url)
assertNull(config.proxy)
assertEquals("default", config.layout)
assertEquals(jsonObject["enableReleases"].asBoolean, config.enableReleases)
assertEquals(jsonObject["enableSnapshots"].asBoolean, config.enableSnapshots)
assertNull(config.authentication)
// ------------------------------------
jsonObject.add("authentication", JsonObject().apply {
addProperty("username", "testUsername")
addProperty("password", "testPassword")
})
config = MavenRepositoryConfigSerializer.deserialize(
jsonObject,
MavenRepositoryConfig::class.java,
TestJsonSerializationContext.default()
)
assertEquals(jsonObject["id"].asString, config.id)
assertEquals(URL(jsonObject["url"].asString), config.url)
assertNull(config.proxy)
assertEquals("default", config.layout)
assertEquals(jsonObject["enableReleases"].asBoolean, config.enableReleases)
assertEquals(jsonObject["enableSnapshots"].asBoolean, config.enableSnapshots)
assertNotNull(config.authentication)
}
}
private class TestJsonSerializationContext(private val gson: Gson) : JsonDeserializationContext,
JsonSerializationContext {
override fun <T : Any?> deserialize(json: JsonElement?, typeOfT: Type): T {
return gson.fromJson(json, typeOfT)
}
companion object {
fun default(): TestJsonSerializationContext {
return TestJsonSerializationContext(
GsonBuilder()
.registerTypeAdapter(MavenRepositoryConfig::class.java, MavenRepositoryConfigSerializer)
.registerTypeAdapter(BotConfig::class.java, BotConfigSerializer)
.registerTypeAdapter(ProxyType::class.java, ProxyTypeSerializer)
.registerTypeAdapter(Artifact::class.java, ArtifactSerializer)
.registerTypeAdapter(Authentication::class.java, AuthenticationSerializer)
.registerTypeAdapter(UsernameAuthenticator::class.java, UsernameAuthenticatorSerializer)
.registerTypeAdapter(ProxyConfig::class.java, ProxyConfigSerializer)
.create()
)
}
}
override fun serialize(src: Any?): JsonElement {
return gson.toJsonTree(src)
}
override fun serialize(src: Any?, typeOfSrc: Type?): JsonElement {
return gson.toJsonTree(src, typeOfSrc)
}
}
internal class AuthenticationSerializerTest {
@Test
fun `deserialize test`() {
assertThrows(JsonParseException::class.java) {
AuthenticationSerializer.deserialize(
JsonNull.INSTANCE,
Authentication::class.java, TestJsonSerializationContext.default()
)
}
assertThrows(JsonParseException::class.java) {
AuthenticationSerializer.deserialize(
JsonArray(),
Authentication::class.java, TestJsonSerializationContext.default()
)
}
assertThrows(JsonParseException::class.java) {
AuthenticationSerializer.deserialize(
JsonPrimitive("A STRING"),
Authentication::class.java, TestJsonSerializationContext.default()
)
}
val expectJsonObject = JsonObject().apply {
addProperty("username", "testUsername")
addProperty("password", "testPassword")
}
val mockContext = mockk<AuthenticationContext> {
every { put(any(), any()) }.answers { }
}
val result = AuthenticationSerializer.deserialize(
expectJsonObject,
Authentication::class.java, TestJsonSerializationContext.default()
)
assertNotNull(result)
result.fill(mockContext, "username", null)
result.fill(mockContext, "password", null)
verify {
mockContext.put("username", "testUsername")
mockContext.put("password", "testPassword".toCharArray())
}
}
}
internal class BotConfigSerializerTest {
private val gson = GsonBuilder()
.registerTypeAdapter(BotConfig::class.java, BotConfigSerializer)
.registerTypeAdapter(Artifact::class.java, ArtifactSerializer)
.registerTypeAdapter(ProxyType::class.java, ProxyTypeSerializer)
.registerTypeAdapter(ProxyConfig::class.java, ProxyConfigSerializer)
.create()
@Test
fun `serializer test`() {
// 检查 BotConfig 的序列化
val botConfig = BotConfig(
account = BotAccount(
name = "test-bot",
token = "test-token",
creatorId = 10000
)
)
// 使用 gson 序列化 botConfig, 并检查序列化结果
val jsonObject = gson.toJsonTree(botConfig) as JsonObject
assertEquals("test-bot", jsonObject["account"].asJsonObject["name"].asString)
assertEquals("test-token", jsonObject["account"].asJsonObject["token"].asString)
assertEquals(10000, jsonObject["account"].asJsonObject["creatorId"].asInt)
assertEquals(botConfig.enabled, jsonObject["enabled"].asBoolean)
assertEquals(botConfig.proxy.host, jsonObject["proxy"].asJsonObject["host"].asString)
assertEquals(botConfig.proxy.port, jsonObject["proxy"].asJsonObject["port"].asInt)
assertEquals(botConfig.proxy.type.name, jsonObject["proxy"].asJsonObject["type"].asString)
assertEquals(botConfig.disableBuiltInAbility, jsonObject["disableBuiltInAbility"].asBoolean)
assertEquals(botConfig.autoUpdateCommandList, jsonObject["autoUpdateCommandList"].asBoolean)
assertEquals(botConfig.extensions.isEmpty(), jsonObject["extensions"].asJsonArray.isEmpty)
assertEquals(botConfig.baseApiUrl, jsonObject["baseApiUrl"].asString)
}
@Test
fun `deserialize test`() {
assertThrows(JsonParseException::class.java) {
BotConfigSerializer.deserialize(
JsonNull.INSTANCE,
BotConfig::class.java, TestJsonSerializationContext(gson)
)
}
assertThrows(JsonParseException::class.java) {
BotConfigSerializer.deserialize(
JsonArray(),
BotConfig::class.java, TestJsonSerializationContext(gson)
)
}
assertThrows(JsonParseException::class.java) {
BotConfigSerializer.deserialize(
JsonPrimitive("A STRING"),
BotConfig::class.java, TestJsonSerializationContext(gson)
)
}
// 检查 BotConfig 的反序列化中是否能正确判断 account 的类型
assertThrows(JsonParseException::class.java) {
BotConfigSerializer.deserialize(
JsonObject().apply {
addProperty("account", "A STRING")
},
BotConfig::class.java, TestJsonSerializationContext(gson)
)
}
assertThrows(JsonParseException::class.java) {
BotConfigSerializer.deserialize(
JsonObject().apply {
add("account", JsonNull.INSTANCE)
},
BotConfig::class.java, TestJsonSerializationContext(gson)
)
}
assertThrows(JsonParseException::class.java) {
BotConfigSerializer.deserialize(
JsonObject().apply {
add("account", JsonArray())
},
BotConfig::class.java, TestJsonSerializationContext(gson)
)
}
assertThrows(JsonParseException::class.java) {
BotConfigSerializer.deserialize(
JsonObject(),
BotConfig::class.java, TestJsonSerializationContext(gson)
)
}
val expectBotAccount = BotAccount(
name = "test-bot",
token = "test-token",
creatorId = 10000
)
val expectDefaultBotConfig = BotConfig(account = expectBotAccount)
val minimumJsonObject = JsonObject().apply {
add("account", gson.toJsonTree(expectBotAccount))
}
val actualMinimumBotConfig = BotConfigSerializer.deserialize(
minimumJsonObject, BotConfig::class.java, TestJsonSerializationContext(gson)
)
assertNotNull(actualMinimumBotConfig)
assertEquals(expectDefaultBotConfig, actualMinimumBotConfig)
val expectDefaultProxy = ProxyConfig(
type = ProxyType.HTTP,
host = "https://example.com",
port = 443
)
// -------------------------------------------------
val jsonObject = JsonObject().apply {
add(
"account", gson.toJsonTree(
BotAccount(
name = "test-bot",
token = "test-token",
creatorId = 10000
)
)
)
addProperty("enabled", true)
add("proxy", gson.toJsonTree(expectDefaultProxy))
addProperty("disableBuiltInAbility", true)
addProperty("autoUpdateCommandList", true)
addProperty("baseApiUrl", "https://test.com")
add("extensions", JsonArray().apply {
add("org.example:test:1.0.0-SNAPSHOT")
})
}
val botConfig = BotConfigSerializer.deserialize(
jsonObject,
BotConfig::class.java, TestJsonSerializationContext(gson)
)
assertEquals("test-bot", botConfig.account.name)
assertEquals("test-token", botConfig.account.token)
assertEquals(10000, botConfig.account.creatorId)
assertEquals(true, botConfig.enabled)
assertEquals(expectDefaultProxy, botConfig.proxy)
assertEquals(true, botConfig.disableBuiltInAbility)
assertEquals(true, botConfig.autoUpdateCommandList)
assertEquals("https://test.com", botConfig.baseApiUrl)
assertEquals(false, botConfig.extensions.isEmpty())
assertEquals(1, botConfig.extensions.size)
}
}
internal class ProxyConfigSerializerTest {
// 测试 ProxyConfig 的 Json 序列化
@Test
fun `serialize test`() {
assertEquals(JsonNull.INSTANCE, ProxyConfigSerializer.serialize(null, null, null))
val expectDefaultConfig = ProxyConfig()
val actualDefaultJson = ProxyConfigSerializer.serialize(expectDefaultConfig, null, null)
assertTrue(actualDefaultJson is JsonObject)
assertEquals(expectDefaultConfig.type.name, actualDefaultJson["type"].asString)
assertEquals(expectDefaultConfig.host, actualDefaultJson["host"].asString)
assertEquals(expectDefaultConfig.port, actualDefaultJson["port"].asInt)
}
@Test
fun `Bad type deserialize test`() {
val defaultConfig = ProxyConfig()
assertEquals(defaultConfig, ProxyConfigSerializer.deserialize(null, null, null))
assertEquals(defaultConfig, ProxyConfigSerializer.deserialize(JsonNull.INSTANCE, null, null))
}
@Test
fun `deserialize test - object`() {
val defaultConfig = ProxyConfig()
assertThrows(JsonParseException::class.java) {
ProxyConfigSerializer.deserialize(JsonArray(), null, null)
}
val jsonWithoutType = JsonObject().apply {
addProperty("host", "example.com")
addProperty("port", 8080)
}
assertEquals(defaultConfig, ProxyConfigSerializer.deserialize(jsonWithoutType, null, null))
val looksGoodJson = JsonObject().apply {
addProperty("type", "HTTP")
addProperty("host", "example.com")
addProperty("port", 8080)
}
assertEquals(
ProxyConfig(
type = ProxyType.HTTP,
host = "example.com",
port = 8080
), ProxyConfigSerializer.deserialize(looksGoodJson, null, null)
)
assertThrows(JsonParseException::class.java) {
ProxyConfigSerializer.deserialize(JsonObject().apply {
addProperty("type", "UNKNOWN")
addProperty("host", "example.com")
addProperty("port", 8080)
}, null, null)
}
assertThrows(JsonParseException::class.java) {
ProxyConfigSerializer.deserialize(JsonObject().apply {
addProperty("type", "HTTP")
addProperty("host", "example.com")
}, null, null)
}
assertThrows(JsonParseException::class.java) {
ProxyConfigSerializer.deserialize(JsonObject().apply {
addProperty("type", "HTTP")
addProperty("port", 8080)
}, null, null)
}
}
}
internal class ArtifactSerializerTest {
@Test
fun badJsonType() {
assertFailsWith<JsonParseException> { ArtifactSerializer.deserialize(JsonObject(), null, null) }
assertFailsWith<JsonParseException> { ArtifactSerializer.deserialize(JsonArray(), null, null) }
assertFailsWith<JsonParseException> { ArtifactSerializer.deserialize(JsonPrimitive("A STRING"), null, null) }
}
@Test
fun `Basic format serialization`() {
val gav = "org.example.software:test:1.0.0-SNAPSHOT"
val expectArtifact = DefaultArtifact(gav)
val actualArtifact = DefaultArtifact(ArtifactSerializer.serialize(expectArtifact, null, null).asString)
assertEquals(expectArtifact, actualArtifact)
}
@Test
fun `Full format serialization`() {
val gav = "org.example.software:test:war:javadoc:1.0.0-SNAPSHOT"
val expectArtifact = DefaultArtifact(gav)
val actualArtifact = DefaultArtifact(ArtifactSerializer.serialize(expectArtifact, null, null).asString)
assertEquals(expectArtifact, actualArtifact)
}
@Test
fun `Bad format serialization`() {
assertFailsWith<JsonParseException> {
ArtifactSerializer.deserialize(JsonPrimitive("org.example~test"), null, null)
}
}
@Test
fun `Other artifact implementation serialization`() {
val gav = "org.example.software:test:war:javadoc:1.0.0-SNAPSHOT"
val expectArtifact = DefaultArtifact(gav)
val otherArtifactImpl = mockk<Artifact> {
every { groupId } returns expectArtifact.groupId
every { artifactId } returns expectArtifact.artifactId
every { version } returns expectArtifact.version
every { classifier } returns expectArtifact.classifier
every { extension } returns expectArtifact.extension
}
val json = ArtifactSerializer.serialize(otherArtifactImpl, null, null)
assertTrue(json is JsonPrimitive)
assertEquals(expectArtifact.toString(), json.asString)
}
@Test
fun deserialize() {
val gav = "org.example.software:test:war:javadoc:1.0.0-SNAPSHOT"
val expectArtifact = DefaultArtifact(gav)
val actualArtifact = ArtifactSerializer.deserialize(JsonPrimitive(gav), null, null)
assertEquals(expectArtifact, actualArtifact)
}
}
internal class UsernameAuthenticatorSerializerTest {
@Test
fun serializeTest() {
val authenticator = UsernameAuthenticator("testUser", "testPassword")
val jsonElement = UsernameAuthenticatorSerializer.serialize(authenticator, null, null)
assertTrue(jsonElement.isJsonObject)
val jsonObject = jsonElement.asJsonObject
assertEquals("testUser", jsonObject["username"]?.asString)
assertEquals("testPassword", jsonObject["password"]?.asString)
}
@Test
fun deserializeTest() {
org.junit.jupiter.api.assertThrows<JsonParseException> {
UsernameAuthenticatorSerializer.deserialize(JsonArray(), null, null)
}
org.junit.jupiter.api.assertThrows<JsonParseException> {
UsernameAuthenticatorSerializer.deserialize(JsonPrimitive(""), null, null)
}
assertNull(UsernameAuthenticatorSerializer.deserialize(JsonNull.INSTANCE, null, null))
org.junit.jupiter.api.assertThrows<JsonParseException> {
UsernameAuthenticatorSerializer.deserialize(JsonObject().apply {
addProperty("username", "testUser")
}, null, null)
}
org.junit.jupiter.api.assertThrows<JsonParseException> {
UsernameAuthenticatorSerializer.deserialize(JsonObject().apply {
addProperty("username", "testUser")
add("password", JsonArray())
}, null, null)
}
org.junit.jupiter.api.assertThrows<JsonParseException> {
UsernameAuthenticatorSerializer.deserialize(JsonObject().apply {
addProperty("password", "testPassword")
}, null, null)
}
org.junit.jupiter.api.assertThrows<JsonParseException> {
UsernameAuthenticatorSerializer.deserialize(JsonObject().apply {
add("username", JsonArray())
addProperty("password", "testPassword")
}, null, null)
}
org.junit.jupiter.api.assertThrows<JsonParseException> {
UsernameAuthenticatorSerializer.deserialize(JsonObject().apply {
addProperty("username", "")
addProperty("password", "")
}, null, null)
}
org.junit.jupiter.api.assertThrows<JsonParseException> {
UsernameAuthenticatorSerializer.deserialize(JsonObject().apply {
addProperty("username", "testUser")
addProperty("password", "")
}, null, null)
}
org.junit.jupiter.api.assertThrows<JsonParseException> {
UsernameAuthenticatorSerializer.deserialize(JsonObject().apply {
addProperty("username", "")
addProperty("password", "testPassword")
}, null, null)
}
val authenticator = UsernameAuthenticatorSerializer.deserialize(JsonObject().apply {
addProperty("username", "testUser")
addProperty("password", "testPassword")
}, null, null)
assertNotNull(authenticator)
assertTrue(authenticator.checkCredentials("testUser", "testPassword"))
assertFalse(authenticator.checkCredentials("falseUser", "testPassword"))
assertFalse(authenticator.checkCredentials("testUser", "falsePassword"))
}
}
internal class BotAccountSerializerTest {
private val expectToken = "123456789:AAHErDroUTznQsOd_oZPJ6cQEj4Z5mGHO10"
private val gson = GsonBuilder()
.registerTypeAdapter(BotAccount::class.java, BotAccountSerializer)
.create()
@Test
fun `Invalid json type check test`() {
assertThrows(JsonParseException::class.java) {
BotAccountSerializer.deserialize(null, null, null)
}
assertThrows(JsonParseException::class.java) {
BotAccountSerializer.deserialize(JsonNull.INSTANCE, null, null)
}
assertThrows(JsonParseException::class.java) {
BotAccountSerializer.deserialize(JsonPrimitive("A STRING"), null, null)
}
assertThrows(JsonParseException::class.java) {
BotAccountSerializer.deserialize(JsonArray(), null, null)
}
}
@Test
fun `Field missing test`() {
assertThrows(JsonParseException::class.java) {
BotAccountSerializer.deserialize(JsonObject(), null, null)
}
assertThrows(JsonParseException::class.java) {
BotAccountSerializer.deserialize(JsonObject().apply {
addProperty("token", expectToken)
addProperty("creatorId", 1)
}, null, null)
}
assertThrows(JsonParseException::class.java) {
BotAccountSerializer.deserialize(JsonObject().apply {
addProperty("name", "testUser")
addProperty("creatorId", 1)
}, null, null)
}
assertThrows(JsonParseException::class.java) {
BotAccountSerializer.deserialize(JsonObject().apply {
addProperty("name", "testUser")
addProperty("token", expectToken)
}, null, null)
}
val account = BotAccountSerializer.deserialize(JsonObject().apply {
addProperty("name", "testUser")
addProperty("token", expectToken)
addProperty("creatorId", 1)
}, null, null)
assertNotNull(account)
assertEquals("testUser", account.name)
assertEquals(expectToken, account.token)
assertEquals(1, account.creatorId)
}
@Test
fun `'token' check test`() {
val jsonObject = JsonObject().apply {
addProperty("name", "testUser")
addProperty("token", expectToken)
addProperty("creatorId", 123456789123456789)
}
val looksGoodAccount = BotAccountSerializer.deserialize(jsonObject, null, null)
assertNotNull(looksGoodAccount)
assertEquals("testUser", looksGoodAccount.name)
assertEquals(expectToken, looksGoodAccount.token)
assertEquals(123456789123456789, looksGoodAccount.creatorId)
try {
BotAccountSerializer.deserialize(jsonObject.deepCopy().apply {
addProperty("token", "")
}, null, null)
fail("Token 为空,但是没有抛出异常。")
} catch (e: JsonParseException) {
assertEquals("`token` cannot be empty.", e.message)
}
try {
BotAccountSerializer.deserialize(jsonObject.deepCopy().apply {
addProperty("token", "abcdefghijklmnopqrstuvwxyz")
}, null, null)
fail("Token 格式错误(基本格式错误),但是没有抛出异常。")
} catch (e: JsonParseException) {
assertEquals("`token` is invalid.", e.message)
}
try {
BotAccountSerializer.deserialize(jsonObject.deepCopy().apply {
addProperty("token", "abcdefgh:ijklmnopqrstuvwxyz-1234567890_abcde")
}, null, null)
fail("Token 格式错误ID 不为数字),但是没有抛出异常。")
} catch (e: JsonParseException) {
assertEquals("`token` is invalid.", e.message)
}
try {
BotAccountSerializer.deserialize(jsonObject.deepCopy().apply {
addProperty("token", "0123456789:ijklmnopqrstu-vwxyz_123456")
}, null, null)
fail("Token 格式错误(授权令牌长度错误),但是没有抛出异常。")
} catch (e: JsonParseException) {
assertEquals("`token` is invalid.", e.message)
}
}
@Test
fun `'creatorId' check test`() {
val jsonObject = JsonObject().apply {
addProperty("name", "testUser")
addProperty("token", expectToken)
addProperty("creatorId", 1)
}
val looksGoodAccount = gson.fromJson(jsonObject, BotAccount::class.java)
assertEquals(1, looksGoodAccount.creatorId)
try {
BotAccountSerializer.deserialize(jsonObject.deepCopy().apply {
addProperty("creatorId", "A STRING")
}, null, null)
fail("creatorId 不是一个数字,但是没有抛出异常。")
} catch (e: JsonParseException) {
assertEquals("`creatorId` must be a number.", e.message)
}
try {
BotAccountSerializer.deserialize(jsonObject.deepCopy().apply {
addProperty("creatorId", -1)
}, null, null)
fail("creatorId 不能为负数,但是没有抛出异常。")
} catch (e: JsonParseException) {
assertEquals("`creatorId` must be a positive number.", e.message)
}
}
@Test
fun `json deserialize test`() {
val jsonObject = JsonObject().apply {
addProperty("name", "testUser")
addProperty("token", expectToken)
addProperty("creatorId", 1)
}
val looksGoodAccount = gson.fromJson(jsonObject, BotAccount::class.java)
assertNotNull(looksGoodAccount)
assertEquals("testUser", looksGoodAccount.name)
assertEquals(expectToken, looksGoodAccount.token)
assertEquals(1, looksGoodAccount.creatorId)
}
}

View File

@ -4,4 +4,3 @@ rootProject.name = "scalabot"
include(":scalabot-app") include(":scalabot-app")
include(":scalabot-extension") include(":scalabot-extension")
include("scalabot-ext-example") include("scalabot-ext-example")
include("scalabot-meta")