43 Commits

Author SHA1 Message Date
e12f858690 release: 发布 0.2.0 版本. 2022-04-20 17:37:03 +08:00
3e99d7d033 fix(example): 修复 Reply 判断条件不充分的问题.
由于条件判断不充分, 导致运行时可能会出现 NPE 的问题.
2022-04-20 16:35:02 +08:00
8131f41313 feat(extension): 初步添加一些工具方法.
添加 AbilityBots 类, 向开发者提供一些"工具"方法, 该方法将有助于插件的功能开发.
2022-04-19 00:17:57 +08:00
270e744bcf build: 更新并增加 Kotlin 相关依赖项.
由于不明确的 Kotlin 运行时机制, 需直接向框架本身添加 Kotlin 标准库依赖, 以解决潜在的 Kotlin 反射支持问题.
不可能因运行时问题就继续向框架添加不相关的依赖, 所以该问题将继续调查.
2022-04-19 00:08:24 +08:00
d84465ebd9 build: 更新 TelegramBots 依赖项.
更新该依赖项以支持新版本的 Telegram API.
请注意: TelegramBots 已将弃用类移除.
2022-04-18 23:56:30 +08:00
18bc3a8d48 refactor(logging): 补充两个日志屏蔽.
屏蔽两个日志量较大的类, 日志量过多会影响 Debug.
2022-04-18 23:54:29 +08:00
2b88134207 refactor(extension): 移除 ScalaBotExtension 类.
该类的作用不大, 实现的细节可以由开发者自行实现(或许会设计地更好), 故移除该类.

DEPRECATED: 移除 ScalaBotExtension 类

由于该抽象类设计过于简单, 且并未达到其存在的预期目的(简化开发扩展的复杂性), 故移除本类.
开发者应将扩展类由继承 ScalaBotExtension 更改为实现 AbilityExtension 类, `getSender(): MessageSender` 和 `getDBContext():DBContext` 方法以及 `bot`(BaseAbilityBot) 字段应手动添加, 并根据需要通过构造方法获取并存储.
2022-04-15 00:18:59 +08:00
142eddfa28 refactor(extension): Maven 本地仓库路径将会相对于 DATA_ROOT 路径.
为防止混淆配置意义, 方便数据转移, 将 Maven 本地仓库路径调整为相对 DATA_ROOT 目录, 绝对路径不受影响.
2022-04-14 22:38:51 +08:00
64849adfab fix(config): 修复潜在的未初始化异常.
原设计中, PathConst 类会晚于 AppPaths 类中各枚举值的初始化, 进而导致 DATA_ROOT 获取 PathConst 中字段时出现未初始化异常的情况.
由于 AppPaths 设计为在运行时生成并获取路径, 所以该文件不会对 AppPaths 造成太大影响, 但 Kotlin 已决定在未来版本明确该问题为错误(Error), 所以将 PathConst 由伴生对象修改为单例对象以避免该问题.
2022-04-14 21:46:01 +08:00
29bd12a8dd feat(bot): 增加 accountId 属性.
增加该属性有利于其他组件对机器人账户进行标记.
2022-04-10 16:28:20 +08:00
9cdf10ccc2 refactor(launch): 增强关闭阶段的鲁棒性.
增加异常捕获, 防止由于部分 bot 发生异常而无法关闭其余机器人.
2022-04-10 16:27:20 +08:00
4210efef3b refactor(config): 改进了数据目录的获取方式.
补充了通过 user.dir 获取目录路径的方式.
2022-04-10 16:26:13 +08:00
bc0f3be32c docs(extension): 补充参数说明.
补充 shareDataFolder 参数说明, 便于扩展开发者根据需要调整数据存取.
2022-03-29 10:06:25 +08:00
c5f28e395e refactor(util): 优化代码.
使用 Kotlin 语法糖优化不必要的代码.
2022-03-28 23:43:33 +08:00
1172caa8d7 build(extension): 更新 TelegramBots 依赖项版本.
将依赖版本更新, 无兼容性问题.
2022-03-28 23:05:10 +08:00
eb95436404 fix(extension): 修复创建扩展对象时可能会出现的 NPE 问题.
创建对象后没有检查 NPE 问题, 修复该问题后有助于接受 Factory 不提供扩展的情况(现在这个情况是允许的了).
2022-03-28 21:38:40 +08:00
804d0e3012 build(extension): 为编译指定 Java 目标版本.
指定 Java 目标版本, 以免因环境错误编译成其他的 Java 字节码版本.
2022-03-25 18:15:00 +08:00
1281dbcabe feat(config): 支持指定本地仓库的路径.
可指定本地仓库的路径, 用于代替用户目录下的默认 Maven 本地仓库, 这么做可以在任意位置设置共享本地仓库.
2022-03-21 23:47:59 +08:00
1cd26b3b25 build: 更新依赖项版本. 2022-03-21 22:34:30 +08:00
19162dcaef feat(database): 更改数据库命名方式.
由于 BotToken 可以更换, 所以旧版命名方式将会有迁移的问题, 故更改为通过 Bot AccountId 来命名数据库.

