diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52c7735c1..47e72d469 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,12 +8,16 @@ on: jobs: check: runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write steps: - name: Checkout code uses: actions/checkout@v7 with: ref: ${{ github.head_ref }} fetch-depth: 0 + persist-credentials: false - name: Set up Python uses: actions/setup-python@v6 @@ -44,13 +48,15 @@ jobs: poetry run nb larkhelp-generate zh_hans COMMANDS.md - name: Commit and push changes + env: + HEAD_REF: ${{ github.head_ref }} run: | git add . if ! git diff --cached --quiet; then git config --global user.name "github-actions[bot]" git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" git commit -m "Auto update from GitHub Actions" - git push origin HEAD:${{ github.head_ref }} + git push origin "HEAD:$HEAD_REF" else echo "No changes to commit." fi diff --git a/migrations/versions/878331db0e57_add_wakeuprank_risedata_table.py b/migrations/versions/878331db0e57_add_wakeuprank_risedata_table.py index 83b5a9ad4..16a453df1 100644 --- a/migrations/versions/878331db0e57_add_wakeuprank_risedata_table.py +++ b/migrations/versions/878331db0e57_add_wakeuprank_risedata_table.py @@ -28,10 +28,10 @@ def upgrade(name: str = "") -> None: bind = op.get_bind() dialect = bind.dialect.name if dialect == "mysql": - table_check = f"SHOW TABLES LIKE '{TABLE_NAME}'" + table_check = sa.text("SHOW TABLES LIKE :table_name") else: - table_check = f"SELECT name FROM sqlite_master WHERE type='table' AND name='{TABLE_NAME}'" - result = bind.execute(sa.text(table_check)).fetchall() + table_check = sa.text("SELECT name FROM sqlite_master WHERE type='table' AND name=:table_name") + result = bind.execute(table_check, {"table_name": TABLE_NAME}).fetchall() if result: return diff --git a/migrations/versions/b4c5d6e7f8a9_add_diarypost_table.py b/migrations/versions/b4c5d6e7f8a9_add_diarypost_table.py index e48935532..6bcfcb46a 100644 --- a/migrations/versions/b4c5d6e7f8a9_add_diarypost_table.py +++ b/migrations/versions/b4c5d6e7f8a9_add_diarypost_table.py @@ -12,6 +12,15 @@ from alembic import op import sqlalchemy as sa +from sqlalchemy import ( + bindparam, + column, + delete as sa_delete, + func, + insert as sa_insert, + select, + table, +) # revision identifiers, used by Alembic. revision: str = "b4c5d6e7f8a9" @@ -31,10 +40,10 @@ def upgrade(name: str = "") -> None: bind = op.get_bind() dialect = bind.dialect.name if dialect == "mysql": - table_check = f"SHOW TABLES LIKE '{DIARYPOST_TABLE}'" + table_check = sa.text("SHOW TABLES LIKE :table_name") else: - table_check = f"SELECT name FROM sqlite_master WHERE type='table' AND name='{DIARYPOST_TABLE}'" - result = bind.execute(sa.text(table_check)).fetchall() + table_check = sa.text("SELECT name FROM sqlite_master WHERE type='table' AND name=:table_name") + result = bind.execute(table_check, {"table_name": DIARYPOST_TABLE}).fetchall() if result: # 表已存在(之前迁移部分执行过),跳过 return @@ -55,23 +64,37 @@ def upgrade(name: str = "") -> None: # 2. 将 note 表中 context_id='moonlark_diary' 的数据迁移到 diarypost # created_time 是 Float 类型的 Unix 时间戳,需要转换为 DateTime # MySQL 用 FROM_UNIXTIME(),SQLite 用 datetime(..., 'unixepoch', 'localtime') + diarypost = table( + DIARYPOST_TABLE, + column("content"), + column("keywords"), + column("created_at"), + column("expire_at"), + ) + note = table( + NOTE_TABLE, + column("content"), + column("keywords"), + column("created_time"), + column("expire_time"), + column("context_id"), + ) if dialect == "mysql": - created_at_expr = "FROM_UNIXTIME(created_time)" + created_at_col = func.from_unixtime(note.c.created_time) else: - created_at_expr = "datetime(created_time, 'unixepoch', 'localtime')" - op.execute(f""" - INSERT INTO {DIARYPOST_TABLE} (content, keywords, created_at, expire_at) - SELECT - content, - COALESCE(keywords, ''), - {created_at_expr}, - expire_time - FROM {NOTE_TABLE} - WHERE context_id = '{DIARY_CONTEXT_ID}' - """) + created_at_col = func.datetime(note.c.created_time, "unixepoch", "localtime") + select_stmt = select(note.c.content, func.coalesce(note.c.keywords, ""), created_at_col, note.c.expire_time).where( + note.c.context_id == bindparam("context_id") + ) + insert_stmt = sa_insert(diarypost).from_select( + ["content", "keywords", "created_at", "expire_at"], + select_stmt, + ) + bind.execute(insert_stmt, {"context_id": DIARY_CONTEXT_ID}) # 3. 删除已迁移的 note 记录 - op.execute(f"DELETE FROM {NOTE_TABLE} WHERE context_id = '{DIARY_CONTEXT_ID}'") + delete_stmt = sa_delete(note).where(note.c.context_id == bindparam("context_id")) + bind.execute(delete_stmt, {"context_id": DIARY_CONTEXT_ID}) def downgrade(name: str = "") -> None: diff --git a/migrations/versions/c3d4e5f6a7b8_add_timer_table.py b/migrations/versions/c3d4e5f6a7b8_add_timer_table.py index a770b4f63..4111c14a9 100644 --- a/migrations/versions/c3d4e5f6a7b8_add_timer_table.py +++ b/migrations/versions/c3d4e5f6a7b8_add_timer_table.py @@ -28,10 +28,10 @@ def upgrade(name: str = "") -> None: bind = op.get_bind() dialect = bind.dialect.name if dialect == "mysql": - table_check = f"SHOW TABLES LIKE '{TABLE_NAME}'" + table_check = sa.text("SHOW TABLES LIKE :table_name") else: - table_check = f"SELECT name FROM sqlite_master WHERE type='table' AND name='{TABLE_NAME}'" - result = bind.execute(sa.text(table_check)).fetchall() + table_check = sa.text("SELECT name FROM sqlite_master WHERE type='table' AND name=:table_name") + result = bind.execute(table_check, {"table_name": TABLE_NAME}).fetchall() if result: return diff --git a/src/plugins/nonebot_plugin_larkcave/commands/remove.py b/src/plugins/nonebot_plugin_larkcave/commands/remove.py index 605a9821a..3ad13745a 100644 --- a/src/plugins/nonebot_plugin_larkcave/commands/remove.py +++ b/src/plugins/nonebot_plugin_larkcave/commands/remove.py @@ -40,7 +40,7 @@ async def _( await lang.finish("remove_comment.no_result", user_id, comment_id) return if not (comment.author == user_id or is_superuser): - await lang.reply() + await lang.reply("remove_comment.no_permission", user_id) await cave.finish() await lang.send( "remove_comment.info", @@ -62,13 +62,12 @@ async def _( try: cave_data = await session.get_one(CaveData, {"id": cave_id}) except NoResultFound: - await lang.reply() - await cave.finish() + await lang.finish("remove.no_result", user_id, cave_id) if not (cave_data.author == user_id or is_superuser): - await lang.reply() + await lang.reply("remove.no_permission", user_id) await cave.finish() if not cave_data.public: - await lang.reply() + await lang.reply("remove.private", user_id, cave_id) await cave.finish() cave_data.public = False session.add( diff --git a/src/plugins/nonebot_plugin_larksetu/plugins/nonebot_plugin_seturank/__main__.py b/src/plugins/nonebot_plugin_larksetu/plugins/nonebot_plugin_seturank/__main__.py index f0d8d1944..9c49b1f3c 100644 --- a/src/plugins/nonebot_plugin_larksetu/plugins/nonebot_plugin_seturank/__main__.py +++ b/src/plugins/nonebot_plugin_larksetu/plugins/nonebot_plugin_seturank/__main__.py @@ -31,7 +31,7 @@ async def _(session: async_scoped_session, user_id: str = get_user_id()) -> None class SetuRanking(WebRanking): - async def get_sorted_data(self) -> list[RankingData]: + async def get_sorted_data(self, user_id: str) -> list[RankingData]: async with get_session() as session: result = ( (await session.execute(select(models.UserData).order_by(models.UserData.count.desc()))).scalars().all() diff --git a/src/plugins/nonebot_plugin_quick_math/utils/ranking.py b/src/plugins/nonebot_plugin_quick_math/utils/ranking.py index c09ff1f37..64638785d 100644 --- a/src/plugins/nonebot_plugin_quick_math/utils/ranking.py +++ b/src/plugins/nonebot_plugin_quick_math/utils/ranking.py @@ -17,7 +17,7 @@ async def get_user_list(order_by: Any = QuickMathUser.max_point) -> AsyncGenerat class RecordRanking(WebRanking): - async def get_sorted_data(self) -> list[RankingData]: + async def get_sorted_data(self, user_id: str) -> list[RankingData]: return [ { "user_id": user.user_id, @@ -30,7 +30,7 @@ async def get_sorted_data(self) -> list[RankingData]: class TotalRanking(WebRanking): - async def get_sorted_data(self) -> list[RankingData]: + async def get_sorted_data(self, user_id: str) -> list[RankingData]: return [ { "user_id": user.user_id, diff --git a/src/plugins/nonebot_plugin_ranking/web.py b/src/plugins/nonebot_plugin_ranking/web.py index 5c8806d34..a3a7cc5e3 100644 --- a/src/plugins/nonebot_plugin_ranking/web.py +++ b/src/plugins/nonebot_plugin_ranking/web.py @@ -33,11 +33,7 @@ async def get_name(self, user_id: str) -> str: async def handle( self, request: Request, offset: int = 0, limit: int = 20, user_id: str = get_user_id("-1") ) -> RankingResponse: - try: - data = await self.get_sorted_data(user_id) - except TypeError: - # 兼容旧版 - data: list[RankingData] = await self.get_sorted_data() # type: ignore + data = await self.get_sorted_data(user_id) index = offset return { "me": await find_user(data, user_id), diff --git a/src/plugins/nonebot_plugin_version_manager/__main__.py b/src/plugins/nonebot_plugin_version_manager/__main__.py index 4689652b3..e3fc9b422 100644 --- a/src/plugins/nonebot_plugin_version_manager/__main__.py +++ b/src/plugins/nonebot_plugin_version_manager/__main__.py @@ -1,7 +1,6 @@ import asyncio import os import signal -import subprocess import sys import time from pathlib import Path @@ -26,38 +25,158 @@ version_cmd = on_alconna(version_alc, permission=SUPERUSER) -def run_command(cmd: list[str], cwd: Optional[Path] = None) -> tuple[int, str, str]: - """运行任意命令并返回结果""" - project_root = cwd or config.version_manager_project_root.resolve() +async def git_rev_parse_head(cwd: Path) -> tuple[int, str, str]: + try: + proc = await asyncio.create_subprocess_exec( + config.version_manager_git_path, + "rev-parse", + "--abbrev-ref", + "HEAD", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=cwd, + ) + stdout, stderr = await proc.communicate() + return proc.returncode or 0, stdout.decode(errors="ignore").strip(), stderr.decode(errors="ignore").strip() + except Exception as e: + return -1, "", str(e) + + +async def git_rev_parse_short(cwd: Path) -> tuple[int, str, str]: + try: + proc = await asyncio.create_subprocess_exec( + config.version_manager_git_path, + "rev-parse", + "--short", + "HEAD", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=cwd, + ) + stdout, stderr = await proc.communicate() + return proc.returncode or 0, stdout.decode(errors="ignore").strip(), stderr.decode(errors="ignore").strip() + except Exception as e: + return -1, "", str(e) + + +async def git_log_one(cwd: Path) -> tuple[int, str, str]: + try: + proc = await asyncio.create_subprocess_exec( + config.version_manager_git_path, + "log", + "-1", + "--format=%s", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=cwd, + ) + stdout, stderr = await proc.communicate() + return proc.returncode or 0, stdout.decode(errors="ignore").strip(), stderr.decode(errors="ignore").strip() + except Exception as e: + return -1, "", str(e) + + +async def git_status(cwd: Path) -> tuple[int, str, str]: + try: + proc = await asyncio.create_subprocess_exec( + config.version_manager_git_path, + "status", + "--porcelain", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=cwd, + ) + stdout, stderr = await proc.communicate() + return proc.returncode or 0, stdout.decode(errors="ignore").strip(), stderr.decode(errors="ignore").strip() + except Exception as e: + return -1, "", str(e) + + +async def git_pull(cwd: Path) -> tuple[int, str, str]: + try: + proc = await asyncio.create_subprocess_exec( + config.version_manager_git_path, + "pull", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=cwd, + ) + stdout, stderr = await proc.communicate() + return proc.returncode or 0, stdout.decode(errors="ignore").strip(), stderr.decode(errors="ignore").strip() + except Exception as e: + return -1, "", str(e) + +async def git_diff_poetry_lock(cwd: Path) -> tuple[int, str, str]: try: - result = subprocess.run( - cmd, cwd=project_root, capture_output=True, text=True, encoding="utf-8", errors="ignore" + proc = await asyncio.create_subprocess_exec( + config.version_manager_git_path, + "diff", + "--name-only", + "HEAD", + "origin/HEAD", + "--", + "poetry.lock", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=cwd, ) - return result.returncode, result.stdout.strip(), result.stderr.strip() + stdout, stderr = await proc.communicate() + return proc.returncode or 0, stdout.decode(errors="ignore").strip(), stderr.decode(errors="ignore").strip() except Exception as e: return -1, "", str(e) -def run_git_command(args: list[str], cwd: Optional[Path] = None) -> tuple[int, str, str]: - """运行 git 命令并返回结果""" - git_path = config.version_manager_git_path - return run_command([git_path] + args, cwd) +async def git_diff_migrations(cwd: Path) -> tuple[int, str, str]: + try: + proc = await asyncio.create_subprocess_exec( + config.version_manager_git_path, + "diff", + "--name-only", + "HEAD", + "origin/HEAD", + "--", + "migrations/versions/", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=cwd, + ) + stdout, stderr = await proc.communicate() + return proc.returncode or 0, stdout.decode(errors="ignore").strip(), stderr.decode(errors="ignore").strip() + except Exception as e: + return -1, "", str(e) -def run_nb_command(args: list[str], cwd: Optional[Path] = None) -> tuple[int, str, str]: - """运行 nb_cli 命令并返回结果""" - nb_path = config.version_manager_nb_path - return run_command([nb_path] + args, cwd) +async def poetry_install(cwd: Path) -> tuple[int, str, str]: + try: + proc = await asyncio.create_subprocess_exec( + "poetry", "install", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, cwd=cwd + ) + stdout, stderr = await proc.communicate() + return proc.returncode or 0, stdout.decode(errors="ignore").strip(), stderr.decode(errors="ignore").strip() + except Exception as e: + return -1, "", str(e) -def run_poetry_command(args: list[str], cwd: Optional[Path] = None) -> tuple[int, str, str]: - """运行 poetry 命令并返回结果""" - return run_command(["poetry"] + args, cwd) +async def nb_orm_upgrade(cwd: Path) -> tuple[int, str, str]: + try: + proc = await asyncio.create_subprocess_exec( + config.version_manager_nb_path, + "orm", + "upgrade", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=cwd, + ) + stdout, stderr = await proc.communicate() + return proc.returncode or 0, stdout.decode(errors="ignore").strip(), stderr.decode(errors="ignore").strip() + except Exception as e: + return -1, "", str(e) -async def get_version_info() -> dict: +async def get_version_info(root: Optional[Path] = None) -> dict: """获取版本信息""" + cwd = root or config.version_manager_project_root.resolve() info = { "branch": "unknown", "commit": "unknown", @@ -68,22 +187,22 @@ async def get_version_info() -> dict: } # 获取当前分支 - code, stdout, stderr = run_git_command(["rev-parse", "--abbrev-ref", "HEAD"]) + code, stdout, stderr = await git_rev_parse_head(cwd) if code == 0: info["branch"] = stdout # 获取最新 commit hash - code, stdout, stderr = run_git_command(["rev-parse", "--short", "HEAD"]) + code, stdout, stderr = await git_rev_parse_short(cwd) if code == 0: info["commit"] = stdout # 获取最新提交信息 - code, stdout, stderr = run_git_command(["log", "-1", "--format=%s"]) + code, stdout, stderr = await git_log_one(cwd) if code == 0: info["message"] = stdout # 检查是否有未提交的改动 - code, stdout, stderr = run_git_command(["status", "--porcelain"]) + code, stdout, stderr = await git_status(cwd) if code == 0: if stdout: info["dirty"] = True @@ -104,43 +223,35 @@ async def get_version_info() -> dict: return info -async def check_file_changes(file_pattern: str) -> tuple[bool, list[str]]: - """检查指定文件是否有改动 - - Args: - file_pattern: 文件路径模式,如 "poetry.lock" 或 "migrations/versions/" - - Returns: - (是否有改动, 改动文件列表) - """ - code, stdout, stderr = run_git_command(["diff", "--name-only", "HEAD", "origin/HEAD", "--", file_pattern]) - - changed_files = [] - if code == 0 and stdout: +async def _parse_diff_output(stdout: str) -> list[str]: + """解析 git diff 的输出为文件列表""" + files = [] + if stdout: for line in stdout.split("\n"): line = line.strip() if line: - changed_files.append(line) - - return len(changed_files) > 0, changed_files + files.append(line) + return files -async def check_poetry_lock_changes() -> tuple[bool, list[str]]: +async def check_poetry_lock_changes(cwd: Path) -> tuple[bool, list[str]]: """检查 poetry.lock 是否有改动 Returns: (是否有改动, 改动文件列表) """ - return await check_file_changes("poetry.lock") + code, stdout, stderr = await git_diff_poetry_lock(cwd) + changed_files = await _parse_diff_output(stdout) if code == 0 else [] + return len(changed_files) > 0, changed_files -async def check_migration_changes() -> tuple[bool, list[str]]: +async def check_migration_changes(cwd: Path) -> tuple[bool, list[str]]: """检查是否有新的数据库迁移文件 Returns: (是否有新迁移, 新迁移文件列表) """ - migrations_dir = config.version_manager_project_root / "migrations" / "versions" + migrations_dir = cwd / "migrations" / "versions" if not migrations_dir.exists(): return False, [] @@ -151,7 +262,8 @@ async def check_migration_changes() -> tuple[bool, list[str]]: local_files.add(f.name) # 检查远程是否有新的迁移(通过 git diff 比较) - has_changes, changed_files = await check_file_changes("migrations/versions/") + code, stdout, stderr = await git_diff_migrations(cwd) + changed_files = await _parse_diff_output(stdout) if code == 0 else [] new_migrations = [] for line in changed_files: @@ -169,6 +281,7 @@ async def perform_upgrade(user_id: str) -> dict: Returns: 更新结果信息 """ + cwd = config.version_manager_project_root.resolve() result = { "success": False, "git_pull_output": "", @@ -184,7 +297,7 @@ async def perform_upgrade(user_id: str) -> dict: } # 1. 执行 git pull - code, stdout, stderr = run_git_command(["pull"]) + code, stdout, stderr = await git_pull(cwd) result["git_pull_output"] = stdout result["git_pull_error"] = stderr @@ -193,13 +306,13 @@ async def perform_upgrade(user_id: str) -> dict: return result # 2. 检查 poetry.lock 是否有改动 - has_poetry_changes, poetry_files = await check_poetry_lock_changes() + has_poetry_changes, poetry_files = await check_poetry_lock_changes(cwd) result["has_poetry_changes"] = has_poetry_changes # 3. 如果有 poetry.lock 改动且配置为自动安装依赖,执行 poetry install if has_poetry_changes and config.version_manager_auto_install_deps: logger.info("Detected poetry.lock changes, installing dependencies...") - code, stdout, stderr = run_poetry_command(["install"]) + code, stdout, stderr = await poetry_install(cwd) result["poetry_install_output"] = stdout result["poetry_install_error"] = stderr @@ -208,14 +321,14 @@ async def perform_upgrade(user_id: str) -> dict: # 依赖安装失败不阻止后续操作,但记录错误 # 4. 检查是否有数据库改动 - has_db_changes, new_migrations = await check_migration_changes() + has_db_changes, new_migrations = await check_migration_changes(cwd) result["has_db_changes"] = has_db_changes result["new_migrations"] = new_migrations # 5. 如果有数据库改动且配置为自动升级,执行数据库升级 if has_db_changes and config.version_manager_auto_upgrade_db: logger.info(f"Detected {len(new_migrations)} new migration(s), upgrading database...") - code, stdout, stderr = run_nb_command(["orm", "upgrade"]) + code, stdout, stderr = await nb_orm_upgrade(cwd) result["db_upgrade_output"] = stdout result["db_upgrade_error"] = stderr diff --git a/src/plugins/nonebot_plugin_wakatime/utils/ranking.py b/src/plugins/nonebot_plugin_wakatime/utils/ranking.py index dc47c7dcc..55c44efd7 100644 --- a/src/plugins/nonebot_plugin_wakatime/utils/ranking.py +++ b/src/plugins/nonebot_plugin_wakatime/utils/ranking.py @@ -33,7 +33,7 @@ async def get_sorted_ranking_data() -> list[RankingData]: class WakaTimeRanking(WebRanking): - async def get_sorted_data(self) -> list[RankingData]: + async def get_sorted_data(self, user_id: str) -> list[RankingData]: return await get_sorted_ranking_data() diff --git a/src/plugins/nonebot_plugin_wordle/__init__.py b/src/plugins/nonebot_plugin_wordle/__init__.py index 3269c015f..2c657aafb 100644 --- a/src/plugins/nonebot_plugin_wordle/__init__.py +++ b/src/plugins/nonebot_plugin_wordle/__init__.py @@ -103,7 +103,7 @@ async def loop(self) -> None: await self.fail() async def win(self, user_id: str) -> None: - session = await create_minigame_session(user_id) + session = await create_minigame_session(user_id, "wordle") session.start_time = self.start_time t = await session.finish() await session.add_points(round((7 - len(self.history)) * 5000 / t))