mirror of
https://github.com/LamGC/ScalaBot.git
synced 2025-07-01 04:47:24 +00:00
Compare commits
67 Commits
Author | SHA1 | Date | |
---|---|---|---|
efbb57f1f7
|
|||
5e18149640
|
|||
0a5313e94a
|
|||
a0afde52ac
|
|||
ef37f3b2d7
|
|||
6e59a9a5ac
|
|||
a44732a7f6
|
|||
95ad251826
|
|||
8174f2a3a2
|
|||
478480014a
|
|||
830f05c90a
|
|||
8be0978783 | |||
ce613787f6
|
|||
2389d082f4
|
|||
27f54c3c36
|
|||
7b985ce325
|
|||
77b7a7cd08
|
|||
e8b746b3f8
|
|||
d24572a4f3
|
|||
f11290c73d
|
|||
1f2ab0f9b1
|
|||
d14ef9de36
|
|||
3e51327ed7
|
|||
93cf5c4e2f
|
|||
e12f858690
|
|||
3e99d7d033
|
|||
8131f41313
|
|||
270e744bcf
|
|||
d84465ebd9
|
|||
18bc3a8d48
|
|||
2b88134207
|
|||
142eddfa28
|
|||
64849adfab
|
|||
29bd12a8dd
|
|||
9cdf10ccc2
|
|||
4210efef3b
|
|||
bc0f3be32c
|
|||
c5f28e395e
|
|||
1172caa8d7
|
|||
eb95436404
|
|||
804d0e3012
|
|||
1281dbcabe
|
|||
1cd26b3b25
|
|||
19162dcaef
|
|||
0748afaff5
|
|||
b20b25bc7b
|
|||
9c2ca5103c
|
|||
05af90a5a0
|
|||
4b20b0cc59
|
|||
de83d2c3d3
|
|||
22a824377a
|
|||
a0344f251f
|
|||
84b67c7f89
|
|||
851fb0251f
|
|||
1776f07f16
|
|||
1f48bbae8e
|
|||
00e90eabd0
|
|||
285c8b04d1
|
|||
a642948f45
|
|||
6df9f1b3c7
|
|||
4bc3776717
|
|||
eb34f17b06
|
|||
54b3e1cad7
|
|||
35d092a22d
|
|||
90434d9dbf
|
|||
b3c63e5abe
|
|||
737ac9a08a
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -3,6 +3,7 @@
|
||||
|
||||
# Log file
|
||||
*.log
|
||||
*.log.gz
|
||||
|
||||
# BlueJ files
|
||||
*.ctxt
|
||||
|
23
README.md
23
README.md
@ -9,13 +9,26 @@ on [rubenlagus/TelegramBots](https://github.com/rubenlagus/TelegramBots).
|
||||
是按 Bot 融入应用的方式设计的, 且 AbilityExtension 对 ReplyFlow 不太支持(因为无法获取所属 AbilityBot 的 StateDB), 所以我尝试提供了一个 Factory 接口,在创建
|
||||
AbilityExtension 对象时提供扩展将要服务的 AbilityBot 对象,这样 AbilityExtension 就可以不受限的实现功能了。
|
||||
|
||||
## 开发版本警告
|
||||
|
||||
当前应用处于开发版本状态,在 1.0.0 发布前,任何功能都可能存在不兼容更改,在升级版本前,请仔细阅读更新日志, (如果有)按照迁移指南迁移数据后方可升级;
|
||||
由于不遵循迁移指南而导致的损失,本项目相关开发人员不会对此负责。
|
||||
|
||||
### 版本号
|
||||
|
||||
本项目遵循 SemVer 版本号规范,但在正式版(1.0.0)发布前,可能会存在次版本号更新不向下兼容的问题,请仔细阅读迁移指南进行升级!
|
||||
|
||||
## 使用
|
||||
|
||||
1. 首先,在 Telegram 中联系 [BotFather](https://t.me/BotFather) ,申请机器人账号。
|
||||
2. 下载 [最新版本](https://github.com/LamGC/ScalaBot/releases/latest) 的 ScalaBot, 将发行包解压到某个目录中,然后准备一个用于存储 ScalaBot 运行数据的目录。
|
||||
3. 在作为数据存储位置的目录中,执行从分发包中解压出来的 `bin/ScalaBot` 脚本以打开 ScalaBot。 由于首次启动缺少配置文件,ScalaBot 将会初始化配置文件(`config.json` 和 `bot.json`)
|
||||
4. 将配置文件配置好后,如已下载好需要使用的扩展包,将扩展包移至 `extensions` 文件夹即可。(无需下载的扩展包将由 ScalaBot 自动下载)
|
||||
5. 如果一切正常,ScalaBot 正常运行,绑定好的 Telegram Bot 账号将会对消息有所反应。
|
||||
1. (如果没有准备机器人账号)首先,在 Telegram 中联系 [BotFather](https://t.me/BotFather) ,申请机器人账号。
|
||||
2. 运行环境需要安装好 Java 11(或更高版本);
|
||||
3. 下载 [最新版本](https://github.com/LamGC/ScalaBot/releases/latest) 的 ScalaBot 发行包, 将发行包解压到某个目录中,然后准备一个用于存储 ScalaBot
|
||||
运行数据的目录;
|
||||
4. (可选)如果有需要在非运行目录的路径上运行 ScalaBot(例如以 Service 形式启动,或者使用 Docker),可通过环境变量 `BOT_DATA_PATH` 指定 ScalaBot 的运行目录;
|
||||
5. 在作为数据存储位置的目录中,执行从分发包中解压出来的 `bin/ScalaBot` 脚本以打开 ScalaBot。 由于首次启动缺少配置文件,ScalaBot 将会初始化配置文件(`config.json` 和 `bot.json`
|
||||
),可按照 [配置文件示例](https://github.com/LamGC/ScalaBot/wiki/Configuration) 进行配置。
|
||||
6. 将配置文件配置好后,如已下载好需要使用的扩展包,将扩展包移至 `extensions` 文件夹即可。(无需下载的扩展包将由 ScalaBot 自动下载)
|
||||
7. 如果一切正常,ScalaBot 正常运行,绑定好的 Telegram Bot 账号将会对消息有所反应。
|
||||
|
||||
## 开发扩展包
|
||||
|
||||
|
@ -7,5 +7,5 @@ allprojects {
|
||||
|
||||
}
|
||||
group = "net.lamgc"
|
||||
version = "0.0.1"
|
||||
version = "0.3.0"
|
||||
}
|
@ -11,7 +11,7 @@ dependencies {
|
||||
|
||||
implementation("org.slf4j:slf4j-api:1.7.36")
|
||||
implementation("io.github.microutils:kotlin-logging:2.1.21")
|
||||
implementation("ch.qos.logback:logback-classic:1.2.10")
|
||||
implementation("ch.qos.logback:logback-classic:1.2.11")
|
||||
|
||||
val aetherVersion = "1.1.0"
|
||||
implementation("org.eclipse.aether:aether-api:$aetherVersion")
|
||||
@ -22,18 +22,20 @@ dependencies {
|
||||
implementation("org.eclipse.aether:aether-connector-basic:$aetherVersion")
|
||||
implementation("org.apache.maven:maven-aether-provider:3.3.9")
|
||||
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.1")
|
||||
implementation("org.jetbrains.kotlin:kotlin-reflect:1.6.20")
|
||||
implementation("com.google.code.gson:gson:2.9.0")
|
||||
|
||||
implementation("org.jdom:jdom2:2.0.6.1")
|
||||
|
||||
implementation("org.telegram:telegrambots-abilities:5.6.0")
|
||||
implementation("org.telegram:telegrambots:5.6.0")
|
||||
implementation("org.telegram:telegrambots-abilities:6.0.1")
|
||||
implementation("org.telegram:telegrambots:6.0.1")
|
||||
|
||||
implementation("io.prometheus:simpleclient:0.15.0")
|
||||
implementation("io.prometheus:simpleclient_httpserver:0.15.0")
|
||||
|
||||
testImplementation(kotlin("test"))
|
||||
testImplementation("io.mockk:mockk:1.12.3")
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
|
@ -1,17 +0,0 @@
|
||||
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();
|
||||
}
|
||||
|
||||
}
|
@ -1,17 +1,27 @@
|
||||
package net.lamgc.scalabot
|
||||
|
||||
import ch.qos.logback.core.PropertyDefinerBase
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import mu.KotlinLogging
|
||||
import net.lamgc.scalabot.util.ArtifactSerializer
|
||||
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.repository.Authentication
|
||||
import org.eclipse.aether.repository.Proxy
|
||||
import org.eclipse.aether.repository.RemoteRepository
|
||||
import org.eclipse.aether.repository.RepositoryPolicy
|
||||
import org.telegram.telegrambots.bots.DefaultBotOptions
|
||||
import org.telegram.telegrambots.meta.ApiConstants
|
||||
import java.io.File
|
||||
import java.net.URL
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
private val log = KotlinLogging.logger { }
|
||||
|
||||
@ -25,7 +35,14 @@ internal data class BotAccount(
|
||||
val name: String,
|
||||
val token: String,
|
||||
val creatorId: Long = -1
|
||||
)
|
||||
) {
|
||||
|
||||
val id
|
||||
// 不要想着每次获取都要从 token 里取出有性能损耗.
|
||||
// 由于 Gson 解析方式, 如果不这么做, 会出现 token 设置前 id 初始化完成, 就只有"0"了,
|
||||
// 虽然能过单元测试, 但实际使用过程是不能正常用的.
|
||||
get() = token.substringBefore(":").toLong()
|
||||
}
|
||||
|
||||
/**
|
||||
* 机器人配置.
|
||||
@ -37,16 +54,17 @@ internal data class BotAccount(
|
||||
internal data class BotConfig(
|
||||
val enabled: Boolean = true,
|
||||
val account: BotAccount,
|
||||
val disableBuiltInAbility: Boolean = true,
|
||||
val disableBuiltInAbility: Boolean = false,
|
||||
val autoUpdateCommandList: Boolean = false,
|
||||
/*
|
||||
* 使用构件坐标来选择机器人所使用的扩展包.
|
||||
* 这么做的原因是我暂时没找到一个合适的方法来让开发者方便地设定自己的扩展 Id,
|
||||
* 而构件坐标(POM Reference 或者叫 GAV 坐标)是开发者创建 Maven/Gradle 项目所一定会设置的,
|
||||
* 而构件坐标(POM Reference 或者叫 GAV 坐标)是开发者创建 Maven/Gradle 项目时一定会设置的,
|
||||
* 所以就直接用了. :P
|
||||
*/
|
||||
val extensions: Set<Artifact>,
|
||||
val proxy: ProxyConfig? = null,
|
||||
val baseApiUrl: String? = null
|
||||
val proxy: ProxyConfig? = ProxyConfig(),
|
||||
val baseApiUrl: String? = ApiConstants.BASE_URL
|
||||
)
|
||||
|
||||
/**
|
||||
@ -74,35 +92,133 @@ internal data class ProxyConfig(
|
||||
internal data class MetricsConfig(
|
||||
val enable: Boolean = false,
|
||||
val port: Int = 9386,
|
||||
val bindAddress: String? = null
|
||||
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 id: String? = null,
|
||||
val url: URL,
|
||||
val proxy: Proxy? = Proxy("http", "127.0.0.1", 1080),
|
||||
val layout: String = "default",
|
||||
val enableReleases: Boolean = true,
|
||||
val enableSnapshots: Boolean = true,
|
||||
// 可能要设计个 type 来判断解析成什么类型的 Authentication.
|
||||
val authentication: Authentication? = null
|
||||
) {
|
||||
|
||||
fun toRemoteRepository(proxyConfig: ProxyConfig): RemoteRepository {
|
||||
val builder =
|
||||
RemoteRepository.Builder(id ?: createDefaultRepositoryId(), checkRepositoryLayout(layout), url.toString())
|
||||
if (proxy != null) {
|
||||
builder.setProxy(proxy)
|
||||
} else if (proxyConfig.type == DefaultBotOptions.ProxyType.HTTP) {
|
||||
builder.setProxy(proxyConfig.toAetherProxy())
|
||||
}
|
||||
|
||||
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 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
|
||||
}
|
||||
|
||||
private val repoNumber = AtomicInteger(1)
|
||||
|
||||
fun createDefaultRepositoryId(): String {
|
||||
return "Repository-${repoNumber.getAndIncrement()}"
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ScalaBot App 配置.
|
||||
*
|
||||
* App 配置信息与 BotConfig 分开, 分别存储在各自单独的文件中.
|
||||
* @property proxy Telegram API 代理配置.
|
||||
* @property metrics 运行指标数据配置. 可通过时序数据库记录运行数据.
|
||||
* @property mavenRepositories Maven 远端仓库配置.
|
||||
* @property mavenLocalRepository Maven 本地仓库路径. 相对于运行目录 (而不是 DATA_ROOT 目录)
|
||||
*/
|
||||
internal data class AppConfig(
|
||||
val proxy: ProxyConfig = ProxyConfig(),
|
||||
val metrics: MetricsConfig = MetricsConfig()
|
||||
val metrics: MetricsConfig = MetricsConfig(),
|
||||
val mavenRepositories: List<MavenRepositoryConfig> = emptyList(),
|
||||
val mavenLocalRepository: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 需要用到的路径.
|
||||
*
|
||||
* 必须提供 `pathSupplier` 或 `fileSupplier` 其中一个, 才能正常提供路径.
|
||||
*/
|
||||
internal enum class AppPaths(
|
||||
private val pathSupplier: () -> String,
|
||||
private val initializer: AppPaths.() -> Unit = AppPaths::defaultInitializer
|
||||
private val pathSupplier: () -> String = { fileSupplier.invoke().canonicalPath },
|
||||
private val initializer: AppPaths.() -> Unit = AppPaths::defaultInitializer,
|
||||
private val fileSupplier: () -> File = { File(pathSupplier()) }
|
||||
) {
|
||||
DEFAULT_CONFIG_APPLICATION({ "./config.json" }, {
|
||||
/**
|
||||
* 数据根目录.
|
||||
*
|
||||
* 所有运行数据的存放位置.
|
||||
*
|
||||
* 提示: 结尾不带 `/`.
|
||||
*/
|
||||
DATA_ROOT(fileSupplier = {
|
||||
File(
|
||||
System.getProperty(PathConst.PROP_DATA_PATH) ?: System.getenv(PathConst.ENV_DATA_PATH)
|
||||
?: System.getProperty("user.dir") ?: "."
|
||||
)
|
||||
}, initializer = {
|
||||
val f = file
|
||||
if (!f.exists()) {
|
||||
f.mkdirs()
|
||||
}
|
||||
}),
|
||||
|
||||
DEFAULT_CONFIG_APPLICATION({ "$DATA_ROOT/config.json" }, {
|
||||
if (!file.exists()) {
|
||||
file.bufferedWriter(StandardCharsets.UTF_8).use {
|
||||
GsonConst.botConfigGson.toJson(AppConfig(), it)
|
||||
GsonConst.botConfigGson.toJson(
|
||||
AppConfig(
|
||||
mavenRepositories = listOf(
|
||||
MavenRepositoryConfig(
|
||||
id = "central",
|
||||
url = URL(MavenRepositoryExtensionFinder.MAVEN_CENTRAL_URL)
|
||||
)
|
||||
)
|
||||
), it
|
||||
)
|
||||
}
|
||||
}
|
||||
}),
|
||||
DEFAULT_CONFIG_BOT({ "./bot.json" }, {
|
||||
DEFAULT_CONFIG_BOT({ "$DATA_ROOT/bot.json" }, {
|
||||
if (!file.exists()) {
|
||||
file.bufferedWriter(StandardCharsets.UTF_8).use {
|
||||
GsonConst.botConfigGson.toJson(
|
||||
@ -121,15 +237,15 @@ internal enum class AppPaths(
|
||||
}
|
||||
}
|
||||
}),
|
||||
DATA_DB({ "./data/db/" }),
|
||||
DATA_LOGS({ "./data/logs/" }),
|
||||
EXTENSIONS({ "./extensions/" }),
|
||||
DATA_EXTENSIONS({ "./data/extensions/" }),
|
||||
TEMP({ "./tmp/" })
|
||||
DATA_DB({ "$DATA_ROOT/data/db/" }),
|
||||
DATA_LOGS({ "$DATA_ROOT/data/logs/" }),
|
||||
EXTENSIONS({ "$DATA_ROOT/extensions/" }),
|
||||
DATA_EXTENSIONS({ "$DATA_ROOT/data/extensions/" }),
|
||||
TEMP({ "$DATA_ROOT/tmp/" })
|
||||
;
|
||||
|
||||
val file: File
|
||||
get() = File(pathSupplier.invoke())
|
||||
get() = fileSupplier.invoke()
|
||||
val path: String
|
||||
get() = pathSupplier.invoke()
|
||||
|
||||
@ -138,7 +254,7 @@ internal enum class AppPaths(
|
||||
@Synchronized
|
||||
fun initial() {
|
||||
if (!initialized.get()) {
|
||||
initializer(this)
|
||||
initializer()
|
||||
initialized.set(true)
|
||||
}
|
||||
}
|
||||
@ -146,6 +262,21 @@ internal enum class AppPaths(
|
||||
override fun toString(): String {
|
||||
return path
|
||||
}
|
||||
|
||||
private object PathConst {
|
||||
const val PROP_DATA_PATH = "bot.path.data"
|
||||
const val ENV_DATA_PATH = "BOT_DATA_PATH"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 为 LogBack 提供日志目录路径.
|
||||
*/
|
||||
internal class LogDirectorySupplier : PropertyDefinerBase() {
|
||||
override fun getPropertyValue(): String {
|
||||
return AppPaths.DATA_LOGS.path
|
||||
}
|
||||
}
|
||||
|
||||
internal object Const {
|
||||
@ -153,22 +284,32 @@ internal object Const {
|
||||
}
|
||||
|
||||
private fun AppPaths.defaultInitializer() {
|
||||
if (!file.exists()) {
|
||||
val result = if (path.endsWith("/")) {
|
||||
file.mkdirs()
|
||||
val f = file
|
||||
val p = path
|
||||
if (!f.exists()) {
|
||||
val result = if (p.endsWith("/")) {
|
||||
f.mkdirs()
|
||||
} else {
|
||||
file.createNewFile()
|
||||
f.createNewFile()
|
||||
}
|
||||
if (!result) {
|
||||
log.warn { "初始化文件(夹)失败: $path" }
|
||||
log.warn { "初始化文件(夹)失败: $p" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun initialFiles() {
|
||||
val configFilesNotInitialized = !AppPaths.DEFAULT_CONFIG_APPLICATION.file.exists()
|
||||
&& !AppPaths.DEFAULT_CONFIG_BOT.file.exists()
|
||||
|
||||
for (path in AppPaths.values()) {
|
||||
path.initial()
|
||||
}
|
||||
|
||||
if (configFilesNotInitialized) {
|
||||
log.warn { "配置文件已初始化, 请根据需要修改配置文件后重新启动本程序." }
|
||||
exitProcess(1)
|
||||
}
|
||||
}
|
||||
|
||||
private object GsonConst {
|
||||
@ -179,6 +320,8 @@ private object GsonConst {
|
||||
|
||||
val appConfigGson: Gson = baseGson.newBuilder()
|
||||
.registerTypeAdapter(DefaultBotOptions.ProxyType::class.java, ProxyTypeSerializer)
|
||||
.registerTypeAdapter(MavenRepositoryConfig::class.java, MavenRepositoryConfigSerializer)
|
||||
.registerTypeAdapter(Authentication::class.java, AuthenticationSerializer)
|
||||
.create()
|
||||
|
||||
val botConfigGson: Gson = baseGson.newBuilder()
|
||||
|
@ -4,24 +4,32 @@ import io.prometheus.client.exporter.HTTPServer
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import mu.KotlinLogging
|
||||
import net.lamgc.scalabot.util.registerShutdownHook
|
||||
import org.eclipse.aether.repository.LocalRepository
|
||||
import org.telegram.telegrambots.bots.DefaultBotOptions
|
||||
import org.telegram.telegrambots.meta.TelegramBotsApi
|
||||
import org.telegram.telegrambots.meta.generics.BotSession
|
||||
import org.telegram.telegrambots.updatesreceivers.DefaultBotSession
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
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
|
||||
|
||||
private val log = KotlinLogging.logger { }
|
||||
|
||||
private val launcher = Launcher()
|
||||
.registerShutdownHook()
|
||||
|
||||
fun main(args: Array<String>): Unit = runBlocking {
|
||||
log.info { "ScalaBot 正在启动中..." }
|
||||
log.info { "数据目录: ${AppPaths.DATA_ROOT}" }
|
||||
log.debug { "启动参数: ${args.joinToString(prefix = "[", postfix = "]")}" }
|
||||
initialFiles()
|
||||
if (Const.config.metrics.enable) {
|
||||
|
||||
val launcher = Launcher()
|
||||
.registerShutdownHook()
|
||||
startMetricsServer()
|
||||
}
|
||||
if (!launcher.launch()) {
|
||||
exitProcess(1)
|
||||
}
|
||||
@ -31,11 +39,16 @@ fun main(args: Array<String>): Unit = runBlocking {
|
||||
* 启动运行指标服务器.
|
||||
* 使用 Prometheus 指标格式.
|
||||
*/
|
||||
fun startMetricsServer() {
|
||||
internal fun startMetricsServer(config: MetricsConfig = Const.config.metrics) {
|
||||
if (!config.enable) {
|
||||
log.debug { "运行指标服务器已禁用." }
|
||||
return
|
||||
}
|
||||
|
||||
val builder = HTTPServer.Builder()
|
||||
.withDaemonThreads(true)
|
||||
.withPort(Const.config.metrics.port)
|
||||
.withHostname(Const.config.metrics.bindAddress)
|
||||
.withPort(config.port)
|
||||
.withHostname(config.bindAddress)
|
||||
|
||||
val httpServer = builder
|
||||
.build()
|
||||
@ -43,7 +56,7 @@ fun startMetricsServer() {
|
||||
log.info { "运行指标服务器已启动. (Port: ${httpServer.port})" }
|
||||
}
|
||||
|
||||
internal class Launcher : AutoCloseable {
|
||||
internal class Launcher(private val config: AppConfig = Const.config) : AutoCloseable {
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
@ -52,6 +65,46 @@ internal class Launcher : AutoCloseable {
|
||||
|
||||
private val botApi = TelegramBotsApi(DefaultBotSession::class.java)
|
||||
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 = AppPaths.DATA_ROOT.file.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 {
|
||||
createDirectories(
|
||||
PosixFilePermissions.asFileAttribute(
|
||||
setOf(
|
||||
PosixFilePermission.OWNER_READ,
|
||||
PosixFilePermission.OWNER_WRITE,
|
||||
PosixFilePermission.GROUP_READ,
|
||||
PosixFilePermission.GROUP_WRITE,
|
||||
PosixFilePermission.OTHERS_READ,
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.toRealPath()
|
||||
.toFile()
|
||||
repoPath
|
||||
} else {
|
||||
File("${System.getProperty("user.home")}/.m2/repository")
|
||||
}
|
||||
if (!localPath.exists()) {
|
||||
localPath.mkdirs()
|
||||
}
|
||||
return LocalRepository(localPath)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun launch(): Boolean {
|
||||
@ -59,9 +112,16 @@ internal class Launcher : AutoCloseable {
|
||||
if (botConfigs.isEmpty()) {
|
||||
log.warn { "尚未配置任何机器人, 请先配置机器人后再启动本程序." }
|
||||
return false
|
||||
} else if (botConfigs.none { it.enabled }) {
|
||||
log.warn { "配置文件中没有已启用的机器人, 请至少启用一个机器人." }
|
||||
return false
|
||||
}
|
||||
for (botConfig in botConfigs) {
|
||||
try {
|
||||
launchBot(botConfig)
|
||||
} catch (e: Exception) {
|
||||
log.error(e) { "机器人 `${botConfig.account.name}` 启动时发生错误." }
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@ -76,15 +136,15 @@ internal class Launcher : AutoCloseable {
|
||||
val proxyConfig =
|
||||
if (botConfig.proxy != null && botConfig.proxy.type != DefaultBotOptions.ProxyType.NO_PROXY) {
|
||||
botConfig.proxy
|
||||
} else if (Const.config.proxy.type != DefaultBotOptions.ProxyType.NO_PROXY) {
|
||||
Const.config.proxy
|
||||
} else if (config.proxy.type != DefaultBotOptions.ProxyType.NO_PROXY) {
|
||||
config.proxy
|
||||
} else {
|
||||
null
|
||||
}
|
||||
if (proxyConfig != null) {
|
||||
proxyType = proxyConfig.type
|
||||
proxyHost = Const.config.proxy.host
|
||||
proxyPort = Const.config.proxy.port
|
||||
proxyHost = config.proxy.host
|
||||
proxyPort = config.proxy.port
|
||||
log.debug { "机器人 `${botConfig.account.name}` 已启用代理配置: $proxyConfig" }
|
||||
}
|
||||
|
||||
@ -93,25 +153,62 @@ internal class Launcher : AutoCloseable {
|
||||
}
|
||||
}
|
||||
val account = botConfig.account
|
||||
|
||||
val remoteRepositories = config.mavenRepositories
|
||||
.map { it.toRemoteRepository(config.proxy) }
|
||||
.toMutableList().apply {
|
||||
if (this.none {
|
||||
it.url == MavenRepositoryExtensionFinder.MAVEN_CENTRAL_URL
|
||||
|| it.url == MavenRepositoryExtensionFinder.MAVEN_CENTRAL_URL.trimEnd('/')
|
||||
}) {
|
||||
add(MavenRepositoryExtensionFinder.getMavenCentralRepository(proxy = config.proxy.toAetherProxy()))
|
||||
}
|
||||
}.toList()
|
||||
val extensionPackageFinders = setOf(
|
||||
MavenRepositoryExtensionFinder(
|
||||
localRepository = mavenLocalRepository,
|
||||
remoteRepositories = remoteRepositories,
|
||||
proxy = config.proxy.toAetherProxy()
|
||||
)
|
||||
)
|
||||
|
||||
val bot = ScalaBot(
|
||||
account.name,
|
||||
account.token,
|
||||
account.creatorId,
|
||||
BotDBMaker.getBotMaker(account),
|
||||
BotDBMaker.getBotDbInstance(account),
|
||||
botOption,
|
||||
botConfig.extensions,
|
||||
botConfig.disableBuiltInAbility
|
||||
extensionPackageFinders,
|
||||
botConfig
|
||||
)
|
||||
botSessionMap[bot] = botApi.registerBot(bot)
|
||||
log.info { "机器人 `${bot.botUsername}` 已启动." }
|
||||
|
||||
if (botConfig.autoUpdateCommandList) {
|
||||
log.debug { "[Bot ${botConfig.account.name}] 正在自动更新命令列表..." }
|
||||
try {
|
||||
val result = bot.updateCommandList()
|
||||
if (result) {
|
||||
log.info { "[Bot ${botConfig.account.name}] 已成功更新 Bot 命令列表." }
|
||||
} else {
|
||||
log.warn { "[Bot ${botConfig.account.name}] 自动更新 Bot 命令列表失败!" }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
log.warn(e) { "命令列表自动更新失败." }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun close() {
|
||||
botSessionMap.forEach {
|
||||
try {
|
||||
if (!it.value.isRunning) {
|
||||
return@forEach
|
||||
}
|
||||
log.info { "正在关闭机器人 `${it.key.botUsername}` ..." }
|
||||
it.value.stop()
|
||||
log.info { "已关闭机器人 `${it.key.botUsername}`." }
|
||||
} catch (e: Exception) {
|
||||
log.error(e) { "机器人 `${it.key.botUsername}` 关闭时发生异常." }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,23 +1,190 @@
|
||||
package net.lamgc.scalabot
|
||||
|
||||
import net.lamgc.scalabot.util.toHaxString
|
||||
import com.google.common.io.Files
|
||||
import mu.KotlinLogging
|
||||
import net.lamgc.scalabot.util.toHexString
|
||||
import org.mapdb.DB
|
||||
import org.mapdb.DBException
|
||||
import org.mapdb.DBMaker
|
||||
import org.telegram.abilitybots.api.db.DBContext
|
||||
import org.telegram.abilitybots.api.db.MapDBContext
|
||||
import java.io.File
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* 数据库适配器.
|
||||
*
|
||||
* 应按照新到旧的顺序放置, 新的适配器应该在上面.
|
||||
*/
|
||||
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 {
|
||||
fun getBotMaker(botAccount: BotAccount): DBContext {
|
||||
val digest: MessageDigest = MessageDigest.getInstance("SHA-256")
|
||||
val digestBytes = digest.digest(botAccount.token.toByteArray(StandardCharsets.UTF_8))
|
||||
val dbPath = AppPaths.DATA_DB.path + "${digestBytes.toHaxString()}.db"
|
||||
val db = DBMaker.fileDB(dbPath)
|
||||
private val logger = KotlinLogging.logger { }
|
||||
|
||||
fun getBotDbInstance(botAccount: BotAccount): DBContext {
|
||||
for (adapter in adapters) {
|
||||
val botDb = try {
|
||||
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()
|
||||
.checksumStoreEnable()
|
||||
.fileChannelEnable()
|
||||
.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 {
|
||||
@Suppress("UnstableApiUsage")
|
||||
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 改变后数据库文件名也会改变, 故弃用该方法.
|
||||
*/
|
||||
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")
|
||||
})
|
@ -4,7 +4,6 @@ import mu.KotlinLogging
|
||||
import net.lamgc.scalabot.extension.BotExtensionFactory
|
||||
import net.lamgc.scalabot.util.getPriority
|
||||
import org.eclipse.aether.artifact.Artifact
|
||||
import org.eclipse.aether.repository.LocalRepository
|
||||
import org.telegram.abilitybots.api.util.AbilityExtension
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
@ -18,18 +17,15 @@ import java.util.concurrent.atomic.AtomicInteger
|
||||
internal class ExtensionLoader(
|
||||
private val bot: ScalaBot,
|
||||
private val extensionsDataFolder: File = AppPaths.DATA_EXTENSIONS.file,
|
||||
private val extensionsPath: File = AppPaths.EXTENSIONS.file
|
||||
private val extensionsPath: File = AppPaths.EXTENSIONS.file,
|
||||
private val extensionFinders: Set<ExtensionPackageFinder> = setOf()
|
||||
) {
|
||||
private val log = KotlinLogging.logger { }
|
||||
|
||||
private val finders: Set<ExtensionPackageFinder> = setOf(
|
||||
private val finders: Set<ExtensionPackageFinder> = mutableSetOf(
|
||||
FileNameFinder,
|
||||
MavenMetaInformationFinder,
|
||||
MavenRepositoryExtensionFinder(
|
||||
LocalRepository("${System.getProperty("user.home")}/.m2/repository"),
|
||||
proxy = Const.config.proxy.toAetherProxy()
|
||||
)
|
||||
)
|
||||
MavenMetaInformationFinder
|
||||
).apply { addAll(extensionFinders) }.toSet()
|
||||
|
||||
fun getExtensions(): Set<LoadedExtensionEntry> {
|
||||
val extensionEntries = mutableSetOf<LoadedExtensionEntry>()
|
||||
@ -94,6 +90,10 @@ internal class ExtensionLoader(
|
||||
try {
|
||||
val extension =
|
||||
factory.createExtensionInstance(bot, getExtensionDataFolder(extensionArtifact))
|
||||
if (extension == null) {
|
||||
log.debug { "Factory ${factory::class.java} 创建插件时返回了 null, 已跳过. (BotName: ${bot.botUsername})" }
|
||||
continue
|
||||
}
|
||||
factories.add(LoadedExtensionEntry(extensionArtifact, factory::class.java, extension))
|
||||
} catch (e: Exception) {
|
||||
log.error(e) { "创建扩展时发生异常. (ExtArtifact: `$extensionArtifact`, Factory: ${factory::class.java.name})" }
|
||||
@ -103,13 +103,11 @@ internal class ExtensionLoader(
|
||||
}
|
||||
|
||||
private fun allFoundedPackageNumber(filesMap: Map<ExtensionPackageFinder, Set<FoundExtensionPackage>>): Int {
|
||||
val result = mutableSetOf<URL>()
|
||||
var number = 0
|
||||
for (files in filesMap.values) {
|
||||
for (file in files) {
|
||||
result.add(file.getRawUrl())
|
||||
number += files.size
|
||||
}
|
||||
}
|
||||
return result.size
|
||||
return number
|
||||
}
|
||||
|
||||
private fun findExtensionPackage(
|
||||
@ -134,7 +132,7 @@ internal class ExtensionLoader(
|
||||
result[finder] = artifacts
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
log.error { "搜索器 ${finder::class.java.name} 在搜索扩展 `$extensionArtifact` 时发生错误:" }
|
||||
log.error(e) { "搜索器 ${finder::class.java.name} 在搜索扩展 `$extensionArtifact` 时发生错误." }
|
||||
}
|
||||
}
|
||||
return result
|
||||
|
@ -234,7 +234,7 @@ internal object MavenMetaInformationFinder : ExtensionPackageFinder {
|
||||
*/
|
||||
@FinderRules(priority = FinderPriority.REMOTE)
|
||||
internal class MavenRepositoryExtensionFinder(
|
||||
private val localRepository: LocalRepository,
|
||||
private val localRepository: LocalRepository = LocalRepository("${System.getProperty("user.home")}/.m2/repository"),
|
||||
private val proxy: Proxy? = null,
|
||||
private val remoteRepositories: List<RemoteRepository> = listOf(getMavenCentralRepository(proxy)),
|
||||
) : ExtensionPackageFinder {
|
||||
@ -257,9 +257,21 @@ internal class MavenRepositoryExtensionFinder(
|
||||
}
|
||||
|
||||
override fun findByArtifact(extensionArtifact: Artifact, extensionsPath: File): Set<FoundExtensionPackage> {
|
||||
log.debug {
|
||||
StringBuilder().apply {
|
||||
append("构件 $extensionArtifact 将在以下仓库拉取: \n")
|
||||
remoteRepositories.forEach {
|
||||
append("\t- ${it}\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
val extensionArtifactResult = repositorySystem.resolveArtifact(
|
||||
repoSystemSession,
|
||||
ArtifactRequest(extensionArtifact, remoteRepositories, null)
|
||||
ArtifactRequest(
|
||||
extensionArtifact,
|
||||
repositorySystem.newResolutionRepositories(repoSystemSession, remoteRepositories),
|
||||
null
|
||||
)
|
||||
)
|
||||
val extResolvedArtifact = extensionArtifactResult.artifact
|
||||
if (!extensionArtifactResult.isResolved) {
|
||||
@ -354,14 +366,14 @@ internal class MavenRepositoryExtensionFinder(
|
||||
RepositoryPolicy(
|
||||
true,
|
||||
RepositoryPolicy.UPDATE_POLICY_DAILY,
|
||||
RepositoryPolicy.CHECKSUM_POLICY_WARN
|
||||
RepositoryPolicy.CHECKSUM_POLICY_FAIL
|
||||
)
|
||||
)
|
||||
builder.setSnapshotPolicy(
|
||||
RepositoryPolicy(
|
||||
true,
|
||||
RepositoryPolicy.UPDATE_POLICY_ALWAYS,
|
||||
RepositoryPolicy.CHECKSUM_POLICY_WARN
|
||||
RepositoryPolicy.CHECKSUM_POLICY_FAIL
|
||||
)
|
||||
)
|
||||
if (authentication != null) {
|
||||
|
@ -7,32 +7,121 @@ import mu.KotlinLogging
|
||||
import org.eclipse.aether.artifact.Artifact
|
||||
import org.telegram.abilitybots.api.bot.AbilityBot
|
||||
import org.telegram.abilitybots.api.db.DBContext
|
||||
import org.telegram.abilitybots.api.objects.Ability
|
||||
import org.telegram.abilitybots.api.toggle.BareboneToggle
|
||||
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.SetMyCommands
|
||||
import org.telegram.telegrambots.meta.api.objects.Update
|
||||
import org.telegram.telegrambots.meta.api.objects.commands.BotCommand
|
||||
|
||||
/**
|
||||
* 可扩展 Bot.
|
||||
* @param name 机器人名称. 建议设为机器人用户名.
|
||||
* @param token 机器人 API 令牌.
|
||||
* @property creatorId 机器人所有人的 Telegram 用户 Id. 可通过联系部分机器人来获取该信息.
|
||||
* (e.g. [@userinfobot](http://t.me/userinfobot))
|
||||
* @param db 机器人数据库对象. 用于状态机等用途.
|
||||
* @param options AbilityBot 设置对象.
|
||||
* @property extensions 扩展坐标集合.
|
||||
* @param disableBuiltInAbility 是否禁用 [AbilityBot] 内置命令.
|
||||
*/
|
||||
internal class ScalaBot(
|
||||
name: String,
|
||||
token: String,
|
||||
private val creatorId: Long,
|
||||
db: DBContext,
|
||||
options: DefaultBotOptions,
|
||||
val extensions: Set<Artifact>,
|
||||
disableBuiltInAbility: Boolean
|
||||
extensionFinders: Set<ExtensionPackageFinder>,
|
||||
botConfig: BotConfig,
|
||||
private val creatorId: Long = botConfig.account.creatorId,
|
||||
val accountId: Long = botConfig.account.id,
|
||||
val extensions: Set<Artifact> = botConfig.extensions
|
||||
) :
|
||||
AbilityBot(token, name, db, if (disableBuiltInAbility) DefaultToggle() else BareboneToggle(), options) {
|
||||
AbilityBot(
|
||||
botConfig.account.token,
|
||||
botConfig.account.name,
|
||||
db,
|
||||
if (botConfig.disableBuiltInAbility)
|
||||
BareboneToggle()
|
||||
else
|
||||
DefaultToggle(),
|
||||
options
|
||||
) {
|
||||
|
||||
private val extensionLoader = ExtensionLoader(
|
||||
bot = this,
|
||||
extensionFinders = extensionFinders
|
||||
)
|
||||
|
||||
init {
|
||||
log.info { "[Bot $botUsername] 正在加载扩展..." }
|
||||
val extensionEntries = extensionLoader.getExtensions()
|
||||
for (entry in extensionEntries) {
|
||||
addExtension(entry.extension)
|
||||
log.debug {
|
||||
"[Bot $botUsername] 扩展包 `${entry.extensionArtifact}` 中的扩展 `${entry.extension::class.qualifiedName}` " +
|
||||
"(由工厂类 `${entry.factoryClass.name}` 创建) 已注册."
|
||||
}
|
||||
}
|
||||
log.info { "[Bot $botUsername] 扩展加载完成." }
|
||||
}
|
||||
|
||||
override fun creatorId(): Long = creatorId
|
||||
|
||||
override fun onUpdateReceived(update: Update?) {
|
||||
botUpdateCounter.labels(botUsername).inc()
|
||||
botUpdateGauge.labels(botUsername).inc()
|
||||
|
||||
val timer = updateProcessTime.labels(botUsername).startTimer()
|
||||
try {
|
||||
super.onUpdateReceived(update)
|
||||
} catch (e: Exception) {
|
||||
exceptionHandlingCounter.labels(botUsername).inc()
|
||||
throw e
|
||||
} finally {
|
||||
timer.observeDuration()
|
||||
botUpdateGauge.labels(botUsername).dec()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 Telegram Bot 的命令列表.
|
||||
*
|
||||
* 本方法将根据已注册的 [Ability] 更新 Telegram 中机器人的命令列表.
|
||||
*
|
||||
* 调用本方法前, 必须先调用一次 [registerAbilities], 否则无法获取 Ability 信息.
|
||||
* @return 更新成功返回 `true`.
|
||||
*/
|
||||
fun updateCommandList(): Boolean {
|
||||
if (abilities() == null) {
|
||||
throw IllegalStateException("Abilities has not been initialized.")
|
||||
}
|
||||
|
||||
val botCommands = abilities().values.map {
|
||||
val abilityInfo = if (it.info() == null || it.info().trim().isEmpty()) {
|
||||
log.warn { "[Bot $botUsername] Ability `${it.name()}` 没有说明信息." }
|
||||
"(The command has no description)"
|
||||
} else {
|
||||
log.debug { "[Bot $botUsername] Ability `${it.name()}` info `${it.info()}`" }
|
||||
it.info().trim()
|
||||
}
|
||||
BotCommand(it.name(), abilityInfo)
|
||||
}
|
||||
|
||||
if (botCommands.isEmpty()) {
|
||||
log.info { "Bot 没有任何命令, 命令列表更新已跳过." }
|
||||
return true
|
||||
}
|
||||
|
||||
val setMyCommands = SetMyCommands()
|
||||
setMyCommands.commands = botCommands
|
||||
return execute(DeleteMyCommands()) && execute(setMyCommands)
|
||||
}
|
||||
|
||||
override fun onRegister() {
|
||||
super.onRegister()
|
||||
onlineBotGauge.inc()
|
||||
}
|
||||
|
||||
override fun onClosing() {
|
||||
onlineBotGauge.dec()
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
@ -67,7 +156,7 @@ internal class ScalaBot(
|
||||
private val updateProcessTime = Summary.build()
|
||||
.name("update_process_duration_seconds")
|
||||
.help(
|
||||
"Time to process update. (This indicator includes the pre-processing of update by TelegrammBots, " +
|
||||
"Time to process update. (This indicator includes the pre-processing of update by TelegramBots, " +
|
||||
"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)"
|
||||
)
|
||||
@ -83,45 +172,4 @@ internal class ScalaBot(
|
||||
.subsystem("telegrambots")
|
||||
.register()
|
||||
}
|
||||
|
||||
private val extensionLoader = ExtensionLoader(this)
|
||||
|
||||
init {
|
||||
val extensionEntries = extensionLoader.getExtensions()
|
||||
for (entry in extensionEntries) {
|
||||
addExtension(entry.extension)
|
||||
log.debug {
|
||||
"[Bot ${botUsername}] 扩展包 `${entry.extensionArtifact}` 中的扩展 `${entry.extension::class.qualifiedName}` " +
|
||||
"(由工厂类 `${entry.factoryClass.name}` 创建) 已注册."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun creatorId(): Long = creatorId
|
||||
|
||||
override fun onUpdateReceived(update: Update?) {
|
||||
botUpdateCounter.labels(botUsername).inc()
|
||||
botUpdateGauge.labels(botUsername).inc()
|
||||
|
||||
val timer = updateProcessTime.labels(botUsername).startTimer()
|
||||
try {
|
||||
super.onUpdateReceived(update)
|
||||
} catch (e: Exception) {
|
||||
exceptionHandlingCounter.labels(botUsername).inc()
|
||||
throw e
|
||||
} finally {
|
||||
timer.observeDuration()
|
||||
botUpdateGauge.labels(botUsername).dec()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRegister() {
|
||||
super.onRegister()
|
||||
onlineBotGauge.inc()
|
||||
}
|
||||
|
||||
override fun onClosing() {
|
||||
super.onClosing()
|
||||
onlineBotGauge.dec()
|
||||
}
|
||||
}
|
||||
|
@ -1,12 +1,18 @@
|
||||
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
|
||||
|
||||
object ProxyTypeSerializer : JsonDeserializer<DefaultBotOptions.ProxyType>,
|
||||
internal object ProxyTypeSerializer : JsonDeserializer<DefaultBotOptions.ProxyType>,
|
||||
JsonSerializer<DefaultBotOptions.ProxyType> {
|
||||
|
||||
override fun deserialize(
|
||||
@ -34,7 +40,7 @@ object ProxyTypeSerializer : JsonDeserializer<DefaultBotOptions.ProxyType>,
|
||||
}
|
||||
}
|
||||
|
||||
object ArtifactSerializer : JsonSerializer<Artifact>, JsonDeserializer<Artifact> {
|
||||
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")) {
|
||||
@ -55,3 +61,108 @@ object ArtifactSerializer : JsonSerializer<Artifact>, JsonDeserializer<Artifact>
|
||||
|
||||
}
|
||||
|
||||
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(
|
||||
id = json.get("id")?.asString,
|
||||
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",
|
||||
enableReleases = json.get("enableReleases")?.asBoolean ?: true,
|
||||
enableSnapshots = json.get("enableSnapshots")?.asBoolean ?: true,
|
||||
authentication = if (json.has("authentication") && json.get("authentication").isJsonObject)
|
||||
context.deserialize<Authentication>(
|
||||
json.getAsJsonObject("authentication"), Authentication::class.java
|
||||
) else null
|
||||
)
|
||||
}
|
||||
is JsonPrimitive -> {
|
||||
MavenRepositoryConfig(url = URL(json.asString))
|
||||
}
|
||||
else -> {
|
||||
throw JsonParseException("Unsupported Maven warehouse configuration type.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -7,9 +7,8 @@ import org.eclipse.aether.artifact.Artifact
|
||||
import java.io.File
|
||||
import java.io.FileFilter
|
||||
import java.io.FilenameFilter
|
||||
import java.net.URL
|
||||
|
||||
internal fun ByteArray.toHaxString(): String = ByteUtils.bytesToHexString(this)
|
||||
internal fun ByteArray.toHexString(): String = joinToString("") { it.toString(16) }
|
||||
|
||||
internal fun Artifact.equalsArtifact(that: Artifact): Boolean =
|
||||
this.groupId.equals(that.groupId) &&
|
||||
@ -34,11 +33,7 @@ internal fun File.deepListFiles(
|
||||
this.listFiles(filenameFilter)
|
||||
} else {
|
||||
this.listFiles()
|
||||
}
|
||||
|
||||
if (files == null) {
|
||||
return null
|
||||
}
|
||||
} ?: return null
|
||||
|
||||
val result = if (addSelf) mutableSetOf(this) else mutableSetOf()
|
||||
for (file in files) {
|
||||
@ -48,12 +43,10 @@ internal fun File.deepListFiles(
|
||||
if (!onlyFile) {
|
||||
result.add(file)
|
||||
}
|
||||
val subFiles = file.deepListFiles(false, onlyFile, fileFilter, filenameFilter)
|
||||
if (subFiles != null) {
|
||||
val subFiles = file.deepListFiles(false, onlyFile, fileFilter, filenameFilter) ?: continue
|
||||
result.addAll(subFiles)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toTypedArray()
|
||||
}
|
||||
|
||||
@ -62,9 +55,14 @@ internal fun File.deepListFiles(
|
||||
* @return 获取 Finder 的优先级.
|
||||
* @throws NoSuchFieldException 如果 Finder 没有添加 [FinderRules] 注解时抛出该异常.
|
||||
*/
|
||||
internal fun ExtensionPackageFinder.getPriority() =
|
||||
this::class.java.getDeclaredAnnotation(FinderRules::class.java)?.priority
|
||||
internal fun ExtensionPackageFinder.getPriority(): Int {
|
||||
val value = this::class.java.getDeclaredAnnotation(FinderRules::class.java)?.priority
|
||||
?: 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 钩子.
|
||||
@ -82,30 +80,19 @@ private object UtilsInternal {
|
||||
val autoCloseableSet = mutableSetOf<AutoCloseable>()
|
||||
|
||||
init {
|
||||
Runtime.getRuntime().addShutdownHook(Thread({
|
||||
Runtime.getRuntime().addShutdownHook(Thread(this::doCloseResources, "Shutdown-AutoCloseable"))
|
||||
}
|
||||
|
||||
fun doCloseResources() {
|
||||
log.debug { "Closing registered hook resources..." }
|
||||
autoCloseableSet.forEach {
|
||||
autoCloseableSet.removeIf {
|
||||
try {
|
||||
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"))
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<included>
|
||||
<define name="DATA_LOGS" class="net.lamgc.scalabot.LogDirectorySupplier"/>
|
||||
|
||||
<appender name="STD_OUT" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder>
|
||||
<pattern>[%d{HH:mm:ss.SSS} %5level][%logger{36}][%thread]: %msg%n</pattern>
|
||||
@ -20,7 +22,7 @@
|
||||
</appender>
|
||||
|
||||
<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">
|
||||
<fileNamePattern>data/logs/%d{yyyy-MM-dd}.log.gz</fileNamePattern>
|
||||
<maxHistory>30</maxHistory>
|
||||
|
@ -5,6 +5,8 @@
|
||||
<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"/>
|
||||
<logger name="org.telegram.telegrambots.facilities.proxysocketfactorys" level="INFO"/>
|
||||
<logger name="org.eclipse.aether.internal.impl.DefaultUpdateCheckManager" level="INFO"/>
|
||||
|
||||
<root level="DEBUG">
|
||||
<appender-ref ref="FILE_OUT"/>
|
||||
|
122
scalabot-app/src/test/kotlin/AppConfigTest.kt
Normal file
122
scalabot-app/src/test/kotlin/AppConfigTest.kt
Normal file
@ -0,0 +1,122 @@
|
||||
package net.lamgc.scalabot
|
||||
|
||||
import com.google.gson.Gson
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
import kotlin.math.abs
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
internal class BotAccountTest {
|
||||
|
||||
@Test
|
||||
fun deserializerTest() {
|
||||
val accountId = abs(Random().nextInt()).toLong()
|
||||
val creatorId = abs(Random().nextInt()).toLong()
|
||||
val botAccount = Gson().fromJson(
|
||||
"""
|
||||
{
|
||||
"name": "TestBot",
|
||||
"token": "${accountId}:AAHErDroUTznQsOd_oZPJ6cQEj4Z5mGHO10",
|
||||
"creatorId": $creatorId
|
||||
}
|
||||
""".trimIndent(), BotAccount::class.java
|
||||
)
|
||||
assertEquals("TestBot", botAccount.name)
|
||||
assertEquals("${accountId}:AAHErDroUTznQsOd_oZPJ6cQEj4Z5mGHO10", botAccount.token)
|
||||
assertEquals(accountId, botAccount.id, "Botaccount ID does not match expectations.")
|
||||
assertEquals(creatorId, botAccount.creatorId)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
internal class AppPathsTest {
|
||||
|
||||
@Test
|
||||
fun `Data root path priority`() {
|
||||
System.setProperty("bot.path.data", "A")
|
||||
|
||||
assertEquals("A", AppPaths.DATA_ROOT.file.path, "`DATA_ROOT`没有优先返回 Property 的值.")
|
||||
System.getProperties().remove("bot.path.data")
|
||||
if (System.getenv("BOT_DATA_PATH") != null) {
|
||||
assertEquals(
|
||||
System.getenv("BOT_DATA_PATH"), AppPaths.DATA_ROOT.file.path,
|
||||
"`DATA_ROOT`没有返回 env 的值."
|
||||
)
|
||||
} else {
|
||||
assertEquals(
|
||||
System.getProperty("user.dir"), AppPaths.DATA_ROOT.file.path,
|
||||
"`DATA_ROOT`没有返回 `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() }
|
||||
}
|
||||
|
||||
defaultInitializerMethod.isAccessible = false
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -1,9 +1,21 @@
|
||||
package net.lamgc.scalabot.util
|
||||
|
||||
import io.mockk.every
|
||||
import io.mockk.justRun
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
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.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.assertDoesNotThrow
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
import java.io.File
|
||||
import java.lang.reflect.InvocationTargetException
|
||||
import java.nio.charset.StandardCharsets
|
||||
import kotlin.test.*
|
||||
|
||||
internal class UtilsKtTest {
|
||||
|
||||
@ -16,4 +28,92 @@ internal class UtilsKtTest {
|
||||
.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()
|
||||
}
|
||||
}
|
@ -23,7 +23,12 @@ public class SayHelloExtension implements AbilityExtension {
|
||||
.info("Say hello to you.")
|
||||
.privacy(Privacy.PUBLIC)
|
||||
.locality(Locality.ALL)
|
||||
.action(ctx -> ctx.bot().silent().send("Hello! " + ctx.user().getUserName(), ctx.chatId()))
|
||||
.action(ctx -> {
|
||||
String msg = "Hello! " + ctx.user().getUserName() +
|
||||
" ( " + ctx.user().getId() + " ) [ " + ctx.user().getLanguageCode() + " ]" + "\n" +
|
||||
"Current Chat ID: " + ctx.chatId();
|
||||
ctx.bot().silent().send(msg, ctx.chatId());
|
||||
})
|
||||
.build();
|
||||
}
|
||||
|
||||
@ -32,11 +37,16 @@ public class SayHelloExtension implements AbilityExtension {
|
||||
*/
|
||||
public Ability test() {
|
||||
ReplyFlow botHello = ReplyFlow.builder(bot.db())
|
||||
.enableStats("say_hello")
|
||||
.action((bot, upd) -> bot.silent().send("What is u name?", upd.getMessage().getChatId()))
|
||||
.onlyIf(update -> "hello".equalsIgnoreCase(update.getMessage().getText()))
|
||||
.onlyIf(update -> update.hasMessage()
|
||||
&& update.getMessage().hasText()
|
||||
&& "hello".equalsIgnoreCase(update.getMessage().getText()))
|
||||
.next(Reply.of((bot, upd) -> bot.silent()
|
||||
.send("OK! You name is " + upd.getMessage().getText().substring("my name is ".length()), upd.getMessage().getChatId()),
|
||||
upd -> upd.getMessage().getText().startsWith("my name is ")))
|
||||
upd -> upd.hasMessage()
|
||||
&& upd.getMessage().hasText()
|
||||
&& upd.getMessage().getText().startsWith("my name is ")))
|
||||
.build();
|
||||
|
||||
return Ability.builder()
|
||||
|
@ -4,14 +4,16 @@ plugins {
|
||||
kotlin("jvm") version "1.6.10"
|
||||
java
|
||||
`maven-publish`
|
||||
signing
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api("org.telegram:telegrambots-abilities:5.6.0")
|
||||
api("org.telegram:telegrambots-abilities:6.0.1")
|
||||
api("org.slf4j:slf4j-api:1.7.36")
|
||||
|
||||
// There is nothing to test.
|
||||
// testImplementation(kotlin("test"))
|
||||
testImplementation("org.junit.jupiter:junit-jupiter-api:5.8.2")
|
||||
testImplementation("org.mockito:mockito-core:4.4.0")
|
||||
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")
|
||||
}
|
||||
|
||||
tasks.withType<Javadoc> {
|
||||
@ -23,6 +25,8 @@ tasks.withType<Javadoc> {
|
||||
java {
|
||||
withJavadocJar()
|
||||
withSourcesJar()
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
@ -35,24 +39,21 @@ tasks.withType<KotlinCompile> {
|
||||
|
||||
publishing {
|
||||
repositories {
|
||||
val repoRootKey = "maven.repo.local.root"
|
||||
val snapshot = project.version.toString().endsWith("-SNAPSHOT")
|
||||
val repoRoot = System.getProperty(repoRootKey)?.trim()
|
||||
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
|
||||
if (project.version.toString().endsWith("-SNAPSHOT")) {
|
||||
maven("https://repo.lamgc.moe/repository/maven-snapshots/") {
|
||||
credentials {
|
||||
username = project.properties["repo.credentials.private.username"].toString()
|
||||
password = project.properties["repo.credentials.private.password"].toString()
|
||||
}
|
||||
}
|
||||
val repoUri = if (snapshot) {
|
||||
uri("$repoRoot/snapshots")
|
||||
} else {
|
||||
uri("$repoRoot/releases")
|
||||
maven("https://repo.lamgc.moe/repository/maven-releases/") {
|
||||
credentials {
|
||||
username = project.properties["repo.credentials.private.username"].toString()
|
||||
password = project.properties["repo.credentials.private.password"].toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
maven(repoUri)
|
||||
}
|
||||
|
||||
publications {
|
||||
@ -94,3 +95,8 @@ publishing {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
signing {
|
||||
useGpgCmd()
|
||||
sign(publishing.publications["maven"])
|
||||
}
|
||||
|
@ -13,6 +13,7 @@ import java.io.File;
|
||||
* 所以将通过该接口工厂来创建扩展对象.
|
||||
*
|
||||
* @author LamGC
|
||||
* @since 0.0.1
|
||||
*/
|
||||
public interface BotExtensionFactory {
|
||||
|
||||
@ -21,11 +22,15 @@ public interface BotExtensionFactory {
|
||||
*
|
||||
* <p> 如扩展无使用 {@link org.telegram.abilitybots.api.db.DBContext} 的话,
|
||||
* 也可以返回扩展单例, 因为 AbilityBot 本身并不禁止多个机器人共用一个扩展对象
|
||||
* (因为 AbilityBot 只是调用了扩展中的方法来创建了功能对象).
|
||||
* (AbilityBot 只是调用了扩展中的方法来创建 Ability 对象).
|
||||
*
|
||||
* @param bot 机器人对象.
|
||||
* @param shareDataFolder ScalaBot App 为扩展提供的数据目录, 建议存储在数据目录中, 便于数据的存储管理.
|
||||
* @return 返回为该 Bot 对象创建的扩展对象.
|
||||
* @param shareDataFolder ScalaBot App 为扩展提供的共享数据目录.
|
||||
* <p>路径格式为:
|
||||
* <pre> $DATA_ROOT/data/extensions/{GroupId}/{ArtifactId}</pre>
|
||||
* <b>同一个扩展包的 Factory</b> 接收到的共享数据目录<b>都是一样的</b>,
|
||||
* 建议将数据存储在数据目录中, 便于数据的存储管理.
|
||||
* @return 返回为该 Bot 对象创建的扩展对象, 如果不希望为该机器人提供扩展, 可返回 {@code null}.
|
||||
*/
|
||||
AbilityExtension createExtensionInstance(BaseAbilityBot bot, File shareDataFolder);
|
||||
|
||||
|
@ -1,32 +0,0 @@
|
||||
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();
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,52 @@
|
||||
package net.lamgc.scalabot.extension.util;
|
||||
|
||||
import org.telegram.abilitybots.api.bot.BaseAbilityBot;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class AbilityBots {
|
||||
|
||||
private final static Pattern botTokenPattern = Pattern.compile("([1-9]\\d+):([A-Za-z\\d_-]{35,})");
|
||||
|
||||
private AbilityBots() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 AbilityBot 的账户 Id.
|
||||
*
|
||||
* <p> 账户 Id 来自于 botToken 中, token 的格式为 "[AccountId]:[Secret]".
|
||||
* <p> 账户 Id 的真实性与 botToken 的有效性有关, 本方法并不会确保 botToken 的有效性, 一般情况下也无需考虑 Id 的有效性,
|
||||
* 如果有需要, 可尝试通过调用 {@link org.telegram.telegrambots.meta.api.methods.GetMe} 来确保 botToken 的有效性.
|
||||
*
|
||||
* @param bot 要获取账户 Id 的 AbilityBot 对象.
|
||||
* @return 返回 AbilityBot 的账户 Id.
|
||||
* @throws IllegalArgumentException 当 AbilityBot 的 botToken 格式错误时抛出该异常.
|
||||
*/
|
||||
public static long getBotAccountId(BaseAbilityBot bot) {
|
||||
String botToken = bot.getBotToken();
|
||||
Matcher matcher = botTokenPattern.matcher(botToken);
|
||||
if (!matcher.matches()) {
|
||||
throw new IllegalArgumentException("Invalid token format.");
|
||||
}
|
||||
return Long.parseLong(matcher.group(1));
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消某一对话的状态机.
|
||||
*
|
||||
* @param bot AbilityBot 实例.
|
||||
* @param chatId 要删除状态机的聊天 Id.
|
||||
* @return 如果状态机存在, 则删除后返回 true, 不存在(未开启任何状态机, 即没有触发任何 Reply)则返回 false.
|
||||
*/
|
||||
public static boolean cancelReplyState(BaseAbilityBot bot, long chatId) {
|
||||
Map<Long, Integer> stateMap = bot.db().getMap("user_state_replies");
|
||||
if (!stateMap.containsKey(chatId)) {
|
||||
return false;
|
||||
}
|
||||
stateMap.remove(chatId);
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,121 @@
|
||||
package net.lamgc.scalabot.extension.util;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mapdb.DBMaker;
|
||||
import org.telegram.abilitybots.api.bot.AbilityBot;
|
||||
import org.telegram.abilitybots.api.bot.BaseAbilityBot;
|
||||
import org.telegram.abilitybots.api.db.MapDBContext;
|
||||
import org.telegram.abilitybots.api.objects.*;
|
||||
import org.telegram.abilitybots.api.sender.SilentSender;
|
||||
import org.telegram.telegrambots.meta.api.objects.Message;
|
||||
import org.telegram.telegrambots.meta.api.objects.Update;
|
||||
import org.telegram.telegrambots.meta.api.objects.User;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
public class AbilityBotsTest {
|
||||
|
||||
public static final User USER = new User(1L, "first", false, "last", "username", null, false, false, false);
|
||||
public static final User CREATOR = new User(1337L, "creatorFirst", false, "creatorLast", "creatorUsername", null, false, false, false);
|
||||
|
||||
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 getBotAccountIdTest() {
|
||||
String expectToken = "1234567890:AAHXcNDBRZTKfyPED5Gi3PZDIKPOM6xhxwo";
|
||||
long actual = AbilityBots.getBotAccountId(new TestingAbilityBot(expectToken, "test"));
|
||||
assertEquals(1234567890, actual);
|
||||
|
||||
String badTokenA = "12c34d56a7890:AAHXcNDBRZTKfyPED5Gi3PZDIKPOM6xhxwo";
|
||||
assertThrows(IllegalArgumentException.class, () ->
|
||||
AbilityBots.getBotAccountId(new TestingAbilityBot(badTokenA, "test")));
|
||||
|
||||
String badTokenB = "12c34d56a7890AAHXcNDBRZTKfyPED5Gi3PZDIKPOM6xhxwo";
|
||||
assertThrows(IllegalArgumentException.class, () ->
|
||||
AbilityBots.getBotAccountId(new TestingAbilityBot(badTokenB, "test")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelReplyStateTest() {
|
||||
User userA = new User(10001L, "first", false, "last", "username", null, false, false, false);
|
||||
User userB = new User(10101L, "first", false, "last", "username", null, false, false, false);
|
||||
SilentSender silent = mock(SilentSender.class);
|
||||
BaseAbilityBot bot = new TestingAbilityBot("", "", silent);
|
||||
bot.onRegister();
|
||||
bot.onUpdateReceived(mockFullUpdate(bot, userA, "/set_reply"));
|
||||
verify(silent, times(1)).send("Reply set!", userA.getId());
|
||||
bot.onUpdateReceived(mockFullUpdate(bot, userA, "reply_01"));
|
||||
verify(silent, times(1)).send("Reply 01", userA.getId());
|
||||
assertTrue(AbilityBots.cancelReplyState(bot, userA.getId()));
|
||||
bot.onUpdateReceived(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.onUpdateReceived(mockFullUpdate(bot, userA, "/set_reply"));
|
||||
verify(silent, times(1)).send("Reply set!", userA.getId());
|
||||
bot.onUpdateReceived(mockFullUpdate(bot, userA, "reply_01"));
|
||||
verify(silent, times(1)).send("Reply 01", userA.getId());
|
||||
bot.onUpdateReceived(mockFullUpdate(bot, userA, "reply_02"));
|
||||
verify(silent, times(1)).send("Reply 02", userA.getId());
|
||||
}
|
||||
|
||||
public static class TestingAbilityBot extends AbilityBot {
|
||||
|
||||
public TestingAbilityBot(String botToken, String botUsername) {
|
||||
super(botToken, botUsername, new MapDBContext(DBMaker.heapDB().make()));
|
||||
}
|
||||
|
||||
public TestingAbilityBot(String botToken, String botUsername, SilentSender silentSender) {
|
||||
super(botToken, botUsername, new MapDBContext(DBMaker.heapDB().make()));
|
||||
this.silent = silentSender;
|
||||
}
|
||||
|
||||
public Ability setReply() {
|
||||
return Ability.builder()
|
||||
.name("set_reply")
|
||||
.enableStats()
|
||||
.locality(Locality.ALL)
|
||||
.privacy(Privacy.PUBLIC)
|
||||
.action(ctx -> ctx.bot().silent().send("Reply set!", ctx.chatId()))
|
||||
.reply(ReplyFlow.builder(db())
|
||||
.action((bot, upd) -> bot.silent().send("Reply 01", upd.getMessage().getChatId()))
|
||||
.onlyIf(upd -> upd.hasMessage() && upd.getMessage().getText().equals("reply_01"))
|
||||
.next(Reply.of((bot, upd) ->
|
||||
bot.silent().send("Reply 02", upd.getMessage().getChatId()),
|
||||
upd -> upd.hasMessage() && upd.getMessage().getText().equals("reply_02")))
|
||||
.build()
|
||||
)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public long creatorId() {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
Reference in New Issue
Block a user