21 Commits

Author SHA1 Message Date
c7fedf3882 release: 发布 0.5.0 版本. 2022-07-19 01:51:00 +08:00
a7de85eacb test(extension): 修正更新 TelegramBots 依赖项所导致的测试错误.
忘记执行测试了, 我的锅)
2022-07-16 21:03:12 +08:00
b6013e2fbe refactor(extension): 将构件下载请求跟构件解析请求对齐.
防止在构件处理过程中因仓库原因出现问题, 因此统一两个请求所使用的仓库列表.
2022-07-16 20:55:24 +08:00
f79a4e4ff3 refactor(extension): 在 MavenRepositoryExtensionFinder 增加一个扩展包信息日志.
当初写的时候没留意这个问题, 结果最近给坑了, 所以补个日志, 打印出 Maven 搜索器所解析出来的构件信息.
2022-07-16 20:51:06 +08:00
93b9c6b727 refactor(launch): 统一代理的使用.
之前的版本中, 如果未指定 Maven 仓库的独立代理配置, 同时 Bot 拥有独立代理配置的情况下, Aether 将不会使用 Bot
的独立代理配置, 这样弄比较乱, 因此统一代理配置的使用顺序:
- 如果配置中包括了代理配置, 则优先使用独立代理配置;
- 如果不包括独立代理配置, 则使用关联 Bot 的独立代理配置;
- 如果关联 Bot 没有独立代理配置, 则使用 AppConfig 中的全局配置(如无配置则直连访问).
2022-07-16 20:46:15 +08:00
a8a0a9576f build(dependencies): 更新 TelegramBots 依赖项的版本 (6.0.1 -> 6.1.0)
更新版本将有利于开发者和用户使用新的功能.
2022-07-16 20:30:22 +08:00
e8711e9974 refactor(meta): 为 ProxyConfig 覆盖 toString 方法.
覆盖 toString 方法后, 可以输出易懂的代理信息(我担心按照 data class 的格式输出, 用户可能看不懂).
2022-07-16 20:26:45 +08:00
93685e9440 test(config): 优化单元测试.
将 AppPaths 的单元测试也覆盖了, 直接方便了.
2022-07-12 01:29:19 +08:00
92b7e84b3a test(config): 补充相关的单元测试.
经检查, 已确定完全覆盖代码, 为完成单元测试的编写, 稍微改了一下 AppPaths 的代码, 不会有影响的 :P
2022-07-12 01:20:53 +08:00
8c4e48e3eb refactor(launch): 更改初始化配置中, 退出进程的时机.
为了能在单元测试中检查 initialFiles 是否正常, 故将 exitProcess 移到 main 方法中, 方便进行测试.
2022-07-12 00:27:18 +08:00
7f7b2b8895 build(meta): 调整部分依赖的引入范围.
将 Aether-api 和 telegrambots-meta 的引入范围由 implementation 改为 api,
方便依赖的其他项目使用.
2022-07-11 23:58:22 +08:00
441991b705 docs(extension): 补充部分扩展组件的 KDoc.
补充部分方法的文档, 不过文档内容嘛...有待加强.
2022-07-09 01:20:57 +08:00
51d036c4c6 feat(launch): 延后 BotConfig 的反序列化时机.
通过将 BotConfig 的反序列化时机延后到启动机器人的时候, 可以避免因某个机器人配置错误导致所有机器人都无法启动的问题.
注意, 语法错误还是会在启动时报错, 只是说部分序列化器会检查字段值是否有误, 通过延后反序列化来防止全反序列化的时候一个配置炸了影响全部而已.
2022-07-04 16:40:17 +08:00
3c54c33364 fix(config): 修复因正则表达式错误导致的 Token 检查失败.
由于表达式中限定的 BotId 为 Int 范围, 而目前的 TelegramUserId 已经扩展到 Long 了, 所以新的 Bot 是无法通过检查的, 已修正表达式问题.
2022-07-04 16:26:01 +08:00
43dd0e7bea docs(meta): 加一个 Readme.
加一个文档来稍微说明一下这个模块的用途.
2022-07-03 02:50:32 +08:00
c144755913 feat(config): 增加 BotAccountSerializer, 主要用于检查字段值.
增加 BotAccount 的序列化器, 便于检查有关字段的值是否有效.
2022-07-03 02:32:37 +08:00
9ed55204c0 refactor(config): 调整序列化工具类, 便于进行测试.
修正序列化工具类的类名, 并调整访问权为 internal.
同时将 `checkJsonKey` 改成更方便的 `JsonObject.getPrimitiveValueOrThrow`.
2022-07-03 01:35:23 +08:00
9b7fc30512 fix(config): 修复因使用了错误的 Gson 对象而导致的配置初始化警告.
导致的原因是在初始化 app.json 时错误的使用了 botConfigGson, 而 botConfigGson 未配置用于 MetricsServer 的 UsernameAuthenticator 序列化器, 导致在初始化配置文件中序列化 AppConfig 时, Gson 会反射调用 BasicAuthenticator, 导致被 Java 模块系统拦截并报错, 现已修复该问题.
2022-07-02 22:01:05 +08:00
27dc26160d refactor(config): 对配置文件的 AppPath 对象更名.
先前为确保后续可以增加指定配置文件路径的功能, 在命名上标记的 DEFAULT 在现在已经不符合实际意义了, 故将 DEFAULT 前缀移除.
2022-06-30 00:15:07 +08:00
ae411ce829 refactor(metrics): 调整 MetricsHttpServer 注册关闭钩子的时机.
将关闭钩子的时机调整到 main 方法中, 可以减少多余的钩子注册(比如测试时无需注册钩子, 却还是注册了).
2022-06-29 03:06:15 +08:00
1afe0f07a8 perf(extension): 优化 printExtensionFileConflictError 日志输出.
Kotlin-logging 在日志输出方法中做了检查, 如果级别未开启则不会调用方法获取日志内容,
故将内容构造部分移入 error 代码块以避免无意义的生成日志内容.
2022-06-29 03:03:16 +08:00
14 changed files with 762 additions and 135 deletions

