mirror of
https://github.com/LamGC/ScalaBot.git
synced 2025-07-01 21:07:23 +00:00
initial: 基本完成的首个版本, 还需要调整一下.
暂时按照当初的计划实现了一个可用版本出来, 发布与否晚些再确定.
This commit is contained in:
@ -0,0 +1,16 @@
|
||||
package net.lamgc.scalabot.util;
|
||||
|
||||
final class ByteUtils {
|
||||
|
||||
private ByteUtils() {
|
||||
}
|
||||
|
||||
public static String bytesToHexString(byte[] bytes) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (byte aByte : bytes) {
|
||||
builder.append(Integer.toHexString(aByte));
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,26 @@
|
||||
package net.lamgc.scalabot.util;
|
||||
|
||||
import ch.qos.logback.classic.Level;
|
||||
import ch.qos.logback.classic.spi.LoggingEvent;
|
||||
import ch.qos.logback.core.filter.AbstractMatcherFilter;
|
||||
import ch.qos.logback.core.spi.FilterReply;
|
||||
|
||||
/**
|
||||
* 标准输出过滤器.
|
||||
*
|
||||
* <p> LogBack 在性能上虽然很好,但是自带的 ThresholdFilter 竟然不支持过滤 WARN 以下等级的日志!
|
||||
* 重点是,加两个参数就可以实现这个过滤功能了(加一个 onMatch 和 onMismatch 就可以了)!
|
||||
* 绝了。
|
||||
*
|
||||
* @author LamGC
|
||||
*/
|
||||
public class StdOutFilter extends AbstractMatcherFilter<LoggingEvent> {
|
||||
|
||||
private final static int maxLevel = Level.INFO_INT;
|
||||
|
||||
@Override
|
||||
public FilterReply decide(LoggingEvent event) {
|
||||
int levelInt = event.getLevel().levelInt;
|
||||
return levelInt <= maxLevel ? FilterReply.ACCEPT : FilterReply.DENY;
|
||||
}
|
||||
}
|
178
scalabot-app/src/main/kotlin/AppConfigs.kt
Normal file
178
scalabot-app/src/main/kotlin/AppConfigs.kt
Normal file
@ -0,0 +1,178 @@
|
||||
package net.lamgc.scalabot
|
||||
|
||||
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.ProxyTypeSerializer
|
||||
import org.eclipse.aether.artifact.Artifact
|
||||
import org.telegram.telegrambots.bots.DefaultBotOptions
|
||||
import java.io.File
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
private val log = KotlinLogging.logger { }
|
||||
|
||||
/**
|
||||
* 机器人帐号信息.
|
||||
* @property name 机器人名称, 建议与实际设定的名称相同.
|
||||
* @property token 机器人 API Token.
|
||||
* @property creatorId 机器人创建者, 管理机器人需要使用该信息.
|
||||
*/
|
||||
internal data class BotAccount(
|
||||
val name: String,
|
||||
val token: String,
|
||||
val creatorId: Long = -1
|
||||
)
|
||||
|
||||
/**
|
||||
* 机器人配置.
|
||||
* @property account 机器人帐号信息, 用于访问 API.
|
||||
* @property disableBuiltInAbility 是否禁用 AbilityBot 自带命令.
|
||||
* @property extensions 该机器人启用的扩展.
|
||||
* @property proxy 为该机器人单独设置的代理配置, 如无设置, 则使用 AppConfig 中的代理配置.
|
||||
*/
|
||||
internal data class BotConfig(
|
||||
val enabled: Boolean = true,
|
||||
val account: BotAccount,
|
||||
val disableBuiltInAbility: Boolean = true,
|
||||
/*
|
||||
* 使用构件坐标来选择机器人所使用的扩展包.
|
||||
* 这么做的原因是我暂时没找到一个合适的方法来让开发者方便地设定自己的扩展 Id,
|
||||
* 而构件坐标(POM Reference 或者叫 GAV 坐标)是开发者创建 Maven/Gradle 项目所一定会设置的,
|
||||
* 所以就直接用了. :P
|
||||
*/
|
||||
val extensions: Set<Artifact>,
|
||||
val proxy: ProxyConfig? = null,
|
||||
val baseApiUrl: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 代理配置.
|
||||
* @property type 代理类型.
|
||||
* @property host 代理服务端地址.
|
||||
* @property port 代理服务端端口.
|
||||
*/
|
||||
internal data class ProxyConfig(
|
||||
val type: DefaultBotOptions.ProxyType = DefaultBotOptions.ProxyType.NO_PROXY,
|
||||
val host: String = "127.0.0.1",
|
||||
val port: Int = 1080
|
||||
)
|
||||
|
||||
/**
|
||||
* ScalaBot App 配置.
|
||||
*
|
||||
* App 配置信息与 BotConfig 分开, 分别存储在各自单独的文件中.
|
||||
* @property proxy Telegram API 代理配置.
|
||||
*/
|
||||
internal data class AppConfig(
|
||||
val proxy: ProxyConfig = ProxyConfig(),
|
||||
)
|
||||
|
||||
/**
|
||||
* 需要用到的路径.
|
||||
*/
|
||||
internal enum class AppPaths(
|
||||
private val pathSupplier: () -> String,
|
||||
private val initializer: AppPaths.() -> Unit = AppPaths::defaultInitializer
|
||||
) {
|
||||
DEFAULT_CONFIG_APPLICATION({ "./config.json" }, {
|
||||
if (!file.exists()) {
|
||||
file.bufferedWriter(StandardCharsets.UTF_8).use {
|
||||
GsonConst.botConfigGson.toJson(AppConfig(), it)
|
||||
}
|
||||
}
|
||||
}),
|
||||
DEFAULT_CONFIG_BOT({ "./bot.json" }, {
|
||||
if (!file.exists()) {
|
||||
file.bufferedWriter(StandardCharsets.UTF_8).use {
|
||||
GsonConst.botConfigGson.toJson(
|
||||
setOf(
|
||||
BotConfig(
|
||||
enabled = false,
|
||||
proxy = ProxyConfig(),
|
||||
account = BotAccount(
|
||||
"Bot Username",
|
||||
"Bot API Token",
|
||||
-1
|
||||
), extensions = emptySet()
|
||||
)
|
||||
), it
|
||||
)
|
||||
}
|
||||
}
|
||||
}),
|
||||
DATA_DB({ "./data/db/" }),
|
||||
DATA_LOGS({ "./data/logs/" }),
|
||||
EXTENSIONS({ "./extensions/" }),
|
||||
DATA_EXTENSIONS({ "./data/extensions/" }),
|
||||
TEMP({ "./tmp/" })
|
||||
;
|
||||
|
||||
val file: File
|
||||
get() = File(pathSupplier.invoke())
|
||||
val path: String
|
||||
get() = pathSupplier.invoke()
|
||||
|
||||
private val initialized = AtomicBoolean(false)
|
||||
|
||||
@Synchronized
|
||||
fun initial() {
|
||||
if (!initialized.get()) {
|
||||
initializer(this)
|
||||
initialized.set(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal object Const {
|
||||
val config = loadAppConfig()
|
||||
}
|
||||
|
||||
private fun AppPaths.defaultInitializer() {
|
||||
if (!file.exists()) {
|
||||
val result = if (path.endsWith("/")) {
|
||||
file.mkdirs()
|
||||
} else {
|
||||
file.createNewFile()
|
||||
}
|
||||
if (!result) {
|
||||
log.warn { "初始化文件(夹)失败: $path" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun initialFiles() {
|
||||
for (path in AppPaths.values()) {
|
||||
path.initial()
|
||||
}
|
||||
}
|
||||
|
||||
private object GsonConst {
|
||||
val baseGson: Gson = GsonBuilder()
|
||||
.setPrettyPrinting()
|
||||
.serializeNulls()
|
||||
.create()
|
||||
|
||||
val appConfigGson: Gson = baseGson.newBuilder()
|
||||
.registerTypeAdapter(DefaultBotOptions.ProxyType::class.java, ProxyTypeSerializer)
|
||||
.create()
|
||||
|
||||
val botConfigGson: Gson = baseGson.newBuilder()
|
||||
.registerTypeAdapter(DefaultBotOptions.ProxyType::class.java, ProxyTypeSerializer)
|
||||
.registerTypeAdapter(Artifact::class.java, ArtifactSerializer)
|
||||
.create()
|
||||
}
|
||||
|
||||
internal fun loadAppConfig(configFile: File = AppPaths.DEFAULT_CONFIG_APPLICATION.file): AppConfig {
|
||||
configFile.bufferedReader(StandardCharsets.UTF_8).use {
|
||||
return GsonConst.appConfigGson.fromJson(it, AppConfig::class.java)!!
|
||||
}
|
||||
}
|
||||
|
||||
internal fun loadBotConfig(botConfigFile: File = AppPaths.DEFAULT_CONFIG_BOT.file): Set<BotConfig> {
|
||||
botConfigFile.bufferedReader(StandardCharsets.UTF_8).use {
|
||||
return GsonConst.botConfigGson.fromJson(it, object : TypeToken<Set<BotConfig>>() {}.type)!!
|
||||
}
|
||||
}
|
88
scalabot-app/src/main/kotlin/AppMain.kt
Normal file
88
scalabot-app/src/main/kotlin/AppMain.kt
Normal file
@ -0,0 +1,88 @@
|
||||
package net.lamgc.scalabot
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import mu.KotlinLogging
|
||||
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 kotlin.system.exitProcess
|
||||
|
||||
private val log = KotlinLogging.logger { }
|
||||
|
||||
private val launcher = Launcher()
|
||||
|
||||
fun main(args: Array<String>): Unit = runBlocking {
|
||||
log.info { "ScalaBot 正在启动中..." }
|
||||
log.debug { "启动参数: ${args.joinToString(prefix = "[", postfix = "]")}" }
|
||||
initialFiles()
|
||||
if (!launcher.launch()) {
|
||||
exitProcess(1)
|
||||
}
|
||||
}
|
||||
|
||||
internal class Launcher {
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
private val log = KotlinLogging.logger { }
|
||||
}
|
||||
|
||||
private val botApi = TelegramBotsApi(DefaultBotSession::class.java)
|
||||
private val botSessionMap = mutableMapOf<ScalaBot, BotSession>()
|
||||
|
||||
fun launch(): Boolean {
|
||||
val botConfigs = loadBotConfig()
|
||||
if (botConfigs.isEmpty()) {
|
||||
log.warn { "尚未配置任何机器人, 请先配置机器人后再启动本程序." }
|
||||
return false
|
||||
}
|
||||
for (botConfig in botConfigs) {
|
||||
launchBot(botConfig)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun launchBot(botConfig: BotConfig) {
|
||||
if (!botConfig.enabled) {
|
||||
log.debug { "机器人 `${botConfig.account.name}` 已禁用, 跳过启动." }
|
||||
return
|
||||
}
|
||||
log.info { "正在启动机器人 `${botConfig.account.name}`..." }
|
||||
val botOption = DefaultBotOptions().apply {
|
||||
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 {
|
||||
null
|
||||
}
|
||||
if (proxyConfig != null) {
|
||||
proxyType = proxyConfig.type
|
||||
proxyHost = Const.config.proxy.host
|
||||
proxyPort = Const.config.proxy.port
|
||||
log.debug { "机器人 `${botConfig.account.name}` 已启用代理配置: $proxyConfig" }
|
||||
}
|
||||
|
||||
if (botConfig.baseApiUrl != null) {
|
||||
baseUrl = botConfig.baseApiUrl
|
||||
}
|
||||
}
|
||||
val account = botConfig.account
|
||||
val bot = ScalaBot(
|
||||
account.name,
|
||||
account.token,
|
||||
account.creatorId,
|
||||
BotDBMaker.getBotMaker(account),
|
||||
botOption,
|
||||
botConfig.extensions,
|
||||
botConfig.disableBuiltInAbility
|
||||
)
|
||||
botSessionMap[bot] = botApi.registerBot(bot)
|
||||
log.info { "机器人 `${bot.botUsername}` 已启动." }
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
25
scalabot-app/src/main/kotlin/BotDBMaker.kt
Normal file
25
scalabot-app/src/main/kotlin/BotDBMaker.kt
Normal file
@ -0,0 +1,25 @@
|
||||
package net.lamgc.scalabot
|
||||
|
||||
import net.lamgc.scalabot.util.toHaxString
|
||||
import org.mapdb.DBMaker
|
||||
import org.telegram.abilitybots.api.db.DBContext
|
||||
import org.telegram.abilitybots.api.db.MapDBContext
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.security.MessageDigest
|
||||
|
||||
internal object BotDBMaker {
|
||||
|
||||
private val digest: MessageDigest = MessageDigest.getInstance("SHA-256")
|
||||
|
||||
fun getBotMaker(botAccount: BotAccount): DBContext {
|
||||
val digestBytes = digest.digest(botAccount.token.toByteArray(StandardCharsets.UTF_8))
|
||||
val dbPath = AppPaths.DATA_DB.path + "${digestBytes.toHaxString()}.db"
|
||||
val db = DBMaker.fileDB(dbPath)
|
||||
.closeOnJvmShutdownWeakReference()
|
||||
.checksumStoreEnable()
|
||||
.fileChannelEnable()
|
||||
.make()
|
||||
return MapDBContext(db)
|
||||
}
|
||||
|
||||
}
|
367
scalabot-app/src/main/kotlin/Extension.kt
Normal file
367
scalabot-app/src/main/kotlin/Extension.kt
Normal file
@ -0,0 +1,367 @@
|
||||
package net.lamgc.scalabot
|
||||
|
||||
import mu.KotlinLogging
|
||||
import net.lamgc.scalabot.extension.BotExtensionFactory
|
||||
import net.lamgc.scalabot.util.deepListFiles
|
||||
import net.lamgc.scalabot.util.equalsArtifact
|
||||
import org.eclipse.aether.artifact.Artifact
|
||||
import org.eclipse.aether.artifact.DefaultArtifact
|
||||
import org.jdom2.Document
|
||||
import org.jdom2.filter.Filters
|
||||
import org.jdom2.input.SAXBuilder
|
||||
import org.jdom2.xpath.XPathFactory
|
||||
import org.telegram.abilitybots.api.util.AbilityExtension
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.net.URL
|
||||
import java.net.URLClassLoader
|
||||
import java.util.*
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.jar.JarEntry
|
||||
import java.util.jar.JarInputStream
|
||||
|
||||
internal class ExtensionLoader(private val bot: ScalaBot) {
|
||||
|
||||
private val log = KotlinLogging.logger { }
|
||||
|
||||
private val finders: Set<ExtensionPackageFinder> = setOf(
|
||||
FileNameFinder,
|
||||
MavenMetaInformationFinder
|
||||
)
|
||||
|
||||
fun getExtensions(): Set<ExtensionEntry> {
|
||||
val extensionEntries = mutableSetOf<ExtensionEntry>()
|
||||
for (extensionArtifact in bot.extensions) {
|
||||
val extensionFilesMap = findExtensionPackageFile(extensionArtifact)
|
||||
val extensionFiles = filesMapToSet(extensionFilesMap)
|
||||
if (extensionFiles.size > 1) {
|
||||
printExtensionFileConflictError(extensionArtifact, extensionFilesMap)
|
||||
continue
|
||||
} else if (extensionFiles.isEmpty()) {
|
||||
log.warn { "[Bot ${bot.botUsername}] 找不到符合的扩展包文件: $extensionArtifact" }
|
||||
continue
|
||||
}
|
||||
extensionEntries.addAll(getExtensionFactories(extensionArtifact, extensionFiles.first()))
|
||||
}
|
||||
return extensionEntries.toSet()
|
||||
}
|
||||
|
||||
private fun getExtensionFactories(extensionArtifact: Artifact, extensionFile: File): Set<ExtensionEntry> {
|
||||
val extClassLoader =
|
||||
ExtensionClassLoaderCleaner.getOrCreateExtensionClassLoader(extensionArtifact, extensionFile)
|
||||
val factories = mutableSetOf<ExtensionEntry>()
|
||||
for (factory in extClassLoader.serviceLoader) {
|
||||
val extension =
|
||||
factory.createExtensionInstance(bot, getExtensionDataFolder(extensionArtifact))
|
||||
factories.add(ExtensionEntry(extensionArtifact, factory::class.java, extension))
|
||||
}
|
||||
return factories.toSet()
|
||||
}
|
||||
|
||||
private fun filesMapToSet(filesMap: Map<ExtensionPackageFinder, Set<File>>): MutableSet<File> {
|
||||
val result: MutableSet<File> = mutableSetOf()
|
||||
for (files in filesMap.values) {
|
||||
result.addAll(files)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun findExtensionPackageFile(
|
||||
extensionArtifact: Artifact,
|
||||
extensionsPath: File = AppPaths.EXTENSIONS.file
|
||||
): Map<ExtensionPackageFinder, Set<File>> {
|
||||
val result = mutableMapOf<ExtensionPackageFinder, Set<File>>()
|
||||
for (finder in finders) {
|
||||
val artifacts = finder.findByArtifact(extensionArtifact, extensionsPath)
|
||||
if (artifacts.isNotEmpty()) {
|
||||
result[finder] = artifacts
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun printExtensionFileConflictError(
|
||||
extensionArtifact: Artifact,
|
||||
foundResult: Map<ExtensionPackageFinder, Set<File>>
|
||||
) {
|
||||
val errMessage = StringBuilder(
|
||||
"""
|
||||
[Bot ${bot.botUsername}] 扩展包 $extensionArtifact 存在多个文件, 为防止安全问题, 已禁止加载该扩展包:
|
||||
""".trimIndent()
|
||||
).append('\n')
|
||||
foundResult.forEach { (finder, files) ->
|
||||
errMessage.append("\t- 搜索器 `").append(finder::class.simpleName).append("` 找到了以下扩展包: \n")
|
||||
for (file in files) {
|
||||
errMessage.append("\t\t* ").append(file.canonicalPath).append('\n')
|
||||
}
|
||||
}
|
||||
log.error { errMessage }
|
||||
}
|
||||
|
||||
private fun getExtensionDataFolder(extensionArtifact: Artifact): File {
|
||||
val dataFolder =
|
||||
File(AppPaths.DATA_EXTENSIONS.file, "${extensionArtifact.groupId}/${extensionArtifact.artifactId}")
|
||||
if (!dataFolder.exists()) {
|
||||
dataFolder.mkdirs()
|
||||
}
|
||||
return dataFolder
|
||||
}
|
||||
|
||||
|
||||
data class ExtensionEntry(
|
||||
val extensionArtifact: Artifact,
|
||||
val factoryClass: Class<out BotExtensionFactory>,
|
||||
val extension: AbilityExtension
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 该类为保留措施, 尚未启用.
|
||||
*/
|
||||
internal object ExtensionClassLoaderCleaner {
|
||||
|
||||
private val artifactMap = mutableMapOf<Artifact, ExtensionClassLoader>()
|
||||
private val usageCountMap = mutableMapOf<ExtensionClassLoader, AtomicInteger>()
|
||||
|
||||
@Synchronized
|
||||
fun getOrCreateExtensionClassLoader(extensionArtifact: Artifact, extensionFile: File): ExtensionClassLoader {
|
||||
return if (!artifactMap.containsKey(extensionArtifact)) {
|
||||
val newClassLoader = ExtensionClassLoader(extensionFile)
|
||||
artifactMap[extensionArtifact] = newClassLoader
|
||||
usageCountMap[newClassLoader] = AtomicInteger(1)
|
||||
newClassLoader
|
||||
} else {
|
||||
artifactMap[extensionArtifact]!!
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun releaseExtensionClassLoader(extensionArtifacts: Set<Artifact>) {
|
||||
for (extensionArtifact in extensionArtifacts) {
|
||||
if (!artifactMap.containsKey(extensionArtifact)) {
|
||||
throw IllegalStateException("No corresponding classloader exists.")
|
||||
}
|
||||
|
||||
val classLoader = artifactMap[extensionArtifact]!!
|
||||
val usageCounter = usageCountMap[classLoader]!!
|
||||
if (usageCounter.decrementAndGet() == 0) {
|
||||
cleanExtensionClassLoader(extensionArtifact)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun cleanExtensionClassLoader(extensionArtifact: Artifact) {
|
||||
if (!artifactMap.containsKey(extensionArtifact)) {
|
||||
return
|
||||
}
|
||||
val classLoader = artifactMap.remove(extensionArtifact)!!
|
||||
try {
|
||||
classLoader.close()
|
||||
} finally {
|
||||
usageCountMap.remove(classLoader)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 扩展包搜索器.
|
||||
*
|
||||
* 通过实现该接口, 可添加一种搜索扩展包的方式, 无论其来源.
|
||||
*/
|
||||
internal interface ExtensionPackageFinder {
|
||||
/**
|
||||
* 在指定目录中搜索指定构件坐标的扩展包文件(夹).
|
||||
* @param extensionArtifact 欲查找的扩展包构件坐标.
|
||||
* @param extensionsPath 建议的搜索路径, 如搜索器希望通过网络来获取也可以.
|
||||
* @return 返回按搜索器的方式可以找到的所有与构件坐标有关的扩展包路径.
|
||||
*/
|
||||
fun findByArtifact(extensionArtifact: Artifact, extensionsPath: File): Set<File>
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于文件名的搜索器.
|
||||
*
|
||||
* 将搜索文件名(不带扩展包名)结尾为 `${groupId}-${artifactId}-${version}` 的文件.
|
||||
*/
|
||||
internal object FileNameFinder : ExtensionPackageFinder {
|
||||
|
||||
override fun findByArtifact(extensionArtifact: Artifact, extensionsPath: File): Set<File> {
|
||||
val focusName = getExtensionFilename(extensionArtifact)
|
||||
val files = extensionsPath.listFiles { file ->
|
||||
file.nameWithoutExtension.endsWith(focusName)
|
||||
}
|
||||
return files?.toSet() ?: emptySet()
|
||||
}
|
||||
|
||||
private fun getExtensionFilename(extensionArtifact: Artifact) =
|
||||
"${extensionArtifact.groupId}_${extensionArtifact.artifactId}_${extensionArtifact.version}"
|
||||
|
||||
}
|
||||
|
||||
internal object MavenMetaInformationFinder : ExtensionPackageFinder {
|
||||
|
||||
private const val MAVEN_META_XML = "pom.xml"
|
||||
private const val MAVEN_META_PROPERTIES = "pom.properties"
|
||||
|
||||
override fun findByArtifact(extensionArtifact: Artifact, extensionsPath: File): Set<File> {
|
||||
val files = extensionsPath.listFiles() ?: return emptySet()
|
||||
val result = mutableSetOf<File>()
|
||||
for (file in files) {
|
||||
if (file.isFile) {
|
||||
val foundArtifact = when (file.extension) {
|
||||
"jar", "zip" -> {
|
||||
getArtifactCoordinateFromArtifactJar(file)
|
||||
}
|
||||
// 尚不清楚 jmod 的具体结构细节, 担心在 Maven 正式支持 jmod 之后会出现变数, 故暂不支持.
|
||||
"jmod" -> null
|
||||
else -> null
|
||||
}
|
||||
if (foundArtifact != null && extensionArtifact.equalsArtifact(foundArtifact)) {
|
||||
result.add(file)
|
||||
}
|
||||
} else if (file.isDirectory) {
|
||||
val foundArtifact = getArtifactCoordinateFromArtifactDirectory(file)
|
||||
if (foundArtifact != null && extensionArtifact.equalsArtifact(foundArtifact)) {
|
||||
result.add(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
return if (result.isEmpty()) emptySet() else result
|
||||
}
|
||||
|
||||
private fun getArtifactCoordinateFromArtifactDirectory(dir: File): Artifact? {
|
||||
if (!dir.isDirectory) {
|
||||
return null
|
||||
}
|
||||
|
||||
val mavenMetaRoot = File(dir, "META-INF/maven/")
|
||||
if (!mavenMetaRoot.exists() || !mavenMetaRoot.isDirectory) {
|
||||
return null
|
||||
}
|
||||
|
||||
val files = mavenMetaRoot.deepListFiles(filenameFilter = { _, name ->
|
||||
name != null && (name.contentEquals(MAVEN_META_XML) || name.contentEquals(MAVEN_META_PROPERTIES))
|
||||
})
|
||||
|
||||
val metaFile = files?.firstOrNull() ?: return null
|
||||
return when (metaFile.extension.lowercase()) {
|
||||
"xml" -> metaFile.inputStream().use { getArtifactFromPomXml(it) }
|
||||
"properties" -> metaFile.inputStream().use { getArtifactFromPomProperties(it) }
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun getArtifactCoordinateFromArtifactJar(file: File): Artifact? {
|
||||
if (!file.isFile) {
|
||||
return null
|
||||
}
|
||||
file.inputStream().use {
|
||||
val jarInputStream = JarInputStream(it)
|
||||
var entry: JarEntry?
|
||||
while (true) {
|
||||
entry = jarInputStream.nextJarEntry
|
||||
if (entry == null) {
|
||||
break
|
||||
}
|
||||
|
||||
if (entry.name.startsWith("META-INF/maven")) {
|
||||
val artifact = if (entry.name.endsWith(MAVEN_META_XML)) {
|
||||
getArtifactFromPomXml(jarInputStream)
|
||||
} else if (entry.name.endsWith(MAVEN_META_PROPERTIES)) {
|
||||
getArtifactFromPomProperties(jarInputStream)
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
return artifact
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// language=XPATH
|
||||
private const val XPATH_POM_ARTIFACT = "/project/artifactId"
|
||||
|
||||
// language=XPATH
|
||||
private const val XPATH_POM_GROUP = "/project/groupId"
|
||||
|
||||
// language=XPATH
|
||||
private const val XPATH_POM_PARENT_GROUP = "/project/parent/groupId"
|
||||
|
||||
// language=XPATH
|
||||
private const val XPATH_POM_VERSION = "/project/version"
|
||||
|
||||
// language=XPATH
|
||||
private const val XPATH_POM_PARENT_VERSION = "/project/parent/version"
|
||||
|
||||
// Packaging 也等同于 Extension(Artifact 里的)
|
||||
// language=XPATH
|
||||
private const val XPATH_POM_PACKAGING = "/project/packaging"
|
||||
|
||||
private val xmlReader = SAXBuilder()
|
||||
private val xPathFactory = XPathFactory.instance()
|
||||
|
||||
private fun getArtifactFromPomXml(input: InputStream): DefaultArtifact? {
|
||||
val document = xmlReader.build(input) ?: return null
|
||||
|
||||
val artifactName = querySelectorContent(document, XPATH_POM_ARTIFACT) ?: return null
|
||||
val groupId =
|
||||
querySelectorContent(document, XPATH_POM_GROUP) ?: querySelectorContent(document, XPATH_POM_PARENT_GROUP)
|
||||
?: return null
|
||||
val version = querySelectorContent(document, XPATH_POM_VERSION) ?: querySelectorContent(
|
||||
document,
|
||||
XPATH_POM_PARENT_VERSION
|
||||
) ?: return null
|
||||
val extensionName = querySelectorContent(document, XPATH_POM_PACKAGING)
|
||||
|
||||
return DefaultArtifact(groupId, artifactName, extensionName, version)
|
||||
}
|
||||
|
||||
|
||||
private fun querySelectorContent(doc: Document, xPath: String): String? =
|
||||
xPathFactory.compile(xPath, Filters.element()).evaluateFirst(doc).text
|
||||
|
||||
private const val PROP_KEY_GROUP = "groupId"
|
||||
private const val PROP_KEY_ARTIFACT = "artifactId"
|
||||
private const val PROP_KEY_VERSION = "version"
|
||||
|
||||
private fun getArtifactFromPomProperties(input: InputStream): DefaultArtifact? {
|
||||
val prop = Properties()
|
||||
prop.load(input)
|
||||
if (isEmptyOrNull(prop, PROP_KEY_GROUP) || isEmptyOrNull(prop, PROP_KEY_ARTIFACT) || isEmptyOrNull(
|
||||
prop,
|
||||
PROP_KEY_VERSION
|
||||
)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return DefaultArtifact(
|
||||
prop.getProperty(PROP_KEY_GROUP),
|
||||
prop.getProperty(PROP_KEY_ARTIFACT),
|
||||
null,
|
||||
prop.getProperty(PROP_KEY_VERSION)
|
||||
)
|
||||
}
|
||||
|
||||
private fun isEmptyOrNull(prop: Properties, key: String): Boolean =
|
||||
!prop.containsKey(key) || prop.getProperty(key).trim().isEmpty()
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 扩展包专属的类加载器.
|
||||
*
|
||||
* 通过为每个扩展包提供专门的加载器, 可防止意外使用其他扩展的类(希望如此).
|
||||
*/
|
||||
internal class ExtensionClassLoader(extensionFile: File) :
|
||||
URLClassLoader(arrayOf(URL("file:///${extensionFile.canonicalPath}"))) {
|
||||
|
||||
val serviceLoader: ServiceLoader<BotExtensionFactory> = ServiceLoader.load(BotExtensionFactory::class.java, this)
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
41
scalabot-app/src/main/kotlin/ScalaBot.kt
Normal file
41
scalabot-app/src/main/kotlin/ScalaBot.kt
Normal file
@ -0,0 +1,41 @@
|
||||
package net.lamgc.scalabot
|
||||
|
||||
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.toggle.BareboneToggle
|
||||
import org.telegram.abilitybots.api.toggle.DefaultToggle
|
||||
import org.telegram.telegrambots.bots.DefaultBotOptions
|
||||
|
||||
internal class ScalaBot(
|
||||
name: String,
|
||||
token: String,
|
||||
private val creatorId: Long,
|
||||
db: DBContext,
|
||||
options: DefaultBotOptions,
|
||||
val extensions: Set<Artifact>,
|
||||
disableBuiltInAbility: Boolean
|
||||
) :
|
||||
AbilityBot(token, name, db, if (disableBuiltInAbility) DefaultToggle() else BareboneToggle(), options) {
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
private val log = KotlinLogging.logger { }
|
||||
}
|
||||
|
||||
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
|
||||
}
|
57
scalabot-app/src/main/kotlin/util/Serializers.kt
Normal file
57
scalabot-app/src/main/kotlin/util/Serializers.kt
Normal file
@ -0,0 +1,57 @@
|
||||
package net.lamgc.scalabot.util
|
||||
|
||||
import com.google.gson.*
|
||||
import org.eclipse.aether.artifact.Artifact
|
||||
import org.eclipse.aether.artifact.DefaultArtifact
|
||||
import org.telegram.telegrambots.bots.DefaultBotOptions
|
||||
import java.lang.reflect.Type
|
||||
|
||||
object ProxyTypeSerializer : JsonDeserializer<DefaultBotOptions.ProxyType>,
|
||||
JsonSerializer<DefaultBotOptions.ProxyType> {
|
||||
|
||||
override fun deserialize(
|
||||
json: JsonElement,
|
||||
typeOfT: Type?,
|
||||
context: JsonDeserializationContext?
|
||||
): DefaultBotOptions.ProxyType {
|
||||
if (!json.isJsonPrimitive) {
|
||||
throw JsonParseException("Wrong configuration value type.")
|
||||
}
|
||||
val value = json.asString.trim()
|
||||
try {
|
||||
return DefaultBotOptions.ProxyType.valueOf(value.uppercase())
|
||||
} catch (e: IllegalArgumentException) {
|
||||
throw JsonParseException("Invalid value: $value")
|
||||
}
|
||||
}
|
||||
|
||||
override fun serialize(
|
||||
src: DefaultBotOptions.ProxyType,
|
||||
typeOfSrc: Type?,
|
||||
context: JsonSerializationContext?
|
||||
): JsonElement {
|
||||
return JsonPrimitive(src.toString())
|
||||
}
|
||||
}
|
||||
|
||||
object ArtifactSerializer : JsonSerializer<Artifact>, JsonDeserializer<Artifact> {
|
||||
override fun serialize(src: Artifact, typeOfSrc: Type?, context: JsonSerializationContext?): JsonElement {
|
||||
val gavBuilder = StringBuilder("${src.groupId}:${src.artifactId}")
|
||||
if (!src.extension.equals("jar")) {
|
||||
gavBuilder.append(':').append(src.extension)
|
||||
}
|
||||
if (src.classifier.isNotEmpty()) {
|
||||
gavBuilder.append(':').append(src.classifier)
|
||||
}
|
||||
return JsonPrimitive(gavBuilder.append(':').append(src.version).toString())
|
||||
}
|
||||
|
||||
override fun deserialize(json: JsonElement?, typeOfT: Type?, context: JsonDeserializationContext?): Artifact {
|
||||
if (!json!!.isJsonPrimitive) {
|
||||
throw JsonParseException("Wrong configuration value type.")
|
||||
}
|
||||
return DefaultArtifact(json.asString.trim())
|
||||
}
|
||||
|
||||
}
|
||||
|
55
scalabot-app/src/main/kotlin/util/Utils.kt
Normal file
55
scalabot-app/src/main/kotlin/util/Utils.kt
Normal file
@ -0,0 +1,55 @@
|
||||
package net.lamgc.scalabot.util
|
||||
|
||||
import org.eclipse.aether.artifact.Artifact
|
||||
import java.io.File
|
||||
import java.io.FileFilter
|
||||
import java.io.FilenameFilter
|
||||
|
||||
internal fun ByteArray.toHaxString(): String = ByteUtils.bytesToHexString(this)
|
||||
|
||||
internal fun Artifact.equalsArtifact(that: Artifact): Boolean =
|
||||
this.groupId.equals(that.groupId) &&
|
||||
this.artifactId.equals(that.artifactId) &&
|
||||
this.version.equals(that.version) &&
|
||||
this.baseVersion.equals(that.baseVersion) &&
|
||||
this.isSnapshot == that.isSnapshot &&
|
||||
this.classifier.equals(that.classifier) &&
|
||||
this.extension.equals(that.extension) &&
|
||||
(if (this.file == null) that.file == null else this.file.equals(that.file)) &&
|
||||
this.properties.equals(that.properties)
|
||||
|
||||
internal fun File.deepListFiles(
|
||||
addSelf: Boolean = false,
|
||||
onlyFile: Boolean = false,
|
||||
fileFilter: FileFilter? = null,
|
||||
filenameFilter: FilenameFilter? = null
|
||||
): Array<File>? {
|
||||
val files = if (fileFilter != null) {
|
||||
this.listFiles(fileFilter)
|
||||
} else if (filenameFilter != null) {
|
||||
this.listFiles(filenameFilter)
|
||||
} else {
|
||||
this.listFiles()
|
||||
}
|
||||
|
||||
if (files == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
val result = if (addSelf) mutableSetOf(this) else mutableSetOf()
|
||||
for (file in files) {
|
||||
if (file.isFile) {
|
||||
result.add(file)
|
||||
} else {
|
||||
if (!onlyFile) {
|
||||
result.add(file)
|
||||
}
|
||||
val subFiles = file.deepListFiles(false, onlyFile, fileFilter, filenameFilter)
|
||||
if (subFiles != null) {
|
||||
result.addAll(subFiles)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toTypedArray()
|
||||
}
|
||||
|
37
scalabot-app/src/main/resources/logback.xml
Normal file
37
scalabot-app/src/main/resources/logback.xml
Normal file
@ -0,0 +1,37 @@
|
||||
<configuration scan="false" debug="false">
|
||||
<appender name="STD_OUT" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder>
|
||||
<pattern>[%d{HH:mm:ss.SSS} %5level][%logger{36}][%thread]: %msg%n</pattern>
|
||||
</encoder>
|
||||
<filter class="net.lamgc.scalabot.util.StdOutFilter">
|
||||
|
||||
</filter>
|
||||
</appender>
|
||||
|
||||
<appender name="STD_ERR" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<target>System.err</target>
|
||||
<encoder>
|
||||
<pattern>%red([%d{HH:mm:ss.SSS} %5level][%logger{36}][%thread]: %msg%n)</pattern>
|
||||
</encoder>
|
||||
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
|
||||
<level>WARN</level>
|
||||
</filter>
|
||||
</appender>
|
||||
|
||||
<appender name="FILE_OUT" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<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>
|
||||
</rollingPolicy>
|
||||
<encoder>
|
||||
<pattern>[%d{HH:mm:ss.SSS} %5level][%logger{36}][%thread]: %msg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<root level="INFO">
|
||||
<appender-ref ref="FILE_OUT"/>
|
||||
<appender-ref ref="STD_ERR" additivity="false"/>
|
||||
<appender-ref ref="STD_OUT"/>
|
||||
</root>
|
||||
</configuration>
|
35
scalabot-app/src/test/kotlin/util/ArtifactSerializerTest.kt
Normal file
35
scalabot-app/src/test/kotlin/util/ArtifactSerializerTest.kt
Normal file
@ -0,0 +1,35 @@
|
||||
@file:Suppress("PackageDirectoryMismatch")
|
||||
|
||||
package net.lamgc.scalabot.util
|
||||
|
||||
import com.google.gson.JsonObject
|
||||
import com.google.gson.JsonParseException
|
||||
import com.google.gson.JsonPrimitive
|
||||
import org.eclipse.aether.artifact.DefaultArtifact
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
|
||||
internal class ArtifactSerializerTest {
|
||||
|
||||
@Test
|
||||
fun badJsonType() {
|
||||
assertFailsWith<JsonParseException> { ArtifactSerializer.deserialize(JsonObject(), null, null) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun serialize() {
|
||||
val gav = "org.example.software:test:1.0.0-SNAPSHOT"
|
||||
val expectArtifact = DefaultArtifact(gav)
|
||||
val actualArtifact = DefaultArtifact(ArtifactSerializer.serialize(expectArtifact, null, null).asString)
|
||||
assertEquals(expectArtifact, actualArtifact)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deserialize() {
|
||||
val gav = "org.example.software:test:1.0.0-SNAPSHOT"
|
||||
val expectArtifact = DefaultArtifact(gav)
|
||||
val actualArtifact = ArtifactSerializer.deserialize(JsonPrimitive(gav), null, null)
|
||||
assertEquals(expectArtifact, actualArtifact)
|
||||
}
|
||||
}
|
19
scalabot-app/src/test/kotlin/util/UtilsKtTest.kt
Normal file
19
scalabot-app/src/test/kotlin/util/UtilsKtTest.kt
Normal file
@ -0,0 +1,19 @@
|
||||
package net.lamgc.scalabot.util
|
||||
|
||||
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
|
||||
|
||||
internal class UtilsKtTest {
|
||||
|
||||
@Test
|
||||
fun `Extension Function - Artifact_equalsArtifact`() {
|
||||
val equalGAV = "org.example:demo:1.0.0-SNAPSHOT"
|
||||
assertTrue(DefaultArtifact(equalGAV).equalsArtifact(DefaultArtifact(equalGAV)))
|
||||
assertFalse(
|
||||
DefaultArtifact("org.example:demo:1.0.0")
|
||||
.equalsArtifact(DefaultArtifact("com.example:demo-2:1.0.0-SNAPSHOT"))
|
||||
)
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user