CLOSED #3
2022-03-20 11:56:38 +08:00
0748afaff5 refactor(config): 为 BotAccount 添加 id 字段.
根据 Telegram Bot token 的组成结构, 可以取出 Bot Account Id, 故添加 id 字段.
2022-03-20 11:37:24 +08:00
b20b25bc7b docs(extension): 补充说明 shareDataFolder.
原来的说明容易产生误解, 让扩展开发者以为一个扩展一个数据目录, 所以在这块上补充了一些说明.
2022-03-20 11:04:07 +08:00
9c2ca5103c release: 发布 0.1.0 版本. 2022-03-15 15:30:35 +08:00
05af90a5a0 docs: 补充开发版本说明.
补充说明,要求用户升级前仔细阅读更新日志, 避免出现问题.
2022-03-15 15:30:07 +08:00
4b20b0cc59 feat(bot): 新增命令列表更新功能.
增加`命令列表更新`功能, 该功能可根据已加载的 Ability, 调用 Telegram Bot API 更新命令列表.
后续会尝试支持 BotCommandScope.
2022-03-15 15:18:04 +08:00
de83d2c3d3 refactor(example): say_hello 命令添加用户的账号信息.
补充账号信息, 方便查(为了测试).

P.S. 其实这个 Commit 应该算作 feat, 但这个是示例扩展, 所以就改成了 refactor.
2022-03-15 15:15:34 +08:00
22a824377a refactor(bot): 简化 ScalaBot 构造器的参数.
改动前的构造器参数大多是直接从 BotConfig 中传递进去, 这么做不利于添加新的参数, 因此改动后, BotConfig 将直接传递到 ScalaBot 的构造器中, 由 ScaleBot 内部按需获取参数进行初始化.
2022-03-15 14:13:53 +08:00
a0344f251f fix: 修复因某一个机器人启动失败而导致整个程序崩溃.
改动之后, 将容忍机器人的启动失败, 而不会因此异常退出程序.
2022-03-10 13:33:57 +08:00
84b67c7f89 refactor(bot): 补充一些日志信息.
补充启动过程的日志信息, 防止用户以为启动卡死 (因为在通过 Maven 仓库加载扩展包的时候会比较慢).
2022-03-10 13:31:37 +08:00
851fb0251f docs: 添加 Wiki 内容. 2022-03-03 21:25:17 +08:00
1776f07f16 refactor(extension): 修改 resolveRepositoryByUrl 中对构件的校验和检查策略.
将 Release 与 Snapshot 的 Checksum 策略设为 Fail, 可防止意外的包修改问题.
2022-02-28 08:38:21 +08:00
1f48bbae8e build(vcs): 补充对日志归档包的排除项.
补充日志归档包(.log.gz)的排除项, 排除了测试过程中产生的日志归档包.
2022-02-28 08:34:07 +08:00
00e90eabd0 fix(extension): 移除默认的 MavenRepositoryExtensionFinder.
将 MavenRepositoryExtensionFinder 的初始化过程交给 Launcher 执行.
2022-02-28 08:26:26 +08:00
285c8b04d1 fix: 修复因 config.json 不存在而引发的启动异常.
当 config.json 不存在时, 当 Launcher 类被加载时, 将导致因 config.json 加载失败而出现类初始化异常, 通过调整访问时机, 修复了这个问题.
2022-02-26 17:36:23 +08:00
a642948f45 feat: 可通过配置文件设置用于查找扩展包的 Maven 仓库.
使用 Maven 扩展包搜素器将不再限制仓库, 可通过配置文件添加其他仓库.
2022-02-26 17:30:31 +08:00
6df9f1b3c7 refactor(bot): 按照 Kotlin 官方代码规范, 修正代码格式错误.
Kotlin 官方代码规范建议把 Companion object 放在类的最后面, 确实应该如此.
2022-02-26 17:26:24 +08:00
4bc3776717 refactor(config): 更改 BotConfig 的 proxy 属性默认值.
为了让用户了解到可对机器人配置单独的代理, 而将 proxy 的默认值改为 ProxyConfig 缺省值.
2022-02-26 16:22:16 +08:00
eb34f17b06 refactor(config): 调整 BotConfig 中部分字段的默认值.
内置命令默认打开, 方便用户查询命令. baseApiUrl 默认值为 Telegram 官方 API 地址, 设置默认值可让用户了解可以对此项进行修改.
2022-02-26 16:18:32 +08:00
54b3e1cad7 fix(bot): 修复 disableBuiltInAbility 逻辑错误的问题.
设置反了, 所以 disableBuiltInAbility == true 就会打开内置命令.
2022-02-26 16:11:27 +08:00
35d092a22d refactor: 根据 Kotlin 规范, 调整类访问权.
按照 Kotlin 编码规范, 将序列化类调整为`内部`可预防意外的外部使用.
2022-02-26 15:46:08 +08:00
90434d9dbf perf(extension): 修正 FoundedPackage 统计方法.
该问题源于旧版本时方法的用途, 原本该方法用于合并所有已找到的 FoundedPackage, 但新版本已经被其他方法替代, 所以也没必要再通过合并来统计数量.
2022-02-26 15:37:50 +08:00
b3c63e5abe feat(config): 支持通过环境变量或 VM 参数指定数据目录.
主要是为 Docker 镜像支持, 这样可以在镜像中指定数据目录, 方便设置持久卷或者文件映射.
2022-02-26 12:00:16 +08:00
737ac9a08a fix: 修复了注释中的一个错字 :P
纠正了语句错误而已.
2022-02-24 02:23:53 +08:00
21 changed files with 842 additions and 171 deletions

