Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/main/kotlin/top/colter/dynamic/DynamicApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,10 @@ public object DynamicApplication : CoroutineScope {
SourceUpdatePublishResult.failed("主项目来源更新处理器尚未初始化")
}
}
private val configStore: MainConfigStore = MainConfigStore(
configService = configService,
defaultConfigProvider = ::defaultMainConfig,
)
private val pluginManager: PluginManager = PluginManager(
pluginDirPath = "plugins",
scope = this,
Expand All @@ -147,10 +151,12 @@ public object DynamicApplication : CoroutineScope {
drawAssetRegistry = drawAssetRegistry,
primaryBotAccountResolver = ::resolvePrimaryMessageBotAccount,
knownBotAccountIdsResolver = ::resolveKnownMessageBotAccounts,
// 构造期读取一次:configStore.current() 会惰性加载配置(与 run() 共用 defaultMainConfig),
// 之后 hookTimeoutSeconds 的变更通过 save() 的 restartTargets 提示重启生效。
pluginHookTimeoutMs = configStore.current().plugin.hookTimeoutSeconds * 1000L,
)
private val listenerTokens: MutableList<ListenerToken> = mutableListOf()
private val taskScheduler: DefaultTaskScheduler = DefaultTaskScheduler(scope = this)
private val configStore: MainConfigStore = MainConfigStore(configService)
private val shutdownStarted: AtomicBoolean = AtomicBoolean(false)
private val startedAtEpochMillis: Long = System.currentTimeMillis()

Expand Down
14 changes: 13 additions & 1 deletion src/main/kotlin/top/colter/dynamic/MainConfigStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import top.colter.dynamic.listener.PushTemplateRenderer

public class MainConfigStore(
private val configService: ConfigService = YamlConfigService(),
private val defaultConfigProvider: () -> MainDynamicConfig = { MainDynamicConfig() },
) {
@Volatile
private var currentConfig: MainDynamicConfig? = null
Expand All @@ -38,7 +39,7 @@ public class MainConfigStore(
public fun loadOrCreate(
adminTokenProvider: () -> String,
secretProvider: () -> String,
defaultConfigProvider: () -> MainDynamicConfig = { MainDynamicConfig() },
defaultConfigProvider: () -> MainDynamicConfig = this.defaultConfigProvider,
): MainDynamicConfig {
val loaded = configService.loadOrCreate(
MainDynamicConfig.CONFIG_ID,
Expand Down Expand Up @@ -806,6 +807,13 @@ public object MainConfigForms {
section = "插件目录",
description = "下载插件和插件目录时最多等待多久。\n支持小数,例如 0.5 表示 0.5 秒。",
),
ConfigFieldSpec(
path = "plugin.hookTimeoutSeconds",
label = "插件启动钩子超时(秒)",
type = ConfigFieldType.NUMBER,
section = "插件目录",
description = "插件 start/stop 钩子执行的最大等待时间。\n默认 60 秒;仅支持整数。来源平台插件启动时要访问平台 API,网络慢或冷启动时可适当调大,避免插件被判定启动失败。",
),
ConfigFieldSpec(
path = "network.proxy.enabled",
label = "启用网络代理",
Expand Down Expand Up @@ -1171,6 +1179,7 @@ public object MainConfigForms {
require(config.pluginCatalog.downloadTimeoutSeconds > 0.0) { "插件下载超时必须大于 0 秒" }
require(config.pluginCatalog.maxDownloadMegabytes.isFiniteNumber()) { "插件最大下载大小必须是有效数字" }
require(config.pluginCatalog.maxDownloadMegabytes > 0.0) { "插件最大下载大小必须大于 0 MB" }
require(config.plugin.hookTimeoutSeconds in 1L..3600L) { "插件启动钩子超时必须为 1 到 3600 秒" }
val proxy = config.network.proxy
if (proxy.enabled) {
require(proxy.host.isNotBlank()) { "启用网络代理时代理主机不能为空" }
Expand Down Expand Up @@ -1272,6 +1281,9 @@ public object MainConfigForms {
if (previous.notifications.routeMonitorIntervalSeconds != next.notifications.routeMonitorIntervalSeconds) {
targets += "主程序"
}
if (previous.plugin != next.plugin) {
targets += "主程序"
}
return targets.toList()
}

Expand Down
9 changes: 9 additions & 0 deletions src/main/kotlin/top/colter/dynamic/MainDynamicConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public data class MainDynamicConfig(
val messageRouting: MessageRoutingConfig = MessageRoutingConfig(),
val delivery: DeliveryConfig = DeliveryConfig(),
val draw: DrawSettings = DrawSettings(),
val plugin: PluginConfig = PluginConfig(),
val pluginCatalog: PluginCatalogConfig = PluginCatalogConfig(),
val network: NetworkConfig = NetworkConfig(),
val webAdmin: WebAdminConfig = WebAdminConfig(),
Expand Down Expand Up @@ -64,6 +65,14 @@ public data class PluginCatalogConfig(
}
}

public data class PluginConfig(
val hookTimeoutSeconds: Long = DEFAULT_HOOK_TIMEOUT_SECONDS,
) {
public companion object {
public const val DEFAULT_HOOK_TIMEOUT_SECONDS: Long = 60
}
}

public data class NetworkConfig(
val proxy: NetworkProxyConfig = NetworkProxyConfig(),
)
Expand Down
2 changes: 1 addition & 1 deletion src/main/kotlin/top/colter/dynamic/plugin/PluginManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1302,7 +1302,7 @@ public class PluginManager(

private companion object {
private val PLUGIN_ID_REGEX: Regex = Regex("^[a-zA-Z0-9._-]+$")
private const val DEFAULT_PLUGIN_HOOK_TIMEOUT_MS: Long = 10_000
private const val DEFAULT_PLUGIN_HOOK_TIMEOUT_MS: Long = 60_000
private const val DEFAULT_INCOMING_MESSAGE_PENDING_LIMIT: Int = 64
}
}
Expand Down
78 changes: 78 additions & 0 deletions src/test/kotlin/top/colter/dynamic/MainConfigStoreTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,84 @@ class MainConfigStoreTest {
}
}

@Test
fun pluginHookTimeoutShouldDefaultTo60SecondsAndBeExposed() {
val configService = YamlConfigService(createTempDirectory("dynamic-bot-main-config"))
val store = MainConfigStore(configService)
val current = store.loadOrCreate(
adminTokenProvider = { "token" },
secretProvider = { "secret" },
)

assertEquals(60, current.plugin.hookTimeoutSeconds)
assertTrue(MainConfigForms.formSpec.fields.map { it.path }.contains("plugin.hookTimeoutSeconds"))

val invalid = assertFailsWith<IllegalArgumentException> {
MainConfigForms.validate(
MainDynamicConfig(plugin = PluginConfig(hookTimeoutSeconds = 0)),
)
}
assertTrue(invalid.message!!.contains("插件启动钩子超时"))

val tooLarge = assertFailsWith<IllegalArgumentException> {
MainConfigForms.validate(
MainDynamicConfig(plugin = PluginConfig(hookTimeoutSeconds = 3601)),
)
}
assertTrue(tooLarge.message!!.contains("插件启动钩子超时"))
}

@Test
fun pluginChangeShouldRequireRestart() {
val configService = YamlConfigService(createTempDirectory("dynamic-bot-main-config"))
val store = MainConfigStore(configService)
val current = store.loadOrCreate(
adminTokenProvider = { "token" },
secretProvider = { "secret" },
)

val result = store.save(
current.copy(plugin = current.plugin.copy(hookTimeoutSeconds = 120)),
)

assertTrue(result.changed)
assertTrue(result.restartRequired)
assertEquals(listOf("主程序"), result.restartTargets)
}

@Test
fun currentShouldFallBackToInjectedDefaultConfigProvider() {
val configService = YamlConfigService(createTempDirectory("dynamic-bot-main-config"))
val store = MainConfigStore(
configService = configService,
defaultConfigProvider = {
MainDynamicConfig(webAdmin = WebAdminConfig(host = "0.0.0.0"))
},
)

// current() 在未显式 loadOrCreate 时,也应使用注入的 provider 创建默认配置
val current = store.current()

assertEquals("0.0.0.0", current.webAdmin.host)
assertEquals(60, current.plugin.hookTimeoutSeconds)
}

@Test
fun pluginHookTimeoutShouldAcceptBoundaryValues() {
MainConfigForms.validate(
MainDynamicConfig(
webAdmin = WebAdminConfig(token = "token"),
plugin = PluginConfig(hookTimeoutSeconds = 1),
),
)
MainConfigForms.validate(
MainDynamicConfig(
webAdmin = WebAdminConfig(token = "token"),
plugin = PluginConfig(hookTimeoutSeconds = 3600),
),
)
}

private fun assertContainsNone(actual: Collection<String>, removed: Collection<String>) {
removed.forEach { value ->
assertFalse(value in actual, "不应再展示配置字段:$value")
Expand Down
Loading