From f9ddba970a3fb586c992dd68da33258b909b5cc0 Mon Sep 17 00:00:00 2001 From: FlanChanXwO Date: Mon, 18 May 2026 15:29:34 +0800 Subject: [PATCH 1/6] fix(fortune): pregenerate daily card caches --- CHANGELOG.md | 1 + main.py | 52 ++++- src/application/ports/fortune_repository.py | 7 + src/domain/fortune/service.py | 39 +++- .../astrbot/commands/fortune.py | 29 ++- .../persistence/sqlite_fortune_repository.py | 59 +++++- .../test_fortune_pregeneration.py | 183 ++++++++++++++++++ 7 files changed, 350 insertions(+), 20 deletions(-) create mode 100644 tests/infrastructure/test_fortune_pregeneration.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 048aa6e..7fd8999 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - **修复部分提示无法关闭**:`fetching`、`found`、`send_failed` 及新增 fortune 提示项均可独立关闭 - **恢复今日运势卡片样式**:重新接回旧版模板、字体资源与渲染管线,避免截图卡片退化为简化样式 - **修复私聊运势重复触发**:`jrys/今日运势` 在多命令前缀场景下改为基于命令唤醒状态去重,避免 regex 与 command 同时响应 +- **恢复运势自动预缓存**:启用 `auto_refresh` 时,近期使用过 `jrys` 的用户会在跨日后预生成并缓存运势卡片图片 ## [2.0.0] - 2026-05-17 diff --git a/main.py b/main.py index 7481400..fe79f16 100644 --- a/main.py +++ b/main.py @@ -6,8 +6,11 @@ from __future__ import annotations -from collections.abc import AsyncGenerator +import asyncio +import datetime import re +from collections.abc import AsyncGenerator +from contextlib import suppress from typing import Any from astrbot.api import logger @@ -126,6 +129,21 @@ def _is_fortune_command_invocation(event: AstrMessageEvent) -> bool: return _get_invoked_command(event) in {"今日运势", "jrys"} +def _fortune_auto_refresh_enabled(config: Any) -> bool: + fortune = getattr(config, "fortune", None) + return ( + getattr(fortune, "enabled", False) is True + and getattr(fortune, "auto_refresh", False) is True + ) + + +def _seconds_until_next_midnight() -> float: + now = datetime.datetime.now() + tomorrow = now.date() + datetime.timedelta(days=1) + next_midnight = datetime.datetime.combine(tomorrow, datetime.time.min) + return max(1.0, (next_midnight - now).total_seconds()) + + def _resolve_fortune_refresh_target(event: AstrMessageEvent, args: str) -> str: command = _get_invoked_command(event) if command in FORTUNE_REFRESH_COMMANDS: @@ -177,6 +195,7 @@ def __init__(self, context: Context, config: AstrBotConfig): super().__init__(context, config) self.context = context self._plugin_config = config + self._fortune_pregenerate_task: asyncio.Task[None] | None = None register_session_config_web_apis(self.context) async def initialize(self) -> None: @@ -203,6 +222,10 @@ async def initialize(self) -> None: _setu_handler = SetuCommandHandler() _fortune_handler = FortuneCommandHandler() _session_config_handler = SessionConfigCommandHandler() + if _fortune_auto_refresh_enabled(cfg): + self._fortune_pregenerate_task = asyncio.create_task( + self._fortune_pregenerate_loop(), name="setu_fortune_pregenerate" + ) try: register_setu_llm_tools() @@ -223,6 +246,12 @@ async def terminate(self) -> None: clear_session_config_repo, ) + if self._fortune_pregenerate_task is not None: + self._fortune_pregenerate_task.cancel() + with suppress(asyncio.CancelledError): + await self._fortune_pregenerate_task + self._fortune_pregenerate_task = None + try: unregister_setu_llm_tools() unregister_fortune_llm_tools() @@ -242,6 +271,27 @@ async def terminate(self) -> None: logger.info("SetuPlugin terminated") + async def _fortune_pregenerate_loop(self) -> None: + """Cache fortune card images for recently active users after day rollover.""" + while True: + await asyncio.sleep(_seconds_until_next_midnight()) + await self._pregenerate_active_fortune_images() + + async def _pregenerate_active_fortune_images(self) -> None: + if _fortune_handler is None: + return + try: + cached_count = await _fortune_handler.pregenerate_active_fortune_images() + if cached_count: + logger.info( + "[fortune] Pregenerated %d rendered fortune card caches", + cached_count, + ) + else: + logger.debug("[fortune] No fortune card caches pregenerated") + except Exception as exc: + logger.warning("[fortune] Failed to pregenerate fortune card caches: %s", exc) + def _runtime_plugin_config(self) -> dict[str, Any]: """Return the plugin-scoped config dict passed in by AstrBot.""" if isinstance(self._plugin_config, dict): diff --git a/src/application/ports/fortune_repository.py b/src/application/ports/fortune_repository.py index 1f78918..93d1ab6 100644 --- a/src/application/ports/fortune_repository.py +++ b/src/application/ports/fortune_repository.py @@ -43,6 +43,13 @@ async def get_active_users(self, days: int = 3) -> list[str]: """Get active user IDs.""" ... + @abstractmethod + async def get_active_fortune_requests( + self, days: int = 3, date_str: str | None = None + ) -> list[FortuneGenerationRequest]: + """Get generation requests for users active within N days.""" + ... + @abstractmethod async def get_cached_image_path(self, user_id: str, date_str: str) -> Any | None: """Get cached image path.""" diff --git a/src/domain/fortune/service.py b/src/domain/fortune/service.py index d79ff9e..dccb505 100644 --- a/src/domain/fortune/service.py +++ b/src/domain/fortune/service.py @@ -148,20 +148,39 @@ async def pregenerate_active_users(self, days: int = 3) -> int: Returns: Number of fortunes pregenerated """ + records = await self.pregenerate_active_user_records( + days=days, include_existing=False + ) + return len(records) + + async def pregenerate_active_user_records( + self, days: int = 3, *, include_existing: bool = False + ) -> list[FortuneRecord]: + """Ensure today's fortune records exist for recently active users. + + Args: + days: Number of days to look back for activity + include_existing: Include existing records in the returned list + + Returns: + Records that were created, plus existing records when requested + """ today_str = date.today().isoformat() - active_users = await self._repo.get_active_users(days) + active_requests = await self._repo.get_active_fortune_requests( + days, date_str=today_str + ) - pregenerated = 0 - for user_id in active_users: - # Check if already has fortune today - request = FortuneGenerationRequest(user_id, "指挥官", today_str) + records: list[FortuneRecord] = [] + for request in active_requests: existing = await self._repo.get_today_fortune(request) - if not existing: - # Generate new fortune - await self.get_or_create_fortune(request) - pregenerated += 1 + if existing: + if include_existing: + records.append(existing) + continue + + records.append(await self.get_or_create_fortune(request)) - return pregenerated + return records async def update_image_cache( self, record: FortuneRecord, image_data: bytes, img_url: str | None diff --git a/src/infrastructure/astrbot/commands/fortune.py b/src/infrastructure/astrbot/commands/fortune.py index 8c4c273..86edfe6 100644 --- a/src/infrastructure/astrbot/commands/fortune.py +++ b/src/infrastructure/astrbot/commands/fortune.py @@ -16,15 +16,15 @@ FortuneGenerationRequest, FortuneRecord, ) -from ....domain.setu import SetuRequest from ....domain.fortune.service import FortuneService +from ....domain.setu import SetuRequest from ....shared import get_logger from ... import get_access_control_repo, get_provider from ...permission_service import PermissionService from ...persistence import get_fortune_repo from ...providers import init_provider_from_config -from ..fortune_renderer import FortuneRenderer from ..config import get_config +from ..fortune_renderer import FortuneRenderer logger = get_logger() @@ -64,7 +64,7 @@ async def fortune_command( repo = get_fortune_repo() service = FortuneService(repository=repo) result = await service.get_or_create_fortune(request) - fortune_image = await self._render_fortune_image(event, result, service) + fortune_image = await self._render_fortune_image(result, service) if fortune_image: yield event.chain_result([Comp.Image.fromBytes(fortune_image)]) else: @@ -351,6 +351,27 @@ async def _llm_refresh_all_fortune(self, event: AstrMessageEvent) -> str: except Exception as e: return self._message("fortune_refresh_all_failed", error=e) + async def pregenerate_active_fortune_images(self, days: int = 3) -> int: + """Pregenerate and cache rendered fortune cards for active users.""" + config = get_config() + if not config or not config.fortune.enabled or not config.fortune.auto_refresh: + return 0 + + repo = get_fortune_repo() + service = FortuneService(repository=repo) + records = await service.pregenerate_active_user_records( + days=days, include_existing=True + ) + + cached_count = 0 + for record in records: + if await service.get_cached_image(record.user_id, record.date_str): + continue + image_bytes = await self._render_fortune_image(record, service) + if image_bytes: + cached_count += 1 + return cached_count + # ==================== Helper Methods ==================== async def _check_access(self, event: AstrMessageEvent, config) -> tuple[bool, str]: @@ -401,7 +422,7 @@ def _format_fortune(self, result: FortuneRecord) -> str: ) async def _render_fortune_image( - self, event: AstrMessageEvent, record: FortuneRecord, service: FortuneService + self, record: FortuneRecord, service: FortuneService ) -> bytes | None: """Render fortune to image, fallback to None when unavailable.""" cached = await service.get_cached_image(record.user_id, record.date_str) diff --git a/src/infrastructure/persistence/sqlite_fortune_repository.py b/src/infrastructure/persistence/sqlite_fortune_repository.py index 356eccb..505bdde 100644 --- a/src/infrastructure/persistence/sqlite_fortune_repository.py +++ b/src/infrastructure/persistence/sqlite_fortune_repository.py @@ -45,6 +45,7 @@ async def _init_db(self) -> None: """ CREATE TABLE IF NOT EXISTS fortune_data ( user_id TEXT NOT NULL, + username TEXT, date_str TEXT NOT NULL, title TEXT NOT NULL, stars INTEGER NOT NULL, @@ -72,6 +73,10 @@ async def _migrate_db(self, db: Any) -> None: await db.execute("ALTER TABLE fortune_data ADD COLUMN img_url TEXT") await db.commit() + if "username" not in column_names: + await db.execute("ALTER TABLE fortune_data ADD COLUMN username TEXT") + await db.commit() + if "last_view_date" not in column_names: await db.execute("ALTER TABLE fortune_data ADD COLUMN last_view_date TEXT") await db.commit() @@ -88,9 +93,10 @@ def _row_to_record( self, row: tuple, user_id: str, username: str, date_str: str ) -> FortuneRecord: """Convert a database row to FortuneRecord.""" + stored_username = row[9] if len(row) > 9 else None return FortuneRecord( user_id=user_id, - username=username, + username=username or stored_username or user_id, date_str=date_str, title=row[0], star_count=row[1], @@ -112,7 +118,7 @@ async def get_today_fortune( async with self._db_lock: async with aiosqlite.connect(str(self._db_path)) as db: cursor = await db.execute( - "SELECT title, stars, desc_text, extra, theme, image_cached, img_url, last_view_date, group_id " + "SELECT title, stars, desc_text, extra, theme, image_cached, img_url, last_view_date, group_id, username " "FROM fortune_data WHERE user_id = ? AND date_str = ?", (request.user_id, request.date_str), ) @@ -132,10 +138,11 @@ async def save_fortune(self, record: FortuneRecord) -> bool: async with aiosqlite.connect(str(self._db_path)) as db: await db.execute( "INSERT OR REPLACE INTO fortune_data " - "(user_id, date_str, title, stars, desc_text, extra, theme, image_cached, img_url, last_view_date, group_id) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "(user_id, username, date_str, title, stars, desc_text, extra, theme, image_cached, img_url, last_view_date, group_id) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ( record.user_id, + record.username, record.date_str, record.title, record.star_count, @@ -221,12 +228,54 @@ async def get_active_users(self, days: int = 3) -> list[str]: async with self._db_lock: async with aiosqlite.connect(str(self._db_path)) as db: cursor = await db.execute( - "SELECT DISTINCT user_id FROM fortune_data WHERE last_view_date >= ?", + "SELECT DISTINCT user_id FROM fortune_data WHERE COALESCE(last_view_date, date_str) >= ?", (cutoff,), ) rows = await cursor.fetchall() return [row[0] for row in rows] + async def get_active_fortune_requests( + self, days: int = 3, date_str: str | None = None + ) -> list[FortuneGenerationRequest]: + """Get latest generation request data for recently active users.""" + import aiosqlite + + target_date = date_str or datetime.date.today().isoformat() + cutoff = (datetime.date.today() - datetime.timedelta(days=days)).isoformat() + async with self._db_lock: + async with aiosqlite.connect(str(self._db_path)) as db: + cursor = await db.execute( + """ + SELECT user_id, + COALESCE(NULLIF(username, ''), user_id) AS username, + group_id + FROM fortune_data + WHERE COALESCE(last_view_date, date_str) >= ? + ORDER BY user_id, + COALESCE(last_view_date, date_str) DESC, + date_str DESC, + rowid DESC + """, + (cutoff,), + ) + rows = await cursor.fetchall() + + requests: list[FortuneGenerationRequest] = [] + seen: set[str] = set() + for user_id, username, group_id in rows: + if user_id in seen: + continue + seen.add(user_id) + requests.append( + FortuneGenerationRequest( + user_id=str(user_id), + username=str(username or user_id), + date_str=target_date, + group_id=str(group_id) if group_id else None, + ) + ) + return requests + async def get_cached_image_path(self, user_id: str, date_str: str) -> Any | None: """Get cached image path for fortune.""" cache_path = self._cache_dir / f"{user_id}_{date_str}.jpg" diff --git a/tests/infrastructure/test_fortune_pregeneration.py b/tests/infrastructure/test_fortune_pregeneration.py new file mode 100644 index 0000000..bbf7174 --- /dev/null +++ b/tests/infrastructure/test_fortune_pregeneration.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +from datetime import date +from pathlib import Path +from types import SimpleNamespace + +import pytest +from astrbot_plugin_setu.src.domain.fortune import ( + FortuneGenerationRequest, + FortuneRecord, +) +from astrbot_plugin_setu.src.infrastructure.astrbot.commands import ( + fortune as fortune_cmd, +) +from astrbot_plugin_setu.src.infrastructure.astrbot.commands.fortune import ( + FortuneCommandHandler, +) +from astrbot_plugin_setu.src.infrastructure.persistence.sqlite_fortune_repository import ( + SQLiteFortuneRepo, +) + + +class MemoryFortuneRepo: + def __init__(self, active_requests: list[FortuneGenerationRequest]) -> None: + self.active_requests = active_requests + self.records: dict[tuple[str, str], FortuneRecord] = {} + self.cached_images: dict[tuple[str, str], bytes] = {} + + async def get_today_fortune( + self, request: FortuneGenerationRequest + ) -> FortuneRecord | None: + return self.records.get((request.user_id, request.date_str)) + + async def save_fortune(self, record: FortuneRecord) -> bool: + self.records[(record.user_id, record.date_str)] = record + return True + + async def delete_fortune(self, user_id: str, date_str: str) -> bool: + self.records.pop((user_id, date_str), None) + return True + + async def delete_group_fortunes(self, group_id: str, date_str: str) -> int: + return 0 + + async def delete_all_fortunes(self, date_str: str) -> int: + return 0 + + async def get_active_users(self, days: int = 3) -> list[str]: + return [request.user_id for request in self.active_requests] + + async def get_active_fortune_requests( + self, days: int = 3, date_str: str | None = None + ) -> list[FortuneGenerationRequest]: + target_date = date_str or date.today().isoformat() + return [ + FortuneGenerationRequest( + user_id=request.user_id, + username=request.username, + date_str=target_date, + group_id=request.group_id, + ) + for request in self.active_requests + ] + + async def get_cached_image_path( + self, user_id: str, date_str: str + ) -> Path | None: + return None + + async def save_cached_image( + self, user_id: str, date_str: str, image_data: bytes, img_url: str | None + ) -> Path: + self.cached_images[(user_id, date_str)] = image_data + record = self.records[(user_id, date_str)] + self.records[(user_id, date_str)] = record.with_image_cache(img_url) + return Path(f"/tmp/{user_id}_{date_str}.jpg") + + async def delete_cached_image(self, user_id: str, date_str: str) -> bool: + self.cached_images.pop((user_id, date_str), None) + return True + + async def cleanup_expired_cache(self, date_str: str) -> int: + return 0 + + +@pytest.mark.asyncio +async def test_pregenerate_active_fortune_images_writes_rendered_cache( + monkeypatch, +) -> None: + today = date.today().isoformat() + repo = MemoryFortuneRepo( + [ + FortuneGenerationRequest( + user_id="user-1", + username="测试用户", + date_str="2026-05-17", + group_id="group-1", + ) + ] + ) + handler = FortuneCommandHandler() + + async def fake_background_image() -> tuple[bytes, str]: + return b"background", "https://example.com/bg.jpg" + + async def fake_render_to_image(*args, **kwargs) -> bytes: + return b"rendered-card" + + monkeypatch.setattr( + fortune_cmd, + "get_config", + lambda: SimpleNamespace( + fortune=SimpleNamespace(enabled=True, auto_refresh=True) + ), + ) + monkeypatch.setattr(fortune_cmd, "get_fortune_repo", lambda: repo) + monkeypatch.setattr(handler, "_get_fortune_background_image", fake_background_image) + monkeypatch.setattr(handler._renderer, "render_to_image", fake_render_to_image) + + cached_count = await handler.pregenerate_active_fortune_images() + + assert cached_count == 1 + assert repo.cached_images[("user-1", today)] == b"rendered-card" + record = repo.records[("user-1", today)] + assert record.username == "测试用户" + assert record.group_id == "group-1" + assert record.image_cached is True + assert record.img_url == "https://example.com/bg.jpg" + + +@pytest.mark.asyncio +async def test_pregenerate_active_fortune_images_respects_auto_refresh( + monkeypatch, +) -> None: + repo = MemoryFortuneRepo( + [FortuneGenerationRequest("user-1", "测试用户", "2026-05-17")] + ) + handler = FortuneCommandHandler() + + monkeypatch.setattr( + fortune_cmd, + "get_config", + lambda: SimpleNamespace( + fortune=SimpleNamespace(enabled=True, auto_refresh=False) + ), + ) + monkeypatch.setattr(fortune_cmd, "get_fortune_repo", lambda: repo) + + assert await handler.pregenerate_active_fortune_images() == 0 + assert repo.records == {} + assert repo.cached_images == {} + + +@pytest.mark.asyncio +async def test_sqlite_repo_builds_active_generation_requests(temp_data_dir) -> None: + today = date.today().isoformat() + target_date = "2099-01-02" + repo = SQLiteFortuneRepo(temp_data_dir / "fortune") + await repo.initialize() + await repo.save_fortune( + FortuneRecord.create_new( + user_id="user-1", + username="测试用户", + date_str=today, + title="大吉", + star_count=6, + description="今日顺利", + extra_message="", + theme_color="theme-red", + group_id="group-1", + ) + ) + + requests = await repo.get_active_fortune_requests(days=3, date_str=target_date) + + assert requests == [ + FortuneGenerationRequest( + user_id="user-1", + username="测试用户", + date_str=target_date, + group_id="group-1", + ) + ] From 28fc58134dde492dbb8d7cd4eaca687d81f9c3ba Mon Sep 17 00:00:00 2001 From: FlanChanXwO Date: Mon, 18 May 2026 16:07:26 +0800 Subject: [PATCH 2/6] test: pin astrbot root during plugin tests --- .github/workflow/release-from-changelog.yml | 211 -------------------- tests/conftest.py | 10 +- 2 files changed, 8 insertions(+), 213 deletions(-) delete mode 100644 .github/workflow/release-from-changelog.yml diff --git a/.github/workflow/release-from-changelog.yml b/.github/workflow/release-from-changelog.yml deleted file mode 100644 index 04c339c..0000000 --- a/.github/workflow/release-from-changelog.yml +++ /dev/null @@ -1,211 +0,0 @@ -name: Release From Changelog - -on: - push: - branches: - - master - - main - paths: - - data/plugins/**/CHANGELOG.md - - data/plugins/**/changelog.md - workflow_dispatch: - inputs: - changelog_path: - description: "Optional single changelog path (e.g. data/plugins/foo/CHANGELOG.md)" - required: false - default: "" - tag_prefix: - description: "Optional tag prefix override. Empty = -v" - required: false - default: "" - -permissions: - contents: write - -concurrency: - group: release-from-changelog-${{ github.ref }} - cancel-in-progress: false - -jobs: - prepare: - runs-on: ubuntu-latest - outputs: - changelog_files: ${{ steps.collect.outputs.changelog_files }} - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Collect changelog files - id: collect - shell: bash - run: | - set -euo pipefail - - if [ -n "${{ inputs.changelog_path }}" ]; then - file="${{ inputs.changelog_path }}" - if [ ! -f "$file" ]; then - echo "Input changelog path does not exist: $file" >&2 - exit 1 - fi - python - <<'PY' -import json -import os - -file_path = os.environ["FILE_PATH"] -with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as fh: - fh.write("changelog_files=" + json.dumps([file_path]) + "\n") -PY - else - before="${{ github.event.before }}" - after="${{ github.sha }}" - - if [ -z "$before" ] || [ "$before" = "0000000000000000000000000000000000000000" ]; then - changed_files="$(git --no-pager ls-files "data/plugins/*/CHANGELOG.md" "data/plugins/*/changelog.md")" - else - changed_files="$(git --no-pager diff --name-only "$before" "$after")" - fi - - printf '%s\n' "$changed_files" \ - | grep -E '^data/plugins/[^/]+/(CHANGELOG\.md|changelog\.md)$' > _changed_changelogs.txt || true - - python - <<'PY' -import json -import os -from pathlib import Path - -items = [line.strip() for line in Path("_changed_changelogs.txt").read_text(encoding="utf-8").splitlines() if line.strip()] -seen = set() -result = [] -for item in items: - if item not in seen: - seen.add(item) - result.append(item) - -with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as fh: - fh.write("changelog_files=" + json.dumps(result) + "\n") -PY - fi - env: - FILE_PATH: ${{ inputs.changelog_path }} - - release: - runs-on: ubuntu-latest - needs: prepare - if: needs.prepare.outputs.changelog_files != '[]' && needs.prepare.outputs.changelog_files != '' - - strategy: - fail-fast: false - matrix: - changelog: ${{ fromJson(needs.prepare.outputs.changelog_files) }} - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Parse version and notes - id: parse - shell: bash - run: | - python - <<'PY' -import os -import pathlib -import re -import sys - -changelog_path = pathlib.Path("${{ matrix.changelog }}") -if not changelog_path.exists(): - print(f"Missing changelog: {changelog_path}", file=sys.stderr) - sys.exit(1) - -parts = changelog_path.parts -if len(parts) < 3 or parts[0] != "data" or parts[1] != "plugins": - print(f"Unsupported changelog path: {changelog_path}", file=sys.stderr) - sys.exit(1) -plugin_name = parts[2] - -text = changelog_path.read_text(encoding="utf-8") -match = re.search(r"^##\s*\[?v?(\d+\.\d+\.\d+(?:[-+][^\]\s]+)?)\]?.*$", text, re.MULTILINE) -if not match: - print("No version heading like '## [1.2.3]' found.", file=sys.stderr) - sys.exit(1) - -version = match.group(1) -start = match.start() -next_match = re.search(r"^##\s+", text[match.end():], re.MULTILINE) -if next_match: - end = match.end() + next_match.start() - notes = text[start:end].strip() -else: - notes = text[start:].strip() - -prefix_input = "${{ inputs.tag_prefix }}".strip() -if prefix_input: - tag = f"{prefix_input}{version}" -else: - tag = f"{plugin_name}-v{version}" - -notes_file = pathlib.Path(f"release-notes-{plugin_name}.md") -notes_file.write_text(notes + "\n", encoding="utf-8") - -with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as fh: - fh.write(f"plugin={plugin_name}\n") - fh.write(f"version={version}\n") - fh.write(f"tag={tag}\n") - fh.write(f"notes_file={notes_file.as_posix()}\n") -PY - - - name: Check existing tag/release - id: dedupe - env: - GH_TOKEN: ${{ github.token }} - shell: bash - run: | - tag="${{ steps.parse.outputs.tag }}" - skip="false" - reason="" - - if gh api "repos/${{ github.repository }}/git/ref/tags/${tag}" >/dev/null 2>&1; then - skip="true" - reason="tag '$tag' already exists" - fi - - if gh release view "$tag" >/dev/null 2>&1; then - skip="true" - if [ -z "$reason" ]; then - reason="release '$tag' already exists" - else - reason="$reason; release '$tag' already exists" - fi - fi - - echo "skip=$skip" >> "$GITHUB_OUTPUT" - echo "reason=$reason" >> "$GITHUB_OUTPUT" - - - name: Skip summary - if: steps.dedupe.outputs.skip == 'true' - shell: bash - run: | - echo "Skip creating release for ${{ steps.parse.outputs.plugin }}: ${{ steps.dedupe.outputs.reason }}" - - - name: Create release and tag - if: steps.dedupe.outputs.skip != 'true' - env: - GH_TOKEN: ${{ github.token }} - shell: bash - run: | - tag="${{ steps.parse.outputs.tag }}" - gh release create "$tag" \ - --target "$GITHUB_SHA" \ - --title "$tag" \ - --notes-file "${{ steps.parse.outputs.notes_file }}" - - - name: Release summary - if: steps.dedupe.outputs.skip != 'true' - shell: bash - run: | - echo "Created release ${{ steps.parse.outputs.tag }} from ${{ matrix.changelog }}" diff --git a/tests/conftest.py b/tests/conftest.py index 4f8bf5c..fab3d93 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,14 +2,20 @@ from __future__ import annotations +import os from pathlib import Path from typing import Any from unittest.mock import MagicMock import pytest -from astrbot.api.event import AstrMessageEvent -from astrbot.core import AstrBotConfig +for parent in Path(__file__).resolve().parents: + if (parent / "astrbot" / "core" / "__init__.py").exists(): + os.environ.setdefault("ASTRBOT_ROOT", str(parent)) + break + +from astrbot.api.event import AstrMessageEvent # noqa: E402 +from astrbot.core import AstrBotConfig # noqa: E402 @pytest.fixture From 3a24f5113cd317df98efd5e3d82421e2dc0359f1 Mon Sep 17 00:00:00 2001 From: FlanChanXwO Date: Mon, 18 May 2026 16:27:06 +0800 Subject: [PATCH 3/6] chore(release): prepare v2.0.2 --- AGENTS.md | 218 +++++++++++++++++++++++++++++++++++++++++++++----- CHANGELOG.md | 10 ++- metadata.yaml | 2 +- 3 files changed, 206 insertions(+), 24 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e8c87a1..9763245 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,39 +1,213 @@ -# Repository Guidelines +# AGENTS.md - astrbot_plugin_setu + +AstrBot plugin for Setu image delivery, session overrides, access control, and +fortune cards backed by image providers. + +## Communication Language + +Must communicate with the user in Chinese (中文). Keep engineering updates concise +and grounded in local code. + +## Project Overview + +- **Language**: Python 3.10+ compatible code style with `from __future__ import annotations` +- **Framework**: AstrBot v4.24.x plugin system +- **Architecture**: DDD-style layered `src/` package +- **Runtime features**: random image fetching, provider proxy rewrite, send-mode fallback, + Setu/Fortune access control, per-session config overrides, Fortune card rendering +- **Current release focus**: v2.0.2 restores Fortune daily pre-rendered image cache and + prevents AstrBot core test runtime data from being written under the plugin directory + +## Required Skills + +Use `$skill-astrbot-dev` for AstrBot plugin structure, decorators/hooks, lifecycle, +config schema, message flow, platform adapters, HTML rendering, and LLM tools. If +docs and source disagree, trust source. + +Use `$github` for issues, PRs, CI runs, and advanced repository queries through +`gh`. + +## Directory Structure + +```text +main.py # AstrBot Star entrypoint and command registration +metadata.yaml # Plugin metadata and release version +_conf_schema.json # AstrBot config UI schema +CHANGELOG.md # Release changelog +README.md # User-facing usage docs +AGENTS.md # Agent-facing repository guide +src/ + application/ + ports/ # Repository/provider interfaces + session_config/ # Session override DTOs, keys, service + setu/ # Setu use cases and DTOs + settings.py # Config snapshot access for application layer + domain/ + access_control/ # Access policy and decision service + fortune/ # Fortune entities, generation service, value objects + setu/ # Setu request/tag domain objects + infrastructure/ + astrbot/ # AstrBot adapters, command handlers, renderer, Web API + persistence/ # JSON/SQLite repositories + providers/ # Lolicon, Atri, custom, multi-provider adapters + sending/ # Image sender, strategies, NapCat stream upload + shared/ + config/ # Pydantic config models and message defaults + logging.py # Plugin logger wrapper + send_cache.py # Stable send cache before adapter delivery +pages/ + sessionConfig/ # WebUI page for session overrides +templates/ + fortune.html # Legacy-compatible Fortune card template + res/fonts/ # Embedded Fortune card fonts +tests/ # pytest + pytest-asyncio tests +skills/ # Plugin-specific Codex/AstrBot skills +``` -## Project Structure & Module Organization +## Key Conventions -This is an AstrBot plugin. `main.py` centralizes AstrBot command registration. `src/` has four top-level packages only: `application/` for use cases and ports; `domain/` for Setu, fortune, and access-control rules; `infrastructure/` for AstrBot adapters, providers, persistence, permissions, and sending; and `shared/` for helpers and config models. Tests live in `tests/`, WebUI pages in `pages/`, and plugin skills in `skills/`. +### Main Entry -## Agent Skills & Tooling +`main.py` should stay focused on AstrBot registration, singleton initialization, +and forwarding into infrastructure command handlers. Keep reusable orchestration in +`application/` or `infrastructure/` modules. -Use `$skill-astrbot-dev` for AstrBot structure, decorators/hooks, lifecycle, config schema, message flow, platform adapters, and LLM tools. If docs and source disagree, trust source. Use `$github` for issues, PRs, CI, and advanced repository queries through `gh`. +Command handlers must be `async def` and yield AstrBot results. Prefer +`event.plain_result(...)` and `event.chain_result(...)`; do not assume every event +has `event.result(...)`. -## Build, Test, and Development Commands +### Setu Flow + +Setu fetching is routed through `GetSetuImagesUseCase`, provider ports, and +`ImageSender`. Empty payloads should not carry hardcoded use-case notices; command +handlers resolve configurable messages instead. + +Provider behavior belongs in `src/infrastructure/providers/`. Pixiv proxy rewrite +and provider diagnostics should stay observable through structured logs. + +### Fortune Flow + +`FortuneCommandHandler` owns AstrBot-specific Fortune behavior. `FortuneService` +owns domain generation and repository-backed record lifecycle only. + +Fortune card rendering is image-first: + +- `fortune_command` gets or creates today's `FortuneRecord` +- `_render_fortune_image()` reuses cached card bytes when present +- otherwise it fetches a background image, renders `templates/fortune.html`, and + saves the rendered card through `FortuneService.update_image_cache()` +- fallback to plain text is allowed only when background fetching or rendering fails -- `python -m pip install -e ".[dev]"`: install the plugin with dev tools. -- `python -m pip install -U astrbot`: refresh the AstrBot SDK for local signatures. -- `PYTHONPATH=/path/to/data/plugins python -m pytest`: run the full test suite. -- `python -m pytest tests/domain`: run focused domain tests. -- `python -m ruff check .`: lint Python code. -- `python -m ruff format .`: format Python files. -- `python -m py_compile main.py src/**/*.py tests/**/*.py`: syntax check. +`fortune.auto_refresh` means recently active users should be handled after day +rollover. v2.0.2 behavior is to pre-generate records and cache rendered card images, +not just write database rows. -## Ruff Tooling +### Message Configuration -Ruff settings come from repository config. The project targets Python 3.10, uses 100-character lines, and sorts `astrbot` and `astrbot_plugin_setu` as first-party imports. If the parent cache is not writable, use `RUFF_CACHE_DIR=.ruff_cache`. +All user-facing prompts should go through `MessagesConfig` / `MessageTextConfig` +and `resolve_message()`. Avoid handler-local hardcoded fallback dictionaries; use a +minimal generic fallback only when config is unavailable. -## Coding Style & Naming Conventions +When adding a message key, keep these files in sync: + +- `_conf_schema.json` +- `src/shared/config/models.py` +- focused tests under `tests/infrastructure/` or `tests/shared/` + +### Runtime Data -Use 4-space indentation, type hints, and `from __future__ import annotations` in new Python modules. Use `snake_case` for modules/functions, `PascalCase` for classes, and `UPPER_SNAKE_CASE` for constants. Handlers, hooks, and tool functions should be `async def`. Keep `main.py` focused on registration and forwarding; keep reusable orchestration in `application/`. +Do not write runtime files into the plugin source directory. Use +`StarTools.get_data_dir(self.name)` in plugin runtime and temp pytest directories in +tests. + +`tests/conftest.py` pins `ASTRBOT_ROOT` before importing `astrbot.core`, because +AstrBot defaults its root to `os.getcwd()` and otherwise creates +`data/cmd_config.json` and `data/t2i_templates/` under the plugin checkout. + +Never commit local runtime artifacts such as: + +- `data/` +- `assets/*.png` generated during manual checks +- downloaded image caches +- local AstrBot runtime config + +### WebUI + +WebUI pages live under `pages//index.html`. The current session config +page uses plain JS and AstrBot's injected bridge. APIs are registered through +`context.register_web_api(...)` from `src/infrastructure/astrbot/session_config_api.py`. + +### GitHub Actions + +Workflow files must live under `.github/workflows/`. Do not add workflow files under +`.github/workflow/`; GitHub Actions will not load that path. + +## Build, Test, and Development Commands + +```bash +python -m pip install -e ".[dev]" +python -m pip install -U astrbot +PYTHONPATH=/path/to/data/plugins python -m pytest +PYTHONPATH=/path/to/data/plugins python -m pytest tests/infrastructure/test_fortune_pregeneration.py -q +RUFF_CACHE_DIR=.ruff_cache python -m ruff check . +python -m ruff format . +python -m py_compile main.py src/**/*.py tests/**/*.py +``` + +If parent cache directories are not writable, set `RUFF_CACHE_DIR=.ruff_cache`. ## Testing Guidelines -Use pytest and pytest-asyncio. Name files `test_*.py`, classes `TestFeatureName`, and methods `test_behavior`. Reuse fixtures from `tests/conftest.py` for AstrBot config, events, providers, and temp data directories. Add focused unit tests for domain and infrastructure changes. +Use pytest and pytest-asyncio. Name files `test_*.py`, classes `TestFeatureName`, +and methods `test_behavior`. + +Reuse fixtures from `tests/conftest.py` for AstrBot config, events, providers, and +temporary data directories. Add focused tests when touching: + +- provider behavior and URL rewriting +- sender fallback logic +- message config keys and placeholder rendering +- Fortune record/cache lifecycle +- SQLite migrations or repository queries +- `main.py` command routing and trigger de-duplication + +## Code Rules + +- Use 4-space indentation and type hints. +- Use `snake_case` for functions/modules, `PascalCase` for classes, and + `UPPER_SNAKE_CASE` for constants. +- New Python modules should start with a module docstring when useful, then + `from __future__ import annotations`. +- Logger formatting should use `%s` placeholders, not `{}` interpolation. +- Keep comments rare and useful; explain non-obvious behavior, not line-by-line actions. +- Do not add external dependencies without updating `requirements.txt` and documenting why. + +## Release Rules + +Use semantic versioning and keep release files synchronized: + +- Patch bump: fixes, compatibility adjustments, tests/tooling corrections +- Minor bump: new user-facing features, new config fields, new APIs or WebUI features +- Major bump: breaking config/data/API changes or migrations requiring manual action + +For every release bump: + +- update `metadata.yaml` `version:` +- add a new top `CHANGELOG.md` section +- keep `_conf_schema.json`, `src/shared/config/models.py`, README examples, and tests + synchronized when config changes + +## Commit and PR Guidelines -## Commit & Pull Request Guidelines +Recent history follows Conventional Commits, for example `fix(fortune): ...`, +`feat(safety): ...`, `refactor: ...`, `docs: ...`, and `chore: ...`. -Recent history mostly follows Conventional Commits, for example `feat(safety): ...`, `fix: ...`, `refactor: ...`, `docs: ...`, and `chore: ...`. Keep subjects imperative and scoped when useful. PRs should explain motivation, summarize core file changes, identify breaking changes, and include verification output or screenshots. +Keep PRs focused. Include: -## Security & Configuration Tips +- motivation and user-visible behavior +- core files changed +- tests or checks run +- migration notes if config, database, or runtime data behavior changes -Do not commit secrets, tokens, local AstrBot runtime data, or downloaded image caches. Session overrides are runtime data; keep fixtures under `tests/`. Keep `_conf_schema.json`, `src/shared/config/models.py`, `metadata.yaml`, `requirements.txt`, and README examples in sync when adding settings or dependencies. +Do not include unrelated working-tree changes, generated caches, local AstrBot data, +or downloaded images in commits. diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fd8999..6b79a2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [2.0.2] - 2026-05-18 + +### Fixed +- **恢复运势自动预缓存**:启用 `auto_refresh` 时,近期使用过 `jrys` 的用户会在跨日后预生成并缓存运势卡片图片 +- **修复测试目录污染**:测试初始化阶段固定 `ASTRBOT_ROOT`,避免 AstrBot 核心运行时数据写入插件目录下的 `data/` + +### Changed +- **清理无效工作流路径**:移除 `.github/workflow/` 下的旧工作流文件,保留 GitHub Actions 标准目录 `.github/workflows/` + ## [2.0.1] - 2026-05-18 ### Added @@ -15,7 +24,6 @@ - **修复部分提示无法关闭**:`fetching`、`found`、`send_failed` 及新增 fortune 提示项均可独立关闭 - **恢复今日运势卡片样式**:重新接回旧版模板、字体资源与渲染管线,避免截图卡片退化为简化样式 - **修复私聊运势重复触发**:`jrys/今日运势` 在多命令前缀场景下改为基于命令唤醒状态去重,避免 regex 与 command 同时响应 -- **恢复运势自动预缓存**:启用 `auto_refresh` 时,近期使用过 `jrys` 的用户会在跨日后预生成并缓存运势卡片图片 ## [2.0.0] - 2026-05-17 diff --git a/metadata.yaml b/metadata.yaml index 675eab1..190264d 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -1,6 +1,6 @@ name: astrbot_plugin_setu display_name: 瑟瑟! -version: v2.0.1 +version: v2.0.2 author: FlanChanXwO desc: 随机福利图插件,支持标签与数量,以及图片分级控制,可以针对于特定平台配置概率绕过审核的发送方式。 repo: https://github.com/FlanChanXwO/astrbot_plugin_setu From 57e7421a4712cf14885b711b800f29d6ec5ae2c9 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 08:33:15 +0000 Subject: [PATCH 4/6] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- main.py | 4 +++- tests/infrastructure/test_fortune_pregeneration.py | 4 +--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/main.py b/main.py index fe79f16..dc03a4e 100644 --- a/main.py +++ b/main.py @@ -290,7 +290,9 @@ async def _pregenerate_active_fortune_images(self) -> None: else: logger.debug("[fortune] No fortune card caches pregenerated") except Exception as exc: - logger.warning("[fortune] Failed to pregenerate fortune card caches: %s", exc) + logger.warning( + "[fortune] Failed to pregenerate fortune card caches: %s", exc + ) def _runtime_plugin_config(self) -> dict[str, Any]: """Return the plugin-scoped config dict passed in by AstrBot.""" diff --git a/tests/infrastructure/test_fortune_pregeneration.py b/tests/infrastructure/test_fortune_pregeneration.py index bbf7174..723b9a8 100644 --- a/tests/infrastructure/test_fortune_pregeneration.py +++ b/tests/infrastructure/test_fortune_pregeneration.py @@ -62,9 +62,7 @@ async def get_active_fortune_requests( for request in self.active_requests ] - async def get_cached_image_path( - self, user_id: str, date_str: str - ) -> Path | None: + async def get_cached_image_path(self, user_id: str, date_str: str) -> Path | None: return None async def save_cached_image( From cc0b7e1e01f4f617cf1483f519ff6ad055b1e639 Mon Sep 17 00:00:00 2001 From: FlanChanXwO Date: Mon, 18 May 2026 17:25:35 +0800 Subject: [PATCH 5/6] fix(fortune): isolate pregeneration cache failures --- .../astrbot/commands/fortune.py | 20 +++-- .../test_fortune_pregeneration.py | 87 ++++++++++++++++++- 2 files changed, 100 insertions(+), 7 deletions(-) diff --git a/src/infrastructure/astrbot/commands/fortune.py b/src/infrastructure/astrbot/commands/fortune.py index 86edfe6..1e80384 100644 --- a/src/infrastructure/astrbot/commands/fortune.py +++ b/src/infrastructure/astrbot/commands/fortune.py @@ -365,11 +365,21 @@ async def pregenerate_active_fortune_images(self, days: int = 3) -> int: cached_count = 0 for record in records: - if await service.get_cached_image(record.user_id, record.date_str): - continue - image_bytes = await self._render_fortune_image(record, service) - if image_bytes: - cached_count += 1 + try: + cached_path = await repo.get_cached_image_path( + record.user_id, record.date_str + ) + if cached_path: + continue + image_bytes = await self._render_fortune_image(record, service) + if image_bytes: + cached_count += 1 + except Exception as exc: + logger.warning( + "[fortune] Failed to pregenerate cache for %s: %s", + record.user_id, + exc, + ) return cached_count # ==================== Helper Methods ==================== diff --git a/tests/infrastructure/test_fortune_pregeneration.py b/tests/infrastructure/test_fortune_pregeneration.py index 723b9a8..e078ee8 100644 --- a/tests/infrastructure/test_fortune_pregeneration.py +++ b/tests/infrastructure/test_fortune_pregeneration.py @@ -25,6 +25,7 @@ def __init__(self, active_requests: list[FortuneGenerationRequest]) -> None: self.active_requests = active_requests self.records: dict[tuple[str, str], FortuneRecord] = {} self.cached_images: dict[tuple[str, str], bytes] = {} + self.cached_paths: dict[tuple[str, str], Path] = {} async def get_today_fortune( self, request: FortuneGenerationRequest @@ -62,8 +63,10 @@ async def get_active_fortune_requests( for request in self.active_requests ] - async def get_cached_image_path(self, user_id: str, date_str: str) -> Path | None: - return None + async def get_cached_image_path( + self, user_id: str, date_str: str + ) -> Path | None: + return self.cached_paths.get((user_id, date_str)) async def save_cached_image( self, user_id: str, date_str: str, image_data: bytes, img_url: str | None @@ -126,6 +129,86 @@ async def fake_render_to_image(*args, **kwargs) -> bytes: assert record.img_url == "https://example.com/bg.jpg" +@pytest.mark.asyncio +async def test_pregenerate_active_fortune_images_skips_existing_cache( + monkeypatch, +) -> None: + today = date.today().isoformat() + repo = MemoryFortuneRepo( + [ + FortuneGenerationRequest( + user_id="user-1", + username="测试用户", + date_str="2026-05-17", + group_id="group-1", + ) + ] + ) + repo.cached_paths[("user-1", today)] = Path(f"/tmp/user-1_{today}.jpg") + handler = FortuneCommandHandler() + render_call_count = 0 + + async def fake_render_to_image(*args, **kwargs) -> bytes: + nonlocal render_call_count + render_call_count += 1 + return b"rendered-card" + + monkeypatch.setattr( + fortune_cmd, + "get_config", + lambda: SimpleNamespace( + fortune=SimpleNamespace(enabled=True, auto_refresh=True) + ), + ) + monkeypatch.setattr(fortune_cmd, "get_fortune_repo", lambda: repo) + monkeypatch.setattr(handler._renderer, "render_to_image", fake_render_to_image) + + cached_count = await handler.pregenerate_active_fortune_images() + + assert cached_count == 0 + assert render_call_count == 0 + assert repo.cached_images == {} + + +@pytest.mark.asyncio +async def test_pregenerate_active_fortune_images_continues_after_record_error( + monkeypatch, +) -> None: + today = date.today().isoformat() + repo = MemoryFortuneRepo( + [ + FortuneGenerationRequest("bad-user", "坏数据", "2026-05-17"), + FortuneGenerationRequest("good-user", "测试用户", "2026-05-17"), + ] + ) + handler = FortuneCommandHandler() + + async def fake_background_image() -> tuple[bytes, str]: + return b"background", "https://example.com/bg.jpg" + + async def fake_render_to_image(fortune, *args, **kwargs) -> bytes: + if fortune["username"] == "坏数据": + raise RuntimeError("boom") + return b"rendered-card" + + monkeypatch.setattr( + fortune_cmd, + "get_config", + lambda: SimpleNamespace( + fortune=SimpleNamespace(enabled=True, auto_refresh=True) + ), + ) + monkeypatch.setattr(fortune_cmd, "get_fortune_repo", lambda: repo) + monkeypatch.setattr(handler, "_get_fortune_background_image", fake_background_image) + monkeypatch.setattr(handler._renderer, "render_to_image", fake_render_to_image) + + cached_count = await handler.pregenerate_active_fortune_images() + + assert cached_count == 1 + assert ("bad-user", today) not in repo.cached_images + assert repo.cached_images[("good-user", today)] == b"rendered-card" + + @pytest.mark.asyncio async def test_pregenerate_active_fortune_images_respects_auto_refresh( monkeypatch, From 80cd3ec54bee2a71326ba3d507a408223197f9c2 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 09:46:13 +0000 Subject: [PATCH 6/6] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/infrastructure/test_fortune_pregeneration.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/infrastructure/test_fortune_pregeneration.py b/tests/infrastructure/test_fortune_pregeneration.py index e078ee8..de40241 100644 --- a/tests/infrastructure/test_fortune_pregeneration.py +++ b/tests/infrastructure/test_fortune_pregeneration.py @@ -63,9 +63,7 @@ async def get_active_fortune_requests( for request in self.active_requests ] - async def get_cached_image_path( - self, user_id: str, date_str: str - ) -> Path | None: + async def get_cached_image_path(self, user_id: str, date_str: str) -> Path | None: return self.cached_paths.get((user_id, date_str)) async def save_cached_image(