1
.gitignore vendored
View File

@ -3,6 +3,7 @@
# Log file
*.log
*.log.gz
# BlueJ files
*.ctxt

View File

@ -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 账号将会对消息有所反应。
## 开发扩展包

View File

@ -7,5 +7,5 @@ allprojects {
}
group = "net.lamgc"
version = "0.0.1"
version = "0.2.0"
}

View File

@ -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,13 +22,14 @@ 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")

View File

@ -1,15 +1,22 @@
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.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
@ -25,7 +32,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 +51,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 +89,105 @@ 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 url: URL,
val proxy: Proxy? = Proxy("http", "127.0.0.1", 1080),
val layout: String = "default",
// 可能要设计个 type 来判断解析成什么类型的 Authentication.
val authentication: Authentication? = null
) {
fun toRemoteRepository(): RemoteRepository {
val builder = RemoteRepository.Builder(null, checkRepositoryLayout(layout), url.toString())
if (proxy != null) {
builder.setProxy(proxy)
} else if (Const.config.proxy.type == DefaultBotOptions.ProxyType.HTTP) {
builder.setProxy(Const.config.proxy.toAetherProxy())
}
return builder.build()
}
private companion object {
fun checkRepositoryLayout(layoutType: String): String {
val type = layoutType.trim().lowercase()
if (type != "default" && type != "legacy") {
throw IllegalArgumentException("Invalid layout type (expecting 'default' or 'legacy')")
}
return type
}
}
}
/**
* 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(
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 +206,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 +223,7 @@ internal enum class AppPaths(
@Synchronized
fun initial() {
if (!initialized.get()) {
initializer(this)
initializer()
initialized.set(true)
}
}
@ -146,6 +231,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,14 +253,16 @@ 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" }
}
}
}
@ -179,6 +281,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()

View File

@ -4,6 +4,7 @@ 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
@ -17,6 +18,7 @@ private val launcher = Launcher()
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) {
@ -52,6 +54,16 @@ internal class Launcher : AutoCloseable {
private val botApi = TelegramBotsApi(DefaultBotSession::class.java)
private val botSessionMap = mutableMapOf<ScalaBot, BotSession>()
private val mavenLocalRepository =
if (Const.config.mavenLocalRepository != null && Const.config.mavenLocalRepository.isNotEmpty()) {
val repoPath = AppPaths.DATA_ROOT.file.toPath()
.resolve(Const.config.mavenLocalRepository)
.toRealPath()
.toFile()
LocalRepository(repoPath)
} else {
LocalRepository("${System.getProperty("user.home")}/.m2/repository")
}
@Synchronized
fun launch(): Boolean {
@ -61,7 +73,11 @@ internal class Launcher : AutoCloseable {
return false
}
for (botConfig in botConfigs) {
launchBot(botConfig)
try {
launchBot(botConfig)
} catch (e: Exception) {
log.error(e) { "机器人 `${botConfig.account.name}` 启动时发生错误." }
}
}
return true
}
@ -93,25 +109,57 @@ internal class Launcher : AutoCloseable {
}
}
val account = botConfig.account
val remoteRepositories = Const.config.mavenRepositories
.map(MavenRepositoryConfig::toRemoteRepository)
.toMutableList().apply {
add(MavenRepositoryExtensionFinder.getMavenCentralRepository(proxy = Const.config.proxy.toAetherProxy()))
}.toList()
val extensionPackageFinders = setOf(
MavenRepositoryExtensionFinder(
localRepository = mavenLocalRepository,
remoteRepositories = remoteRepositories,
proxy = Const.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 {
log.info { "正在关闭机器人 `${it.key.botUsername}` ..." }
it.value.stop()
log.info { "已关闭机器人 `${it.key.botUsername}`." }
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}` 关闭时发生异常." }
}
}
}

View File

@ -1,23 +1,190 @@
package net.lamgc.scalabot
import com.google.common.io.Files
import mu.KotlinLogging
import net.lamgc.scalabot.util.toHaxString
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.toHaxString()}.db")
})

View File

@ -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(

View File

@ -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 {
@ -354,14 +354,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) {

View File

@ -7,32 +7,116 @@ 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)
}
val setMyCommands = SetMyCommands()
setMyCommands.commands = botCommands
return execute(DeleteMyCommands()) && execute(setMyCommands)
}
override fun onRegister() {
super.onRegister()
onlineBotGauge.inc()
}
override fun onClosing() {
super.onClosing()
onlineBotGauge.dec()
}
companion object {
@JvmStatic
@ -83,45 +167,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()
}
}

View File

@ -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,105 @@ 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(
url = URL(checkJsonKey(json, "url")),
proxy = if (json.has("proxy") && json.get("proxy").isJsonObject)
context.deserialize<Proxy>(
json.getAsJsonObject("proxy"), Proxy::class.java
) else null,
layout = json.get("layout").asString ?: "default",
authentication = if (json.has("authentication") && json.get("authentication").isJsonObject)
context.deserialize<Authentication>(
json.getAsJsonObject("authentication"), Authentication::class.java
) else null
)
}
is JsonPrimitive -> {
MavenRepositoryConfig(URL(json.asString))
}
else -> {
throw JsonParseException("Unsupported Maven warehouse configuration type.")
}
}
}
}

View File

@ -34,11 +34,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,10 +44,8 @@ internal fun File.deepListFiles(
if (!onlyFile) {
result.add(file)
}
val subFiles = file.deepListFiles(false, onlyFile, fileFilter, filenameFilter)
if (subFiles != null) {
result.addAll(subFiles)
}
val subFiles = file.deepListFiles(false, onlyFile, fileFilter, filenameFilter) ?: continue
result.addAll(subFiles)
}
}
return result.toTypedArray()

View 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>

View File

@ -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"/>

View File

@ -0,0 +1,31 @@
package net.lamgc.scalabot
import com.google.gson.Gson
import java.util.*
import kotlin.math.abs
import kotlin.test.Test
import kotlin.test.assertEquals
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)
}
}

View File

@ -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()

View File

@ -7,11 +7,12 @@ plugins {
}
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 +24,8 @@ tasks.withType<Javadoc> {
java {
withJavadocJar()
withSourcesJar()
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
tasks.test {

View File

@ -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);

View File

@ -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();
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}
}