diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 365d10f..d2b7664 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -131,6 +131,29 @@ jobs: exit 1 } + - name: "Smoke test" + shell: pwsh + run: | + # 冒烟测试:用临时配置启动产物,确认可以正常运行 + $env:PYTHONUTF8 = "1" + '{"account_token":"smoke_test_token","system":{"proxy":null,"logger":{"level":20}},"servers":[]}' | Out-File -Encoding utf8 config.json + $exe = "build\OneDisc_Windows_${{ matrix.arch }}.exe" + $p = Start-Process -FilePath $exe -PassThru -RedirectStandardOutput smoke.out.log -RedirectStandardError smoke.err.log + Start-Sleep -Seconds 25 + if (-not $p.HasExited) { Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue } + Start-Sleep -Seconds 5 + Get-Process | Where-Object { $_.ProcessName -like "OneDisc*" } | Stop-Process -Force -ErrorAction SilentlyContinue + $out = "" + foreach ($f in @("smoke.out.log", "smoke.err.log")) { + if (Test-Path $f) { $out += (Get-Content $f -Raw) + "`n" } + } + Write-Host "===== smoke log =====" + Write-Host $out + if ($out -notmatch "OneDisc \(By: IT Craft Development Team\)" -or $out -notmatch "logging in using static token") { + Write-Error "冒烟测试失败:程序未能正常启动" + exit 1 + } + - name: "Upload Release Asset" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -220,6 +243,28 @@ jobs: exit 1 fi + - name: "Smoke test" + run: | + # 冒烟测试:用临时配置启动产物,确认可以正常运行 + cat > config.json <<'EOF' + { + "account_token": "smoke_test_token", + "system": {"proxy": null, "logger": {"level": 20}}, + "servers": [] + } + EOF + ./build/OneDisc_Linux_${{ matrix.arch }} > smoke.log 2>&1 & + PID=$! + sleep 20 + kill $PID 2>/dev/null || true + wait $PID 2>/dev/null || true + echo "===== smoke.log =====" + cat smoke.log + if ! grep -q "OneDisc (By: IT Craft Development Team)" smoke.log || ! grep -q "logging in using static token" smoke.log; then + echo "::error::冒烟测试失败:程序未能正常启动" + exit 1 + fi + - name: "Upload Release Asset" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -228,15 +273,17 @@ jobs: build-macos: needs: get-version-number - runs-on: macos-latest + runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: include: - arch: x64 python-arch: x64 + os: macos-26-intel - arch: arm64 python-arch: arm64 + os: macos-latest steps: - name: "Checkout" uses: actions/checkout@v4 @@ -245,6 +292,7 @@ jobs: uses: actions/setup-python@v5 with: python-version: '3.12' + architecture: ${{ matrix.python-arch }} - name: "Setup Poetry" uses: snok/install-poetry@v1 @@ -307,6 +355,28 @@ jobs: exit 1 fi + - name: "Smoke test" + run: | + # 冒烟测试:用临时配置启动产物,确认可以正常运行 + cat > config.json <<'EOF' + { + "account_token": "smoke_test_token", + "system": {"proxy": null, "logger": {"level": 20}}, + "servers": [] + } + EOF + ./build/OneDisc_macOS_${{ matrix.arch }} > smoke.log 2>&1 & + PID=$! + sleep 20 + kill $PID 2>/dev/null || true + wait $PID 2>/dev/null || true + echo "===== smoke.log =====" + cat smoke.log + if ! grep -q "OneDisc (By: IT Craft Development Team)" smoke.log || ! grep -q "logging in using static token" smoke.log; then + echo "::error::冒烟测试失败:程序未能正常启动" + exit 1 + fi + - name: "Upload Release Asset" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a9c1dc..eac5657 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -138,6 +138,29 @@ jobs: exit 1 } + - name: "Smoke test" + shell: pwsh + run: | + # 冒烟测试:用临时配置启动产物,确认可以正常运行 + $env:PYTHONUTF8 = "1" + '{"account_token":"smoke_test_token","system":{"proxy":null,"logger":{"level":20}},"servers":[]}' | Out-File -Encoding utf8 config.json + $exe = "build\onedisc-windows-${{ matrix.arch }}.exe" + $p = Start-Process -FilePath $exe -PassThru -RedirectStandardOutput smoke.out.log -RedirectStandardError smoke.err.log + Start-Sleep -Seconds 25 + if (-not $p.HasExited) { Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue } + Start-Sleep -Seconds 5 + Get-Process | Where-Object { $_.ProcessName -like "onedisc*" } | Stop-Process -Force -ErrorAction SilentlyContinue + $out = "" + foreach ($f in @("smoke.out.log", "smoke.err.log")) { + if (Test-Path $f) { $out += (Get-Content $f -Raw) + "`n" } + } + Write-Host "===== smoke log =====" + Write-Host $out + if ($out -notmatch "OneDisc \(By: IT Craft Development Team\)" -or $out -notmatch "logging in using static token") { + Write-Error "冒烟测试失败:程序未能正常启动" + exit 1 + } + - name: "Upload build" uses: actions/upload-artifact@v4 with: @@ -235,6 +258,28 @@ jobs: exit 1 fi + - name: "Smoke test" + run: | + # 冒烟测试:用临时配置启动产物,确认可以正常运行 + cat > config.json <<'EOF' + { + "account_token": "smoke_test_token", + "system": {"proxy": null, "logger": {"level": 20}}, + "servers": [] + } + EOF + ./build/onedisc-linux-${{ matrix.arch }} > smoke.log 2>&1 & + PID=$! + sleep 20 + kill $PID 2>/dev/null || true + wait $PID 2>/dev/null || true + echo "===== smoke.log =====" + cat smoke.log + if ! grep -q "OneDisc (By: IT Craft Development Team)" smoke.log || ! grep -q "logging in using static token" smoke.log; then + echo "::error::冒烟测试失败:程序未能正常启动" + exit 1 + fi + - name: "Upload build" uses: actions/upload-artifact@v4 with: @@ -243,15 +288,17 @@ jobs: build-macos: needs: get-version-number - runs-on: macos-latest + runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: include: - arch: x64 python-arch: x64 + os: macos-26-intel - arch: arm64 python-arch: arm64 + os: macos-latest steps: - name: "Checkout" uses: actions/checkout@v4 @@ -260,6 +307,7 @@ jobs: uses: actions/setup-python@v5 with: python-version: '3.12' + architecture: ${{ matrix.python-arch }} - name: "Setup Poetry" uses: snok/install-poetry@v1 @@ -330,6 +378,28 @@ jobs: exit 1 fi + - name: "Smoke test" + run: | + # 冒烟测试:用临时配置启动产物,确认可以正常运行 + cat > config.json <<'EOF' + { + "account_token": "smoke_test_token", + "system": {"proxy": null, "logger": {"level": 20}}, + "servers": [] + } + EOF + ./build/onedisc-macos-${{ matrix.arch }} > smoke.log 2>&1 & + PID=$! + sleep 20 + kill $PID 2>/dev/null || true + wait $PID 2>/dev/null || true + echo "===== smoke.log =====" + cat smoke.log + if ! grep -q "OneDisc (By: IT Craft Development Team)" smoke.log || ! grep -q "logging in using static token" smoke.log; then + echo "::error::冒烟测试失败:程序未能正常启动" + exit 1 + fi + - name: "Upload build" uses: actions/upload-artifact@v4 with: diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..73a2bcd --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,78 @@ +# CLAUDE.md + +本文件为在 OneDisc 仓库中工作的 AI 编程助手(及人类开发者)提供指南。 + +## 项目简介 + +OneDisc 是一个基于 Discord 的 OneBot 实现,支持 OneBot V11 / V12 协议,可对接 +NoneBot2、koishi 等 OneBot 框架。v1.0 起使用 Poetry 管理依赖,并使用 Nuitka +将程序编译为各平台可执行文件发布。 + +## 环境要求 + +- Python >= 3.12 +- Poetry(`poetry install --all-groups` 安装全部依赖,含 dev 组的 nuitka) + +## 仓库结构 + +``` +main.py 程序入口(读取配置 → 初始化日志 → 导入插件 → 连接 Discord) +version.py 从 pyproject.toml 读取版本号 +pyproject.toml 项目元数据 + 依赖(版本号唯一权威来源,发版前必须修改) +call_action.py 动作分发(根据 OneBot 协议类型调用对应 action) +actions/ OneBot 动作实现 + v11/ OneBot V11 动作 + v12/ OneBot V12 动作 +network/ 各协议版本的网络服务(HTTP / WS / WS 反向) +utils/ 工具模块(配置、日志、数据库、事件、消息解析等) +docs/ VitePress 文档(独立发布到 GitHub Pages) +.github/workflows/ CI 定义(见下文) +``` + +## 常用命令 + +```bash +poetry install --all-groups # 安装全部依赖 +python main.py # 运行(需先有 config.json,缺失时进入创建向导) +poetry run python -m nuitka ... # 编译(参数见 .github/workflows/ci.yml) +``` + +## 编码规范与注意事项 + +1. **日志**:使用 `utils/logger.py` 的 `get_logger()`,每个模块在模块级调用 + `logger = get_logger()` 即可获得以本模块命名的 logger。 + ⚠️ **禁止在 get_logger 中使用 `inspect.stack()`/`inspect.getmodule()` 获取调用方**: + 程序使用 Nuitka 编译发布,编译模块没有真实源码文件,inspect 会抛 + `AttributeError: 'dict' object has no attribute 'endswith'` 导致程序无法启动 + (见 issue #106)。现在通过 `sys._getframe(1).f_globals["__name__"]` 实现。 +2. **版本号**:唯一权威来源是 `pyproject.toml` 的 `project.version`。`version.py` + 运行时读取它(打包后从可执行文件同目录的数据文件中读取)。 + **发版前必须先修改 pyproject.toml 中的版本号**,否则发布的可执行文件会显示 + 旧版本号(v0.2.10 发布时显示 0.2.9.0 的错误曾真实发生过)。 +3. **配置文件**:程序从当前工作目录读取 `config.json`,缺失时进入创建向导并退出。 +4. **类型标注**:项目使用较新的类型标注语法(`str | None`),保持风格一致。 + +## CI / 构建 + +- `.github/workflows/ci.yml`(Build (Dev)):push 到 master 或手动触发,构建 + Windows / Linux / macOS 三平台 onefile 可执行文件并上传 artifact,同时构建并 + 部署文档到 GitHub Pages。 +- `.github/workflows/build-release.yml`(Build (Release)):创建 Release 时触发, + 构建并直接把可执行文件上传到 Release 资产。 +- 每个构建任务都包含**冒烟测试**:用临时 config.json 启动产物,检查是否打印 + 「OneDisc (By: IT Craft Development Team)」和「当前版本」后正常存活,防止发布 + 无法启动的产物(如缺少 websockets 等库)。 +- 本地验证构建产物:`./build/onedisc --version` 不可用,请按冒烟测试步骤运行并 + 观察启动日志。 + +## 发版流程 + +1. 修改 `pyproject.toml` 中的 `version`(如 `1.0.0`)。 +2. 合并 PR 到 master。 +3. 创建 tag(`v1.0.0`)并发布 Release → 自动触发 build-release.yml 构建并上传资产。 +4. 下载 Release 资产做冒烟测试确认可运行。 + +## 其他 + +- Docker 部署见 `Dockerfile`(python:3.12-slim + poetry + wkhtmltopdf)。 +- 文档使用 VitePress(`docs/`),本地预览:`npm install && npm run docs:dev`。 diff --git a/utils/logger.py b/utils/logger.py index c7417f7..0d92ee7 100644 --- a/utils/logger.py +++ b/utils/logger.py @@ -1,8 +1,8 @@ import json import logging +import sys import httpx import discord -import inspect BASIC_CONFIG = { "level": 20, @@ -18,6 +18,13 @@ def init_logger(logger_config: dict) -> None: Args: logger_config (dict): 配置(`config.json->system.logger`) """ + # 强制 stdout/stderr 使用 UTF-8:在非 UTF-8 locale(如英文 Windows 重定向输出、 + # POSIX 环境)下,避免日志中的中文被转义成 \\uXXXX(Nuitka 打包环境同样生效) + for stream in (sys.stdout, sys.stderr): + try: + stream.reconfigure(encoding="utf-8") + except Exception: + pass config = BASIC_CONFIG.copy() config.update(logger_config) logging.basicConfig(**config) @@ -33,7 +40,15 @@ def get_logger(name: str | None = None) -> logging.Logger: Returns: logging.Logger: 日志记录器 """ - return logging.getLogger(name or inspect.getmodule(inspect.stack()[1][0]).__name__) + if name is None: + try: + # 使用 sys._getframe 获取调用方模块名,避免使用 inspect。 + # 在 Nuitka 编译环境下,编译模块没有真实源码文件, + # inspect.stack()/getmodule() 会抛 AttributeError 导致程序无法启动 + name = sys._getframe(1).f_globals.get("__name__") + except Exception: + name = None + return logging.getLogger(name or __name__) logger = get_logger() diff --git a/version.py b/version.py index bc30cb4..9a5c96f 100644 --- a/version.py +++ b/version.py @@ -1,18 +1,38 @@ +import os import toml +def _pyproject_candidates() -> list[str]: + """ + 返回 pyproject.toml 的候选路径 + + 源码运行时:优先使用当前文件所在目录(仓库根目录) + Nuitka 打包运行时:pyproject.toml 作为数据文件被解压到可执行文件/临时目录中, + 此时 __file__ 指向解压目录,因此优先使用 __file__ 所在目录 + """ + candidates = [ + os.path.join(os.path.dirname(os.path.abspath(__file__)), "pyproject.toml"), + "pyproject.toml", + ] + return candidates + + def get_version_from_pyproject() -> str: - # 读取 pyproject.toml 文件 - with open('pyproject.toml', 'r') as file: - data = toml.load(file) + # 依次尝试所有候选路径,读取 pyproject.toml 文件 + for path in _pyproject_candidates(): + try: + with open(path, "r", encoding="utf-8") as file: + data = toml.load(file) + + # 提取版本号 + version = data.get("project", {}).get("version", None) - # 提取版本号 - version = data.get('project', {}).get('version', None) + if version: + return version + except Exception: + continue + return "Failed to read version number." - if version: - return version - else: - return "Failed to read version number." try: VERSION = get_version_from_pyproject()