diff --git a/src/main/kotlin/top/colter/dynamic/DynamicApplication.kt b/src/main/kotlin/top/colter/dynamic/DynamicApplication.kt index 771a021..3d8c563 100644 --- a/src/main/kotlin/top/colter/dynamic/DynamicApplication.kt +++ b/src/main/kotlin/top/colter/dynamic/DynamicApplication.kt @@ -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, @@ -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 = 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() diff --git a/src/main/kotlin/top/colter/dynamic/MainConfigStore.kt b/src/main/kotlin/top/colter/dynamic/MainConfigStore.kt index e3bc44c..21b25a7 100644 --- a/src/main/kotlin/top/colter/dynamic/MainConfigStore.kt +++ b/src/main/kotlin/top/colter/dynamic/MainConfigStore.kt @@ -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 @@ -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, @@ -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 = "启用网络代理", @@ -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()) { "启用网络代理时代理主机不能为空" } @@ -1272,6 +1281,9 @@ public object MainConfigForms { if (previous.notifications.routeMonitorIntervalSeconds != next.notifications.routeMonitorIntervalSeconds) { targets += "主程序" } + if (previous.plugin != next.plugin) { + targets += "主程序" + } return targets.toList() } diff --git a/src/main/kotlin/top/colter/dynamic/MainDynamicConfig.kt b/src/main/kotlin/top/colter/dynamic/MainDynamicConfig.kt index 597f92f..68ae62c 100644 --- a/src/main/kotlin/top/colter/dynamic/MainDynamicConfig.kt +++ b/src/main/kotlin/top/colter/dynamic/MainDynamicConfig.kt @@ -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(), @@ -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(), ) diff --git a/src/main/kotlin/top/colter/dynamic/plugin/PluginManager.kt b/src/main/kotlin/top/colter/dynamic/plugin/PluginManager.kt index 1bafd5c..9f188ce 100644 --- a/src/main/kotlin/top/colter/dynamic/plugin/PluginManager.kt +++ b/src/main/kotlin/top/colter/dynamic/plugin/PluginManager.kt @@ -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 } } diff --git a/src/test/kotlin/top/colter/dynamic/MainConfigStoreTest.kt b/src/test/kotlin/top/colter/dynamic/MainConfigStoreTest.kt index 34015f7..df46620 100644 --- a/src/test/kotlin/top/colter/dynamic/MainConfigStoreTest.kt +++ b/src/test/kotlin/top/colter/dynamic/MainConfigStoreTest.kt @@ -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 { + MainConfigForms.validate( + MainDynamicConfig(plugin = PluginConfig(hookTimeoutSeconds = 0)), + ) + } + assertTrue(invalid.message!!.contains("插件启动钩子超时")) + + val tooLarge = assertFailsWith { + 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, removed: Collection) { removed.forEach { value -> assertFalse(value in actual, "不应再展示配置字段:$value")