View File

@ -12,5 +12,5 @@ allprojects {
} }
group = "net.lamgc" group = "net.lamgc"
version = "0.4.0" version = "0.5.0"
} }

View File

@ -29,8 +29,8 @@ dependencies {
implementation("org.jdom:jdom2:2.0.6.1") implementation("org.jdom:jdom2:2.0.6.1")
implementation("org.telegram:telegrambots-abilities:6.0.1") implementation("org.telegram:telegrambots-abilities:6.1.0")
implementation("org.telegram:telegrambots:6.0.1") implementation("org.telegram:telegrambots:6.1.0")
implementation("io.prometheus:simpleclient:0.15.0") implementation("io.prometheus:simpleclient:0.15.0")
implementation("io.prometheus:simpleclient_httpserver:0.15.0") implementation("io.prometheus:simpleclient_httpserver:0.15.0")

View File

@ -3,7 +3,7 @@ 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.reflect.TypeToken import com.google.gson.JsonArray
import mu.KotlinLogging import mu.KotlinLogging
import net.lamgc.scalabot.config.* import net.lamgc.scalabot.config.*
import net.lamgc.scalabot.config.serializer.* import net.lamgc.scalabot.config.serializer.*
@ -18,7 +18,6 @@ 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.concurrent.atomic.AtomicInteger
import kotlin.system.exitProcess
private val log = KotlinLogging.logger { } private val log = KotlinLogging.logger { }
@ -41,13 +40,28 @@ internal fun ProxyConfig.toAetherProxy(): Proxy? {
return Proxy(typeStr, host, port) return Proxy(typeStr, host, port)
} }
internal fun MavenRepositoryConfig.toRemoteRepository(proxyConfig: ProxyConfig): RemoteRepository { internal fun MavenRepositoryConfig.toRemoteRepository(proxyConfig: ProxyConfig? = null): RemoteRepository {
val builder = val repositoryId = if (id == null) {
RemoteRepository.Builder(id ?: createDefaultRepositoryId(), checkRepositoryLayout(layout), url.toString()) val generatedRepoId = createDefaultRepositoryId()
log.debug { "仓库 Url `$url` 未设置仓库 Id, 已分配缺省 Id: $generatedRepoId" }
generatedRepoId
} else {
id
}
val builder = RemoteRepository.Builder(repositoryId, checkRepositoryLayout(layout), url.toString())
if (proxy != null) { if (proxy != null) {
builder.setProxy(proxy) val selfProxy = proxy!!
} else if (proxyConfig.type == ProxyType.HTTP) { builder.setProxy(selfProxy)
builder.setProxy(proxyConfig.toAetherProxy()) 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 {
log.debug { "仓库 $repositoryId 不支持 全局/Bot 的代理配置: `$proxyConfig` (仅支持 HTTP 和 HTTPS)" }
}
} else {
log.debug { "仓库 $repositoryId 不使用代理." }
} }
builder.setReleasePolicy( builder.setReleasePolicy(
@ -111,10 +125,10 @@ internal enum class AppPaths(
} }
}), }),
DEFAULT_CONFIG_APPLICATION({ "$DATA_ROOT/config.json" }, { 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.botConfigGson.toJson( GsonConst.appConfigGson.toJson(
AppConfig( AppConfig(
mavenRepositories = listOf( mavenRepositories = listOf(
MavenRepositoryConfig( MavenRepositoryConfig(
@ -127,7 +141,7 @@ internal enum class AppPaths(
} }
} }
}), }),
DEFAULT_CONFIG_BOT({ "$DATA_ROOT/bot.json" }, { 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(
@ -168,11 +182,26 @@ 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
} }
private object PathConst { object PathConst {
const val PROP_DATA_PATH = "bot.path.data" const val PROP_DATA_PATH = "bot.path.data"
const val ENV_DATA_PATH = "BOT_DATA_PATH" const val ENV_DATA_PATH = "BOT_DATA_PATH"
} }
@ -207,9 +236,14 @@ private fun AppPaths.defaultInitializer() {
} }
} }
internal fun initialFiles() { /**
val configFilesNotInitialized = !AppPaths.DEFAULT_CONFIG_APPLICATION.file.exists() * 执行 AppPaths 所有项目的初始化, 并检查是否停止运行, 让用户编辑配置.
&& !AppPaths.DEFAULT_CONFIG_BOT.file.exists() *
* @return 如果需要让用户编辑配置, 则返回 `true`.
*/
internal fun initialFiles(): Boolean {
val configFilesNotInitialized = !AppPaths.CONFIG_APPLICATION.file.exists()
&& !AppPaths.CONFIG_BOT.file.exists()
for (path in AppPaths.values()) { for (path in AppPaths.values()) {
path.initial() path.initial()
@ -217,12 +251,13 @@ internal fun initialFiles() {
if (configFilesNotInitialized) { if (configFilesNotInitialized) {
log.warn { "配置文件已初始化, 请根据需要修改配置文件后重新启动本程序." } log.warn { "配置文件已初始化, 请根据需要修改配置文件后重新启动本程序." }
exitProcess(1) return true
} }
return false
} }
private object GsonConst { internal object GsonConst {
val baseGson: Gson = GsonBuilder() private val baseGson: Gson = GsonBuilder()
.setPrettyPrinting() .setPrettyPrinting()
.serializeNulls() .serializeNulls()
.create() .create()
@ -238,10 +273,11 @@ private object GsonConst {
.registerTypeAdapter(ProxyType::class.java, ProxyTypeSerializer) .registerTypeAdapter(ProxyType::class.java, ProxyTypeSerializer)
.registerTypeAdapter(BotConfig::class.java, BotConfigSerializer) .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.DEFAULT_CONFIG_APPLICATION.file): AppConfig { internal fun loadAppConfig(configFile: File = AppPaths.CONFIG_APPLICATION.file): AppConfig {
try { 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)!!
@ -252,10 +288,10 @@ internal fun loadAppConfig(configFile: File = AppPaths.DEFAULT_CONFIG_APPLICATIO
} }
} }
internal fun loadBotConfig(botConfigFile: File = AppPaths.DEFAULT_CONFIG_BOT.file): Set<BotConfig>? { internal fun loadBotConfigJson(botConfigFile: File = AppPaths.CONFIG_BOT.file): JsonArray? {
try { 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) { } catch (e: Exception) {
log.error(e) { "读取 Bot 配置文件 (bot.json) 时发生错误, 请检查配置格式是否正确." } log.error(e) { "读取 Bot 配置文件 (bot.json) 时发生错误, 请检查配置格式是否正确." }

View File

@ -1,12 +1,10 @@
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.AppConfig import net.lamgc.scalabot.config.*
import net.lamgc.scalabot.config.BotConfig
import net.lamgc.scalabot.config.MetricsConfig
import net.lamgc.scalabot.config.ProxyType
import net.lamgc.scalabot.util.registerShutdownHook import net.lamgc.scalabot.util.registerShutdownHook
import org.eclipse.aether.repository.LocalRepository import org.eclipse.aether.repository.LocalRepository
import org.telegram.telegrambots.bots.DefaultBotOptions import org.telegram.telegrambots.bots.DefaultBotOptions
@ -29,11 +27,13 @@ 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 { "启动参数: ${args.joinToString(prefix = "[", postfix = "]")}" } log.debug { "启动参数: ${args.joinToString(prefix = "[", postfix = "]")}" }
initialFiles() if (initialFiles()) {
exitProcess(1)
}
val launcher = Launcher() val launcher = Launcher()
.registerShutdownHook() .registerShutdownHook()
startMetricsServer() startMetricsServer()?.registerShutdownHook()
if (!launcher.launch()) { if (!launcher.launch()) {
exitProcess(1) exitProcess(1)
} }
@ -57,7 +57,6 @@ internal fun startMetricsServer(config: MetricsConfig = Const.config.metrics): H
val httpServer = builder val httpServer = builder
.build() .build()
.registerShutdownHook()
log.info { "运行指标服务器已启动. (Port: ${httpServer.port})" } log.info { "运行指标服务器已启动. (Port: ${httpServer.port})" }
return httpServer return httpServer
} }
@ -111,22 +110,39 @@ internal class Launcher(private val config: AppConfig = Const.config) : AutoClos
@Synchronized @Synchronized
fun launch(): Boolean { fun launch(): Boolean {
val botConfigs = loadBotConfig() ?: return false val botConfigs = loadBotConfigJson() ?: return false
if (botConfigs.isEmpty()) { if (botConfigs.isEmpty) {
log.warn { "尚未配置任何机器人, 请先配置机器人后再启动本程序." } log.warn { "尚未配置任何机器人, 请先配置机器人后再启动本程序." }
return false return false
} else if (botConfigs.none { it.enabled }) {
log.warn { "配置文件中没有已启用的机器人, 请至少启用一个机器人." }
return false
} }
for (botConfig in botConfigs) { var launchedCounts = 0
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) {
log.error(e) { "机器人 `${botConfig.account.name}` 启动时发生错误." } log.error(e) { "机器人 `${botConfig.account.name}` 启动时发生错误." }
} }
} }
return true return if (launchedCounts != 0) {
log.info { "已启动 $launchedCounts 个机器人." }
true
} else {
log.warn { "未启动任何机器人, 请检查配置并至少启用一个机器人." }
false
}
} }
private fun launchBot(botConfig: BotConfig) { private fun launchBot(botConfig: BotConfig) {
@ -135,16 +151,20 @@ internal class Launcher(private val config: AppConfig = Const.config) : AutoClos
return return
} }
log.info { "正在启动机器人 `${botConfig.account.name}`..." } log.info { "正在启动机器人 `${botConfig.account.name}`..." }
val proxyConfig =
if (botConfig.proxy.type != ProxyType.NO_PROXY) {
log.debug { "[Bot ${botConfig.account.name}] 使用独立代理: ${botConfig.proxy.type}" }
botConfig.proxy
} else if (config.proxy.type != ProxyType.NO_PROXY) {
log.debug { "[Bot ${botConfig.account.name}] 使用全局代理: ${botConfig.proxy.type}" }
config.proxy
} else {
log.debug { "[Bot ${botConfig.account.name}] 不使用代理." }
ProxyConfig(type = ProxyType.NO_PROXY)
}
val botOption = DefaultBotOptions().apply { val botOption = DefaultBotOptions().apply {
val proxyConfig = if (proxyConfig.type != ProxyType.NO_PROXY) {
if (botConfig.proxy.type != ProxyType.NO_PROXY) {
botConfig.proxy
} else if (config.proxy.type != ProxyType.NO_PROXY) {
config.proxy
} else {
null
}
if (proxyConfig != null) {
proxyType = proxyConfig.type.toTelegramBotsType() proxyType = proxyConfig.type.toTelegramBotsType()
proxyHost = config.proxy.host proxyHost = config.proxy.host
proxyPort = config.proxy.port proxyPort = config.proxy.port
@ -156,7 +176,7 @@ internal class Launcher(private val config: AppConfig = Const.config) : AutoClos
val account = botConfig.account val account = botConfig.account
val remoteRepositories = config.mavenRepositories val remoteRepositories = config.mavenRepositories
.map { it.toRemoteRepository(config.proxy) } .map { it.toRemoteRepository(proxyConfig) }
.toMutableList().apply { .toMutableList().apply {
if (this.none { if (this.none {
it.url == MavenRepositoryExtensionFinder.MAVEN_CENTRAL_URL it.url == MavenRepositoryExtensionFinder.MAVEN_CENTRAL_URL

View File

@ -14,6 +14,18 @@ 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,
@ -27,6 +39,13 @@ 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) {
@ -52,6 +71,17 @@ 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 {
@ -68,6 +98,9 @@ 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
@ -102,6 +135,11 @@ 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) {
@ -110,6 +148,14 @@ 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>> {
@ -138,31 +184,45 @@ internal class ExtensionLoader(
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>>
) { ) {
val errMessage = StringBuilder( log.error {
""" val errMessage = StringBuilder(
[Bot ${bot.botUsername}] 扩展包 $extensionArtifact 存在多个文件, 为防止安全问题, 已禁止加载该扩展包: """
""".trimIndent() [Bot ${bot.botUsername}] 扩展包 $extensionArtifact 存在多个文件, 为防止安全问题, 已禁止加载该扩展包:
).append('\n') """.trimIndent()
).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}")
@ -172,6 +232,12 @@ 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>,
@ -181,6 +247,10 @@ internal class ExtensionLoader(
} }
/** /**
* 扩展的类加载器清除器.
*
* 原计划是用来通过关闭 ClassLoader 来卸载扩展的, 但似乎并没有这么做.
*
* 该类为保留措施, 尚未启用. * 该类为保留措施, 尚未启用.
*/ */
internal object ExtensionClassLoaderCleaner { internal object ExtensionClassLoaderCleaner {
@ -257,7 +327,7 @@ internal interface ExtensionPackageFinder {
/** /**
* 已找到的扩展包信息. * 已找到的扩展包信息.
* 通过实现该接口, 以寻找远端文件的 [ExtensionPackageFinder] * 通过实现该接口, 以寻找远端文件的 [ExtensionPackageFinder];
* 可以在适当的时候将扩展包下载到本地, 而无需在搜索阶段下载扩展包. * 可以在适当的时候将扩展包下载到本地, 而无需在搜索阶段下载扩展包.
*/ */
internal interface FoundExtensionPackage { internal interface FoundExtensionPackage {
@ -296,6 +366,7 @@ 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,

View File

@ -257,23 +257,25 @@ 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 { log.debug {
StringBuilder().apply { StringBuilder().apply {
append("构件 $extensionArtifact 将在以下仓库拉取: \n") append("构件 $extensionArtifact 将在以下仓库拉取: \n")
remoteRepositories.forEach { repositories.forEach {
append("\t- ${it}\n") append("\t- $it\n")
} }
} }
} }
val extensionArtifactResult = repositorySystem.resolveArtifact( val extensionArtifactResult = repositorySystem.resolveArtifact(
repoSystemSession, repoSystemSession,
ArtifactRequest( ArtifactRequest(
extensionArtifact, extensionArtifact,
repositorySystem.newResolutionRepositories(repoSystemSession, remoteRepositories), repositories,
null null
) )
) )
val extResolvedArtifact = extensionArtifactResult.artifact val resolvedArtifact: Artifact? = extensionArtifactResult.artifact
if (!extensionArtifactResult.isResolved) { if (!extensionArtifactResult.isResolved) {
if (extensionArtifactResult.isMissing) { if (extensionArtifactResult.isMissing) {
log.warn { "在指定的仓库中找不到构件: ${extensionArtifactResult.artifact}" } log.warn { "在指定的仓库中找不到构件: ${extensionArtifactResult.artifact}" }
@ -281,17 +283,26 @@ 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(extResolvedArtifact, null), remoteRepositories), CollectRequest(Dependency(resolvedArtifact, null), repositories),
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, extResolvedArtifact, extensionArtifactResult.repository, dependencies)) return setOf(MavenExtensionPackage(this, resolvedArtifact, extensionArtifactResult.repository, dependencies))
} }
private fun checkAndCollectDependencyArtifacts( private fun checkAndCollectDependencyArtifacts(

View File

@ -4,31 +4,39 @@ import com.github.stefanbirkner.systemlambda.SystemLambda
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verify 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 org.junit.jupiter.api.io.TempDir
import org.telegram.telegrambots.bots.DefaultBotOptions
import java.io.File import java.io.File
import kotlin.test.Test import java.io.IOException
import kotlin.test.assertEquals import java.net.URL
import kotlin.test.assertNotNull import java.nio.file.Files
import kotlin.test.assertTrue import java.nio.file.Path
import kotlin.io.path.deleteExisting
import kotlin.test.*
internal class AppPathsTest { internal class AppPathsTest {
@Test @Test
fun `Data root path priority`() { fun `Data root path priority`() {
System.setProperty("bot.path.data", "fromSystemProperties") System.setProperty(AppPaths.PathConst.PROP_DATA_PATH, "fromSystemProperties")
assertEquals("fromSystemProperties", AppPaths.DATA_ROOT.file.path, "`DATA_ROOT`没有优先返回 Property 的值.") assertEquals("fromSystemProperties", AppPaths.DATA_ROOT.file.path, "`DATA_ROOT`没有优先返回 Property 的值.")
System.getProperties().remove("bot.path.data") System.getProperties().remove(AppPaths.PathConst.PROP_DATA_PATH)
val expectEnvValue = "fromEnvironmentVariable" val expectEnvValue = "fromEnvironmentVariable"
SystemLambda.withEnvironmentVariable("BOT_DATA_PATH", expectEnvValue).execute { SystemLambda.withEnvironmentVariable(AppPaths.PathConst.ENV_DATA_PATH, expectEnvValue).execute {
assertEquals( assertEquals(
expectEnvValue, AppPaths.DATA_ROOT.file.path, expectEnvValue, AppPaths.DATA_ROOT.file.path,
"`DATA_ROOT`没有优先返回 env 的值." "`DATA_ROOT`没有优先返回 env 的值."
) )
} }
SystemLambda.withEnvironmentVariable("BOT_DATA_PATH", null).execute { SystemLambda.withEnvironmentVariable(AppPaths.PathConst.ENV_DATA_PATH, null).execute {
assertEquals( assertEquals(
System.getProperty("user.dir"), AppPaths.DATA_ROOT.file.path, System.getProperty("user.dir"), AppPaths.DATA_ROOT.file.path,
"`DATA_ROOT`没有返回 System.properties `user.dir` 的值." "`DATA_ROOT`没有返回 System.properties `user.dir` 的值."
@ -116,5 +124,282 @@ internal class AppPathsTest {
defaultInitializerMethod.isAccessible = false 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 DefaultBotOptions.ProxyType.NO_PROXY,
ProxyType.SOCKS5 to DefaultBotOptions.ProxyType.SOCKS5,
ProxyType.SOCKS4 to DefaultBotOptions.ProxyType.SOCKS4,
ProxyType.HTTP to DefaultBotOptions.ProxyType.HTTP,
ProxyType.HTTPS to DefaultBotOptions.ProxyType.HTTP
)
for (proxyType in ProxyType.values()) {
assertEquals(
expectTypeMapping[proxyType],
proxyType.toTelegramBotsType(),
"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.values()) {
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.values()) {
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.values()) {
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.values()) {
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.values()) {
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.values()) {
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

@ -6,7 +6,7 @@ plugins {
} }
dependencies { dependencies {
api("org.telegram:telegrambots-abilities:6.0.1") api("org.telegram:telegrambots-abilities:6.1.0")
api("org.slf4j:slf4j-api:1.7.36") api("org.slf4j:slf4j-api:1.7.36")
testImplementation("org.junit.jupiter:junit-jupiter-api:5.8.2") testImplementation("org.junit.jupiter:junit-jupiter-api:5.8.2")

View File

@ -16,8 +16,8 @@ import static org.mockito.Mockito.*;
public class AbilityBotsTest { public class AbilityBotsTest {
public static final User USER = new User(1L, "first", false, "last", "username", null, false, false, false); public static final User USER = new User(1L, "first", false, "last", "username", null, false, false, false, false, false);
public static final User CREATOR = new User(1337L, "creatorFirst", false, "creatorLast", "creatorUsername", null, false, false, false); public static final User CREATOR = new User(1337L, "creatorFirst", false, "creatorLast", "creatorUsername", null, false, false, false, false, false);
static Update mockFullUpdate(BaseAbilityBot bot, User user, String args) { static Update mockFullUpdate(BaseAbilityBot bot, User user, String args) {
bot.users().put(USER.getId(), USER); bot.users().put(USER.getId(), USER);
@ -56,8 +56,8 @@ public class AbilityBotsTest {
@Test @Test
void cancelReplyStateTest() { void cancelReplyStateTest() {
User userA = new User(10001L, "first", false, "last", "username", null, false, false, false); User userA = new User(10001L, "first", false, "last", "username", null, false, false, false, false, false);
User userB = new User(10101L, "first", false, "last", "username", null, false, false, false); User userB = new User(10101L, "first", false, "last", "username", null, false, false, false, false, false);
SilentSender silent = mock(SilentSender.class); SilentSender silent = mock(SilentSender.class);
BaseAbilityBot bot = new TestingAbilityBot("", "", silent); BaseAbilityBot bot = new TestingAbilityBot("", "", silent);
bot.onRegister(); bot.onRegister();
@ -94,6 +94,7 @@ public class AbilityBotsTest {
this.silent = silentSender; this.silent = silentSender;
} }
@SuppressWarnings("unused")
public Ability setReply() { public Ability setReply() {
return Ability.builder() return Ability.builder()
.name("set_reply") .name("set_reply")

13
scalabot-meta/README.md Normal file
View File

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

View File

@ -8,12 +8,12 @@ plugins {
dependencies { dependencies {
val aetherVersion = "1.1.0" val aetherVersion = "1.1.0"
implementation("org.eclipse.aether:aether-api:$aetherVersion") api("org.eclipse.aether:aether-api:$aetherVersion")
implementation("org.eclipse.aether:aether-util:$aetherVersion") implementation("org.eclipse.aether:aether-util:$aetherVersion")
implementation("org.telegram:telegrambots-meta:6.0.1") implementation("org.telegram:telegrambots-meta:6.1.0")
implementation("com.google.code.gson:gson:2.9.0") api("com.google.code.gson:gson:2.9.0")
testImplementation(kotlin("test")) testImplementation(kotlin("test"))
testImplementation("io.mockk:mockk:1.12.4") testImplementation("io.mockk:mockk:1.12.4")

View File

@ -72,8 +72,16 @@ enum class ProxyType {
data class ProxyConfig( data class ProxyConfig(
val type: ProxyType = ProxyType.NO_PROXY, val type: ProxyType = ProxyType.NO_PROXY,
val host: String = "127.0.0.1", val host: String = "127.0.0.1",
val port: Int = 1080 val port: Int = 1080,
) ) {
override fun toString(): String {
return if (type != ProxyType.NO_PROXY) {
"$type://$host:$port"
} else {
"NO_PROXY"
}
}
}
/** /**
* ScalaBot 的运行指标公开配置. * ScalaBot 的运行指标公开配置.

View File

@ -3,6 +3,7 @@ package net.lamgc.scalabot.config.serializer
import com.google.gson.* import com.google.gson.*
import com.google.gson.reflect.TypeToken import com.google.gson.reflect.TypeToken
import net.lamgc.scalabot.config.* import net.lamgc.scalabot.config.*
import net.lamgc.scalabot.config.serializer.SerializeUtils.getPrimitiveValueOrThrow
import org.eclipse.aether.artifact.AbstractArtifact import org.eclipse.aether.artifact.AbstractArtifact
import org.eclipse.aether.artifact.Artifact import org.eclipse.aether.artifact.Artifact
import org.eclipse.aether.artifact.DefaultArtifact import org.eclipse.aether.artifact.DefaultArtifact
@ -12,6 +13,7 @@ import org.eclipse.aether.util.repository.AuthenticationBuilder
import java.lang.reflect.Type import java.lang.reflect.Type
import java.net.MalformedURLException import java.net.MalformedURLException
import java.net.URL import java.net.URL
import java.util.regex.Pattern
object ProxyTypeSerializer : JsonDeserializer<ProxyType>, object ProxyTypeSerializer : JsonDeserializer<ProxyType>,
JsonSerializer<ProxyType> { JsonSerializer<ProxyType> {
@ -81,8 +83,8 @@ object AuthenticationSerializer : JsonDeserializer<Authentication> {
if (json !is JsonObject) { if (json !is JsonObject) {
throw JsonParseException("Unsupported JSON type.") throw JsonParseException("Unsupported JSON type.")
} }
val username = SerializerUtils.checkJsonKey(json, "username") val username = json.getPrimitiveValueOrThrow("username").asString
val password = SerializerUtils.checkJsonKey(json, "password") val password = json.getPrimitiveValueOrThrow("password").asString
val builder = AuthenticationBuilder() val builder = AuthenticationBuilder()
builder.addUsername(username) builder.addUsername(username)
builder.addPassword(password) builder.addPassword(password)
@ -91,14 +93,14 @@ object AuthenticationSerializer : JsonDeserializer<Authentication> {
} }
private object SerializerUtils { internal object SerializeUtils {
fun checkJsonKey(json: JsonObject, key: String): String {
if (!json.has(key)) { fun JsonObject.getPrimitiveValueOrThrow(fieldName: String): JsonPrimitive {
throw JsonParseException("Required field does not exist: $key") val value = get(fieldName) ?: throw JsonParseException("Missing `$fieldName` field.")
} else if (!json.get(key).isJsonPrimitive) { if (value !is JsonPrimitive) {
throw JsonParseException("Wrong field `$key` type: ${json.get(key)::class.java}") throw JsonParseException("Invalid `account` field type.")
} }
return json.get(key).asString return value
} }
} }
@ -114,7 +116,7 @@ object MavenRepositoryConfigSerializer
is JsonObject -> { is JsonObject -> {
MavenRepositoryConfig( MavenRepositoryConfig(
id = json.get("id")?.asString, id = json.get("id")?.asString,
url = URL(SerializerUtils.checkJsonKey(json, "url")), url = URL(json.getPrimitiveValueOrThrow("url").asString),
proxy = if (json.has("proxy")) proxy = if (json.has("proxy"))
context.deserialize<Proxy>( context.deserialize<Proxy>(
json.get("proxy"), Proxy::class.java json.get("proxy"), Proxy::class.java
@ -260,3 +262,39 @@ object BotConfigSerializer : JsonSerializer<BotConfig>, JsonDeserializer<BotConf
} }
} }
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

@ -5,6 +5,7 @@ import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verify import io.mockk.verify
import net.lamgc.scalabot.config.* import net.lamgc.scalabot.config.*
import net.lamgc.scalabot.config.serializer.SerializeUtils.getPrimitiveValueOrThrow
import org.eclipse.aether.artifact.Artifact import org.eclipse.aether.artifact.Artifact
import org.eclipse.aether.artifact.DefaultArtifact import org.eclipse.aether.artifact.DefaultArtifact
import org.eclipse.aether.repository.Authentication import org.eclipse.aether.repository.Authentication
@ -12,54 +13,39 @@ import org.eclipse.aether.repository.AuthenticationContext
import org.eclipse.aether.repository.Proxy import org.eclipse.aether.repository.Proxy
import org.intellij.lang.annotations.Language import org.intellij.lang.annotations.Language
import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Assertions.assertThrows
import java.lang.reflect.InvocationTargetException
import java.lang.reflect.Method
import java.lang.reflect.Type import java.lang.reflect.Type
import java.net.URL import java.net.URL
import kotlin.test.* import kotlin.test.*
internal class SerializersKtTest { internal class SerializeUtilsTest {
private val instance: Any
private val method: Method
init {
val clazz = Class.forName("net.lamgc.scalabot.config.serializer.SerializerUtils")
method = clazz.getDeclaredMethod("checkJsonKey", JsonObject::class.java, String::class.java)
method.isAccessible = true
instance = clazz.getDeclaredField("INSTANCE").apply {
isAccessible = true
}.get(null)
}
private fun invoke(json: JsonObject, key: String): String {
try {
return method.invoke(instance, json, key) as String
} catch (e: InvocationTargetException) {
throw e.targetException
}
}
@Test @Test
fun `Json key checker test`() { fun `getPrimitiveValueOrThrow test`() {
assertThrows(JsonParseException::class.java) { assertThrows(JsonParseException::class.java) {
invoke(JsonObject(), "NOT_EXIST_KEY") JsonObject().getPrimitiveValueOrThrow("NOT_EXIST_KEY")
}
assertThrows(JsonParseException::class.java) {
invoke(JsonObject().apply { add("NULL_KEY", JsonNull.INSTANCE) }, "NULL_KEY")
}
assertThrows(JsonParseException::class.java) {
invoke(JsonObject().apply { add("ARRAY_KEY", JsonArray()) }, "ARRAY_KEY")
}
assertThrows(JsonParseException::class.java) {
invoke(JsonObject().apply { add("OBJECT_KEY", JsonObject()) }, "OBJECT_KEY")
} }
val expectKey = "TEST" assertThrows(JsonParseException::class.java) {
val expectString = "testString" JsonObject().apply {
val json = JsonObject().apply { addProperty(expectKey, expectString) } 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")
}
assertEquals(expectString, invoke(json, expectKey)) val expectKey = "STRING_KEY"
val expectValue = JsonPrimitive("A STRING")
assertEquals(expectValue, JsonObject()
.apply { add(expectKey, expectValue) }
.getPrimitiveValueOrThrow(expectKey))
} }
} }
@ -706,3 +692,161 @@ internal class UsernameAuthenticatorSerializerTest {
} }
} }
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)
}
}