diff --git a/pytests/image_sys_test/emoji_manager_test.py b/pytests/image_sys_test/emoji_manager_test.py index 8a788938..b71ec847 100644 --- a/pytests/image_sys_test/emoji_manager_test.py +++ b/pytests/image_sys_test/emoji_manager_test.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from types import ModuleType from pathlib import Path +import asyncio import pytest @@ -196,8 +197,20 @@ def _install_stub_modules(monkeypatch): emoji = _EmojiConfig() bot = _BotConfig() + class _ConfigManager: + def __init__(self): + self.reload_callbacks = [] + + def register_reload_callback(self, callback): + self.reload_callbacks.append(callback) + + def unregister_reload_callback(self, callback): + if callback in self.reload_callbacks: + self.reload_callbacks.remove(callback) + config_mod.global_config = _GlobalConfig() config_mod.model_config = _ModelConfig() + config_mod.config_manager = _ConfigManager() # src.llm_models.utils_model llm_mod = _stub_module("src.llm_models.utils_model") @@ -479,6 +492,62 @@ def test_load_emojis_from_db_empty(monkeypatch): assert any("成功加载" in m for m in _messages(logger.info_calls)) +def test_emoji_manager_registers_reload_callback(monkeypatch): + emoji_manager_new = import_emoji_manager_new(monkeypatch) + + assert emoji_manager_new.emoji_manager.reload_runtime_config in emoji_manager_new.config_manager.reload_callbacks + + +def test_emoji_manager_shutdown_unregisters_reload_callback(monkeypatch): + emoji_manager_new = import_emoji_manager_new(monkeypatch) + manager = emoji_manager_new.EmojiManager() + + assert manager.reload_runtime_config in emoji_manager_new.config_manager.reload_callbacks + + manager.shutdown() + + assert manager.reload_runtime_config not in emoji_manager_new.config_manager.reload_callbacks + + # 重复调用应保持幂等,不应抛错也不应重复注册 + manager.shutdown() + + assert manager.reload_runtime_config not in emoji_manager_new.config_manager.reload_callbacks + + +@pytest.mark.asyncio +async def test_reload_runtime_config_wakes_maintenance_loop(monkeypatch): + emoji_manager_new = import_emoji_manager_new(monkeypatch) + manager = emoji_manager_new.EmojiManager() + + emoji_manager_new.global_config.emoji.steal_emoji = False + emoji_manager_new.global_config.emoji.check_interval = 60 + + maintenance_runs = 0 + second_run_event = asyncio.Event() + + def _check_emoji_file_integrity(): + nonlocal maintenance_runs + maintenance_runs += 1 + if maintenance_runs >= 2: + second_run_event.set() + + monkeypatch.setattr(manager, "check_emoji_file_integrity", _check_emoji_file_integrity) + monkeypatch.setattr(manager, "remove_untracked_emoji_files", lambda: None) + + task = asyncio.create_task(manager.periodic_emoji_maintenance()) + try: + await asyncio.sleep(0.05) + assert maintenance_runs >= 1 + + manager.reload_runtime_config() + + await asyncio.wait_for(second_run_event.wait(), timeout=0.2) + finally: + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + def test_load_emojis_from_db_partial_bad_records(monkeypatch): emoji_manager_new = import_emoji_manager_new(monkeypatch) logger = emoji_manager_new.logger diff --git a/src/chat/emoji_system/emoji_manager.py b/src/chat/emoji_system/emoji_manager.py index 2be0bd69..edd4bbf9 100644 --- a/src/chat/emoji_system/emoji_manager.py +++ b/src/chat/emoji_system/emoji_manager.py @@ -17,8 +17,7 @@ from src.common.database.database_model import Images, ImageType from src.common.database.database import get_db_session, get_db_session_manual from src.common.utils.utils_image import ImageUtils from src.prompt.prompt_manager import prompt_manager -from src.config.config import global_config -from src.config.config import model_config +from src.config.config import config_manager, global_config, model_config from src.llm_models.utils_model import LLMRequest logger = get_logger("emoji") @@ -53,9 +52,28 @@ class EmojiManager: self._emoji_num: int = 0 self.emojis: list[MaiEmoji] = [] + self._maintenance_wakeup_event = asyncio.Event() + self._reload_callback_registered = False + + config_manager.register_reload_callback(self.reload_runtime_config) + self._reload_callback_registered = True logger.info("启动表情包管理器") + def reload_runtime_config(self) -> None: + """响应配置热重载,唤醒维护循环以尽快应用最新配置。""" + self._maintenance_wakeup_event.set() + logger.info("[配置热重载] Emoji 模块配置已更新,将立即应用到维护循环") + + def shutdown(self) -> None: + """清理 EmojiManager 生命周期资源。""" + if not self._reload_callback_registered: + return + config_manager.unregister_reload_callback(self.reload_runtime_config) + self._reload_callback_registered = False + self._maintenance_wakeup_event.set() + logger.info("[关闭] Emoji 模块已注销配置热重载回调") + async def get_emoji_description( self, *, emoji_bytes: Optional[bytes] = None, emoji_hash: Optional[str] = None ) -> Optional[Tuple[str, List[str]]]: @@ -640,7 +658,13 @@ class EmojiManager: logger.info(f"[定期维护] 删除无法注册的表情包文件: {emoji_file.name}") except Exception as e: logger.error(f"[定期维护] 删除文件 {emoji_file.name} 时出错: {e}") - await asyncio.sleep(global_config.emoji.check_interval * 60) + wait_seconds = max(global_config.emoji.check_interval * 60, 0) + try: + await asyncio.wait_for(self._maintenance_wakeup_event.wait(), timeout=wait_seconds) + except asyncio.TimeoutError: + pass + finally: + self._maintenance_wakeup_event.clear() async def register_emoji_by_filename(self, filename: Path | str) -> bool: """ diff --git a/src/main.py b/src/main.py index 91da2d83..78aaa8ad 100644 --- a/src/main.py +++ b/src/main.py @@ -164,6 +164,7 @@ async def main(): system.schedule_tasks(), ) finally: + emoji_manager.shutdown() await config_manager.stop_file_watcher()