diff --git a/AGENTS.md b/AGENTS.md index 648dbd93..e4804849 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,26 +1,46 @@ # Repository Guidelines ## Project Structure & Module Organization -`src/Undefined/` contains the main Python package. Core areas include `ai/`, `cognitive/`, `services/`, `skills/`, and `webui/`. `tests/` holds the pytest suite; add new tests close to the behavior they cover with `test_*.py` names. `apps/undefined-console/` is the Tauri + Vite management client. `code/NagaAgent/` is a Git submodule, so keep upstream syncs and local patches as clearly separated changes. Packaged assets live in `res/`, `img/`, and `config/`; generated outputs like `dist/` and runtime data under `logs/` are not primary edit targets. +`src/Undefined/` contains the main runtime package. Core areas include `ai/`, `services/`, `skills/`, `cognitive/`, `memes/`, `knowledge/`, `api/`, `webui/`, `config/`, and `mcp/`; media-facing integrations live in `arxiv/`, `bilibili/`, `github/`, and `attachments.py`. `tests/` holds the pytest suite. `apps/undefined-console/` is the primary Tauri + Vite management client, while `code/NagaAgent/` remains a git submodule and should be updated deliberately, with upstream syncs kept separate from repo-local changes. Runtime and generated state primarily lives under `data/`, `logs/`, and `dist/`; the root `knowledge/` directory stores knowledge-base data rather than application code. Prefer editing source files and docs over generated outputs unless the task is explicitly about runtime state. ## Build, Test, and Development Commands Use `uv` for the root project: - `uv sync` installs Python dependencies. -- `uv run playwright install` installs browser runtimes used by screenshot features. -- `uv run Undefined-webui` starts the recommended local management entrypoint. +- `uv run playwright install` installs browser runtimes used by screenshot and web tooling features. +- `uv run Undefined-webui` starts the recommended Management-first local entrypoint. +- `uv run Undefined` starts the bot directly. - `uv run pytest tests/` runs the backend test suite. - `uv run ruff check .` and `uv run ruff format --check .` enforce Python linting and formatting. - `uv run mypy .` runs strict type checks. -- `uv build --wheel` validates packaging and resource inclusion. +- `uv build --wheel` validates packaging and bundled resources. +- `bash scripts/install_git_hooks.sh` enables the repository-managed git hooks. -For the desktop console app, run `cd apps/undefined-console && npm ci && npm run check`. Use `npm run tauri:dev` for local desktop development. +For the console app, run `cd apps/undefined-console && npm ci && npm run check`. Use `npm run dev` for the Vite shell and `npm run tauri:dev` for the desktop shell. ## Coding Style & Naming Conventions -Use 4-space indentation. Python code should be type-annotated and Ruff-formatted; follow `snake_case` for modules and functions, `PascalCase` for classes, and keep modules focused. WebUI JavaScript in `src/Undefined/webui/static/js/` is formatted with Biome using 4-space indents. `code/NagaAgent/frontend/` follows Vue/TypeScript conventions enforced by ESLint. +Use 4-space indentation. Python code must be fully type-annotated and pass strict mypy checks. Disk I/O should go through `src/Undefined/utils/io.py` so writes stay async-safe and atomic. Follow `snake_case` for modules and functions, `PascalCase` for classes, and prefer extending existing services/helpers over introducing one-off abstractions. Skills handlers must not import repo-local modules outside `skills/`; pass dependencies through the execution context instead. WebUI JavaScript in `src/Undefined/webui/static/js/` is formatted with Biome, and `apps/undefined-console/` changes must satisfy Biome, TypeScript, and Cargo checks. + +## Tools & Features + +### `group.get_member_info` — brief parameter +The tool supports a `brief` boolean parameter (`default: false`). When `brief: true`, it returns only the current nickname (group card or QQ nickname) in a single line, suitable for quick queries where the AI needs to address a user by their latest name. + +### `group.get_avatar` — fetch user avatar +`group.get_avatar` accepts `user_id` (required) and optional `size` (40, 100, 140, 640, default 100). It downloads the QQ avatar and registers it as an attachment, returning an `` tag that can be embedded in messages. + +### Unified attachment tag +Use `` for both images and files. The legacy `` tag is still supported for backward compatibility but `attachment` is the recommended unified syntax. The system distinguishes image vs file based on the UID prefix (`pic_`/`file_`). +Remote attachments are cached only up to `[attachments].remote_download_max_size_mb`; larger items, or all remote items when the value is `0`, are registered as URL references with `source_ref` instead of downloaded file content. + +### Auto processing pipelines +Automatic extraction pipelines live under `src/Undefined/skills/auto_pipeline/pipelines//` and use `config.json + handler.py`. Slash commands have higher priority; when a command is dispatched, automatic pipelines and AI auto-reply are skipped. Command inputs and command outputs should be recorded in message history so later AI turns can see the result. For non-command messages, all pipelines detect in parallel and all matches process in parallel before AI auto-reply. Outputs should go through `MessageSender`, which writes history and automatically registers local CQ media or uploaded files as session attachment UIDs. + +### User identification in prompts +The system prompt now includes a rule: **recognize and address users by their QQ ID (`sender_id`)** because nicknames can change. When needing to address a user, use the latest nickname obtained via `group.get_member_info(brief=true)`. Observations recorded in cognitive memory should always include the QQ ID, e.g., “QQ号12345678(昵称张三)做了某事”. ## Testing Guidelines -Write tests as `tests/test_.py`. Async tests are supported through `pytest-asyncio`. Add or update tests for behavior changes in APIs, config loading, cognitive memory, and WebUI routes. CI runs the full Python suite plus console checks on pushes and pull requests; no explicit coverage threshold is configured, so use judgment and cover touched paths well. +Write tests as `tests/test_.py`. Async tests use `pytest-asyncio`. Add or update coverage for behavior changes in APIs, config loading/hot reload, cognitive memory, meme or knowledge flows, and WebUI/runtime routes. If you touch `apps/undefined-console/` or `src/Undefined/webui/static/js/`, run `npm run check` in `apps/undefined-console/` in addition to the Python checks. No fixed coverage threshold is configured, so cover touched paths well. ## Commit & Pull Request Guidelines -Recent history follows Conventional Commits with optional scopes, for example `fix(webui): refine launcher return flow` and `docs(build): document linux no-strip workaround`. Keep subjects imperative and concise. For pull requests, include a short impact summary, linked issues, and the commands you ran. Attach screenshots for WebUI or Tauri UI changes. If you change versioned release files, keep `pyproject.toml` and `src/Undefined/__init__.py` in sync. To mirror local checks, enable repo hooks with `git config core.hooksPath .githooks`. +Recent history follows Conventional Commits with optional scopes, for example `fix(webui): refine launcher return flow` and `feat(commands): add /version (/v) slash command`. Keep commit subjects imperative and concise. Keep `code/NagaAgent/` syncs separate from local feature work when possible. If you are bumping release versions, prefer `uv run python scripts/bump_version.py ` so `pyproject.toml`, `src/Undefined/__init__.py`, `apps/undefined-console/package.json`, and the Tauri config stay in sync. For pull requests, include a short impact summary, linked issues, and the commands you ran; attach screenshots for WebUI or Tauri UI changes. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 53d35be7..913c8eae 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -33,12 +33,18 @@ graph TB BilibiliSender["sender.py
视频发送
• 视频消息 • 降级信息卡片"] end + subgraph GitHubModule["GitHub 模块 (github/)"] + GitHubParser["parser.py
仓库标识解析
• URL • SSH • owner/repo • JSON"] + GitHubClient["client.py
public API 获取
• 仓库信息 • contributors"] + GitHubSender["sender.py
图片卡片发送
• 头像 • 简介 • 统计"] + end + subgraph SecurityLayer["安全防线 (services/)"] SecurityService["SecurityService
安全服务
• 注入攻击检测
• 速率限制
[security.py]"] InjectionAgent["InjectionResponseAgent
注入响应生成
[injection_response_agent.py]"] end - CommandDispatcher["CommandDispatcher
命令分发器
• /help /stats /lsadmin
• /addadmin /rmadmin
• /bugfix /lsfaq
[services/command.py]"] + CommandDispatcher["CommandDispatcher
命令分发器
• /help /stats /lsadmin
• /addadmin /rmadmin
• /bugfix /faq
[services/command.py]"] subgraph QueueSystem["车站-列车 队列系统 (services/)"] AICoordinator["AICoordinator
AI 协调器
• Prompt 构建
• 队列管理
• 回复执行
[ai_coordinator.py]"] @@ -95,7 +101,7 @@ graph TB TS_Messages["messages.*
• send_message
• get_recent_messages
• get_forward_msg"] TS_Memory["memory.*
• add / delete
• list / update"] TS_Contacts["contacts.*
• query_friends
• query_groups"] - TS_GroupAnalysis["group_analysis.*
• analyze_member_messages
• analyze_join_statistics
• analyze_new_member_activity"] + TS_GroupAnalysis["group_analysis.*
• member_structure
• message_mix
• member_activity
• activity_trend
• inactive_risk
• member_messages
• join_statistics
• new_member_activity"] TS_Notices["notices.*
• list / get / stats"] TS_Render["render.*
• render_html
• render_latex
• render_markdown"] TS_Scheduler["scheduler.*
• create_schedule_task
• delete_schedule_task
• list_schedule_tasks"] @@ -116,7 +122,7 @@ graph TB subgraph CommandsLayer["平台指令 (skills/commands/)"] Cmd_Core["核心指令
• help • stats"] Cmd_Admin["管理指令
• addadmin
• rmadmin • lsadmin"] - Cmd_FAQ["FAQ 指令
• lsfaq • viewfaq
• searchfaq • delfaq"] + Cmd_FAQ["FAQ 指令
• faq (ls/view/search/del)"] Cmd_Fun["娱乐指令
• bugfix"] end @@ -208,13 +214,16 @@ graph TB SecurityService -.->|"API 调用"| LLM_API SecurityService -->|"注入攻击"| InjectionAgent - MessageHandler -->|"1.5 Bilibili检测"| BilibiliParser + MessageHandler -->|"2. 指令?"| CommandDispatcher + CommandDispatcher -->|"执行结果经统一发送层写历史"| OneBotClient + MessageHandler -->|"2.5 非命令自动管线"| BilibiliParser BilibiliParser -->|"BV号"| BilibiliDownloader BilibiliDownloader -->|"视频文件"| BilibiliSender BilibiliSender -->|"发送"| OneBotClient - - MessageHandler -->|"2. 指令?"| CommandDispatcher - CommandDispatcher -->|"执行结果"| OneBotClient + MessageHandler -->|"2.6 非命令自动管线"| GitHubParser + GitHubParser -->|"仓库ID"| GitHubClient + GitHubClient -->|"public仓库信息"| GitHubSender + GitHubSender -->|"发送图片卡片"| OneBotClient MessageHandler -->|"3. 自动回复"| AICoordinator AICoordinator -->|"创建上下文"| RequestContext @@ -353,21 +362,22 @@ sequenceDiagram alt 检测到注入 SS->>MH: 拦截并响应 else 安全 - %% Bilibili 自动提取 - alt 消息包含B站链接/BV号 - MH->>MH: extract_bilibili_ids() - MH->>MH: download_video() - MH->>OH: 发送视频/信息卡片 - OH->>OB: WebSocket API - OB->>U: 显示视频 - else 非B站内容 - %% 指令处理 - MH->>CD: 解析斜杠命令 + %% 指令处理 + MH->>CD: 解析斜杠命令 alt 是命令 CD->>ST: FAQ/管理员操作 + CD->>ST: 写入命令输出历史 CD-->>OB: 返回结果 OB->>U: 发送响应 - else 是AI消息 + else 非命令消息 + MH->>MH: 并行检测 skills/auto_pipeline 管线 + opt 命中自动提取管线 + MH->>MH: 并行处理全部命中的自动提取 + MH->>OH: 发送视频/卡片/PDF + OH->>OB: WebSocket API + OB->>U: 显示自动提取结果 + MH->>ST: 写入自动提取结果历史和附件 UID + end %% AI处理流程 MH->>AC: handle_auto_reply() @@ -810,6 +820,7 @@ description: 从 PDF 文件中提取文本和表格,填写表单。当用户 | **认知记忆** | `cognitive.enabled`, `cognitive.query.*`, `models.embedding.*` | 事件检索、时间衰减加权、侧写与后台史官 | | **Bilibili** | `bilibili.auto_extract_enabled`, `bilibili.cookie`, `bilibili.prefer_quality` | B站视频自动提取与下载 | | **arXiv** | `arxiv.auto_extract_enabled`, `arxiv.max_file_size`, `arxiv.auto_extract_max_items` | arXiv 论文自动提取、搜索与 PDF 发送 | +| **GitHub** | `github.auto_extract_enabled`, `github.request_timeout_seconds`, `github.auto_extract_max_items` | GitHub public 仓库自动提取与图片卡片发送 | | **思考链** | `*.thinking_enabled` | 思维链支持 | | **思维链兼容** | `*.thinking_tool_call_compat` | 思维链 + 工具调用兼容 | | **WebUI** | `webui.url`, `webui.port`, `webui.password` | 配置控制台 | @@ -820,7 +831,8 @@ description: 从 PDF 文件中提取文本和表格,填写表单。当用户 1. **外部实体层**:用户、管理员、OneBot 协议端 (NapCat/Lagrange.Core)、大模型 API 服务商 2. **核心入口层**:main.py 启动入口、配置管理器 (config/loader.py)、热更新应用器 (config/hot_reload.py)、OneBotClient (onebot.py)、RequestContext (context.py)、Runtime API Server (api/app.py → api/routes/ 路由子模块) -3. **消息处理层**:MessageHandler (handlers.py)、SecurityService (security.py)、CommandDispatcher (services/command.py)、AICoordinator (ai_coordinator.py)、QueueManager (queue_manager.py)、Bilibili 自动提取 (bilibili/) +3. **消息处理层**:MessageHandler (handlers.py)、SecurityService (security.py)、CommandDispatcher (services/command.py)、AICoordinator (ai_coordinator.py)、QueueManager (queue_manager.py)、自动处理管线 (skills/auto_pipeline/)、Bilibili/arXiv/GitHub 解析与发送模块 + 自动提取由 `AutoPipelineRegistry` 并行检测、并行处理全部命中的管线;发送结果写入历史后继续进入 AI 自动回复。 4. **AI 核心能力层**:AIClient (client.py)、PromptBuilder (prompts.py)、ModelRequester (llm.py)、ToolManager (tooling.py)、MultimodalAnalyzer (multimodal.py)、SummaryService (summaries.py)、TokenCounter (tokens.py) 5. **存储与上下文层**:MessageHistoryManager (utils/history.py, 10000条限制)、MemoryStorage (memory.py, 置顶备忘录, 500条上限)、EndSummaryStorage、CognitiveService + JobQueue + HistorianWorker + VectorStore + ProfileStorage、MemeService + MemeWorker + MemeStore + MemeVectorStore (表情包库)、FAQStorage、ScheduledTaskStorage、TokenUsageStorage (自动归档) 6. **技能系统层**:ToolRegistry (registry.py)、AgentRegistry、6个 Agents、11类 Toolsets diff --git a/CHANGELOG.md b/CHANGELOG.md index 5db78b07..422c313e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,43 @@ +## v3.3.3 命令推断、自动处理管线与统一附件上下文 + +本版本重点优化了命令系统的交互体验、AI 工具边界和消息前置处理链路。新增 GitHub 链接自动卡片生成,并将 Bilibili、arXiv、GitHub 等自动提取迁入 `skills/auto_pipeline` 热重载管线,使预处理结果能写入历史并进入后续 AI 回复上下文。同时,帮助说明与用户侧写默认改为图片输出,系统剥离独立群聊分析工具集,全面推行统一附件标签,并为远程附件加入可配置下载上限和 URL 引用降级,完善底层用户识别机制与模型高级透传配置。 + +- 优化命令系统:支持声明式子命令与用户意图推断。如 `/faq` 合并了列表、阅读、搜索与删除入口,可自然理解省略部分参数后的交互意图;旧 `/lsfaq`、`/viewfaq`、`/searchfaq`、`/delfaq` 入口已移除。 +- 改善长文本呈现:`/help` 与 `/profile` 默认渲染为图片分发,避免长内容刷屏;保留 `-t` 参数用于强制返回纯文本,`/profile` 仍支持 `-f` 合并转发模式。 +- 新增自动处理管线:在 `skills/auto_pipeline` 下提供 `config.json + handler.py` 结构,支持热重载、并行检测和并行处理多个命中结果;首批内置 Bilibili、arXiv、GitHub 三条管线,且斜杠命令优先级高于自动管线,命中命令后不会触发后续自动提取或 AI 自动回复。 +- 优化自动管线启动加载:首次目录扫描、配置读取和 handler 导入改为异步线程加载,避免启动初始化阶段阻塞事件循环。 +- 修复自动管线兜底初始化:未显式执行异步初始化的兼容路径会按未初始化处理并主动加载管线,避免静默跳过自动提取。 +- 新增 GitHub 自动卡片:支持在对话中检测 GitHub 链接及 `owner/repo` 标识,自动生成包含项目简介、Stars、Forks、贡献者等信息的卡片;支持独立开关与白名单控制,并裁剪渲染截图底部空白。 +- 打通命令与自动提取历史上下文:斜杠命令输入、命令输出、自动管线发送的信息、图片、文件和视频摘要都会写入消息历史;统一发送层会自动把本地 CQ 图片、视频、语音和上传文件登记为当前会话可见的 `pic_*` / `file_*` UID,并记录群合并转发摘要,后续 AI 回复可直接看到这些结果并用 `` 引用附件。 +- 新增远程附件下载上限:`[attachments].remote_download_max_size_mb` 控制外部远程附件缓存大小,超过上限或配置为 `0` 时只登记 URL 引用,避免大文件导致磁盘占用和消息处理延迟激增。 +- 修复远程附件 URL 引用:下载禁用或超限时始终保留可访问 URL,原始 OneBot 文件标识或 QQ 来源作为追溯信息保存,避免后续 `` 渲染成不可发送的图片地址。 +- 优化历史落盘延迟:消息先同步写入内存历史供后续流程读取,磁盘 JSON 持久化改为按会话后台串行合并写入,减少大群历史文件全量保存拖慢复读和自动处理前置链路的情况。 +- 修复历史异步保存失败保留:磁盘写入失败会向调度层传播并保留待保存快照,等待后续保存触发,避免队列合并过程中丢失已弹出的历史数据。 +- 独立群聊分析工具:将群成员构成与水群分析从基础群工具中完全剥离,划归专属 `group_analysis.*` 集合,避免日常查询与复杂分析导致的 AI 工具边界混淆。 +- 统一多模态附件标签:全系统推行 `` 作为标准分发标签(继续兼容旧 ``),配套统一了表情包提示词,新增 `group.get_avatar` 头像专用工具。 +- 优化表情包回复体验:提示词要求“文字 + 表情包”场景先发送必要文字,再在后续轮次检索和发送表情包,避免表情包检索拖慢首条回复。 +- 稳定用户识别体系:底层认知逻辑固定以 QQ 号为唯一绑定标识,辅以 `group.get_member_info(brief=true)` 快速拉取当前昵称,有效解决群友频繁更换名片引发的识别错乱。 +- 增强模型连接配置:新增 `stream_enabled` 独立控制推流行为,增加 `request_params` 以支持向不同厂商端点透传特殊参数。 +- 完善流式 usage 统计:Chat Completions 启用上游流式请求时会自动附带 `stream_options.include_usage=true`,避免流式路径丢失 token 用量。 +- 收窄流式回退范围:仅在明确的流式参数不兼容错误或 SDK 未实现时降级到非流式请求,鉴权、限流、网络和超时错误不再重复重发。 +- 细化子命令限流:声明式子命令按父命令与子命令分别记录冷却时间,避免同一复合命令下不同操作互相挤占。 +- 修复私聊模型池命令回归:`/compare`、`/pk` 和完整的 `选` 模型选择消息会先交给模型池处理,普通私聊内容不会因模型池开启而被提前拦截。 +- 改进命令参数解析:`[@QQ(含空格昵称)]` 会作为一个整体参数归一化,且超管可通过 dispatcher 层的管理员权限检查。 +- 修正 `/naga` 子命令分发:handler 直接使用分发层解析出的子命令,并兼容改写后的 `[subcmd, *args]` 参数格式,避免把子命令名和 `naga_id` 相互误判。 +- 收窄模型热更新范围:`summary`、`historian`、`grok` 专用模型变化只刷新 AI 运行时配置,不再重建聊天、视觉和 Agent 模型对象。 +- 修复配置热更新细节:`skills.hot_reload` 变化会同步刷新 Anthropic Skills 注册表;模型池条目会保留 `reasoning_effort_style`;配置模板补齐 `models.summary.stream_enabled`。 +- 修复总结模型选择:配置了 `models.summary` 时,聊天总结、摘要合并和标题生成会使用专用 summary 模型配置;仅热更新 summary 模型时也会重建摘要服务,避免继续沿用旧模型。 +- 提升基础工程质量:对命令推断、群分析、附件处理、配置参数等进行了全面的单元与集成测试补强,总测试用例数提升至 1500+ 项。 +- 修复图片渲染超时:重构 `render.py` 为浏览器实例单例复用模式,并引入可配置的并发信号量;共享渲染上下文会在页面创建失败时正确释放,防止低资源设备多任务抢占导致截图超时或资源残留。 +- 改进渲染热更新:`render.browser_max_concurrency` 运行时变化会在当前渲染任务空闲后重建信号量,且不依赖 `asyncio.Semaphore` 私有属性;本地 LaTeX mathtext 渲染改用 Matplotlib 公共 `math_to_image` 接口。 +- 改进 GitHub 卡片稳定性:裸仓库识别改为结合上下文与仓库名形态判断,降低普通路径误触发;渲染头像等外部资源会复用代理配置,发送后清理临时 PNG 缓存。 +- 收紧子命令推断:声明式 inference 正则需要完整匹配参数,避免只匹配前缀时误选子命令。 +- 修复 Playwright 启动失败清理:浏览器 launch 失败时会停止已启动的 driver,避免反复失败时残留资源。 +- 改进 LaTeX 渲染稳定性:常见公式优先使用本地 `matplotlib` mathtext 渲染,复杂内容再回退共享浏览器实例中的 MathJax + Playwright,避免简单公式依赖外部网络或系统 TeX 环境。 +- 修复流式输出一致性:Chat Completions 与 Responses 流式聚合结果不再裁剪模型输出前后空白,与非流式路径保持一致,避免代码块缩进或刻意留白被改写。 + +--- + ## v3.3.2 架构重构、假@检测与认知侧写增强 围绕核心架构进行了大规模重构与功能增强:Runtime API 拆分为路由子模块、配置系统模块化拆分、新增假@检测机制与 /profile 多输出模式。同步引入复读机制全面升级(可配置阈值与冷却)、消息预处理并行化、WebUI 多项交互功能,以及 arXiv 论文分析 Agent 和安全计算器工具。测试覆盖从约 800 提升至 1438+。 diff --git a/CLAUDE.md b/CLAUDE.md index d6994630..46c67c9e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,25 +4,32 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## 项目概述 -Undefined 是基于 Python asyncio 的高性能 QQ 机器人平台,通过 OneBot V11 协议(NapCat/Lagrange.Core)与 QQ 通信,搭载认知记忆架构和自研 Skills 系统。 +Undefined 是基于 Python asyncio 的高性能 QQ 机器人平台,通过 OneBot V11 协议(NapCat/Lagrange.Core)与 QQ 通信,搭载认知记忆架构、自研 Skills 系统、本地知识库、全局表情包库,以及 Management-first WebUI / Desktop / Android 管理端。 ## 开发命令 ```bash +# 依赖安装 / 初始化 +uv sync +uv run playwright install # 页面截图等能力依赖的浏览器运行时 + # 启动 -uv run Undefined-webui # 启动 WebUI 管理控制台(推荐入口) +uv run Undefined-webui # 启动 Management-first WebUI(推荐入口) uv run Undefined # 直接启动 Bot # 代码质量(提交前必须全部通过) -uv run ruff format . # 格式化 -uv run ruff check . # Lint -uv run mypy . # 严格类型检查(strict=true) -uv run pytest tests/ # 运行全部测试 -uv run pytest tests/test_xxx.py # 运行单个测试文件 -uv run pytest tests/test_xxx.py::test_func -v # 运行单个测试函数 - -# 前端(仅改动 apps/undefined-console/ 或 webui/static/js/ 时需要) -cd apps/undefined-console && npm install && npm run check +uv run ruff format . +uv run ruff check . +uv run mypy . # strict=true +uv run pytest tests/ +uv run pytest tests/test_xxx.py +uv run pytest tests/test_xxx.py::test_func -v +uv build --wheel # 校验打包与资源包含 + +# 前端 / 桌面端(仅改动 apps/undefined-console/ 或 webui/static/js/ 时需要) +cd apps/undefined-console && npm ci && npm run check +cd apps/undefined-console && npm run dev +cd apps/undefined-console && npm run tauri:dev # Git hooks 安装 bash scripts/install_git_hooks.sh @@ -33,74 +40,115 @@ bash scripts/install_git_hooks.sh ## 代码规范 - **类型注释**:所有 Python 代码必须有完整类型注释,mypy strict 模式 -- **异步 IO**:磁盘读写必须走 `utils/io.py`(asyncio.to_thread + 跨平台文件锁 + 原子写入),禁止在事件循环中直接阻塞 IO -- **Python 版本**:>=3.11, <3.14(推荐 3.12) -- **测试**:pytest + pytest-asyncio,asyncio_mode = "auto" -- **JS 格式化**:`biome.json` 管理 `webui/static/js/` 目录 +- **异步 IO**:磁盘读写必须走 `utils/io.py`(`asyncio.to_thread` + 跨平台文件锁 + 原子写入),禁止在事件循环中直接阻塞 IO +- **Python 版本**:`>=3.11, <3.14`(推荐 3.12) +- **测试**:pytest + pytest-asyncio,`asyncio_mode = "auto"` +- **前端格式化**:`src/Undefined/webui/static/js/` 由根目录 `biome.json` 管理;`apps/undefined-console/` 走 Biome + TypeScript + Cargo 检查 +- **版本号同步**:发布版本时优先使用 `uv run python scripts/bump_version.py `,统一同步 Python 包、console 与 Tauri 版本号 +- **Git hooks**:优先通过 `scripts/install_git_hooks.sh` 安装,不要手动维护 `core.hooksPath` ## 架构分层 -源码在 `src/Undefined/`,8 层架构: +核心源码位于 `src/Undefined/`,主要模块如下: + +| 目录 / 文件 | 职责 | +|---|---| +| `ai/` | AI 运行时核心:`client.py`(主入口)、`llm.py`(模型请求)、`prompts.py`(Prompt 构建)、`tooling.py`(工具管理)、`multimodal.py`(多模态)、`model_selector.py`(模型选择)、`summaries.py`(短期总结) | +| `services/` | 运行服务:`ai_coordinator.py`(协调器+队列投递)、`queue_manager.py`(车站-列车队列)、`command.py`(命令分发)、`model_pool.py`(多模型池)、`security.py`(安全防护) | +| `skills/` | 热重载技能系统:`tools/`(原子工具)、`toolsets/`(按域分组工具)、`agents/`(智能体)、`commands/`(斜杠指令)、`anthropic_skills/`(SKILL.md 知识注入) | +| `cognitive/` | 认知记忆:`service.py`(入口)、`vector_store.py`(ChromaDB)、`historian.py`(后台史官异步改写+侧写合并)、`job_queue.py`、`profile_storage.py` | +| `memes/` | 表情包库:两阶段 AI 管线、异步处理队列、SQLite 元数据、ChromaDB 向量检索 | +| `knowledge/` | 本地知识库:文本切分、嵌入、重排、ChromaDB 存储与运行时检索 | +| `arxiv/` | arXiv 论文解析、元信息获取、PDF 下载与发送 | +| `bilibili/` | B 站链接/BV 解析、视频下载与发送 | +| `github/` | GitHub public 仓库解析、API 获取与图片卡片发送 | +| `api/` | Runtime API / Management API 相关服务;路由拆分在 `api/routes/`,包含 `chat`、`cognitive`、`health`、`memes`、`memory`、`naga`、`system`、`tools` | +| `webui/` | aiohttp 管理控制台;路由拆分在 `webui/routes/`,覆盖配置、日志、运行态、表情包与系统管理 | +| `mcp/` | MCP 工具注册、连接与转换 | +| `config/` | 配置系统:`loader.py`(TOML 解析+类型化)、`models.py`(数据模型)、`hot_reload.py`(热更新) | +| `attachments.py` | 富媒体/附件注册、作用域隔离、`` 统一标签(`` 向后兼容)渲染 | +| `utils/` | `io.py`(异步 IO)、`history.py`(消息历史)、`paths.py`、`logging.py`、`sender.py` 等通用能力 | ### 消息处理流程 -``` -OneBot WebSocket → onebot.py → handlers.py → SecurityService(注入检测) - → CommandDispatcher(斜杠指令) 或 AICoordinator(AI回复) - → QueueManager(车站-列车模型,4级优先级) → AIClient → LLM API -``` -### 关键模块 - -| 目录 | 职责 | -|------|------| -| `ai/` | AI 核心:client.py(主入口)、llm.py(模型请求)、prompts.py(Prompt构建)、tooling.py(工具管理)、multimodal.py(多模态)、model_selector.py(模型选择) | -| `services/` | 运行服务:ai_coordinator.py(协调器+队列投递)、queue_manager.py(车站-列车队列)、command.py(命令分发)、model_pool.py(多模型池)、security.py(安全防护) | -| `skills/` | 热重载技能系统:tools/(原子工具)、toolsets/(11类工具集)、agents/(7个智能体)、commands/(斜杠指令)、anthropic_skills/(SKILL.md知识注入) | -| `cognitive/` | 认知记忆:service.py(入口)、vector_store.py(ChromaDB)、historian.py(后台史官异步改写+侧写合并)、job_queue.py、profile_storage.py | -| `memes/` | 表情包库:service.py(两阶段AI管线)、worker.py(异步处理队列)、store.py(SQLite)、vector_store.py(ChromaDB)、models.py | -| `config/` | 配置系统:loader.py(TOML解析+类型化)、models.py(数据模型)、hot_reload.py(热更新) | -| `webui/` | aiohttp Web 管理控制台 | -| `api/` | Management API + Runtime API | -| `utils/` | io.py(异步IO)、history.py(消息历史)、paths.py、logging.py、sender.py 等 | +```text +OneBot WebSocket → onebot.py → handlers.py + → 附件登记 / 访问控制 / 表情包入库 + → SecurityService(注入检测) + → CommandDispatcher(斜杠指令,命中即结束后续处理) + → skills/auto_pipeline(Bilibili / arXiv / GitHub 并行自动提取) + → AICoordinator → QueueManager(按模型隔离, 4 级优先级) + → AIClient → LLM API / Skills / MCP + +Management / Runtime 请求 → webui/app.py 或 api/app.py → routes/* + → 配置、日志、记忆、工具调用、AI Chat 等能力 +``` ### 多模型池分工 -- `ai/model_selector.py` — 纯选择逻辑(策略/偏好/compare状态),无 IO 副作用 -- `services/model_pool.py` — 私聊交互服务,持有 ai/config/sender + +- `ai/model_selector.py` — 纯选择逻辑(策略 / 偏好 / compare 状态),无 IO 副作用 +- `services/model_pool.py` — 私聊交互服务,持有 ai/config/sender,处理 `/compare`、`选X`、`select_chat_config` - `services/ai_coordinator.py` — 持有 `ModelPoolService`(`self.model_pool`),私聊队列投递时通过它选模型 -- `handlers.py` — 私聊消息调用 `ai_coordinator.model_pool.handle_private_message()` +- `handlers.py` — 私聊消息只调 `await self.ai_coordinator.model_pool.handle_private_message(user_id, text)`,不直接感知选择细节 +- `skills/agents/runner.py` — Agent 直接调用 `ai_client.model_selector.select_agent_config(...)`,无 `hasattr` - 默认关闭:`models.pool_enabled = false`;群聊不参与多模型,始终走主模型 ### Skills 系统 -- **热重载**:自动扫描 `skills/` 下 `config.json`/`handler.py` 变更并重载 + +- **热重载**:自动扫描 `skills/` 下 `config.json` / `handler.py` 变更并重载 +- **自动处理管线**:`skills/auto_pipeline/pipelines//` 使用 `config.json + handler.py`,在斜杠命令之后、AI 自动回复之前并行检测/处理;命令输入和命令输出要写入历史,管线输出通过 `MessageSender` 自动写历史并登记本地媒体/文件附件 UID。 - **Skills handler 不引用 `skills/` 外的本地模块**,依赖通过 context 注入 -- **Agent 标准结构**:`config.json`(工具定义) + `handler.py`(执行逻辑) + `prompt.md`(系统提示) + `intro.md` + `mcp.json`(可选私有MCP) -- **Agent 直接调用** `ai_client.model_selector.select_agent_config(...)`,无 hasattr +- **Agent 标准结构**:`config.json` + `handler.py` + `prompt.md` + `intro.md` + `mcp.json`(可选) + `anthropic_skills/`(可选) +- **共享授权**:通过 `callable.json` 将工具或 Agent 白名单暴露给其他 Agent +- **Anthropic Skills**:支持 SKILL.md 目录结构与渐进式披露 + +#### 关键工具说明 + +- `group.get_member_info`:支持 `brief` 参数(默认 false)。当 `brief=true` 时只返回当前昵称(群名片优先,否则 QQ 昵称),便于快速称呼用户。 +- `group.get_avatar`:接受 `user_id` 与可选 `size`(40/100/140/640),下载 QQ 头像并注册为附件,返回 `` 标签。 +- 统一附件标签:推荐使用 ``,系统根据 UID 前缀(`pic_`/`file_`)自动区分图片与文件。旧 `` 语法向后兼容。 +- 远程附件默认按 `[attachments].remote_download_max_size_mb` 限制下载缓存;超过上限或配置为 `0` 时只登记 URL 引用(`source_ref`),避免大文件造成磁盘和延迟压力。 ### 队列模型 + 车站-列车模型(QueueManager):按模型隔离队列组,4 级优先级(超管 > 私聊 > @提及 > 普通群聊),普通队列自动修剪保留最新 2 条,非阻塞按节奏发车(默认 1Hz)。 ### 存储与数据 -- `data/history/` — 消息历史(group_*.json / private_*.json,默认 10000 条,可通过 `[history]` 配置节调整,0=无限制) -- `data/cognitive/` — ChromaDB 向量库 + profiles/ 侧写 + queues/ 任务队列 -- `data/memes/` — 表情包库(blobs原图、previews预览图、memes.sqlite3元数据、chromadb向量检索) + +- `data/history/` — 消息历史(`group_*.json` / `private_*.json`,默认 10000 条,可通过 `[history]` 调整,0 = 无限) +- `data/cognitive/` — ChromaDB 向量库 + `profiles/` 侧写 + `queues/` 任务队列 +- `data/memes/` — 表情包库(`blobs/` 原图、`previews/` 预览图、`memes.sqlite3` 元数据、`chromadb/` 向量检索) +- `data/cache/` — 附件、下载、渲染与 WebUI 文件缓存 +- `data/attachment_registry.json` — 附件注册表 - `data/memory.json` — 置顶备忘录(500 条上限) +- `data/end_summaries.json` — 短期总结存储 +- `data/scheduled_tasks.json` — 定时任务存储 - `data/faq/` — FAQ 存储 - `data/token_usage.jsonl` — Token 统计(自动 gzip 归档) +- `knowledge/` — 本地知识库数据目录(`texts/`、`intro.md`、`chroma/` 等) - `res/prompts/` — 系统提示词模板 +## 提示词约定 + +系统提示词(`res/prompts/undefined.xml`)包含用户识别规则: +- 以 QQ 号(`sender_id`)为用户唯一标识,昵称可能随时变动 +- 称呼用户时使用当前最新昵称,不确定时可调用 `group.get_member_info(brief=true)` 查询 +- 认知记忆(observations)必须包含 QQ 号,格式如:“QQ号12345678(昵称张三)做了某事” + ## 配置系统 - 主配置:`config.toml`(从 `config.toml.example` 复制) - 配置热更新:`config.reload()` 触发回调 - MCP 配置:`config/mcp.json`(全局)或 `agents//mcp.json`(Agent 私有) -- 脚本 `scripts/sync_config_template.py` 可同步新配置项到已有 config.toml +- 脚本 `scripts/sync_config_template.py` 可将新配置项同步到已有 `config.toml` ## 运维脚本 +- `scripts/install_git_hooks.sh` — 安装 Git hooks(设置 `.githooks`) - `scripts/sync_config_template.py` — 同步配置模板新增项(支持 `--dry-run`) -- `scripts/reembed_cognitive.py` — 更换嵌入模型后重建向量库(支持 `--events-only`/`--profiles-only`/`--batch-size`/`--dry-run`) -- `scripts/install_git_hooks.sh` — 安装 Git hooks +- `scripts/reembed_cognitive.py` — 更换嵌入模型后重建认知向量库(支持 `--events-only` / `--profiles-only` / `--batch-size` / `--dry-run`) +- `scripts/bump_version.py` — 同步更新 Python / console / Tauri 版本号,并可选择连同 lock 文件一起更新 ## 跨平台控制台 -`apps/undefined-console/` — Tauri + Vue3 + TypeScript,支持 Windows/macOS/Linux/Android,连接同一 Management API。 +`apps/undefined-console/` 是基于 Tauri v2 + TypeScript + Vite 的管理客户端,支持 Windows / macOS / Linux / Android,连接同一套 Management API 与 Runtime API。 diff --git a/README.md b/README.md index 0cc94f04..08afd2ac 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ - **Management API + Runtime API 分层**:配置、日志、Bot 启停和管理探针由 Management API 提供;主进程 Runtime API 则专注探针、记忆只读查询、认知侧写检索和 WebUI AI Chat。详见 [docs/management-api.md](docs/management-api.md) 与 [docs/openapi.md](docs/openapi.md)。 - **多模型池**:支持配置多个 AI 模型,可轮询、随机选择或用户指定;支持多模型并发比较,选择最佳结果继续对话。详见 [多模型功能文档](docs/multi-model.md)。 - **本地知识库**:将纯文本文件向量化存入 ChromaDB,AI 可通过关键词搜索或语义搜索查询领域知识;支持增量嵌入与自动扫描。详见 [知识库文档](docs/knowledge.md)。 -- **全局表情包库**:收到图片后可异步判定是否为表情包,通过两阶段 LLM 管线生成纯文本描述与标签,支持关键词检索、语义检索和混合检索,并可直接按统一图片 `uid` 发送或插入 ``。详见 [表情包库说明](docs/memes.md)。 +- **全局表情包库**:收到图片后可异步判定是否为表情包,通过两阶段 LLM 管线生成纯文本描述与标签,支持关键词检索、语义检索和混合检索,并可直接按统一图片 `uid` 发送或插入 ``。详见 [表情包库说明](docs/memes.md)。 - **访问控制(群/私聊)**:支持 `access.mode` 三种模式(`off` / `blacklist` / `allowlist`)和群/私聊黑白名单;可按策略限制收发范围,避免误触发与误投递。详见 [docs/access-control.md](docs/access-control.md)。 - **版本变更可查询**:仓库根目录维护 `CHANGELOG.md`,并提供 `/changelog` 命令在运行时查看最近版本和单版本摘要。 - **并行工具执行**:无论是主 AI 还是子 Agent,均支持 `asyncio` 并发工具调用,大幅提升多任务处理速度(如同时读取多个文件或搜索多个关键词)。 @@ -57,6 +57,8 @@ - **Anthropic Skills**:支持 Anthropic Agent Skills(SKILL.md 格式),遵循 agentskills.io 开放标准,提供领域知识注入能力。 - **Bilibili 视频提取**:自动检测消息中的 B 站视频链接/BV 号/小程序分享,下载 1080p 视频并通过 QQ 发送;同时提供 AI 工具调用入口。 - **arXiv 论文提取与搜索**:自动检测消息中的 arXiv 链接/标识并发送论文信息与 PDF;同时提供 `arxiv_paper` 发送工具和 `arxiv_search` 检索工具。 +- **GitHub 仓库卡片**:自动检测 GitHub 仓库链接或 `owner/repo` 仓库 ID,获取 public 仓库信息并发送简洁图片卡片,展示头像、简介、stars、forks、issues、contributors 等概览。 +- **自动处理管线**:Bilibili、arXiv、GitHub 等自动提取统一运行在 `skills/auto_pipeline` 中,斜杠命令优先级更高;命令输入/输出会写入历史,非命令消息会并行检测和处理命中管线,结果通过统一发送层写入历史并登记附件 UID 后再进入 AI 回复。远程大附件超过 `[attachments].remote_download_max_size_mb` 时只登记 URL 引用,避免无界下载和缓存膨胀。 - **思维链支持**:支持开启思维链,提升复杂逻辑推理能力。 - **高并发架构**:基于 `asyncio` 全异步设计,支持多队列消息处理与工具并发执行,轻松应对高并发场景。 - **异步安全 I/O**:统一 IO 层通过线程池 + 跨平台文件锁(Linux/macOS `flock`,Windows `msvcrt`)+ 原子写入(`os.replace`)保证并发写入不损坏、且不阻塞主事件循环。 diff --git a/apps/undefined-console/package-lock.json b/apps/undefined-console/package-lock.json index cb667065..c2c4dc67 100644 --- a/apps/undefined-console/package-lock.json +++ b/apps/undefined-console/package-lock.json @@ -1,12 +1,12 @@ { "name": "undefined-console", - "version": "3.3.2", + "version": "3.3.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "undefined-console", - "version": "3.3.2", + "version": "3.3.3", "dependencies": { "@tauri-apps/api": "^2.3.0", "@tauri-apps/plugin-http": "^2.3.0" diff --git a/apps/undefined-console/package.json b/apps/undefined-console/package.json index d7b6e213..d9c09cba 100644 --- a/apps/undefined-console/package.json +++ b/apps/undefined-console/package.json @@ -1,7 +1,7 @@ { "name": "undefined-console", "private": true, - "version": "3.3.2", + "version": "3.3.3", "type": "module", "scripts": { "tauri": "tauri", diff --git a/apps/undefined-console/src-tauri/Cargo.lock b/apps/undefined-console/src-tauri/Cargo.lock index f9b61ac6..c49cdcb8 100644 --- a/apps/undefined-console/src-tauri/Cargo.lock +++ b/apps/undefined-console/src-tauri/Cargo.lock @@ -4063,7 +4063,7 @@ checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "undefined_console" -version = "3.3.2" +version = "3.3.3" dependencies = [ "serde", "serde_json", diff --git a/apps/undefined-console/src-tauri/Cargo.toml b/apps/undefined-console/src-tauri/Cargo.toml index 3acc5288..68cdf136 100644 --- a/apps/undefined-console/src-tauri/Cargo.toml +++ b/apps/undefined-console/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "undefined_console" -version = "3.3.2" +version = "3.3.3" description = "Undefined cross-platform management console" authors = ["Undefined contributors"] license = "MIT" diff --git a/apps/undefined-console/src-tauri/tauri.conf.json b/apps/undefined-console/src-tauri/tauri.conf.json index 7fe51078..566f1df0 100644 --- a/apps/undefined-console/src-tauri/tauri.conf.json +++ b/apps/undefined-console/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Undefined Console", - "version": "3.3.2", + "version": "3.3.3", "identifier": "com.undefined.console", "build": { "beforeDevCommand": "npm run dev", diff --git a/config.toml.example b/config.toml.example index 4448a048..4ec89d53 100644 --- a/config.toml.example +++ b/config.toml.example @@ -122,6 +122,9 @@ responses_force_stateless_replay = false # zh: 是否启用自动 prompt_cache_key(建议开启,以提高相似请求的缓存命中率)。 # en: Enable automatic prompt_cache_key generation (recommended). prompt_cache_enabled = true +# zh: 是否对上游模型 API 启用流式请求。 +# en: Enable streaming requests to the upstream model API. +stream_enabled = false # zh: 额外请求体参数(可选),可用于 temperature 或供应商私有参数。 # en: Extra request-body params (optional), e.g. temperature or vendor-specific fields. @@ -189,6 +192,9 @@ responses_tool_choice_compat = false # en: Responses API force stateless replay: when enabled, multi-turn tool follow-ups always skip previous_response_id and replay the full message history instead. Use only when the upstream does not handle stateful responses follow-ups correctly. Disabled by default. responses_force_stateless_replay = false prompt_cache_enabled = true +# zh: 是否对上游模型 API 启用流式请求。 +# en: Enable streaming requests to the upstream model API. +stream_enabled = false # zh: 额外请求体参数(可选),可用于 temperature 或供应商私有参数。 # en: Extra request-body params (optional), e.g. temperature or vendor-specific fields. @@ -246,6 +252,9 @@ responses_tool_choice_compat = false # en: Responses API force stateless replay: when enabled, multi-turn tool follow-ups always skip previous_response_id and replay the full message history instead. Use only when the upstream does not handle stateful responses follow-ups correctly. Disabled by default. responses_force_stateless_replay = false prompt_cache_enabled = true +# zh: 是否对上游模型 API 启用流式请求。 +# en: Enable streaming requests to the upstream model API. +stream_enabled = false # zh: 额外请求体参数(可选),可用于 temperature 或供应商私有参数。 # en: Extra request-body params (optional), e.g. temperature or vendor-specific fields. @@ -301,6 +310,9 @@ responses_tool_choice_compat = false # zh: Responses API 续轮强制降级。 # en: Responses API force stateless replay. responses_force_stateless_replay = false +# zh: 是否对上游模型 API 启用流式请求。 +# en: Enable streaming requests to the upstream model API. +stream_enabled = false # zh: 额外请求体参数(可选),可用于 temperature 或供应商私有参数。 # en: Extra request-body params (optional), e.g. temperature or vendor-specific fields. @@ -355,6 +367,9 @@ responses_tool_choice_compat = false # en: Responses API force stateless replay: when enabled, multi-turn tool follow-ups always skip previous_response_id and replay the full message history instead. Use only when the upstream does not handle stateful responses follow-ups correctly. Disabled by default. responses_force_stateless_replay = false prompt_cache_enabled = true +# zh: 是否对上游模型 API 启用流式请求。 +# en: Enable streaming requests to the upstream model API. +stream_enabled = false # zh: 额外请求体参数(可选),可用于 temperature 或供应商私有参数。 # en: Extra request-body params (optional), e.g. temperature or vendor-specific fields. @@ -422,6 +437,9 @@ responses_tool_choice_compat = false # en: Responses API force stateless replay: when enabled, multi-turn tool follow-ups always skip previous_response_id and replay the full message history instead. Use only when the upstream does not handle stateful responses follow-ups correctly. Disabled by default. responses_force_stateless_replay = false prompt_cache_enabled = true +# zh: 是否对上游模型 API 启用流式请求。 +# en: Enable streaming requests to the upstream model API. +stream_enabled = false # zh: 额外请求体参数(可选),可用于 temperature 或供应商私有参数。 # en: Extra request-body params (optional), e.g. temperature or vendor-specific fields. @@ -476,6 +494,9 @@ responses_tool_choice_compat = false # en: Responses API force stateless replay: when enabled, multi-turn tool follow-ups always skip previous_response_id and replay the full message history instead. Use only when the upstream does not handle stateful responses follow-ups correctly. Disabled by default. responses_force_stateless_replay = false prompt_cache_enabled = true +# zh: 是否对上游模型 API 启用流式请求。 +# en: Enable streaming requests to the upstream model API. +stream_enabled = false # zh: 额外请求体参数(可选),可用于 temperature 或供应商私有参数。 # en: Extra request-body params (optional), e.g. temperature or vendor-specific fields. @@ -520,6 +541,9 @@ reasoning_effort_style = "openai" # zh: 是否启用自动 prompt_cache_key(建议开启,以提高相似请求的缓存命中率)。 # en: Enable automatic prompt_cache_key generation (recommended). prompt_cache_enabled = true +# zh: 是否对上游模型 API 启用流式请求。 +# en: Enable streaming requests to the upstream model API. +stream_enabled = false # zh: 额外请求体参数(可选),可用于 temperature 或供应商私有参数。 # en: Extra request-body params (optional), e.g. temperature or vendor-specific fields. @@ -754,6 +778,13 @@ onebot_fetch_limit = 10000 # en: Max messages/members returned by group analysis tools. group_analysis_limit = 500 +# zh: 附件缓存配置。 +# en: Attachment cache settings. +[attachments] +# zh: 远程附件自动下载并缓存的最大大小(MB)。超过上限或设为 0 时只保留 URL 引用,不下载文件内容。 +# en: Max remote attachment size (MB) to download into cache. Above the limit, or 0, keeps only a URL reference. +remote_download_max_size_mb = 25 + # zh: Skills 热重载配置(可选)。 # en: Skills hot reload settings (optional). [skills] @@ -799,6 +830,8 @@ grok_search_enabled = false [proxy] # zh: 是否使用代理。 # en: Whether to use proxy. +# zh: 作用于 GitHub/arXiv 自动提取等走统一 HTTP 客户端的联网请求;关闭后也不会再读取代理环境变量。 +# en: Applies to GitHub/arXiv auto extraction and other shared HTTP-client requests; when disabled, proxy env vars are ignored. use_proxy = true # zh: 例如 http://127.0.0.1:7890(也可使用环境变量 "HTTP_PROXY")。 # en: e.g. http://127.0.0.1:7890 (or use the "HTTP_PROXY" environment variable). @@ -817,6 +850,13 @@ request_timeout_seconds = 30.0 # en: Default retry count (0-5). request_retries = 0 +# zh: HTML/Markdown 图片渲染配置。 +# en: HTML/Markdown image rendering settings. +[render] +# zh: 渲染浏览器最大同时开启数量。0 表示自动:Linux 默认 1,其它平台默认 2。 +# en: Max concurrent render browser pages. 0 = auto: Linux defaults to 1, other platforms default to 2. +browser_max_concurrency = 0 + # zh: 第三方 API 基础地址(便于自定义镜像或私有网关)。 # en: Third-party API base URLs (for mirrors or private gateways). [api_endpoints] @@ -942,6 +982,25 @@ author_preview_limit = 20 # en: Max summary preview characters in paper info. <=0 falls back to 1000. summary_preview_chars = 1000 +# zh: GitHub 仓库自动提取配置(仅获取 public 仓库信息)。 +# en: GitHub repository auto-extraction settings (public repositories only). +[github] +# zh: 是否启用自动提取(检测到 GitHub 仓库链接或 owner/repo 仓库 ID 时自动发送图片卡片)。 +# en: Enable auto-extraction (auto-send an image card when GitHub repository links or owner/repo IDs are detected). +auto_extract_enabled = false +# zh: GitHub API 请求超时(秒)。<=0 回退 10,>60 截断到 60。 +# en: GitHub API request timeout (seconds). <=0 falls back to 10, >60 is clamped to 60. +request_timeout_seconds = 10.0 +# zh: 自动提取功能的群聊白名单(空=跟随全局 access.allowed_group_ids)。 +# en: Group allowlist for auto-extraction (empty = follow global access.allowed_group_ids). +auto_extract_group_ids = [] +# zh: 自动提取功能的私聊白名单(空=跟随全局 access.allowed_private_ids)。 +# en: Private chat allowlist for auto-extraction (empty = follow global access.allowed_private_ids). +auto_extract_private_ids = [] +# zh: 单条消息最多自动处理几个 GitHub 仓库。<=0 回退 3,>10 截断到 10。 +# en: Max number of GitHub repositories to auto-process from one message. <=0 falls back to 3, >10 is clamped to 10. +auto_extract_max_items = 3 + # zh: Code Delivery Agent 配置(代码交付 Agent,在 Docker 容器中编写代码并打包上传)。 # en: Code Delivery Agent settings (writes code in Docker containers and delivers packaged results). [code_delivery] diff --git a/docs/auto-pipeline.md b/docs/auto-pipeline.md new file mode 100644 index 00000000..61a71303 --- /dev/null +++ b/docs/auto-pipeline.md @@ -0,0 +1,117 @@ +# 自动处理管线开发指南 + +自动处理管线位于 `src/Undefined/skills/auto_pipeline/`,用于在普通消息进入 AI 自动回复前执行自动提取,例如 Bilibili 视频、arXiv 论文和 GitHub 仓库卡片。斜杠命令优先级高于自动处理管线,命中命令后不会继续触发自动提取或 AI 回复。 + +`MessageHandler` 启动时会通过异步初始化在线程中加载管线配置和 handler 模块,避免目录扫描、`config.json` 读取和模块导入阻塞事件循环;注册 OneBot 消息回调前会等待首次加载完成,后续热重载也在线程中执行。 + +## 运行顺序 + +1. `MessageHandler` 先并行执行消息预处理:附件收集、历史文本解析、昵称或群信息读取等。 +2. 用户消息先写入历史。 +3. 若消息命中斜杠命令,立即分发命令并结束本轮后续流程;命令输入和命令输出会写入历史,供后续 AI 轮次读取。 +4. 未命中命令时,`AutoPipelineRegistry` 并行调用所有已注册管线的 `detect(context)`。 +5. 对所有命中的管线,并行调用对应的 `process(detection, context)`。 +6. 管线发送出的信息、图片、文件或视频摘要通过统一发送器写入历史;本地图片、文件和视频会自动登记为当前会话可见的统一附件 UID。 +7. 自动处理完成后,当前消息和管线输出一起进入 AI 自动回复/Agent 循环。 + +命中自动处理管线的消息会继续进入 AI 自动回复,让 AI 基于用户消息和刚写入的自动处理结果判断后续行为。 + +## 目录结构 + +```text +src/Undefined/skills/auto_pipeline/ +├── registry.py +├── models.py +└── pipelines/ + ├── bilibili/ + │ ├── config.json + │ └── handler.py + ├── arxiv/ + │ ├── config.json + │ └── handler.py + └── github/ + ├── config.json + └── handler.py +``` + +## `config.json` + +```json +{ + "name": "example", + "description": "检测并处理某类自动提取消息。", + "order": 100, + "enabled": true +} +``` + +- `name`: 管线唯一名称,必须与 `AutoPipelineDetection.name` 一致。 +- `description`: 日志和维护说明。 +- `order`: 注册排序字段,仅用于稳定展示和结果收集顺序;处理不依赖优先级。 +- `enabled`: 设为 `false` 时该管线不会加载。 + +## `handler.py` + +```python +from __future__ import annotations + +from Undefined.skills.auto_pipeline.models import AutoPipelineContext, AutoPipelineDetection + + +async def detect(context: AutoPipelineContext) -> AutoPipelineDetection | None: + text = str(context["text"]) + if "example" not in text: + return None + return AutoPipelineDetection(name="example", items=("example",)) + + +async def process( + detection: AutoPipelineDetection, + context: AutoPipelineContext, +) -> None: + sender = context["sender"] + target_id = int(context["target_id"]) + target_type = str(context["target_type"]) + message = f"自动处理结果: {', '.join(detection.items)}" + if target_type == "group": + await sender.send_group_message(target_id, message) + else: + await sender.send_private_message(target_id, message) +``` + +`detect` 应只做轻量检测和 ID 提取;`process` 执行下载、渲染、发送等重操作。发送消息应优先走 `MessageSender`,不要绕过历史写入。`MessageSender` 会自动将本地 CQ 图片、视频、语音以及 `send_group_file` / `send_private_file` 上传的文件登记为会话附件,让历史中带上 `pic_*` / `file_*` UID,便于后续 AI 回复引用;管线通常不需要单独处理附件登记。 + +## 附件 UID 绑定语义 + +- 外部接收的远程图片或文件默认会先下载并写入附件缓存,UID 绑定的是缓存中的文件内容;超过 `[attachments].remote_download_max_size_mb` 时会降级为 URL 引用,UID 绑定原始 URL 而不下载文件内容。原始 URL、OneBot `file` 标识或 WebUI 文件 ID 会保存在 `source_ref` / `segment_data` 中用于追溯。 +- 外部接收的本地路径、`file://` 路径或 WebUI 已上传文件会被复制到附件缓存,UID 同样绑定缓存副本,而不是直接绑定原路径。 +- 内部生成或发送的本地媒体、视频、语音和上传文件由 `MessageSender` 在发送成功后读取并登记,UID 绑定发送当时复制进缓存的内容;原始本地路径或 CQ `file` 字段作为来源信息保留。 + +## Context 字段 + +常用字段: + +- `config`: 当前运行配置。 +- `sender`: 统一消息发送器。 +- `onebot`: OneBot 客户端。 +- `target_id`: 群号或私聊用户 QQ。 +- `target_type`: `group` 或 `private`。 +- `text`: 当前消息纯文本。 +- `message_content`: 当前消息原始结构化段。 +- `extract_bilibili_ids`、`extract_arxiv_ids`、`extract_github_repo_ids`: 现有解析 helper。 +- `handle_bilibili_extract`、`handle_arxiv_extract`、`handle_github_extract`: 现有发送处理 helper。 + +新增管线可以复用已有解析器和发送器,避免重复网络、解析和历史写入逻辑。 + +## 热重载 + +`AutoPipelineRegistry` 监视 `config.json` 和 `handler.py`,并跟随 `[skills]` 配置: + +```toml +[skills] +hot_reload = true +hot_reload_interval = 2.0 +hot_reload_debounce = 0.5 +``` + +修改管线文件后,运行中的机器人会在去抖后重新加载管线。禁用 `[skills].hot_reload` 会同时停止自动处理管线、工具和 Agent 的热重载 watcher。 \ No newline at end of file diff --git a/docs/build.md b/docs/build.md index 17e7f3d1..229c5b94 100644 --- a/docs/build.md +++ b/docs/build.md @@ -28,56 +28,15 @@ uv sync --group dev -p 3.12 uv run playwright install ``` -### 系统级 LaTeX 环境(必装,用于 `render.render_latex`) +### LaTeX 渲染环境 -`render.render_latex` 使用系统外部 LaTeX(`usetex=True`)渲染公式,**必须提前安装**,否则渲染会失败并返回错误。 - -**Debian / Ubuntu** - -```bash -sudo apt-get update -sudo apt-get install -y texlive-full dvipng ghostscript -``` - -**Arch Linux** - -```bash -sudo pacman -S --needed \ - texlive-basic \ - texlive-bin \ - texlive-latex \ - texlive-latexrecommended \ - texlive-latexextra \ - texlive-fontsrecommended \ - texlive-binextra \ - texlive-mathscience \ - ghostscript -``` - -**macOS** +`render.render_latex` 会优先使用 Python 依赖中的 `matplotlib` mathtext 在本地渲染常见数学公式,不需要额外安装系统 TeX。mathtext 无法处理的复杂内容会回退到 MathJax + Playwright,因此请确保已经执行: ```bash -# 推荐 MacTeX(完整,约 4 GB) -brew install --cask mactex-no-gui - -# 或体积更小的 BasicTeX,之后按需补包 -brew install --cask basictex -sudo tlmgr update --self -sudo tlmgr install dvipng type1cm type1ec cm-super collection-fontsrecommended -``` - -**Windows** - -安装 [MiKTeX](https://miktex.org/download)(推荐,缺包时自动下载)或 [TeX Live](https://tug.org/texlive/windows.html)。安装完成后在 MiKTeX Console 里手动安装 `dvipng` 包,并确保 `latex.exe` 在 PATH 中。 - -**验证** - -```bash -latex --version -dvipng --version +uv run playwright install ``` -若日志出现 `type1ec.sty not found` 或 `latex was not able to process`,TeX 包仍不完整:Debian / Ubuntu 已装 `texlive-full` 则无需额外操作;Arch 补装 `texlive-latexextra` `texlive-fontsrecommended` `texlive-binextra`;macOS BasicTeX 用户运行 `sudo tlmgr install cm-super`。 +如果运行环境无法访问 MathJax CDN,请在配置中启用 HTTP/HTTPS 代理,或尽量使用 mathtext 支持的常见数学公式语法。 ### Node.js / Rust / Tauri diff --git a/docs/configuration.md b/docs/configuration.md index 7764a2db..65675a14 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -346,6 +346,8 @@ Prompt caching 补充: - 仅用于**请求体**字段,不包含 `api_key`、`base_url`、`timeout`、`extra_headers` 等 client 选项。 - 聊天类(`chat_completions`)保留字段:`model`、`messages`、`max_tokens`、`tools`、`tool_choice`、`stream`、`stream_options`、`thinking`、`reasoning`、`reasoning_effort`、`output_config`。 - 聊天类(`responses`)保留字段:`model`、`input`、`instructions`、`max_output_tokens`、`tools`、`tool_choice`、`previous_response_id`、`stream`、`stream_options`、`thinking`、`reasoning`、`reasoning_effort`、`output_config`。启用 `responses_force_stateless_replay` 时会主动跳过 `previous_response_id`。历史 `output` items 由运行时自动维护;不要通过 `request_params` 手工注入或覆盖 `function_call.id` / `call_id`。 +- 启用 `stream_enabled` 且使用 `chat_completions` 时,运行时会自动发送 `stream_options.include_usage=true`,以便 OpenAI 兼容接口在流式尾包返回 usage 并维持 token 统计。 +- 流式请求仅在明确的流式参数不兼容错误或 SDK 未实现时回退到非流式请求;鉴权、限流、网络、超时、解析或代码异常会直接暴露,便于定位真实问题。 - embedding 保留字段:`model`、`input`、`dimensions`。 - rerank 保留字段:`model`、`query`、`documents`、`top_n`、`return_documents`。 @@ -441,7 +443,15 @@ Prompt caching 补充: |---|---:|---| | `max_records` | `10000` | 每个会话最多保留条数 | -说明:该值主要在 `MessageHistoryManager` 初始化时使用,运行中修改建议重启后再观察效果。 +说明:该值主要在 `MessageHistoryManager` 初始化时使用,运行中修改建议重启后再观察效果。消息进入后会先同步写入内存历史,供命令、自动管线与 AI 后续流程立即读取;磁盘 JSON 持久化按会话在后台串行合并写入,连续消息会合并为最新快照,降低复读等快路径被大历史文件全量落盘阻塞的概率。 + +### 4.10.1 `[attachments]` 附件缓存 + +| 字段 | 默认值 | 说明 | +|---|---:|---| +| `remote_download_max_size_mb` | `25` | 远程附件自动下载并缓存的最大大小(MB)。超过上限时只登记 URL 引用;设为 `0` 可完全禁用远程附件下载 | + +外部接收的远程图片或文件默认会先下载到附件缓存再生成 UID,避免后续 URL 失效;大文件超过阈值时,UID 仍会生成,但绑定的是 URL 引用而不是缓存文件,AI 可在上下文中看到原始 `source_ref`。 --- @@ -489,6 +499,10 @@ Prompt caching 补充: 环境变量兜底: - 若 TOML 未配置 `http_proxy` / `https_proxy`,会尝试 `HTTP_PROXY` / `HTTPS_PROXY`。 +说明: +- 该配置会影响走统一 HTTP 请求封装的联网能力,例如 GitHub 仓库自动提取、arXiv 查询及部分第三方 API 请求。 +- 当 `use_proxy = false` 时,上述请求不会使用代理,也不会再读取代理环境变量。 + --- ### 4.14 `[network]` 网络请求默认参数 @@ -500,7 +514,20 @@ Prompt caching 补充: --- -### 4.15 `[api_endpoints]` 第三方 API 基址 +### 4.15 `[render]` HTML/Markdown 图片渲染 + +| 字段 | 默认值 | 说明 | 约束/回退 | +|---|---:|---|---| +| `browser_max_concurrency` | `0` | 渲染浏览器最大同时开启数量 | `<=0` 时启用自动值:Linux=`1`,其它平台=`2` | + +说明: +- 该配置只影响 `render.py` 的 HTML/Markdown 图片渲染链路,不影响 `crawl_webpage` 等独立浏览器实现。 +- 渲染浏览器当前采用单例复用,因此这里限制的是并发页面/上下文数量,而不是浏览器进程数量。 +- 配置变更会对后续新的渲染请求生效;已在执行中的渲染任务不受影响。 + +--- + +### 4.16 `[api_endpoints]` 第三方 API 基址 | 字段 | 默认值 | 说明 | |---|---:|---| @@ -511,7 +538,7 @@ Prompt caching 补充: --- -### 4.16 `[xxapi]` 与 `[weather]` +### 4.17 `[xxapi]` 与 `[weather]` | 字段 | 默认值 | 说明 | |---|---:|---| @@ -521,7 +548,7 @@ Prompt caching 补充: --- -### 4.17 `[token_usage]` Token 归档 +### 4.18 `[token_usage]` Token 归档 | 字段 | 默认值 | 说明 | |---|---:|---| @@ -537,7 +564,7 @@ Prompt caching 补充: --- -### 4.18 `[mcp]` +### 4.19 `[mcp]` | 字段 | 默认值 | 说明 | |---|---:|---| @@ -547,7 +574,7 @@ Prompt caching 补充: --- -### 4.19 `[messages]` 消息工具限制 +### 4.20 `[messages]` 消息工具限制 | 字段 | 默认值 | 说明 | 约束/回退 | |---|---:|---|---| @@ -556,7 +583,7 @@ Prompt caching 补充: --- -### 4.20 `[bilibili]` 自动提取 +### 4.21 `[bilibili]` 自动提取 | 字段 | 默认值 | 说明 | 约束/回退 | |---|---:|---|---| @@ -590,7 +617,32 @@ Prompt caching 补充: --- -### 4.21 `[code_delivery]` 代码交付 Agent +### 4.20.2 `[github]` 仓库自动提取 + +| 字段 | 默认值 | 说明 | 约束/回退 | +|---|---:|---|---| +| `auto_extract_enabled` | `false` | 是否自动提取 GitHub 仓库链接或 `owner/repo` 仓库 ID | | +| `request_timeout_seconds` | `10.0` | GitHub API 请求超时(秒) | `<=0` 回退 `10`,`>60` 截断到 `60` | +| `auto_extract_group_ids` | `[]` | 功能级群白名单 | 空时跟随全局 access | +| `auto_extract_private_ids` | `[]` | 功能级私聊白名单 | 空时跟随全局 access | +| `auto_extract_max_items` | `3` | 单条消息最多自动处理几个仓库 | `<=0` 回退 `3`,`>10` 截断到 `10` | + +触发规则: +- 命中 `https://github.com/owner/repo`、`github.com/owner/repo` 或 `git@github.com:owner/repo.git` 时触发。 +- 裸 `owner/repo` 会作为 GitHub 仓库 ID 尝试一次 public API 请求;失败时只记录日志,不向会话发送错误消息。 +- 仅支持 public 仓库。卡片渲染为图片,包含仓库 ID、作者头像、简介、stars、forks、issues、contributors、watchers、语言、许可证、默认分支和更新时间等信息。 +- GitHub API 请求默认复用全局 `[proxy]` 代理设置。 + +自动提取调度说明: +- 斜杠命令优先级高于自动处理管线;命中命令后直接分发并结束本轮后续处理,不会触发自动提取或 AI 自动回复。命令输入和命令输出会写入历史,供后续 AI 轮次读取。 +- 同一条消息内,自动处理管线会并行检测 Bilibili、arXiv、GitHub 等已注册管线。 +- 检测到多个管线时会并行处理全部命中结果;通常单条消息只会命中一个管线,因此不手动维护优先级。 +- 自动提取发送出的信息消息、图片卡片、文件或视频摘要会通过统一发送层写入消息历史,本地媒体和文件会自动登记为会话附件 UID,随后才进入 AI 自动回复,因此 AI 可以读取刚刚的自动提取结果。 +- 管线实现位于 `src/Undefined/skills/auto_pipeline/`,跟随 `[skills]` 热重载配置自动重新加载。开发新管线请参考 [自动处理管线开发指南](auto-pipeline.md)。 + +--- + +### 4.22 `[code_delivery]` 代码交付 Agent | 字段 | 默认值 | 说明 | 约束/回退 | |---|---:|---|---| @@ -613,7 +665,7 @@ Prompt caching 补充: --- -### 4.22 `[webui]` +### 4.23 `[webui]` | 字段 | 默认值 | 说明 | |---|---:|---| @@ -628,7 +680,7 @@ Prompt caching 补充: --- -### 4.23 `[api]` Runtime API / OpenAPI +### 4.24 `[api]` Runtime API / OpenAPI | 字段 | 默认值 | 说明 | |---|---:|---| @@ -647,7 +699,7 @@ Prompt caching 补充: --- -### 4.24 `[cognitive]` 认知记忆 +### 4.25 `[cognitive]` 认知记忆 ### 4.24.1 根配置 @@ -711,7 +763,7 @@ Prompt caching 补充: --- -### 4.25 `[memes]` 表情包库 +### 4.26 `[memes]` 表情包库 | 字段 | 默认值 | 说明 | 约束/回退 | |---|---:|---|---| @@ -743,7 +795,7 @@ Prompt caching 补充: - 第二阶段不做 OCR;向量存储和检索文本只使用纯文本 `description + tags + aliases`。 - 同一图片内容在单进程内会按 `SHA256` 串行入库,避免并发表情包重复写入。 - 若入库在写入来源记录或向量索引阶段失败,会回滚已写入的元数据与本地文件,避免残留孤儿记录。 -- 表情包与普通图片复用同一套图片 `uid` 语义。检索返回的 `uid` 既可用于 `memes.send_meme_by_uid`,也可直接用于 ``。 +- 表情包与普通图片复用同一套图片 `uid` 语义。检索返回的 `uid` 既可用于 `memes.send_meme_by_uid`,也可直接用于 ``。 - 检索模式: - `keyword`:只跑 SQLite FTS / LIKE 关键词检索;按空白切分后的中文、英文关键词都会参与 FTS 匹配 - `semantic`:只跑 Chroma 语义检索 @@ -751,7 +803,7 @@ Prompt caching 补充: - 关键词检索会按空白切分查询词项并构造 FTS phrase,因此中文标签、别名或描述词同样可以走 FTS 召回。 - `query_default_mode` 只影响 `memes.search_memes` 未显式传 `query_mode` 时的默认值。 -### 4.26 `[naga]` Naga 外部网关集成 +### 4.27 `[naga]` Naga 外部网关集成 > **⚠️ 此功能面向与 NagaAgent 对接的高级场景,普通用户不建议开启。** @@ -819,6 +871,8 @@ Prompt caching 补充: ### 5.3 明确“会执行热应用”的字段 - 模型发车间隔 / 模型名 / 模型池变更(队列间隔刷新) - `models.grok.model_name` / `models.grok.queue_interval_seconds`(队列间隔刷新) +- `models.summary` / `models.historian` / `models.grok` 的非队列字段会刷新 AI 运行时配置,但不会重建聊天、视觉或 Agent 模型客户端;其中 `models.summary` 热更新会重建摘要服务,聊天总结、摘要合并和标题生成会立即使用专用 summary 模型配置。 +- `render.browser_max_concurrency` 会在当前渲染任务空闲后重建渲染并发信号量。 - `skills.intro_autogen_*`(Agent intro 生成器配置刷新) - `search.searxng_url`(搜索客户端刷新) - `skills.hot_reload*`(技能热重载任务重启) diff --git a/docs/deployment.md b/docs/deployment.md index bc5eb1be..b936e4c3 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -47,44 +47,15 @@ uv sync uv run playwright install ``` -### 3. 安装系统级依赖(必装) +### 3. 安装渲染运行时 -Bot 内置的数学公式等功能直接强依赖系统级的渲染环境,你**必须**提前在宿主机配置以下依赖。若是缺失该依赖,渲染图像或公式时后台将直接报错。 +网页截图、Markdown 渲染和复杂 LaTeX 公式回退渲染依赖 Playwright 浏览器内核。源码部署时请执行: -**安装 LaTeX 与工具链**: - -- **Ubuntu / Debian** - 直接无脑安装完整的 TeX Live 环境最为稳妥: - ```bash - sudo apt-get update - sudo apt-get install -y texlive-full dvipng ghostscript - ``` - -- **Arch Linux** - 通过 pacman 安装基础包: - ```bash - sudo pacman -S --needed texlive-basic texlive-bin texlive-latex texlive-latexrecommended texlive-latexextra texlive-fontsrecommended texlive-binextra texlive-mathscience ghostscript - ``` - -- **macOS** - 推荐通过 Homebrew 安装 MacTeX 环境,提供完整(省心,体积较大)或者精简两个版本: - ```bash - # 方式 1:完整环境(推荐) - brew install --cask mactex-no-gui - - # 方式 2:精简版(体积小,需手动拉取补包) - brew install --cask basictex - sudo tlmgr update --self - sudo tlmgr install dvipng type1cm type1ec cm-super collection-fontsrecommended - ``` - -- **Windows** - 安装 [MiKTeX](https://miktex.org/download) (推荐,能自动下载缺失宏包)或者 [TeX Live](https://tug.org/texlive/windows.html)。 - 1. 打开 MiKTeX Console。 - 2. 搜索 `dvipng` 手动将其安装上。 - 3. 确认环境变量 `PATH` 中已经包含了 `latex.exe`。 +```bash +uv run playwright install +``` -> 验证安装:使用 `latex --version` 与 `dvipng --version` 命令检测是否识别。如日志报错 `type1ec.sty not found` 或 `dvipng: command not found`,一般是由于所处的系统少安装了包或可执行文件不在环境变量中。 +`render.render_latex` 会优先使用 Python 依赖中的 `matplotlib` mathtext 在本地渲染常见数学公式,不需要额外安装系统 TeX。mathtext 无法处理的复杂内容会回退到 MathJax + Playwright;如果运行环境无法访问 MathJax CDN,请在配置中启用 HTTP/HTTPS 代理。 ### 4. 配置环境 @@ -158,7 +129,7 @@ uv tool install Undefined-bot uv tool run --from Undefined-bot playwright install ``` -> **系统依赖提醒**:同源码部署要求一致,你必须在宿主机上预先安装所需的 LaTeX/dvipng 渲染环境。请参考上文 [3. 安装系统级依赖(必装)](#3-安装系统级依赖必装) 查阅你操作系统的对应安装命令,未配置前若触发公式与 Markdown 的图片渲染则会报错执行失败。 +> **渲染依赖提醒**:同源码部署要求一致,你需要在宿主机上预先安装 Playwright 浏览器内核。请参考上文 [3. 安装渲染运行时](#3-安装渲染运行时)。未配置前,网页截图、Markdown 渲染和复杂 LaTeX 公式回退渲染可能会失败。 安装完成后,在任意目录准备 `config.toml` 并启动: diff --git a/docs/memes.md b/docs/memes.md index e0f93e86..a01b6fc1 100644 --- a/docs/memes.md +++ b/docs/memes.md @@ -27,7 +27,8 @@ Undefined 平台自 3.3.0 版本起内置了强大的**全局表情包库**功 存储与索引完成后,AI Agent 会通过内置的 `memes.*` 系列工具使用表情包: - **`memes.search_memes`**:支持关键词检索(基于 SQLite)、语义检索(基于 ChromaDB 向量相似度)与混合检索(Hybrid)。AI 可借此根据当前对话的语境快速寻找最有梗的静态图或 GIF。 -- **发送机制**:使用统一的图片 `uid` 进行索引。系统不仅提供了 `memes.send_meme_by_uid` 让 AI 一键发送表情包,还支持 AI 输出 `` 统一资源标签指令进行图文混排。 +- **发送机制**:使用统一的图片 `uid` 进行索引。系统不仅提供了 `memes.send_meme_by_uid` 让 AI 一键发送表情包,还支持 AI 输出 `` 统一资源标签指令进行图文混排。 +- **回复顺序**:如果表情包本身就能完成表达,AI 可以直接搜索并发送表情包;如果同一轮既需要文字发言又想补表情包,提示词要求先发送必要文字,再把 `memes.search_memes` / `memes.send_meme_by_uid` 放到后续轮次,避免表情包检索拖慢首条回复体验。 ## 目录结构与配置 diff --git a/docs/openapi.md b/docs/openapi.md index 59b40234..836f979e 100644 --- a/docs/openapi.md +++ b/docs/openapi.md @@ -200,7 +200,7 @@ curl http://127.0.0.1:8788/openapi.json - `POST /api/v1/memes/{uid}/reindex` 说明: -- 表情包库条目使用统一图片 `uid`,与普通图片 `` 语义一致。 +- 表情包库条目使用统一图片 `uid`,与普通图片 `` 语义一致。 - 入库文本和向量索引只使用纯文本 `description + tags + aliases`,不依赖 OCR。 - 后台重跑分析使用两阶段 LLM 管线:先判定,再描述。 diff --git a/docs/slash-commands.md b/docs/slash-commands.md index f0baa17e..46ac175e 100644 --- a/docs/slash-commands.md +++ b/docs/slash-commands.md @@ -19,19 +19,23 @@ Undefined 提供了一套强大的斜杠指令(Slash Commands)系统。管 #### 1. 基础帮助与状态查询 - **/help [命令名]** - **说明**: - - 不带参数时,显示所有可用命令的快速速查表。 - - 带命令名时,显示该命令的统一格式帮助(命令元信息 + 命令目录下 README 文档内容)。 + - 不带参数时,默认将所有可用命令的快速速查表渲染为图片发送。 + - 带命令名时,默认将该命令的统一格式帮助渲染为图片发送(命令元信息 + 命令目录下 README 文档内容)。 + - 命令目录下 README 会作为 Markdown 渲染到图片中,而不是以源文件文本展示。 - **参数**: | 参数 | 是否必填 | 说明 | |------|----------|------| | `命令名` | 可选 | 目标命令名,支持带或不带 `/`,如 `stats` 或 `/stats` | + | `-t` | 可选 | 直接发送纯文本帮助,不进行图片渲染 | - **示例**: ``` /help + /help -t /help stats - /help /lsfaq + /help /faq + /help stats -t ``` - **/copyright** @@ -94,8 +98,8 @@ Undefined 提供了一套强大的斜杠指令(Slash Commands)系统。管 | 参数 | 是否必填 | 说明 | |------|----------|------| | `group` / `g` | 可选 | 查看群聊侧写(仅群聊可用) | - | `-f` / `--forward` | 可选 | 合并转发模式输出(默认) | - | `-r` / `--render` | 可选 | 渲染为图片发送 | + | `-f` / `--forward` | 可选 | 合并转发模式输出 | + | `-r` / `--render` | 可选 | 渲染为图片发送(默认) | | `-t` / `--text` | 可选 | 直接文本消息发送 | | `` | 可选 | 🔒 超管专用:查看指定用户的侧写 | | `g <群号>` | 可选 | 🔒 超管专用:查看指定群聊的侧写 | @@ -104,13 +108,14 @@ Undefined 提供了一套强大的斜杠指令(Slash Commands)系统。管 - **私聊**:查看自己的用户侧写,不支持 `group` 参数。 - **群聊**:不带参数查看自己的用户侧写,带 `group` / `g` 查看当前群聊侧写。 - **超管指定目标**:超级管理员可传入 QQ 号或群号查看任意用户/群的侧写,非超管使用时提示无权限。 - - **输出模式**:默认合并转发;`-r` 渲染为图片;`-t` 直接文本发送。 + - **输出模式**:默认渲染为图片;`-f` 合并转发;`-t` 直接文本发送。 - **限流**:普通用户 60 秒,管理员 10 秒,超管无限制。 - **示例**: ``` - /profile → 查看自己的侧写(合并转发) - /p -r → 查看自己的侧写(渲染图片) + /profile → 查看自己的侧写(渲染图片) + /p -f → 查看自己的侧写(合并转发) /p -t → 查看自己的侧写(直接文本) + /p -r → 查看自己的侧写(显式渲染图片) /me → 同上(别名) /profile group → 查看当前群聊的侧写 /p g → 同上 @@ -210,43 +215,27 @@ Undefined 提供了一套强大的斜杠指令(Slash Commands)系统。管 #### 5. 本地群级 FAQ 系统 用于对常见问题(FAQ)进行检索和管理。FAQ 不必每次请求 AI 大模型,极大地节省 Token 并加快响应。 -- **/lsfaq** - - **说明**:列出当前群组的所有 FAQ 条目(最多显示 20 条,超出部分提示剩余数量)。 - - **参数**:无 - - **返回内容**:每条 FAQ 显示其 ID、标题和创建日期。 - - **示例**:`/lsfaq` - -- **/searchfaq \<关键词\>** - - **说明**:在当前群组的 FAQ 库中按关键词进行全文搜索,最多返回 10 条结果。 - - **参数**: - - | 参数 | 是否必填 | 说明 | - |------|----------|------| - | `关键词` | 必填 | 支持多词(空格分隔),合并为一个搜索字符串 | - - - **示例**:`/searchfaq 登录问题` - -- **/viewfaq \** - - **说明**:查看指定 ID 的 FAQ 完整内容。 - - **参数**: - - | 参数 | 是否必填 | 说明 | - |------|----------|------| - | `ID` | 必填 | FAQ 的唯一 ID,格式形如 `20241205-001` | - - - **返回内容**:FAQ 标题、ID、分析对象 QQ、时间范围、创建时间和完整正文。 - - **示例**:`/viewfaq 20241205-001` - -- **/delfaq \** - - **说明**:删除指定 ID 的 FAQ。需要管理员或超管权限。 - - **参数**: - - | 参数 | 是否必填 | 说明 | - |------|----------|------| - | `ID` | 必填 | FAQ 的唯一 ID,格式形如 `20241205-001` | - - - **边界行为**:若 ID 不存在,返回"FAQ 不存在"提示。 - - **示例**:`/delfaq 20241205-001` +- **/faq [子命令] [参数]**(别名 `/f`) + - **说明**:FAQ 管理统一入口,支持子命令和自动推断。 + - **子命令**: + + | 子命令 | 用法 | 权限 | 说明 | + |--------|------|------|------| + | `ls` | `/faq ls` | 公开 | 列出当前群组所有 FAQ 条目(最多 20 条) | + | `view` | `/faq view ` | 公开 | 查看指定 ID 的 FAQ 完整内容 | + | `search` | `/faq search <关键词>` | 公开 | 按关键词搜索 FAQ(最多 10 条) | + | `del` | `/faq del ` | 管理员 | 删除指定 ID 的 FAQ | + + - **自动推断**:无需显式写子命令,系统自动推断意图: + - 无参数 `/faq` → 列表(ls) + - 参数为 ID 格式(如 `20241205-001`)→ 查看(view) + - 参数为非 ID 格式(如 `登录`)→ 搜索(search) + - 显式子命令优先,不会被推断覆盖 + - **示例**: + - `/faq` — 列出所有 FAQ + - `/faq 20241205-001` — 查看 FAQ(自动推断为 view) + - `/faq 登录` — 搜索 FAQ(自动推断为 search) + - `/faq del 20241205-001` — 删除 FAQ(需管理员) #### 6. 排障与反馈 - **/bugfix \ [QQ号2...] \<开始时间\> \<结束时间\>** @@ -285,7 +274,7 @@ Undefined 提供了一套强大的斜杠指令(Slash Commands)系统。管 | `bind ` | 公开 | 仅群聊 | 在当前白名单群提交绑定申请,生成 `bind_uuid` 并发送到 Naga 端等待回调确认 | | `unbind ` | 超管 | 群聊/私聊 | 吊销当前绑定,并 best-effort 通知远端同步解绑 | - **权限模型**:命令入口 `config.json` 声明 `"permission": "public"`(允许所有人触发),实际权限由 `scopes.json` 按子命令细粒度控制(详见下方"scopes.json 子命令权限"一节)。 + **权限模型**:`bind` 子命令配置 `permission: public` + `allow_in_private: false`(仅群聊可用),`unbind` 子命令配置 `permission: superadmin`(仅超管可用),均在 `config.json` 的 `subcommands` 中声明,分发层自动检查。 - **示例**: ``` @@ -346,12 +335,25 @@ src/Undefined/ }, "show_in_help": true, "order": 100, - "aliases": ["hi", "helloworld"] + "aliases": ["hi", "helloworld"], + "subcommands": { + "list": { "description": "列出打招呼记录", "permission": "public" }, + "greet": { "description": "执行打招呼", "args": "<目标>", "permission": "admin" } + }, + "inference": { + "default": "greet", + "rules": [ + { "pattern": "^[a-z]+$", "subcommand": "greet" } + ], + "fallback": "greet" + } } ``` *提示: `permission` 可选 `public` / `admin` / `superadmin`。* *提示: `allow_in_private` 控制该命令是否允许在私聊中通过 `/命令` 直接触发,默认 `false`。* *提示: `rate_limit` 单独指定各级使用者的独立调用冷却拦截秒级时间(0代表无限制)。* +*提示:可选字段 `subcommands` 为子命令声明,每个子命令可独立配置 `description`(必填)、`permission`、`allow_in_private`、`rate_limit`、`args`(参数格式,用于帮助展示);缺省值继承父命令。* +*提示:可选字段 `inference` 为自动推断配置,`default` 为无参数时推断的子命令,`rules` 为正则匹配列表(按顺序匹配 `args[0]`),`fallback` 为规则都不命中时的兜底子命令。* *提示:可选字段 `help_footer` 为字符串数组,主要用于 `/help` 这类命令在列表页尾部输出固定提示文案。* #### B. 执行逻辑 (`handler.py`) @@ -377,7 +379,7 @@ async def execute(args: list[str], context: CommandContext) -> None: ``` #### C. 说明文档 (`README.md`) -`README.md` 会被 `/help ` 自动读取并拼接到统一帮助模板中,建议保持简洁、可读、结构稳定(推荐包含“功能 / 用法 / 参数 / 示例 / 说明”)。 +`README.md` 会被 `/help ` 自动读取并作为 Markdown 渲染到统一帮助图片中。若需要纯文本可使用 `/help -t`。建议保持文档简洁、可读、结构稳定(推荐包含“功能 / 用法 / 参数 / 示例 / 说明”)。 ```md # /hello 命令说明 @@ -416,78 +418,56 @@ async def execute(args: list[str], context: CommandContext) -> None: > **可见性**:`/help` 会根据当前用户的权限级别过滤命令列表。`superadmin` 权限的命令不会对普通用户显示;`admin` 权限的命令不会对非管理员显示。 -### 4. `scopes.json` — 子命令权限控制 +### 4. 子命令声明式注册与自动推断 -对于拥有多个子命令的复合命令(如 `/naga`),可以在命令目录下新建 `scopes.json` 文件,按子命令名声明独立的权限与作用域。 +对于拥有多个子命令的复合命令(如 `/faq`),在 `config.json` 中用 `subcommands` 声明各子命令,用 `inference` 配置自动推断逻辑。 -#### 基本格式 +#### 子命令声明 ```json { - "bind": "group_only", - "unbind": "superadmin" + "subcommands": { + "ls": { "description": "列出所有FAQ" }, + "view": { "description": "查看FAQ详情", "args": "" }, + "search":{ "description": "搜索FAQ", "args": "<关键词>" }, + "del": { "description": "删除FAQ", "permission": "admin", "args": "" } + } } ``` -#### 可用的 scope 值 - -| 值 | 别名 | 含义 | -|----|------|------| -| `public` | — | 任何人、任何场景均可使用 | -| `admin` | `admin_only` | 仅管理员及超管可使用 | -| `superadmin` | `superadmin_only` | 仅超级管理员可使用 | -| `group_only` | — | 任何人均可使用,但仅限群聊场景 | -| `private_only` | — | 任何人均可使用,但仅限私聊场景 | - -**说明**: -- 未在 `scopes.json` 中列出的子命令默认视为 `superadmin`。 -- `scopes.json` 由命令 handler 自行加载和校验,注册表不直接读取。 -- 使用 `group_only`/`private_only` 时,scope 同时隐含 **权限为 public**(只限制场景,不限制身份)。 +子命令字段(均可选,缺省继承父命令): +- `description` — 子命令描述(必填) +- `permission` — 权限级别,默认继承父命令 +- `allow_in_private` — 是否允许私聊,默认继承父命令 +- `rate_limit` — 限流配置,默认继承父命令(仅覆盖指定字段) +- `args` — 参数格式如 ``,用于 `/help` 详情展示 -#### handler 中如何使用 +子命令限流按父命令与解析后的子命令共同计数,例如 `/faq ls` 与 `/faq search` 不会互相占用冷却时间。 -在 handler 中加载 scopes.json 并调用检查函数: +#### 自动推断 -```python -import json -from pathlib import Path +```json +{ + "inference": { + "default": "ls", + "rules": [ + { "pattern": "^\\d{8}-\\d{3}$", "subcommand": "view" } + ], + "fallback": "search" + } +} +``` -_SCOPES_FILE = Path(__file__).parent / "scopes.json" +- `default`:无参数时推断为该子命令 +- `rules`:正则匹配列表,按顺序匹配 `args[0]`,命中则推断为对应子命令;命中后 args 保留为子命令的参数 +- `fallback`:所有规则都不命中时,推断为该子命令,原始参数整体传入 -_SCOPE_ALIASES: dict[str, str] = { - "admin_only": "admin", - "superadmin_only": "superadmin", -} +#### 权限与作用域 -def _load_scopes() -> dict[str, str]: - try: - with open(_SCOPES_FILE, encoding="utf-8") as f: - data = json.load(f) - return {str(k): str(v) for k, v in data.items()} - except Exception: - return {} - -def _check_scope(subcmd: str, sender_id: int, context) -> str | None: - """返回错误提示或 None 表示通过。""" - scopes = _load_scopes() - raw = scopes.get(subcmd, "superadmin") - scope = _SCOPE_ALIASES.get(raw, raw) - - if scope == "group_only": - return "该子命令仅限群聊使用" if context.scope != "group" else None - if scope == "private_only": - return "该子命令仅限私聊使用" if context.scope != "private" else None - if scope == "public": - return None - if scope == "superadmin" and context.config.is_superadmin(sender_id): - return None - if scope == "admin" and ( - context.config.is_admin(sender_id) - or context.config.is_superadmin(sender_id) - ): - return None - return "权限不足" -``` +分发层会自动处理: +- `args[0]` 显式匹配子命令名 → 直接使用该子命令的元信息 +- 无匹配则按 `inference` 推断 → 推断出的子命令同样做权限/作用域/限流检查 +- 权限检查、作用域检查、限流均在分发层统一完成,handler 无需内部再判断 ### 5. 自动注册与生效 diff --git a/docs/usage.md b/docs/usage.md index 66dbc307..3f96aa61 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -129,7 +129,7 @@ Undefined 搭载了基于 ChromaDB 向量数据库的后台认知系统,无需 | 工具 | 说明 | |---|---| | `render.render_markdown` | 将 Markdown 文本(含表格、代码块、标题等)渲染为图片发送 | -| `render.render_latex` | 将 LaTeX 数学公式渲染为图片(**依赖系统 TeX 环境**,需提前安装,详见[部署文档](deployment.md#3-安装系统级依赖必装)) | +| `render.render_latex` | 将 LaTeX 数学公式渲染为图片;常见公式本地渲染,复杂内容回退 MathJax + Playwright(详见[部署文档](deployment.md#3-安装渲染运行时)) | | `render.render_html` | 将 HTML 内容渲染为图片 | 支持 `embed`(嵌入回复)和 `send`(直接发送)两种图片交付方式。 @@ -180,14 +180,10 @@ Undefined 搭载了基于 ChromaDB 向量数据库的后台认知系统,无需 | `group.find_member` | 按昵称/备注搜索群成员 | | `group.get_member_title` | 获取成员群头衔 | | `group.get_honor_info` | 查询群荣誉(龙王、话唠等) | -| `group.get_member_activity` | 分析群成员活跃度(支持 member_list / history / hybrid 三种数据源模式) | -| `group.rank_members` | 对群成员进行多维度排名 | -| `group.filter_members` | 按条件过滤群成员 | -| `group.detect_inactive_risk` | 检测长期潜水有流失风险的成员 | -| `group.activity_trend` | 分析群活跃度趋势变化 | -| `group.level_distribution` | 统计群成员等级分布 | | `group.get_files` | 获取群文件列表 | +群聊统计、排行、活跃度和风险识别等分析类能力统一归入 `group_analysis.*`。 + **示例:** > *"帮我查一下这个群里近 30 天没说过话的成员有哪些。"* > *"请列出本群最近发言最多的前 10 名成员。"* @@ -198,9 +194,21 @@ Undefined 搭载了基于 ChromaDB 向量数据库的后台认知系统,无需 | 工具 | 说明 | |---|---| -| `group_analysis.analyze_member_messages` | 深度分析指定成员的消息数量、类型分布和活跃时段 | -| `group_analysis.analyze_join_statistics` | 统计群成员加入趋势与留存情况 | -| `group_analysis.analyze_new_member_activity` | 分析新成员加入后的活跃度变化 | +| `group_analysis.member_structure` | 统计角色分布、等级概览、入群时间覆盖和最后发言分层等成员结构事实 | +| `group_analysis.message_mix` | 统计消息类型分布、活跃时段、活跃星期、时间覆盖和最近消息样本 | +| `group_analysis.member_activity` | 分析群成员活跃度(支持 member_list / history / hybrid 三种数据源模式) | +| `group_analysis.rank_members` | 对群成员进行多维度排名 | +| `group_analysis.filter_members` | 按角色、等级、入群时间、活跃时间等条件过滤群成员 | +| `group_analysis.inactive_risk` | 检测长期潜水或新成员沉默等活跃风险 | +| `group_analysis.activity_trend` | 分析群活跃趋势变化 | +| `group_analysis.level_distribution` | 统计群成员等级分布 | +| `group_analysis.member_messages` | 深度分析指定成员的消息数量、类型分布和活跃时段 | +| `group_analysis.join_statistics` | 统计群成员加入趋势与留存情况 | +| `group_analysis.new_member_activity` | 分析新成员加入后的活跃度变化 | + +**示例:** +> *"帮我分析一下这个群最近整体活跃度怎么样。"* +> *"这个群有没有潜水风险比较高的成员?顺便看看新人加入情况。"* --- @@ -308,12 +316,9 @@ Undefined 搭载了基于 ChromaDB 向量数据库的后台认知系统,无需 Bot 支持在运行时维护一个结构化的群专属 FAQ 知识库,可通过斜杠指令进行增删查操作。 -| 指令 | 权限 | 说明 | -|---|---|---| -| `/lsfaq` | 公开 | 列出当前群的全部 FAQ 条目 | -| `/viewfaq ` | 公开 | 查看指定 FAQ 的详细内容 | -| `/searchfaq <关键词>` | 公开 | 按关键词搜索匹配的 FAQ | -| `/delfaq ` | 管理员 | 删除指定 ID 的 FAQ 条目 | +| 指令 | 别名 | 权限 | 说明 | +|---|---|---|---| +| `/faq [子命令] [参数]` | `/f` | 公开(del 需管理员) | FAQ 管理:列表/查看/搜索/删除,支持自动推断子命令 | --- @@ -323,18 +328,15 @@ Bot 支持在运行时维护一个结构化的群专属 FAQ 知识库,可通 | 指令 | 别名 | 权限 | 私聊 | 说明 | |---|---|---|---|---| -| `/help [命令名]` | — | 公开 | ✅ | 显示命令列表;附带命令名时展示该命令的详细帮助文档 | +| `/help [命令名] [-t]` | — | 公开 | ✅ | 默认以图片展示命令列表或详细帮助,`-t` 输出纯文本 | | `/version` | `/v` | 公开 | ✅ | 查看当前版本号及最新版本变更标题 | | `/changelog [子命令]` | `/cl` | 公开 | ✅ | 查看版本更新日志(详见下方说明) | | `/copyright` | `/about` `/license` `/cprt` | 公开 | ✅ | 查看版权信息与 MIT 许可证声明 | | `/stats [天数] [--ai]` | — | 公开 | ✅ | 查看 Token 使用统计图表;附加 `--ai` 启用 AI 智能分析报告 | -| `/lsfaq` | — | 公开 | ❌ | 列出当前群的全部 FAQ | -| `/viewfaq ` | — | 公开 | ❌ | 查看指定 FAQ 详情 | -| `/searchfaq <关键词>` | — | 公开 | ❌ | 按关键词搜索 FAQ | -| `/delfaq ` | — | 管理员 | ❌ | 删除指定 FAQ | +| `/faq [子命令] [参数]` | `/f` | 公开 | ❌ | FAQ 管理:列表/查看/搜索/删除,支持自动推断子命令 | | `/bugfix [起止时间]` | — | 管理员 | ❌ | 基于目标用户近期发言生成娱乐性 Bug 修复报告 | | `/lsadmin` | — | 管理员 | ✅ | 查看系统当前的超管与管理员列表 | -| `/naga ` | — | 公开 | ✅ | 绑定或解绑关联的 NagaAgent 实例 | +| `/naga ` | — | 公开 | ✅ | 绑定或解绑关联的 NagaAgent 实例;bind 仅群聊,unbind 需超管 | | `/addadmin ` | — | **超级管理员** | ✅ | 将指定用户提权为普通管理员 | | `/rmadmin ` | — | **超级管理员** | ✅ | 撤销指定用户的管理员权限 | diff --git a/pyproject.toml b/pyproject.toml index 6dcc6a32..16311cd6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "Undefined-bot" -version = "3.3.2" +version = "3.3.3" description = "QQ bot platform with cognitive memory architecture and multi-agent Skills, via OneBot V11." readme = "README.md" authors = [ diff --git a/res/prompts/describe_meme_image.txt b/res/prompts/describe_meme_image.txt index e223e567..f54a08dd 100644 --- a/res/prompts/describe_meme_image.txt +++ b/res/prompts/describe_meme_image.txt @@ -18,7 +18,8 @@ - 情绪/语气:无语、震惊、委屈、嘲讽、得意、阴阳怪气、崩溃、开心、嫌弃 - 动作/状态:翻白眼、鼓掌、捂脸、叹气、偷笑、流汗、发呆、摊手 - 使用场景:敷衍回应、上班吐槽、群聊附和、阴阳怪气、看乐子、尴尬收场 -- 如果图中文字本身就是表情包语义的一部分,可以概括它表达的意思,但不要逐字转录。 + - 图中文字/OCR:图片内写的关键字句、台词、配文、对话气泡内容等 +- 图中如果有文字(配字、台词、对话气泡、水印等),必须把文字内容融入描述,这是搜索命中的重要依据。短文字尽量原文写入描述,长文字可以概括核心关键词句,但不要完全忽略图中文字。 - `description` 不能只写情绪和用途,也必须写出图片内容本身:谁/什么在画面里、表情如何、动作如何、画面是什么类型。 - 如果画面里有明显视觉特征,也要写出来,例如: - 主体是谁或是什么 @@ -52,8 +53,8 @@ - 不要堆重复词,例如 `无语`、`很无语`、`超级无语` 这种不要同时出现。 额外限制: -- 不要输出 OCR。 -- 不要逐字抄录图中文字。 +- 不要逐字抄录大段长文,但短句、关键词、配字应尽量原文写入描述。 +- 不要输出独立的 OCR 字段或逐行转录,文字内容应自然融入 description。 - 不要输出 Markdown,不要输出额外解释。 - 你必须且只能调用 `submit_meme_description`。 - 如果收到的是一张网格图(多帧拼接)或多张图片,说明原图是动图/GIF:描述应涵盖动图的动态变化过程和整体语义,不要只描述单帧;可以在 tags 里加上 `动图` 标签。 @@ -71,9 +72,13 @@ `tags`: [`猫猫`, `嫌弃`, `无语`, `翻白眼`] - `description`: `真人聊天截图里尴尬陪笑又有点敷衍的表情包` `tags`: [`真人`, `聊天截图`, `尴尬`, `敷衍`] +- `description`: `熊猫头配字“行吧”无奈妥协表情包` + `tags`: [`熊猫头`, `无奈`, `行吧`, `配字`] +- `description`: `猫猫捂脸配字“我裂开了”崩溃反应图` + `tags`: [`猫猫`, `崩溃`, `我裂开了`, `捂脸`] 差的例子: - `description`: `一张表情包` - `description`: `一个猫的图片` -- `description`: `图片上写了几句话` +- `description`: `图片上写了字`(只提有字但不写具体内容) - `description`: `表达无语` diff --git a/res/prompts/undefined.xml b/res/prompts/undefined.xml index b8e503a5..8c809b57 100644 --- a/res/prompts/undefined.xml +++ b/res/prompts/undefined.xml @@ -132,15 +132,30 @@ **关键点:每次消息处理都必须以 end 结束,这是维持对话流的核心机制。** + + **自动处理管线结果:** + 在你收到当前消息前,系统可能已经并行处理了当前消息中的 Bilibili、arXiv、GitHub 等自动提取内容。 + 这些预处理输出会紧邻当前消息写入历史,可能包含 Bot 发送的信息卡片、图片、文件、视频摘要或附件 UID。 + 你应把这些紧邻的 Bot 消息视为当前消息的预处理结果,可直接基于它们继续回应;除非用户明确要求重新处理,不要重复调用同类下载、解析或卡片生成工具。 + + + + **斜杠命令历史:** + 斜杠命令优先于自动处理管线;命中命令后,本轮不会继续进入自动提取或 AI 自动回复。 + 用户发出的命令消息和 Bot 发送的命令结果会写入消息历史;后续对话中看到这些相邻的 Bot 消息时,应把它们当作已经执行过的命令结果,不要无故重复执行同一命令。 + + + **图文混排规则:** - - 如果你已经决定要回复,并且表情包能让表达更像真人,默认先尝试表情包,而不是先写文字 + - 如果你已经决定要回复,并且只靠表情包就能完成表达,默认先尝试表情包,而不是先写文字 + - 如果本轮既需要文字发言又想配表情包,先调用 `send_message` 发出必要文字;`memes.search_memes` 和 `memes.send_meme_by_uid` 放到后续响应轮次再做,因为表情包检索可能拖慢首条回复体验 - 如果要发送独立表情包,先用 `memes.search_memes` 找到合适的图片 `uid`,再用 `memes.send_meme_by_uid` 单独发送一条图片消息 - 对于吐槽、附和、接梗、表达态度、表达情绪这类回复,只要表情包能完成表达,就应该直接发表情包,不要用文字去“描述你本来想发的表情包” - 对于私聊对话、被拍一拍、被@、轻量答疑这类本来就会回复的场景,只要表情包能自然增强语气、缓和语气或让回复更像真人,也可以配合使用 - 除非 `memes.search_memes` 没找到合适结果,或表情包会干扰信息传递,否则不要把本来适合发图的反应先写成一句话来代替发图 - 表情包相关规则只决定“怎么回复”,不单独构成“该不该回复”的参与许可;是否回复仍以前面的回复触发逻辑为准 - - 默认不要把表情包和正文写进同一条消息;需要补一句解释时,优先分成两条消息发送 + - 默认不要把表情包和正文写进同一条消息;需要补一句解释时,优先分成两条消息发送;如果文字本身是必要回复,先发文字,再延后检索和发送表情包 - 推荐使用统一标签 `` 引用任何附件(图片或文件),系统根据 UID 前缀自动处理: - `pic_*` UID → 内嵌为图片(等效于旧 `` 语法) - `file_*` UID → 作为独立文件消息在文字之后发出 @@ -148,6 +163,7 @@ - `` 是推荐的统一语法,适用于所有类型的附件 - 可以图文混排,例如:`我给你介绍一下`\n``\n`如图所示` - 文件附件在文字消息发出后作为独立文件消息依次发送,不会混排在文字中 + - 有些远程大附件会只登记为 URL 引用,附件上下文会给出 `source_ref`/说明;这类 `file_*` 不一定有本地缓存,回复时应基于 URL 引用说明,不要假设一定能作为本地文件重新发送 - 表情包库返回的图片 UID 也可以直接用于 `` - 只能引用工具结果或上下文里明确给出的图片 UID,禁止臆造 UID - 不要把 `file_*` UID 放进 `` 标签(会报类型错误) @@ -191,7 +207,7 @@ 每条一个要点,可以多条。宁可多提取,不要遗漏。**严格一条一个要点**,不要把多个信息塞进同一条——拆成多条分别写入。 不适合写入 observations 的:纯流水账(”回复了一句话”、”决定不回复”、”调用了search工具”)——这类无回忆价值的动作如果需要记,写到 memo。 **群聊场景下的积极观察**:即使你决定不回复,也应积极观察群聊动态,提取有价值的信息写入 observations。注意观察:话题趋势变化、成员关系互动、群聊氛围/事件、新成员发言特征等。宁可多记几条,也不要遗漏有潜在价值的信息。 - 格式要求:每条具体、绝对化(写明谁、什么时候、在哪里),避免代词和相对时间,不要复述已知记忆。 + 格式要求:每条具体、绝对化(写明谁、什么时候、在哪里),避免代词和相对时间,不要复述已知记忆。记录 observations 时必须包含 QQ 号,格式如:"QQ号12345678(昵称张三)做了某事"——昵称会变但 QQ 号不变。 若当前消息在转述第三方人物/群成员的信息,必须按原文实体记录(昵称/QQ号);禁止默认改写成当前 sender。 如果同一条内容已写入 observations,不要重复写入 memory.add。 @@ -414,6 +430,7 @@ 只对最近的消息进行回复,已经回复过的不再重复回复 充分利用上下文(历史消息、时间戳)进行推理 在回复前,理解对话的连贯性和流向 + 识别和称呼用户时以 QQ 号(sender_id)为准,昵称可能随时变动。需要称呼用户时使用当前最新昵称(群名片优先,其次 QQ 昵称)。不确定最新昵称时,可调用 group.get_member_info 并设置 brief=true 快速查询。 看清发言者名字/QQ号与对话对象,确认对方在明确和你讲话才回复 如果之前你在讨论某个话题,回复时要自然延续 如果别人在回应你的话,要做出相应反应 @@ -613,7 +630,7 @@ 只有在需要分析图片内容时才调用 file_analysis_agent(如报错截图/界面/文档/图片问题) 当消息中出现“[图片: xxx]”占位符时,xxx 即为 file_id 或 URL,可直接作为 file_source 调用 file_analysis_agent 未调用 file_analysis_agent 时,不要猜测图片内容;可以说明“我看不到图片内容,需要先分析” - 即使已分析图片,也要再次判断是否需要发言;如果明显在和别人说或你拿不准是不是在对你说,默认不回复;若你已经决定回复,并且表情包不会干扰信息传递,先尝试 memes.search_memes 并使用独立表情包回应,默认由表情包承担主要表达,文字只作极短补充 + 即使已分析图片,也要再次判断是否需要发言;如果明显在和别人说或你拿不准是不是在对你说,默认不回复;若你已经决定回复,并且只靠表情包就能完成表达,可尝试 memes.search_memes 并使用独立表情包回应;若还需要文字发言,先 send_message 发送必要文字,再把表情包检索和发送放到后续轮次 回复时不要描述图片内容,像正常人一样直接回应重点 不要分析每条图片。图片分析有很大延迟,只有需要时才分析 diff --git a/res/prompts/undefined_nagaagent.xml b/res/prompts/undefined_nagaagent.xml index 205b99f5..9dbf1cf4 100644 --- a/res/prompts/undefined_nagaagent.xml +++ b/res/prompts/undefined_nagaagent.xml @@ -132,15 +132,30 @@ **关键点:每次消息处理都必须以 end 结束,这是维持对话流的核心机制。** + + **自动处理管线结果:** + 在你收到当前消息前,系统可能已经并行处理了当前消息中的 Bilibili、arXiv、GitHub 等自动提取内容。 + 这些预处理输出会紧邻当前消息写入历史,可能包含 Bot 发送的信息卡片、图片、文件、视频摘要或附件 UID。 + 你应把这些紧邻的 Bot 消息视为当前消息的预处理结果,可直接基于它们继续回应;除非用户明确要求重新处理,不要重复调用同类下载、解析或卡片生成工具。 + + + + **斜杠命令历史:** + 斜杠命令优先于自动处理管线;命中命令后,本轮不会继续进入自动提取或 AI 自动回复。 + 用户发出的命令消息和 Bot 发送的命令结果会写入消息历史;后续对话中看到这些相邻的 Bot 消息时,应把它们当作已经执行过的命令结果,不要无故重复执行同一命令。 + + + **图文混排规则:** - - 如果你已经决定要回复,并且表情包能让表达更像真人,默认先尝试表情包,而不是先写文字 + - 如果你已经决定要回复,并且只靠表情包就能完成表达,默认先尝试表情包,而不是先写文字 + - 如果本轮既需要文字发言又想配表情包,先调用 `send_message` 发出必要文字;`memes.search_memes` 和 `memes.send_meme_by_uid` 放到后续响应轮次再做,因为表情包检索可能拖慢首条回复体验 - 如果要发送独立表情包,先用 `memes.search_memes` 找到合适的图片 `uid`,再用 `memes.send_meme_by_uid` 单独发送一条图片消息 - 对于吐槽、附和、接梗、表达态度、表达情绪这类回复,只要表情包能完成表达,就应该直接发表情包,不要用文字去“描述你本来想发的表情包” - 对于私聊对话、被拍一拍、被@、轻量答疑这类本来就会回复的场景,只要表情包能自然增强语气、缓和语气或让回复更像真人,也可以配合使用 - 除非 `memes.search_memes` 没找到合适结果,或表情包会干扰信息传递,否则不要把本来适合发图的反应先写成一句话来代替发图 - 表情包相关规则只决定“怎么回复”,不单独构成“该不该回复”的参与许可;是否回复仍以前面的回复触发逻辑为准 - - 默认不要把表情包和正文写进同一条消息;需要补一句解释时,优先分成两条消息发送 + - 默认不要把表情包和正文写进同一条消息;需要补一句解释时,优先分成两条消息发送;如果文字本身是必要回复,先发文字,再延后检索和发送表情包 - 推荐使用统一标签 `` 引用任何附件(图片或文件),系统根据 UID 前缀自动处理: - `pic_*` UID → 内嵌为图片(等效于旧 `` 语法) - `file_*` UID → 作为独立文件消息在文字之后发出 @@ -148,6 +163,7 @@ - `` 是推荐的统一语法,适用于所有类型的附件 - 可以图文混排,例如:`我给你介绍一下`\n``\n`如图所示` - 文件附件在文字消息发出后作为独立文件消息依次发送,不会混排在文字中 + - 有些远程大附件会只登记为 URL 引用,附件上下文会给出 `source_ref`/说明;这类 `file_*` 不一定有本地缓存,回复时应基于 URL 引用说明,不要假设一定能作为本地文件重新发送 - 表情包库返回的图片 UID 也可以直接用于 `` - 只能引用工具结果或上下文里明确给出的图片 UID,禁止臆造 UID - 不要把 `file_*` UID 放进 `` 标签(会报类型错误) @@ -467,7 +483,7 @@ **启动前信息充足度闸门:** 在决定启动任何业务工具或 Agent 前,只围绕最后一条消息判断四件事: - 1. 当前任务对象是否明确 + 1. 当前任务对象是否明确(优先从上下文推断,推断不了或不确定时再追问) 2. 目标产物 / 目标动作是否明确 3. 会显著影响结果的关键参数是否已给出 4. 是否仍存在会导致明显误做或重做的关键歧义 @@ -659,7 +675,7 @@ 只有在需要分析图片内容时才调用 file_analysis_agent(如报错截图/界面/文档/图片问题) 当消息中出现“[图片: xxx]”占位符时,xxx 即为 file_id 或 URL,可直接作为 file_source 调用 file_analysis_agent 未调用 file_analysis_agent 时,不要猜测图片内容;可以说明“我看不到图片内容,需要先分析” - 即使已分析图片,也要再次判断是否需要发言;如果明显在和别人说或你拿不准是不是在对你说,默认不回复;若你已经决定回复,并且表情包不会干扰信息传递,先尝试 memes.search_memes 并使用独立表情包回应,默认由表情包承担主要表达,文字只作极短补充 + 即使已分析图片,也要再次判断是否需要发言;如果明显在和别人说或你拿不准是不是在对你说,默认不回复;若你已经决定回复,并且只靠表情包就能完成表达,可尝试 memes.search_memes 并使用独立表情包回应;若还需要文字发言,先 send_message 发送必要文字,再把表情包检索和发送放到后续轮次 回复时不要描述图片内容,像正常人一样直接回应重点 不要分析每条图片。图片分析有很大延迟,只有需要时才分析 diff --git a/src/Undefined/README.md b/src/Undefined/README.md index 272816a9..46a55b58 100644 --- a/src/Undefined/README.md +++ b/src/Undefined/README.md @@ -6,6 +6,7 @@ - `ai/`:模型请求、提示词构建、工具调度、多模态与总结能力 - `arxiv/`:arXiv 论文解析、元信息获取、PDF 下载与发送(自动触发 + AI 工具) - `bilibili/`:B 站视频解析、下载与发送(自动触发 + AI 工具) +- `github/`:GitHub public 仓库链接/ID 解析、API 获取与图片卡片发送(自动触发) - `memes/`:全局表情包库、多模态解析管线与异步存储 - `services/`:安全、命令、队列与协调服务 - `skills/`:工具与智能体插件系统 diff --git a/src/Undefined/__init__.py b/src/Undefined/__init__.py index c2c8768d..74f5cd58 100644 --- a/src/Undefined/__init__.py +++ b/src/Undefined/__init__.py @@ -1,3 +1,3 @@ """Undefined - A high-performance, highly scalable QQ group and private chat robot based on a self-developed architecture.""" -__version__ = "3.3.2" +__version__ = "3.3.3" diff --git a/src/Undefined/ai/client.py b/src/Undefined/ai/client.py index 404ad87a..a46f4ce5 100644 --- a/src/Undefined/ai/client.py +++ b/src/Undefined/ai/client.py @@ -98,6 +98,23 @@ def __call__( ) +def _attachment_remote_download_max_bytes(runtime_config: Config) -> int: + value = int(runtime_config.attachment_remote_download_max_size_mb) + return max(0, value) * 1024 * 1024 + + +def _resolve_summary_model_config( + runtime_config: Config | None, + chat_config: ChatModelConfig, +) -> ChatModelConfig | AgentModelConfig: + if runtime_config is None: + return chat_config + if not getattr(runtime_config, "summary_model_configured", False): + return chat_config + summary_model = getattr(runtime_config, "summary_model", None) + return summary_model if summary_model is not None else chat_config + + class AIClient: """AI 模型客户端""" @@ -138,7 +155,15 @@ def __init__( self._knowledge_manager: Any = None self._cognitive_service: Any = cognitive_service self._meme_service: Any = None - self.attachment_registry = AttachmentRegistry(http_client=self._http_client) + if self.runtime_config is not None: + self.attachment_registry = AttachmentRegistry( + http_client=self._http_client, + remote_download_max_bytes=_attachment_remote_download_max_bytes( + self.runtime_config + ), + ) + else: + self.attachment_registry = AttachmentRegistry(http_client=self._http_client) # 私聊发送回调 self._send_private_message_callback: Optional[SendPrivateMessageCallback] = None @@ -265,9 +290,7 @@ def __init__( cognitive_service=self._cognitive_service, ) self._multimodal = MultimodalAnalyzer(self._requester, self.vision_config) - self._summary_service = SummaryService( - self._requester, self.chat_config, self._token_counter - ) + self._rebuild_summary_service() async def init_mcp_async() -> None: try: @@ -639,6 +662,47 @@ def apply_search_config(self, searxng_url: str) -> None: self._search_wrapper = None logger.info("[配置] 搜索服务已回退为禁用") + def apply_model_configs( + self, + *, + chat_config: ChatModelConfig, + vision_config: VisionModelConfig, + agent_config: AgentModelConfig, + runtime_config: Config, + ) -> None: + """应用热更新后的模型配置。""" + self.chat_config = chat_config + self.vision_config = vision_config + self.agent_config = agent_config + self.runtime_config = runtime_config + self._multimodal = MultimodalAnalyzer(self._requester, self.vision_config) + self._rebuild_summary_service() + self.apply_attachment_config(runtime_config) + logger.info( + "[配置] AI 模型配置已热更新: chat=%s vision=%s agent=%s", + self.chat_config.model_name, + self.vision_config.model_name, + self.agent_config.model_name, + ) + + def apply_runtime_config(self, runtime_config: Config) -> None: + """应用不需要重建模型客户端的运行时配置。""" + self.runtime_config = runtime_config + self._rebuild_summary_service() + logger.info("[配置] AI 运行时配置已热更新") + + def _rebuild_summary_service(self) -> None: + self._summary_service = SummaryService( + self._requester, + _resolve_summary_model_config(self.runtime_config, self.chat_config), + self._token_counter, + ) + + def apply_attachment_config(self, runtime_config: Config) -> None: + self.attachment_registry.set_remote_download_max_bytes( + _attachment_remote_download_max_bytes(runtime_config) + ) + def count_tokens(self, text: str) -> int: return self._token_counter.count(text) diff --git a/src/Undefined/ai/llm.py b/src/Undefined/ai/llm.py index 028d122d..a79a2982 100644 --- a/src/Undefined/ai/llm.py +++ b/src/Undefined/ai/llm.py @@ -502,6 +502,160 @@ def _split_responses_params( return known, extra +def _without_stream_request_fields(body: dict[str, Any]) -> dict[str, Any]: + stripped = dict(body) + stripped.pop("stream", None) + stripped.pop("stream_options", None) + return stripped + + +def _ensure_chat_stream_usage_options(body: dict[str, Any]) -> None: + stream_options = body.get("stream_options") + if stream_options is None: + body["stream_options"] = {"include_usage": True} + return + if isinstance(stream_options, dict) and "include_usage" not in stream_options: + body["stream_options"] = {**stream_options, "include_usage": True} + + +_STREAM_FALLBACK_STATUS_CODES = {400, 404, 405, 422, 501} +_STREAM_FALLBACK_ERROR_MARKERS = ( + "stream", + "stream_options", + "streaming", + "not support", + "unsupported", + "unrecognized", + "unknown parameter", + "unexpected parameter", +) + + +def _status_error_text(exc: APIStatusError) -> str: + parts = [str(exc)] + body = getattr(exc, "body", None) + if isinstance(body, dict): + parts.append(json.dumps(body, ensure_ascii=False, default=str)) + elif body is not None: + parts.append(str(body)) + response = getattr(exc, "response", None) + if response is not None: + try: + parts.append(response.text) + except Exception: + pass + return "\n".join(part for part in parts if part).lower() + + +def _should_fallback_from_stream(exc: Exception) -> bool: + if isinstance(exc, NotImplementedError): + return True + if not isinstance(exc, APIStatusError): + return False + if exc.status_code not in _STREAM_FALLBACK_STATUS_CODES: + return False + text = _status_error_text(exc) + return any(marker in text for marker in _STREAM_FALLBACK_ERROR_MARKERS) + + +def _stringify_stream_delta(value: Any) -> str: + if value is None: + return "" + if isinstance(value, str): + return value + if isinstance(value, list): + parts = [_stringify_stream_delta(item) for item in value] + return "".join(part for part in parts if part) + if isinstance(value, dict): + for key in ("text", "content", "delta", "value"): + if value.get(key) is not None: + return _stringify_stream_delta(value.get(key)) + return "" + return str(value) + + +def _extract_stream_response_item(event: dict[str, Any]) -> dict[str, Any] | None: + for key in ("item", "output_item", "data"): + value = event.get(key) + if isinstance(value, dict): + return value + response = event.get("response") + if isinstance(response, dict) and isinstance(response.get("output"), list): + return None + if isinstance(response, dict): + return response + return None + + +def _extract_stream_usage( + event: dict[str, Any], *, api_mode: str +) -> dict[str, Any] | None: + usage = event.get("usage") + if not isinstance(usage, dict): + response = event.get("response") + if isinstance(response, dict) and isinstance(response.get("usage"), dict): + usage = response.get("usage") + if not isinstance(usage, dict): + return None + if api_mode == API_MODE_RESPONSES: + return { + "input_tokens": int(usage.get("input_tokens", 0) or 0), + "output_tokens": int(usage.get("output_tokens", 0) or 0), + "total_tokens": int(usage.get("total_tokens", 0) or 0), + } + return { + "prompt_tokens": int(usage.get("prompt_tokens", 0) or 0), + "completion_tokens": int(usage.get("completion_tokens", 0) or 0), + "total_tokens": int(usage.get("total_tokens", 0) or 0), + } + + +def _ensure_tool_call_slot( + tool_calls: list[dict[str, Any]], index: int +) -> dict[str, Any]: + while len(tool_calls) <= index: + tool_calls.append( + { + "id": "", + "type": "function", + "function": {"name": "", "arguments": ""}, + } + ) + return tool_calls[index] + + +def _merge_tool_call_delta( + target_tool_calls: list[dict[str, Any]], tool_delta: dict[str, Any] +) -> None: + index = tool_delta.get("index") + try: + slot_index = int(index) if index is not None else len(target_tool_calls) + except (TypeError, ValueError): + slot_index = len(target_tool_calls) + tool_call = _ensure_tool_call_slot(target_tool_calls, slot_index) + call_id = str(tool_delta.get("id") or "").strip() + if call_id: + tool_call["id"] = call_id + tool_type = str(tool_delta.get("type") or "").strip() + if tool_type: + tool_call["type"] = tool_type + function_delta = tool_delta.get("function") + if not isinstance(function_delta, dict): + return + function = tool_call.setdefault("function", {"name": "", "arguments": ""}) + if not isinstance(function, dict): + function = {"name": "", "arguments": ""} + tool_call["function"] = function + function_name = str(function_delta.get("name") or "").strip() + if function_name: + function["name"] = function_name + arguments_delta = function_delta.get("arguments") + if arguments_delta is not None: + function["arguments"] = str(function.get("arguments") or "") + str( + arguments_delta + ) + + def _is_deepseek_provider(model_config: ModelConfig) -> bool: model_name = str(getattr(model_config, "model_name", "") or "").lower() if model_name.startswith("deepseek"): @@ -1380,6 +1534,21 @@ async def _request_with_openai( self, model_config: ModelConfig, request_body: dict[str, Any] ) -> dict[str, Any]: client = self._get_openai_client_for_model(model_config) + if bool(getattr(model_config, "stream_enabled", False)): + try: + return await self._request_with_openai_streaming( + client, model_config, request_body + ) + except Exception as exc: + if not _should_fallback_from_stream(exc): + raise + logger.warning( + "[API流式回退] model=%s api_mode=%s reason=%s", + getattr(model_config, "model_name", ""), + get_api_mode(model_config), + type(exc).__name__, + ) + request_body = _without_stream_request_fields(request_body) if get_api_mode(model_config) == API_MODE_RESPONSES: params, extra_body = _split_responses_params(request_body) if extra_body: @@ -1392,6 +1561,141 @@ async def _request_with_openai( response = await client.chat.completions.create(**params) return self._response_to_dict(response) + async def _request_with_openai_streaming( + self, + client: AsyncOpenAI, + model_config: ModelConfig, + request_body: dict[str, Any], + ) -> dict[str, Any]: + api_mode = get_api_mode(model_config) + stream_body = dict(request_body) + stream_body["stream"] = True + if api_mode == API_MODE_RESPONSES: + return await self._stream_responses_request(client, stream_body) + _ensure_chat_stream_usage_options(stream_body) + return await self._stream_chat_completions_request(client, stream_body) + + async def _stream_chat_completions_request( + self, client: AsyncOpenAI, request_body: dict[str, Any] + ) -> dict[str, Any]: + params, extra_body = _split_chat_completion_params(request_body) + if extra_body: + params["extra_body"] = extra_body + response = await client.chat.completions.create(**params) + + content_parts: list[str] = [] + tool_calls: list[dict[str, Any]] = [] + usage: dict[str, Any] | None = None + finish_reason = "stop" + role = "assistant" + + async for chunk in response: + chunk_dict = self._response_to_dict(chunk) + usage = ( + _extract_stream_usage(chunk_dict, api_mode=API_MODE_CHAT_COMPLETIONS) + or usage + ) + choices = chunk_dict.get("choices") + if not isinstance(choices, list): + continue + for choice in choices: + if not isinstance(choice, dict): + continue + delta = choice.get("delta") + if not isinstance(delta, dict): + continue + role_value = str(delta.get("role") or "").strip() + if role_value: + role = role_value + content_delta = _stringify_stream_delta(delta.get("content")) + if content_delta: + content_parts.append(content_delta) + raw_tool_calls = delta.get("tool_calls") + if isinstance(raw_tool_calls, list): + for tool_delta in raw_tool_calls: + if isinstance(tool_delta, dict): + _merge_tool_call_delta(tool_calls, tool_delta) + current_finish_reason = str(choice.get("finish_reason") or "").strip() + if current_finish_reason: + finish_reason = current_finish_reason + + message: dict[str, Any] = { + "role": role, + "content": "".join(content_parts), + } + if tool_calls: + message["tool_calls"] = tool_calls + result: dict[str, Any] = { + "choices": [ + { + "index": 0, + "message": message, + "finish_reason": finish_reason, + } + ] + } + if usage is not None: + result["usage"] = usage + return result + + async def _stream_responses_request( + self, client: AsyncOpenAI, request_body: dict[str, Any] + ) -> dict[str, Any]: + params, extra_body = _split_responses_params(request_body) + if extra_body: + params["extra_body"] = extra_body + stream = await client.responses.create(**params) + + output_items: list[dict[str, Any]] = [] + output_text_parts: list[str] = [] + usage: dict[str, Any] | None = None + final_response: dict[str, Any] | None = None + + async for event in stream: + event_dict = self._response_to_dict(event) + usage = ( + _extract_stream_usage(event_dict, api_mode=API_MODE_RESPONSES) or usage + ) + event_type = str(event_dict.get("type") or "").strip().lower() + response = event_dict.get("response") + if isinstance(response, dict): + final_response = response + if event_type == "response.output_text.delta": + delta = _stringify_stream_delta(event_dict.get("delta")) + if delta: + output_text_parts.append(delta) + continue + if event_type == "response.completed": + if isinstance(response, dict): + final_response = response + continue + item = _extract_stream_response_item(event_dict) + if not isinstance(item, dict): + continue + item_type = str(item.get("type") or "").strip().lower() + if item_type == "message": + output_items.append(item) + continue + if item_type == "function_call": + output_items.append(item) + continue + if item_type == "reasoning": + output_items.append(item) + + if final_response is not None: + if usage is not None and not isinstance(final_response.get("usage"), dict): + final_response = dict(final_response) + final_response["usage"] = usage + return final_response + + synthesized: dict[str, Any] = { + "output": output_items, + "output_text": "".join(output_text_parts), + } + if usage is not None: + synthesized["usage"] = usage + return synthesized + async def embed( self, model_config: EmbeddingModelConfig, diff --git a/src/Undefined/ai/model_selector.py b/src/Undefined/ai/model_selector.py index e934c4d1..e64ae60e 100644 --- a/src/Undefined/ai/model_selector.py +++ b/src/Undefined/ai/model_selector.py @@ -156,7 +156,7 @@ def try_resolve_compare(self, group_id: int, user_id: int, text: str) -> str | N self._pending_compares.pop(key, None) return None - match = re.match(r"选\s*(\d+)", text.strip()) + match = re.fullmatch(r"选\s*(\d+)", text.strip()) if not match: return None idx = int(match.group(1)) @@ -251,6 +251,8 @@ def _entry_to_chat_config( prompt_cache_enabled=entry.prompt_cache_enabled, reasoning_enabled=entry.reasoning_enabled, reasoning_effort=entry.reasoning_effort, + reasoning_effort_style=entry.reasoning_effort_style, + stream_enabled=entry.stream_enabled, request_params=entry.request_params, pool=primary.pool, ) @@ -276,6 +278,8 @@ def _entry_to_agent_config( prompt_cache_enabled=entry.prompt_cache_enabled, reasoning_enabled=entry.reasoning_enabled, reasoning_effort=entry.reasoning_effort, + reasoning_effort_style=entry.reasoning_effort_style, + stream_enabled=entry.stream_enabled, request_params=entry.request_params, pool=primary.pool, ) diff --git a/src/Undefined/ai/summaries.py b/src/Undefined/ai/summaries.py index fb2b6925..f45d6d33 100644 --- a/src/Undefined/ai/summaries.py +++ b/src/Undefined/ai/summaries.py @@ -10,7 +10,7 @@ from Undefined.ai.llm import ModelRequester from Undefined.ai.parsing import extract_choices_content from Undefined.ai.tokens import TokenCounter -from Undefined.config import ChatModelConfig +from Undefined.config import AgentModelConfig, ChatModelConfig from Undefined.utils.logging import log_debug_json from Undefined.utils.resources import read_text_resource @@ -32,7 +32,7 @@ class SummaryService: def __init__( self, requester: ModelRequester, - chat_config: ChatModelConfig, + chat_config: ChatModelConfig | AgentModelConfig, token_counter: TokenCounter, summarize_prompt_path: str = "res/prompts/summarize.txt", merge_prompt_path: str = "res/prompts/merge_summaries.txt", diff --git a/src/Undefined/ai/transports/openai_transport.py b/src/Undefined/ai/transports/openai_transport.py index 64f96286..ecb68741 100644 --- a/src/Undefined/ai/transports/openai_transport.py +++ b/src/Undefined/ai/transports/openai_transport.py @@ -598,9 +598,9 @@ def normalize_responses_result( } ) - content = "\n".join(text for text in assistant_texts if text).strip() + content = "\n".join(text for text in assistant_texts if text) if not content and "output_text" in result: - content = str(result["output_text"]).strip() + content = str(result["output_text"]) message: dict[str, Any] = { "role": "assistant", diff --git a/src/Undefined/api/_probes.py b/src/Undefined/api/_probes.py index fc51bb3a..021a4cd1 100644 --- a/src/Undefined/api/_probes.py +++ b/src/Undefined/api/_probes.py @@ -99,6 +99,8 @@ def _build_internal_model_probe_payload(mcfg: Any) -> dict[str, Any]: ) if hasattr(mcfg, "prompt_cache_enabled"): payload["prompt_cache_enabled"] = getattr(mcfg, "prompt_cache_enabled", True) + if hasattr(mcfg, "stream_enabled"): + payload["stream_enabled"] = getattr(mcfg, "stream_enabled", False) if hasattr(mcfg, "reasoning_enabled"): payload["reasoning_enabled"] = getattr(mcfg, "reasoning_enabled", False) if hasattr(mcfg, "reasoning_effort"): diff --git a/src/Undefined/arxiv/sender.py b/src/Undefined/arxiv/sender.py index 1cecf06b..499dc49d 100644 --- a/src/Undefined/arxiv/sender.py +++ b/src/Undefined/arxiv/sender.py @@ -126,9 +126,9 @@ async def _send_text_message( message: str, ) -> None: if target_type == "group": - await sender.send_group_message(target_id, message, auto_history=False) + await sender.send_group_message(target_id, message) else: - await sender.send_private_message(target_id, message, auto_history=False) + await sender.send_private_message(target_id, message) async def _send_file_message( @@ -139,13 +139,9 @@ async def _send_file_message( file_name: str, ) -> None: if target_type == "group": - await sender.send_group_file( - target_id, file_path, file_name, auto_history=False - ) + await sender.send_group_file(target_id, file_path, file_name) else: - await sender.send_private_file( - target_id, file_path, file_name, auto_history=False - ) + await sender.send_private_file(target_id, file_path, file_name) async def _send_arxiv_paper_once( diff --git a/src/Undefined/attachments.py b/src/Undefined/attachments.py index 403154dc..d257f6c9 100644 --- a/src/Undefined/attachments.py +++ b/src/Undefined/attachments.py @@ -70,6 +70,7 @@ _FORWARD_ATTACHMENT_MAX_DEPTH = 3 _ATTACHMENT_CACHE_MAX_AGE_SECONDS = 7 * 24 * 60 * 60 _ATTACHMENT_REGISTRY_MAX_RECORDS = 2000 +_DEFAULT_REMOTE_DOWNLOAD_MAX_BYTES = 25 * 1024 * 1024 @dataclass(frozen=True) @@ -98,6 +99,8 @@ def prompt_ref(self) -> dict[str, str]: } if self.source_kind.strip(): ref["source_kind"] = self.source_kind.strip() + if self.local_path is None and self.source_ref.strip(): + ref["source_ref"] = self.source_ref.strip() if self.semantic_kind.strip(): ref["semantic_kind"] = self.semantic_kind.strip() if self.description.strip(): @@ -123,6 +126,11 @@ class AttachmentRenderError(RuntimeError): """Raised when an attachment tag cannot be rendered.""" +class _RemoteAttachmentTooLarge(Exception): + def __init__(self, mime_type: str = "") -> None: + self.mime_type = mime_type + + def _now_iso() -> str: return datetime.now().isoformat(timespec="seconds") @@ -231,6 +239,9 @@ def attachment_refs_to_xml( source_kind = str(item.get("source_kind", "") or "").strip() if source_kind: attrs.append(f'source_kind="{escape_xml_attr(source_kind)}"') + source_ref = str(item.get("source_ref", "") or "").strip() + if source_ref: + attrs.append(f'source_ref="{escape_xml_attr(source_ref)}"') semantic_kind = str(item.get("semantic_kind", "") or "").strip() if semantic_kind: attrs.append(f'semantic_kind="{escape_xml_attr(semantic_kind)}"') @@ -322,6 +333,15 @@ def _media_kind_from_value(value: str) -> str: return "file" +def _remote_reference_source_kind(source_kind: str) -> str: + cleaned = str(source_kind or "").strip() + if not cleaned: + return "remote_url_reference" + if cleaned.endswith("_reference"): + return cleaned + return f"{cleaned}_reference" + + def _segment_text( type_: str, data: Mapping[str, Any], ref: Mapping[str, str] | None ) -> str: @@ -439,12 +459,14 @@ def __init__( http_client: httpx.AsyncClient | None = None, max_records: int = _ATTACHMENT_REGISTRY_MAX_RECORDS, max_age_seconds: int = _ATTACHMENT_CACHE_MAX_AGE_SECONDS, + remote_download_max_bytes: int = _DEFAULT_REMOTE_DOWNLOAD_MAX_BYTES, ) -> None: self._registry_path = registry_path self._cache_dir = cache_dir self._http_client = http_client self._max_records = max(0, int(max_records)) self._max_age_seconds = max(0, int(max_age_seconds)) + self._remote_download_max_bytes = max(0, int(remote_download_max_bytes)) self._lock = asyncio.Lock() self._records: dict[str, AttachmentRecord] = {} self._loaded = False @@ -456,6 +478,9 @@ def __init__( Callable[[str], Awaitable[AttachmentRecord | None]] | None ) = None + def set_remote_download_max_bytes(self, value: int) -> None: + self._remote_download_max_bytes = max(0, int(value)) + def set_global_image_resolver( self, resolver: Callable[[str], AttachmentRecord | None] | None, @@ -489,6 +514,16 @@ def _prune_records(self) -> bool: for uid, record in self._records.items(): cache_path = self._resolve_managed_cache_path(record.local_path) + if record.local_path is None: + try: + mtime = datetime.fromisoformat(record.created_at).timestamp() + except ValueError: + mtime = now + if self._max_age_seconds > 0 and now - mtime > self._max_age_seconds: + dirty = True + continue + retained.append((uid, record, None, mtime)) + continue if cache_path is None or not cache_path.is_file(): dirty = True continue @@ -835,26 +870,152 @@ async def register_remote_url( source_ref: str = "", segment_data: Mapping[str, str] | None = None, ) -> AttachmentRecord: - timeout = httpx.Timeout(_DEFAULT_REMOTE_TIMEOUT_SECONDS) - if self._http_client is not None: - response = await self._http_client.get( - url, timeout=timeout, follow_redirects=True - ) - else: - async with httpx.AsyncClient( - timeout=timeout, follow_redirects=True - ) as client: - response = await client.get(url) - response.raise_for_status() name = display_name or _display_name_from_source(url, "attachment.bin") - mime_type = response.headers.get("content-type", "").split(";", 1)[0].strip() - return await self.register_bytes( + return await self._register_remote_url_or_reference( scope_key, - response.content, + url, kind=kind, display_name=name, source_kind=source_kind, source_ref=source_ref or url, + segment_data=segment_data, + ) + + async def register_remote_reference( + self, + scope_key: str, + url: str, + *, + kind: str, + display_name: str | None = None, + source_kind: str = "remote_url_reference", + source_ref: str = "", + mime_type: str | None = None, + segment_data: Mapping[str, str] | None = None, + description: str = "", + ) -> AttachmentRecord: + await self.load() + normalized_kind = _media_kind_from_value(kind) + normalized_media_type = ( + "image" if normalized_kind == "image" else normalized_kind + ) + prefix = "pic" if normalized_media_type == "image" else "file" + ref = source_ref or url + name = display_name or _display_name_from_source(url, "attachment.bin") + digest_hex = hashlib.sha256(ref.encode("utf-8")).hexdigest() + + async with self._lock: + for existing in self._records.values(): + if ( + existing.scope_key == scope_key + and existing.kind == normalized_kind + and existing.local_path is None + and existing.source_ref == ref + ): + return existing + + uid = self._build_uid(prefix) + record = AttachmentRecord( + uid=uid, + scope_key=scope_key, + kind=normalized_kind, + media_type=normalized_media_type, + display_name=name, + source_kind=source_kind, + source_ref=ref, + local_path=None, + mime_type=mime_type or mimetypes.guess_type(name)[0] or "", + sha256=digest_hex, + created_at=_now_iso(), + segment_data={ + str(k): str(v) + for k, v in dict(segment_data or {}).items() + if str(k).strip() and str(v).strip() + }, + description=description, + ) + self._records[uid] = record + self._prune_records() + await self._persist() + return record + + async def _register_remote_url_or_reference( + self, + scope_key: str, + url: str, + *, + kind: str, + display_name: str, + source_kind: str, + source_ref: str, + segment_data: Mapping[str, str] | None, + ) -> AttachmentRecord: + timeout = httpx.Timeout(_DEFAULT_REMOTE_TIMEOUT_SECONDS) + max_bytes = self._remote_download_max_bytes + reference_segment_data = dict(segment_data or {}) + if source_ref and source_ref != url: + reference_segment_data.setdefault("original_source_ref", source_ref) + if max_bytes <= 0: + return await self.register_remote_reference( + scope_key, + url, + kind=kind, + display_name=display_name, + source_kind=_remote_reference_source_kind(source_kind), + source_ref=url, + segment_data=reference_segment_data, + description="远程附件未下载:remote_download_max_size_mb=0", + ) + + async def _stream(client: httpx.AsyncClient) -> tuple[bytes, str]: + async with client.stream( + "GET", url, timeout=timeout, follow_redirects=True + ) as response: + response.raise_for_status() + mime_type = ( + response.headers.get("content-type", "").split(";", 1)[0].strip() + ) + raw_length = response.headers.get("content-length", "").strip() + if raw_length.isdigit() and int(raw_length) > max_bytes: + raise _RemoteAttachmentTooLarge(mime_type) + + chunks: list[bytes] = [] + total = 0 + async for chunk in response.aiter_bytes(): + total += len(chunk) + if total > max_bytes: + raise _RemoteAttachmentTooLarge(mime_type) + chunks.append(chunk) + return b"".join(chunks), mime_type + + try: + if self._http_client is not None: + content, mime_type = await _stream(self._http_client) + else: + async with httpx.AsyncClient( + timeout=timeout, follow_redirects=True + ) as client: + content, mime_type = await _stream(client) + except _RemoteAttachmentTooLarge as exc: + return await self.register_remote_reference( + scope_key, + url, + kind=kind, + display_name=display_name, + source_kind=_remote_reference_source_kind(source_kind), + source_ref=url, + mime_type=exc.mime_type, + segment_data=reference_segment_data, + description=f"远程附件超过下载上限 {max_bytes} bytes,保留 URL 引用。", + ) + + return await self.register_bytes( + scope_key, + content, + kind=kind, + display_name=display_name, + source_kind=source_kind, + source_ref=source_ref, mime_type=mime_type or None, segment_data=segment_data, ) @@ -1201,7 +1362,11 @@ def _render_image_tag( for key, value in dict(getattr(record, "segment_data", {}) or {}).items(): cleaned_key = str(key or "").strip() cleaned_value = str(value or "").strip() - if not cleaned_key or not cleaned_value or cleaned_key == "file": + if ( + not cleaned_key + or not cleaned_value + or cleaned_key in {"file", "original_source_ref"} + ): continue cq_args.append( f"{_escape_cq_component(cleaned_key)}={_escape_cq_component(cleaned_value)}" diff --git a/src/Undefined/bilibili/sender.py b/src/Undefined/bilibili/sender.py index ee240711..9be5392d 100644 --- a/src/Undefined/bilibili/sender.py +++ b/src/Undefined/bilibili/sender.py @@ -40,6 +40,23 @@ def _build_info_card(info: "VideoInfo", truncate_desc: bool = True) -> str: return "\n".join(parts) +def _build_video_history_message( + info: "VideoInfo", + *, + quality_name: str, + file_size_mb: float, +) -> str: + return "\n".join( + [ + f"[视频] 「{info.title}」", + f"UP主: {info.up_name}", + f"清晰度: {quality_name}", + f"大小: {file_size_mb:.1f}MB", + f"https://www.bilibili.com/video/{info.bvid}", + ] + ) + + async def send_bilibili_video( video_id: str, sender: MessageSender, @@ -159,7 +176,17 @@ async def send_bilibili_video( pre_video_card = _build_info_card(video_info, truncate_desc=False) await _send_message(sender, target_type, target_id, pre_video_card) - await _send_message(sender, target_type, target_id, video_message) + await _send_message( + sender, + target_type, + target_id, + video_message, + history_message=_build_video_history_message( + video_info, + quality_name=quality_name, + file_size_mb=file_size_mb, + ), + ) result = f"已发送视频「{video_info.title}」({quality_name}, {file_size_mb:.1f}MB)" except Exception as exc: logger.warning("[Bilibili] 视频发送失败:", exc) @@ -201,9 +228,22 @@ async def _send_message( target_type: Literal["group", "private"], target_id: int, message: str, + *, + history_message: str | None = None, + attachments: list[dict[str, str]] | None = None, ) -> None: """根据目标类型发送消息。""" if target_type == "group": - await sender.send_group_message(target_id, message, auto_history=False) + await sender.send_group_message( + target_id, + message, + history_message=history_message, + attachments=attachments, + ) else: - await sender.send_private_message(target_id, message, auto_history=False) + await sender.send_private_message( + target_id, + message, + history_message=history_message, + attachments=attachments, + ) diff --git a/src/Undefined/config/hot_reload.py b/src/Undefined/config/hot_reload.py index f0d83c9d..1ba6dc3a 100644 --- a/src/Undefined/config/hot_reload.py +++ b/src/Undefined/config/hot_reload.py @@ -4,6 +4,7 @@ import logging from dataclasses import dataclass from pathlib import Path +from typing import TYPE_CHECKING, Any from Undefined.ai import AIClient from Undefined.config import Config @@ -13,6 +14,9 @@ from Undefined.skills.agents.intro_generator import AgentIntroGenConfig from Undefined.utils.queue_intervals import build_model_queue_intervals +if TYPE_CHECKING: + from Undefined.handlers import MessageHandler + logger = logging.getLogger(__name__) @@ -60,6 +64,18 @@ "grok_model.model_name", } +_CORE_AI_MODEL_CONFIG_PREFIXES: tuple[str, ...] = ( + "chat_model", + "vision_model", + "agent_model", +) + +_RUNTIME_AI_MODEL_CONFIG_PREFIXES: tuple[str, ...] = ( + "summary_model", + "historian_model", + "grok_model", +) + _AGENT_INTRO_KEYS: set[str] = { "agent_intro_autogen_enabled", "agent_intro_autogen_queue_interval", @@ -80,6 +96,8 @@ _SEARCH_KEYS: set[str] = {"searxng_url"} +_ATTACHMENT_KEYS: set[str] = {"attachment_remote_download_max_size_mb"} + @dataclass class HotReloadContext: @@ -87,6 +105,7 @@ class HotReloadContext: queue_manager: QueueManager config_manager: ConfigManager security_service: SecurityService + message_handler: MessageHandler | None = None def apply_config_updates( @@ -121,8 +140,27 @@ def apply_config_updates( if _needs_search_update(changed_keys): context.ai_client.apply_search_config(updated.searxng_url) + if _needs_attachment_update(changed_keys): + context.ai_client.apply_attachment_config(updated) + + if _needs_core_ai_model_update(changed_keys): + context.ai_client.apply_model_configs( + chat_config=updated.chat_model, + vision_config=updated.vision_model, + agent_config=updated.agent_model, + runtime_config=updated, + ) + elif _needs_runtime_ai_model_update(changed_keys): + context.ai_client.apply_runtime_config(updated) + if _needs_skills_hot_reload_update(changed_keys): asyncio.create_task(_apply_skills_hot_reload(updated, context.ai_client)) + asyncio.create_task( + _apply_message_handler_skills_hot_reload( + updated, + context.message_handler, + ) + ) if _needs_config_hot_reload_update(changed_keys): asyncio.create_task( @@ -160,23 +198,44 @@ def _needs_search_update(changed_keys: set[str]) -> bool: return bool(changed_keys & _SEARCH_KEYS) +def _needs_attachment_update(changed_keys: set[str]) -> bool: + return bool(changed_keys & _ATTACHMENT_KEYS) + + +def _matches_prefixes(changed_keys: set[str], prefixes: tuple[str, ...]) -> bool: + return any( + key == prefix or key.startswith(f"{prefix}.") + for key in changed_keys + for prefix in prefixes + ) + + +def _needs_core_ai_model_update(changed_keys: set[str]) -> bool: + return _matches_prefixes(changed_keys, _CORE_AI_MODEL_CONFIG_PREFIXES) + + +def _needs_runtime_ai_model_update(changed_keys: set[str]) -> bool: + return _matches_prefixes(changed_keys, _RUNTIME_AI_MODEL_CONFIG_PREFIXES) + + async def _apply_skills_hot_reload(updated: Config, ai_client: AIClient) -> None: + registries: list[Any] = [ai_client.tool_registry, ai_client.agent_registry] + anthropic_skill_registry = getattr(ai_client, "anthropic_skill_registry", None) + if anthropic_skill_registry is not None: + registries.append(anthropic_skill_registry) + if not updated.skills_hot_reload: - await ai_client.tool_registry.stop_hot_reload() - await ai_client.agent_registry.stop_hot_reload() + for registry in registries: + await registry.stop_hot_reload() logger.info("[配置] 技能热重载已禁用") return - await ai_client.tool_registry.stop_hot_reload() - await ai_client.agent_registry.stop_hot_reload() - ai_client.tool_registry.start_hot_reload( - interval=updated.skills_hot_reload_interval, - debounce=updated.skills_hot_reload_debounce, - ) - ai_client.agent_registry.start_hot_reload( - interval=updated.skills_hot_reload_interval, - debounce=updated.skills_hot_reload_debounce, - ) + for registry in registries: + await registry.stop_hot_reload() + registry.start_hot_reload( + interval=updated.skills_hot_reload_interval, + debounce=updated.skills_hot_reload_debounce, + ) logger.info( "[配置] 技能热重载已更新: interval=%.2fs debounce=%.2fs", updated.skills_hot_reload_interval, @@ -184,6 +243,19 @@ async def _apply_skills_hot_reload(updated: Config, ai_client: AIClient) -> None ) +async def _apply_message_handler_skills_hot_reload( + updated: Config, + message_handler: MessageHandler | None, +) -> None: + if message_handler is None: + return + await message_handler.apply_skills_hot_reload_config( + enabled=updated.skills_hot_reload, + interval=updated.skills_hot_reload_interval, + debounce=updated.skills_hot_reload_debounce, + ) + + async def _restart_config_hot_reload( config_manager: ConfigManager, interval: float, debounce: float ) -> None: diff --git a/src/Undefined/config/loader.py b/src/Undefined/config/loader.py index 992829a5..d40411c9 100644 --- a/src/Undefined/config/loader.py +++ b/src/Undefined/config/loader.py @@ -260,6 +260,7 @@ class Config: history_summary_time_fetch_limit: int history_onebot_fetch_limit: int history_group_analysis_limit: int + attachment_remote_download_max_size_mb: int skills_hot_reload: bool skills_hot_reload_interval: float skills_hot_reload_debounce: float @@ -274,6 +275,7 @@ class Config: https_proxy: str network_request_timeout: float network_request_retries: int + render_browser_max_concurrency: int api_xxapi_base_url: str api_xingzhige_base_url: str api_jkyai_base_url: str @@ -339,6 +341,12 @@ class Config: arxiv_auto_extract_max_items: int arxiv_author_preview_limit: int arxiv_summary_preview_chars: int + # GitHub 仓库自动提取 + github_auto_extract_enabled: bool + github_request_timeout_seconds: float + github_auto_extract_group_ids: list[int] + github_auto_extract_private_ids: list[int] + github_auto_extract_max_items: int # 认知记忆 cognitive: CognitiveConfig # 表情包库 @@ -389,6 +397,16 @@ class Config: init=False, repr=False, ) + _github_group_ids_set: set[int] = dataclass_field( + default_factory=set, + init=False, + repr=False, + ) + _github_private_ids_set: set[int] = dataclass_field( + default_factory=set, + init=False, + repr=False, + ) def __post_init__(self) -> None: # 访问控制属于高频热路径,启动后缓存为 set 降低重复构建开销。 @@ -412,6 +430,12 @@ def __post_init__(self) -> None: self._arxiv_private_ids_set = { int(item) for item in self.arxiv_auto_extract_private_ids } + self._github_group_ids_set = { + int(item) for item in self.github_auto_extract_group_ids + } + self._github_private_ids_set = { + int(item) for item in self.github_auto_extract_private_ids + } @classmethod def load(cls, config_path: Optional[Path] = None, strict: bool = True) -> "Config": @@ -875,6 +899,17 @@ def load(cls, config_path: Optional[Path] = None, strict: bool = True) -> "Confi 500, ), ) + attachment_remote_download_max_size_mb = max( + 0, + _coerce_int( + _get_value( + data, + ("attachments", "remote_download_max_size_mb"), + "ATTACHMENTS_REMOTE_DOWNLOAD_MAX_SIZE_MB", + ), + 25, + ), + ) skills_hot_reload = _coerce_bool( _get_value(data, ("skills", "hot_reload"), "SKILLS_HOT_RELOAD"), True @@ -989,6 +1024,18 @@ def load(cls, config_path: Optional[Path] = None, strict: bool = True) -> "Confi if network_request_retries > 5: network_request_retries = 5 + render_browser_max_concurrency = max( + 0, + _coerce_int( + _get_value( + data, + ("render", "browser_max_concurrency"), + "RENDER_BROWSER_MAX_CONCURRENCY", + ), + 0, + ), + ) + api_xxapi_base_url = _normalize_base_url( _coerce_str( _get_value(data, ("api_endpoints", "xxapi_base_url"), "XXAPI_BASE_URL"), @@ -1108,6 +1155,31 @@ def load(cls, config_path: Optional[Path] = None, strict: bool = True) -> "Confi if arxiv_summary_preview_chars > 8000: arxiv_summary_preview_chars = 8000 + # GitHub 配置 + github_auto_extract_enabled = _coerce_bool( + _get_value(data, ("github", "auto_extract_enabled"), None), False + ) + github_request_timeout_seconds = _coerce_float( + _get_value(data, ("github", "request_timeout_seconds"), None), 10.0 + ) + if github_request_timeout_seconds <= 0: + github_request_timeout_seconds = 10.0 + if github_request_timeout_seconds > 60.0: + github_request_timeout_seconds = 60.0 + github_auto_extract_group_ids = _coerce_int_list( + _get_value(data, ("github", "auto_extract_group_ids"), None) + ) + github_auto_extract_private_ids = _coerce_int_list( + _get_value(data, ("github", "auto_extract_private_ids"), None) + ) + github_auto_extract_max_items = _coerce_int( + _get_value(data, ("github", "auto_extract_max_items"), None), 3 + ) + if github_auto_extract_max_items <= 0: + github_auto_extract_max_items = 3 + if github_auto_extract_max_items > 10: + github_auto_extract_max_items = 10 + # Code Delivery Agent 配置 code_delivery_enabled = _coerce_bool( _get_value(data, ("code_delivery", "enabled"), None), True @@ -1294,6 +1366,7 @@ def load(cls, config_path: Optional[Path] = None, strict: bool = True) -> "Confi history_summary_time_fetch_limit=history_summary_time_fetch_limit, history_onebot_fetch_limit=history_onebot_fetch_limit, history_group_analysis_limit=history_group_analysis_limit, + attachment_remote_download_max_size_mb=attachment_remote_download_max_size_mb, skills_hot_reload_interval=skills_hot_reload_interval, skills_hot_reload_debounce=skills_hot_reload_debounce, agent_intro_autogen_enabled=agent_intro_autogen_enabled, @@ -1307,6 +1380,7 @@ def load(cls, config_path: Optional[Path] = None, strict: bool = True) -> "Confi https_proxy=https_proxy, network_request_timeout=network_request_timeout, network_request_retries=network_request_retries, + render_browser_max_concurrency=render_browser_max_concurrency, api_xxapi_base_url=api_xxapi_base_url, api_xingzhige_base_url=api_xingzhige_base_url, api_jkyai_base_url=api_jkyai_base_url, @@ -1353,6 +1427,11 @@ def load(cls, config_path: Optional[Path] = None, strict: bool = True) -> "Confi arxiv_auto_extract_max_items=arxiv_auto_extract_max_items, arxiv_author_preview_limit=arxiv_author_preview_limit, arxiv_summary_preview_chars=arxiv_summary_preview_chars, + github_auto_extract_enabled=github_auto_extract_enabled, + github_request_timeout_seconds=github_request_timeout_seconds, + github_auto_extract_group_ids=github_auto_extract_group_ids, + github_auto_extract_private_ids=github_auto_extract_private_ids, + github_auto_extract_max_items=github_auto_extract_max_items, embedding_model=embedding_model, rerank_model=rerank_model, knowledge_enabled=knowledge_enabled, @@ -1522,6 +1601,18 @@ def is_arxiv_auto_extract_allowed_private(self, user_id: int) -> bool: return int(user_id) in self._arxiv_private_ids_set return self.is_private_allowed(user_id) + def is_github_auto_extract_allowed_group(self, group_id: int) -> bool: + """群聊是否允许 GitHub 仓库自动提取。""" + if self._github_group_ids_set: + return int(group_id) in self._github_group_ids_set + return self.is_group_allowed(group_id) + + def is_github_auto_extract_allowed_private(self, user_id: int) -> bool: + """私聊是否允许 GitHub 仓库自动提取。""" + if self._github_private_ids_set: + return int(user_id) in self._github_private_ids_set + return self.is_private_allowed(user_id) + def should_process_group_message(self, is_at_bot: bool) -> bool: """是否处理该条群消息。""" diff --git a/src/Undefined/config/model_parsers.py b/src/Undefined/config/model_parsers.py index 13e16c2f..ec3fa55d 100644 --- a/src/Undefined/config/model_parsers.py +++ b/src/Undefined/config/model_parsers.py @@ -133,6 +133,10 @@ def _parse_model_pool( item.get("reasoning_effort"), primary_config.reasoning_effort, ), + stream_enabled=_coerce_bool( + item.get("stream_enabled"), + getattr(primary_config, "stream_enabled", False), + ), request_params=merge_request_params( primary_config.request_params, item.get("request_params"), @@ -265,6 +269,14 @@ def _parse_chat_model_config(data: dict[str, Any]) -> ChatModelConfig: ), "medium", ) + stream_enabled = _coerce_bool( + _get_value( + data, + ("models", "chat", "stream_enabled"), + "CHAT_MODEL_STREAM_ENABLED", + ), + False, + ) config = ChatModelConfig( api_url=_coerce_str( _get_value(data, ("models", "chat", "api_url"), "CHAT_MODEL_API_URL"), @@ -314,6 +326,7 @@ def _parse_chat_model_config(data: dict[str, Any]) -> ChatModelConfig: prompt_cache_enabled=prompt_cache_enabled, reasoning_enabled=reasoning_enabled, reasoning_effort=reasoning_effort, + stream_enabled=stream_enabled, request_params=_get_model_request_params(data, "chat"), ) config.pool = _parse_model_pool(data, "chat", config) @@ -369,6 +382,14 @@ def _parse_vision_model_config(data: dict[str, Any]) -> VisionModelConfig: ), "medium", ) + stream_enabled = _coerce_bool( + _get_value( + data, + ("models", "vision", "stream_enabled"), + "VISION_MODEL_STREAM_ENABLED", + ), + False, + ) return VisionModelConfig( api_url=_coerce_str( _get_value(data, ("models", "vision", "api_url"), "VISION_MODEL_API_URL"), @@ -422,6 +443,7 @@ def _parse_vision_model_config(data: dict[str, Any]) -> VisionModelConfig: prompt_cache_enabled=prompt_cache_enabled, reasoning_enabled=reasoning_enabled, reasoning_effort=reasoning_effort, + stream_enabled=stream_enabled, request_params=_get_model_request_params(data, "vision"), ) @@ -489,6 +511,14 @@ def _parse_security_model_config( ), "medium", ) + stream_enabled = _coerce_bool( + _get_value( + data, + ("models", "security", "stream_enabled"), + "SECURITY_MODEL_STREAM_ENABLED", + ), + False, + ) if api_url and api_key and model_name: return SecurityModelConfig( @@ -535,6 +565,7 @@ def _parse_security_model_config( prompt_cache_enabled=prompt_cache_enabled, reasoning_enabled=reasoning_enabled, reasoning_effort=reasoning_effort, + stream_enabled=stream_enabled, request_params=_get_model_request_params(data, "security"), ) @@ -556,6 +587,7 @@ def _parse_security_model_config( prompt_cache_enabled=chat_model.prompt_cache_enabled, reasoning_enabled=chat_model.reasoning_enabled, reasoning_effort=chat_model.reasoning_effort, + stream_enabled=chat_model.stream_enabled, request_params=merge_request_params(chat_model.request_params), ) @@ -623,6 +655,14 @@ def _parse_naga_model_config( ), getattr(security_model, "reasoning_effort", "medium"), ) + stream_enabled = _coerce_bool( + _get_value( + data, + ("models", "naga", "stream_enabled"), + "NAGA_MODEL_STREAM_ENABLED", + ), + getattr(security_model, "stream_enabled", False), + ) if api_url and api_key and model_name: return SecurityModelConfig( @@ -669,6 +709,7 @@ def _parse_naga_model_config( prompt_cache_enabled=prompt_cache_enabled, reasoning_enabled=reasoning_enabled, reasoning_effort=reasoning_effort, + stream_enabled=stream_enabled, request_params=_get_model_request_params(data, "naga"), ) @@ -692,6 +733,7 @@ def _parse_naga_model_config( prompt_cache_enabled=security_model.prompt_cache_enabled, reasoning_enabled=security_model.reasoning_enabled, reasoning_effort=security_model.reasoning_effort, + stream_enabled=security_model.stream_enabled, request_params=merge_request_params(security_model.request_params), ) @@ -745,6 +787,14 @@ def _parse_agent_model_config(data: dict[str, Any]) -> AgentModelConfig: ), "medium", ) + stream_enabled = _coerce_bool( + _get_value( + data, + ("models", "agent", "stream_enabled"), + "AGENT_MODEL_STREAM_ENABLED", + ), + False, + ) config = AgentModelConfig( api_url=_coerce_str( _get_value(data, ("models", "agent", "api_url"), "AGENT_MODEL_API_URL"), @@ -796,6 +846,7 @@ def _parse_agent_model_config(data: dict[str, Any]) -> AgentModelConfig: prompt_cache_enabled=prompt_cache_enabled, reasoning_enabled=reasoning_enabled, reasoning_effort=reasoning_effort, + stream_enabled=stream_enabled, request_params=_get_model_request_params(data, "agent"), ) config.pool = _parse_model_pool(data, "agent", config) @@ -886,6 +937,14 @@ def _parse_grok_model_config(data: dict[str, Any]) -> GrokModelConfig: ), "medium", ), + stream_enabled=_coerce_bool( + _get_value( + data, + ("models", "grok", "stream_enabled"), + "GROK_MODEL_STREAM_ENABLED", + ), + False, + ), request_params=_get_model_request_params(data, "grok"), ) @@ -1154,6 +1213,14 @@ def _parse_historian_model_config( ), fallback.reasoning_effort, ), + stream_enabled=_coerce_bool( + _get_value( + {"models": {"historian": h}}, + ("models", "historian", "stream_enabled"), + "HISTORIAN_MODEL_STREAM_ENABLED", + ), + fallback.stream_enabled, + ), request_params=merge_request_params( fallback.request_params, h.get("request_params"), @@ -1249,6 +1316,14 @@ def _parse_summary_model_config( ), fallback.reasoning_effort, ), + stream_enabled=_coerce_bool( + _get_value( + {"models": {"summary": s}}, + ("models", "summary", "stream_enabled"), + "SUMMARY_MODEL_STREAM_ENABLED", + ), + fallback.stream_enabled, + ), request_params=merge_request_params( fallback.request_params, s.get("request_params"), diff --git a/src/Undefined/config/models.py b/src/Undefined/config/models.py index 118fb9ad..b2ae1cdc 100644 --- a/src/Undefined/config/models.py +++ b/src/Undefined/config/models.py @@ -45,6 +45,7 @@ class ModelPoolEntry: prompt_cache_enabled: bool = True reasoning_enabled: bool = False reasoning_effort: str = "medium" + stream_enabled: bool = False request_params: dict[str, Any] = field(default_factory=dict) @@ -81,6 +82,7 @@ class ChatModelConfig: prompt_cache_enabled: bool = True # 是否启用自动 prompt_cache_key reasoning_enabled: bool = False # 是否启用 reasoning.effort reasoning_effort: str = "medium" # reasoning effort 档位 + stream_enabled: bool = False # 是否对上游启用流式请求 request_params: dict[str, Any] = field(default_factory=dict) pool: ModelPool | None = None # 模型池配置 @@ -109,6 +111,7 @@ class VisionModelConfig: prompt_cache_enabled: bool = True # 是否启用自动 prompt_cache_key reasoning_enabled: bool = False # 是否启用 reasoning.effort reasoning_effort: str = "medium" # reasoning effort 档位 + stream_enabled: bool = False # 是否对上游启用流式请求 request_params: dict[str, Any] = field(default_factory=dict) @@ -136,6 +139,7 @@ class SecurityModelConfig: prompt_cache_enabled: bool = True # 是否启用自动 prompt_cache_key reasoning_enabled: bool = False # 是否启用 reasoning.effort reasoning_effort: str = "medium" # reasoning effort 档位 + stream_enabled: bool = False # 是否对上游启用流式请求 request_params: dict[str, Any] = field(default_factory=dict) @@ -189,6 +193,7 @@ class AgentModelConfig: prompt_cache_enabled: bool = True # 是否启用自动 prompt_cache_key reasoning_enabled: bool = False # 是否启用 reasoning.effort reasoning_effort: str = "medium" # reasoning effort 档位 + stream_enabled: bool = False # 是否对上游启用流式请求 request_params: dict[str, Any] = field(default_factory=dict) pool: ModelPool | None = None # 模型池配置 @@ -209,6 +214,7 @@ class GrokModelConfig: prompt_cache_enabled: bool = True # 是否启用自动 prompt_cache_key reasoning_enabled: bool = False # 是否启用 reasoning.effort reasoning_effort: str = "medium" # reasoning effort 档位 + stream_enabled: bool = False # 是否对上游启用流式请求 request_params: dict[str, Any] = field(default_factory=dict) diff --git a/src/Undefined/github/__init__.py b/src/Undefined/github/__init__.py new file mode 100644 index 00000000..c326d250 --- /dev/null +++ b/src/Undefined/github/__init__.py @@ -0,0 +1,19 @@ +"""GitHub 仓库自动提取与信息卡片。""" + +from Undefined.github.client import get_public_repo_info +from Undefined.github.models import GitHubRepoInfo +from Undefined.github.parser import ( + extract_from_json_message, + extract_github_repo_ids, + normalize_github_repo_id, +) +from Undefined.github.sender import send_github_repo_card + +__all__ = [ + "GitHubRepoInfo", + "extract_from_json_message", + "extract_github_repo_ids", + "get_public_repo_info", + "normalize_github_repo_id", + "send_github_repo_card", +] diff --git a/src/Undefined/github/client.py b/src/Undefined/github/client.py new file mode 100644 index 00000000..574c5ef5 --- /dev/null +++ b/src/Undefined/github/client.py @@ -0,0 +1,169 @@ +"""GitHub public API 客户端。""" + +from __future__ import annotations + +import re +from typing import Any + +from Undefined.github.models import GitHubRepoInfo +from Undefined.github.parser import normalize_github_repo_id +from Undefined.skills.http_client import request_with_retry + +_API_BASE_URL = "https://api.github.com" +_HEADERS = { + "Accept": "application/vnd.github+json", + "User-Agent": "Undefined-bot/3.x (https://github.com/69gg/Undefined)", + "X-GitHub-Api-Version": "2022-11-28", +} + + +def _as_str(value: object) -> str: + return str(value or "").strip() + + +def _as_int(value: object, default: int = 0) -> int: + if isinstance(value, bool): + return int(value) + if isinstance(value, int): + return value + if isinstance(value, float): + return int(value) + if not isinstance(value, str): + return default + try: + return int(value) + except ValueError: + return default + + +def _as_optional_int(value: object) -> int | None: + if value is None: + return None + if isinstance(value, bool): + return int(value) + if isinstance(value, int): + return value + if isinstance(value, float): + return int(value) + if not isinstance(value, str): + return None + try: + return int(value) + except ValueError: + return None + + +def _topics(value: object) -> tuple[str, ...]: + if not isinstance(value, list): + return () + return tuple(str(item).strip() for item in value if str(item).strip()) + + +def _license_name(value: object) -> str: + if not isinstance(value, dict): + return "" + return _as_str(value.get("spdx_id")) or _as_str(value.get("name")) + + +def _parse_contributor_count(link_header: str, payload: object) -> int | None: + for part in link_header.split(","): + if 'rel="last"' not in part: + continue + match = re.search(r"[?&]page=(\d+)", part) + if match: + return int(match.group(1)) + if isinstance(payload, list): + return len(payload) + return None + + +async def _fetch_contributor_count( + repo_id: str, + *, + request_timeout: float, + context: dict[str, object] | None, +) -> int | None: + response = await request_with_retry( + "GET", + f"{_API_BASE_URL}/repos/{repo_id}/contributors", + params={"per_page": 1}, + headers=_HEADERS, + default_timeout=request_timeout, + follow_redirects=True, + context=context, + retries=0, + ) + return _parse_contributor_count(response.headers.get("link", ""), response.json()) + + +def _parse_repo_info( + payload: dict[str, Any], contributor_count: int | None +) -> GitHubRepoInfo: + owner = payload.get("owner") + owner_data = owner if isinstance(owner, dict) else {} + return GitHubRepoInfo( + repo_id=_as_str(payload.get("full_name")), + name=_as_str(payload.get("name")), + full_name=_as_str(payload.get("full_name")), + owner_login=_as_str(owner_data.get("login")), + owner_avatar_url=_as_str(owner_data.get("avatar_url")), + description=_as_str(payload.get("description")), + html_url=_as_str(payload.get("html_url")), + stars=_as_int(payload.get("stargazers_count")), + forks=_as_int(payload.get("forks_count")), + open_issues=_as_int(payload.get("open_issues_count")), + watchers=_as_int(payload.get("watchers_count")), + subscribers=_as_optional_int(payload.get("subscribers_count")), + contributors=contributor_count, + language=_as_str(payload.get("language")), + license_name=_license_name(payload.get("license")), + default_branch=_as_str(payload.get("default_branch")), + topics=_topics(payload.get("topics")), + created_at=_as_str(payload.get("created_at")), + updated_at=_as_str(payload.get("updated_at")), + pushed_at=_as_str(payload.get("pushed_at")), + archived=bool(payload.get("archived")), + fork=bool(payload.get("fork")), + ) + + +async def get_public_repo_info( + repo_id: str, + *, + request_timeout: float = 10.0, + context: dict[str, object] | None = None, +) -> GitHubRepoInfo: + """获取 public GitHub 仓库信息。""" + normalized = normalize_github_repo_id(repo_id) + if normalized is None: + raise ValueError(f"无法解析 GitHub 仓库标识: {repo_id}") + + response = await request_with_retry( + "GET", + f"{_API_BASE_URL}/repos/{normalized}", + headers=_HEADERS, + default_timeout=request_timeout, + follow_redirects=True, + context=context, + retries=0, + ) + payload = response.json() + if not isinstance(payload, dict): + raise ValueError("GitHub API 返回格式异常") + if bool(payload.get("private")): + raise ValueError(f"仅支持 public GitHub 仓库: {normalized}") + + contributor_count: int | None = None + try: + contributor_count = await _fetch_contributor_count( + normalized, + request_timeout=request_timeout, + context=context, + ) + except Exception: + contributor_count = None + + info = _parse_repo_info(payload, contributor_count) + if not info.repo_id: + raise ValueError("GitHub API 返回缺少仓库 ID") + return info diff --git a/src/Undefined/github/models.py b/src/Undefined/github/models.py new file mode 100644 index 00000000..4724c3be --- /dev/null +++ b/src/Undefined/github/models.py @@ -0,0 +1,33 @@ +"""GitHub 仓库信息模型。""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class GitHubRepoInfo: + """GitHub public 仓库信息。""" + + repo_id: str + name: str + full_name: str + owner_login: str + owner_avatar_url: str + description: str + html_url: str + stars: int + forks: int + open_issues: int + watchers: int + subscribers: int | None + contributors: int | None + language: str + license_name: str + default_branch: str + topics: tuple[str, ...] + created_at: str + updated_at: str + pushed_at: str + archived: bool + fork: bool diff --git a/src/Undefined/github/parser.py b/src/Undefined/github/parser.py new file mode 100644 index 00000000..d0cccfce --- /dev/null +++ b/src/Undefined/github/parser.py @@ -0,0 +1,196 @@ +"""GitHub 仓库标识解析。""" + +from __future__ import annotations + +import html +import json +import logging +import re +from typing import Any +from urllib.parse import unquote, urlparse + +logger = logging.getLogger(__name__) + +_URL_HOSTS = {"github.com", "www.github.com"} +_HTTP_URL_REGEX = re.compile(r"https?://(?:www\.)?github\.com/[^\s<>()]+", re.I) +_SCHEMELESS_URL_REGEX = re.compile( + r"(?()]+", + re.I, +) +_SSH_URL_REGEX = re.compile( + r"git@github\.com:([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?)/([^\s<>()]+)", + re.I, +) +_BARE_REPO_REGEX = re.compile( + r"(? str: + stripped = value.strip() + while stripped and stripped[-1] in ".,;:!?)>]}\"'": + stripped = stripped[:-1].rstrip() + while stripped and stripped[0] in "(<[{\"'": + stripped = stripped[1:].lstrip() + return stripped + + +def _normalize_owner_repo(owner: str, repo: str, *, bare: bool = False) -> str | None: + normalized_owner = _strip_wrapper_chars(html.unescape(owner)).strip() + normalized_repo = _strip_wrapper_chars(html.unescape(repo)).strip() + normalized_repo = normalized_repo.split("?", 1)[0].split("#", 1)[0] + if normalized_repo.lower().endswith(".git"): + normalized_repo = normalized_repo[:-4] + normalized_repo = normalized_repo.strip("/") + if not normalized_owner or not normalized_repo: + return None + if not _OWNER_REGEX.fullmatch(normalized_owner): + return None + if normalized_repo in {".", ".."}: + return None + if not _REPO_REGEX.fullmatch(normalized_repo): + return None + if bare and normalized_owner.isdigit() and normalized_repo.isdigit(): + return None + return f"{normalized_owner}/{normalized_repo}" + + +def _has_strong_bare_repo_shape(candidate: str) -> bool: + owner, repo = candidate.split("/", 1) + return ( + any(char.isupper() for char in candidate) + or any(char.isdigit() for char in owner) + or any(char in ".-_" for char in owner) + or any(char in ".-_" for char in repo) + ) + + +def _has_bare_repo_context_cue(text: str, start: int, end: int) -> bool: + prefix = text[max(0, start - 32) : start] + suffix = text[end : end + 32] + return bool( + _BARE_REPO_CONTEXT_CUE_REGEX.search(prefix) + or _BARE_REPO_CONTEXT_CUE_REGEX.search(suffix) + ) + + +def _should_accept_bare_repo_match(text: str, match: re.Match[str]) -> bool: + candidate = match.group(1) + return _has_strong_bare_repo_shape(candidate) or _has_bare_repo_context_cue( + text, + match.start(), + match.end(), + ) + + +def normalize_github_repo_id(identifier: str) -> str | None: + """将 GitHub URL、SSH URL 或 owner/repo 文本标准化为仓库 ID。""" + raw = _strip_wrapper_chars(html.unescape(identifier)) + if not raw: + return None + + ssh_match = _SSH_URL_REGEX.search(raw) + if ssh_match: + return _normalize_owner_repo(ssh_match.group(1), ssh_match.group(2)) + + parse_target = raw + if re.match(r"^(?:www\.)?github\.com/", parse_target, re.I): + parse_target = f"https://{parse_target}" + parsed = urlparse(parse_target) + hostname = parsed.hostname.lower() if parsed.hostname else "" + if hostname in _URL_HOSTS: + path = unquote(parsed.path or "").strip("/") + parts = [part for part in path.split("/") if part] + if len(parts) >= 2: + return _normalize_owner_repo(parts[0], parts[1]) + return None + + bare_match = _BARE_REPO_REGEX.fullmatch(raw) + if bare_match: + owner, repo = bare_match.group(1).split("/", 1) + return _normalize_owner_repo(owner, repo, bare=True) + return None + + +def _append_candidate( + candidate: str, + *, + results: list[str], + seen: set[str], +) -> None: + repo_id = normalize_github_repo_id(candidate) + if repo_id is None or repo_id.lower() in seen: + return + seen.add(repo_id.lower()) + results.append(repo_id) + + +def extract_github_repo_ids(text: str) -> list[str]: + """从纯文本中提取 GitHub 仓库 ID。""" + results: list[str] = [] + seen: set[str] = set() + + for match in _HTTP_URL_REGEX.finditer(text): + _append_candidate(match.group(0), results=results, seen=seen) + + for match in _SCHEMELESS_URL_REGEX.finditer(text): + _append_candidate(match.group(0), results=results, seen=seen) + + for match in _SSH_URL_REGEX.finditer(text): + _append_candidate(match.group(0), results=results, seen=seen) + + for match in _BARE_REPO_REGEX.finditer(text): + if _should_accept_bare_repo_match(text, match): + _append_candidate(match.group(1), results=results, seen=seen) + + return results + + +def _collect_json_strings(value: Any) -> list[str]: + if isinstance(value, str): + return [value] + if isinstance(value, list): + strings: list[str] = [] + for item in value: + strings.extend(_collect_json_strings(item)) + return strings + if isinstance(value, dict): + strings = [] + for item in value.values(): + strings.extend(_collect_json_strings(item)) + return strings + return [] + + +def extract_from_json_message(segments: list[dict[str, Any]]) -> list[str]: + """从 QQ JSON 消息段中提取 GitHub 仓库 ID。""" + results: list[str] = [] + seen: set[str] = set() + + for segment in segments: + if segment.get("type") != "json": + continue + + raw_data = segment.get("data", {}).get("data", "") + if not raw_data: + continue + + try: + payload = json.loads(html.unescape(raw_data)) + except (TypeError, json.JSONDecodeError): + logger.debug("[GitHub] JSON 消息解析失败,跳过", exc_info=True) + continue + + for item in _collect_json_strings(payload): + for repo_id in extract_github_repo_ids(item): + if repo_id.lower() in seen: + continue + seen.add(repo_id.lower()) + results.append(repo_id) + + return results diff --git a/src/Undefined/github/sender.py b/src/Undefined/github/sender.py new file mode 100644 index 00000000..df443a4e --- /dev/null +++ b/src/Undefined/github/sender.py @@ -0,0 +1,361 @@ +"""GitHub 仓库卡片渲染与发送。""" + +from __future__ import annotations + +import html +import logging +from pathlib import Path +from typing import TYPE_CHECKING, Literal +import uuid + +from Undefined.github.client import get_public_repo_info +from Undefined.github.models import GitHubRepoInfo +from Undefined.render import render_html_to_image +from Undefined.skills.http_config import get_request_proxy +from Undefined.utils.paths import RENDER_CACHE_DIR, ensure_dir + +if TYPE_CHECKING: + from Undefined.utils.sender import MessageSender + +logger = logging.getLogger(__name__) + + +def _html_text(text: str) -> str: + return html.escape(text, quote=True) + + +def _format_count(value: int | None) -> str: + if value is None: + return "-" + return f"{value:,}" + + +def _date(value: str) -> str: + return value[:10] if value else "-" + + +def _stat_item(label: str, value: int | None) -> str: + return ( + '
' + f'
{_html_text(_format_count(value))}
' + f'
{_html_text(label)}
' + "
" + ) + + +def _chip(label: str, value: str) -> str: + if not value: + value = "-" + return ( + '
' + f"{_html_text(label)}" + f"{_html_text(value)}" + "
" + ) + + +def _status_badges(info: GitHubRepoInfo) -> str: + badges: list[str] = ['Public'] + if info.archived: + badges.append('Archived') + if info.fork: + badges.append('Fork') + return "".join(badges) + + +def _topic_html(info: GitHubRepoInfo) -> str: + topics = info.topics[:6] + if not topics: + return "" + topic_items = "".join(f"{_html_text(topic)}" for topic in topics) + return f'
{topic_items}
' + + +def _build_repo_card_html(info: GitHubRepoInfo) -> str: + description = info.description or "No description provided." + avatar = info.owner_avatar_url + avatar_html = ( + f'' + if avatar + else '
' + ) + stats = "".join( + [ + _stat_item("Stars", info.stars), + _stat_item("Forks", info.forks), + _stat_item("Issues", info.open_issues), + _stat_item("Contributors", info.contributors), + _stat_item("Watchers", info.watchers), + ] + ) + chips = "".join( + [ + _chip("Language", info.language), + _chip("License", info.license_name), + _chip("Branch", info.default_branch), + _chip("Updated", _date(info.updated_at)), + ] + ) + return f""" + + + + + + +
+
+ {avatar_html} +
+

GitHub Repository

+

{_html_text(info.full_name or info.repo_id)}

+
{_status_badges(info)}
+
+
+
+

{_html_text(description)}

+
{stats}
+
{chips}
+ {_topic_html(info)} + +
+
+ +""" + + +def _build_fallback_message(info: GitHubRepoInfo) -> str: + lines = [ + f"GitHub: {info.full_name or info.repo_id}", + info.description or "No description provided.", + ( + f"Stars: {_format_count(info.stars)} | Forks: {_format_count(info.forks)} | " + f"Issues: {_format_count(info.open_issues)} | Contributors: {_format_count(info.contributors)}" + ), + ] + if info.language or info.license_name: + lines.append( + f"Language: {info.language or '-'} | License: {info.license_name or '-'}" + ) + if info.html_url: + lines.append(info.html_url) + return "\n".join(lines) + + +async def _render_repo_card(info: GitHubRepoInfo, output_path: Path) -> None: + html_content = _build_repo_card_html(info) + await render_html_to_image( + html_content, + str(output_path), + viewport_width=768, + screenshot_selector=".card", + proxy=get_request_proxy(info.html_url or "https://github.com"), + ) + + +async def _send_message( + sender: "MessageSender", + target_type: Literal["group", "private"], + target_id: int, + message: str, + *, + history_message: str | None = None, + attachments: list[dict[str, str]] | None = None, +) -> None: + if target_type == "group": + await sender.send_group_message( + target_id, + message, + history_message=history_message, + attachments=attachments, + ) + else: + await sender.send_private_message( + target_id, + message, + history_message=history_message, + attachments=attachments, + ) + + +async def send_github_repo_card( + *, + repo_id: str, + sender: "MessageSender", + target_type: Literal["group", "private"], + target_id: int, + request_timeout: float = 10.0, + context: dict[str, object] | None = None, +) -> str: + """获取 public 仓库信息并发送图片卡片。""" + info = await get_public_repo_info( + repo_id, + request_timeout=request_timeout, + context=context, + ) + output_dir = ensure_dir(RENDER_CACHE_DIR / "github") + output_path = ( + output_dir + / f"github_{info.repo_id.replace('/', '_')}_{uuid.uuid4().hex[:8]}.png" + ) + + try: + try: + await _render_repo_card(info, output_path) + message = f"[CQ:image,file={output_path.resolve().as_uri()}]" + except Exception: + logger.exception( + "[GitHub] 渲染仓库卡片失败,回退到文本: repo=%s", info.repo_id + ) + message = _build_fallback_message(info) + + await _send_message( + sender, + target_type, + target_id, + message, + history_message=_build_fallback_message(info), + ) + finally: + if output_path.exists(): + try: + output_path.unlink() + except OSError: + logger.debug( + "[GitHub] 清理渲染缓存失败: %s", output_path, exc_info=True + ) + return f"已发送 GitHub 仓库卡片: {info.repo_id}" diff --git a/src/Undefined/handlers.py b/src/Undefined/handlers.py index 9d5bfcba..5b316bd4 100644 --- a/src/Undefined/handlers.py +++ b/src/Undefined/handlers.py @@ -9,7 +9,7 @@ from pathlib import Path import random import time -from typing import Any, Coroutine +from typing import Any, Coroutine, Literal from Undefined.attachments import ( append_attachment_text, @@ -38,6 +38,8 @@ from Undefined.services.security import SecurityService from Undefined.services.command import CommandDispatcher from Undefined.services.ai_coordinator import AICoordinator +from Undefined.services.model_pool import ModelPoolService +from Undefined.skills.auto_pipeline import AutoPipelineRegistry from Undefined.utils.resources import resolve_resource_path from Undefined.utils.queue_intervals import build_model_queue_intervals @@ -51,6 +53,10 @@ REPEAT_REPLY_HISTORY_PREFIX = "[系统复读] " +def _is_private_model_pool_control_text(text: str) -> bool: + return ModelPoolService.is_private_control_text(text) + + def _format_poke_history_text(display_name: str, user_id: int) -> str: """格式化拍一拍历史文本。""" return f"{display_name}(暱称)[{user_id}(QQ号)] 拍了拍你。" @@ -89,7 +95,13 @@ def __init__( self.faq_storage = faq_storage # 初始化工具组件 self.history_manager = MessageHistoryManager(config.history_max_records) - self.sender = MessageSender(onebot, self.history_manager, config.bot_qq, config) + self.sender = MessageSender( + onebot, + self.history_manager, + config.bot_qq, + config, + attachment_registry=getattr(ai, "attachment_registry", None), + ) # 初始化服务 self.security = SecurityService(config, ai._http_client) @@ -128,6 +140,9 @@ def __init__( self._background_tasks: set[asyncio.Task[None]] = set() self._profile_name_refresh_cache: dict[tuple[str, int], str] = {} self._bot_nickname_cache = BotNicknameCache(onebot, config.bot_qq) + self.auto_pipeline_registry = AutoPipelineRegistry() + self._auto_pipeline_initialized = False + self._auto_pipeline_init_lock = asyncio.Lock() # 复读功能状态(按群跟踪最近消息文本与发送者) self._repeat_counter: dict[int, list[tuple[str, int]]] = {} @@ -138,6 +153,29 @@ def __init__( # 启动队列 self.ai_coordinator.queue_manager.start(self.ai_coordinator.execute_reply) + async def initialize(self) -> None: + """完成需要事件循环承载的异步初始化。""" + await self.initialize_auto_pipeline() + + async def initialize_auto_pipeline(self) -> None: + """异步加载自动处理管线并按配置启动热重载。""" + if getattr(self, "_auto_pipeline_initialized", False): + return + init_lock = getattr(self, "_auto_pipeline_init_lock", None) + if init_lock is None: + init_lock = asyncio.Lock() + self._auto_pipeline_init_lock = init_lock + async with init_lock: + if getattr(self, "_auto_pipeline_initialized", False): + return + await self.auto_pipeline_registry.load_items_async() + self._auto_pipeline_initialized = True + if getattr(self.config, "skills_hot_reload", False): + self.auto_pipeline_registry.start_hot_reload( + interval=self.config.skills_hot_reload_interval, + debounce=self.config.skills_hot_reload_debounce, + ) + def _get_repeat_lock(self, group_id: int) -> asyncio.Lock: """获取或创建指定群的复读竞态保护锁。""" lock = self._repeat_locks.get(group_id) @@ -602,39 +640,12 @@ async def handle_message(self, event: dict[str, Any]) -> None: ) return - # Bilibili 视频自动提取(私聊) - if self.config.bilibili_auto_extract_enabled: - if self.config.is_bilibili_auto_extract_allowed_private( - private_sender_id - ): - bvids = await self._extract_bilibili_ids( - text, private_message_content - ) - if bvids: - self._spawn_background_task( - "bilibili_auto_extract_private", - self._handle_bilibili_extract( - private_sender_id, bvids, "private" - ), - ) - return - - # arXiv 论文自动提取(私聊) - if self.config.arxiv_auto_extract_enabled: - if self.config.is_arxiv_auto_extract_allowed_private(private_sender_id): - paper_ids = self._extract_arxiv_ids(text, private_message_content) - if paper_ids: - self._spawn_background_task( - "arxiv_auto_extract_private", - self._handle_arxiv_extract( - private_sender_id, paper_ids, "private" - ), - ) - return - - # 私聊消息直接触发回复 - if await self.ai_coordinator.model_pool.handle_private_message( - private_sender_id, text + if ( + getattr(self.config, "model_pool_enabled", False) + and _is_private_model_pool_control_text(text) + ) and await self.ai_coordinator.model_pool.handle_private_message( + private_sender_id, + text, ): return @@ -647,6 +658,13 @@ async def handle_message(self, event: dict[str, Any]) -> None: ) return + await self._run_auto_extract_pipeline( + target_id=private_sender_id, + target_type="private", + text=text, + message_content=private_message_content, + ) + await self.ai_coordinator.handle_private_reply( private_sender_id, text, @@ -778,7 +796,7 @@ async def _fetch_group_name() -> str: # normalized_text 用于命令解析和 AI 路由,原始 text 已用于历史/日志 is_fake_at = False normalized_text = text - if not is_at_bot: + if not is_at_bot and ("@" in text or "@" in text): nicknames = await self._bot_nickname_cache.get_nicknames(group_id) if nicknames: is_fake_at, normalized_text = strip_fake_at(text, nicknames) @@ -801,6 +819,14 @@ async def _fetch_group_name() -> str: ) return + # 只有被@时才处理斜杠命令(使用 normalized_text 以支持假@后的命令)。 + # 命令优先于自动处理管线,命中后不触发后续自动提取或 AI 回复。 + if is_at_bot: + command = self.command_dispatcher.parse_command(normalized_text) + if command: + await self.command_dispatcher.dispatch(group_id, sender_id, command) + return + # 关键词自动回复:心理委员 (使用原始消息内容提取文本,保证关键词触发不受影响) if self.config.keyword_reply_enabled and matches_xinliweiyuan(text): rand_val = random.random() @@ -893,38 +919,16 @@ async def _fetch_group_name() -> str: ) return - # Bilibili 视频自动提取 - if self.config.bilibili_auto_extract_enabled: - if self.config.is_bilibili_auto_extract_allowed_group(group_id): - bvids = await self._extract_bilibili_ids(text, message_content) - if bvids: - self._spawn_background_task( - "bilibili_auto_extract_group", - self._handle_bilibili_extract(group_id, bvids, "group"), - ) - return - - # arXiv 论文自动提取 - if self.config.arxiv_auto_extract_enabled: - if self.config.is_arxiv_auto_extract_allowed_group(group_id): - paper_ids = self._extract_arxiv_ids(text, message_content) - if paper_ids: - self._spawn_background_task( - "arxiv_auto_extract_group", - self._handle_arxiv_extract(group_id, paper_ids, "group"), - ) - return + await self._run_auto_extract_pipeline( + target_id=group_id, + target_type="group", + text=text, + message_content=message_content, + ) # 提取文本内容 # (已在上方提取用于日志记录) - # 只有被@时才处理斜杠命令(使用 normalized_text 以支持假@后的命令) - if is_at_bot: - command = self.command_dispatcher.parse_command(normalized_text) - if command: - await self.command_dispatcher.dispatch(group_id, sender_id, command) - return - # 自动回复处理(使用 normalized_text 以去除假@前缀) display_name = sender_card or sender_nickname or str(sender_id) await self.ai_coordinator.handle_auto_reply( @@ -1097,6 +1101,55 @@ async def _extract_bilibili_ids( bvids = await extract_from_json_message(message_content) return bvids + async def _run_auto_extract_pipeline( + self, + *, + target_id: int, + target_type: Literal["group", "private"], + text: str, + message_content: list[dict[str, Any]], + ) -> bool: + """并行检测并处理所有命中的自动处理管线。""" + if not getattr(self, "_auto_pipeline_initialized", False): + await self.initialize_auto_pipeline() + detections = await self.auto_pipeline_registry.run( + { + "config": self.config, + "sender": self.sender, + "onebot": self.onebot, + "target_id": target_id, + "target_type": target_type, + "text": text, + "message_content": message_content, + "extract_bilibili_ids": self._extract_bilibili_ids, + "extract_arxiv_ids": self._extract_arxiv_ids, + "extract_github_repo_ids": self._extract_github_repo_ids, + "handle_bilibili_extract": self._handle_bilibili_extract, + "handle_arxiv_extract": self._handle_arxiv_extract, + "handle_github_extract": self._handle_github_extract, + } + ) + return bool(detections) + + async def apply_skills_hot_reload_config( + self, + *, + enabled: bool, + interval: float, + debounce: float, + ) -> None: + """跟随全局 skills 热重载配置更新自动处理管线。""" + if not enabled: + await self.auto_pipeline_registry.stop_hot_reload() + logger.info("[auto_pipeline] 热重载已随配置禁用") + return + + await self.auto_pipeline_registry.stop_hot_reload() + self.auto_pipeline_registry.start_hot_reload( + interval=interval, + debounce=debounce, + ) + def _extract_arxiv_ids( self, text: str, message_content: list[dict[str, Any]] ) -> list[str]: @@ -1120,6 +1173,34 @@ def _extract_arxiv_ids( return paper_ids + def _extract_github_repo_ids( + self, text: str, message_content: list[dict[str, Any]] + ) -> list[str]: + """从文本和消息段中提取 GitHub 仓库 ID。""" + from Undefined.github.parser import ( + extract_from_json_message, + extract_github_repo_ids, + ) + + repo_ids: list[str] = [] + seen: set[str] = set() + + for repo_id in extract_github_repo_ids(text): + key = repo_id.lower() + if key in seen: + continue + seen.add(key) + repo_ids.append(repo_id) + + for repo_id in extract_from_json_message(message_content): + key = repo_id.lower() + if key in seen: + continue + seen.add(key) + repo_ids.append(repo_id) + + return repo_ids + async def _handle_bilibili_extract( self, target_id: int, @@ -1154,13 +1235,9 @@ async def _handle_bilibili_extract( try: error_msg = f"视频提取失败: {exc}" if target_type == "group": - await self.sender.send_group_message( - target_id, error_msg, auto_history=False - ) + await self.sender.send_group_message(target_id, error_msg) else: - await self.sender.send_private_message( - target_id, error_msg, auto_history=False - ) + await self.sender.send_private_message(target_id, error_msg) except Exception: pass @@ -1206,6 +1283,52 @@ async def _handle_arxiv_extract( target_id, ) + async def _handle_github_extract( + self, + target_id: int, + repo_ids: list[str], + target_type: str, + ) -> None: + """处理 GitHub 仓库自动提取和发送。""" + from Undefined.github.sender import send_github_repo_card + + max_items = max( + 1, int(getattr(self.config, "github_auto_extract_max_items", 3)) + ) + request_timeout = float( + getattr(self.config, "github_request_timeout_seconds", 10.0) + ) + + for repo_id in repo_ids[:max_items]: + try: + result = await send_github_repo_card( + repo_id=repo_id, + sender=self.sender, + target_type=target_type, # type: ignore[arg-type] + target_id=target_id, + request_timeout=request_timeout, + context={ + "request_id": ( + f"github_auto_extract:{target_type}:{target_id}:{repo_id}" + ) + }, + ) + logger.info( + "[GitHub] 自动提取完成 %s → %s:%s: %s", + repo_id, + target_type, + target_id, + result, + ) + except Exception as exc: + logger.info( + "[GitHub] 自动提取跳过 %s → %s:%s: %s", + repo_id, + target_type, + target_id, + exc, + ) + def _spawn_background_task( self, name: str, @@ -1242,5 +1365,7 @@ async def close(self) -> None: *list(self._background_tasks), return_exceptions=True, ) + await self.history_manager.flush_pending_saves() + await self.auto_pipeline_registry.stop_hot_reload() await self.ai_coordinator.queue_manager.stop() logger.info("消息处理器已关闭") diff --git a/src/Undefined/main.py b/src/Undefined/main.py index 3909975c..6826d0a0 100644 --- a/src/Undefined/main.py +++ b/src/Undefined/main.py @@ -42,6 +42,7 @@ format_update_result, restart_process, ) +from Undefined.render import close_browser as close_render_browser def ensure_runtime_dirs() -> None: @@ -381,6 +382,7 @@ async def main() -> None: ) handler = MessageHandler(config, onebot, ai, faq_storage, task_storage) + await handler.initialize() onebot.set_message_handler(handler.handle_message) elapsed = time.perf_counter() - init_start logger.info("[初始化] 核心组件加载完成: elapsed=%.3fs", elapsed) @@ -433,6 +435,7 @@ async def main() -> None: queue_manager=handler.queue_manager, config_manager=config_manager, security_service=handler.security, + message_handler=handler, ) def _apply_config_updates( @@ -517,6 +520,7 @@ def _apply_config_updates( if retrieval_runtime is not None: await retrieval_runtime.stop() await config_manager.stop_hot_reload() + await close_render_browser() logger.info("[退出] 机器人已停止运行") diff --git a/src/Undefined/render.py b/src/Undefined/render.py index 3678ce1b..744901fb 100644 --- a/src/Undefined/render.py +++ b/src/Undefined/render.py @@ -1,9 +1,17 @@ -import markdown +"""HTML 渲染模块:将 HTML/Markdown 渲染为图片""" + import asyncio -from playwright.async_api import async_playwright +import logging +import markdown +import sys +from collections.abc import Awaitable, Callable +from playwright.async_api import async_playwright, Browser, Page, Playwright -from typing import Any +from typing import Any, TypeVar +from Undefined.config import get_config + +logger = logging.getLogger(__name__) # --- Markdown 配置 --- _MARKDOWN_EXTENSIONS = [ @@ -38,6 +46,96 @@ } +# --- 浏览器实例管理(懒加载单例) --- +_playwright: Playwright | None = None +_browser: Browser | None = None +_browser_lock = asyncio.Lock() +_render_semaphore: asyncio.Semaphore | None = None +_render_semaphore_limit: int | None = None +_render_active_count = 0 + +# 默认并发限制:Linux 默认 1,其它平台默认 2 +_DEFAULT_MAX_CONCURRENT = 1 if sys.platform == "linux" else 2 +_RenderResult = TypeVar("_RenderResult") + + +def _resolve_render_browser_max_concurrency() -> int: + """解析渲染浏览器并发上限,0 表示沿用平台默认值。""" + try: + runtime_config = get_config(strict=False) + except Exception: + logger.debug("[渲染] 读取配置失败,回退到默认浏览器并发上限", exc_info=True) + return _DEFAULT_MAX_CONCURRENT + + raw_limit = getattr(runtime_config, "render_browser_max_concurrency", 0) + try: + configured_limit = int(raw_limit) + except (TypeError, ValueError): + configured_limit = 0 + + if configured_limit <= 0: + return _DEFAULT_MAX_CONCURRENT + return configured_limit + + +async def _get_browser() -> Browser: + """获取或创建浏览器实例(懒加载单例)""" + global _playwright, _browser + + if _browser is not None: + return _browser + + async with _browser_lock: + if _browser is not None: + return _browser + + playwright = await async_playwright().start() + try: + browser = await playwright.chromium.launch(headless=True) + except Exception: + await playwright.stop() + raise + _playwright = playwright + _browser = browser + logger.info("[渲染] 浏览器实例已启动") + return _browser + + +async def _get_semaphore() -> asyncio.Semaphore: + """获取渲染并发信号量""" + global _render_semaphore, _render_semaphore_limit + + configured_limit = _resolve_render_browser_max_concurrency() + if _render_semaphore is None: + _render_semaphore = asyncio.Semaphore(configured_limit) + _render_semaphore_limit = configured_limit + elif _render_semaphore_limit != configured_limit: + if _render_active_count <= 0: + _render_semaphore = asyncio.Semaphore(configured_limit) + _render_semaphore_limit = configured_limit + return _render_semaphore + + +async def close_browser() -> None: + """关闭浏览器实例,应在程序退出时调用""" + global _playwright, _browser, _render_semaphore, _render_semaphore_limit + global _render_active_count + + async with _browser_lock: + if _browser is not None: + await _browser.close() + _browser = None + logger.info("[渲染] 浏览器实例已关闭") + + if _playwright is not None: + await _playwright.stop() + _playwright = None + + _render_semaphore = None + _render_semaphore_limit = None + _render_active_count = 0 + + async def render_markdown_to_html(md_text: str) -> str: """将 Markdown 转换为带样式的 HTML 文本""" @@ -96,6 +194,9 @@ async def render_html_to_image( output_path: str, *, viewport_width: int = 1280, + screenshot_selector: str | None = None, + timeout_ms: int = 60000, + proxy: str | None = None, ) -> None: """ 将 HTML 字符串转换为 PNG 图片 @@ -104,31 +205,72 @@ async def render_html_to_image( html_content: 完整的 HTML 字符串 output_path: 输出图片路径 (例如 'result.png') viewport_width: 视口宽度(像素),默认 1280 + screenshot_selector: 仅截图匹配的元素,默认截整页 + timeout_ms: 截图超时时间(毫秒),默认 60000 + proxy: 可选浏览器代理地址 """ - async with async_playwright() as p: - # 启动无头浏览器 - browser = await p.chromium.launch(headless=True) - # 设置上下文,可以指定缩放比例(device_scale_factor),2代表2倍清晰度(Retina) - context = await browser.new_context( - device_scale_factor=2, - viewport={"width": viewport_width, "height": 800}, - ) - page = await context.new_page() - # 设置页面内容 - await page.set_content(html_content) + async def _capture(page: Page) -> None: + # 等待网络空闲(确保 CDN 上的 MathJax/Mermaid 脚本加载完) + await page.wait_for_load_state("networkidle", timeout=timeout_ms) - # --- 关键:等待渲染完成 --- - # 1. 等待网络空闲(确保 CDN 上的 MathJax/Mermaid 脚本加载完) - await page.wait_for_load_state("networkidle") + # 给 Mermaid 一点时间执行 JS 绘图 + await asyncio.sleep(1) - # 2. 如果有 Mermaid,给它一点时间执行 JS 绘图 - # 如果页面里没有 mermaid 脚本,这行会很快跳过 - await asyncio.sleep(1) # 等待 1 秒钟让 Mermaid 渲染完成 + # 截图(带超时保护) + if screenshot_selector: + await page.locator(screenshot_selector).first.screenshot( + path=output_path, + timeout=timeout_ms, + ) + else: + await page.screenshot( + path=output_path, + full_page=True, + timeout=timeout_ms, + ) + + await render_html_with_page( + html_content, + _capture, + viewport_width=viewport_width, + timeout_ms=timeout_ms, + proxy=proxy, + ) + + +async def render_html_with_page( + html_content: str, + callback: Callable[[Page], Awaitable[_RenderResult]], + *, + viewport_width: int = 1280, + timeout_ms: int = 60000, + proxy: str | None = None, +) -> _RenderResult: + """在共享浏览器实例中打开 HTML 页面并交给调用方渲染。""" + browser = await _get_browser() + semaphore = await _get_semaphore() - # 3. 自动调整视口大小以匹配内容 - # 如果你想截取整个页面,使用 full_page=True - # 如果只想截取特定容器,可以定位 element = page.locator(".container") - await page.screenshot(path=output_path, full_page=True) + global _render_active_count - await browser.close() + async with semaphore: + _render_active_count += 1 + context = None + try: + context_kwargs: dict[str, Any] = { + "device_scale_factor": 2, + "viewport": {"width": viewport_width, "height": 800}, + } + if proxy: + context_kwargs["proxy"] = {"server": proxy} + context = await browser.new_context(**context_kwargs) + page = await context.new_page() + page.set_default_timeout(timeout_ms) + await page.set_content(html_content) + return await callback(page) + finally: + try: + if context is not None: + await context.close() + finally: + _render_active_count = max(0, _render_active_count - 1) diff --git a/src/Undefined/services/command.py b/src/Undefined/services/command.py index 075fd954..039b3d33 100644 --- a/src/Undefined/services/command.py +++ b/src/Undefined/services/command.py @@ -18,7 +18,10 @@ ) from Undefined.utils.sender import MessageSender from Undefined.services.commands.context import CommandContext -from Undefined.services.commands.registry import CommandMeta, CommandRegistry +from Undefined.services.commands.registry import ( + CommandRateLimit, + CommandRegistry, +) from Undefined.services.security import SecurityService from Undefined.token_usage_storage import TokenUsageStorage from Undefined.ai.queue_budget import ( @@ -48,6 +51,29 @@ _STATS_AI_FLAGS = {"--ai", "-a"} _STATS_TIME_RANGE_RE = re.compile(r"^\d+[dwm]?$", re.IGNORECASE) +# 命令参数中的 @ 提及:[@QQ] / [@QQ(昵称)] / [@{QQ}] +_AT_ARG_RE = re.compile(r"^\[@\s*\{?(\d{5,15})\}?(?:\(.*?\))?\]$") +_ARG_TOKEN_RE = re.compile(r"\[@\s*\{?\d{5,15}\}?(?:\([^\]]*\))?\]|\S+") + + +def _normalize_qq_arg(arg: str) -> str: + """将命令参数里的 @ 提及归一化为纯数字 QQ 号。 + + 命中 ``[@QQ号]`` / ``[@QQ号(昵称)]`` / ``[@{QQ号}]`` 时返回 ``"QQ号"``, + 否则原样返回。命令实现因此可以始终把参数当作纯数字处理。 + """ + if not arg: + return arg + match = _AT_ARG_RE.match(arg.strip()) + return match.group(1) if match else arg + + +def _split_command_args(args_str: str) -> list[str]: + """按空白拆分命令参数,同时保留完整的 ``[@QQ(昵称)]`` 片段。""" + if not args_str.strip(): + return [] + return [match.group(0) for match in _ARG_TOKEN_RE.finditer(args_str)] + class _PrivateCommandSenderProxy: """将命令处理器里的 send_group_message 代理到私聊发送。""" @@ -138,14 +164,21 @@ def parse_command(self, text: str) -> Optional[dict[str, Any]]: 返回: 包含命令名(name)和参数列表(args)的字典,解析失败则返回 None + + 说明: + - 仅剥离开头的 @ 机器人提及,保留命令参数中的真 @ + - 自动将参数里的 ``[@QQ号]`` / ``[@QQ号(昵称)]`` / ``[@{QQ号}]`` + 归一化为纯数字 QQ 号,命令实现无需关心格式差异 """ - clean_text = re.sub(r"\[@\s*\d+(?:\(.*?\))?\]", "", text).strip() + clean_text = re.sub(r"^(?:\[@\s*\d+(?:\(.*?\))?\]\s*)+", "", text).strip() match = re.match(r"/(\w+)\s*(.*)", clean_text) if not match: return None cmd_name = match.group(1).lower() args_str = match.group(2).strip() + raw_args = _split_command_args(args_str) + args = [_normalize_qq_arg(arg) for arg in raw_args] logger.debug( "[命令] 解析命令: text_len=%s cmd=%s args=%s", @@ -155,7 +188,7 @@ def parse_command(self, text: str) -> Optional[dict[str, Any]]: ) return { "name": cmd_name, - "args": args_str.split() if args_str else [], + "args": args, } def _parse_time_range(self, time_str: str) -> int: @@ -268,7 +301,15 @@ async def _handle_stats( forward_messages = self._build_stats_forward_nodes( summary, img_dir, days, ai_analysis ) - await self.onebot.send_forward_msg(group_id, forward_messages) + await self._send_group_forward_message( + group_id, + forward_messages, + history_message=self._build_stats_history_message( + summary, + days, + ai_analysis, + ), + ) from Undefined.utils.cache import cleanup_cache_dir @@ -284,6 +325,49 @@ async def _handle_stats( f"❌ 生成统计图表失败,请稍后重试(错误码: {error_id})", ) + async def _send_group_forward_message( + self, + group_id: int, + messages: list[dict[str, Any]], + *, + history_message: str, + ) -> None: + send_forward = getattr(self.sender, "send_group_forward_message", None) + if callable(send_forward): + await send_forward(group_id, messages, history_message=history_message) + return + + await self.onebot.send_forward_msg(group_id, messages) + if self.history_manager is None: + return + text_content = history_message.strip() + if not text_content: + return + await self.history_manager.add_group_message( + group_id=group_id, + sender_id=getattr(self.config, "bot_qq", 0), + text_content=text_content, + sender_nickname="Bot", + group_name="", + ) + + @staticmethod + def _build_stats_history_message( + summary: dict[str, Any], + days: int, + ai_analysis: str, + ) -> str: + lines = [ + f"[命令输出] /stats 最近 {days} 天 Token 使用统计", + f"总调用: {summary.get('total_calls', 0)}", + f"总 Token: {summary.get('total_tokens', 0)}", + f"输入 Token: {summary.get('prompt_tokens', 0)}", + f"输出 Token: {summary.get('completion_tokens', 0)}", + ] + if ai_analysis.strip(): + lines.extend(["", "AI 分析:", ai_analysis.strip()]) + return "\n".join(lines) + async def _handle_stats_private( self, user_id: int, @@ -996,17 +1080,6 @@ async def _send_target_message(message: str) -> None: ) return - if scope == "private" and not meta.allow_in_private: - logger.info( - "[命令] 私聊作用域禁用: /%s user=%s", - meta.name, - user_id, - ) - await _send_target_message( - f"⚠️ /{meta.name} 当前不支持私聊使用。请在群聊中 @机器人 后执行。" - ) - return - logger.info( "[命令] 命令匹配成功: input=/%s resolved=/%s permission=%s rate_limit=%s private=%s", cmd_name, @@ -1022,11 +1095,49 @@ async def _send_target_message(message: str) -> None: ) return - allowed, role_name = self._check_command_permission(meta, sender_id) + # ── 子命令解析与推断 ── + subcmd_name: str | None = None + subcmd_meta = None + if meta.subcommands: + subcmd_name, cmd_args, subcmd_meta = ( + self.command_registry.resolve_subcommand(meta, cmd_args) + ) + + # 确定实际用于权限/作用域/限流检查的元信息 + effective_permission = ( + subcmd_meta.permission if subcmd_meta else meta.permission + ) + effective_allow_private = ( + subcmd_meta.allow_in_private if subcmd_meta else meta.allow_in_private + ) + effective_rate_limit = ( + subcmd_meta.rate_limit if subcmd_meta else meta.rate_limit + ) + rate_limit_key = ( + meta.name if subcmd_name is None else f"{meta.name}:{subcmd_name}" + ) + + # 作用域检查(子命令可能覆盖 allow_in_private) + if scope == "private" and not effective_allow_private: + logger.info( + "[命令] 私聊作用域禁用: /%s subcmd=%s user=%s", + meta.name, + subcmd_name, + user_id, + ) + await _send_target_message( + f"⚠️ /{meta.name} 当前不支持私聊使用。请在群聊中 @机器人 后执行。" + ) + return + + allowed, role_name = self._check_command_permission_raw( + effective_permission, sender_id + ) if not allowed: logger.warning( - "[命令] 权限校验失败: cmd=/%s sender=%s required=%s", + "[命令] 权限校验失败: cmd=/%s subcmd=%s sender=%s required=%s", meta.name, + subcmd_name, sender_id, role_name, ) @@ -1041,13 +1152,16 @@ async def _send_target_message(message: str) -> None: logger.debug("[命令] 权限校验通过: cmd=/%s sender=%s", meta.name, sender_id) if not await self._check_command_rate_limit( - command_meta=meta, + rate_limit=effective_rate_limit, + command_name=meta.name, + rate_limit_key=rate_limit_key, sender_id=sender_id, send_message=_send_target_message, ): logger.warning( - "[命令] 速率限制拦截: cmd=/%s scope=%s sender=%s", + "[命令] 速率限制拦截: cmd=/%s subcmd=%s scope=%s sender=%s", meta.name, + subcmd_name, scope, sender_id, ) @@ -1083,6 +1197,7 @@ async def _send_target_message(message: str) -> None: is_webui_session=is_webui_session, cognitive_service=getattr(self.ai, "_cognitive_service", None), history_manager=self.history_manager, + resolved_subcommand=subcmd_name, ) try: @@ -1108,26 +1223,27 @@ async def _send_target_message(message: str) -> None: f"❌ 命令执行失败,请稍后重试(错误码: {error_id})" ) - def _check_command_permission( + def _check_command_permission_raw( self, - command_meta: CommandMeta, + permission: str, sender_id: int, ) -> tuple[bool, str]: - permission = command_meta.permission if permission == "superadmin": return self.config.is_superadmin(sender_id), "超级管理员" if permission == "admin": - return self.config.is_admin(sender_id), "管理员" + return ( + self.config.is_admin(sender_id) or self.config.is_superadmin(sender_id) + ), "管理员" return True, "" async def _check_command_rate_limit( self, - command_meta: CommandMeta, + rate_limit: CommandRateLimit, + command_name: str, + rate_limit_key: str, sender_id: int, send_message: Callable[[str], Awaitable[None]], ) -> bool: - rate_limit = command_meta.rate_limit - # 获取 rate_limiter 实例 limiter = self.rate_limiter if limiter is None and hasattr(self.security, "rate_limiter"): @@ -1136,12 +1252,12 @@ async def _check_command_rate_limit( if limiter is None: logger.warning( "[命令] 限流器缺失,跳过限流: cmd=/%s", - command_meta.name, + command_name, ) return True allowed, remaining = limiter.check_command( - sender_id, command_meta.name, rate_limit + sender_id, rate_limit_key, rate_limit ) if not allowed: if remaining >= 60: @@ -1151,15 +1267,14 @@ async def _check_command_rate_limit( else: time_str = f"{remaining}秒" - await send_message( - f"⏳ /{command_meta.name} 命令太频繁,请 {time_str}后再试" - ) + await send_message(f"⏳ /{command_name} 命令太频繁,请 {time_str}后再试") return False - limiter.record_command(sender_id, command_meta.name, rate_limit) + limiter.record_command(sender_id, rate_limit_key, rate_limit) logger.debug( - "[命令] 动态限流记录成功: cmd=/%s sender=%s limits=%s", - command_meta.name, + "[命令] 动态限流记录成功: cmd=/%s key=%s sender=%s limits=%s", + command_name, + rate_limit_key, sender_id, f"U:{rate_limit.user}/A:{rate_limit.admin}", ) @@ -1238,7 +1353,7 @@ def _parse_bugfix_args( """解析 bugfix 命令的参数""" if len(args) < 3: return ( - "❌ 用法: /bugfix [QQ号2] ... <开始时间> <结束时间>\n" + "❌ 用法: /bugfix [QQ号|@用户2] ... <开始时间> <结束时间>\n" "时间格式: YYYY/MM/DD/HH:MM,结束时间可用 now\n" "示例: /bugfix 123456 2024/12/01/09:00 now" ) @@ -1258,7 +1373,7 @@ def _parse_bugfix_args( return target_qqs, start_date, end_date, start_str, end_str except ValueError: - return "❌ 参数格式错误:QQ号应为数字,时间格式应为 YYYY/MM/DD/HH:MM。" + return "❌ 参数格式错误:QQ号应为数字或 @ 提及,时间格式应为 YYYY/MM/DD/HH:MM。" async def _obtain_bugfix_summary(self, group_id: int, processed_text: str) -> str: """利用 AI 生成聊天记录的 Bug 分析摘要""" diff --git a/src/Undefined/services/commands/context.py b/src/Undefined/services/commands/context.py index 56732b97..07b98694 100644 --- a/src/Undefined/services/commands/context.py +++ b/src/Undefined/services/commands/context.py @@ -34,3 +34,14 @@ class CommandContext: is_webui_session: bool = False cognitive_service: Any = None history_manager: Any = None + resolved_subcommand: str | None = None + + def check_permission(self, permission: str) -> bool: + """统一权限检查入口,供 handler 内部提权场景使用。""" + if permission == "superadmin": + return self.config.is_superadmin(self.sender_id) + if permission == "admin": + return self.config.is_admin(self.sender_id) or self.config.is_superadmin( + self.sender_id + ) + return True diff --git a/src/Undefined/services/commands/registry.py b/src/Undefined/services/commands/registry.py index 9fe3df2d..2c0533de 100644 --- a/src/Undefined/services/commands/registry.py +++ b/src/Undefined/services/commands/registry.py @@ -3,11 +3,12 @@ import asyncio import json import logging +import re import sys import threading import time import types -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path from typing import Any, Awaitable, Callable, cast @@ -35,6 +36,35 @@ class CommandRateLimit: superadmin: int = 0 +@dataclass +class SubcommandInferenceRule: + """子命令推断规则:正则匹配 args[0] 推断为指定子命令。""" + + pattern: re.Pattern[str] + subcommand: str + + +@dataclass +class SubcommandInference: + """子命令自动推断配置。""" + + default: str | None = None + rules: list[SubcommandInferenceRule] = field(default_factory=list) + fallback: str | None = None + + +@dataclass +class SubcommandMeta: + """子命令元信息。""" + + name: str + description: str + permission: str + allow_in_private: bool + rate_limit: CommandRateLimit + args: str = "" + + @dataclass class CommandMeta: """命令元信息。""" @@ -55,6 +85,8 @@ class CommandMeta: module_name: str visibility_path: Path | None visibility_module_name: str | None + subcommands: dict[str, SubcommandMeta] = field(default_factory=dict) + inference: SubcommandInference | None = None handler: CommandHandler | None = None visibility_checker: CommandVisibilityChecker | None = None @@ -153,17 +185,21 @@ def _load_command_dir( ] ) + parent_rate_limit = self._normalize_rate_limit(config.get("rate_limit")) + parent_permission = self._normalize_permission(config.get("permission")) + parent_allow_private = bool(config.get("allow_in_private", False)) + meta = CommandMeta( name=name, description=str(config.get("description") or "").strip(), usage=str(config.get("usage") or f"/{name}").strip(), example=str(config.get("example") or "").strip(), - permission=self._normalize_permission(config.get("permission")), - rate_limit=self._normalize_rate_limit(config.get("rate_limit")), + permission=parent_permission, + rate_limit=parent_rate_limit, show_in_help=bool(config.get("show_in_help", True)), order=int(config.get("order", 999)), aliases=self._normalize_aliases(config.get("aliases")), - allow_in_private=bool(config.get("allow_in_private", False)), + allow_in_private=parent_allow_private, help_footer=self._normalize_help_footer(config.get("help_footer")), handler_path=handler_path, doc_path=(command_dir / _COMMAND_DOC_FILENAME) @@ -184,6 +220,13 @@ def _load_command_dir( ) if (command_dir / _COMMAND_POLICY_FILENAME).exists() else None, + subcommands=self._normalize_subcommands( + config.get("subcommands"), + parent_permission, + parent_allow_private, + parent_rate_limit, + ), + inference=self._normalize_inference(config.get("inference")), ) if name in commands: logger.warning( @@ -192,13 +235,17 @@ def _load_command_dir( command_dir, ) commands[name] = meta + subcmd_info = ( + f" subcommands={len(meta.subcommands)}" if meta.subcommands else "" + ) logger.info( - "[CommandRegistry] 已注册命令: /%s permission=%s rate_limit=%s private=%s aliases=%s", + "[CommandRegistry] 已注册命令: /%s permission=%s rate_limit=%s private=%s aliases=%s%s", meta.name, meta.permission, f"U:{meta.rate_limit.user}s/A:{meta.rate_limit.admin}s/S:{meta.rate_limit.superadmin}s", meta.allow_in_private, meta.aliases or "[]", + subcmd_info, ) for alias in meta.aliases: existing = aliases.get(alias) @@ -248,6 +295,24 @@ def _normalize_rate_limit(self, value: Any) -> CommandRateLimit: return CommandRateLimit(user=3600, admin=0, superadmin=0) return CommandRateLimit() + def _merge_rate_limit( + self, value: Any, parent_rate_limit: CommandRateLimit + ) -> CommandRateLimit: + if not isinstance(value, dict): + return parent_rate_limit + try: + return CommandRateLimit( + user=int(value.get("user", parent_rate_limit.user)), + admin=int(value.get("admin", parent_rate_limit.admin)), + superadmin=int(value.get("superadmin", parent_rate_limit.superadmin)), + ) + except (ValueError, TypeError): + logger.warning( + "[CommandRegistry] 子命令限流配置解析失败,继承父命令: %s", + value, + ) + return parent_rate_limit + def _normalize_aliases(self, value: Any) -> list[str]: if not isinstance(value, list): return [] @@ -268,6 +333,122 @@ def _normalize_help_footer(self, value: Any) -> list[str]: footer.append(line) return footer + def _normalize_subcommands( + self, + value: Any, + parent_permission: str, + parent_allow_private: bool, + parent_rate_limit: CommandRateLimit, + ) -> dict[str, SubcommandMeta]: + if not isinstance(value, dict): + return {} + result: dict[str, SubcommandMeta] = {} + for sub_name, sub_cfg in value.items(): + if not isinstance(sub_cfg, dict): + logger.warning("[CommandRegistry] 子命令配置无效,跳过: %s", sub_name) + continue + name = str(sub_name).strip().lower() + if not name: + continue + sub_permission = self._normalize_permission( + sub_cfg.get("permission", parent_permission) + ) + sub_allow_private = bool( + sub_cfg.get("allow_in_private", parent_allow_private) + ) + sub_rate_limit = self._merge_rate_limit( + sub_cfg.get("rate_limit"), parent_rate_limit + ) + result[name] = SubcommandMeta( + name=name, + description=str(sub_cfg.get("description") or "").strip(), + permission=sub_permission, + allow_in_private=sub_allow_private, + rate_limit=sub_rate_limit, + args=str(sub_cfg.get("args") or "").strip(), + ) + return result + + def _normalize_inference(self, value: Any) -> SubcommandInference | None: + if not isinstance(value, dict): + return None + default = None + if "default" in value: + default = str(value["default"]).strip().lower() or None + rules: list[SubcommandInferenceRule] = [] + raw_rules = value.get("rules") + if isinstance(raw_rules, list): + for rule_cfg in raw_rules: + if not isinstance(rule_cfg, dict): + continue + pattern_str = str(rule_cfg.get("pattern") or "").strip() + subcmd = str(rule_cfg.get("subcommand") or "").strip().lower() + if pattern_str and subcmd: + try: + compiled = re.compile(pattern_str) + rules.append( + SubcommandInferenceRule(pattern=compiled, subcommand=subcmd) + ) + except re.error: + logger.warning( + "[CommandRegistry] 推断规则正则编译失败: %s", + pattern_str, + ) + fallback = None + if "fallback" in value: + fallback = str(value["fallback"]).strip().lower() or None + if default is None and not rules and fallback is None: + return None + return SubcommandInference(default=default, rules=rules, fallback=fallback) + + def resolve_subcommand( + self, meta: CommandMeta, args: list[str] + ) -> tuple[str | None, list[str], SubcommandMeta | None]: + """解析/推断子命令,返回 (subcmd_name, rewritten_args, subcmd_meta)。 + + rewritten_args 为 [subcmd, *sub_args],handler 统一按此格式分发。 + 若无法推断则 subcmd_name=None, subcmd_meta=None, args 不变。 + """ + if not meta.subcommands: + return None, args, None + + # 1. args[0] 显式匹配子命令名 + if args: + first = args[0].strip().lower() + if first in meta.subcommands: + return first, args, meta.subcommands[first] + + # 2. 推断流程 + inference = meta.inference + if inference is None: + return None, args, None + + # 2a. 无参数 + default + if not args and inference.default is not None: + subcmd_name = inference.default + if subcmd_name in meta.subcommands: + return subcmd_name, [subcmd_name], meta.subcommands[subcmd_name] + + # 2b. rules 匹配 args[0] + if args and inference.rules: + for rule in inference.rules: + if rule.pattern.fullmatch(args[0]): + subcmd_name = rule.subcommand + if subcmd_name in meta.subcommands: + return ( + subcmd_name, + [subcmd_name, *args], + meta.subcommands[subcmd_name], + ) + + # 2c. fallback + if args and inference.fallback is not None: + subcmd_name = inference.fallback + if subcmd_name in meta.subcommands: + return subcmd_name, [subcmd_name, *args], meta.subcommands[subcmd_name] + + return None, args, None + def _build_snapshot(self) -> dict[str, CommandSnapshot]: if not self.base_dir.exists(): return {} diff --git a/src/Undefined/services/model_pool.py b/src/Undefined/services/model_pool.py index 2e6787ad..d075c768 100644 --- a/src/Undefined/services/model_pool.py +++ b/src/Undefined/services/model_pool.py @@ -4,6 +4,7 @@ import asyncio import logging +import re from typing import TYPE_CHECKING, Any from Undefined.config.models import ChatModelConfig @@ -14,6 +15,9 @@ logger = logging.getLogger(__name__) +_COMPARE_COMMAND_RE = re.compile(r"^/(?:compare|pk)(?:\s+(?P.*))?$") +_SELECT_COMMAND_RE = re.compile(r"^选\s*\d+\s*$") + class ModelPoolService: """封装多模型池的私聊交互逻辑,与消息处理层解耦""" @@ -23,26 +27,36 @@ def __init__(self, ai: Any, config: "Config", sender: MessageSender) -> None: self._config = config self._sender = sender + @staticmethod + def is_private_control_text(text: str) -> bool: + stripped = text.strip() + return bool( + _COMPARE_COMMAND_RE.fullmatch(stripped) + or _SELECT_COMMAND_RE.fullmatch(stripped) + ) + async def handle_private_message(self, user_id: int, text: str) -> bool: """处理私聊多模型指令,返回 True 表示消息已被消费""" if not self._config.model_pool_enabled: return False - selector = self._ai.model_selector - - selected = selector.try_resolve_compare(0, user_id, text) - if selected: - selector.set_preference(0, user_id, "chat", selected) - await selector.save_preferences() - await self._sender.send_private_message( - user_id, f"已切换到模型: {selected}" + stripped = text.strip() + compare_match = _COMPARE_COMMAND_RE.fullmatch(stripped) + if compare_match: + await self._run_compare( + user_id, (compare_match.group("prompt") or "").strip() ) return True - stripped = text.strip() - for prefix in ("/compare ", "/pk "): - if stripped.startswith(prefix): - await self._run_compare(user_id, stripped[len(prefix) :].strip()) + if _SELECT_COMMAND_RE.fullmatch(stripped): + selector = self._ai.model_selector + selected = selector.try_resolve_compare(0, user_id, stripped) + if selected: + selector.set_preference(0, user_id, "chat", selected) + await selector.save_preferences() + await self._sender.send_private_message( + user_id, f"已切换到模型: {selected}" + ) return True return False diff --git a/src/Undefined/skills/README.md b/src/Undefined/skills/README.md index 74e69dbe..0e4cca2a 100644 --- a/src/Undefined/skills/README.md +++ b/src/Undefined/skills/README.md @@ -8,6 +8,14 @@ ``` skills/ +├── auto_pipeline/ # 自动处理管线,斜杠命令之后、AI 之前并行检测/处理 +│ ├── __init__.py +│ ├── registry.py +│ └── pipelines/ +│ ├── bilibili/ +│ ├── arxiv/ +│ └── github/ +│ ├── tools/ # 基础小工具,直接暴露给 AI 调用 │ ├── __init__.py │ ├── send_message/ @@ -62,6 +70,16 @@ skills/ ## 工具、智能体、工具集与 Anthropic Skills 对比 +### 自动处理管线 + +- **定位**: 消息进入 AI 前的自动预处理能力,例如 Bilibili、arXiv、GitHub 链接提取;斜杠命令优先级更高,命中后不触发管线。 +- **调用方式**: `MessageHandler` 自动调用,不暴露给 AI 主动调用。 +- **命名规则**: `pipelines//`,`config.json` 中的 `name` 必须与命中结果一致。 +- **目录结构**: `auto_pipeline/pipelines/{pipeline_name}/config.json + handler.py`。 +- **执行方式**: 同一条非命令消息会并行检测全部管线,并行处理全部命中结果;处理产出的消息通过统一发送层写入历史并自动登记本地媒体/文件附件后,再进入 AI 自动回复。 +- **热重载**: 跟随 `[skills]` 的 `hot_reload`、`hot_reload_interval`、`hot_reload_debounce` 配置。 +- **示例**: `bilibili`, `arxiv`, `github` + ### 基础工具 - **定位**: 单一功能的原子操作 @@ -110,16 +128,25 @@ skills/ ## 选择指南 -| 特性 | 基础工具 | 工具集 | 智能体 | 平台指令 (Commands) | Anthropic Skills | -|------|----------|--------|--------|------------------|------------------| -| 复杂度 | 低 | 中 | 高 | 中(独立执行逻辑) | 低(纯提示词) | -| 调用层级 | 直接调用 | 直接调用 | 间接调用 | 被群聊拦截器直接执行 | 直接调用(tool) | -| 内部工具 | 无 | 无 | 可包含多个子工具 | 无 | 无(知识注入) | -| 适用场景 | 通用原子操作 | 功能分组工具 | 领域复杂任务 | 基础系统管理与控制 | 领域知识/指导 | -| 格式 | config.json + handler.py | config.json + handler.py | config.json + handler.py + prompt.md | config.json + handler.py | SKILL.md | +| 特性 | 自动处理管线 | 基础工具 | 工具集 | 智能体 | 平台指令 (Commands) | Anthropic Skills | +|------|--------------|----------|--------|--------|------------------|------------------| +| 复杂度 | 中 | 低 | 中 | 高 | 中(独立执行逻辑) | 低(纯提示词) | +| 调用层级 | 消息预处理自动调用 | 直接调用 | 直接调用 | 间接调用 | 被群聊拦截器直接执行 | 直接调用(tool) | +| 内部工具 | 无 | 无 | 无 | 可包含多个子工具 | 无 | 无(知识注入) | +| 适用场景 | 链接/内容自动提取 | 通用原子操作 | 功能分组工具 | 领域复杂任务 | 基础系统管理与控制 | 领域知识/指导 | +| 格式 | config.json + handler.py | config.json + handler.py | config.json + handler.py | config.json + handler.py + prompt.md | config.json + handler.py | SKILL.md | ## 添加新技能 +### 添加自动处理管线 + +1. 在 `skills/auto_pipeline/pipelines/` 下创建新目录 +2. 添加 `config.json`,包含 `name`、`description`、`order` 和 `enabled` +3. 添加 `handler.py`,必须包含 `async def detect(context)` 与 `async def process(detection, context)` +4. 自动被 `AutoPipelineRegistry` 发现和注册,并支持热重载 + +详细说明请参考 [自动处理管线开发指南](../../../docs/auto-pipeline.md)。 + ### 添加基础工具 1. 在 `skills/tools/` 下创建新目录 diff --git a/src/Undefined/skills/__init__.py b/src/Undefined/skills/__init__.py index 3c624495..1cd4ed03 100644 --- a/src/Undefined/skills/__init__.py +++ b/src/Undefined/skills/__init__.py @@ -6,5 +6,6 @@ from Undefined.skills.tools import ToolRegistry from Undefined.skills.agents import AgentRegistry +from Undefined.skills.auto_pipeline import AutoPipelineRegistry -__all__ = ["ToolRegistry", "AgentRegistry"] +__all__ = ["ToolRegistry", "AgentRegistry", "AutoPipelineRegistry"] diff --git a/src/Undefined/skills/agents/entertainment_agent/prompt.md b/src/Undefined/skills/agents/entertainment_agent/prompt.md index 5672395a..8160f03a 100644 --- a/src/Undefined/skills/agents/entertainment_agent/prompt.md +++ b/src/Undefined/skills/agents/entertainment_agent/prompt.md @@ -7,8 +7,8 @@ - 输出轻松友好,但不要过度承诺或编造。 - 如果要发独立表情包,默认先调用 `memes.search_memes` + `memes.send_meme_by_uid`,把表情包单独发一条,不和正文混在一起。 - 对于吐槽、附和、接梗、表达态度或情绪的回复,默认由表情包承担主要表达;只要能发表情包,就不要先用文字描述来替代它。 -- 如果工具返回了图片 UID,且用户确实需要图文并茂的结果,可以在最终回复里用 `` 做图文混排。 -- `` 可以引用当前会话图片或表情包库返回的图片 UID,不能臆造,也不要改写成 Markdown 图片语法。 +- 如果工具返回了图片 UID,且用户确实需要图文并茂的结果,可以在最终回复里用 `` 做图文混排。 +- `` 可以引用当前会话图片或表情包库返回的图片 UID,不能臆造,也不要改写成 Markdown 图片语法。 - 如果用户明确要求“参考这张图画”“照这个风格重画”“基于这些图生成”,优先调用 `ai_draw_one` 并传入 `reference_image_uids`,不要把图片内容重新手写成长段描述后再当纯文本生图。 边界提醒: diff --git a/src/Undefined/skills/agents/entertainment_agent/tools/ai_draw_one/handler.py b/src/Undefined/skills/agents/entertainment_agent/tools/ai_draw_one/handler.py index 0aa440c7..87196909 100644 --- a/src/Undefined/skills/agents/entertainment_agent/tools/ai_draw_one/handler.py +++ b/src/Undefined/skills/agents/entertainment_agent/tools/ai_draw_one/handler.py @@ -895,7 +895,7 @@ async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: return register_error or "图片生成失败:无法创建内嵌图片 UID" success = True uid = str(getattr(registered_record, "uid", "") or "").strip() - return f'已生成图片,可在回复中插入 ' + return f'已生成图片,可在回复中插入 ' resolved_target_id, resolved_message_type, target_error = _resolve_send_target( target_id, diff --git a/src/Undefined/skills/agents/entertainment_agent/tools/minecraft_skin/handler.py b/src/Undefined/skills/agents/entertainment_agent/tools/minecraft_skin/handler.py index d19162cb..5ab5244a 100644 --- a/src/Undefined/skills/agents/entertainment_agent/tools/minecraft_skin/handler.py +++ b/src/Undefined/skills/agents/entertainment_agent/tools/minecraft_skin/handler.py @@ -99,7 +99,7 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: if delivery == "embed": if record is None: return "获取成功,但无法注册到附件系统(缺少 attachment_registry 或 scope_key)" - return f'' + return f'' # delivery == "send" resolved_target_id, resolved_message_type, target_error = _resolve_send_target( diff --git a/src/Undefined/skills/agents/entertainment_agent/tools/wenchang_dijun/handler.py b/src/Undefined/skills/agents/entertainment_agent/tools/wenchang_dijun/handler.py index c151319e..6f4a8e40 100644 --- a/src/Undefined/skills/agents/entertainment_agent/tools/wenchang_dijun/handler.py +++ b/src/Undefined/skills/agents/entertainment_agent/tools/wenchang_dijun/handler.py @@ -51,7 +51,7 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: source_kind="wenchang_dijun", source_ref=pic, ) - result += f'\n签文图片:' + result += f'\n签文图片:' except Exception as exc: logger.warning("注册文昌帝君签文图片失败: %s", exc) result += f"\n签文图片:{pic}" diff --git a/src/Undefined/skills/auto_pipeline/__init__.py b/src/Undefined/skills/auto_pipeline/__init__.py new file mode 100644 index 00000000..165a394d --- /dev/null +++ b/src/Undefined/skills/auto_pipeline/__init__.py @@ -0,0 +1,6 @@ +"""自动处理管线注册与运行。""" + +from Undefined.skills.auto_pipeline.models import AutoPipelineDetection +from Undefined.skills.auto_pipeline.registry import AutoPipelineRegistry + +__all__ = ["AutoPipelineDetection", "AutoPipelineRegistry"] diff --git a/src/Undefined/skills/auto_pipeline/models.py b/src/Undefined/skills/auto_pipeline/models.py new file mode 100644 index 00000000..3d3fb7e1 --- /dev/null +++ b/src/Undefined/skills/auto_pipeline/models.py @@ -0,0 +1,18 @@ +"""自动处理管线的共享数据模型。""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Literal, Mapping + +AutoPipelineTargetType = Literal["group", "private"] +AutoPipelineContext = dict[str, Any] + + +@dataclass(frozen=True) +class AutoPipelineDetection: + """单条自动处理管线的命中结果。""" + + name: str + items: tuple[str, ...] + metadata: Mapping[str, Any] = field(default_factory=dict) diff --git a/src/Undefined/skills/auto_pipeline/pipelines/arxiv/config.json b/src/Undefined/skills/auto_pipeline/pipelines/arxiv/config.json new file mode 100644 index 00000000..2fd349a0 --- /dev/null +++ b/src/Undefined/skills/auto_pipeline/pipelines/arxiv/config.json @@ -0,0 +1,6 @@ +{ + "name": "arxiv", + "description": "检测并处理 arXiv 论文链接或论文编号自动提取。", + "order": 20, + "enabled": true +} diff --git a/src/Undefined/skills/auto_pipeline/pipelines/arxiv/handler.py b/src/Undefined/skills/auto_pipeline/pipelines/arxiv/handler.py new file mode 100644 index 00000000..2fcd83db --- /dev/null +++ b/src/Undefined/skills/auto_pipeline/pipelines/arxiv/handler.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from typing import Any + +from Undefined.skills.auto_pipeline.models import ( + AutoPipelineContext, + AutoPipelineDetection, +) + + +def _is_allowed(config: Any, target_type: str, target_id: int) -> bool: + if not getattr(config, "arxiv_auto_extract_enabled", False): + return False + if target_type == "group": + return bool(config.is_arxiv_auto_extract_allowed_group(target_id)) + return bool(config.is_arxiv_auto_extract_allowed_private(target_id)) + + +async def detect(context: AutoPipelineContext) -> AutoPipelineDetection | None: + target_id = int(context["target_id"]) + target_type = str(context["target_type"]) + config = context["config"] + if not _is_allowed(config, target_type, target_id): + return None + + extractor = context["extract_arxiv_ids"] + paper_ids = extractor(context["text"], context["message_content"]) + if not paper_ids: + return None + return AutoPipelineDetection( + name="arxiv", items=tuple(str(item) for item in paper_ids) + ) + + +async def process( + detection: AutoPipelineDetection, + context: AutoPipelineContext, +) -> None: + handler = context["handle_arxiv_extract"] + await handler( + int(context["target_id"]), + list(detection.items), + str(context["target_type"]), + ) diff --git a/src/Undefined/skills/auto_pipeline/pipelines/bilibili/config.json b/src/Undefined/skills/auto_pipeline/pipelines/bilibili/config.json new file mode 100644 index 00000000..5c416a80 --- /dev/null +++ b/src/Undefined/skills/auto_pipeline/pipelines/bilibili/config.json @@ -0,0 +1,6 @@ +{ + "name": "bilibili", + "description": "检测并处理 Bilibili 链接、AV 号或 BV 号自动提取。", + "order": 10, + "enabled": true +} diff --git a/src/Undefined/skills/auto_pipeline/pipelines/bilibili/handler.py b/src/Undefined/skills/auto_pipeline/pipelines/bilibili/handler.py new file mode 100644 index 00000000..5528b554 --- /dev/null +++ b/src/Undefined/skills/auto_pipeline/pipelines/bilibili/handler.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from typing import Any + +from Undefined.skills.auto_pipeline.models import ( + AutoPipelineContext, + AutoPipelineDetection, +) + + +def _is_allowed(config: Any, target_type: str, target_id: int) -> bool: + if not getattr(config, "bilibili_auto_extract_enabled", False): + return False + if target_type == "group": + return bool(config.is_bilibili_auto_extract_allowed_group(target_id)) + return bool(config.is_bilibili_auto_extract_allowed_private(target_id)) + + +async def detect(context: AutoPipelineContext) -> AutoPipelineDetection | None: + target_id = int(context["target_id"]) + target_type = str(context["target_type"]) + config = context["config"] + if not _is_allowed(config, target_type, target_id): + return None + + extractor = context["extract_bilibili_ids"] + bvids = await extractor(context["text"], context["message_content"]) + if not bvids: + return None + return AutoPipelineDetection( + name="bilibili", items=tuple(str(item) for item in bvids) + ) + + +async def process( + detection: AutoPipelineDetection, + context: AutoPipelineContext, +) -> None: + handler = context["handle_bilibili_extract"] + await handler( + int(context["target_id"]), + list(detection.items), + str(context["target_type"]), + ) diff --git a/src/Undefined/skills/auto_pipeline/pipelines/github/config.json b/src/Undefined/skills/auto_pipeline/pipelines/github/config.json new file mode 100644 index 00000000..6090f459 --- /dev/null +++ b/src/Undefined/skills/auto_pipeline/pipelines/github/config.json @@ -0,0 +1,6 @@ +{ + "name": "github", + "description": "检测并处理 GitHub public 仓库链接或 owner/repo 自动提取。", + "order": 30, + "enabled": true +} diff --git a/src/Undefined/skills/auto_pipeline/pipelines/github/handler.py b/src/Undefined/skills/auto_pipeline/pipelines/github/handler.py new file mode 100644 index 00000000..392e458d --- /dev/null +++ b/src/Undefined/skills/auto_pipeline/pipelines/github/handler.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from typing import Any + +from Undefined.skills.auto_pipeline.models import ( + AutoPipelineContext, + AutoPipelineDetection, +) + + +def _is_allowed(config: Any, target_type: str, target_id: int) -> bool: + if not getattr(config, "github_auto_extract_enabled", False): + return False + if target_type == "group": + return bool(config.is_github_auto_extract_allowed_group(target_id)) + return bool(config.is_github_auto_extract_allowed_private(target_id)) + + +async def detect(context: AutoPipelineContext) -> AutoPipelineDetection | None: + target_id = int(context["target_id"]) + target_type = str(context["target_type"]) + config = context["config"] + if not _is_allowed(config, target_type, target_id): + return None + + extractor = context["extract_github_repo_ids"] + repo_ids = extractor(context["text"], context["message_content"]) + if not repo_ids: + return None + return AutoPipelineDetection( + name="github", items=tuple(str(item) for item in repo_ids) + ) + + +async def process( + detection: AutoPipelineDetection, + context: AutoPipelineContext, +) -> None: + handler = context["handle_github_extract"] + await handler( + int(context["target_id"]), + list(detection.items), + str(context["target_type"]), + ) diff --git a/src/Undefined/skills/auto_pipeline/registry.py b/src/Undefined/skills/auto_pipeline/registry.py new file mode 100644 index 00000000..e825384f --- /dev/null +++ b/src/Undefined/skills/auto_pipeline/registry.py @@ -0,0 +1,293 @@ +"""自动处理管线注册器。""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +import importlib.util +import json +import logging +from pathlib import Path +import sys +import time +from types import ModuleType +from typing import Any, Awaitable, Callable + +from Undefined.skills.auto_pipeline.models import ( + AutoPipelineContext, + AutoPipelineDetection, +) + +logger = logging.getLogger(__name__) + +DetectHandler = Callable[[AutoPipelineContext], Awaitable[AutoPipelineDetection | None]] +ProcessHandler = Callable[[AutoPipelineDetection, AutoPipelineContext], Awaitable[None]] + + +@dataclass(frozen=True) +class AutoPipelineItem: + name: str + description: str + order: int + handler_path: Path + module_name: str + detect: DetectHandler + process: ProcessHandler + + +class AutoPipelineRegistry: + """发现、热重载并并行运行自动处理管线。""" + + def __init__(self, base_dir: Path | str | None = None) -> None: + self.base_dir = ( + Path(base_dir) + if base_dir is not None + else Path(__file__).parent / "pipelines" + ) + self._items: dict[str, AutoPipelineItem] = {} + self._items_lock = asyncio.Lock() + self._reload_lock = asyncio.Lock() + self._watch_task: asyncio.Task[None] | None = None + self._watch_stop: asyncio.Event | None = None + self._last_snapshot: dict[str, tuple[int, int]] = {} + self._watch_filenames: set[str] = {"config.json", "handler.py"} + + def load_items(self) -> None: + """从磁盘加载所有自动处理管线。""" + self._items = self._load_items_sync() + + async def load_items_async(self) -> None: + """在线程中加载所有自动处理管线,避免阻塞事件循环。""" + async with self._reload_lock: + await self._reload_items() + + def _load_items_sync(self) -> dict[str, AutoPipelineItem]: + """同步加载管线定义;热重载路径会在线程中调用。""" + items: dict[str, AutoPipelineItem] = {} + if not self.base_dir.exists(): + logger.warning("[auto_pipeline] 目录不存在: %s", self.base_dir) + return items + + for item_dir in sorted(self.base_dir.iterdir()): + if not item_dir.is_dir() or item_dir.name.startswith("_"): + continue + item = self._load_item(item_dir) + if item is not None: + items[item.name] = item + + loaded_items = dict(sorted(items.items(), key=lambda pair: pair[1].order)) + logger.info( + "[auto_pipeline] 已加载自动处理管线: count=%s names=%s", + len(loaded_items), + ",".join(loaded_items), + ) + return loaded_items + + def _load_item(self, item_dir: Path) -> AutoPipelineItem | None: + config_path = item_dir / "config.json" + handler_path = item_dir / "handler.py" + if not config_path.exists() or not handler_path.exists(): + logger.debug("[auto_pipeline] 跳过缺少 config/handler 的目录: %s", item_dir) + return None + + try: + config = self._load_config(config_path) + if not config.get("enabled", True): + return None + name = str(config["name"]).strip() + description = str(config.get("description", "")).strip() + order = int(config.get("order", 100)) + module = self._load_handler_module(name, handler_path) + detect = getattr(module, "detect", None) + process = getattr(module, "process", None) + if not callable(detect) or not callable(process): + raise RuntimeError( + "handler.py 必须提供 detect(context) 和 process(detection, context)" + ) + return AutoPipelineItem( + name=name, + description=description, + order=order, + handler_path=handler_path, + module_name=self._build_module_name(name), + detect=detect, + process=process, + ) + except Exception: + logger.exception("[auto_pipeline] 加载管线失败: %s", item_dir) + return None + + def _load_config(self, config_path: Path) -> dict[str, Any]: + with open(config_path, "r", encoding="utf-8") as file: + data = json.load(file) + if not isinstance(data, dict) or not str(data.get("name", "")).strip(): + raise ValueError("config.json 必须包含 name") + return data + + def _build_module_name(self, name: str) -> str: + return f"Undefined.skills.auto_pipeline.pipelines.{name}.handler" + + def _load_handler_module(self, name: str, handler_path: Path) -> ModuleType: + module_name = self._build_module_name(name) + if module_name in sys.modules: + del sys.modules[module_name] + + spec = importlib.util.spec_from_file_location(module_name, handler_path) + if spec is None or spec.loader is None: + raise RuntimeError(f"无法加载 handler: {handler_path}") + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + try: + spec.loader.exec_module(module) + except Exception: + if sys.modules.get(module_name) is module: + del sys.modules[module_name] + raise + return module + + async def run(self, context: AutoPipelineContext) -> list[AutoPipelineDetection]: + """并行检测所有管线,并并行处理全部命中结果。""" + detections = await self.detect(context) + if detections: + await self.process(detections, context) + return detections + + async def detect(self, context: AutoPipelineContext) -> list[AutoPipelineDetection]: + async with self._items_lock: + items = list(self._items.values()) + if not items: + return [] + + results = await asyncio.gather( + *(self._detect_one(item, context) for item in items), + return_exceptions=True, + ) + detections: list[AutoPipelineDetection] = [] + for item, result in zip(items, results, strict=True): + if isinstance(result, BaseException): + logger.exception( + "[auto_pipeline] 检测失败: name=%s", + item.name, + exc_info=(type(result), result, result.__traceback__), + ) + continue + if result is not None: + detections.append(result) + return detections + + async def _detect_one( + self, item: AutoPipelineItem, context: AutoPipelineContext + ) -> AutoPipelineDetection | None: + start = time.monotonic() + detection = await item.detect(context) + duration_ms = int((time.monotonic() - start) * 1000) + if detection is not None: + logger.info( + "[auto_pipeline] 命中管线: name=%s items=%s duration_ms=%s", + item.name, + len(detection.items), + duration_ms, + ) + return detection + + async def process( + self, + detections: list[AutoPipelineDetection], + context: AutoPipelineContext, + ) -> None: + async with self._items_lock: + items = dict(self._items) + tasks: list[Awaitable[None]] = [] + names: list[str] = [] + for detection in detections: + item = items.get(detection.name) + if item is None: + logger.warning( + "[auto_pipeline] 命中结果缺少处理器: name=%s", detection.name + ) + continue + names.append(detection.name) + tasks.append(self._process_one(item, detection, context)) + if not tasks: + return + + results = await asyncio.gather(*tasks, return_exceptions=True) + for name, result in zip(names, results, strict=True): + if isinstance(result, BaseException): + logger.exception( + "[auto_pipeline] 处理失败: name=%s", + name, + exc_info=(type(result), result, result.__traceback__), + ) + + async def _process_one( + self, + item: AutoPipelineItem, + detection: AutoPipelineDetection, + context: AutoPipelineContext, + ) -> None: + start = time.monotonic() + await item.process(detection, context) + logger.info( + "[auto_pipeline] 管线处理完成: name=%s duration_ms=%s", + item.name, + int((time.monotonic() - start) * 1000), + ) + + def _compute_snapshot(self) -> dict[str, tuple[int, int]]: + snapshot: dict[str, tuple[int, int]] = {} + if not self.base_dir.exists(): + return snapshot + for path in self.base_dir.rglob("*"): + if not path.is_file() or path.name not in self._watch_filenames: + continue + try: + stat = path.stat() + snapshot[str(path)] = (int(stat.st_mtime_ns), int(stat.st_size)) + except OSError: + continue + return snapshot + + async def _reload_items(self) -> None: + items = await asyncio.to_thread(self._load_items_sync) + async with self._items_lock: + self._items = items + + async def _watch_loop(self, interval: float, debounce: float) -> None: + self._last_snapshot = await asyncio.to_thread(self._compute_snapshot) + last_change = 0.0 + pending = False + while self._watch_stop and not self._watch_stop.is_set(): + await asyncio.sleep(interval) + snapshot = await asyncio.to_thread(self._compute_snapshot) + if snapshot != self._last_snapshot: + self._last_snapshot = snapshot + last_change = time.monotonic() + pending = True + if pending and (time.monotonic() - last_change) >= debounce: + pending = False + async with self._reload_lock: + await self._reload_items() + logger.info("[auto_pipeline] 热重载完成: count=%s", len(self._items)) + + def start_hot_reload(self, interval: float = 2.0, debounce: float = 0.5) -> None: + if self._watch_task is not None: + return + self._watch_stop = asyncio.Event() + self._watch_task = asyncio.create_task(self._watch_loop(interval, debounce)) + logger.info( + "[auto_pipeline] 热重载已启动: interval=%.2fs debounce=%.2fs", + interval, + debounce, + ) + + async def stop_hot_reload(self) -> None: + if self._watch_task is None or self._watch_stop is None: + return + self._watch_stop.set() + try: + await self._watch_task + finally: + self._watch_task = None + self._watch_stop = None + logger.info("[auto_pipeline] 热重载已停止") diff --git a/src/Undefined/skills/commands/README.md b/src/Undefined/skills/commands/README.md index 4d646668..74cde82b 100644 --- a/src/Undefined/skills/commands/README.md +++ b/src/Undefined/skills/commands/README.md @@ -12,7 +12,7 @@ commands/ ├── addadmin/ # 在运行时动态添加普通管理员QQ的指令 ├── bugfix/ # 一键读取群上下文帮你诊断并回复 bug 发作原因的娱乐工具 ├── copyright/ # 输出版权信息、开源协议与风险免责声明 -├── delfaq/ # 删除特定 ID 的常见问题解答 +├── faq/ # FAQ 管理:列表/查看/搜索/删除(支持自动推断子命令) ├── help/ # 打印基础指令集列表 ├── lsadmin/ # 列出并获取当前系统的管理员和超管花名册 ├── ... @@ -23,8 +23,8 @@ commands/ 要在系统里跑通一个斜杠指令,你需要新建目录并放入: -- `config.json`:命令元信息(名称、权限、限流、帮助摘要等) +- `config.json`:命令元信息(名称、权限、限流、子命令、自动推断、帮助摘要等) - `handler.py`:命令执行逻辑 -- `README.md`:详细帮助文档(会被 `/help ` 自动读取并展示) +- `README.md`:详细帮助文档(会被 `/help ` 自动读取并作为 Markdown 渲染到帮助图片中;需要纯文本时使用 `/help -t`) -关于 `config.json` 的具体参数格式(包括限流规则配置)以及 `handler.py` 的执行上下文和逻辑编写,请前往 **[斜杠指令开发指南](../../../../docs/slash-commands.md#🛠️-第二部分如何自定义扩展新的斜杠命令)** 查阅最新的开发说明。 +关于 `config.json` 的具体参数格式(包括子命令声明、自动推断配置、限流规则)以及 `handler.py` 的执行上下文和逻辑编写,请前往 **[斜杠指令开发指南](../../../../docs/slash-commands.md#🛠️-第二部分如何自定义扩展新的斜杠命令)** 查阅最新的开发说明。 diff --git a/src/Undefined/skills/commands/addadmin/config.json b/src/Undefined/skills/commands/addadmin/config.json index 46b9378c..ac1610b7 100644 --- a/src/Undefined/skills/commands/addadmin/config.json +++ b/src/Undefined/skills/commands/addadmin/config.json @@ -1,8 +1,8 @@ { "name": "addadmin", - "description": "添加管理员(仅超级管理员)", - "usage": "/addadmin ", - "example": "/addadmin 123456789", + "description": "添加管理员(仅超级管理员),支持 @ 提及", + "usage": "/addadmin ", + "example": "/addadmin 123456789 或 /addadmin @某人", "permission": "superadmin", "rate_limit": { "user": 0, diff --git a/src/Undefined/skills/commands/addadmin/handler.py b/src/Undefined/skills/commands/addadmin/handler.py index 3d12f60d..df54cb04 100644 --- a/src/Undefined/skills/commands/addadmin/handler.py +++ b/src/Undefined/skills/commands/addadmin/handler.py @@ -14,7 +14,7 @@ async def execute(args: list[str], context: CommandContext) -> None: if not args: await context.sender.send_group_message( context.group_id, - "❌ 用法: /addadmin \n示例: /addadmin 123456789", + "❌ 用法: /addadmin \n示例: /addadmin 123456789 或 /addadmin @某人", ) return @@ -23,7 +23,7 @@ async def execute(args: list[str], context: CommandContext) -> None: except ValueError: await context.sender.send_group_message( context.group_id, - "❌ QQ 号格式错误,必须为数字", + "❌ QQ 号格式错误,必须为数字或 @ 提及", ) return diff --git a/src/Undefined/skills/commands/bugfix/config.json b/src/Undefined/skills/commands/bugfix/config.json index b1748215..1385cd14 100644 --- a/src/Undefined/skills/commands/bugfix/config.json +++ b/src/Undefined/skills/commands/bugfix/config.json @@ -1,8 +1,8 @@ { "name": "bugfix", - "description": "分析聊天记录并生成 Bug 修复报告(仅管理员)", - "usage": "/bugfix [QQ号2] ... <开始时间> <结束时间>", - "example": "/bugfix 123456 2024/12/01/09:00 now", + "description": "分析聊天记录并生成 Bug 修复报告(仅管理员),目标用户支持 @ 提及", + "usage": "/bugfix [QQ号|@用户2] ... <开始时间> <结束时间>", + "example": "/bugfix 123456 2024/12/01/09:00 now 或 /bugfix @某人 2024/12/01/09:00 now", "permission": "admin", "rate_limit": { "user": 10, diff --git a/src/Undefined/skills/commands/changelog/handler.py b/src/Undefined/skills/commands/changelog/handler.py index 30118196..33aae11d 100644 --- a/src/Undefined/skills/commands/changelog/handler.py +++ b/src/Undefined/skills/commands/changelog/handler.py @@ -104,6 +104,14 @@ async def _send_list(limit: int, context: CommandContext) -> None: ): bot_qq = getattr(context.config, "bot_qq", 0) forward_nodes = _build_list_forward_nodes(entries, bot_qq=bot_qq) + send_forward = getattr(context.sender, "send_group_forward_message", None) + if callable(send_forward): + await send_forward( + context.group_id, + forward_nodes, + history_message=_format_list_entries(entries), + ) + return await context.onebot.send_forward_msg(context.group_id, forward_nodes) return await _send_message(_format_list_entries(entries), context) diff --git a/src/Undefined/skills/commands/delfaq/README.md b/src/Undefined/skills/commands/delfaq/README.md deleted file mode 100644 index c6b16451..00000000 --- a/src/Undefined/skills/commands/delfaq/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# /delfaq 命令说明 - -## 功能 -删除指定 FAQ 条目(需要管理员权限)。 - -## 用法 -- `/delfaq ` - -## 参数 -- `` 必填,例如 `20241205-001`。 - -## 示例 -- `/delfaq 20241205-001` - -## 说明 -- 删除前建议先使用 `/viewfaq ` 确认内容。 -- 删除操作不可逆,请谨慎执行。 diff --git a/src/Undefined/skills/commands/delfaq/config.json b/src/Undefined/skills/commands/delfaq/config.json deleted file mode 100644 index ab6f0a25..00000000 --- a/src/Undefined/skills/commands/delfaq/config.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "delfaq", - "description": "删除指定 FAQ", - "usage": "/delfaq ", - "example": "/delfaq 20241205-001", - "permission": "admin", - "rate_limit": { - "user": 10, - "admin": 5, - "superadmin": 0 - }, - "show_in_help": true, - "order": 60, - "allow_in_private": false, - "aliases": [] -} diff --git a/src/Undefined/skills/commands/delfaq/handler.py b/src/Undefined/skills/commands/delfaq/handler.py deleted file mode 100644 index 46e64ed5..00000000 --- a/src/Undefined/skills/commands/delfaq/handler.py +++ /dev/null @@ -1,29 +0,0 @@ -from __future__ import annotations - -from Undefined.services.commands.context import CommandContext - - -async def execute(args: list[str], context: CommandContext) -> None: - """处理 /delfaq。""" - - if not args: - await context.sender.send_group_message( - context.group_id, - "❌ 用法: /delfaq \n示例: /delfaq 20241205-001", - ) - return - faq_id = args[0] - faq = await context.faq_storage.get(context.group_id, faq_id) - if not faq: - await context.sender.send_group_message( - context.group_id, - f"❌ FAQ 不存在: {faq_id}", - ) - return - if await context.faq_storage.delete(context.group_id, faq_id): - await context.sender.send_group_message( - context.group_id, - f"✅ 已删除 FAQ: [{faq_id}] {faq.title}", - ) - return - await context.sender.send_group_message(context.group_id, f"❌ 删除失败: {faq_id}") diff --git a/src/Undefined/skills/commands/faq/README.md b/src/Undefined/skills/commands/faq/README.md new file mode 100644 index 00000000..374f3d18 --- /dev/null +++ b/src/Undefined/skills/commands/faq/README.md @@ -0,0 +1,37 @@ +# /faq 命令说明 + +## 功能 +FAQ 管理:列表、查看、搜索、删除。支持子命令和自动推断。 + +## 用法 +- `/faq [ls|view|search|del] [参数]` + +## 子命令 + +| 子命令 | 用法 | 权限 | 说明 | +|--------|------|------|------| +| ls | `/faq ls` | public | 列出当前群组所有 FAQ 条目 | +| view | `/faq view ` | public | 查看指定 ID 的 FAQ 详细内容 | +| search | `/faq search <关键词>` | public | 按关键词搜索 FAQ | +| del | `/faq del ` | admin | 删除指定 FAQ | + +## 自动推断 + +无需显式写子命令,系统自动推断意图: +- 无参数 `/faq` → 列表(ls) +- 参数为 ID 格式(如 `20241205-001`)→ 查看(view) +- 参数为非 ID 格式(如 `登录`)→ 搜索(search) +- 显式子命令优先,不会被推断覆盖 + +## 示例 +- `/faq` — 列出所有 FAQ +- `/faq 20241205-001` — 查看 ID 为 20241205-001 的 FAQ(自动推断为 view) +- `/faq 登录` — 搜索包含"登录"的 FAQ(自动推断为 search) +- `/faq del 20241205-001` — 删除指定 FAQ(需管理员) +- `/faq view 20241205-001` — 显式查看(等同推断结果) + +## 说明 +- 子命令权限在 `config.json` 的 `subcommands` 中声明(`del` 为 `admin`),分发层自动检查。 +- 自动推断规则在 `config.json` 的 `inference` 中配置,分发层自动处理。 +- 默认列表最多展示 20 条,搜索结果最多展示 10 条。 +- 删除操作不可逆,请谨慎执行。 diff --git a/src/Undefined/skills/commands/faq/config.json b/src/Undefined/skills/commands/faq/config.json new file mode 100644 index 00000000..cf5c2a2b --- /dev/null +++ b/src/Undefined/skills/commands/faq/config.json @@ -0,0 +1,29 @@ +{ + "name": "faq", + "description": "FAQ 管理:列表/查看/搜索/删除(支持自动推断子命令)", + "usage": "/faq(/f) [ls|view|search|del] [参数]", + "example": "/faq 20241205-001", + "permission": "public", + "rate_limit": { + "user": 10, + "admin": 5, + "superadmin": 0 + }, + "show_in_help": true, + "order": 30, + "allow_in_private": false, + "aliases": ["f"], + "subcommands": { + "ls": { "description": "列出所有FAQ" }, + "view": { "description": "查看FAQ详情", "args": "" }, + "search":{ "description": "搜索FAQ", "args": "<关键词>" }, + "del": { "description": "删除FAQ", "permission": "admin", "args": "" } + }, + "inference": { + "default": "ls", + "rules": [ + { "pattern": "^\\d{8}-\\d{3}$", "subcommand": "view" } + ], + "fallback": "search" + } +} diff --git a/src/Undefined/skills/commands/faq/handler.py b/src/Undefined/skills/commands/faq/handler.py new file mode 100644 index 00000000..96224a37 --- /dev/null +++ b/src/Undefined/skills/commands/faq/handler.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +from Undefined.services.commands.context import CommandContext + +_USAGE_TEXT = ( + "用法:/faq [ls|view|search|del] [参数]\n" + "子命令:ls(列表)、view (查看)、search <关键词>(搜索)、del (删除,需管理员)\n" + "自动推断:无参数→ls,ID格式→view,非ID→search" +) + + +async def _send(message: str, context: CommandContext) -> None: + await context.sender.send_group_message(context.group_id, message) + + +async def _handle_ls(context: CommandContext) -> None: + faqs = await context.faq_storage.list_all(context.group_id) + if not faqs: + await _send("📭 当前群组没有保存的 FAQ", context) + return + + lines = ["📋 FAQ 列表:", ""] + for faq in faqs[:20]: + lines.append(f"📌 [{faq.id}] {faq.title}") + lines.append(f" 创建时间: {faq.created_at[:10]}") + lines.append("") + if len(faqs) > 20: + lines.append(f"... 还有 {len(faqs) - 20} 条") + await _send("\n".join(lines), context) + + +async def _handle_view(args: list[str], context: CommandContext) -> None: + if not args: + await _send("❌ 用法: /faq view \n示例: /faq 20241205-001", context) + return + faq_id = args[0] + faq = await context.faq_storage.get(context.group_id, faq_id) + if not faq: + await _send(f"❌ FAQ 不存在: {faq_id}", context) + return + message = ( + f"📖 FAQ: {faq.title}\n\n" + f"🆔 ID: {faq.id}\n" + f"👤 分析对象: {faq.target_qq}\n" + f"📅 时间范围: {faq.start_time} ~ {faq.end_time}\n" + f"🕐 创建时间: {faq.created_at}\n\n" + f"{faq.content}" + ) + await _send(message, context) + + +async def _handle_search(args: list[str], context: CommandContext) -> None: + if not args: + await _send("❌ 用法: /faq search <关键词>\n示例: /faq 登录", context) + return + keyword = " ".join(args) + results = await context.faq_storage.search(context.group_id, keyword) + if not results: + await _send(f'🔍 未找到包含 "{keyword}" 的 FAQ', context) + return + lines = [f'🔍 搜索 "{keyword}" 找到 {len(results)} 条结果:', ""] + for faq in results[:10]: + lines.append(f"📌 [{faq.id}] {faq.title}") + lines.append("") + if len(results) > 10: + lines.append(f"... 还有 {len(results) - 10} 条") + lines.append("\n使用 /faq 查看详情") + await _send("\n".join(lines), context) + + +async def _handle_del(args: list[str], context: CommandContext) -> None: + if not args: + await _send("❌ 用法: /faq del \n示例: /faq del 20241205-001", context) + return + faq_id = args[0] + faq = await context.faq_storage.get(context.group_id, faq_id) + if not faq: + await _send(f"❌ FAQ 不存在: {faq_id}", context) + return + if await context.faq_storage.delete(context.group_id, faq_id): + await _send(f"✅ 已删除 FAQ: [{faq_id}] {faq.title}", context) + return + await _send(f"❌ 删除失败: {faq_id}", context) + + +async def execute(args: list[str], context: CommandContext) -> None: + """处理 /faq。分发层已处理子命令推断和权限检查,args 格式为 [子命令, *子参数]。""" + if not args: + await _handle_ls(context) + return + + subcommand = args[0].lower() + sub_args = args[1:] + + if subcommand == "ls": + await _handle_ls(context) + elif subcommand == "view": + await _handle_view(sub_args, context) + elif subcommand == "search": + await _handle_search(sub_args, context) + elif subcommand == "del": + await _handle_del(sub_args, context) + else: + await _send(_USAGE_TEXT, context) diff --git a/src/Undefined/skills/commands/help/README.md b/src/Undefined/skills/commands/help/README.md index 6b8e91a1..f9a6de04 100644 --- a/src/Undefined/skills/commands/help/README.md +++ b/src/Undefined/skills/commands/help/README.md @@ -1,18 +1,23 @@ # /help 命令说明 ## 功能 -查看命令列表,或查看某个命令的详细帮助。 +查看命令列表,或查看某个命令的详细帮助。帮助内容默认渲染为图片,便于阅读较长文档。 ## 用法 - `/help` +- `/help -t` - `/help ` +- `/help -t` ## 示例 - `/help` +- `/help -t` - `/help stats` -- `/help /lsfaq` +- `/help /faq` +- `/help stats -t` ## 说明 - `` 支持带或不带 `/` 前缀。 - `` 支持命令别名(若该命令配置了别名)。 +- `-t` 会直接发送纯文本帮助,不进行图片渲染。 - 列表页尾部提示文案由 `help/config.json` 的 `help_footer` 字段配置并自动渲染。 diff --git a/src/Undefined/skills/commands/help/config.json b/src/Undefined/skills/commands/help/config.json index 8639fb7f..d5059053 100644 --- a/src/Undefined/skills/commands/help/config.json +++ b/src/Undefined/skills/commands/help/config.json @@ -1,8 +1,8 @@ { "name": "help", "description": "显示命令列表或命令详细帮助", - "usage": "/help [命令名]", - "example": "/help stats", + "usage": "/help [命令名] [-t]", + "example": "/help stats -t", "permission": "public", "rate_limit": { "user": 0, @@ -14,7 +14,7 @@ "allow_in_private": true, "aliases": ["h"], "help_footer": [ - "查看详细帮助:/help ", + "查看帮助:/h [cmd](默认图片,纯文本加 -t)", "详细版权与免责声明:/cprt", "Copyright (c) 2025 Null . Licensed under the MIT License." ] diff --git a/src/Undefined/skills/commands/help/handler.py b/src/Undefined/skills/commands/help/handler.py index dc3dbc79..3376ed11 100644 --- a/src/Undefined/skills/commands/help/handler.py +++ b/src/Undefined/skills/commands/help/handler.py @@ -1,19 +1,44 @@ from __future__ import annotations +import html +import logging +import uuid from pathlib import Path +import markdown + from Undefined.services.commands.context import CommandContext +from Undefined.services.commands.registry import CommandMeta, SubcommandMeta _DOC_MAX_CHARS = 8000 +_TEXT_FLAG = "-t" +_MARKDOWN_EXTENSIONS = ["tables", "fenced_code", "sane_lists"] + +logger = logging.getLogger("help") def _permission_label(permission: str) -> str: permission_label_map = { - "public": "公开可用", - "admin": "仅限管理员", - "superadmin": "仅限超级管理员", + "public": "公开", + "admin": "管理员", + "superadmin": "超管", } - return permission_label_map.get(permission, "公开可用") + return permission_label_map.get(permission, "公开") + + +def _sender_permission_label(context: CommandContext) -> str: + config = context.config + try: + if config.is_superadmin(context.sender_id): + return "超管" + except Exception: + pass + try: + if config.is_admin(context.sender_id): + return "管理员" + except Exception: + pass + return "普通用户" def _scope_label(allow_in_private: bool) -> str: @@ -24,6 +49,19 @@ def _is_private_scope(context: CommandContext) -> bool: return int(context.group_id) == 0 +async def _send_message(context: CommandContext, message: str) -> None: + if _is_private_scope(context): + try: + send_private = context.sender.send_private_message + except AttributeError: + send_private = None + if send_private is not None: + user_id = int(context.user_id or context.sender_id) + await send_private(user_id, message) + return + await context.sender.send_group_message(context.group_id, message) + + def _can_see_command(permission: str, sender_id: int, context: CommandContext) -> bool: """根据命令权限判断用户是否可见该命令。""" if permission in ("public", ""): @@ -37,7 +75,18 @@ def _can_see_command(permission: str, sender_id: int, context: CommandContext) - return True -def _format_command_list(context: CommandContext) -> str: +def _format_usage_with_alias(item: CommandMeta) -> str: + """格式化命令用法,自动附上最短别名(如 /changelog(/cl))。""" + usage = item.usage + if not item.aliases: + return usage + shortest = min(item.aliases, key=len) + if len(shortest) >= len(item.name): + return usage + return usage.replace(f"/{item.name}", f"/{item.name}(/{shortest})", 1) + + +def _visible_commands(context: CommandContext) -> list[CommandMeta]: commands = context.registry.list_commands(include_hidden=False) in_private = _is_private_scope(context) if in_private: @@ -50,35 +99,42 @@ def _format_command_list(context: CommandContext) -> str: for item in commands if _can_see_command(item.permission, context.sender_id, context) ] + return commands - command_lines = [ - ( - f"- {item.usage}({_scope_label(item.allow_in_private)}):" - f"{item.description or '暂无说明'}" - ) - for item in commands - ] + +def _format_command_list(context: CommandContext) -> str: + commands = _visible_commands(context) + + in_private = _is_private_scope(context) + scope_hint = "私聊" if in_private else "群聊" + perm_hint = _sender_permission_label(context) + + command_lines = [] + for item in commands: + line = f"/{item.name}" + if item.aliases: + shortest = min(item.aliases, key=len) + if len(shortest) < len(item.name): + line = f"/{item.name}(/{shortest})" + desc = item.description or "暂无说明" + # 子命令数量 + if item.subcommands: + desc += f"({len(item.subcommands)}个子命令)" + command_lines.append(f"{line} — {desc}") help_meta = context.registry.resolve("help") footer_lines = ( help_meta.help_footer if help_meta is not None and help_meta.help_footer else [ - "查看详细帮助:/help ", - "版权与免责声明:/copyright", - "Copyright (c) 2025 Null . Licensed under the MIT License.", + "/help <命令> 查看详情 | /copyright 版权声明", ] ) - scope_hint = ( - "当前会话:私聊(仅展示支持私聊的命令)" if in_private else "当前会话:群聊" - ) lines = [ "Undefined 命令帮助", + f"会话:{scope_hint} | 权限:{perm_hint}", "", - scope_hint, - "", - "可用命令:", *command_lines, "", *footer_lines, @@ -86,6 +142,19 @@ def _format_command_list(context: CommandContext) -> str: return "\n".join(lines) +def _parse_detail_args(args: list[str]) -> tuple[str | None, bool]: + command_name: str | None = None + force_text = False + for arg in args: + normalized = arg.strip().lower() + if normalized == _TEXT_FLAG: + force_text = True + continue + if command_name is None: + command_name = arg + return command_name, force_text + + def _normalize_command_name(text: str) -> str: return text.strip().lstrip("/").lower() @@ -100,7 +169,32 @@ def _load_command_doc(doc_path: Path | None) -> str: return f"{trimmed}\n\n[文档过长,已截断]" -def _format_command_detail(command_name: str, context: CommandContext) -> str | None: +def _format_subcommand_detail(subcmd: SubcommandMeta, parent_permission: str) -> str: + args_str = f" {subcmd.args}" if subcmd.args else "" + perm_mark = "" + if subcmd.permission != parent_permission: + perm_mark = f" [{_permission_label(subcmd.permission)}]" + return f" {subcmd.name}{args_str} — {subcmd.description}{perm_mark}" + + +def _format_inference_hint(meta: CommandMeta) -> str | None: + inference = meta.inference + if inference is None: + return None + parts: list[str] = [] + if inference.default is not None: + parts.append(f"无参数→{inference.default}") + for rule in inference.rules: + parts.append(f"匹配{rule.pattern.pattern}→{rule.subcommand}") + if inference.fallback is not None: + parts.append(f"其他→{inference.fallback}") + return "自动推断:" + "、".join(parts) if parts else None + + +def _resolve_visible_command( + command_name: str, + context: CommandContext, +) -> CommandMeta | None: meta = context.registry.resolve(command_name) if meta is None: return None @@ -110,43 +204,484 @@ def _format_command_detail(command_name: str, context: CommandContext) -> str | return None if not _can_see_command(meta.permission, context.sender_id, context): return None + return meta - aliases = "、".join(f"/{alias}" for alias in meta.aliases) if meta.aliases else "无" - doc_content = _load_command_doc(meta.doc_path) + +def _format_command_name_with_alias(meta: CommandMeta) -> str: + name_line = f"/{meta.name}" + if meta.aliases: + shortest = min(meta.aliases, key=len) + if len(shortest) < len(meta.name): + name_line = f"/{meta.name}(/{shortest})" + return name_line + + +def _format_rate_limit(meta: CommandMeta) -> str: rate_limit = meta.rate_limit + return f"{rate_limit.user}s/{rate_limit.admin}s/{rate_limit.superadmin}s" + +def _format_command_detail_text(meta: CommandMeta, doc_content: str) -> str: + aliases = "、".join(f"/{alias}" for alias in meta.aliases) if meta.aliases else "无" + name_line = _format_command_name_with_alias(meta) lines = [ - f"命令详情:/{meta.name}", + f"{name_line} — {meta.description or '暂无说明'}", "", - f"描述:{meta.description or '暂无说明'}", - f"用法:{meta.usage or f'/{meta.name}'}", - f"示例:{meta.example or meta.usage or f'/{meta.name}'}", - f"权限:{_permission_label(meta.permission)}", - f"作用域:{_scope_label(meta.allow_in_private)}", - ( - "限流:" - f"user={rate_limit.user}s, admin={rate_limit.admin}s, superadmin={rate_limit.superadmin}s" - ), - f"别名:{aliases}", + f"用法:{_format_usage_with_alias(meta)}", ] + if meta.example: + lines.append(f"示例:{meta.example}") + lines.append( + f"权限:{_permission_label(meta.permission)} | " + f"作用域:{_scope_label(meta.allow_in_private)} | " + f"限流:{_format_rate_limit(meta)}" + ) + if aliases != "无": + lines.append(f"别名:{aliases}") + + if meta.subcommands: + lines.append("") + lines.append("子命令:") + for subcmd in meta.subcommands.values(): + lines.append(_format_subcommand_detail(subcmd, meta.permission)) + + inference_hint = _format_inference_hint(meta) + if inference_hint: + lines.append("") + lines.append(inference_hint) + if doc_content: lines.extend(["", "说明文档:", doc_content]) return "\n".join(lines) +def _html_text(text: str) -> str: + return html.escape(text, quote=True) + + +def _html_code(text: str) -> str: + return f"{_html_text(text)}" + + +def _markdown_to_html(markdown_text: str) -> str: + return str(markdown.markdown(markdown_text, extensions=_MARKDOWN_EXTENSIONS)) + + +def _build_meta_item(label: str, value: str, *, code: bool = False) -> str: + value_html = _html_code(value) if code else _html_text(value) + return ( + '
' + f'{_html_text(label)}' + f'
{value_html}
' + "
" + ) + + +def _format_subcommands_html(meta: CommandMeta) -> str: + if not meta.subcommands: + return "" + rows: list[str] = [] + for subcmd in meta.subcommands.values(): + args_str = f" {subcmd.args}" if subcmd.args else "" + perm_mark = "" + if subcmd.permission != meta.permission: + perm_mark = ( + f'{_permission_label(subcmd.permission)}' + ) + rows.append( + "" + f"{_html_code(subcmd.name + args_str)}" + f"{_html_text(subcmd.description)}{perm_mark}" + "" + ) + return ( + '
' + "

子命令

" + '' + "".join(rows) + "
" + "
" + ) + + +def _format_command_list_html(context: CommandContext) -> str: + commands = _visible_commands(context) + in_private = _is_private_scope(context) + scope_hint = "私聊" if in_private else "群聊" + perm_hint = _sender_permission_label(context) + help_meta = context.registry.resolve("help") + footer_lines = ( + help_meta.help_footer + if help_meta is not None and help_meta.help_footer + else [ + "/help <命令> 查看详情 | /copyright 版权声明", + ] + ) + + command_rows: list[str] = [] + for item in commands: + command_label = _format_command_name_with_alias(item) + subcommand_badge = "" + if item.subcommands: + subcommand_badge = ( + f'{len(item.subcommands)} 个子命令' + ) + command_rows.append( + "" + f'{_html_code(command_label)}' + f'
{_html_text(item.description or "暂无说明")}{subcommand_badge}
' + "" + ) + + footer_html = "".join(f"
  • {_html_text(line)}
  • " for line in footer_lines) + return f""" + + + + + + +
    +
    +

    Undefined 命令帮助

    +

    可用命令

    +

    当前会话可见的斜杠命令速查表

    +
    + 会话:{_html_text(scope_hint)} + 权限:{_html_text(perm_hint)} + 命令:{len(commands)} +
    +
    +
    + {"".join(command_rows)}
    +
    +

    提示

    + +
    +
    +
    + +""" + + +def _base_help_css() -> str: + return """ + * { box-sizing: border-box; } + body { + margin: 0; + padding: 24px; + background: #f5f7fb; + color: #1f2937; + font-family: 'Microsoft YaHei', 'PingFang SC', 'Noto Sans CJK SC', Arial, sans-serif; + font-size: 15px; + line-height: 1.65; + } + .panel { + width: 100%; + max-width: 720px; + margin: 0 auto; + background: #ffffff; + border: 1px solid #d8dee9; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08); + } + .header { + padding: 22px 26px 18px; + border-bottom: 1px solid #e5e7eb; + background: #f9fafb; + } + .eyebrow { + margin: 0 0 6px; + color: #0f766e; + font-size: 13px; + font-weight: 700; + } + h1 { + margin: 0; + color: #111827; + font-size: 26px; + line-height: 1.25; + font-weight: 800; + } + .description { + margin: 8px 0 0; + color: #4b5563; + font-size: 15px; + } + .content { padding: 20px 26px 26px; } + code { + font-family: 'Cascadia Code', 'SFMono-Regular', Consolas, monospace; + color: #075985; + background: #eff6ff; + border: 1px solid #dbeafe; + border-radius: 4px; + padding: 1px 5px; + white-space: pre-wrap; + overflow-wrap: anywhere; + } + .section { margin-top: 20px; } + h2 { + margin: 0 0 10px; + color: #111827; + font-size: 16px; + line-height: 1.35; + } + .badge { + display: inline-block; + margin-left: 8px; + color: #7c2d12; + background: #ffedd5; + border: 1px solid #fed7aa; + border-radius: 4px; + padding: 0 5px; + font-size: 12px; + font-weight: 700; + white-space: nowrap; + } + """ + + +def _format_command_detail_html(meta: CommandMeta, doc_content: str) -> str: + aliases = "、".join(f"/{alias}" for alias in meta.aliases) if meta.aliases else "无" + inference_hint = _format_inference_hint(meta) + meta_items = [ + _build_meta_item("用法", _format_usage_with_alias(meta), code=True), + _build_meta_item("权限", _permission_label(meta.permission)), + _build_meta_item("作用域", _scope_label(meta.allow_in_private)), + _build_meta_item("限流", _format_rate_limit(meta)), + ] + if meta.example: + meta_items.append(_build_meta_item("示例", meta.example, code=True)) + if aliases != "无": + meta_items.append(_build_meta_item("别名", aliases)) + + inference_section = "" + if inference_hint: + hint_text = inference_hint.removeprefix("自动推断:") + inference_section = ( + '
    ' + "

    自动推断

    " + f'

    {_html_text(hint_text)}

    ' + "
    " + ) + + doc_section = "" + if doc_content: + doc_section = ( + '
    ' + "

    说明文档

    " + f'
    {_markdown_to_html(doc_content)}
    ' + "
    " + ) + + title = _format_command_name_with_alias(meta) + description = meta.description or "暂无说明" + return f""" + + + + + + +
    +
    +

    Undefined 命令帮助

    +

    {_html_text(title)}

    +

    {_html_text(description)}

    +
    +
    +
    {"".join(meta_items)}
    + {_format_subcommands_html(meta)} + {inference_section} + {doc_section} +
    +
    + +""" + + +async def _send_rendered_html( + context: CommandContext, + html_content: str, + filename_prefix: str, +) -> None: + from Undefined.render import render_html_to_image + from Undefined.utils.paths import RENDER_CACHE_DIR, ensure_dir + + output_dir = ensure_dir(RENDER_CACHE_DIR) + output_path = output_dir / f"{filename_prefix}_{uuid.uuid4().hex[:8]}.png" + await render_html_to_image(html_content, str(output_path), viewport_width=760) + image_cq = f"[CQ:image,file={output_path.resolve().as_uri()}]" + await _send_message(context, image_cq) + + +async def _send_rendered_list(context: CommandContext) -> None: + await _send_rendered_html(context, _format_command_list_html(context), "help_list") + + +async def _send_rendered_detail( + context: CommandContext, + meta: CommandMeta, + doc_content: str, +) -> None: + html_content = _format_command_detail_html(meta, doc_content) + await _send_rendered_html(context, html_content, f"help_{meta.name}") + + async def execute(args: list[str], context: CommandContext) -> None: """处理 /help。""" - if not args: - await context.sender.send_group_message( - context.group_id, _format_command_list(context) - ) + command_arg, force_text = _parse_detail_args(args) + if command_arg is None: + if force_text: + await _send_message(context, _format_command_list(context)) + return + try: + await _send_rendered_list(context) + except Exception: + logger.exception("渲染命令列表图片失败,回退到纯文本") + await _send_message(context, _format_command_list(context)) return - detail_text = _format_command_detail(_normalize_command_name(args[0]), context) - if detail_text is None: - await context.sender.send_group_message( - context.group_id, - f"❌ 未找到命令:{args[0]}\n请使用 /help 查看命令列表", + meta = _resolve_visible_command(_normalize_command_name(command_arg), context) + if meta is None: + await _send_message( + context, + f"❌ 未找到命令:{command_arg}\n请使用 /help 查看命令列表", ) return - await context.sender.send_group_message(context.group_id, detail_text) + + doc_content = _load_command_doc(meta.doc_path) + detail_text = _format_command_detail_text(meta, doc_content) + if force_text: + await _send_message(context, detail_text) + return + + try: + await _send_rendered_detail(context, meta, doc_content) + except Exception: + logger.exception("渲染命令帮助图片失败,回退到纯文本") + await _send_message(context, detail_text) diff --git a/src/Undefined/skills/commands/lsfaq/README.md b/src/Undefined/skills/commands/lsfaq/README.md deleted file mode 100644 index bc8a4247..00000000 --- a/src/Undefined/skills/commands/lsfaq/README.md +++ /dev/null @@ -1,14 +0,0 @@ -# /lsfaq 命令说明 - -## 功能 -列出当前群组中的 FAQ 条目,便于快速查看知识库索引。 - -## 用法 -- `/lsfaq` - -## 示例 -- `/lsfaq` - -## 说明 -- 默认最多展示前若干条,并提示剩余数量。 -- 可配合 `/viewfaq ` 查看具体内容。 diff --git a/src/Undefined/skills/commands/lsfaq/config.json b/src/Undefined/skills/commands/lsfaq/config.json deleted file mode 100644 index 63df6ff9..00000000 --- a/src/Undefined/skills/commands/lsfaq/config.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "lsfaq", - "description": "列出当前群组的所有 FAQ 条目", - "usage": "/lsfaq", - "example": "/lsfaq", - "permission": "public", - "rate_limit": { - "user": 10, - "admin": 5, - "superadmin": 0 - }, - "show_in_help": true, - "order": 30, - "allow_in_private": false, - "aliases": [] -} diff --git a/src/Undefined/skills/commands/lsfaq/handler.py b/src/Undefined/skills/commands/lsfaq/handler.py deleted file mode 100644 index ace889c3..00000000 --- a/src/Undefined/skills/commands/lsfaq/handler.py +++ /dev/null @@ -1,25 +0,0 @@ -from __future__ import annotations - -from Undefined.services.commands.context import CommandContext - - -async def execute(args: list[str], context: CommandContext) -> None: - """处理 /lsfaq。""" - - _ = args - faqs = await context.faq_storage.list_all(context.group_id) - if not faqs: - await context.sender.send_group_message( - context.group_id, - "📭 当前群组没有保存的 FAQ", - ) - return - - lines = ["📋 FAQ 列表:", ""] - for faq in faqs[:20]: - lines.append(f"📌 [{faq.id}] {faq.title}") - lines.append(f" 创建时间: {faq.created_at[:10]}") - lines.append("") - if len(faqs) > 20: - lines.append(f"... 还有 {len(faqs) - 20} 条") - await context.sender.send_group_message(context.group_id, "\n".join(lines)) diff --git a/src/Undefined/skills/commands/naga/README.md b/src/Undefined/skills/commands/naga/README.md index 3fcb4c10..bef8d04f 100644 --- a/src/Undefined/skills/commands/naga/README.md +++ b/src/Undefined/skills/commands/naga/README.md @@ -1,8 +1,10 @@ `/naga` 用于把 QQ 用户与 NagaAgent 的远端身份绑定起来,并在需要时解除绑定。 当前可用命令: -- `/naga bind ` -- `/naga unbind ` +- `/naga bind ` — 公开,仅群聊 +- `/naga unbind ` — 超级管理员 + +权限在 `config.json` 的 `subcommands` 中声明,分发层自动检查,无需 handler 内部判断。 ## /naga bind 执行: diff --git a/src/Undefined/skills/commands/naga/config.json b/src/Undefined/skills/commands/naga/config.json index d9c039bc..eaef8754 100644 --- a/src/Undefined/skills/commands/naga/config.json +++ b/src/Undefined/skills/commands/naga/config.json @@ -7,5 +7,9 @@ "allow_in_private": true, "show_in_help": true, "order": 95, - "aliases": [] + "aliases": [], + "subcommands": { + "bind": { "description": "发起绑定", "permission": "public", "allow_in_private": false }, + "unbind": { "description": "解绑并吊销", "permission": "superadmin" } + } } diff --git a/src/Undefined/skills/commands/naga/handler.py b/src/Undefined/skills/commands/naga/handler.py index af7d1dde..d658344c 100644 --- a/src/Undefined/skills/commands/naga/handler.py +++ b/src/Undefined/skills/commands/naga/handler.py @@ -1,9 +1,6 @@ from __future__ import annotations -import asyncio -import json import logging -from pathlib import Path from typing import Literal from uuid import uuid4 @@ -16,53 +13,6 @@ logger = logging.getLogger(__name__) -_SCOPES_FILE = Path(__file__).parent / "scopes.json" - - -def _load_scopes_sync() -> dict[str, str]: - try: - with open(_SCOPES_FILE, encoding="utf-8") as f: - data = json.load(f) - return {str(k): str(v) for k, v in data.items()} - except Exception: - return {} - - -async def _load_scopes() -> dict[str, str]: - return await asyncio.to_thread(_load_scopes_sync) - - -_SCOPE_ALIASES: dict[str, str] = { - "admin_only": "admin", - "superadmin_only": "superadmin", -} - - -async def _check_scope( - subcmd: str, sender_id: int, context: CommandContext -) -> str | None: - scopes = await _load_scopes() - raw = scopes.get(subcmd, "superadmin") - scope = _SCOPE_ALIASES.get(raw, raw) - - if scope == "group_only": - if context.scope != "group": - return "该子命令仅限群聊使用" - return None - if scope == "private_only": - if context.scope != "private": - return "该子命令仅限私聊使用" - return None - if scope == "public": - return None - if scope == "superadmin" and context.config.is_superadmin(sender_id): - return None - if scope == "admin" and ( - context.config.is_admin(sender_id) or context.config.is_superadmin(sender_id) - ): - return None - return "权限不足" - async def _reply(context: CommandContext, text: str) -> None: if context.scope == "private" and context.user_id is not None: @@ -158,7 +108,8 @@ async def execute(args: list[str], context: CommandContext) -> None: await _reply(context, "Naga 集成未启用") return - if not args: + resolved_subcommand = (context.resolved_subcommand or "").strip().lower() + if not resolved_subcommand and not args: await _reply( context, "用法: /naga [参数]\n" @@ -168,24 +119,17 @@ async def execute(args: list[str], context: CommandContext) -> None: ) return - subcmd = args[0].lower() - sub_args = args[1:] + subcmd = resolved_subcommand or args[0].lower() + if resolved_subcommand: + sub_args = args[1:] if args and args[0].lower() == resolved_subcommand else args + else: + sub_args = args[1:] logger.info( "[NagaCmd] 子命令解析: trace=%s subcmd=%s sub_args=%s", trace_id, subcmd, sub_args, ) - perm_err = await _check_scope(subcmd, context.sender_id, context) - if perm_err is not None: - logger.warning( - "[NagaCmd] 权限/作用域拒绝: trace=%s subcmd=%s err=%s", - trace_id, - subcmd, - perm_err, - ) - await _reply(context, f"❌ {perm_err}") - return store = _naga_store(context) if store is None: @@ -224,9 +168,6 @@ async def _handle_bind( context.group_id, args, ) - if context.scope != "group": - await _reply(context, "❌ bind 命令仅限群聊中使用") - return if not args: await _reply(context, "用法: /naga bind ") return diff --git a/src/Undefined/skills/commands/naga/scopes.json b/src/Undefined/skills/commands/naga/scopes.json deleted file mode 100644 index 837e61a9..00000000 --- a/src/Undefined/skills/commands/naga/scopes.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "bind": "group_only", - "unbind": "superadmin" -} diff --git a/src/Undefined/skills/commands/profile/README.md b/src/Undefined/skills/commands/profile/README.md new file mode 100644 index 00000000..ed310965 --- /dev/null +++ b/src/Undefined/skills/commands/profile/README.md @@ -0,0 +1,21 @@ +# /profile 命令说明 + +## 功能 +查看用户或群聊的认知侧写。侧写由系统根据聊天历史自动生成和更新。 + +## 用法 +- `/profile` — 查看自己的用户侧写(默认渲染图片) +- `/p -f` — 查看自己的侧写(合并转发) +- `/p -t` — 查看自己的侧写(直接文本) +- `/p -r` — 查看自己的侧写(显式渲染图片) +- `/me` — 同上(别名) +- `/profile g` — 查看当前群聊侧写(仅群聊可用) +- `/p g` — 同上 +- `/p 123456` — 🔒 超管:查看指定QQ号用户的侧写 +- `/p g 789012` — 🔒 超管:查看指定群号的侧写 + +## 说明 +- 私聊只能查看用户侧写,不支持 `g` 参数。 +- 默认渲染为图片发送,可用 `-f` 合并转发,`-t` 直接文本发送。 +- 超级管理员可通过传入 QQ 号或群号查看任意用户/群聊的侧写。 +- 非超管尝试指定目标ID时会提示权限不足。 diff --git a/src/Undefined/skills/commands/profile/config.json b/src/Undefined/skills/commands/profile/config.json index e14c037a..0556ecec 100644 --- a/src/Undefined/skills/commands/profile/config.json +++ b/src/Undefined/skills/commands/profile/config.json @@ -1,8 +1,8 @@ { "name": "profile", - "description": "查看侧写。默认显示你的用户侧写;加 g 查看当前群聊侧写(仅群聊可用)。群聊默认合并转发,可用 -t 直接发送、-r 渲染图片", - "usage": "/p [g] [-t|-f|-r]", - "example": "/p g -r", + "description": "查看认知侧写。默认渲染图片;g查看群聊侧写;-f合并转发;-t直接文本。超管可指定QQ号或 @ 提及查看他人", + "usage": "/profile [g] [-t|-f|-r] [QQ号|@用户]", + "example": "/profile g", "permission": "public", "rate_limit": { "user": 60, diff --git a/src/Undefined/skills/commands/profile/handler.py b/src/Undefined/skills/commands/profile/handler.py index f3d94f67..97333e6c 100644 --- a/src/Undefined/skills/commands/profile/handler.py +++ b/src/Undefined/skills/commands/profile/handler.py @@ -108,6 +108,17 @@ def _node(content: str) -> dict[str, Any]: } nodes = [_node(metadata), _node(profile_text)] + history_message = ( + f"[命令输出] /profile 合并转发\n{metadata}\n\n{_truncate(profile_text)}" + ) + send_forward = getattr(context.sender, "send_group_forward_message", None) + if callable(send_forward): + await send_forward( + context.group_id, + nodes, + history_message=history_message, + ) + return await context.onebot.send_forward_msg(context.group_id, nodes) @@ -181,18 +192,34 @@ async def _send_render( await context.sender.send_group_message(context.group_id, image_cq) +async def _handle_render_fallback( + context: CommandContext, + metadata: str, + profile_text: str, +) -> None: + if _is_private(context): + await _send_text(context, profile_text) + return + + try: + await _send_forward(context, metadata, profile_text) + except Exception: + logger.exception("渲染侧写图片失败后发送合并转发也失败,回退到纯文本") + await _send_text(context, profile_text) + + # ── 入口 ───────────────────────────────────────────────────── async def execute(args: list[str], context: CommandContext) -> None: """处理 /profile 命令。 - 用法: /p [g] [-t|--text] [-f|--forward] [-r|--render] [目标ID] - g / group 查看群聊侧写(仅群聊可用) - -t / --text 纯文本直接发出 - -f / --forward 合并转发发出(群聊默认) - -r / --render 渲染为图片发出 - 目标ID 指定查询对象(仅超级管理员) + 用法: /p [g] [-t|--text] [-f|--forward] [-r|--render] [目标ID] + g / group 查看群聊侧写(仅群聊可用) + -t / --text 纯文本直接发出 + -f / --forward 合并转发发出 + -r / --render 渲染为图片发出(默认) + 目标ID 指定查询对象(仅超级管理员) """ cognitive_service = context.cognitive_service if cognitive_service is None: @@ -203,7 +230,7 @@ async def execute(args: list[str], context: CommandContext) -> None: # 超管指定目标 if target: - if not context.config.is_superadmin(context.sender_id): + if not context.check_permission("superadmin"): await _send_text(context, "❌ 仅超级管理员可查看他人侧写") return @@ -227,13 +254,9 @@ async def execute(args: list[str], context: CommandContext) -> None: profile = _truncate(profile) metadata = _build_metadata(entity_type, entity_id, len(profile)) - # 私聊始终纯文本 - if _is_private(context): - mode = _MODE_TEXT - - # 未指定模式:群聊默认合并转发 + # 未指定模式:默认渲染图片 if not mode: - mode = _MODE_FORWARD + mode = _MODE_RENDER if mode == _MODE_TEXT: await _send_text(context, profile) @@ -241,9 +264,12 @@ async def execute(args: list[str], context: CommandContext) -> None: try: await _send_render(context, metadata, profile) except Exception: - logger.exception("渲染侧写图片失败,回退到纯文本") - await _send_text(context, profile) + logger.exception("渲染侧写图片失败,回退到合并转发") + await _handle_render_fallback(context, metadata, profile) else: + if _is_private(context): + await _send_text(context, profile) + return try: await _send_forward(context, metadata, profile) except Exception: diff --git a/src/Undefined/skills/commands/rmadmin/config.json b/src/Undefined/skills/commands/rmadmin/config.json index 4f7fcc89..36593052 100644 --- a/src/Undefined/skills/commands/rmadmin/config.json +++ b/src/Undefined/skills/commands/rmadmin/config.json @@ -1,8 +1,8 @@ { "name": "rmadmin", - "description": "移除管理员(仅超级管理员)", - "usage": "/rmadmin ", - "example": "/rmadmin 123456789", + "description": "移除管理员(仅超级管理员),支持 @ 提及", + "usage": "/rmadmin ", + "example": "/rmadmin 123456789 或 /rmadmin @某人", "permission": "superadmin", "rate_limit": { "user": 0, diff --git a/src/Undefined/skills/commands/rmadmin/handler.py b/src/Undefined/skills/commands/rmadmin/handler.py index febe565d..cfedd7ee 100644 --- a/src/Undefined/skills/commands/rmadmin/handler.py +++ b/src/Undefined/skills/commands/rmadmin/handler.py @@ -14,7 +14,7 @@ async def execute(args: list[str], context: CommandContext) -> None: if not args: await context.sender.send_group_message( context.group_id, - "❌ 用法: /rmadmin \n示例: /rmadmin 123456789", + "❌ 用法: /rmadmin \n示例: /rmadmin 123456789 或 /rmadmin @某人", ) return @@ -23,7 +23,7 @@ async def execute(args: list[str], context: CommandContext) -> None: except ValueError: await context.sender.send_group_message( context.group_id, - "❌ QQ 号格式错误,必须为数字", + "❌ QQ 号格式错误,必须为数字或 @ 提及", ) return diff --git a/src/Undefined/skills/commands/searchfaq/README.md b/src/Undefined/skills/commands/searchfaq/README.md deleted file mode 100644 index 0e6e6fbf..00000000 --- a/src/Undefined/skills/commands/searchfaq/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# /searchfaq 命令说明 - -## 功能 -按关键词搜索当前群组 FAQ,快速定位已有答案。 - -## 用法 -- `/searchfaq <关键词>` - -## 参数 -- `<关键词>` 必填,支持多个词,系统会合并后搜索。 - -## 示例 -- `/searchfaq 登录` -- `/searchfaq 数据 导入` - -## 说明 -- 结果数量有限制,命中较多时建议缩小关键词范围。 diff --git a/src/Undefined/skills/commands/searchfaq/config.json b/src/Undefined/skills/commands/searchfaq/config.json deleted file mode 100644 index 0f821fdb..00000000 --- a/src/Undefined/skills/commands/searchfaq/config.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "searchfaq", - "description": "按关键词搜索 FAQ", - "usage": "/searchfaq <关键词>", - "example": "/searchfaq 登录", - "permission": "public", - "rate_limit": { - "user": 10, - "admin": 5, - "superadmin": 0 - }, - "show_in_help": true, - "order": 50, - "allow_in_private": false, - "aliases": [] -} diff --git a/src/Undefined/skills/commands/searchfaq/handler.py b/src/Undefined/skills/commands/searchfaq/handler.py deleted file mode 100644 index 2ac29973..00000000 --- a/src/Undefined/skills/commands/searchfaq/handler.py +++ /dev/null @@ -1,30 +0,0 @@ -from __future__ import annotations - -from Undefined.services.commands.context import CommandContext - - -async def execute(args: list[str], context: CommandContext) -> None: - """处理 /searchfaq。""" - - if not args: - await context.sender.send_group_message( - context.group_id, - "❌ 用法: /searchfaq <关键词>\n示例: /searchfaq 登录", - ) - return - keyword = " ".join(args) - results = await context.faq_storage.search(context.group_id, keyword) - if not results: - await context.sender.send_group_message( - context.group_id, - f'🔍 未找到包含 "{keyword}" 的 FAQ', - ) - return - lines = [f'🔍 搜索 "{keyword}" 找到 {len(results)} 条结果:', ""] - for faq in results[:10]: - lines.append(f"📌 [{faq.id}] {faq.title}") - lines.append("") - if len(results) > 10: - lines.append(f"... 还有 {len(results) - 10} 条") - lines.append("\n使用 /viewfaq 查看详情") - await context.sender.send_group_message(context.group_id, "\n".join(lines)) diff --git a/src/Undefined/skills/commands/viewfaq/README.md b/src/Undefined/skills/commands/viewfaq/README.md deleted file mode 100644 index c4b7f6a2..00000000 --- a/src/Undefined/skills/commands/viewfaq/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# /viewfaq 命令说明 - -## 功能 -查看指定 FAQ 条目的完整内容。 - -## 用法 -- `/viewfaq ` - -## 参数 -- `` 必填,例如 `20241205-001`。 - -## 示例 -- `/viewfaq 20241205-001` - -## 说明 -- 可先用 `/lsfaq` 或 `/searchfaq` 找到目标 ID。 -- 若 ID 不存在,会返回未找到提示。 diff --git a/src/Undefined/skills/commands/viewfaq/config.json b/src/Undefined/skills/commands/viewfaq/config.json deleted file mode 100644 index 8654aba5..00000000 --- a/src/Undefined/skills/commands/viewfaq/config.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "viewfaq", - "description": "查看指定 ID 的 FAQ 详细内容", - "usage": "/viewfaq ", - "example": "/viewfaq 20241205-001", - "permission": "public", - "rate_limit": { - "user": 10, - "admin": 5, - "superadmin": 0 - }, - "show_in_help": true, - "order": 40, - "allow_in_private": false, - "aliases": [] -} diff --git a/src/Undefined/skills/commands/viewfaq/handler.py b/src/Undefined/skills/commands/viewfaq/handler.py deleted file mode 100644 index 73240b65..00000000 --- a/src/Undefined/skills/commands/viewfaq/handler.py +++ /dev/null @@ -1,31 +0,0 @@ -from __future__ import annotations - -from Undefined.services.commands.context import CommandContext - - -async def execute(args: list[str], context: CommandContext) -> None: - """处理 /viewfaq。""" - - if not args: - await context.sender.send_group_message( - context.group_id, - "❌ 用法: /viewfaq \n示例: /viewfaq 20241205-001", - ) - return - faq_id = args[0] - faq = await context.faq_storage.get(context.group_id, faq_id) - if not faq: - await context.sender.send_group_message( - context.group_id, - f"❌ FAQ 不存在: {faq_id}", - ) - return - message = ( - f"📖 FAQ: {faq.title}\n\n" - f"🆔 ID: {faq.id}\n" - f"👤 分析对象: {faq.target_qq}\n" - f"📅 时间范围: {faq.start_time} ~ {faq.end_time}\n" - f"🕐 创建时间: {faq.created_at}\n\n" - f"{faq.content}" - ) - await context.sender.send_group_message(context.group_id, message) diff --git a/src/Undefined/skills/http_client.py b/src/Undefined/skills/http_client.py index b2aa11fe..ca6875c0 100644 --- a/src/Undefined/skills/http_client.py +++ b/src/Undefined/skills/http_client.py @@ -7,7 +7,11 @@ import httpx -from Undefined.skills.http_config import get_request_retries, get_request_timeout +from Undefined.skills.http_config import ( + get_request_proxy, + get_request_retries, + get_request_timeout, +) logger = logging.getLogger(__name__) @@ -39,15 +43,21 @@ async def request_with_retry( timeout if timeout is not None else get_request_timeout(default_timeout) ) request_retries = retries if retries is not None else get_request_retries(0) + request_proxy = get_request_proxy(url) request_id = "-" if context is not None: request_id = str(context.get("request_id", "-")) last_exception: Exception | None = None - async with httpx.AsyncClient( - timeout=request_timeout, - follow_redirects=follow_redirects, - ) as client: + client_kwargs: dict[str, Any] = { + "timeout": request_timeout, + "follow_redirects": follow_redirects, + "trust_env": False, + } + if request_proxy is not None: + client_kwargs["proxy"] = request_proxy + + async with httpx.AsyncClient(**client_kwargs) as client: for attempt in range(request_retries + 1): try: response = await client.request( diff --git a/src/Undefined/skills/http_config.py b/src/Undefined/skills/http_config.py index e496abc1..20ca935a 100644 --- a/src/Undefined/skills/http_config.py +++ b/src/Undefined/skills/http_config.py @@ -1,5 +1,7 @@ from __future__ import annotations +from urllib.parse import urlsplit + from Undefined.config import get_config @@ -27,6 +29,22 @@ def get_request_retries(default_retries: int = 0) -> int: return retries +def get_request_proxy(url: str) -> str | None: + config = get_config(strict=False) + if not bool(getattr(config, "use_proxy", False)): + return None + + http_proxy = str(getattr(config, "http_proxy", "") or "").strip() + https_proxy = str(getattr(config, "https_proxy", "") or "").strip() + scheme = urlsplit(url).scheme.lower() + + if scheme == "https": + return https_proxy or http_proxy or None + if scheme == "http": + return http_proxy or https_proxy or None + return https_proxy or http_proxy or None + + def get_xxapi_url(path: str) -> str: config = get_config(strict=False) base_url = _normalize_base_url(config.api_xxapi_base_url, "https://v2.xxapi.cn") diff --git a/src/Undefined/skills/tools/fetch_image_uid/handler.py b/src/Undefined/skills/tools/fetch_image_uid/handler.py index 11724cf8..7986c811 100644 --- a/src/Undefined/skills/tools/fetch_image_uid/handler.py +++ b/src/Undefined/skills/tools/fetch_image_uid/handler.py @@ -43,4 +43,4 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: if mime and not mime.startswith(_IMAGE_MIME_PREFIX): return f"URL 内容不是图片类型(检测到 {mime}),仅支持图片" - return f'' + return f'' diff --git a/src/Undefined/skills/tools/get_picture/handler.py b/src/Undefined/skills/tools/get_picture/handler.py index b13df227..d580dce4 100644 --- a/src/Undefined/skills/tools/get_picture/handler.py +++ b/src/Undefined/skills/tools/get_picture/handler.py @@ -272,7 +272,7 @@ async def _deliver_embed( source_kind="get_picture", source_ref=f"get_picture:{picture_type}", ) - uid_tags.append(f'') + uid_tags.append(f'') except Exception as exc: logger.warning("注册图片到附件系统失败: %s", exc) register_fail += 1 diff --git a/src/Undefined/skills/toolsets/README.md b/src/Undefined/skills/toolsets/README.md index 92209988..a17a830d 100644 --- a/src/Undefined/skills/toolsets/README.md +++ b/src/Undefined/skills/toolsets/README.md @@ -136,7 +136,7 @@ async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: ### Render(渲染) - `render.render_html`: 将 HTML 渲染为图片 -- `render.render_latex`: 将 LaTeX 渲染为图片(依赖系统 TeX 环境,需安装 TeX Live / MiKTeX) +- `render.render_latex`: 将 LaTeX 渲染为图片;常见公式本地渲染,复杂内容回退 MathJax + Playwright - `render.render_markdown`: 将 Markdown 渲染为图片 ### Memes(表情包) @@ -151,3 +151,17 @@ async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: - `scheduler.list_schedule_tasks`: 列出所有定时任务 - `scheduler.update_schedule_task`: 更新定时任务 - `scheduler.create_schedule_task` / `scheduler.update_schedule_task` 支持 `self_instruction` 参数,可在未来时刻调用 AI 自己执行一条延迟指令 + +### Group Analysis(群聊深度分析) + +- `group_analysis.member_structure`: 统计成员结构事实 +- `group_analysis.message_mix`: 统计消息构成事实 +- `group_analysis.member_activity`: 分析群成员活跃度 +- `group_analysis.rank_members`: 对群成员进行多维度排名 +- `group_analysis.filter_members`: 按条件过滤群成员 +- `group_analysis.inactive_risk`: 检测长期潜水或新成员沉默等活跃风险 +- `group_analysis.activity_trend`: 分析群活跃趋势变化 +- `group_analysis.level_distribution`: 统计群成员等级分布 +- `group_analysis.member_messages`: 分析指定成员消息情况 +- `group_analysis.join_statistics`: 统计群成员加入趋势 +- `group_analysis.new_member_activity`: 分析新成员活跃度变化 diff --git a/src/Undefined/skills/toolsets/group/README.md b/src/Undefined/skills/toolsets/group/README.md index 7f0bb390..dfc671a3 100644 --- a/src/Undefined/skills/toolsets/group/README.md +++ b/src/Undefined/skills/toolsets/group/README.md @@ -6,9 +6,9 @@ - 群成员列表/信息查询 - 群荣誉信息 - 群文件列表 -- 群成员多条件筛选(角色/等级/入群时间/活跃度) -- 群活跃排行与活跃趋势分析 -- 群等级分布统计与活跃风险识别 +- 群头像、成员头衔等基础资料查询 + +群聊统计、排行、活跃度、等级分布与风险识别等分析类能力已归入 `group_analysis.*`。 目录结构: - 每个子目录对应一个工具(`config.json` + `handler.py`) diff --git a/src/Undefined/skills/toolsets/group/get_avatar/config.json b/src/Undefined/skills/toolsets/group/get_avatar/config.json new file mode 100644 index 00000000..9e817e7b --- /dev/null +++ b/src/Undefined/skills/toolsets/group/get_avatar/config.json @@ -0,0 +1,23 @@ +{ + "type": "function", + "function": { + "name": "get_avatar", + "description": "获取指定QQ用户的头像,返回可在回复中嵌入的图片 UID。适用于需要展示用户头像的场景。", + "parameters": { + "type": "object", + "properties": { + "user_id": { + "type": "integer", + "description": "要获取头像的QQ号" + }, + "size": { + "type": "integer", + "description": "头像尺寸(像素),可选值:40、100、140、640。默认 100。", + "default": 100, + "enum": [40, 100, 140, 640] + } + }, + "required": ["user_id"] + } + } +} diff --git a/src/Undefined/skills/toolsets/group/get_avatar/handler.py b/src/Undefined/skills/toolsets/group/get_avatar/handler.py new file mode 100644 index 00000000..8b97fb57 --- /dev/null +++ b/src/Undefined/skills/toolsets/group/get_avatar/handler.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import logging +from typing import Any, Dict + +from Undefined.attachments import scope_from_context + +logger = logging.getLogger(__name__) + +_QQ_AVATAR_URL = "https://q1.qlogo.cn/g?b=qq&nk={user_id}&s={size_code}" + +_SIZE_MAP = { + 40: 0, + 100: 1, + 140: 2, + 640: 3, +} + + +def _normalize_size(value: Any) -> int: + try: + size = int(value) + except (TypeError, ValueError): + return 100 + return size if size in _SIZE_MAP else 100 + + +async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: + """获取 QQ 用户头像并注册到附件系统,返回图片 UID。""" + user_id = args.get("user_id") + size = args.get("size", 100) + + if user_id is None: + return "请提供 QQ 号(user_id 参数)" + + try: + user_id = int(user_id) + except (ValueError, TypeError): + return "参数类型错误:user_id 必须是整数" + + if user_id <= 0: + return "QQ 号必须为正整数" + + size_code = _SIZE_MAP[_normalize_size(size)] + avatar_url = _QQ_AVATAR_URL.format(user_id=user_id, size_code=size_code) + + attachment_registry = context.get("attachment_registry") + scope_key = scope_from_context(context) + if attachment_registry is None or not scope_key: + return "当前会话不支持附件注册" + + try: + record = await attachment_registry.register_remote_url( + scope_key, + avatar_url, + kind="image", + display_name=f"avatar_{user_id}.jpg", + source_kind="get_avatar", + source_ref=f"qq:{user_id}", + ) + except Exception as exc: + logger.exception("get_avatar 注册失败: user_id=%s err=%s", user_id, exc) + return f"获取头像失败:{exc}" + + return f'' diff --git a/src/Undefined/skills/toolsets/group/get_member_info/config.json b/src/Undefined/skills/toolsets/group/get_member_info/config.json index 95e5deb2..8b3dcda1 100644 --- a/src/Undefined/skills/toolsets/group/get_member_info/config.json +++ b/src/Undefined/skills/toolsets/group/get_member_info/config.json @@ -38,6 +38,11 @@ "type": "boolean", "description": "是否不使用缓存获取最新信息,默认 false", "default": false + }, + "brief": { + "type": "boolean", + "description": "简洁模式:只返回昵称(群名片优先,其次QQ昵称),不包含其他信息。适用于需要称呼用户时快速查询当前昵称。", + "default": false } }, "required": [ diff --git a/src/Undefined/skills/toolsets/group/get_member_info/handler.py b/src/Undefined/skills/toolsets/group/get_member_info/handler.py index f1d604cf..1b223c54 100644 --- a/src/Undefined/skills/toolsets/group/get_member_info/handler.py +++ b/src/Undefined/skills/toolsets/group/get_member_info/handler.py @@ -26,6 +26,7 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: user_id = args.get("user_id") fields = args.get("fields") no_cache = args.get("no_cache", False) + brief = args.get("brief", False) if group_id is None: return "请提供群号(group_id 参数),或者在群聊中调用" @@ -50,12 +51,15 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: if not member_info: return f"未找到群 {group_id} 中 QQ {user_id} 的成员信息,可能该用户已退群、从未入群或群号不正确" - result_parts = [] nickname = member_info.get("nickname", "") card = member_info.get("card", "") - result_parts.append(f"【群成员信息】群号: {group_id}") + if brief: + display_name = card.strip() if card.strip() else nickname.strip() + return display_name or str(user_id) + result_parts = [] + result_parts.append(f"【群成员信息】群号: {group_id}") display_name = card if card else nickname if display_name: result_parts.append(f"昵称: {display_name}") diff --git a/src/Undefined/skills/toolsets/group_analysis/README.md b/src/Undefined/skills/toolsets/group_analysis/README.md new file mode 100644 index 00000000..babba297 --- /dev/null +++ b/src/Undefined/skills/toolsets/group_analysis/README.md @@ -0,0 +1,18 @@ +# group_analysis 工具集 + +群聊深度分析工具集合,工具名以 `group_analysis.*` 命名。 + +主要能力: +- `group_analysis.member_structure`:统计角色分布、等级概览、入群时间覆盖和最后发言分层等成员结构事实;详细等级分布请使用 `group_analysis.level_distribution`。 +- `group_analysis.message_mix`:统计消息类型分布、活跃时段、活跃星期、时间覆盖和最近消息样本。 +- `group_analysis.member_activity`:分析群成员活跃度。 +- `group_analysis.rank_members`:对群成员进行多维度排名。 +- `group_analysis.filter_members`:按角色、等级、入群时间、活跃时间等条件过滤群成员。 +- `group_analysis.inactive_risk`:检测长期潜水或新成员沉默等活跃风险。 +- `group_analysis.activity_trend`:分析群活跃趋势变化。 +- `group_analysis.level_distribution`:统计群成员等级分布。 +- `group_analysis.member_messages`:分析指定成员的消息数量、类型分布和活跃时段。 +- `group_analysis.join_statistics`:统计群成员加入趋势与留存情况。 +- `group_analysis.new_member_activity`:分析新成员加入后的活跃度变化。 + +这些工具主要给 AI 调用。需要用户直接触发时,应由 AI 根据问题选择合适工具,并将工具输出整理成自然语言回复。 diff --git a/src/Undefined/skills/toolsets/group/activity_trend/config.json b/src/Undefined/skills/toolsets/group_analysis/activity_trend/config.json similarity index 100% rename from src/Undefined/skills/toolsets/group/activity_trend/config.json rename to src/Undefined/skills/toolsets/group_analysis/activity_trend/config.json diff --git a/src/Undefined/skills/toolsets/group/activity_trend/handler.py b/src/Undefined/skills/toolsets/group_analysis/activity_trend/handler.py similarity index 100% rename from src/Undefined/skills/toolsets/group/activity_trend/handler.py rename to src/Undefined/skills/toolsets/group_analysis/activity_trend/handler.py diff --git a/src/Undefined/skills/toolsets/group/filter_members/config.json b/src/Undefined/skills/toolsets/group_analysis/filter_members/config.json similarity index 100% rename from src/Undefined/skills/toolsets/group/filter_members/config.json rename to src/Undefined/skills/toolsets/group_analysis/filter_members/config.json diff --git a/src/Undefined/skills/toolsets/group/filter_members/handler.py b/src/Undefined/skills/toolsets/group_analysis/filter_members/handler.py similarity index 100% rename from src/Undefined/skills/toolsets/group/filter_members/handler.py rename to src/Undefined/skills/toolsets/group_analysis/filter_members/handler.py diff --git a/src/Undefined/skills/toolsets/group/detect_inactive_risk/config.json b/src/Undefined/skills/toolsets/group_analysis/inactive_risk/config.json similarity index 97% rename from src/Undefined/skills/toolsets/group/detect_inactive_risk/config.json rename to src/Undefined/skills/toolsets/group_analysis/inactive_risk/config.json index e5bbc8b0..fbdf699b 100644 --- a/src/Undefined/skills/toolsets/group/detect_inactive_risk/config.json +++ b/src/Undefined/skills/toolsets/group_analysis/inactive_risk/config.json @@ -1,7 +1,7 @@ { "type": "function", "function": { - "name": "detect_inactive_risk", + "name": "inactive_risk", "description": "识别群成员活跃风险(成员数据来自 OneBot get_group_member_list)。检测从未发言、长期潜水、新成员沉默、高等级低活跃等治理候选并打分排序。", "parameters": { "type": "object", diff --git a/src/Undefined/skills/toolsets/group/detect_inactive_risk/handler.py b/src/Undefined/skills/toolsets/group_analysis/inactive_risk/handler.py similarity index 100% rename from src/Undefined/skills/toolsets/group/detect_inactive_risk/handler.py rename to src/Undefined/skills/toolsets/group_analysis/inactive_risk/handler.py diff --git a/src/Undefined/skills/toolsets/group_analysis/analyze_join_statistics/config.json b/src/Undefined/skills/toolsets/group_analysis/join_statistics/config.json similarity index 97% rename from src/Undefined/skills/toolsets/group_analysis/analyze_join_statistics/config.json rename to src/Undefined/skills/toolsets/group_analysis/join_statistics/config.json index c20eebea..2d0ec296 100644 --- a/src/Undefined/skills/toolsets/group_analysis/analyze_join_statistics/config.json +++ b/src/Undefined/skills/toolsets/group_analysis/join_statistics/config.json @@ -1,7 +1,7 @@ { "type": "function", "function": { - "name": "analyze_join_statistics", + "name": "join_statistics", "description": "分析群的加群情况,提供加群人数统计和趋势分析。包含:按时间筛选成员、人数统计、加群趋势分析、可选的成员列表。", "parameters": { "type": "object", diff --git a/src/Undefined/skills/toolsets/group_analysis/analyze_join_statistics/handler.py b/src/Undefined/skills/toolsets/group_analysis/join_statistics/handler.py similarity index 100% rename from src/Undefined/skills/toolsets/group_analysis/analyze_join_statistics/handler.py rename to src/Undefined/skills/toolsets/group_analysis/join_statistics/handler.py diff --git a/src/Undefined/skills/toolsets/group/level_distribution/config.json b/src/Undefined/skills/toolsets/group_analysis/level_distribution/config.json similarity index 100% rename from src/Undefined/skills/toolsets/group/level_distribution/config.json rename to src/Undefined/skills/toolsets/group_analysis/level_distribution/config.json diff --git a/src/Undefined/skills/toolsets/group/level_distribution/handler.py b/src/Undefined/skills/toolsets/group_analysis/level_distribution/handler.py similarity index 100% rename from src/Undefined/skills/toolsets/group/level_distribution/handler.py rename to src/Undefined/skills/toolsets/group_analysis/level_distribution/handler.py diff --git a/src/Undefined/skills/toolsets/group/get_member_activity/config.json b/src/Undefined/skills/toolsets/group_analysis/member_activity/config.json similarity index 98% rename from src/Undefined/skills/toolsets/group/get_member_activity/config.json rename to src/Undefined/skills/toolsets/group_analysis/member_activity/config.json index 905f5f4f..26052fb7 100644 --- a/src/Undefined/skills/toolsets/group/get_member_activity/config.json +++ b/src/Undefined/skills/toolsets/group_analysis/member_activity/config.json @@ -1,7 +1,7 @@ { "type": "function", "function": { - "name": "get_member_activity", + "name": "member_activity", "description": "分析群成员活跃度(数据统一来自 OneBot 接口)。支持 member_list/history/hybrid 三种模式:可结合最近发言与历史消息窗口,输出活跃统计、活跃榜和潜水成员。", "parameters": { "type": "object", diff --git a/src/Undefined/skills/toolsets/group/get_member_activity/handler.py b/src/Undefined/skills/toolsets/group_analysis/member_activity/handler.py similarity index 100% rename from src/Undefined/skills/toolsets/group/get_member_activity/handler.py rename to src/Undefined/skills/toolsets/group_analysis/member_activity/handler.py diff --git a/src/Undefined/skills/toolsets/group_analysis/analyze_member_messages/config.json b/src/Undefined/skills/toolsets/group_analysis/member_messages/config.json similarity index 97% rename from src/Undefined/skills/toolsets/group_analysis/analyze_member_messages/config.json rename to src/Undefined/skills/toolsets/group_analysis/member_messages/config.json index 6b3ce00f..8ed397c4 100644 --- a/src/Undefined/skills/toolsets/group_analysis/analyze_member_messages/config.json +++ b/src/Undefined/skills/toolsets/group_analysis/member_messages/config.json @@ -1,7 +1,7 @@ { "type": "function", "function": { - "name": "analyze_member_messages", + "name": "member_messages", "description": "分析指定群成员的消息情况,提供全面的消息统计和活跃度分析。包含:消息数量统计、消息类型分布、活跃时段分析、可选的消息内容获取。", "parameters": { "type": "object", diff --git a/src/Undefined/skills/toolsets/group_analysis/analyze_member_messages/handler.py b/src/Undefined/skills/toolsets/group_analysis/member_messages/handler.py similarity index 100% rename from src/Undefined/skills/toolsets/group_analysis/analyze_member_messages/handler.py rename to src/Undefined/skills/toolsets/group_analysis/member_messages/handler.py diff --git a/src/Undefined/skills/toolsets/group_analysis/member_structure/config.json b/src/Undefined/skills/toolsets/group_analysis/member_structure/config.json new file mode 100644 index 00000000..25c340dc --- /dev/null +++ b/src/Undefined/skills/toolsets/group_analysis/member_structure/config.json @@ -0,0 +1,26 @@ +{ + "type": "function", + "function": { + "name": "member_structure", + "description": "统计群成员结构事实数据(成员数据来自 OneBot get_group_member_list)。返回角色分布、等级概览、入群时间覆盖、近期入群人数、最后发言分层和可选样例;详细等级分布请使用 group_analysis.level_distribution。", + "parameters": { + "type": "object", + "properties": { + "group_id": { + "type": "integer", + "description": "群号。如果已在群聊中,通常会自动获取。" + }, + "include_examples": { + "type": "boolean", + "description": "是否展示各角色的成员样例,默认 true。", + "default": true + }, + "example_count": { + "type": "integer", + "description": "每类样例数量,默认 3,最大 10。", + "default": 3 + } + } + } + } +} \ No newline at end of file diff --git a/src/Undefined/skills/toolsets/group_analysis/member_structure/handler.py b/src/Undefined/skills/toolsets/group_analysis/member_structure/handler.py new file mode 100644 index 00000000..37a31b96 --- /dev/null +++ b/src/Undefined/skills/toolsets/group_analysis/member_structure/handler.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +import logging +import time +from collections import Counter, defaultdict +from typing import Any + +from Undefined.utils.group_metrics import ( + clamp_int, + format_timestamp, + member_display_name, + parse_member_level, + parse_unix_timestamp, + role_to_cn, +) + +logger = logging.getLogger(__name__) + + +def _to_bool(raw_value: Any, default: bool) -> bool: + if raw_value is None: + return default + if isinstance(raw_value, bool): + return raw_value + if isinstance(raw_value, str): + normalized = raw_value.strip().lower() + if normalized in {"1", "true", "yes", "y", "on"}: + return True + if normalized in {"0", "false", "no", "n", "off"}: + return False + return bool(raw_value) + + +def _format_rate(part: int, total: int) -> str: + if total <= 0: + return "0.0%" + return f"{part / total * 100:.1f}%" + + +def _days_since(now_ts: int, past_ts: int) -> int | None: + if past_ts <= 0: + return None + return max(0, int((now_ts - past_ts) / 86400)) + + +async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: + """统计群成员结构事实数据。""" + request_id = str(context.get("request_id", "-")) + group_id = args.get("group_id") or context.get("group_id") + if group_id is None: + return "请提供群号(group_id 参数),或者在群聊中调用" + + try: + group_id = int(group_id) + except (TypeError, ValueError): + return "参数类型错误:group_id 必须是整数" + if group_id <= 0: + return "参数范围错误:group_id 必须大于 0" + + include_examples = _to_bool(args.get("include_examples"), True) + example_count = clamp_int(args.get("example_count"), 3, 1, 10) + + onebot_client = context.get("onebot_client") + if not onebot_client: + return "群成员结构统计功能不可用(OneBot 客户端未设置)" + + try: + member_list: list[dict[str, Any]] = await onebot_client.get_group_member_list( + group_id + ) + except Exception as exc: + logger.exception( + "统计群成员结构失败: group=%s request_id=%s err=%s", + group_id, + request_id, + exc, + ) + return "统计失败:群成员结构服务暂时不可用,请稍后重试" + + if not member_list: + return f"未能获取到群 {group_id} 的成员列表" + + now_ts = int(time.time()) + total_members = len(member_list) + role_counter: Counter[str] = Counter() + role_samples: dict[str, list[str]] = defaultdict(list) + level_values: list[int] = [] + unknown_level_count = 0 + join_timestamps: list[int] = [] + last_sent_timestamps: list[int] = [] + never_spoke_count = 0 + joined_7d = 0 + joined_30d = 0 + joined_90d = 0 + active_7d = 0 + active_30d = 0 + inactive_90d = 0 + + for member in member_list: + role_text = role_to_cn(member.get("role")) + role_counter[role_text] += 1 + if include_examples and len(role_samples[role_text]) < example_count: + role_samples[role_text].append( + f"{member_display_name(member)}({member.get('user_id')})" + ) + + level = parse_member_level(member.get("level")) + if level is None: + unknown_level_count += 1 + else: + level_values.append(level) + + join_ts = parse_unix_timestamp(member.get("join_time")) + if join_ts > 0: + join_timestamps.append(join_ts) + join_days = _days_since(now_ts, join_ts) + if join_days is not None and join_days <= 7: + joined_7d += 1 + if join_days is not None and join_days <= 30: + joined_30d += 1 + if join_days is not None and join_days <= 90: + joined_90d += 1 + + last_sent_ts = parse_unix_timestamp(member.get("last_sent_time")) + if last_sent_ts <= 0: + never_spoke_count += 1 + continue + last_sent_timestamps.append(last_sent_ts) + silent_days = _days_since(now_ts, last_sent_ts) + if silent_days is not None and silent_days <= 7: + active_7d += 1 + if silent_days is not None and silent_days <= 30: + active_30d += 1 + if silent_days is not None and silent_days >= 90: + inactive_90d += 1 + + lines: list[str] = [f"【群成员结构】群号: {group_id}"] + lines.append(f"成员总数: {total_members}") + lines.append("") + lines.append("角色分布:") + for role_text, amount in role_counter.most_common(): + lines.append( + f"- {role_text}: {amount} 人({_format_rate(amount, total_members)})" + ) + if include_examples and role_samples.get(role_text): + lines.append(f" 样例: {','.join(role_samples[role_text])}") + + lines.append("") + lines.append("等级概览:") + if level_values: + average_level = sum(level_values) / len(level_values) + lines.append(f"- 已识别等级成员: {len(level_values)} 人") + lines.append(f"- 最高等级: Lv.{max(level_values)}") + lines.append(f"- 最低等级: Lv.{min(level_values)}") + lines.append(f"- 平均等级: Lv.{average_level:.1f}") + else: + lines.append("- 暂无可用等级数据") + if unknown_level_count > 0: + lines.append(f"- 等级未知: {unknown_level_count} 人") + + lines.append("") + lines.append("入群结构:") + lines.append(f"- 最近 7 天入群: {joined_7d} 人") + lines.append(f"- 最近 30 天入群: {joined_30d} 人") + lines.append(f"- 最近 90 天入群: {joined_90d} 人") + lines.append( + f"- 入群时间覆盖: {format_timestamp(min(join_timestamps) if join_timestamps else 0)} ~ " + f"{format_timestamp(max(join_timestamps) if join_timestamps else 0)}" + ) + + lines.append("") + lines.append("最后发言结构:") + lines.append(f"- 最近 7 天发言: {active_7d} 人") + lines.append(f"- 最近 30 天发言: {active_30d} 人") + lines.append(f"- 超过 90 天未发言: {inactive_90d} 人") + lines.append(f"- 从未发言/无记录: {never_spoke_count} 人") + lines.append( + f"- 最后发言覆盖: {format_timestamp(min(last_sent_timestamps) if last_sent_timestamps else 0)} ~ " + f"{format_timestamp(max(last_sent_timestamps) if last_sent_timestamps else 0)}" + ) + + return "\n".join(lines) diff --git a/src/Undefined/skills/toolsets/group_analysis/message_mix/config.json b/src/Undefined/skills/toolsets/group_analysis/message_mix/config.json new file mode 100644 index 00000000..538b40a3 --- /dev/null +++ b/src/Undefined/skills/toolsets/group_analysis/message_mix/config.json @@ -0,0 +1,44 @@ +{ + "type": "function", + "function": { + "name": "message_mix", + "description": "统计群消息构成事实数据(历史数据来自 OneBot get_group_msg_history)。返回消息类型分布、活跃时段、活跃星期、时间覆盖和可选最近样本,适合 AI 进一步分析群聊内容形态。", + "parameters": { + "type": "object", + "properties": { + "group_id": { + "type": "integer", + "description": "群号。如果已在群聊中,通常会自动获取。" + }, + "start_time": { + "type": "string", + "description": "开始时间,格式:YYYY-MM-DD HH:MM:SS。未指定时使用最近 days 天。" + }, + "end_time": { + "type": "string", + "description": "结束时间,格式:YYYY-MM-DD HH:MM:SS。未指定时使用当前时间。" + }, + "days": { + "type": "integer", + "description": "未指定 start_time 时使用最近 N 天窗口,默认 30 天,最大 365 天。", + "default": 30 + }, + "max_history_count": { + "type": "integer", + "description": "最多读取历史消息数量,默认 5000,上限受运行时配置 history_search_scan_limit 约束。", + "default": 5000 + }, + "include_samples": { + "type": "boolean", + "description": "是否返回最近消息样本,默认 true。", + "default": true + }, + "sample_count": { + "type": "integer", + "description": "最近消息样本数量,默认 3,最大 10。", + "default": 3 + } + } + } + } +} \ No newline at end of file diff --git a/src/Undefined/skills/toolsets/group_analysis/message_mix/handler.py b/src/Undefined/skills/toolsets/group_analysis/message_mix/handler.py new file mode 100644 index 00000000..ad654847 --- /dev/null +++ b/src/Undefined/skills/toolsets/group_analysis/message_mix/handler.py @@ -0,0 +1,242 @@ +from __future__ import annotations + +import logging +from collections import Counter +from datetime import datetime, timedelta +from typing import Any + +from Undefined.onebot import parse_message_time +from Undefined.utils.group_metrics import clamp_int, datetime_to_ts +from Undefined.utils.message_utils import count_message_types, fetch_group_messages +from Undefined.utils.time_utils import format_datetime, parse_time_range + +logger = logging.getLogger(__name__) + +_DEFAULT_DAYS = 30 +_DEFAULT_MAX_HISTORY_COUNT = 5000 +_WEEKDAY_NAMES = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"] + + +def _runtime_limit(context: dict[str, Any], key: str, fallback: int) -> int: + config = context.get("runtime_config") + value = getattr(config, key, None) if config is not None else None + if isinstance(value, int) and value > 0: + return value + return fallback + + +def _to_bool(raw_value: Any, default: bool) -> bool: + if raw_value is None: + return default + if isinstance(raw_value, bool): + return raw_value + if isinstance(raw_value, str): + normalized = raw_value.strip().lower() + if normalized in {"1", "true", "yes", "y", "on"}: + return True + if normalized in {"0", "false", "no", "n", "off"}: + return False + return bool(raw_value) + + +def _dict_or_empty(raw_value: Any) -> dict[str, Any]: + if isinstance(raw_value, dict): + return raw_value + return {} + + +def _format_rate(part: int, total: int) -> str: + if total <= 0: + return "0.0%" + return f"{part / total * 100:.1f}%" + + +def _resolve_time_window(args: dict[str, Any]) -> tuple[datetime, datetime, str | None]: + start_time = args.get("start_time") + end_time = args.get("end_time") + start_text = str(start_time).strip() if start_time is not None else "" + end_text = str(end_time).strip() if end_time is not None else "" + start_dt, end_dt = parse_time_range(start_text or None, end_text or None) + + if start_text and start_dt is None: + return ( + datetime.now(), + datetime.now(), + "start_time 格式错误,请使用 YYYY-MM-DD HH:MM:SS", + ) + if end_text and end_dt is None: + return ( + datetime.now(), + datetime.now(), + "end_time 格式错误,请使用 YYYY-MM-DD HH:MM:SS", + ) + + days = clamp_int(args.get("days"), _DEFAULT_DAYS, 1, 365) + now_dt = datetime.now() + if end_dt is None: + end_dt = now_dt + if start_dt is None: + start_dt = end_dt - timedelta(days=days) + if start_dt > end_dt: + return start_dt, end_dt, "参数范围错误:start_time 不能晚于 end_time" + return start_dt, end_dt, None + + +def _message_preview(message: dict[str, Any], limit: int = 80) -> str: + raw_message = message.get("message") + parts: list[str] = [] + if isinstance(raw_message, str): + parts.append(raw_message) + elif isinstance(raw_message, list): + for segment in raw_message: + if not isinstance(segment, dict): + continue + segment_type = str(segment.get("type") or "") + data = _dict_or_empty(segment.get("data")) + if segment_type == "text": + parts.append(str(data.get("text") or "")) + elif segment_type: + parts.append(f"[{segment_type}]") + text = "".join(parts).strip().replace("\n", " ") or "(空消息)" + if len(text) <= limit: + return text + return text[: limit - 8].rstrip() + "..." + + +async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: + """统计群消息构成事实数据。""" + request_id = str(context.get("request_id", "-")) + group_id = args.get("group_id") or context.get("group_id") + if group_id is None: + return "请提供群号(group_id 参数),或者在群聊中调用" + + try: + group_id = int(group_id) + except (TypeError, ValueError): + return "参数类型错误:group_id 必须是整数" + if group_id <= 0: + return "参数范围错误:group_id 必须大于 0" + + onebot_client = context.get("onebot_client") + if not onebot_client: + return "群消息构成统计功能不可用(OneBot 客户端未设置)" + + start_dt, end_dt, time_error = _resolve_time_window(args) + if time_error: + return time_error + start_ts = datetime_to_ts(start_dt) + end_ts = datetime_to_ts(end_dt) + if start_ts is None or end_ts is None: + return "时间范围转换失败,请检查参数" + + history_cap = max(100, _runtime_limit(context, "history_search_scan_limit", 10000)) + max_history_count = clamp_int( + args.get("max_history_count"), + _DEFAULT_MAX_HISTORY_COUNT, + 100, + history_cap, + ) + include_samples = _to_bool(args.get("include_samples"), True) + sample_count = clamp_int(args.get("sample_count"), 3, 1, 10) + + try: + raw_messages = await fetch_group_messages( + onebot_client, + group_id, + max_history_count, + start_dt, + ) + except Exception as exc: + logger.exception( + "统计群消息构成失败: group=%s request_id=%s err=%s", + group_id, + request_id, + exc, + ) + return "统计失败:群消息构成服务暂时不可用,请稍后重试" + + filtered_messages: list[dict[str, Any]] = [] + active_users: set[int] = set() + hourly_counter: Counter[int] = Counter() + weekday_counter: Counter[int] = Counter() + message_times: list[datetime] = [] + + for message in raw_messages: + message_time = parse_message_time(message) + message_ts = datetime_to_ts(message_time) + if message_ts is None or message_ts < start_ts or message_ts > end_ts: + continue + filtered_messages.append(message) + message_times.append(message_time) + hourly_counter[message_time.hour] += 1 + weekday_counter[message_time.weekday()] += 1 + + sender = _dict_or_empty(message.get("sender")) + try: + user_id = int(sender.get("user_id") or 0) + except (TypeError, ValueError): + user_id = 0 + if user_id > 0: + active_users.add(user_id) + + total_messages = len(filtered_messages) + lines: list[str] = [f"【群消息构成】群号: {group_id}"] + lines.append(f"时间范围: {format_datetime(start_dt)} ~ {format_datetime(end_dt)}") + lines.append( + f"数据读取: 扫描历史 {len(raw_messages)} 条;窗口有效消息 {total_messages} 条(扫描上限 {max_history_count} 条)" + ) + lines.append(f"活跃发送者: {len(active_users)} 人") + lines.append( + "消息时间覆盖: " + f"{min(message_times).strftime('%Y-%m-%d %H:%M:%S') if message_times else '无'} ~ " + f"{max(message_times).strftime('%Y-%m-%d %H:%M:%S') if message_times else '无'}" + ) + + lines.append("") + lines.append("消息类型分布:") + type_stats = count_message_types(filtered_messages) + if type_stats: + for message_type, amount in sorted( + type_stats.items(), key=lambda item: item[1], reverse=True + ): + lines.append( + f"- {message_type}: {amount} 条({_format_rate(amount, total_messages)})" + ) + else: + lines.append("- 暂无消息类型数据") + + lines.append("") + lines.append("活跃时段 Top:") + if hourly_counter: + for hour, amount in hourly_counter.most_common(5): + lines.append(f"- {hour:02d}:00-{hour:02d}:59: {amount} 条") + else: + lines.append("- 暂无时段数据") + + lines.append("") + lines.append("活跃星期分布:") + if weekday_counter: + for weekday, amount in weekday_counter.most_common(): + lines.append(f"- {_WEEKDAY_NAMES[weekday]}: {amount} 条") + else: + lines.append("- 暂无星期数据") + + if include_samples and filtered_messages: + lines.append("") + lines.append(f"最近消息样本({min(sample_count, len(filtered_messages))} 条):") + for message in sorted(filtered_messages, key=parse_message_time, reverse=True)[ + :sample_count + ]: + sender = _dict_or_empty(message.get("sender")) + sender_name = ( + sender.get("card") + or sender.get("nickname") + or sender.get("user_id") + or "未知" + ) + lines.append( + f"- {parse_message_time(message).strftime('%Y-%m-%d %H:%M:%S')} " + f"{sender_name}: {_message_preview(message)}" + ) + + return "\n".join(lines) diff --git a/src/Undefined/skills/toolsets/group_analysis/analyze_new_member_activity/config.json b/src/Undefined/skills/toolsets/group_analysis/new_member_activity/config.json similarity index 96% rename from src/Undefined/skills/toolsets/group_analysis/analyze_new_member_activity/config.json rename to src/Undefined/skills/toolsets/group_analysis/new_member_activity/config.json index 2a31c984..8552c282 100644 --- a/src/Undefined/skills/toolsets/group_analysis/analyze_new_member_activity/config.json +++ b/src/Undefined/skills/toolsets/group_analysis/new_member_activity/config.json @@ -1,7 +1,7 @@ { "type": "function", "function": { - "name": "analyze_new_member_activity", + "name": "new_member_activity", "description": "分析新成员的活跃情况,帮助了解新成员的融入程度。包含:活跃度统计、最活跃成员排行、发言分布分析。", "parameters": { "type": "object", diff --git a/src/Undefined/skills/toolsets/group_analysis/analyze_new_member_activity/handler.py b/src/Undefined/skills/toolsets/group_analysis/new_member_activity/handler.py similarity index 100% rename from src/Undefined/skills/toolsets/group_analysis/analyze_new_member_activity/handler.py rename to src/Undefined/skills/toolsets/group_analysis/new_member_activity/handler.py diff --git a/src/Undefined/skills/toolsets/group/rank_members/config.json b/src/Undefined/skills/toolsets/group_analysis/rank_members/config.json similarity index 100% rename from src/Undefined/skills/toolsets/group/rank_members/config.json rename to src/Undefined/skills/toolsets/group_analysis/rank_members/config.json diff --git a/src/Undefined/skills/toolsets/group/rank_members/handler.py b/src/Undefined/skills/toolsets/group_analysis/rank_members/handler.py similarity index 100% rename from src/Undefined/skills/toolsets/group/rank_members/handler.py rename to src/Undefined/skills/toolsets/group_analysis/rank_members/handler.py diff --git a/src/Undefined/skills/toolsets/memes/README.md b/src/Undefined/skills/toolsets/memes/README.md index 463dc509..ca199506 100644 --- a/src/Undefined/skills/toolsets/memes/README.md +++ b/src/Undefined/skills/toolsets/memes/README.md @@ -21,5 +21,5 @@ 统一图片 `uid`: - 表情包与普通图片复用同一套 `uid` 语义 -- `memes.search_memes` 返回的 `uid` 既可用于 `memes.send_meme_by_uid`,也可直接插入 `` +- `memes.search_memes` 返回的 `uid` 既可用于 `memes.send_meme_by_uid`,也可直接插入推荐的 ``(旧 `` 仍兼容图片 UID) - 通过 `memes.send_meme_by_uid` 或引用表情包库 `uid` 发出的图片,会在历史记录里附带 `meme_library` 来源与表情包描述,便于后续对话继续理解“之前发的是哪种表情包” diff --git a/src/Undefined/skills/toolsets/memes/search_memes/config.json b/src/Undefined/skills/toolsets/memes/search_memes/config.json index 6088296a..fb4e0499 100644 --- a/src/Undefined/skills/toolsets/memes/search_memes/config.json +++ b/src/Undefined/skills/toolsets/memes/search_memes/config.json @@ -2,7 +2,7 @@ "type": "function", "function": { "name": "search_memes", - "description": "在全局表情包库中按关键词和语义检索表情包,返回可直接发送或用于 的图片 uid。轻松聊天、吐槽、接梗、表达态度或情绪时,应优先使用这个工具找表情包,而不是用文字描述你本来想发的图。", + "description": "在全局表情包库中按关键词和语义检索表情包,返回可直接发送或用于 的图片 uid。轻松聊天、吐槽、接梗、表达态度或情绪时,应优先使用这个工具找表情包,而不是用文字描述你本来想发的图。", "parameters": { "type": "object", "properties": { diff --git a/src/Undefined/skills/toolsets/render/render_html/handler.py b/src/Undefined/skills/toolsets/render/render_html/handler.py index 9341fb19..24079aaf 100644 --- a/src/Undefined/skills/toolsets/render/render_html/handler.py +++ b/src/Undefined/skills/toolsets/render/render_html/handler.py @@ -78,7 +78,7 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: cleanup_cache_dir(RENDER_CACHE_DIR) if record is None: return "渲染成功,但无法注册到附件系统(缺少 attachment_registry 或 scope_key)" - return f'' + return f'' # delivery == "send" resolved_target_id, resolved_message_type, target_error = _resolve_send_target( diff --git a/src/Undefined/skills/toolsets/render/render_latex/handler.py b/src/Undefined/skills/toolsets/render/render_latex/handler.py index 5da83f9b..f799d15c 100644 --- a/src/Undefined/skills/toolsets/render/render_latex/handler.py +++ b/src/Undefined/skills/toolsets/render/render_latex/handler.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import logging import re from typing import Any, Dict @@ -89,77 +90,123 @@ def _build_html(latex_content: str) -> str: """ +def _strip_math_wrappers(content: str) -> str: + """去掉 mathtext 可直接处理的外层数学分隔符。""" + text = content.strip() + wrapper_patterns = ( + r"^\\\[(?P.*?)\\\]$", + r"^\\\((?P.*?)\\\)$", + r"^\$\$(?P.*?)\$\$$", + r"^\$(?P.*?)\$$", + r"^\\begin\{equation\*?\}(?P.*?)\\end\{equation\*?\}$", + ) + for pattern in wrapper_patterns: + match = re.fullmatch(pattern, text, re.DOTALL) + if match is not None: + return match.group("body").strip() + return text + + +def _render_mathtext_sync(content: str, output_format: str) -> tuple[bytes, str]: + """使用 matplotlib mathtext 在本地渲染常见数学公式。""" + import io + + from matplotlib import mathtext + from matplotlib.font_manager import FontProperties + + expression = _strip_math_wrappers(content) + if not expression or "\\begin{" in expression: + raise RuntimeError("内容不是 mathtext 可直接渲染的简单公式") + + font_properties = FontProperties(size=18) + buffer = io.BytesIO() + if output_format == "pdf": + mathtext.math_to_image( + f"${expression}$", + buffer, + prop=font_properties, + dpi=200, + format="pdf", + ) + return buffer.getvalue(), "application/pdf" + + mathtext.math_to_image( + f"${expression}$", + buffer, + prop=font_properties, + dpi=200, + format="png", + ) + return buffer.getvalue(), "image/png" + + +async def _render_mathtext_to_bytes( + content: str, output_format: str +) -> tuple[bytes, str]: + return await asyncio.to_thread(_render_mathtext_sync, content, output_format) + + async def _render_latex_to_bytes( content: str, output_format: str, proxy: str | None = None ) -> tuple[bytes, str]: """ - 使用 MathJax + Playwright 渲染 LaTeX 内容。 + 优先使用本地 mathtext 渲染,复杂内容再回退到 MathJax + Playwright。 返回: (渲染后的字节流, MIME 类型) """ + try: + return await _render_mathtext_to_bytes(content, output_format) + except Exception as exc: + logger.debug("本地 mathtext 渲染失败,回退到 MathJax: %s", exc) + try: from playwright.async_api import ( - async_playwright, + Page, TimeoutError as PwTimeoutError, ) + from Undefined.render import render_html_with_page except ImportError: raise ImportError( "请运行 `uv run playwright install` 安装浏览器运行时" ) from None html_content = _build_html(content) - - launch_kwargs: dict[str, object] = {"headless": True} if proxy: - launch_kwargs["proxy"] = {"server": proxy} logger.info("LaTeX 渲染使用代理: %s", proxy) - async with async_playwright() as p: - browser = await p.chromium.launch(**launch_kwargs) # type: ignore[arg-type] + async def _render_page(page: Page) -> tuple[bytes, str]: + # 等待 MathJax 完成排版(pageReady 回调设置 window._mjReady) try: - page = await browser.new_page() - await page.set_content(html_content) - - # 等待 MathJax 完成排版(pageReady 回调设置 window._mjReady) - try: - await page.wait_for_function( - "() => window._mjReady === true", - timeout=30000, - ) - except PwTimeoutError: - logger.warning("MathJax 排版超时,内容可能过于复杂或网络不可达") - raise RuntimeError( - "LaTeX 内容可能过于复杂或网络不可达(MathJax 加载超时)" - ) from None - - if output_format == "pdf": - # 获取容器尺寸 - container = await page.query_selector("#math-container") - if container is None: - raise RuntimeError("无法定位数学容器元素") - - bbox = await container.bounding_box() - if bbox is None: - raise RuntimeError("无法获取数学容器的边界框") - - # PDF 输出,设置合适的页面尺寸 - pdf_bytes = await page.pdf( - width=f"{bbox['width'] + 40}px", - height=f"{bbox['height'] + 40}px", - print_background=True, - ) - return pdf_bytes, "application/pdf" - else: - # PNG 输出 - container = await page.query_selector("#math-container") - if container is None: - raise RuntimeError("无法定位数学容器元素") - - screenshot_bytes = await container.screenshot(type="png") - return screenshot_bytes, "image/png" - - finally: - await browser.close() + await page.wait_for_function( + "() => window._mjReady === true", + timeout=30000, + ) + except PwTimeoutError: + logger.warning("MathJax 排版超时,内容可能过于复杂或网络不可达") + raise RuntimeError( + "LaTeX 内容可能过于复杂或网络不可达(MathJax 加载超时)" + ) from None + + container = await page.query_selector("#math-container") + if container is None: + raise RuntimeError("无法定位数学容器元素") + + if output_format == "pdf": + bbox = await container.bounding_box() + if bbox is None: + raise RuntimeError("无法获取数学容器的边界框") + + pdf_bytes = await page.pdf( + width=f"{bbox['width'] + 40}px", + height=f"{bbox['height'] + 40}px", + print_background=True, + ) + return pdf_bytes, "application/pdf" + + screenshot_bytes = await container.screenshot(type="png") + return screenshot_bytes, "image/png" + + return await render_html_with_page(html_content, _render_page, proxy=proxy) async def _resolve_proxy(context: Dict[str, Any]) -> str | None: @@ -223,8 +270,7 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: source_kind="rendered_latex", source_ref="render_latex", ) - tag = "pic" if output_format == "png" else "attachment" - return f'<{tag} uid="{record.uid}"/>' + return f'' except Exception as exc: logger.exception("注册渲染结果到附件系统失败: %s", exc) diff --git a/src/Undefined/skills/toolsets/render/render_markdown/handler.py b/src/Undefined/skills/toolsets/render/render_markdown/handler.py index cda52033..f2040b13 100644 --- a/src/Undefined/skills/toolsets/render/render_markdown/handler.py +++ b/src/Undefined/skills/toolsets/render/render_markdown/handler.py @@ -85,7 +85,7 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: cleanup_cache_dir(RENDER_CACHE_DIR) if record is None: return "渲染成功,但无法注册到附件系统(缺少 attachment_registry 或 scope_key)" - return f'' + return f'' # delivery == "send" resolved_target_id, resolved_message_type, target_error = _resolve_send_target( diff --git a/src/Undefined/utils/common.py b/src/Undefined/utils/common.py index 73da9cf3..0d8900e6 100644 --- a/src/Undefined/utils/common.py +++ b/src/Undefined/utils/common.py @@ -17,6 +17,7 @@ # 匹配 [@QQ号] 格式的 @ 提及(兼容 [@{QQ号}] 误加花括号的情况) AT_MENTION_PATTERN = re.compile(r"\[@\{?(\d{5,15})\}?\]") + # 标点符号和空白字符正则(用于字数统计和触发规则匹配) # 包含:空白、常见中英文标点 PUNC_PATTERN = re.compile( diff --git a/src/Undefined/utils/history.py b/src/Undefined/utils/history.py index 578711eb..76cebae2 100644 --- a/src/Undefined/utils/history.py +++ b/src/Undefined/utils/history.py @@ -30,6 +30,8 @@ def __init__(self, max_records: int = 10000) -> None: self._private_message_history: dict[str, list[dict[str, Any]]] = {} self._group_locks: dict[str, asyncio.Lock] = {} self._private_locks: dict[str, asyncio.Lock] = {} + self._pending_history_saves: dict[str, list[dict[str, Any]]] = {} + self._history_save_tasks: dict[str, asyncio.Task[None]] = {} # Lazy Load 初始化标志 self._initialized = asyncio.Event() @@ -55,6 +57,60 @@ def _get_private_lock(self, user_id: str) -> asyncio.Lock: self._private_locks[user_id] = lock return lock + def _ensure_save_state(self) -> None: + if not hasattr(self, "_pending_history_saves"): + self._pending_history_saves = {} + if not hasattr(self, "_history_save_tasks"): + self._history_save_tasks = {} + + def _snapshot_history(self, history: list[dict[str, Any]]) -> list[dict[str, Any]]: + if self._max_records > 0 and len(history) > self._max_records: + return list(history[-self._max_records :]) + return list(history) + + def _queue_history_save(self, history: list[dict[str, Any]], path: str) -> None: + self._ensure_save_state() + self._pending_history_saves[path] = self._snapshot_history(history) + task = self._history_save_tasks.get(path) + if task is None or task.done(): + self._history_save_tasks[path] = asyncio.create_task( + self._drain_history_save(path), + name=f"history_save:{path}", + ) + + async def _drain_history_save(self, path: str) -> None: + failed = False + try: + while True: + self._ensure_save_state() + history = self._pending_history_saves.pop(path, None) + if history is None: + break + try: + await self._save_history_to_file(history, path) + except Exception: + logger.exception("[历史记录错误] 保存历史失败: path=%s", path) + self._pending_history_saves.setdefault(path, history) + failed = True + break + finally: + self._ensure_save_state() + current_task = asyncio.current_task() + if self._history_save_tasks.get(path) is current_task: + self._history_save_tasks.pop(path, None) + if not failed and path in self._pending_history_saves: + self._history_save_tasks[path] = asyncio.create_task( + self._drain_history_save(path), + name=f"history_save:{path}", + ) + + async def flush_pending_saves(self) -> None: + self._ensure_save_state() + while self._history_save_tasks: + tasks = list(self._history_save_tasks.values()) + await asyncio.gather(*tasks, return_exceptions=True) + self._ensure_save_state() + async def _lazy_init(self) -> None: """后台异步加载所有历史记录""" try: @@ -105,6 +161,7 @@ async def _save_history_to_file( logger.debug(f"[历史记录] 保存成功: path={path}") except Exception as e: logger.error(f"[历史记录错误] 保存历史记录失败 {path}: {e}") + raise async def _load_history_from_file(self, path: str) -> list[dict[str, Any]]: """异步从文件加载历史记录""" @@ -358,7 +415,7 @@ async def add_group_message( group_id_str ][-self._max_records :] - await self._save_history_to_file( + self._queue_history_save( self._message_history[group_id_str], self._get_group_history_path(group_id), ) @@ -410,7 +467,7 @@ async def add_private_message( self._private_message_history[user_id_str][-self._max_records :] ) - await self._save_history_to_file( + self._queue_history_save( self._private_message_history[user_id_str], self._get_private_history_path(user_id), ) @@ -486,8 +543,8 @@ async def modify_last_group_message( f"old_len={old_length}, new_len={new_length}" ) - # 原子保存 - await self._save_history_to_file( + # 后台合并保存,避免安全检测路径阻塞在全量落盘上。 + self._queue_history_save( self._message_history[group_id_str], self._get_group_history_path(group_id), ) @@ -523,8 +580,8 @@ async def modify_last_private_message( f"old_len={old_length}, new_len={new_length}" ) - # 原子保存 - await self._save_history_to_file( + # 后台合并保存,避免安全检测路径阻塞在全量落盘上。 + self._queue_history_save( self._private_message_history[user_id_str], self._get_private_history_path(user_id), ) diff --git a/src/Undefined/utils/sender.py b/src/Undefined/utils/sender.py index 63670b1c..3922532e 100644 --- a/src/Undefined/utils/sender.py +++ b/src/Undefined/utils/sender.py @@ -2,8 +2,10 @@ import logging from pathlib import Path -from typing import Any +from typing import Any, Literal +from urllib.parse import unquote, urlsplit +from Undefined.attachments import attachment_refs_to_text, build_attachment_scope from Undefined.config import Config from Undefined.onebot import OneBotClient from Undefined.utils.history import MessageHistoryManager @@ -52,6 +54,53 @@ def _build_file_history_message(file_name: str, size_bytes: int | None) -> str: return f"[文件] {file_name} ({_format_size(size_bytes)})" +def _append_attachment_refs( + history_content: str, + attachments: list[dict[str, str]] | None, +) -> str: + refs_text = attachment_refs_to_text(attachments or []) + if not refs_text or refs_text in history_content: + return history_content + if not history_content: + return refs_text + return f"{history_content}\n{refs_text}" + + +def _merge_attachment_refs( + *groups: list[dict[str, str]] | None, +) -> list[dict[str, str]]: + merged: list[dict[str, str]] = [] + seen_uids: set[str] = set() + for group in groups: + for item in group or []: + uid = str(item.get("uid", "") or "").strip() + if uid and uid in seen_uids: + continue + if uid: + seen_uids.add(uid) + merged.append(item) + return merged + + +def _local_path_from_segment_source(source: Any) -> Path | None: + raw_source = str(source or "").strip() + if not raw_source: + return None + lowered = raw_source.lower() + if lowered.startswith(("http://", "https://", "base64://")): + return None + if lowered.startswith("file://"): + parsed = urlsplit(raw_source) + path = Path(unquote(parsed.path)).expanduser() + else: + path = Path(raw_source).expanduser() + if not path.is_absolute(): + path = (Path.cwd() / path).resolve() + else: + path = path.resolve() + return path if path.is_file() else None + + def _get_file_size(file_path: str) -> int | None: try: return Path(file_path).stat().st_size @@ -68,11 +117,98 @@ def __init__( history_manager: MessageHistoryManager, bot_qq: int, config: Config, + attachment_registry: Any | None = None, ): self.onebot = onebot self.history_manager = history_manager self.bot_qq = bot_qq self.config = config + self.attachment_registry = attachment_registry + + async def register_sent_file_attachment( + self, + target_type: Literal["group", "private"], + target_id: int, + file_path: str, + name: str | None = None, + *, + kind: str = "file", + source_kind: str = "sent_file", + source_ref: str = "", + ) -> list[dict[str, str]]: + """将发送出的本地文件登记为当前会话可见的统一附件 UID。""" + registry = self.attachment_registry + if registry is None: + return [] + + scope_key = build_attachment_scope( + group_id=target_id if target_type == "group" else None, + user_id=target_id if target_type == "private" else None, + request_type=target_type, + ) + if scope_key is None: + return [] + + file_name = name or Path(file_path).name + try: + record = await registry.register_local_file( + scope_key, + file_path, + kind=kind, + display_name=file_name, + source_kind=source_kind, + source_ref=source_ref or str(Path(file_path).resolve()), + ) + return [record.prompt_ref()] + except Exception: + logger.exception( + "[附件登记] 发送文件登记失败: target=%s:%s file=%s", + target_type, + target_id, + file_name, + ) + return [] + + async def _register_local_segment_attachments( + self, + target_type: Literal["group", "private"], + target_id: int, + segments: list[dict[str, Any]], + ) -> list[dict[str, str]]: + kind_by_segment_type = { + "image": "image", + "video": "video", + "record": "record", + } + attachments: list[dict[str, str]] = [] + seen_paths: set[str] = set() + for segment in segments: + segment_type = str(segment.get("type", "") or "").strip().lower() + kind = kind_by_segment_type.get(segment_type) + if kind is None: + continue + data = segment.get("data") + if not isinstance(data, dict): + continue + path = _local_path_from_segment_source(data.get("file")) + if path is None: + continue + path_text = str(path) + if path_text in seen_paths: + continue + seen_paths.add(path_text) + attachments.extend( + await self.register_sent_file_attachment( + target_type, + target_id, + path_text, + path.name, + kind=kind, + source_kind=f"sent_{segment_type}", + source_ref=str(data.get("file", "") or path_text), + ) + ) + return attachments async def send_group_message( self, @@ -107,25 +243,28 @@ async def send_group_message( # 将 [@{qq_id}] 格式转换为 [CQ:at,qq={qq_id}] message = process_at_mentions(message) + segments = message_to_segments(message) + # 准备历史记录文本(不含 reply 段) history_content: str | None = None if auto_history: if history_message is not None: history_content = history_message else: - hist_segments = message_to_segments(message) - history_content = extract_text(hist_segments, self.bot_qq) + history_content = extract_text(segments, self.bot_qq) if history_prefix: history_content = f"{history_prefix}{history_content}" # 发送消息 bot_message_id: int | None = None if len(message) <= MAX_MESSAGE_LENGTH: - segments = message_to_segments(message) + send_segments = list(segments) if reply_to is not None: - segments.insert(0, {"type": "reply", "data": {"id": str(reply_to)}}) + send_segments.insert( + 0, {"type": "reply", "data": {"id": str(reply_to)}} + ) result = await self.onebot.send_group_message( - group_id, segments, mark_sent=mark_sent + group_id, send_segments, mark_sent=mark_sent ) bot_message_id = _extract_message_id(result) else: @@ -135,6 +274,18 @@ async def send_group_message( # 发送成功后写入历史记录 if auto_history and history_content is not None: + history_attachments = _merge_attachment_refs( + attachments, + await self._register_local_segment_attachments( + "group", + group_id, + segments, + ), + ) + history_content = _append_attachment_refs( + history_content, + history_attachments, + ) logger.debug(f"[历史记录] 正在保存 Bot 群聊回复: group={group_id}") await self.history_manager.add_group_message( group_id=group_id, @@ -143,7 +294,7 @@ async def send_group_message( sender_nickname="Bot", group_name="", message_id=bot_message_id, - attachments=attachments, + attachments=history_attachments, ) return bot_message_id @@ -230,24 +381,27 @@ async def send_private_message( safe_message = redact_string(message) logger.info(f"[发送消息] 目标用户:{user_id} | 内容摘要:{safe_message[:100]}...") + segments = message_to_segments(message) + # 准备历史记录文本 history_content: str | None = None if auto_history: if history_message is not None: history_content = history_message else: - hist_segments = message_to_segments(message) - history_content = extract_text(hist_segments, self.bot_qq) + history_content = extract_text(segments, self.bot_qq) # 发送消息 bot_message_id: int | None = None if len(message) <= MAX_MESSAGE_LENGTH: - segments = message_to_segments(message) + send_segments = list(segments) if reply_to is not None: - segments.insert(0, {"type": "reply", "data": {"id": str(reply_to)}}) + send_segments.insert( + 0, {"type": "reply", "data": {"id": str(reply_to)}} + ) result, _ = await self._send_private_segments( user_id, - segments, + send_segments, mark_sent=mark_sent, preferred_temp_group_id=preferred_temp_group_id, ) @@ -263,6 +417,18 @@ async def send_private_message( # 发送成功后写入历史记录 if auto_history and history_content is not None: + history_attachments = _merge_attachment_refs( + attachments, + await self._register_local_segment_attachments( + "private", + user_id, + segments, + ), + ) + history_content = _append_attachment_refs( + history_content, + history_attachments, + ) logger.debug(f"[历史记录] 正在保存 Bot 私聊回复: user={user_id}") await self.history_manager.add_private_message( user_id=user_id, @@ -270,10 +436,51 @@ async def send_private_message( display_name="Bot", user_name="Bot", message_id=bot_message_id, - attachments=attachments, + attachments=history_attachments, ) return bot_message_id + async def send_group_forward_message( + self, + group_id: int, + messages: list[dict[str, Any]], + *, + history_message: str, + auto_history: bool = True, + ) -> None: + """发送群合并转发,并将可读摘要写入历史。""" + if not self.config.is_group_allowed(group_id): + enabled = self.config.access_control_enabled() + reason = self.config.group_access_denied_reason(group_id) or "unknown" + logger.warning( + "[访问控制] 已拦截群合并转发: group=%s reason=%s (access enabled=%s)", + group_id, + reason, + enabled, + ) + raise PermissionError( + "blocked by access control: " + f"type=group reason={reason} group_id={int(group_id)} enabled={enabled}" + ) + + logger.info("[发送合并转发] 目标群:%s | 节点数:%s", group_id, len(messages)) + await self.onebot.send_forward_msg(group_id, messages) + + text_content = str(history_message or "").strip() + if not auto_history or not text_content: + return + + try: + await self.history_manager.add_group_message( + group_id=group_id, + sender_id=self.bot_qq, + text_content=text_content, + sender_nickname="Bot", + group_name="", + ) + except Exception: + logger.exception("[历史记录] 记录群合并转发失败: group=%s", group_id) + async def _send_private_segments( self, user_id: int, @@ -540,6 +747,13 @@ async def send_group_file( file_size = _get_file_size(file_path) history_content = _build_file_history_message(file_name, file_size) + attachments = await self.register_sent_file_attachment( + "group", + group_id, + file_path, + file_name, + ) + history_content = _append_attachment_refs(history_content, attachments) try: await self.history_manager.add_group_message( group_id=group_id, @@ -547,6 +761,7 @@ async def send_group_file( text_content=history_content, sender_nickname="Bot", group_name="", + attachments=attachments, ) except Exception: logger.exception( @@ -587,12 +802,20 @@ async def send_private_file( file_size = _get_file_size(file_path) history_content = _build_file_history_message(file_name, file_size) + attachments = await self.register_sent_file_attachment( + "private", + user_id, + file_path, + file_name, + ) + history_content = _append_attachment_refs(history_content, attachments) try: await self.history_manager.add_private_message( user_id=user_id, text_content=history_content, display_name="Bot", user_name="Bot", + attachments=attachments, ) except Exception: logger.exception( diff --git a/tests/test_ai_client_summary_model.py b/tests/test_ai_client_summary_model.py new file mode 100644 index 00000000..6cad3451 --- /dev/null +++ b/tests/test_ai_client_summary_model.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any, cast + +from Undefined.ai.client import AIClient, _resolve_summary_model_config +from Undefined.config import AgentModelConfig, ChatModelConfig, Config + + +def _chat_config(model_name: str = "chat-model") -> ChatModelConfig: + return ChatModelConfig( + api_url="https://api.example.com/v1", + api_key="key", + model_name=model_name, + max_tokens=4096, + ) + + +def _summary_config(model_name: str = "summary-model") -> AgentModelConfig: + return AgentModelConfig( + api_url="https://api.example.com/v1", + api_key="key", + model_name=model_name, + max_tokens=2048, + ) + + +def test_resolve_summary_model_uses_chat_when_not_configured() -> None: + chat_config = _chat_config() + runtime_config = cast( + Config, + SimpleNamespace( + summary_model_configured=False, + summary_model=_summary_config(), + ), + ) + + assert _resolve_summary_model_config(runtime_config, chat_config) is chat_config + + +def test_resolve_summary_model_uses_dedicated_summary_when_configured() -> None: + chat_config = _chat_config() + summary_config = _summary_config() + runtime_config = cast( + Config, + SimpleNamespace( + summary_model_configured=True, + summary_model=summary_config, + ), + ) + + assert _resolve_summary_model_config(runtime_config, chat_config) is summary_config + + +def test_apply_runtime_config_rebuilds_summary_service_for_summary_model() -> None: + chat_config = _chat_config() + old_summary_config = _summary_config("summary-old") + new_summary_config = _summary_config("summary-new") + old_runtime_config = cast( + Config, + SimpleNamespace( + summary_model_configured=True, + summary_model=old_summary_config, + ), + ) + new_runtime_config = cast( + Config, + SimpleNamespace( + summary_model_configured=True, + summary_model=new_summary_config, + ), + ) + + ai_client = cast(Any, AIClient.__new__(AIClient)) + ai_client.chat_config = chat_config + ai_client.runtime_config = old_runtime_config + ai_client._requester = object() + ai_client._token_counter = object() + ai_client._rebuild_summary_service() + old_summary_service = ai_client._summary_service + + ai_client.apply_runtime_config(new_runtime_config) + + assert ai_client._summary_service is not old_summary_service + assert ai_client._summary_service._chat_config is new_summary_config diff --git a/tests/test_ai_draw_one_handler.py b/tests/test_ai_draw_one_handler.py index 49f76634..168c3e8e 100644 --- a/tests/test_ai_draw_one_handler.py +++ b/tests/test_ai_draw_one_handler.py @@ -384,7 +384,7 @@ async def _fake_request_with_retry(*_args: Any, **_kwargs: Any) -> _FakeResponse }, ) - assert result.startswith('已生成图片,可在回复中插入 None: assert 'description="无语猫猫表情包"' in xml +def test_attachment_refs_to_xml_includes_url_reference_source() -> None: + xml = attachment_refs_to_xml( + [ + { + "uid": "file_remote01", + "kind": "file", + "media_type": "file", + "display_name": "big.zip", + "source_kind": "remote_file_reference", + "source_ref": "https://example.com/big.zip", + } + ] + ) + + assert 'source_ref="https://example.com/big.zip"' in xml + + +@pytest.mark.asyncio +async def test_remote_attachment_above_limit_keeps_url_reference( + tmp_path: Path, +) -> None: + async def _handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + headers={ + "content-length": "4096", + "content-type": "application/zip", + }, + content=b"", + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(_handler)) as client: + registry = AttachmentRegistry( + registry_path=tmp_path / "attachment_registry.json", + cache_dir=tmp_path / "attachments", + http_client=client, + remote_download_max_bytes=1024, + ) + + record = await registry.register_remote_url( + "group:10001", + "https://example.com/big.zip", + kind="file", + display_name="big.zip", + source_kind="remote_file", + ) + + assert record.uid.startswith("file_") + assert record.local_path is None + assert record.source_kind == "remote_file_reference" + assert record.source_ref == "https://example.com/big.zip" + assert record.mime_type == "application/zip" + assert "超过下载上限" in record.description + assert record.prompt_ref()["source_ref"] == "https://example.com/big.zip" + + reloaded = AttachmentRegistry( + registry_path=tmp_path / "attachment_registry.json", + cache_dir=tmp_path / "attachments", + ) + await reloaded.load() + resolved = await reloaded.resolve_async(record.uid, "group:10001") + + assert resolved is not None + assert resolved.local_path is None + assert resolved.source_ref == "https://example.com/big.zip" + + +@pytest.mark.asyncio +async def test_remote_attachment_zero_limit_does_not_request_url( + tmp_path: Path, +) -> None: + requests = 0 + + async def _handler(_request: httpx.Request) -> httpx.Response: + nonlocal requests + requests += 1 + return httpx.Response(200, content=b"unexpected") + + async with httpx.AsyncClient(transport=httpx.MockTransport(_handler)) as client: + registry = AttachmentRegistry( + registry_path=tmp_path / "attachment_registry.json", + cache_dir=tmp_path / "attachments", + http_client=client, + remote_download_max_bytes=0, + ) + + record = await registry.register_remote_url( + "group:10001", + "https://example.com/remote.bin", + kind="file", + display_name="remote.bin", + ) + + assert requests == 0 + assert record.local_path is None + assert record.source_ref == "https://example.com/remote.bin" + assert "remote_download_max_size_mb=0" in record.description + + +@pytest.mark.asyncio +async def test_remote_reference_uses_url_not_provenance_source_ref( + tmp_path: Path, +) -> None: + registry = AttachmentRegistry( + registry_path=tmp_path / "attachment_registry.json", + cache_dir=tmp_path / "attachments", + remote_download_max_bytes=0, + ) + + record = await registry.register_remote_url( + "group:10001", + "https://example.com/avatar.jpg", + kind="image", + display_name="avatar.jpg", + source_kind="get_avatar", + source_ref="qq:12345", + ) + rendered = await render_message_with_pic_placeholders( + f'', + registry=registry, + scope_key="group:10001", + strict=True, + ) + + assert record.local_path is None + assert record.source_ref == "https://example.com/avatar.jpg" + assert record.segment_data["original_source_ref"] == "qq:12345" + assert "file=https://example.com/avatar.jpg" in rendered.delivery_text + assert "original_source_ref" not in rendered.delivery_text + + +@pytest.mark.asyncio +async def test_register_message_attachments_remote_reference_keeps_resolved_url( + tmp_path: Path, +) -> None: + registry = AttachmentRegistry( + registry_path=tmp_path / "attachment_registry.json", + cache_dir=tmp_path / "attachments", + remote_download_max_bytes=0, + ) + + async def _resolve_image_url(_file_id: str) -> str: + return "https://example.com/onebot-image.jpg" + + result = await register_message_attachments( + registry=registry, + segments=[{"type": "image", "data": {"file": "onebot-file-id"}}], + scope_key="group:10001", + resolve_image_url=_resolve_image_url, + ) + uid = result.attachments[0]["uid"] + record = registry.resolve(uid, "group:10001") + assert record is not None + + rendered = await render_message_with_pic_placeholders( + f'', + registry=registry, + scope_key="group:10001", + strict=True, + ) + + assert record.source_ref == "https://example.com/onebot-image.jpg" + assert record.segment_data["original_source_ref"] == "onebot-file-id" + assert "file=https://example.com/onebot-image.jpg" in rendered.delivery_text + assert "original_source_ref" not in rendered.delivery_text + + +@pytest.mark.asyncio +async def test_remote_attachment_stream_over_limit_keeps_url_reference( + tmp_path: Path, +) -> None: + async def _handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + headers={"content-type": "application/octet-stream"}, + content=b"x" * 2048, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(_handler)) as client: + registry = AttachmentRegistry( + registry_path=tmp_path / "attachment_registry.json", + cache_dir=tmp_path / "attachments", + http_client=client, + remote_download_max_bytes=1024, + ) + + record = await registry.register_remote_url( + "group:10001", + "https://example.com/stream.bin", + kind="file", + display_name="stream.bin", + ) + + assert record.local_path is None + assert record.source_ref == "https://example.com/stream.bin" + + +@pytest.mark.asyncio +async def test_remote_attachment_under_limit_is_cached(tmp_path: Path) -> None: + payload = b"small file" + + async def _handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + headers={ + "content-length": str(len(payload)), + "content-type": "text/plain", + }, + content=payload, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(_handler)) as client: + registry = AttachmentRegistry( + registry_path=tmp_path / "attachment_registry.json", + cache_dir=tmp_path / "attachments", + http_client=client, + remote_download_max_bytes=1024, + ) + + record = await registry.register_remote_url( + "group:10001", + "https://example.com/small.txt", + kind="file", + display_name="small.txt", + source_kind="remote_file", + ) + + assert record.local_path is not None + assert Path(record.local_path).read_bytes() == payload + assert record.source_kind == "remote_file" + + @pytest.mark.asyncio async def test_register_message_attachments_normalizes_webui_base64_image( tmp_path: Path, diff --git a/tests/test_auto_pipeline_registry.py b/tests/test_auto_pipeline_registry.py new file mode 100644 index 00000000..13ee1dd6 --- /dev/null +++ b/tests/test_auto_pipeline_registry.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path +from typing import Any + +import pytest + +from Undefined.skills.auto_pipeline import AutoPipelineRegistry + + +def _write_pipeline(base_dir: Path) -> None: + item_dir = base_dir / "example" + item_dir.mkdir(parents=True) + (item_dir / "config.json").write_text( + """ +{ + "name": "example", + "description": "测试管线", + "order": 10, + "enabled": true +} +""".strip(), + encoding="utf-8", + ) + (item_dir / "handler.py").write_text( + """ +from __future__ import annotations + +from Undefined.skills.auto_pipeline.models import AutoPipelineDetection + + +async def detect(context): + context["events"].append("detect") + return AutoPipelineDetection(name="example", items=("item",)) + + +async def process(detection, context): + context["events"].append(f"process:{detection.items[0]}") +""".strip(), + encoding="utf-8", + ) + + +@pytest.mark.asyncio +async def test_auto_pipeline_registry_loads_and_runs_configured_pipeline( + tmp_path: Path, +) -> None: + _write_pipeline(tmp_path) + registry = AutoPipelineRegistry(tmp_path) + registry.load_items() + context: dict[str, Any] = {"events": []} + + detections = await registry.run(context) + + assert [detection.name for detection in detections] == ["example"] + assert context["events"] == ["detect", "process:item"] + + +@pytest.mark.asyncio +async def test_auto_pipeline_registry_initial_async_load_uses_thread( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + registry = AutoPipelineRegistry(tmp_path) + calls: list[Any] = [] + + async def _fake_to_thread(func: Any, *args: Any, **kwargs: Any) -> Any: + calls.append(func) + return func(*args, **kwargs) + + monkeypatch.setattr(asyncio, "to_thread", _fake_to_thread) + monkeypatch.setattr(registry, "_load_items_sync", lambda: {}) + + await registry.load_items_async() + + assert calls == [registry._load_items_sync] + assert registry._items == {} + + +@pytest.mark.asyncio +async def test_auto_pipeline_reload_loads_items_in_thread( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + registry = AutoPipelineRegistry(tmp_path) + calls: list[Any] = [] + + async def _fake_to_thread(func: Any, *args: Any, **kwargs: Any) -> Any: + calls.append(func) + return func(*args, **kwargs) + + monkeypatch.setattr(asyncio, "to_thread", _fake_to_thread) + monkeypatch.setattr(registry, "_load_items_sync", lambda: {}) + + await registry._reload_items() + + assert calls == [registry._load_items_sync] + assert registry._items == {} diff --git a/tests/test_bilibili_sender.py b/tests/test_bilibili_sender.py new file mode 100644 index 00000000..bbbb0d12 --- /dev/null +++ b/tests/test_bilibili_sender.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace +from typing import Any, cast +from unittest.mock import AsyncMock, MagicMock + +import pytest + +import Undefined.bilibili.sender as bilibili_sender + + +def _video_info() -> Any: + return SimpleNamespace( + title="测试视频", + up_name="测试 UP", + desc="视频简介", + cover_url="", + bvid="BV1xx411c7mD", + duration=120, + ) + + +@pytest.mark.asyncio +async def test_send_bilibili_video_records_history_for_video_message( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + video_path = tmp_path / "video.mp4" + video_path.write_bytes(b"video") + sender: Any = SimpleNamespace( + send_group_message=AsyncMock(), + send_private_message=AsyncMock(), + ) + + monkeypatch.setattr( + bilibili_sender, + "normalize_to_bvid", + AsyncMock(return_value="BV1xx411c7mD"), + ) + monkeypatch.setattr( + bilibili_sender, + "download_video", + AsyncMock(return_value=(video_path, _video_info(), 80)), + ) + cleanup_mock = MagicMock() + monkeypatch.setattr(bilibili_sender, "cleanup_file", cleanup_mock) + + result = await bilibili_sender.send_bilibili_video( + video_id="BV1xx411c7mD", + sender=sender, + onebot=cast(Any, SimpleNamespace()), + target_type="group", + target_id=123456, + max_file_size=100, + ) + + assert "已发送视频" in result + assert sender.send_group_message.await_count == 2 + video_call = sender.send_group_message.await_args_list[1] + assert video_call.args[1].startswith("[CQ:video,file=file://") + history_message = video_call.kwargs["history_message"] + assert history_message.startswith("[视频] 「测试视频」") + assert "BV1xx411c7mD" in history_message + cleanup_mock.assert_called_once_with(video_path) diff --git a/tests/test_changelog_command.py b/tests/test_changelog_command.py index 4b2b57e7..8279da58 100644 --- a/tests/test_changelog_command.py +++ b/tests/test_changelog_command.py @@ -36,6 +36,21 @@ async def send_private_message( self.private_messages.append((user_id, message, mark_sent)) +class _ForwardSender(_DummySender): + def __init__(self) -> None: + super().__init__() + self.forward_messages: list[tuple[int, list[dict[str, Any]], str]] = [] + + async def send_group_forward_message( + self, + group_id: int, + messages: list[dict[str, Any]], + *, + history_message: str, + ) -> None: + self.forward_messages.append((group_id, messages, history_message)) + + def _build_context( sender: _DummySender, *, @@ -184,6 +199,30 @@ async def test_changelog_command_large_list_uses_forward_in_group( assert "1. v3.2.0 | 标题0" in nodes[1]["data"]["content"] +@pytest.mark.asyncio +async def test_changelog_large_list_uses_sender_history_layer( + monkeypatch: pytest.MonkeyPatch, +) -> None: + sender = _ForwardSender() + onebot = cast(Any, SimpleNamespace(send_forward_msg=AsyncMock())) + context = _build_context(sender, onebot=onebot) + monkeypatch.setattr( + changelog_handler, + "list_entries", + lambda *, limit: tuple(_entry(f"v3.2.{idx}", f"标题{idx}") for idx in range(6)), + ) + + await changelog_handler.execute(["list", "25"], context) + + assert not sender.messages + assert len(sender.forward_messages) == 1 + group_id, nodes, history_message = sender.forward_messages[0] + assert group_id == 10001 + assert nodes[0]["data"]["content"].startswith("Undefined CHANGELOG") + assert "- v3.2.0 | 标题0" in history_message + onebot.send_forward_msg.assert_not_awaited() + + @pytest.mark.asyncio async def test_changelog_command_large_list_uses_private_sender_in_private_scope( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_command_help_reload.py b/tests/test_command_help_reload.py index dcd094de..2cf46cb6 100644 --- a/tests/test_command_help_reload.py +++ b/tests/test_command_help_reload.py @@ -24,6 +24,21 @@ async def send_group_message( self.messages.append((group_id, message, mark_sent)) +class _RecordingCommandRateLimiter: + def __init__(self) -> None: + self.checks: list[str] = [] + self.records: list[str] = [] + + def check_command( + self, _user_id: int, command_name: str, _limits: Any + ) -> tuple[bool, int]: + self.checks.append(command_name) + return True, 0 + + def record_command(self, _user_id: int, command_name: str, _limits: Any) -> None: + self.records.append(command_name) + + def _build_context(registry: CommandRegistry, sender: _DummySender) -> CommandContext: stub = cast(Any, SimpleNamespace()) return CommandContext( @@ -177,6 +192,38 @@ def test_command_registry_hot_reload_policy_update(tmp_path: Path) -> None: assert registry.is_visible(updated_meta, context) is True +def test_subcommand_rate_limit_partially_overrides_parent(tmp_path: Path) -> None: + commands_dir = tmp_path / "commands" + commands_dir.mkdir(parents=True) + command_dir = _write_command( + commands_dir, + "limited", + command_name="limited", + usage="/limited", + handler_text="ok", + ) + config_path = command_dir / "config.json" + config = json.loads(config_path.read_text("utf-8")) + config["rate_limit"] = {"user": 60, "admin": 5, "superadmin": 0} + config["subcommands"] = { + "fast": { + "description": "fast subcommand", + "rate_limit": {"user": 30}, + } + } + config_path.write_text(json.dumps(config, ensure_ascii=False, indent=2), "utf-8") + + registry = CommandRegistry(commands_dir) + registry.load_commands() + + meta = registry.resolve("limited") + assert meta is not None + sub_meta = meta.subcommands["fast"] + assert sub_meta.rate_limit.user == 30 + assert sub_meta.rate_limit.admin == 5 + assert sub_meta.rate_limit.superadmin == 0 + + @pytest.mark.asyncio async def test_help_command_detail_includes_template_and_readme(tmp_path: Path) -> None: commands_dir = tmp_path / "commands" @@ -198,18 +245,122 @@ async def test_help_command_detail_includes_template_and_readme(tmp_path: Path) sender = _DummySender() context = _build_context(registry, sender) - await help_execute(["foo"], context) + await help_execute(["foo", "-t"], context) output = sender.messages[-1][1] - assert "命令详情:/foo" in output - assert "描述:Foo 命令描述" in output - assert "用法:/foo " in output + assert "/foo(/f)" in output + assert "Foo 命令描述" in output + assert "用法:/foo(/f) " in output assert "示例:/foo alice" in output - assert "作用域:仅群聊" in output + assert "权限:公开 | 作用域:仅群聊" in output assert "别名:/f" in output assert "说明文档:" in output assert "这是 Foo 的详细说明。" in output +@pytest.mark.asyncio +async def test_help_command_detail_defaults_to_rendered_image( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + commands_dir = tmp_path / "commands" + commands_dir.mkdir(parents=True) + _write_command( + commands_dir, + "foo", + command_name="foo", + description="Foo 命令描述", + usage="/foo ", + example="/foo alice", + aliases=["f"], + handler_text="ok", + doc_text="# Foo 文档\n\n这是 Foo 的详细说明。\n\n- 第一项\n- 第二项", + ) + + rendered: dict[str, Any] = {} + + async def fake_render_html_to_image( + html_content: str, + output_path: str, + *, + viewport_width: int = 1280, + ) -> None: + rendered["html"] = html_content + rendered["output_path"] = output_path + rendered["viewport_width"] = viewport_width + + monkeypatch.setattr( + "Undefined.render.render_html_to_image", + fake_render_html_to_image, + ) + + registry = CommandRegistry(commands_dir) + registry.load_commands() + sender = _DummySender() + context = _build_context(registry, sender) + + await help_execute(["foo"], context) + + assert sender.messages + output = sender.messages[-1][1] + assert output.startswith("[CQ:image,file=file://") + assert rendered["viewport_width"] == 760 + assert "Foo 命令描述" in rendered["html"] + assert "/foo(/f)" in rendered["html"] + assert "说明文档" in rendered["html"] + assert '
    第一项" in rendered["html"] + assert "这是 Foo 的详细说明。" in rendered["html"] + + +@pytest.mark.asyncio +async def test_help_list_defaults_to_rendered_image( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + commands_dir = tmp_path / "commands" + commands_dir.mkdir(parents=True) + _write_command( + commands_dir, + "open", + command_name="open", + usage="/open", + allow_in_private=True, + handler_text="ok", + ) + + rendered: dict[str, Any] = {} + + async def fake_render_html_to_image( + html_content: str, + output_path: str, + *, + viewport_width: int = 1280, + ) -> None: + rendered["html"] = html_content + rendered["output_path"] = output_path + rendered["viewport_width"] = viewport_width + + monkeypatch.setattr( + "Undefined.render.render_html_to_image", + fake_render_html_to_image, + ) + + registry = CommandRegistry(commands_dir) + registry.load_commands() + sender = _DummySender() + context = _build_context(registry, sender) + + await help_execute([], context) + + assert sender.messages + assert sender.messages[-1][1].startswith("[CQ:image,file=file://") + assert rendered["viewport_width"] == 760 + assert "可用命令" in rendered["html"] + assert "/open" in rendered["html"] + assert "当前会话可见的斜杠命令速查表" in rendered["html"] + + @pytest.mark.asyncio async def test_help_list_filters_private_only_commands_in_private_scope( tmp_path: Path, @@ -252,10 +403,10 @@ async def test_help_list_filters_private_only_commands_in_private_scope( registry=registry, ) - await help_execute([], private_context) + await help_execute(["-t"], private_context) output = sender.messages[-1][1] - assert "当前会话:私聊" in output - assert "/open(群聊/私聊)" in output + assert "会话:私聊" in output + assert "/open" in output assert "/grouponly" not in output @@ -328,15 +479,15 @@ async def test_help_uses_command_visibility_policy(tmp_path: Path) -> None: sender = _DummySender() context = _build_context(registry, sender) - await help_execute([], context) + await help_execute(["-t"], context) output = sender.messages[-1][1] assert "/visible" in output assert "/gated" not in output sender.messages.clear() context.sender_id = 42 - await help_execute(["gated"], context) - assert "命令详情:/gated" in sender.messages[-1][1] + await help_execute(["gated", "-t"], context) + assert "/gated" in sender.messages[-1][1] @pytest.mark.asyncio @@ -358,3 +509,48 @@ async def test_dispatch_rejects_help_flag_with_new_help_style() -> None: assert sender.messages assert "参数 --help 已弃用" in sender.messages[-1][1] assert "/help stats" in sender.messages[-1][1] + + +@pytest.mark.asyncio +async def test_dispatch_rate_limits_subcommands_independently(tmp_path: Path) -> None: + commands_dir = tmp_path / "commands" + commands_dir.mkdir(parents=True) + command_dir = _write_command( + commands_dir, + "limited", + command_name="limited", + usage="/limited ", + handler_text="ok", + ) + config_path = command_dir / "config.json" + config = json.loads(config_path.read_text("utf-8")) + config["rate_limit"] = {"user": 60, "admin": 0, "superadmin": 0} + config["subcommands"] = { + "alpha": {"description": "alpha subcommand"}, + "beta": {"description": "beta subcommand"}, + } + config_path.write_text(json.dumps(config, ensure_ascii=False, indent=2), "utf-8") + + registry = CommandRegistry(commands_dir) + registry.load_commands() + + sender = _DummySender() + rate_limiter = _RecordingCommandRateLimiter() + config_obj = cast(Any, SimpleNamespace()) + security = cast(Any, SimpleNamespace(rate_limiter=None)) + dispatcher = CommandDispatcher( + config=config_obj, + sender=cast(Any, sender), + ai=cast(Any, SimpleNamespace()), + faq_storage=cast(Any, SimpleNamespace()), + onebot=cast(Any, SimpleNamespace()), + security=security, + rate_limiter=rate_limiter, + ) + dispatcher.command_registry = registry + + await dispatcher.dispatch(12345, 67890, {"name": "limited", "args": ["alpha"]}) + await dispatcher.dispatch(12345, 67890, {"name": "limited", "args": ["beta"]}) + + assert rate_limiter.checks == ["limited:alpha", "limited:beta"] + assert rate_limiter.records == ["limited:alpha", "limited:beta"] diff --git a/tests/test_command_qq_arg.py b/tests/test_command_qq_arg.py new file mode 100644 index 00000000..658ef48a --- /dev/null +++ b/tests/test_command_qq_arg.py @@ -0,0 +1,87 @@ +"""测试命令解析层对 @ 提及形式 QQ 号参数的自动归一化。""" + +from __future__ import annotations + +from Undefined.services.command import ( + CommandDispatcher, + _normalize_qq_arg, + _split_command_args, +) + + +def _dispatcher() -> CommandDispatcher: + return object.__new__(CommandDispatcher) + + +# --------------------------------------------------------------------------- +# _normalize_qq_arg +# --------------------------------------------------------------------------- + + +def test_normalize_plain_digits() -> None: + assert _normalize_qq_arg("1708213363") == "1708213363" + + +def test_normalize_at_tag_without_name() -> None: + assert _normalize_qq_arg("[@1708213363]") == "1708213363" + + +def test_normalize_at_tag_with_name() -> None: + assert _normalize_qq_arg("[@1708213363(Null)]") == "1708213363" + + +def test_normalize_at_tag_with_brace() -> None: + assert _normalize_qq_arg("[@{1708213363}]") == "1708213363" + + +def test_normalize_passthrough_non_qq() -> None: + assert _normalize_qq_arg("g") == "g" + assert _normalize_qq_arg("--ai") == "--ai" + assert _normalize_qq_arg("2024/12/01/09:00") == "2024/12/01/09:00" + assert _normalize_qq_arg("") == "" + + +def test_split_command_args_keeps_at_name_with_spaces() -> None: + assert _split_command_args("g [@1708213363(Null User)] -r") == [ + "g", + "[@1708213363(Null User)]", + "-r", + ] + + +# --------------------------------------------------------------------------- +# parse_command +# --------------------------------------------------------------------------- + + +def test_parse_command_strips_leading_bot_at() -> None: + d = _dispatcher() + cmd = d.parse_command("[@123456(Bot)] /addadmin 7777777") + assert cmd == {"name": "addadmin", "args": ["7777777"]} + + +def test_parse_command_keeps_inline_at_normalized() -> None: + d = _dispatcher() + cmd = d.parse_command("[@123456(Bot)] /addadmin [@1708213363(Null)]") + assert cmd == {"name": "addadmin", "args": ["1708213363"]} + + +def test_parse_command_keeps_inline_at_with_space_name_normalized() -> None: + d = _dispatcher() + cmd = d.parse_command("/profile [@1708213363(Null User)] -r") + assert cmd == {"name": "profile", "args": ["1708213363", "-r"]} + + +def test_parse_command_multiple_at_args() -> None: + d = _dispatcher() + cmd = d.parse_command("/bugfix [@12345(A)] [@67890] 2024/12/01/09:00 now") + assert cmd == { + "name": "bugfix", + "args": ["12345", "67890", "2024/12/01/09:00", "now"], + } + + +def test_parse_command_no_at_unchanged() -> None: + d = _dispatcher() + cmd = d.parse_command("/profile g -r") + assert cmd == {"name": "profile", "args": ["g", "-r"]} diff --git a/tests/test_config_api.py b/tests/test_config_api.py index 936a94a3..da849454 100644 --- a/tests/test_config_api.py +++ b/tests/test_config_api.py @@ -23,6 +23,19 @@ def test_api_config_defaults_when_missing(tmp_path: Path) -> None: assert cfg.api.tool_invoke_denylist == [] assert cfg.api.tool_invoke_timeout == 120 assert cfg.api.tool_invoke_callback_timeout == 10 + assert cfg.attachment_remote_download_max_size_mb == 25 + + +def test_attachment_remote_download_limit_config(tmp_path: Path) -> None: + cfg = _load_config( + tmp_path / "config.toml", + """ +[attachments] +remote_download_max_size_mb = 8 +""", + ) + + assert cfg.attachment_remote_download_max_size_mb == 8 def test_api_config_custom_values(tmp_path: Path) -> None: @@ -100,3 +113,30 @@ def test_api_tool_invoke_invalid_timeout_fallback(tmp_path: Path) -> None: ) assert cfg.api.tool_invoke_timeout == 120 assert cfg.api.tool_invoke_callback_timeout == 10 + + +def test_render_config_defaults_to_auto(tmp_path: Path) -> None: + cfg = _load_config(tmp_path / "config.toml", "") + assert cfg.render_browser_max_concurrency == 0 + + +def test_render_config_accepts_custom_value(tmp_path: Path) -> None: + cfg = _load_config( + tmp_path / "config.toml", + """ +[render] +browser_max_concurrency = 4 +""", + ) + assert cfg.render_browser_max_concurrency == 4 + + +def test_render_config_invalid_values_fallback_to_auto(tmp_path: Path) -> None: + cfg = _load_config( + tmp_path / "config.toml", + """ +[render] +browser_max_concurrency = -3 +""", + ) + assert cfg.render_browser_max_concurrency == 0 diff --git a/tests/test_config_hot_reload.py b/tests/test_config_hot_reload.py index 4e0158ae..4a83d6cd 100644 --- a/tests/test_config_hot_reload.py +++ b/tests/test_config_hot_reload.py @@ -1,8 +1,11 @@ from __future__ import annotations +import asyncio from types import SimpleNamespace from typing import Any, cast +import pytest + from Undefined.config.hot_reload import HotReloadContext, apply_config_updates @@ -26,6 +29,74 @@ def update_max_retries(self, max_retries: int) -> None: self.max_retries.append(max_retries) +class _FakeAIClient: + def __init__(self) -> None: + self.model_updates: list[dict[str, Any]] = [] + self.runtime_updates: list[Any] = [] + self.attachment_updates: list[Any] = [] + + def apply_model_configs( + self, + *, + chat_config: Any, + vision_config: Any, + agent_config: Any, + runtime_config: Any, + ) -> None: + self.model_updates.append( + { + "chat": chat_config, + "vision": vision_config, + "agent": agent_config, + "runtime": runtime_config, + } + ) + + def apply_runtime_config(self, runtime_config: Any) -> None: + self.runtime_updates.append(runtime_config) + + def apply_attachment_config(self, runtime_config: Any) -> None: + self.attachment_updates.append(runtime_config) + + +class _FakeReloadRegistry: + def __init__(self) -> None: + self.started: list[tuple[float, float]] = [] + self.stopped = 0 + + async def stop_hot_reload(self) -> None: + self.stopped += 1 + + def start_hot_reload(self, *, interval: float, debounce: float) -> None: + self.started.append((interval, debounce)) + + +class _FakeMessageHandler: + def __init__(self) -> None: + self.reload_updates: list[tuple[bool, float, float]] = [] + + async def apply_skills_hot_reload_config( + self, + *, + enabled: bool, + interval: float, + debounce: float, + ) -> None: + self.reload_updates.append((enabled, interval, debounce)) + + +class _FakeConfigManager: + def __init__(self) -> None: + self.stopped = 0 + self.started: list[tuple[float, float]] = [] + + async def stop_hot_reload(self) -> None: + self.stopped += 1 + + def start_hot_reload(self, *, interval: float, debounce: float) -> None: + self.started.append((interval, debounce)) + + def test_apply_config_updates_propagates_to_security_service() -> None: updated = cast( Any, @@ -146,3 +217,240 @@ def test_apply_config_updates_hot_reloads_ai_request_max_retries() -> None: ) assert queue_manager.max_retries == [9] + + +def test_apply_config_updates_hot_reloads_ai_model_configs() -> None: + updated = cast( + Any, + SimpleNamespace( + searxng_url="", + ai_request_max_retries=2, + agent_intro_autogen_enabled=False, + agent_intro_autogen_queue_interval=60.0, + agent_intro_autogen_max_tokens=512, + agent_intro_hash_path="data/intro.json", + chat_model=SimpleNamespace( + model_name="chat", + queue_interval_seconds=1.0, + stream_enabled=True, + pool=SimpleNamespace(enabled=False), + ), + agent_model=SimpleNamespace( + model_name="agent", + queue_interval_seconds=1.0, + stream_enabled=False, + pool=SimpleNamespace(enabled=False), + ), + vision_model=SimpleNamespace( + model_name="vision", + queue_interval_seconds=1.0, + stream_enabled=True, + ), + security_model=SimpleNamespace( + model_name="security", + queue_interval_seconds=1.0, + ), + naga_model=SimpleNamespace( + model_name="naga", + queue_interval_seconds=1.0, + ), + grok_model=SimpleNamespace( + model_name="grok", + queue_interval_seconds=1.0, + ), + historian_model=SimpleNamespace( + model_name="historian", + queue_interval_seconds=1.0, + ), + ), + ) + security_service = _FakeSecurityService() + queue_manager = _FakeQueueManager() + ai_client = _FakeAIClient() + context = HotReloadContext( + ai_client=cast(Any, ai_client), + queue_manager=cast(Any, queue_manager), + config_manager=cast(Any, SimpleNamespace()), + security_service=cast(Any, security_service), + ) + + apply_config_updates( + updated, + {"chat_model.stream_enabled": (False, True)}, + context, + ) + + assert len(ai_client.model_updates) == 1 + assert ai_client.model_updates[0]["chat"].stream_enabled is True + assert ai_client.model_updates[0]["vision"].stream_enabled is True + assert ai_client.model_updates[0]["agent"].stream_enabled is False + + +def test_apply_config_updates_runtime_model_config_without_rebuilding_core_models() -> ( + None +): + updated = cast( + Any, + SimpleNamespace( + searxng_url="", + ai_request_max_retries=2, + agent_intro_autogen_enabled=False, + agent_intro_autogen_queue_interval=60.0, + agent_intro_autogen_max_tokens=512, + agent_intro_hash_path="data/intro.json", + chat_model=SimpleNamespace( + model_name="chat", + queue_interval_seconds=1.0, + stream_enabled=True, + pool=SimpleNamespace(enabled=False), + ), + agent_model=SimpleNamespace( + model_name="agent", + queue_interval_seconds=1.0, + stream_enabled=False, + pool=SimpleNamespace(enabled=False), + ), + vision_model=SimpleNamespace( + model_name="vision", + queue_interval_seconds=1.0, + stream_enabled=True, + ), + security_model=SimpleNamespace( + model_name="security", + queue_interval_seconds=1.0, + ), + naga_model=SimpleNamespace( + model_name="naga", + queue_interval_seconds=1.0, + ), + grok_model=SimpleNamespace( + model_name="grok", + queue_interval_seconds=1.0, + ), + historian_model=SimpleNamespace( + model_name="historian", + queue_interval_seconds=1.0, + ), + summary_model=SimpleNamespace( + model_name="summary-new", + queue_interval_seconds=1.0, + ), + ), + ) + ai_client = _FakeAIClient() + queue_manager = _FakeQueueManager() + context = HotReloadContext( + ai_client=cast(Any, ai_client), + queue_manager=cast(Any, queue_manager), + config_manager=cast(Any, SimpleNamespace()), + security_service=cast(Any, _FakeSecurityService()), + ) + + apply_config_updates( + updated, + {"summary_model.model_name": ("summary-old", "summary-new")}, + context, + ) + + assert ai_client.model_updates == [] + assert ai_client.runtime_updates == [updated] + assert len(queue_manager.intervals) == 1 + + +def test_apply_config_updates_hot_reloads_attachment_config() -> None: + updated = cast( + Any, + SimpleNamespace( + searxng_url="", + ai_request_max_retries=2, + attachment_remote_download_max_size_mb=8, + chat_model=SimpleNamespace( + model_name="chat", + queue_interval_seconds=1.0, + pool=SimpleNamespace(enabled=False), + ), + agent_model=SimpleNamespace( + model_name="agent", + queue_interval_seconds=1.0, + pool=SimpleNamespace(enabled=False), + ), + vision_model=SimpleNamespace( + model_name="vision", + queue_interval_seconds=1.0, + ), + security_model=SimpleNamespace( + model_name="security", + queue_interval_seconds=1.0, + ), + naga_model=SimpleNamespace( + model_name="naga", + queue_interval_seconds=1.0, + ), + grok_model=SimpleNamespace( + model_name="grok", + queue_interval_seconds=1.0, + ), + historian_model=SimpleNamespace( + model_name="historian", + queue_interval_seconds=1.0, + ), + ), + ) + ai_client = _FakeAIClient() + context = HotReloadContext( + ai_client=cast(Any, ai_client), + queue_manager=cast(Any, _FakeQueueManager()), + config_manager=cast(Any, SimpleNamespace()), + security_service=cast(Any, _FakeSecurityService()), + ) + + apply_config_updates( + updated, + {"attachment_remote_download_max_size_mb": (25, 8)}, + context, + ) + + assert ai_client.attachment_updates == [updated] + + +@pytest.mark.asyncio +async def test_apply_config_updates_refreshes_auto_pipeline_hot_reload() -> None: + updated = cast( + Any, + SimpleNamespace( + searxng_url="", + skills_hot_reload=True, + skills_hot_reload_interval=3.0, + skills_hot_reload_debounce=0.75, + ), + ) + tool_registry = _FakeReloadRegistry() + agent_registry = _FakeReloadRegistry() + anthropic_skill_registry = _FakeReloadRegistry() + message_handler = _FakeMessageHandler() + config_manager = _FakeConfigManager() + ai_client = SimpleNamespace( + tool_registry=tool_registry, + agent_registry=agent_registry, + anthropic_skill_registry=anthropic_skill_registry, + ) + context = HotReloadContext( + ai_client=cast(Any, ai_client), + queue_manager=cast(Any, _FakeQueueManager()), + config_manager=cast(Any, config_manager), + security_service=cast(Any, _FakeSecurityService()), + message_handler=cast(Any, message_handler), + ) + + apply_config_updates( + updated, + {"skills_hot_reload_interval": (2.0, 3.0)}, + context, + ) + await asyncio.sleep(0) + + assert tool_registry.started == [(3.0, 0.75)] + assert agent_registry.started == [(3.0, 0.75)] + assert anthropic_skill_registry.started == [(3.0, 0.75)] + assert message_handler.reload_updates == [(True, 3.0, 0.75)] + assert config_manager.started == [(3.0, 0.75)] diff --git a/tests/test_config_request_params.py b/tests/test_config_request_params.py index ace8b115..0bb64cce 100644 --- a/tests/test_config_request_params.py +++ b/tests/test_config_request_params.py @@ -29,6 +29,7 @@ def test_model_request_params_load_inherit_and_new_transport_fields( responses_tool_choice_compat = true responses_force_stateless_replay = true prompt_cache_enabled = false +stream_enabled = true [models.chat.request_params] temperature = 0.2 @@ -45,6 +46,7 @@ def test_model_request_params_load_inherit_and_new_transport_fields( api_mode = "chat_completions" reasoning_enabled = false reasoning_effort = "low" +stream_enabled = true [models.chat.pool.models.request_params] temperature = 0.6 @@ -60,6 +62,7 @@ def test_model_request_params_load_inherit_and_new_transport_fields( responses_tool_choice_compat = true responses_force_stateless_replay = true prompt_cache_enabled = false +stream_enabled = true [models.vision.request_params] temperature = 0.4 @@ -75,6 +78,7 @@ def test_model_request_params_load_inherit_and_new_transport_fields( responses_tool_choice_compat = true responses_force_stateless_replay = true prompt_cache_enabled = false +stream_enabled = true [models.agent.request_params] temperature = 0.3 @@ -85,6 +89,7 @@ def test_model_request_params_load_inherit_and_new_transport_fields( model_name = "gpt-historian" api_mode = "chat_completions" reasoning_effort = "xhigh" +stream_enabled = true [models.historian.request_params] temperature = 0.1 @@ -94,6 +99,7 @@ def test_model_request_params_load_inherit_and_new_transport_fields( model_name = "gpt-summary" api_mode = "chat_completions" reasoning_effort = "xhigh" +stream_enabled = true [models.summary.request_params] temperature = 0.15 @@ -105,6 +111,7 @@ def test_model_request_params_load_inherit_and_new_transport_fields( model_name = "grok-4-search" reasoning_enabled = true reasoning_effort = "low" +stream_enabled = true [models.grok.request_params] temperature = 0.5 @@ -152,6 +159,7 @@ def test_model_request_params_load_inherit_and_new_transport_fields( assert cfg.chat_model.responses_tool_choice_compat is True assert cfg.chat_model.responses_force_stateless_replay is True assert cfg.chat_model.prompt_cache_enabled is False + assert cfg.chat_model.stream_enabled is True assert cfg.chat_model.request_params == { "temperature": 0.2, "metadata": {"source": "chat"}, @@ -165,6 +173,7 @@ def test_model_request_params_load_inherit_and_new_transport_fields( assert cfg.chat_model.pool.models[0].responses_tool_choice_compat is True assert cfg.chat_model.pool.models[0].responses_force_stateless_replay is True assert cfg.chat_model.pool.models[0].prompt_cache_enabled is False + assert cfg.chat_model.pool.models[0].stream_enabled is True assert cfg.chat_model.pool.models[0].request_params == { "temperature": 0.6, "metadata": {"source": "chat"}, @@ -177,6 +186,7 @@ def test_model_request_params_load_inherit_and_new_transport_fields( assert cfg.vision_model.responses_tool_choice_compat is True assert cfg.vision_model.responses_force_stateless_replay is True assert cfg.vision_model.prompt_cache_enabled is False + assert cfg.vision_model.stream_enabled is True assert cfg.vision_model.request_params == { "temperature": 0.4, "metadata": {"source": "vision"}, @@ -189,11 +199,13 @@ def test_model_request_params_load_inherit_and_new_transport_fields( assert cfg.security_model.responses_tool_choice_compat is True assert cfg.security_model.responses_force_stateless_replay is True assert cfg.security_model.prompt_cache_enabled is False + assert cfg.security_model.stream_enabled is True assert cfg.security_model.request_params == cfg.chat_model.request_params assert cfg.naga_model.api_mode == cfg.security_model.api_mode assert cfg.naga_model.reasoning_enabled == cfg.security_model.reasoning_enabled assert cfg.naga_model.reasoning_effort == cfg.security_model.reasoning_effort + assert cfg.naga_model.stream_enabled is True assert cfg.naga_model.request_params == cfg.security_model.request_params assert cfg.agent_model.api_mode == "responses" @@ -203,6 +215,7 @@ def test_model_request_params_load_inherit_and_new_transport_fields( assert cfg.agent_model.responses_tool_choice_compat is True assert cfg.agent_model.responses_force_stateless_replay is True assert cfg.agent_model.prompt_cache_enabled is False + assert cfg.agent_model.stream_enabled is True assert cfg.historian_model.api_mode == "chat_completions" assert cfg.historian_model.reasoning_enabled is True @@ -211,6 +224,7 @@ def test_model_request_params_load_inherit_and_new_transport_fields( assert cfg.historian_model.responses_tool_choice_compat is True assert cfg.historian_model.responses_force_stateless_replay is True assert cfg.historian_model.prompt_cache_enabled is False + assert cfg.historian_model.stream_enabled is True assert cfg.historian_model.request_params == { "temperature": 0.1, "metadata": {"source": "historian"}, @@ -223,6 +237,7 @@ def test_model_request_params_load_inherit_and_new_transport_fields( assert cfg.summary_model.responses_tool_choice_compat is True assert cfg.summary_model.responses_force_stateless_replay is True assert cfg.summary_model.prompt_cache_enabled is False + assert cfg.summary_model.stream_enabled is True assert cfg.summary_model.request_params == { "temperature": 0.15, "metadata": {"source": "summary"}, @@ -231,6 +246,7 @@ def test_model_request_params_load_inherit_and_new_transport_fields( assert cfg.grok_model.reasoning_enabled is True assert cfg.grok_model.reasoning_effort == "low" assert cfg.grok_model.prompt_cache_enabled is True + assert cfg.grok_model.stream_enabled is True assert cfg.grok_model.request_params == { "temperature": 0.5, "metadata": {"source": "grok"}, diff --git a/tests/test_config_template_sync.py b/tests/test_config_template_sync.py index c26e107b..5da1f227 100644 --- a/tests/test_config_template_sync.py +++ b/tests/test_config_template_sync.py @@ -94,6 +94,7 @@ def test_sync_config_text_merges_new_fields_into_existing_pool_model_entries() - api_mode = "responses" responses_tool_choice_compat = true responses_force_stateless_replay = true +stream_enabled = true [models.chat.request_params] temperature = 0.2 @@ -112,6 +113,7 @@ def test_sync_config_text_merges_new_fields_into_existing_pool_model_entries() - assert model["api_mode"] == "responses" assert model["responses_tool_choice_compat"] is True assert model["responses_force_stateless_replay"] is True + assert model["stream_enabled"] is True assert model["request_params"]["temperature"] == 0.2 assert "models.chat.pool.models[0].api_mode" in result.added_paths assert "models.chat.pool.models[0].request_params" in result.added_paths diff --git a/tests/test_copyright_command.py b/tests/test_copyright_command.py index dd99c830..c2b27134 100644 --- a/tests/test_copyright_command.py +++ b/tests/test_copyright_command.py @@ -111,7 +111,7 @@ async def test_help_list_contains_copyright_hint(tmp_path: Path) -> None: sender = _DummySender() context = _build_context(registry, sender) - await help_execute([], context) + await help_execute(["-t"], context) assert sender.messages output = sender.messages[-1][1] diff --git a/tests/test_faq_command.py b/tests/test_faq_command.py new file mode 100644 index 00000000..90cec43e --- /dev/null +++ b/tests/test_faq_command.py @@ -0,0 +1,450 @@ +"""FAQ 合并命令单元测试(含注册表子命令推断)""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace +from typing import Any, cast +from unittest.mock import AsyncMock + +import pytest + +from Undefined.faq import FAQ +from Undefined.services.command import CommandDispatcher +from Undefined.services.commands.context import CommandContext +from Undefined.services.commands.registry import CommandRegistry +from Undefined.skills.commands.faq import handler as faq_handler + + +class _DummySender: + def __init__(self) -> None: + self.messages: list[tuple[int, str, bool]] = [] + + async def send_group_message( + self, group_id: int, message: str, mark_sent: bool = False + ) -> None: + self.messages.append((group_id, message, mark_sent)) + + +def _make_faq(**overrides: Any) -> FAQ: + defaults: dict[str, str | int] = dict( + id="20241205-001", + group_id=10001, + target_qq=12345, + start_time="2024-12-05", + end_time="2024-12-06", + created_at="2024-12-05T10:00:00", + title="测试FAQ", + content="这是FAQ内容", + ) + defaults.update(overrides) + return FAQ(**defaults) # type: ignore[arg-type] + + +def _build_context( + sender: _DummySender, + *, + group_id: int = 10001, + sender_id: int = 10002, + is_admin: bool = False, + is_superadmin: bool = False, + faq_storage: Any | None = None, +) -> CommandContext: + config = cast( + Any, + SimpleNamespace( + is_admin=lambda _sid: is_admin, + is_superadmin=lambda _sid: is_superadmin, + ), + ) + storage = faq_storage or cast(Any, SimpleNamespace()) + stub = cast(Any, SimpleNamespace()) + return CommandContext( + group_id=group_id, + sender_id=sender_id, + config=config, + sender=cast(Any, sender), + ai=stub, + faq_storage=storage, + onebot=stub, + security=stub, + queue_manager=None, + rate_limiter=None, + dispatcher=stub, + registry=cast(Any, SimpleNamespace()), + scope="group", + ) + + +# --------------------------------------------------------------------------- +# 注册表:子命令推断 +# --------------------------------------------------------------------------- + + +def _commands_dir() -> Path: + return Path(__import__("Undefined").__path__[0]) / "skills" / "commands" + + +def _load_faq_meta() -> Any: + registry = CommandRegistry(_commands_dir()) + registry.load_commands() + return registry.resolve("faq") + + +def test_registry_faq_has_subcommands() -> None: + meta = _load_faq_meta() + assert meta is not None + assert "ls" in meta.subcommands + assert "view" in meta.subcommands + assert "search" in meta.subcommands + assert "del" in meta.subcommands + + +def test_registry_faq_has_inference() -> None: + meta = _load_faq_meta() + assert meta is not None + assert meta.inference is not None + assert meta.inference.default == "ls" + assert meta.inference.fallback == "search" + assert len(meta.inference.rules) == 1 + + +def test_registry_resolve_explicit_subcommand() -> None: + registry = CommandRegistry(_commands_dir()) + registry.load_commands() + meta = registry.resolve("faq") + assert meta is not None + subcmd, args, submeta = registry.resolve_subcommand(meta, ["del", "20241205-001"]) + assert subcmd == "del" + assert args == ["del", "20241205-001"] + assert submeta is not None + assert submeta.permission == "admin" + + +def test_registry_infer_no_args_default_ls() -> None: + registry = CommandRegistry(_commands_dir()) + registry.load_commands() + meta = registry.resolve("faq") + assert meta is not None + subcmd, args, submeta = registry.resolve_subcommand(meta, []) + assert subcmd == "ls" + assert args == ["ls"] + assert submeta is not None + + +def test_registry_infer_id_pattern_view() -> None: + registry = CommandRegistry(_commands_dir()) + registry.load_commands() + meta = registry.resolve("faq") + assert meta is not None + subcmd, args, submeta = registry.resolve_subcommand(meta, ["20241205-001"]) + assert subcmd == "view" + assert args == ["view", "20241205-001"] + assert submeta is not None + + +def test_registry_inference_rule_requires_full_match() -> None: + registry = CommandRegistry(_commands_dir()) + registry.load_commands() + meta = registry.resolve("faq") + assert meta is not None + subcmd, args, submeta = registry.resolve_subcommand(meta, ["20241205-001-extra"]) + assert subcmd == "search" + assert args == ["search", "20241205-001-extra"] + assert submeta is not None + + +def test_registry_infer_non_id_fallback_search() -> None: + registry = CommandRegistry(_commands_dir()) + registry.load_commands() + meta = registry.resolve("faq") + assert meta is not None + subcmd, args, submeta = registry.resolve_subcommand(meta, ["登录"]) + assert subcmd == "search" + assert args == ["search", "登录"] + assert submeta is not None + + +def test_registry_infer_multi_word_fallback_search() -> None: + registry = CommandRegistry(_commands_dir()) + registry.load_commands() + meta = registry.resolve("faq") + assert meta is not None + subcmd, args, submeta = registry.resolve_subcommand(meta, ["数据", "导入"]) + assert subcmd == "search" + assert args == ["search", "数据", "导入"] + assert submeta is not None + + +# --------------------------------------------------------------------------- +# handler:args 格式为 [subcmd, *sub_args] +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_ls_shows_faq_list() -> None: + sender = _DummySender() + storage = cast( + Any, + SimpleNamespace( + list_all=AsyncMock( + return_value=[ + _make_faq(id="20241205-001", title="FAQ甲"), + _make_faq(id="20241206-002", title="FAQ乙"), + ] + ) + ), + ) + context = _build_context(sender, faq_storage=storage) + + await faq_handler.execute(["ls"], context) + + output = sender.messages[-1][1] + assert "FAQ 列表" in output + assert "20241205-001" in output + assert "FAQ甲" in output + assert "20241206-002" in output + assert "FAQ乙" in output + + +@pytest.mark.asyncio +async def test_ls_shows_empty() -> None: + sender = _DummySender() + storage = cast(Any, SimpleNamespace(list_all=AsyncMock(return_value=[]))) + context = _build_context(sender, faq_storage=storage) + + await faq_handler.execute(["ls"], context) + + output = sender.messages[-1][1] + assert "没有保存的 FAQ" in output + + +@pytest.mark.asyncio +async def test_view_explicit_subcommand() -> None: + sender = _DummySender() + faq = _make_faq() + storage = cast(Any, SimpleNamespace(get=AsyncMock(return_value=faq))) + context = _build_context(sender, faq_storage=storage) + + await faq_handler.execute(["view", "20241205-001"], context) + + output = sender.messages[-1][1] + assert "FAQ: 测试FAQ" in output + + +@pytest.mark.asyncio +async def test_view_not_found() -> None: + sender = _DummySender() + storage = cast(Any, SimpleNamespace(get=AsyncMock(return_value=None))) + context = _build_context(sender, faq_storage=storage) + + await faq_handler.execute(["view", "99999999-999"], context) + + output = sender.messages[-1][1] + assert "FAQ 不存在" in output + + +@pytest.mark.asyncio +async def test_view_no_args_shows_usage() -> None: + sender = _DummySender() + context = _build_context(sender) + + await faq_handler.execute(["view"], context) + + output = sender.messages[-1][1] + assert "用法" in output + + +@pytest.mark.asyncio +async def test_search_explicit_subcommand() -> None: + sender = _DummySender() + storage = cast(Any, SimpleNamespace(search=AsyncMock(return_value=[]))) + context = _build_context(sender, faq_storage=storage) + + await faq_handler.execute(["search", "关键词"], context) + + output = sender.messages[-1][1] + assert "未找到" in output + + +@pytest.mark.asyncio +async def test_search_no_args_shows_usage() -> None: + sender = _DummySender() + context = _build_context(sender) + + await faq_handler.execute(["search"], context) + + output = sender.messages[-1][1] + assert "用法" in output + + +@pytest.mark.asyncio +async def test_del_succeeds_as_admin() -> None: + sender = _DummySender() + faq = _make_faq() + storage = cast( + Any, + SimpleNamespace( + get=AsyncMock(return_value=faq), + delete=AsyncMock(return_value=True), + ), + ) + context = _build_context(sender, is_admin=True, faq_storage=storage) + + await faq_handler.execute(["del", "20241205-001"], context) + + output = sender.messages[-1][1] + assert "已删除" in output + + +@pytest.mark.asyncio +async def test_del_succeeds_as_superadmin() -> None: + sender = _DummySender() + faq = _make_faq() + storage = cast( + Any, + SimpleNamespace( + get=AsyncMock(return_value=faq), + delete=AsyncMock(return_value=True), + ), + ) + context = _build_context( + sender, is_admin=False, is_superadmin=True, faq_storage=storage + ) + + await faq_handler.execute(["del", "20241205-001"], context) + + output = sender.messages[-1][1] + assert "已删除" in output + + +@pytest.mark.asyncio +async def test_del_not_found() -> None: + sender = _DummySender() + storage = cast(Any, SimpleNamespace(get=AsyncMock(return_value=None))) + context = _build_context(sender, is_admin=True, faq_storage=storage) + + await faq_handler.execute(["del", "99999999-999"], context) + + output = sender.messages[-1][1] + assert "FAQ 不存在" in output + + +@pytest.mark.asyncio +async def test_del_no_args_shows_usage() -> None: + sender = _DummySender() + context = _build_context(sender, is_admin=True) + + await faq_handler.execute(["del"], context) + + output = sender.messages[-1][1] + assert "用法" in output + + +# --------------------------------------------------------------------------- +# 注册与别名 +# --------------------------------------------------------------------------- + + +def test_faq_command_is_registered() -> None: + dispatcher = CommandDispatcher( + config=cast( + Any, + SimpleNamespace(is_superadmin=lambda _x: False, is_admin=lambda _x: False), + ), + sender=cast(Any, _DummySender()), + ai=cast(Any, SimpleNamespace()), + faq_storage=cast(Any, SimpleNamespace()), + onebot=cast(Any, SimpleNamespace()), + security=cast(Any, SimpleNamespace(rate_limiter=None)), + ) + + meta = dispatcher.command_registry.resolve("faq") + assert meta is not None + assert meta.allow_in_private is False + assert "f" in meta.aliases + assert "del" in meta.subcommands + assert meta.subcommands["del"].permission == "admin" + assert meta.inference is not None + assert meta.inference.default == "ls" + + +def test_faq_alias_f_resolves() -> None: + dispatcher = CommandDispatcher( + config=cast( + Any, + SimpleNamespace(is_superadmin=lambda _x: False, is_admin=lambda _x: False), + ), + sender=cast(Any, _DummySender()), + ai=cast(Any, SimpleNamespace()), + faq_storage=cast(Any, SimpleNamespace()), + onebot=cast(Any, SimpleNamespace()), + security=cast(Any, SimpleNamespace(rate_limiter=None)), + ) + + meta = dispatcher.command_registry.resolve("f") + assert meta is not None + assert meta.name == "faq" + + +def test_legacy_faq_commands_are_not_registered() -> None: + registry = CommandRegistry(_commands_dir()) + registry.load_commands() + + for name in ("lsfaq", "viewfaq", "searchfaq", "delfaq"): + assert registry.resolve(name) is None + + +def test_dispatcher_admin_permission_allows_superadmin() -> None: + dispatcher = CommandDispatcher( + config=cast( + Any, + SimpleNamespace( + is_superadmin=lambda sender_id: sender_id == 10002, + is_admin=lambda _sender_id: False, + ), + ), + sender=cast(Any, _DummySender()), + ai=cast(Any, SimpleNamespace()), + faq_storage=cast(Any, SimpleNamespace()), + onebot=cast(Any, SimpleNamespace()), + security=cast(Any, SimpleNamespace(rate_limiter=None)), + ) + + assert dispatcher._check_command_permission_raw("admin", 10002) == (True, "管理员") + assert dispatcher._check_command_permission_raw("admin", 10003) == (False, "管理员") + + +# --------------------------------------------------------------------------- +# CommandContext.check_permission +# --------------------------------------------------------------------------- + + +def test_context_check_permission_public() -> None: + sender = _DummySender() + context = _build_context(sender) + assert context.check_permission("public") is True + + +def test_context_check_permission_admin_as_admin() -> None: + sender = _DummySender() + context = _build_context(sender, is_admin=True) + assert context.check_permission("admin") is True + + +def test_context_check_permission_admin_as_normal() -> None: + sender = _DummySender() + context = _build_context(sender, is_admin=False) + assert context.check_permission("admin") is False + + +def test_context_check_permission_superadmin_as_super() -> None: + sender = _DummySender() + context = _build_context(sender, is_superadmin=True) + assert context.check_permission("superadmin") is True + + +def test_context_check_permission_superadmin_as_admin() -> None: + sender = _DummySender() + context = _build_context(sender, is_admin=True, is_superadmin=False) + assert context.check_permission("superadmin") is False diff --git a/tests/test_github_client.py b/tests/test_github_client.py new file mode 100644 index 00000000..08ecdc7e --- /dev/null +++ b/tests/test_github_client.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +import Undefined.github.client as client_module + + +class _FakeResponse: + def __init__(self, payload: object, headers: dict[str, str] | None = None) -> None: + self._payload = payload + self.headers = headers or {} + + def json(self) -> object: + return self._payload + + +def _repo_payload() -> dict[str, Any]: + return { + "full_name": "69gg/Undefined", + "name": "Undefined", + "owner": { + "login": "69gg", + "avatar_url": "https://avatars.githubusercontent.com/u/1?v=4", + }, + "description": "QQ bot platform", + "html_url": "https://github.com/69gg/Undefined", + "stargazers_count": 1234, + "forks_count": 56, + "open_issues_count": 7, + "watchers_count": 1234, + "subscribers_count": 89, + "language": "Python", + "license": {"spdx_id": "MIT", "name": "MIT License"}, + "default_branch": "main", + "topics": ["bot", "onebot"], + "created_at": "2024-01-02T03:04:05Z", + "updated_at": "2026-05-01T03:04:05Z", + "pushed_at": "2026-05-01T03:04:05Z", + "archived": False, + "fork": False, + "private": False, + } + + +@pytest.mark.asyncio +async def test_get_public_repo_info_parses_repo_and_contributor_count( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[str] = [] + + async def fake_request_with_retry( + _method: str, + url: str, + **_kwargs: Any, + ) -> _FakeResponse: + calls.append(url) + if url.endswith("/contributors"): + return _FakeResponse( + [{"login": "alice"}], + { + "link": '; rel="last"' + }, + ) + return _FakeResponse(_repo_payload()) + + monkeypatch.setattr(client_module, "request_with_retry", fake_request_with_retry) + + info = await client_module.get_public_repo_info("69gg/Undefined") + + assert calls == [ + "https://api.github.com/repos/69gg/Undefined", + "https://api.github.com/repos/69gg/Undefined/contributors", + ] + assert info.repo_id == "69gg/Undefined" + assert info.owner_login == "69gg" + assert info.stars == 1234 + assert info.forks == 56 + assert info.open_issues == 7 + assert info.contributors == 42 + assert info.topics == ("bot", "onebot") + + +@pytest.mark.asyncio +async def test_get_public_repo_info_rejects_private_repo( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def fake_request_with_retry( + _method: str, + _url: str, + **_kwargs: Any, + ) -> _FakeResponse: + payload = _repo_payload() + payload["private"] = True + return _FakeResponse(payload) + + monkeypatch.setattr(client_module, "request_with_retry", fake_request_with_retry) + + with pytest.raises(ValueError, match="仅支持 public"): + await client_module.get_public_repo_info("69gg/Undefined") diff --git a/tests/test_github_config.py b/tests/test_github_config.py new file mode 100644 index 00000000..bc41a368 --- /dev/null +++ b/tests/test_github_config.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from pathlib import Path + +from Undefined.config.loader import Config + + +def _load_config(tmp_path: Path, extra_toml: str) -> Config: + config_path = tmp_path / "config.toml" + config_path.write_text( + ( + "[core]\n" + "bot_qq = 10001\n" + "superadmin_qq = 20002\n\n" + "[onebot]\n" + 'ws_url = "ws://127.0.0.1:3001"\n\n' + f"{extra_toml}\n" + ), + encoding="utf-8", + ) + return Config.load(config_path=config_path, strict=False) + + +def test_github_config_clamps_invalid_values(tmp_path: Path) -> None: + config = _load_config( + tmp_path, + ( + "[github]\n" + "auto_extract_enabled = true\n" + "request_timeout_seconds = 99\n" + "auto_extract_group_ids = [123456]\n" + "auto_extract_private_ids = [20003]\n" + "auto_extract_max_items = 99\n" + ), + ) + + assert config.github_auto_extract_enabled is True + assert config.github_request_timeout_seconds == 60.0 + assert config.github_auto_extract_group_ids == [123456] + assert config.github_auto_extract_private_ids == [20003] + assert config.github_auto_extract_max_items == 10 + + +def test_github_auto_extract_allowlist_follows_global_access_when_empty( + tmp_path: Path, +) -> None: + config = _load_config( + tmp_path, + ( + "[access]\n" + 'mode = "allowlist"\n' + "allowed_group_ids = [123456]\n" + "allowed_private_ids = [20003]\n\n" + "[github]\n" + "auto_extract_enabled = true\n" + ), + ) + + assert config.is_github_auto_extract_allowed_group(123456) is True + assert config.is_github_auto_extract_allowed_group(654321) is False + assert config.is_github_auto_extract_allowed_private(20003) is True + assert config.is_github_auto_extract_allowed_private(30004) is False + + +def test_github_auto_extract_allowlist_overrides_global_access_when_non_empty( + tmp_path: Path, +) -> None: + config = _load_config( + tmp_path, + ( + "[access]\n" + 'mode = "allowlist"\n' + "allowed_group_ids = [123456]\n" + "allowed_private_ids = [20003]\n\n" + "[github]\n" + "auto_extract_enabled = true\n" + "auto_extract_group_ids = [654321]\n" + "auto_extract_private_ids = [30004]\n" + ), + ) + + assert config.is_github_auto_extract_allowed_group(123456) is False + assert config.is_github_auto_extract_allowed_group(654321) is True + assert config.is_github_auto_extract_allowed_private(20003) is False + assert config.is_github_auto_extract_allowed_private(30004) is True diff --git a/tests/test_github_parser.py b/tests/test_github_parser.py new file mode 100644 index 00000000..5b74eb7f --- /dev/null +++ b/tests/test_github_parser.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import json + +from Undefined.github.parser import ( + extract_from_json_message, + extract_github_repo_ids, + normalize_github_repo_id, +) + + +def test_normalize_github_repo_id_from_links_and_bare_id() -> None: + assert ( + normalize_github_repo_id("https://github.com/69gg/Undefined/issues/1") + == "69gg/Undefined" + ) + assert normalize_github_repo_id("git@github.com:owner/repo.git") == "owner/repo" + assert normalize_github_repo_id("microsoft/vscode") == "microsoft/vscode" + + +def test_extract_github_repo_ids_deduplicates_links_and_bare_ids() -> None: + text = ( + "看 https://github.com/69gg/Undefined 和 69gg/Undefined," + "还有 github.com/python/cpython/tree/main" + ) + + assert extract_github_repo_ids(text) == ["69gg/Undefined", "python/cpython"] + + +def test_extract_from_json_message_collects_nested_links() -> None: + payload = {"meta": {"desc": "repo: https://github.com/psf/requests"}} + segments = [{"type": "json", "data": {"data": json.dumps(payload)}}] + + assert extract_from_json_message(segments) == ["psf/requests"] + + +def test_invalid_github_repo_ids_are_ignored() -> None: + assert normalize_github_repo_id("https://gist.github.com/user/123") is None + assert normalize_github_repo_id("bad_owner/repo") is None + + +def test_numeric_bare_repo_like_text_is_ignored() -> None: + assert normalize_github_repo_id("1/2") is None + assert normalize_github_repo_id("2024/12") is None + assert extract_github_repo_ids("今天 1/2,计划 2024/12 完成") == [] + + +def test_numeric_url_repo_is_still_allowed() -> None: + assert normalize_github_repo_id("https://github.com/1/2") == "1/2" + + +def test_path_like_bare_repo_text_is_ignored_without_context() -> None: + assert extract_github_repo_ids("看 docs/usage、api/v1 和 src/main") == [] + assert normalize_github_repo_id("docs/usage") == "docs/usage" + assert normalize_github_repo_id("src/main") == "src/main" + + +def test_bare_repo_text_accepts_repo_context_or_strong_shape() -> None: + assert extract_github_repo_ids("GitHub 仓库 microsoft/vscode") == [ + "microsoft/vscode" + ] + assert extract_github_repo_ids("看 69gg/Undefined") == ["69gg/Undefined"] diff --git a/tests/test_github_sender.py b/tests/test_github_sender.py new file mode 100644 index 00000000..28e0aaff --- /dev/null +++ b/tests/test_github_sender.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock + +import pytest + +from Undefined.github.models import GitHubRepoInfo +import Undefined.github.sender as sender_module + + +def _repo_info() -> GitHubRepoInfo: + return GitHubRepoInfo( + repo_id="69gg/Undefined", + name="Undefined", + full_name="69gg/Undefined", + owner_login="69gg", + owner_avatar_url="https://avatars.githubusercontent.com/u/1?v=4", + description="QQ bot platform", + html_url="https://github.com/69gg/Undefined", + stars=1234, + forks=56, + open_issues=7, + watchers=1234, + subscribers=89, + contributors=42, + language="Python", + license_name="MIT", + default_branch="main", + topics=("bot", "onebot"), + created_at="2024-01-02T03:04:05Z", + updated_at="2026-05-01T03:04:05Z", + pushed_at="2026-05-01T03:04:05Z", + archived=False, + fork=False, + ) + + +@pytest.mark.asyncio +async def test_send_github_repo_card_renders_and_sends_image( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + rendered_html: list[str] = [] + + async def fake_render_html_to_image( + html_content: str, + output_path: str, + *, + viewport_width: int = 1280, + screenshot_selector: str | None = None, + proxy: str | None = None, + ) -> None: + rendered_html.append(html_content) + assert viewport_width == 768 + assert screenshot_selector == ".card" + assert proxy is None + Path(output_path).write_bytes(b"png") + + monkeypatch.setattr( + sender_module, + "get_public_repo_info", + AsyncMock(return_value=_repo_info()), + ) + monkeypatch.setattr( + sender_module, "render_html_to_image", fake_render_html_to_image + ) + monkeypatch.setattr(sender_module, "get_request_proxy", lambda _url: None) + monkeypatch.setattr(sender_module, "RENDER_CACHE_DIR", tmp_path) + + sender: Any = SimpleNamespace( + send_group_message=AsyncMock(), + send_private_message=AsyncMock(), + ) + + result = await sender_module.send_github_repo_card( + repo_id="69gg/Undefined", + sender=sender, + target_type="group", + target_id=10001, + ) + + assert result == "已发送 GitHub 仓库卡片: 69gg/Undefined" + assert "69gg/Undefined" in rendered_html[0] + assert "QQ bot platform" in rendered_html[0] + assert "1,234" in rendered_html[0] + sender.send_group_message.assert_called_once() + sent_message = sender.send_group_message.call_args.args[1] + assert sent_message.startswith("[CQ:image,file=file://") + rendered_file = Path( + sent_message.split("file=", 1)[1].rstrip("]").removeprefix("file://") + ) + assert not rendered_file.exists() + history_message = sender.send_group_message.call_args.kwargs["history_message"] + assert history_message.startswith("GitHub: 69gg/Undefined") + assert "auto_history" not in sender.send_group_message.call_args.kwargs diff --git a/tests/test_group_analysis_fact_tools.py b/tests/test_group_analysis_fact_tools.py new file mode 100644 index 00000000..a37e1201 --- /dev/null +++ b/tests/test_group_analysis_fact_tools.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import Any + +import pytest + +from Undefined.skills.toolsets.group_analysis.member_structure.handler import ( + execute as member_structure_execute, +) +from Undefined.skills.toolsets.group_analysis.message_mix.handler import ( + execute as message_mix_execute, +) + + +class _FakeOneBot: + def __init__( + self, + *, + members: list[dict[str, Any]], + messages: list[dict[str, Any]], + ) -> None: + self.members = members + self.messages = messages + self.history_calls: list[tuple[int, int | None, int]] = [] + + async def get_group_member_list(self, group_id: int) -> list[dict[str, Any]]: + assert group_id == 123456 + return self.members + + async def get_group_msg_history( + self, + group_id: int, + message_seq: int | None, + count: int, + ) -> list[dict[str, Any]]: + assert group_id == 123456 + self.history_calls.append((group_id, message_seq, count)) + if message_seq is not None: + return [] + return self.messages[:count] + + +def _ts(value: str) -> int: + return int(datetime.strptime(value, "%Y-%m-%d %H:%M:%S").timestamp()) + + +def _message( + *, + user_id: int, + nickname: str, + time_text: str, + text: str = "hello", + segment_type: str = "text", +) -> dict[str, Any]: + data = {"text": text} if segment_type == "text" else {"file": "pic.jpg"} + return { + "message_seq": _ts(time_text), + "time": _ts(time_text), + "sender": {"user_id": user_id, "nickname": nickname}, + "message": [{"type": segment_type, "data": data}], + } + + +@pytest.mark.asyncio +async def test_member_structure_reports_member_facts() -> None: + now = datetime.now() + members = [ + { + "user_id": 1001, + "card": "Alice", + "role": "owner", + "level": "Lv.42", + "join_time": int((now - timedelta(days=200)).timestamp()), + "last_sent_time": int((now - timedelta(days=2)).timestamp()), + }, + { + "user_id": 1002, + "nickname": "Bob", + "role": "member", + "level": "12", + "join_time": int((now - timedelta(days=10)).timestamp()), + "last_sent_time": int((now - timedelta(days=40)).timestamp()), + }, + { + "user_id": 1003, + "nickname": "Carol", + "role": "admin", + "level": "", + "join_time": int((now - timedelta(days=3)).timestamp()), + "last_sent_time": 0, + }, + ] + onebot = _FakeOneBot(members=members, messages=[]) + + result = await member_structure_execute( + {"group_id": 123456, "example_count": 1}, + {"onebot_client": onebot}, + ) + + assert "【群成员结构】群号: 123456" in result + assert "成员总数: 3" in result + assert "角色分布:" in result + assert "群主: 1 人" in result + assert "管理员: 1 人" in result + assert "成员: 1 人" in result + assert "等级概览:" in result + assert "最高等级: Lv.42" in result + assert "等级未知: 1 人" in result + assert "最近 30 天入群: 2 人" in result + assert "从未发言/无记录: 1 人" in result + + +@pytest.mark.asyncio +async def test_message_mix_reports_message_facts() -> None: + messages = [ + _message( + user_id=1001, + nickname="Alice", + time_text="2025-01-20 10:00:00", + text="今天继续聊插件", + ), + _message( + user_id=1002, nickname="Bob", time_text="2025-01-19 21:00:00", text="收到" + ), + _message( + user_id=1001, + nickname="Alice", + time_text="2025-01-18 22:00:00", + segment_type="image", + ), + ] + onebot = _FakeOneBot(members=[], messages=messages) + + result = await message_mix_execute( + { + "group_id": 123456, + "start_time": "2025-01-01 00:00:00", + "end_time": "2025-01-20 23:59:59", + "sample_count": 2, + }, + {"onebot_client": onebot}, + ) + + assert "【群消息构成】群号: 123456" in result + assert "扫描历史 3 条;窗口有效消息 3 条" in result + assert "活跃发送者: 2 人" in result + assert "文本消息: 2 条" in result + assert "图片消息: 1 条" in result + assert "活跃时段 Top:" in result + assert "最近消息样本(2 条)" in result + assert "今天继续聊插件" in result + assert onebot.history_calls + + +@pytest.mark.asyncio +async def test_fact_tools_require_group_id() -> None: + assert "请提供群号" in await member_structure_execute( + {}, {"onebot_client": object()} + ) + assert "请提供群号" in await message_mix_execute({}, {"onebot_client": object()}) + + +@pytest.mark.asyncio +async def test_fact_tools_require_onebot_client() -> None: + assert "OneBot 客户端未设置" in await member_structure_execute( + {"group_id": 123456}, {} + ) + assert "OneBot 客户端未设置" in await message_mix_execute({"group_id": 123456}, {}) diff --git a/tests/test_group_analysis_toolset_layout.py b/tests/test_group_analysis_toolset_layout.py new file mode 100644 index 00000000..845d2cae --- /dev/null +++ b/tests/test_group_analysis_toolset_layout.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + + +ROOT_DIR = Path(__file__).resolve().parents[1] +TOOLSETS_DIR = ROOT_DIR / "src" / "Undefined" / "skills" / "toolsets" + +GROUP_ANALYSIS_TOOLS = { + "activity_trend", + "filter_members", + "inactive_risk", + "join_statistics", + "level_distribution", + "member_activity", + "member_messages", + "member_structure", + "message_mix", + "new_member_activity", + "rank_members", +} + +MOVED_ANALYSIS_TOOL_DIRS = { + "activity_trend", + "detect_inactive_risk", + "filter_members", + "get_member_activity", + "level_distribution", + "rank_members", +} + + +def _load_config(tool_dir: Path) -> dict[str, Any]: + with (tool_dir / "config.json").open("r", encoding="utf-8") as file: + data = json.load(file) + assert isinstance(data, dict) + return data + + +def _function_name(config: dict[str, Any]) -> str: + function_config = config.get("function") + assert isinstance(function_config, dict) + name = function_config.get("name") + assert isinstance(name, str) + return name + + +def test_group_analysis_tools_are_colocated_and_named() -> None: + group_analysis_dir = TOOLSETS_DIR / "group_analysis" + actual_tool_dirs = { + path.name + for path in group_analysis_dir.iterdir() + if path.is_dir() and (path / "config.json").exists() + } + + assert GROUP_ANALYSIS_TOOLS <= actual_tool_dirs + for tool_name in GROUP_ANALYSIS_TOOLS: + assert _function_name(_load_config(group_analysis_dir / tool_name)) == tool_name + + +def test_group_toolset_keeps_analysis_tools_out() -> None: + group_dir = TOOLSETS_DIR / "group" + group_tool_dirs = {path.name for path in group_dir.iterdir() if path.is_dir()} + + assert group_tool_dirs.isdisjoint(MOVED_ANALYSIS_TOOL_DIRS) diff --git a/tests/test_group_basic_tools.py b/tests/test_group_basic_tools.py new file mode 100644 index 00000000..5298febe --- /dev/null +++ b/tests/test_group_basic_tools.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any + +import pytest + +from Undefined.skills.toolsets.group.get_avatar.handler import execute as get_avatar +from Undefined.skills.toolsets.group.get_member_info.handler import ( + execute as get_member_info, +) + + +class _FakeOneBot: + def __init__(self, member_info: dict[str, Any]) -> None: + self.member_info = member_info + self.calls: list[tuple[int, int, bool]] = [] + + async def get_group_member_info( + self, + group_id: int, + user_id: int, + no_cache: bool, + ) -> dict[str, Any]: + self.calls.append((group_id, user_id, no_cache)) + return self.member_info + + +class _FakeAttachmentRegistry: + def __init__(self) -> None: + self.calls: list[tuple[str, str, dict[str, Any]]] = [] + + async def register_remote_url( + self, + scope_key: str, + url: str, + **kwargs: Any, + ) -> Any: + self.calls.append((scope_key, url, kwargs)) + return SimpleNamespace(uid="pic_avatar") + + +@pytest.mark.asyncio +async def test_get_member_info_brief_returns_only_display_name() -> None: + onebot = _FakeOneBot({"card": "Alice", "nickname": "Fallback"}) + + result = await get_member_info( + {"group_id": 123456, "user_id": 1001, "brief": True}, + {"onebot_client": onebot}, + ) + + assert result == "Alice" + assert onebot.calls == [(123456, 1001, False)] + + +@pytest.mark.asyncio +async def test_get_member_info_brief_falls_back_to_nickname_or_user_id() -> None: + nickname_onebot = _FakeOneBot({"card": "", "nickname": "Bob"}) + no_name_onebot = _FakeOneBot({"card": "", "nickname": ""}) + + nickname_result = await get_member_info( + {"group_id": 123456, "user_id": 1002, "brief": True}, + {"onebot_client": nickname_onebot}, + ) + no_name_result = await get_member_info( + {"group_id": 123456, "user_id": 1003, "brief": True}, + {"onebot_client": no_name_onebot}, + ) + + assert nickname_result == "Bob" + assert no_name_result == "1003" + + +@pytest.mark.asyncio +async def test_get_avatar_accepts_string_size_and_returns_attachment_tag() -> None: + registry = _FakeAttachmentRegistry() + + result = await get_avatar( + {"user_id": "1001", "size": "640"}, + {"group_id": 123456, "attachment_registry": registry}, + ) + + assert result == '' + assert registry.calls + scope_key, avatar_url, kwargs = registry.calls[0] + assert scope_key == "group:123456" + assert avatar_url == "https://q1.qlogo.cn/g?b=qq&nk=1001&s=3" + assert kwargs["kind"] == "image" + assert kwargs["display_name"] == "avatar_1001.jpg" diff --git a/tests/test_handlers_arxiv_auto_extract.py b/tests/test_handlers_arxiv_auto_extract.py index 2beb2ada..a5c67ba3 100644 --- a/tests/test_handlers_arxiv_auto_extract.py +++ b/tests/test_handlers_arxiv_auto_extract.py @@ -8,10 +8,11 @@ import Undefined.handlers as handlers_module from Undefined.handlers import MessageHandler +from Undefined.skills.auto_pipeline import AutoPipelineRegistry @pytest.mark.asyncio -async def test_private_message_schedules_arxiv_auto_extract( +async def test_private_message_runs_arxiv_auto_extract_before_ai_reply( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr( @@ -35,6 +36,7 @@ async def test_private_message_schedules_arxiv_auto_extract( get_msg=AsyncMock(), get_forward_msg=AsyncMock(), ) + handler.sender = SimpleNamespace() handler.history_manager = SimpleNamespace(add_private_message=AsyncMock()) handler.ai_coordinator = SimpleNamespace( model_pool=SimpleNamespace( @@ -42,14 +44,15 @@ async def test_private_message_schedules_arxiv_auto_extract( ), handle_private_reply=AsyncMock(), ) - handler.command_dispatcher = SimpleNamespace(parse_command=lambda _text: None) + handler.command_dispatcher = SimpleNamespace( + parse_command=MagicMock(return_value=None) + ) handler._background_tasks = set() handler._extract_arxiv_ids = MagicMock(return_value=["2501.01234"]) - - def _fake_spawn_background_task(_name: str, coroutine: Any) -> None: - coroutine.close() - - handler._spawn_background_task = MagicMock(side_effect=_fake_spawn_background_task) + handler._handle_arxiv_extract = AsyncMock() + handler.auto_pipeline_registry = AutoPipelineRegistry() + handler.auto_pipeline_registry.load_items() + handler._spawn_background_task = MagicMock() event = { "post_type": "message", @@ -63,5 +66,12 @@ def _fake_spawn_background_task(_name: str, coroutine: Any) -> None: await handler.handle_message(event) handler._extract_arxiv_ids.assert_called_once() - handler._spawn_background_task.assert_called_once() - handler.ai_coordinator.handle_private_reply.assert_not_called() + handler._handle_arxiv_extract.assert_awaited_once_with( + 20001, + ["2501.01234"], + "private", + ) + handler._spawn_background_task.assert_not_called() + handler.ai_coordinator.model_pool.handle_private_message.assert_not_called() + handler.command_dispatcher.parse_command.assert_called_once_with("arxiv 2501.01234") + handler.ai_coordinator.handle_private_reply.assert_awaited_once() diff --git a/tests/test_handlers_auto_extract_pipeline.py b/tests/test_handlers_auto_extract_pipeline.py new file mode 100644 index 00000000..82e5fea0 --- /dev/null +++ b/tests/test_handlers_auto_extract_pipeline.py @@ -0,0 +1,467 @@ +from __future__ import annotations + +import asyncio +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +import Undefined.handlers as handlers_module +from Undefined.handlers import MessageHandler +from Undefined.skills.auto_pipeline import AutoPipelineRegistry + + +@pytest.mark.asyncio +async def test_message_handler_initializes_auto_pipeline_async() -> None: + class _FakeAutoPipelineRegistry: + def __init__(self) -> None: + self.load_count = 0 + self.started: list[tuple[float, float]] = [] + + async def load_items_async(self) -> None: + await asyncio.sleep(0) + self.load_count += 1 + + def start_hot_reload(self, *, interval: float, debounce: float) -> None: + self.started.append((interval, debounce)) + + registry = _FakeAutoPipelineRegistry() + handler: Any = MessageHandler.__new__(MessageHandler) + handler.config = SimpleNamespace( + skills_hot_reload=True, + skills_hot_reload_interval=3.0, + skills_hot_reload_debounce=0.75, + ) + handler.auto_pipeline_registry = registry + handler._auto_pipeline_initialized = False + + await asyncio.gather( + handler.initialize_auto_pipeline(), + handler.initialize_auto_pipeline(), + ) + await handler.initialize_auto_pipeline() + + assert registry.load_count == 1 + assert registry.started == [(3.0, 0.75)] + assert handler._auto_pipeline_initialized is True + + +@pytest.mark.asyncio +async def test_auto_extract_pipeline_initializes_when_flag_missing() -> None: + class _FakeAutoPipelineRegistry: + def __init__(self) -> None: + self.loaded = False + self.run_context: dict[str, Any] | None = None + + async def load_items_async(self) -> None: + self.loaded = True + + async def run(self, context: dict[str, Any]) -> list[object]: + self.run_context = context + return [object()] if self.loaded else [] + + registry = _FakeAutoPipelineRegistry() + handler: Any = MessageHandler.__new__(MessageHandler) + handler.config = SimpleNamespace(skills_hot_reload=False) + handler.sender = SimpleNamespace() + handler.onebot = SimpleNamespace() + handler.auto_pipeline_registry = registry + handler._extract_bilibili_ids = AsyncMock(return_value=[]) + handler._extract_arxiv_ids = MagicMock(return_value=[]) + handler._extract_github_repo_ids = MagicMock(return_value=[]) + handler._handle_bilibili_extract = AsyncMock() + handler._handle_arxiv_extract = AsyncMock() + handler._handle_github_extract = AsyncMock() + + handled = await handler._run_auto_extract_pipeline( + target_id=20001, + target_type="private", + text="hello", + message_content=[], + ) + + assert handled is True + assert registry.loaded is True + assert registry.run_context is not None + assert handler._auto_pipeline_initialized is True + + +@pytest.mark.asyncio +async def test_auto_extract_pipeline_processes_all_matches() -> None: + handler: Any = MessageHandler.__new__(MessageHandler) + handler.sender = SimpleNamespace() + handler.onebot = SimpleNamespace() + handler.config = SimpleNamespace( + bilibili_auto_extract_enabled=True, + is_bilibili_auto_extract_allowed_private=lambda _uid: True, + arxiv_auto_extract_enabled=True, + is_arxiv_auto_extract_allowed_private=lambda _uid: True, + github_auto_extract_enabled=True, + is_github_auto_extract_allowed_private=lambda _uid: True, + ) + handler._extract_bilibili_ids = AsyncMock(return_value=["BV1xx411c7mD"]) + handler._extract_arxiv_ids = MagicMock(return_value=["2501.01234"]) + handler._extract_github_repo_ids = MagicMock(return_value=["69gg/Undefined"]) + handler._handle_bilibili_extract = AsyncMock() + handler._handle_arxiv_extract = AsyncMock() + handler._handle_github_extract = AsyncMock() + handler.auto_pipeline_registry = AutoPipelineRegistry() + handler.auto_pipeline_registry.load_items() + + handled = await handler._run_auto_extract_pipeline( + target_id=20001, + target_type="private", + text="BV1xx411c7mD 69gg/Undefined", + message_content=[], + ) + + assert handled is True + handler._handle_bilibili_extract.assert_awaited_once_with( + 20001, + ["BV1xx411c7mD"], + "private", + ) + handler._handle_arxiv_extract.assert_awaited_once_with( + 20001, + ["2501.01234"], + "private", + ) + handler._handle_github_extract.assert_awaited_once_with( + 20001, + ["69gg/Undefined"], + "private", + ) + + +@pytest.mark.asyncio +async def test_private_command_skips_auto_pipeline_and_ai( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + handlers_module, + "parse_message_content_for_history", + AsyncMock(return_value="/help"), + ) + command = object() + handler: Any = MessageHandler.__new__(MessageHandler) + handler.config = SimpleNamespace( + bot_qq=10000, + model_pool_enabled=True, + is_private_allowed=lambda _uid: True, + access_control_enabled=lambda: False, + should_process_private_message=lambda: True, + ) + handler.onebot = SimpleNamespace( + get_stranger_info=AsyncMock(return_value={"nickname": "测试用户"}), + get_msg=AsyncMock(), + get_forward_msg=AsyncMock(), + ) + handler.sender = SimpleNamespace() + handler.history_manager = SimpleNamespace(add_private_message=AsyncMock()) + handler.ai_coordinator = SimpleNamespace( + model_pool=SimpleNamespace( + handle_private_message=AsyncMock(return_value=False) + ), + handle_private_reply=AsyncMock(), + ) + handler.command_dispatcher = SimpleNamespace( + parse_command=MagicMock(return_value=command), + dispatch_private=AsyncMock(), + ) + handler.auto_pipeline_registry = SimpleNamespace( + run=AsyncMock(return_value=[]), + ) + handler._background_tasks = set() + handler._profile_name_refresh_cache = {} + + event = { + "post_type": "message", + "message_type": "private", + "user_id": 20001, + "message_id": 30001, + "message": [{"type": "text", "data": {"text": "/help"}}], + "sender": {"user_id": 20001, "nickname": "测试用户"}, + } + + await handler.handle_message(event) + + handler.command_dispatcher.dispatch_private.assert_awaited_once_with( + user_id=20001, + sender_id=20001, + command=command, + ) + handler.history_manager.add_private_message.assert_awaited_once() + assert handler.history_manager.add_private_message.await_args is not None + private_history = handler.history_manager.add_private_message.await_args.kwargs + assert private_history["text_content"] == "/help" + handler.auto_pipeline_registry.run.assert_not_awaited() + handler.ai_coordinator.model_pool.handle_private_message.assert_not_awaited() + handler.ai_coordinator.handle_private_reply.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_private_model_pool_command_runs_before_command_dispatch( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + handlers_module, + "parse_message_content_for_history", + AsyncMock(return_value="/compare hello"), + ) + command = object() + handler: Any = MessageHandler.__new__(MessageHandler) + handler.config = SimpleNamespace( + bot_qq=10000, + model_pool_enabled=True, + is_private_allowed=lambda _uid: True, + access_control_enabled=lambda: False, + should_process_private_message=lambda: True, + ) + handler.onebot = SimpleNamespace( + get_stranger_info=AsyncMock(return_value={"nickname": "测试用户"}), + get_msg=AsyncMock(), + get_forward_msg=AsyncMock(), + ) + handler.sender = SimpleNamespace() + handler.history_manager = SimpleNamespace(add_private_message=AsyncMock()) + handler.ai_coordinator = SimpleNamespace( + model_pool=SimpleNamespace(handle_private_message=AsyncMock(return_value=True)), + handle_private_reply=AsyncMock(), + ) + handler.command_dispatcher = SimpleNamespace( + parse_command=MagicMock(return_value=command), + dispatch_private=AsyncMock(), + ) + handler.auto_pipeline_registry = SimpleNamespace(run=AsyncMock(return_value=[])) + handler._background_tasks = set() + handler._profile_name_refresh_cache = {} + handler._collect_message_attachments = AsyncMock(return_value=[]) + handler._extract_bilibili_ids = AsyncMock(return_value=[]) + handler._extract_arxiv_ids = MagicMock(return_value=[]) + handler._extract_github_repo_ids = MagicMock(return_value=[]) + handler._handle_bilibili_extract = AsyncMock() + handler._handle_arxiv_extract = AsyncMock() + handler._handle_github_extract = AsyncMock() + handler._schedule_profile_display_name_refresh = MagicMock() + handler._schedule_meme_ingest = MagicMock() + + event = { + "post_type": "message", + "message_type": "private", + "user_id": 20001, + "message_id": 30001, + "message": [{"type": "text", "data": {"text": "/compare hello"}}], + "sender": {"user_id": 20001, "nickname": "测试用户"}, + } + + await handler.handle_message(event) + + handler.ai_coordinator.model_pool.handle_private_message.assert_awaited_once_with( + 20001, + "/compare hello", + ) + handler.command_dispatcher.parse_command.assert_not_called() + handler.command_dispatcher.dispatch_private.assert_not_awaited() + handler.auto_pipeline_registry.run.assert_not_awaited() + handler.ai_coordinator.handle_private_reply.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_private_message_starting_with_select_does_not_touch_model_pool( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + handlers_module, + "parse_message_content_for_history", + AsyncMock(return_value="选择 69gg/Undefined 看看"), + ) + handler: Any = MessageHandler.__new__(MessageHandler) + handler.config = SimpleNamespace( + bot_qq=10000, + is_private_allowed=lambda _uid: True, + access_control_enabled=lambda: False, + should_process_private_message=lambda: True, + ) + handler.onebot = SimpleNamespace( + get_stranger_info=AsyncMock(return_value={"nickname": "测试用户"}), + get_msg=AsyncMock(), + get_forward_msg=AsyncMock(), + ) + handler.sender = SimpleNamespace() + handler.history_manager = SimpleNamespace(add_private_message=AsyncMock()) + handler.ai_coordinator = SimpleNamespace( + model_pool=SimpleNamespace( + handle_private_message=AsyncMock(return_value=False) + ), + handle_private_reply=AsyncMock(), + ) + handler.command_dispatcher = SimpleNamespace( + parse_command=MagicMock(return_value=None), + dispatch_private=AsyncMock(), + ) + handler.auto_pipeline_registry = SimpleNamespace(run=AsyncMock(return_value=[])) + handler._auto_pipeline_initialized = True + handler._background_tasks = set() + handler._profile_name_refresh_cache = {} + handler._collect_message_attachments = AsyncMock(return_value=[]) + handler._extract_bilibili_ids = AsyncMock(return_value=[]) + handler._extract_arxiv_ids = MagicMock(return_value=[]) + handler._extract_github_repo_ids = MagicMock(return_value=[]) + handler._handle_bilibili_extract = AsyncMock() + handler._handle_arxiv_extract = AsyncMock() + handler._handle_github_extract = AsyncMock() + handler._schedule_profile_display_name_refresh = MagicMock() + handler._schedule_meme_ingest = MagicMock() + + event = { + "post_type": "message", + "message_type": "private", + "user_id": 20001, + "message_id": 30001, + "message": [{"type": "text", "data": {"text": "选择 69gg/Undefined 看看"}}], + "sender": {"user_id": 20001, "nickname": "测试用户"}, + } + + await handler.handle_message(event) + + handler.ai_coordinator.model_pool.handle_private_message.assert_not_awaited() + handler.auto_pipeline_registry.run.assert_awaited_once() + handler.ai_coordinator.handle_private_reply.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_private_model_pool_command_ignored_when_pool_disabled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + handlers_module, + "parse_message_content_for_history", + AsyncMock(return_value="/compare hello"), + ) + handler: Any = MessageHandler.__new__(MessageHandler) + handler.config = SimpleNamespace( + bot_qq=10000, + model_pool_enabled=False, + is_private_allowed=lambda _uid: True, + access_control_enabled=lambda: False, + should_process_private_message=lambda: True, + ) + handler.onebot = SimpleNamespace( + get_stranger_info=AsyncMock(return_value={"nickname": "测试用户"}), + get_msg=AsyncMock(), + get_forward_msg=AsyncMock(), + ) + handler.sender = SimpleNamespace() + handler.history_manager = SimpleNamespace(add_private_message=AsyncMock()) + handler.ai_coordinator = SimpleNamespace( + model_pool=SimpleNamespace( + handle_private_message=AsyncMock(return_value=False) + ), + handle_private_reply=AsyncMock(), + ) + handler.command_dispatcher = SimpleNamespace( + parse_command=MagicMock(return_value=None), + dispatch_private=AsyncMock(), + ) + handler.auto_pipeline_registry = SimpleNamespace(run=AsyncMock(return_value=[])) + handler._auto_pipeline_initialized = True + handler._background_tasks = set() + handler._profile_name_refresh_cache = {} + handler._collect_message_attachments = AsyncMock(return_value=[]) + handler._extract_bilibili_ids = AsyncMock(return_value=[]) + handler._extract_arxiv_ids = MagicMock(return_value=[]) + handler._extract_github_repo_ids = MagicMock(return_value=[]) + handler._handle_bilibili_extract = AsyncMock() + handler._handle_arxiv_extract = AsyncMock() + handler._handle_github_extract = AsyncMock() + handler._schedule_profile_display_name_refresh = MagicMock() + handler._schedule_meme_ingest = MagicMock() + + event = { + "post_type": "message", + "message_type": "private", + "user_id": 20001, + "message_id": 30001, + "message": [{"type": "text", "data": {"text": "/compare hello"}}], + "sender": {"user_id": 20001, "nickname": "测试用户"}, + } + + await handler.handle_message(event) + + handler.ai_coordinator.model_pool.handle_private_message.assert_not_awaited() + handler.command_dispatcher.parse_command.assert_called_once_with("/compare hello") + handler.auto_pipeline_registry.run.assert_awaited_once() + handler.ai_coordinator.handle_private_reply.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_group_command_skips_auto_pipeline_and_ai( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + handlers_module, + "parse_message_content_for_history", + AsyncMock(return_value="/help"), + ) + command = object() + handler: Any = MessageHandler.__new__(MessageHandler) + handler.config = SimpleNamespace( + bot_qq=10000, + is_group_allowed=lambda _gid: True, + access_control_enabled=lambda: False, + should_process_group_message=lambda is_at_bot=False: True, + process_every_message=True, + keyword_reply_enabled=False, + repeat_enabled=False, + ) + handler.onebot = SimpleNamespace( + get_group_info=AsyncMock(return_value={"group_name": "测试群"}), + get_msg=AsyncMock(), + get_forward_msg=AsyncMock(), + ) + handler.history_manager = SimpleNamespace(add_group_message=AsyncMock()) + handler.ai_coordinator = SimpleNamespace( + _is_at_bot=MagicMock(return_value=True), + handle_auto_reply=AsyncMock(), + ) + handler.command_dispatcher = SimpleNamespace( + parse_command=MagicMock(return_value=command), + dispatch=AsyncMock(), + ) + handler.auto_pipeline_registry = SimpleNamespace( + run=AsyncMock(return_value=[]), + ) + handler._schedule_profile_display_name_refresh = MagicMock() + handler._schedule_meme_ingest = MagicMock() + handler._background_tasks = set() + + event = { + "post_type": "message", + "message_type": "group", + "group_id": 30001, + "user_id": 20001, + "message_id": 30001, + "sender": { + "user_id": 20001, + "card": "测试用户", + "nickname": "测试用户", + "role": "member", + "title": "", + }, + "message": [{"type": "text", "data": {"text": "/help"}}], + } + + await handler.handle_message(event) + + handler.command_dispatcher.dispatch.assert_awaited_once_with( + 30001, + 20001, + command, + ) + handler.history_manager.add_group_message.assert_awaited_once() + assert handler.history_manager.add_group_message.await_args is not None + group_history = handler.history_manager.add_group_message.await_args.kwargs + assert group_history["text_content"] == "/help" + handler.auto_pipeline_registry.run.assert_not_awaited() + handler.ai_coordinator.handle_auto_reply.assert_not_awaited() diff --git a/tests/test_handlers_github_auto_extract.py b/tests/test_handlers_github_auto_extract.py new file mode 100644 index 00000000..e88f293d --- /dev/null +++ b/tests/test_handlers_github_auto_extract.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +import Undefined.handlers as handlers_module +from Undefined.handlers import MessageHandler +from Undefined.skills.auto_pipeline import AutoPipelineRegistry + + +@pytest.mark.asyncio +async def test_private_message_runs_github_auto_extract_before_ai_reply( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + handlers_module, + "parse_message_content_for_history", + AsyncMock(return_value="repo 69gg/Undefined"), + ) + + handler: Any = MessageHandler.__new__(MessageHandler) + handler.config = SimpleNamespace( + bot_qq=10000, + is_private_allowed=lambda _uid: True, + access_control_enabled=lambda: False, + should_process_private_message=lambda: True, + bilibili_auto_extract_enabled=False, + arxiv_auto_extract_enabled=False, + github_auto_extract_enabled=True, + is_github_auto_extract_allowed_private=lambda _uid: True, + ) + handler.onebot = SimpleNamespace( + get_stranger_info=AsyncMock(return_value={"nickname": "测试用户"}), + get_msg=AsyncMock(), + get_forward_msg=AsyncMock(), + ) + handler.sender = SimpleNamespace() + handler.history_manager = SimpleNamespace(add_private_message=AsyncMock()) + handler.ai_coordinator = SimpleNamespace( + model_pool=SimpleNamespace( + handle_private_message=AsyncMock(return_value=False) + ), + handle_private_reply=AsyncMock(), + ) + handler.command_dispatcher = SimpleNamespace( + parse_command=MagicMock(return_value=None) + ) + handler._background_tasks = set() + handler._extract_github_repo_ids = MagicMock(return_value=["69gg/Undefined"]) + handler._handle_github_extract = AsyncMock() + handler.auto_pipeline_registry = AutoPipelineRegistry() + handler.auto_pipeline_registry.load_items() + handler._spawn_background_task = MagicMock() + + event = { + "post_type": "message", + "message_type": "private", + "user_id": 20001, + "message_id": 30001, + "message": [{"type": "text", "data": {"text": "69gg/Undefined"}}], + "sender": {"user_id": 20001, "nickname": "测试用户"}, + } + + await handler.handle_message(event) + + handler._extract_github_repo_ids.assert_called_once() + handler._handle_github_extract.assert_awaited_once_with( + 20001, + ["69gg/Undefined"], + "private", + ) + handler._spawn_background_task.assert_not_called() + handler.ai_coordinator.model_pool.handle_private_message.assert_not_called() + handler.command_dispatcher.parse_command.assert_called_once_with("69gg/Undefined") + handler.ai_coordinator.handle_private_reply.assert_awaited_once() diff --git a/tests/test_handlers_repeat.py b/tests/test_handlers_repeat.py index c305b6bb..55207e69 100644 --- a/tests/test_handlers_repeat.py +++ b/tests/test_handlers_repeat.py @@ -47,6 +47,10 @@ def _build_handler( send_group_message=AsyncMock(), send_private_message=AsyncMock(), ) + handler.auto_pipeline_registry = SimpleNamespace( + run=AsyncMock(return_value=[]), + ) + handler._auto_pipeline_initialized = True handler.ai_coordinator = SimpleNamespace( handle_auto_reply=AsyncMock(), handle_private_reply=AsyncMock(), @@ -119,14 +123,21 @@ async def test_repeat_disabled_does_not_repeat() -> None: @pytest.mark.asyncio async def test_repeat_triggers_on_3_identical_from_different_senders() -> None: handler = _build_handler(repeat_enabled=True) - for uid in [20001, 20002, 20003]: + for uid in [20001, 20002]: await handler.handle_message(_group_event(sender_id=uid, text="hello")) + handler.auto_pipeline_registry.run.reset_mock() + handler.ai_coordinator.handle_auto_reply.reset_mock() + await handler.handle_message(_group_event(sender_id=20003, text="hello")) + handler.sender.send_group_message.assert_called_once() call = handler.sender.send_group_message.call_args assert call.args[0] == 30001 assert call.args[1] == "hello" assert call.kwargs.get("history_prefix") == REPEAT_REPLY_HISTORY_PREFIX + handler.auto_pipeline_registry.run.assert_not_called() + handler.ai_coordinator.handle_auto_reply.assert_not_called() + handler._bot_nickname_cache.get_nicknames.assert_not_called() # ── 不触发:3条相同消息来自同一人 ── diff --git a/tests/test_history_level.py b/tests/test_history_level.py index 59a88d4e..a8a4f4a6 100644 --- a/tests/test_history_level.py +++ b/tests/test_history_level.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +from pathlib import Path import pytest @@ -37,6 +38,7 @@ async def fake_save(data: list[dict[str, object]], path: str) -> None: level="Lv.5", message_id=123456, ) + await manager.flush_pending_saves() assert "20001" in manager._message_history assert len(manager._message_history["20001"]) == 1 @@ -78,12 +80,59 @@ async def fake_save(data: list[dict[str, object]], path: str) -> None: sender_card="测试用户", group_name="测试群", ) + await manager.flush_pending_saves() assert "20001" in manager._message_history record = manager._message_history["20001"][0] assert record["level"] == "" +@pytest.mark.asyncio +async def test_history_save_failure_keeps_pending_snapshot( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + from Undefined.utils import io + + manager = MessageHistoryManager.__new__(MessageHistoryManager) + manager._max_records = 10000 + manager._pending_history_saves = {} + manager._history_save_tasks = {} + path = str(tmp_path / "group_20001.json") + attempts = 0 + saved_data: list[list[dict[str, object]]] = [] + + async def fake_write_json( + saved_path: str, + data: list[dict[str, object]], + *, + use_lock: bool = True, + ) -> None: + nonlocal attempts + assert saved_path == path + assert use_lock is True + attempts += 1 + if attempts == 1: + raise OSError("disk full") + saved_data.append(data) + + monkeypatch.setattr(io, "write_json", fake_write_json) + + first_snapshot = [{"message": "first"}] + manager._queue_history_save(first_snapshot, path) + await manager.flush_pending_saves() + + assert manager._pending_history_saves[path] == first_snapshot + assert path not in manager._history_save_tasks + + second_snapshot = [{"message": "second"}] + manager._queue_history_save(second_snapshot, path) + await manager.flush_pending_saves() + + assert manager._pending_history_saves == {} + assert saved_data == [second_snapshot] + + @pytest.mark.asyncio async def test_get_recent_returns_messages_with_level_intact( monkeypatch: pytest.MonkeyPatch, @@ -168,7 +217,88 @@ async def fake_save(data: list[dict[str, object]], path: str) -> None: text_content="测试消息", level="", ) + await manager.flush_pending_saves() record = manager._message_history["20001"][0] assert "level" in record assert record["level"] == "" + + +@pytest.mark.asyncio +async def test_add_group_message_does_not_wait_for_disk_save( + monkeypatch: pytest.MonkeyPatch, +) -> None: + manager = MessageHistoryManager.__new__(MessageHistoryManager) + manager._message_history = {} + manager._max_records = 10000 + manager._initialized = asyncio.Event() + manager._initialized.set() + manager._group_locks = {} + + save_started = asyncio.Event() + allow_save = asyncio.Event() + saved_data: list[list[dict[str, object]]] = [] + + async def fake_save(data: list[dict[str, object]], path: str) -> None: + _ = path + save_started.set() + await allow_save.wait() + saved_data.append(data) + + monkeypatch.setattr(manager, "_save_history_to_file", fake_save) + + await asyncio.wait_for( + manager.add_group_message( + group_id=20001, + sender_id=10001, + text_content="测试消息", + ), + timeout=1, + ) + + assert manager._message_history["20001"][0]["message"] == "测试消息" + assert saved_data == [] + + await asyncio.wait_for(save_started.wait(), timeout=1) + allow_save.set() + await manager.flush_pending_saves() + + assert saved_data[-1][0]["message"] == "测试消息" + + +@pytest.mark.asyncio +async def test_group_message_disk_saves_are_coalesced( + monkeypatch: pytest.MonkeyPatch, +) -> None: + manager = MessageHistoryManager.__new__(MessageHistoryManager) + manager._message_history = {} + manager._max_records = 10000 + manager._initialized = asyncio.Event() + manager._initialized.set() + manager._group_locks = {} + + first_save_started = asyncio.Event() + allow_first_save = asyncio.Event() + saved_messages: list[list[str]] = [] + + async def fake_save(data: list[dict[str, object]], path: str) -> None: + _ = path + saved_messages.append([str(item["message"]) for item in data]) + if len(saved_messages) == 1: + first_save_started.set() + await allow_first_save.wait() + + monkeypatch.setattr(manager, "_save_history_to_file", fake_save) + + await manager.add_group_message(20001, 10001, "第一条") + await asyncio.wait_for(first_save_started.wait(), timeout=1) + await manager.add_group_message(20001, 10002, "第二条") + await manager.add_group_message(20001, 10003, "第三条") + + allow_first_save.set() + await manager.flush_pending_saves() + + assert saved_messages == [ + ["第一条"], + ["第一条", "第二条", "第三条"], + ] diff --git a/tests/test_llm_request_params.py b/tests/test_llm_request_params.py index 59ef377d..b2ebb644 100644 --- a/tests/test_llm_request_params.py +++ b/tests/test_llm_request_params.py @@ -6,12 +6,19 @@ import httpx import pytest -from openai import AsyncOpenAI, BadRequestError +from openai import ( + APIConnectionError, + APIStatusError, + APITimeoutError, + AsyncOpenAI, + BadRequestError, +) from Undefined.ai.client import AIClient from Undefined.ai.llm import ( ModelRequester, _encode_tool_name_for_api, + _should_fallback_from_stream, build_request_body, ) from Undefined.ai.transports.openai_transport import ( @@ -47,6 +54,19 @@ async def create(self, **kwargs: Any) -> dict[str, Any]: } +class _FakeAsyncStream: + def __init__(self, events: list[Any]) -> None: + self._events = list(events) + + def __aiter__(self) -> _FakeAsyncStream: + return self + + async def __anext__(self) -> Any: + if not self._events: + raise StopAsyncIteration + return self._events.pop(0) + + class _FakeResponsesAPI: def __init__(self, responses: list[Any] | None = None) -> None: self.last_kwargs: dict[str, Any] | None = None @@ -70,6 +90,16 @@ def _make_bad_request_error(message: str, body: dict[str, Any]) -> BadRequestErr return BadRequestError(message, response=response, body=body) +def _make_api_status_error( + status_code: int, + message: str, + body: dict[str, Any], +) -> APIStatusError: + request = httpx.Request("POST", "https://api.example.com/v1/chat/completions") + response = httpx.Response(status_code, request=request, json=body) + return APIStatusError(message, response=response, body=body) + + class _FakeClient: def __init__( self, @@ -83,6 +113,47 @@ def __init__( self.responses = _FakeResponsesAPI(responses) +class _FakeStreamingClient: + def __init__( + self, + *, + chat_events: list[dict[str, Any]] | None = None, + response_events: list[dict[str, Any]] | None = None, + ) -> None: + self.chat = type( + "_Chat", + (), + { + "completions": SimpleNamespace( + last_kwargs=None, + calls=[], + create=self._create_chat(chat_events or []), + ) + }, + )() + self.responses = SimpleNamespace( + last_kwargs=None, + calls=[], + create=self._create_responses(response_events or []), + ) + + def _create_chat(self, events: list[dict[str, Any]]) -> Any: + async def _create(**kwargs: Any) -> _FakeAsyncStream: + self.chat.completions.last_kwargs = dict(kwargs) + self.chat.completions.calls.append(dict(kwargs)) + return _FakeAsyncStream(events) + + return _create + + def _create_responses(self, events: list[dict[str, Any]]) -> Any: + async def _create(**kwargs: Any) -> _FakeAsyncStream: + self.responses.last_kwargs = dict(kwargs) + self.responses.calls.append(dict(kwargs)) + return _FakeAsyncStream(events) + + return _create + + @pytest.mark.asyncio async def test_chat_request_uses_model_reasoning_and_request_params( caplog: pytest.LogCaptureFixture, @@ -1627,3 +1698,271 @@ async def test_thinking_enabled_legacy_budget_tokens() -> None: assert kw["extra_body"]["thinking"] == {"type": "enabled", "budget_tokens": 8000} await requester._http_client.aclose() + + +@pytest.mark.asyncio +async def test_chat_request_streaming_aggregates_content_and_tool_calls() -> None: + requester = ModelRequester( + http_client=httpx.AsyncClient(), + token_usage_storage=cast(TokenUsageStorage, _FakeUsageStorage()), + ) + fake_client = _FakeStreamingClient( + chat_events=[ + { + "choices": [ + { + "delta": {"role": "assistant", "content": "hel"}, + "finish_reason": None, + } + ] + }, + { + "choices": [ + { + "delta": { + "content": "lo", + "tool_calls": [ + { + "index": 0, + "id": "call_1", + "type": "function", + "function": {"name": "lookup", "arguments": '{"q"'}, + } + ], + }, + "finish_reason": None, + } + ] + }, + { + "choices": [ + { + "delta": { + "tool_calls": [ + { + "index": 0, + "function": {"arguments": ':"weather"}'}, + } + ], + }, + "finish_reason": "tool_calls", + } + ], + "usage": { + "prompt_tokens": 3, + "completion_tokens": 4, + "total_tokens": 7, + }, + }, + ] + ) + setattr( + requester, + "_get_openai_client_for_model", + lambda _cfg: cast(AsyncOpenAI, fake_client), + ) + cfg = ChatModelConfig( + api_url="https://api.openai.com/v1", + api_key="sk-test", + model_name="gpt-test", + max_tokens=512, + stream_enabled=True, + ) + + result = await requester.request( + model_config=cfg, + messages=[{"role": "user", "content": "hello"}], + max_tokens=128, + call_type="chat", + ) + + assert fake_client.chat.completions.last_kwargs is not None + assert fake_client.chat.completions.last_kwargs["stream"] is True + assert fake_client.chat.completions.last_kwargs["stream_options"] == { + "include_usage": True + } + assert extract_choices_content(result) == "hello" + assert result["choices"][0]["finish_reason"] == "tool_calls" + assert result["choices"][0]["message"]["tool_calls"][0]["id"] == "call_1" + assert ( + result["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"] + == '{"q":"weather"}' + ) + assert result["usage"] == { + "prompt_tokens": 3, + "completion_tokens": 4, + "total_tokens": 7, + } + + await requester._http_client.aclose() + + +@pytest.mark.asyncio +async def test_chat_request_streaming_preserves_content_whitespace() -> None: + requester = ModelRequester( + http_client=httpx.AsyncClient(), + token_usage_storage=cast(TokenUsageStorage, _FakeUsageStorage()), + ) + fake_client = _FakeStreamingClient( + chat_events=[ + {"choices": [{"delta": {"role": "assistant", "content": " code"}}]}, + {"choices": [{"delta": {"content": "\n indented "}}]}, + ] + ) + setattr( + requester, + "_get_openai_client_for_model", + lambda _cfg: cast(AsyncOpenAI, fake_client), + ) + cfg = ChatModelConfig( + api_url="https://api.openai.com/v1", + api_key="sk-test", + model_name="gpt-test", + max_tokens=512, + stream_enabled=True, + ) + + result = await requester.request( + model_config=cfg, + messages=[{"role": "user", "content": "hello"}], + max_tokens=128, + call_type="chat", + ) + + assert extract_choices_content(result) == " code\n indented " + + await requester._http_client.aclose() + + +def test_stream_fallback_keeps_programming_errors_visible() -> None: + request = httpx.Request("POST", "https://api.example.com/v1/chat/completions") + + assert _should_fallback_from_stream( + _make_bad_request_error( + "streaming unsupported", + {"error": {"message": "streaming unsupported"}}, + ) + ) + assert _should_fallback_from_stream(NotImplementedError("streaming unavailable")) + assert not _should_fallback_from_stream( + _make_api_status_error( + 401, + "invalid api key", + {"error": {"message": "invalid api key"}}, + ) + ) + assert not _should_fallback_from_stream( + _make_api_status_error( + 429, + "rate limit", + {"error": {"message": "rate limit exceeded"}}, + ) + ) + assert not _should_fallback_from_stream(APIConnectionError(request=request)) + assert not _should_fallback_from_stream(APITimeoutError(request=request)) + assert not _should_fallback_from_stream(AttributeError("parser bug")) + assert not _should_fallback_from_stream(TypeError("unexpected event shape")) + assert not _should_fallback_from_stream(ValueError("malformed internal state")) + + +@pytest.mark.asyncio +async def test_responses_request_streaming_prefers_completed_response_payload() -> None: + requester = ModelRequester( + http_client=httpx.AsyncClient(), + token_usage_storage=cast(TokenUsageStorage, _FakeUsageStorage()), + ) + fake_client = _FakeStreamingClient( + response_events=[ + {"type": "response.output_text.delta", "delta": "partial "}, + { + "type": "response.completed", + "response": { + "id": "resp_stream", + "output": [ + { + "type": "message", + "role": "assistant", + "content": [ + {"type": "output_text", "text": "final answer"} + ], + } + ], + "usage": { + "input_tokens": 8, + "output_tokens": 5, + "total_tokens": 13, + }, + }, + }, + ] + ) + setattr( + requester, + "_get_openai_client_for_model", + lambda _cfg: cast(AsyncOpenAI, fake_client), + ) + cfg = ChatModelConfig( + api_url="https://api.openai.com/v1", + api_key="sk-test", + model_name="gpt-test", + max_tokens=512, + api_mode="responses", + stream_enabled=True, + ) + + result = await requester.request( + model_config=cfg, + messages=[{"role": "user", "content": "hello"}], + max_tokens=128, + call_type="chat", + ) + + assert fake_client.responses.last_kwargs is not None + assert fake_client.responses.last_kwargs["stream"] is True + assert extract_choices_content(result) == "final answer" + assert result["usage"] == { + "prompt_tokens": 8, + "completion_tokens": 5, + "total_tokens": 13, + } + + await requester._http_client.aclose() + + +@pytest.mark.asyncio +async def test_responses_request_streaming_preserves_synthesized_whitespace() -> None: + requester = ModelRequester( + http_client=httpx.AsyncClient(), + token_usage_storage=cast(TokenUsageStorage, _FakeUsageStorage()), + ) + fake_client = _FakeStreamingClient( + response_events=[ + {"type": "response.output_text.delta", "delta": " code"}, + {"type": "response.output_text.delta", "delta": "\n indented "}, + ] + ) + setattr( + requester, + "_get_openai_client_for_model", + lambda _cfg: cast(AsyncOpenAI, fake_client), + ) + cfg = ChatModelConfig( + api_url="https://api.openai.com/v1", + api_key="sk-test", + model_name="gpt-test", + max_tokens=512, + api_mode="responses", + stream_enabled=True, + ) + + result = await requester.request( + model_config=cfg, + messages=[{"role": "user", "content": "hello"}], + max_tokens=128, + call_type="chat", + ) + + assert extract_choices_content(result) == " code\n indented " + assert result["output_text"] == " code\n indented " + + await requester._http_client.aclose() diff --git a/tests/test_model_pool.py b/tests/test_model_pool.py index 92c9e4f8..5cf677f1 100644 --- a/tests/test_model_pool.py +++ b/tests/test_model_pool.py @@ -154,6 +154,9 @@ def test_resolve_compare_invalid_format( result = model_selector.try_resolve_compare(0, user_id, "选择1") assert result is None + result = model_selector.try_resolve_compare(0, user_id, "选1继续") + assert result is None + result = model_selector.try_resolve_compare(0, user_id, "1") assert result is None @@ -321,6 +324,8 @@ def test_select_chat_config_preserves_responses_flags( api_mode="responses", responses_tool_choice_compat=True, responses_force_stateless_replay=True, + reasoning_effort_style="anthropic", + stream_enabled=True, ) ], ), @@ -332,6 +337,8 @@ def test_select_chat_config_preserves_responses_flags( assert result.api_mode == "responses" assert result.responses_tool_choice_compat is True assert result.responses_force_stateless_replay is True + assert result.reasoning_effort_style == "anthropic" + assert result.stream_enabled is True def test_select_agent_config_preserves_responses_flags( self, @@ -354,6 +361,8 @@ def test_select_agent_config_preserves_responses_flags( api_mode="responses", responses_tool_choice_compat=True, responses_force_stateless_replay=True, + reasoning_effort_style="anthropic", + stream_enabled=True, ) ], ), @@ -365,6 +374,8 @@ def test_select_agent_config_preserves_responses_flags( assert result.api_mode == "responses" assert result.responses_tool_choice_compat is True assert result.responses_force_stateless_replay is True + assert result.reasoning_effort_style == "anthropic" + assert result.stream_enabled is True class TestModelPoolServiceHandleMessage: @@ -477,6 +488,13 @@ async def test_handle_normal_message( assert consumed is False + consumed = await model_pool_service.handle_private_message( + user_id, + "选择一个 GitHub 仓库看看", + ) + + assert consumed is False + @pytest.mark.asyncio async def test_handle_message_when_pool_disabled( self, model_pool_service: ModelPoolService, mock_config: MagicMock @@ -495,17 +513,31 @@ async def test_handle_message_when_pool_disabled( class TestModelPoolServiceCompare: """测试 ModelPoolService 的 compare 功能""" + def test_private_control_text_is_strict(self) -> None: + """测试只有明确模型池控制消息才会被提前拦截""" + assert ModelPoolService.is_private_control_text("/compare") is True + assert ModelPoolService.is_private_control_text("/compare 你好") is True + assert ModelPoolService.is_private_control_text("/pk\t你好") is True + assert ModelPoolService.is_private_control_text("选1") is True + assert ModelPoolService.is_private_control_text("选 2") is True + assert ModelPoolService.is_private_control_text("选择一个仓库") is False + assert ModelPoolService.is_private_control_text("选1继续") is False + assert ModelPoolService.is_private_control_text("/comparex 你好") is False + @pytest.mark.asyncio async def test_compare_without_space( self, model_pool_service: ModelPoolService, mock_sender: MagicMock ) -> None: - """测试 /compare 后面没有空格不会被识别""" + """测试 /compare 后面没有参数时返回用法""" user_id = 12345 consumed = await model_pool_service.handle_private_message(user_id, "/compare") - assert consumed is False - mock_sender.send_private_message.assert_not_called() + assert consumed is True + mock_sender.send_private_message.assert_called_once_with( + user_id, + "用法: /compare <问题>", + ) @pytest.mark.asyncio async def test_compare_single_model( diff --git a/tests/test_naga_command.py b/tests/test_naga_command.py index 6ec2c294..17f90bd8 100644 --- a/tests/test_naga_command.py +++ b/tests/test_naga_command.py @@ -109,7 +109,7 @@ async def test_naga_hidden_from_help_in_non_allowlisted_group( allowed_groups={123}, ) - await help_execute([], context) + await help_execute(["-t"], context) assert sender.group_messages output = sender.group_messages[-1][1] @@ -131,11 +131,12 @@ async def test_naga_visible_in_help_for_superadmin_private( allowed_groups={123}, ) - await help_execute([], context) + await help_execute(["-t"], context) - assert sender.group_messages - output = sender.group_messages[-1][1] - assert "/naga [参数]" in output + assert sender.private_messages + output = sender.private_messages[-1][1] + assert "/naga" in output + assert "NagaAgent" in output @pytest.mark.asyncio @@ -154,10 +155,10 @@ async def test_naga_hidden_when_runtime_api_disabled( ) context.config.api.enabled = False - await help_execute([], context) + await help_execute(["-t"], context) - assert sender.group_messages - output = sender.group_messages[-1][1] + assert sender.private_messages + output = sender.private_messages[-1][1] assert "/naga [参数]" not in output @@ -214,6 +215,38 @@ async def _accepted(*_: Any, **__: Any) -> tuple[str, str]: assert "等待 Naga 端确认" in sender.group_messages[-1][1] +@pytest.mark.asyncio +async def test_naga_bind_uses_dispatch_resolved_subcommand( + registry: CommandRegistry, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + sender = _DummySender() + store = NagaStore(tmp_path / "naga_bindings.json") + context = _context( + sender=sender, + registry=registry, + scope="group", + group_id=123, + allowed_groups={123}, + store=store, + ) + context.resolved_subcommand = "bind" + + async def _accepted(*_: Any, **__: Any) -> tuple[str, str]: + return "accepted", "HTTP 202" + + monkeypatch.setattr(naga_handler, "_submit_bind_request_to_naga", _accepted) + + await naga_handler.execute(["bind", "alice"], context) + + pending = store.get_pending("alice") + assert pending is not None + assert store.get_pending("bind") is None + assert sender.group_messages + assert "等待 Naga 端确认" in sender.group_messages[-1][1] + + @pytest.mark.asyncio async def test_naga_bind_reuses_existing_pending_without_duplicate_submit( registry: CommandRegistry, diff --git a/tests/test_profile_command.py b/tests/test_profile_command.py index b00cd7b0..e18e45cf 100644 --- a/tests/test_profile_command.py +++ b/tests/test_profile_command.py @@ -1,5 +1,6 @@ from __future__ import annotations +from pathlib import Path from types import SimpleNamespace from typing import Any, cast from unittest.mock import AsyncMock @@ -33,6 +34,21 @@ async def send_private_message( self.private_messages.append((user_id, message)) +class _ForwardSender(_DummySender): + def __init__(self) -> None: + super().__init__() + self.forward_messages: list[tuple[int, list[dict[str, Any]], str]] = [] + + async def send_group_forward_message( + self, + group_id: int, + messages: list[dict[str, Any]], + *, + history_message: str, + ) -> None: + self.forward_messages.append((group_id, messages, history_message)) + + def _build_context( *, sender: _DummySender | None = None, @@ -47,6 +63,7 @@ def _build_context( config_stub.is_superadmin = lambda qq: qq == superadmin_qq config_stub.bot_qq = 0 stub = cast(Any, SimpleNamespace()) + stub.send_forward_msg = AsyncMock() if sender is None: sender = _DummySender() return CommandContext( @@ -68,12 +85,49 @@ def _build_context( ) +def _patch_profile_render(monkeypatch: pytest.MonkeyPatch) -> None: + import Undefined.render as render_module + + async def fake_render_html_to_image( + _html_content: str, + output_path: str, + *, + viewport_width: int = 1280, + ) -> None: + assert viewport_width == 480 + Path(output_path).write_bytes(b"png") + + monkeypatch.setattr( + render_module, "render_html_to_image", fake_render_html_to_image + ) + + +def _patch_profile_render_failure(monkeypatch: pytest.MonkeyPatch) -> None: + import Undefined.render as render_module + + async def fake_render_html_to_image( + _html_content: str, + _output_path: str, + *, + viewport_width: int = 1280, + ) -> None: + assert viewport_width == 480 + raise RuntimeError("render failed") + + monkeypatch.setattr( + render_module, "render_html_to_image", fake_render_html_to_image + ) + + # -- Private chat tests -- @pytest.mark.asyncio -async def test_profile_private_own_profile_found() -> None: - """Private chat, own profile found → sends profile via send_private_message.""" +async def test_profile_private_own_profile_found( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Private chat, own profile found → default renders image.""" + _patch_profile_render(monkeypatch) sender = _DummySender() cognitive_service = AsyncMock() cognitive_service.get_profile = AsyncMock(return_value="这是一个用户侧写") @@ -91,7 +145,7 @@ async def test_profile_private_own_profile_found() -> None: assert len(sender.private_messages) == 1 assert sender.private_messages[0][0] == 99999 - assert "这是一个用户侧写" in sender.private_messages[0][1] + assert sender.private_messages[0][1].startswith("[CQ:image,file=file://") cognitive_service.get_profile.assert_called_once_with("user", "99999") @@ -144,7 +198,7 @@ async def test_profile_private_group_subcommand_rejected() -> None: @pytest.mark.asyncio async def test_profile_group_own_profile() -> None: - """Group chat, own profile → sends profile via send_group_message.""" + """Group chat, own profile with -t → sends text via send_group_message.""" sender = _DummySender() cognitive_service = AsyncMock() cognitive_service.get_profile = AsyncMock(return_value="群成员侧写数据") @@ -157,7 +211,7 @@ async def test_profile_group_own_profile() -> None: sender_id=55555, ) - await profile_execute([], context) + await profile_execute(["-t"], context) assert len(sender.group_messages) == 1 assert sender.group_messages[0][0] == 123456 @@ -165,9 +219,146 @@ async def test_profile_group_own_profile() -> None: cognitive_service.get_profile.assert_called_once_with("user", "55555") +@pytest.mark.asyncio +async def test_profile_group_default_renders_image( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Group chat without output flag renders profile as image by default.""" + _patch_profile_render(monkeypatch) + sender = _DummySender() + cognitive_service = AsyncMock() + cognitive_service.get_profile = AsyncMock(return_value="群成员侧写数据") + + context = _build_context( + sender=sender, + cognitive_service=cognitive_service, + scope="group", + group_id=123456, + sender_id=55555, + ) + + await profile_execute([], context) + + assert len(sender.group_messages) == 1 + assert sender.group_messages[0][1].startswith("[CQ:image,file=file://") + cognitive_service.get_profile.assert_called_once_with("user", "55555") + + +@pytest.mark.asyncio +async def test_profile_group_render_failure_falls_back_to_forward( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Group chat render failure falls back to merged forward instead of plain text.""" + _patch_profile_render_failure(monkeypatch) + sender = _DummySender() + cognitive_service = AsyncMock() + cognitive_service.get_profile = AsyncMock(return_value="群成员侧写数据") + + context = _build_context( + sender=sender, + cognitive_service=cognitive_service, + scope="group", + group_id=123456, + sender_id=55555, + ) + + await profile_execute([], context) + + assert sender.group_messages == [] + send_forward_msg = cast(Any, context.onebot.send_forward_msg) + send_forward_msg.assert_called_once() + call = send_forward_msg.call_args + assert call.args[0] == 123456 + nodes = call.args[1] + assert len(nodes) == 2 + assert "类型: 用户侧写" in nodes[0]["data"]["content"] + assert nodes[1]["data"]["content"] == "群成员侧写数据" + + +@pytest.mark.asyncio +async def test_profile_private_render_failure_falls_back_to_text( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Private chat render failure still falls back to plain text.""" + _patch_profile_render_failure(monkeypatch) + sender = _DummySender() + cognitive_service = AsyncMock() + cognitive_service.get_profile = AsyncMock(return_value="这是一个用户侧写") + + context = _build_context( + sender=sender, + cognitive_service=cognitive_service, + scope="private", + group_id=0, + sender_id=99999, + user_id=99999, + ) + + await profile_execute([], context) + + assert len(sender.private_messages) == 1 + assert sender.private_messages[0][1] == "这是一个用户侧写" + send_forward_msg = cast(Any, context.onebot.send_forward_msg) + send_forward_msg.assert_not_called() + + +@pytest.mark.asyncio +async def test_profile_forward_flag_sends_forward_message() -> None: + """Group chat with -f sends a merged forward message.""" + sender = _DummySender() + cognitive_service = AsyncMock() + cognitive_service.get_profile = AsyncMock(return_value="群成员侧写数据") + + context = _build_context( + sender=sender, + cognitive_service=cognitive_service, + scope="group", + group_id=123456, + sender_id=55555, + ) + + await profile_execute(["-f"], context) + + assert sender.group_messages == [] + send_forward_msg = cast(Any, context.onebot.send_forward_msg) + send_forward_msg.assert_called_once() + call = send_forward_msg.call_args + assert call.args[0] == 123456 + nodes = call.args[1] + assert len(nodes) == 2 + assert "类型: 用户侧写" in nodes[0]["data"]["content"] + assert nodes[1]["data"]["content"] == "群成员侧写数据" + + +@pytest.mark.asyncio +async def test_profile_forward_uses_sender_history_layer() -> None: + """Production sender path records merged-forward command output in history.""" + sender = _ForwardSender() + cognitive_service = AsyncMock() + cognitive_service.get_profile = AsyncMock(return_value="群成员侧写数据") + + context = _build_context( + sender=sender, + cognitive_service=cognitive_service, + scope="group", + group_id=123456, + sender_id=55555, + ) + + await profile_execute(["-f"], context) + + assert len(sender.forward_messages) == 1 + group_id, nodes, history_message = sender.forward_messages[0] + assert group_id == 123456 + assert len(nodes) == 2 + assert "群成员侧写数据" in history_message + send_forward_msg = cast(Any, context.onebot.send_forward_msg) + send_forward_msg.assert_not_called() + + @pytest.mark.asyncio async def test_profile_group_profile_subcommand() -> None: - """Group chat, `/profile group` → sends group profile via send_group_message.""" + """Group chat, `/profile group -t` → sends group profile as text.""" sender = _DummySender() cognitive_service = AsyncMock() cognitive_service.get_profile = AsyncMock(return_value="群聊整体侧写") @@ -180,7 +371,7 @@ async def test_profile_group_profile_subcommand() -> None: sender_id=44444, ) - await profile_execute(["GROUP"], context) # Test case-insensitive + await profile_execute(["GROUP", "-t"], context) # Test case-insensitive assert len(sender.group_messages) == 1 assert sender.group_messages[0][0] == 654321 @@ -190,7 +381,7 @@ async def test_profile_group_profile_subcommand() -> None: @pytest.mark.asyncio async def test_profile_group_profile_g_shorthand() -> None: - """Group chat, `/p g` shorthand → shows group profile.""" + """Group chat, `/p g -t` shorthand → shows group profile as text.""" sender = _DummySender() cognitive_service = AsyncMock() cognitive_service.get_profile = AsyncMock(return_value="群聊简称侧写") @@ -203,7 +394,7 @@ async def test_profile_group_profile_g_shorthand() -> None: sender_id=44444, ) - await profile_execute(["g"], context) + await profile_execute(["g", "-t"], context) assert len(sender.group_messages) == 1 assert "群聊简称侧写" in sender.group_messages[0][1] @@ -291,7 +482,7 @@ async def test_profile_truncation() -> None: sender_id=11111, ) - await profile_execute([], context) + await profile_execute(["-t"], context) assert len(sender.group_messages) == 1 message = sender.group_messages[0][1] @@ -305,7 +496,7 @@ async def test_profile_truncation() -> None: @pytest.mark.asyncio async def test_profile_superadmin_target_user() -> None: - """Superadmin can query another user's profile with /p .""" + """Superadmin can query another user's profile with /p -t.""" sender = _DummySender() cognitive_service = AsyncMock() cognitive_service.get_profile = AsyncMock(return_value="目标用户侧写") @@ -319,7 +510,7 @@ async def test_profile_superadmin_target_user() -> None: superadmin_qq=10001, ) - await profile_execute(["99999"], context) + await profile_execute(["99999", "-t"], context) assert len(sender.group_messages) == 1 assert "目标用户侧写" in sender.group_messages[0][1] @@ -328,7 +519,7 @@ async def test_profile_superadmin_target_user() -> None: @pytest.mark.asyncio async def test_profile_superadmin_target_group() -> None: - """Superadmin can query a group profile with /p g <群号>.""" + """Superadmin can query a group profile with /p g <群号> -t.""" sender = _DummySender() cognitive_service = AsyncMock() cognitive_service.get_profile = AsyncMock(return_value="目标群侧写") @@ -342,7 +533,7 @@ async def test_profile_superadmin_target_group() -> None: superadmin_qq=10001, ) - await profile_execute(["g", "789000"], context) + await profile_execute(["g", "789000", "-t"], context) assert len(sender.group_messages) == 1 assert "目标群侧写" in sender.group_messages[0][1] @@ -412,7 +603,7 @@ async def test_profile_superadmin_private_group_with_target() -> None: superadmin_qq=10001, ) - await profile_execute(["g", "654321"], context) + await profile_execute(["g", "654321", "-t"], context) # Private + group + target → still works for superadmin assert len(sender.private_messages) == 1 diff --git a/tests/test_render.py b/tests/test_render.py new file mode 100644 index 00000000..30fc60f1 --- /dev/null +++ b/tests/test_render.py @@ -0,0 +1,254 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Iterator +from types import SimpleNamespace +from typing import Any + +import pytest + +import Undefined.render as render_module + + +def _reset_render_state() -> None: + render_module._playwright = None + render_module._browser = None + render_module._render_semaphore = None + render_module._render_semaphore_limit = None + render_module._render_active_count = 0 + + +@pytest.fixture(autouse=True) +def _reset_render_module_state() -> Iterator[None]: + _reset_render_state() + yield + _reset_render_state() + + +@pytest.mark.asyncio +async def test_get_semaphore_uses_platform_default_when_auto( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runtime_config = SimpleNamespace(render_browser_max_concurrency=0) + monkeypatch.setattr( + render_module, + "get_config", + lambda strict=False: runtime_config, + ) + + semaphore = await render_module._get_semaphore() + + assert ( + render_module._render_semaphore_limit == render_module._DEFAULT_MAX_CONCURRENT + ) + assert semaphore._value == render_module._DEFAULT_MAX_CONCURRENT + + +@pytest.mark.asyncio +async def test_get_semaphore_uses_configured_browser_limit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runtime_config = SimpleNamespace(render_browser_max_concurrency=3) + monkeypatch.setattr( + render_module, + "get_config", + lambda strict=False: runtime_config, + ) + + semaphore = await render_module._get_semaphore() + + assert render_module._render_semaphore_limit == 3 + assert semaphore._value == 3 + + +@pytest.mark.asyncio +async def test_get_semaphore_recreates_idle_instance_when_limit_changes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runtime_config = SimpleNamespace(render_browser_max_concurrency=2) + monkeypatch.setattr( + render_module, + "get_config", + lambda strict=False: runtime_config, + ) + + first = await render_module._get_semaphore() + runtime_config.render_browser_max_concurrency = 4 + second = await render_module._get_semaphore() + + assert first is not second + assert render_module._render_semaphore_limit == 4 + assert second._value == 4 + + +@pytest.mark.asyncio +async def test_get_semaphore_waits_for_active_instance_before_recreating( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runtime_config = SimpleNamespace(render_browser_max_concurrency=2) + monkeypatch.setattr( + render_module, + "get_config", + lambda strict=False: runtime_config, + ) + + first = await render_module._get_semaphore() + render_module._render_active_count = 1 + runtime_config.render_browser_max_concurrency = 4 + active = await render_module._get_semaphore() + render_module._render_active_count = 0 + recreated = await render_module._get_semaphore() + + assert active is first + assert recreated is not first + assert render_module._render_semaphore_limit == 4 + assert recreated._value == 4 + + +@pytest.mark.asyncio +async def test_get_browser_stops_playwright_when_launch_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class _FakeChromium: + async def launch(self, *, headless: bool) -> Any: + assert headless is True + raise RuntimeError("launch failed") + + class _FakePlaywright: + def __init__(self) -> None: + self.chromium = _FakeChromium() + self.stopped = False + + async def stop(self) -> None: + self.stopped = True + + class _FakePlaywrightFactory: + def __init__(self, playwright: _FakePlaywright) -> None: + self.playwright = playwright + + async def start(self) -> _FakePlaywright: + return self.playwright + + playwright = _FakePlaywright() + monkeypatch.setattr( + render_module, + "async_playwright", + lambda: _FakePlaywrightFactory(playwright), + ) + + with pytest.raises(RuntimeError, match="launch failed"): + await render_module._get_browser() + + assert playwright.stopped is True + assert render_module._playwright is None + assert render_module._browser is None + + +@pytest.mark.asyncio +async def test_render_html_with_page_closes_context_when_new_page_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class _FailingContext: + def __init__(self) -> None: + self.closed = False + + async def new_page(self) -> Any: + raise RuntimeError("new page failed") + + async def close(self) -> None: + self.closed = True + + class _FakeBrowser: + def __init__(self, context: _FailingContext) -> None: + self.context = context + + async def new_context(self, **_kwargs: Any) -> _FailingContext: + return self.context + + context = _FailingContext() + + async def _fake_get_browser() -> _FakeBrowser: + return _FakeBrowser(context) + + async def _fake_get_semaphore() -> asyncio.Semaphore: + return asyncio.Semaphore(1) + + async def _unused_callback(_page: Any) -> None: + raise AssertionError("callback should not run") + + monkeypatch.setattr(render_module, "_get_browser", _fake_get_browser) + monkeypatch.setattr(render_module, "_get_semaphore", _fake_get_semaphore) + + with pytest.raises(RuntimeError, match="new page failed"): + await render_module.render_html_with_page("", _unused_callback) + + assert context.closed is True + assert render_module._render_active_count == 0 + + +@pytest.mark.asyncio +async def test_render_html_with_page_decrements_active_count_when_new_context_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class _FakeBrowser: + async def new_context(self, **_kwargs: Any) -> Any: + raise RuntimeError("new context failed") + + async def _fake_get_browser() -> _FakeBrowser: + return _FakeBrowser() + + async def _fake_get_semaphore() -> asyncio.Semaphore: + return asyncio.Semaphore(1) + + async def _unused_callback(_page: Any) -> None: + raise AssertionError("callback should not run") + + monkeypatch.setattr(render_module, "_get_browser", _fake_get_browser) + monkeypatch.setattr(render_module, "_get_semaphore", _fake_get_semaphore) + + with pytest.raises(RuntimeError, match="new context failed"): + await render_module.render_html_with_page("", _unused_callback) + + assert render_module._render_active_count == 0 + + +@pytest.mark.asyncio +async def test_render_html_with_page_active_count_stays_non_negative_after_close( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class _FakePage: + def set_default_timeout(self, _timeout_ms: int) -> None: + pass + + async def set_content(self, _html_content: str) -> None: + pass + + class _FakeContext: + async def new_page(self) -> _FakePage: + return _FakePage() + + async def close(self) -> None: + pass + + class _FakeBrowser: + async def new_context(self, **_kwargs: Any) -> _FakeContext: + return _FakeContext() + + async def _fake_get_browser() -> _FakeBrowser: + return _FakeBrowser() + + async def _fake_get_semaphore() -> asyncio.Semaphore: + return asyncio.Semaphore(1) + + async def _callback(_page: Any) -> str: + assert render_module._render_active_count == 1 + await render_module.close_browser() + return "ok" + + monkeypatch.setattr(render_module, "_get_browser", _fake_get_browser) + monkeypatch.setattr(render_module, "_get_semaphore", _fake_get_semaphore) + + result = await render_module.render_html_with_page("", _callback) + + assert result == "ok" + assert render_module._render_active_count == 0 diff --git a/tests/test_render_latex_tool.py b/tests/test_render_latex_tool.py index 130991a3..f3b4da92 100644 --- a/tests/test_render_latex_tool.py +++ b/tests/test_render_latex_tool.py @@ -2,8 +2,9 @@ from __future__ import annotations +import asyncio import pytest -from typing import Any +from typing import Any, cast # 这个测试需要 Playwright 浏览器运行时,所以标记为可选 pytest_plugins = ("pytest_asyncio",) @@ -61,7 +62,7 @@ async def test_render_simple_equation() -> None: result = await execute(args, context) if "渲染失败" in result and "Executable doesn't exist" in result: pytest.skip("Playwright 浏览器未安装,跳过测试") - assert result == '' + assert result == '' assert len(mock_registry.registered_items) == 1 assert mock_registry.registered_items[0]["kind"] == "image" assert mock_registry.registered_items[0]["mime_type"] == "image/png" @@ -85,7 +86,7 @@ async def test_render_with_delimiters() -> None: result = await execute(args, context) if "渲染失败" in result and "Executable doesn't exist" in result: pytest.skip("Playwright 浏览器未安装,跳过测试") - assert result == '' + assert result == '' assert len(mock_registry.registered_items) == 1 @@ -139,6 +140,72 @@ async def test_invalid_output_format() -> None: assert "无效" in result or "仅支持" in result +@pytest.mark.asyncio +async def test_render_mathtext_runs_in_thread(monkeypatch: pytest.MonkeyPatch) -> None: + import Undefined.skills.toolsets.render.render_latex.handler as handler + + calls: list[Any] = [] + + async def _fake_to_thread(func: Any, *args: Any, **kwargs: Any) -> Any: + calls.append((func, args, kwargs)) + return b"image", "image/png" + + monkeypatch.setattr(asyncio, "to_thread", _fake_to_thread) + + result = await handler._render_mathtext_to_bytes("x", "png") + + assert result == (b"image", "image/png") + assert calls == [(handler._render_mathtext_sync, ("x", "png"), {})] + + +@pytest.mark.asyncio +async def test_mathjax_fallback_uses_shared_render_page( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import Undefined.render as render + import Undefined.skills.toolsets.render.render_latex.handler as handler + + async def _fail_mathtext(_content: str, _output_format: str) -> tuple[bytes, str]: + raise RuntimeError("force fallback") + + class _FakeContainer: + async def screenshot(self, *, type: str) -> bytes: + assert type == "png" + return b"png-bytes" + + class _FakePage: + async def wait_for_function(self, expression: str, *, timeout: int) -> None: + assert expression == "() => window._mjReady === true" + assert timeout == 30000 + + async def query_selector(self, selector: str) -> _FakeContainer | None: + assert selector == "#math-container" + return _FakeContainer() + + calls: list[dict[str, Any]] = [] + + async def _fake_render_html_with_page( + html_content: str, + callback: Any, + **kwargs: Any, + ) -> tuple[bytes, str]: + calls.append({"html": html_content, "kwargs": kwargs}) + return cast(tuple[bytes, str], await callback(_FakePage())) + + monkeypatch.setattr(handler, "_render_mathtext_to_bytes", _fail_mathtext) + monkeypatch.setattr(render, "render_html_with_page", _fake_render_html_with_page) + + result = await handler._render_latex_to_bytes( + r"\begin{aligned}x&=1\\y&=2\end{aligned}", + "png", + proxy="http://127.0.0.1:7890", + ) + + assert result == (b"png-bytes", "image/png") + assert calls[0]["kwargs"]["proxy"] == "http://127.0.0.1:7890" + assert "math-container" in calls[0]["html"] + + def test_strip_document_wrappers() -> None: """测试去除 document 包装""" from Undefined.skills.toolsets.render.render_latex.handler import ( diff --git a/tests/test_runtime_api_probes.py b/tests/test_runtime_api_probes.py index 940f32f9..fa32206b 100644 --- a/tests/test_runtime_api_probes.py +++ b/tests/test_runtime_api_probes.py @@ -31,6 +31,7 @@ async def test_runtime_internal_probe_includes_chat_model_transport_fields() -> responses_tool_choice_compat=False, responses_force_stateless_replay=False, prompt_cache_enabled=True, + stream_enabled=True, reasoning_enabled=True, reasoning_effort="high", ), @@ -39,6 +40,7 @@ async def test_runtime_internal_probe_includes_chat_model_transport_fields() -> api_url="https://grok.example/v1", thinking_enabled=False, prompt_cache_enabled=True, + stream_enabled=True, reasoning_enabled=True, reasoning_effort="low", ), @@ -71,6 +73,7 @@ async def test_runtime_internal_probe_includes_chat_model_transport_fields() -> "responses_tool_choice_compat": False, "responses_force_stateless_replay": False, "prompt_cache_enabled": True, + "stream_enabled": True, "reasoning_enabled": True, "reasoning_effort": "high", } @@ -83,6 +86,7 @@ async def test_runtime_internal_probe_includes_chat_model_transport_fields() -> "api_url": "https://grok.example/...", "thinking_enabled": False, "prompt_cache_enabled": True, + "stream_enabled": True, "reasoning_enabled": True, "reasoning_effort": "low", } diff --git a/tests/test_sender.py b/tests/test_sender.py index ab69a812..ecd92d84 100644 --- a/tests/test_sender.py +++ b/tests/test_sender.py @@ -1,6 +1,8 @@ from __future__ import annotations -from typing import cast +from pathlib import Path +from types import SimpleNamespace +from typing import Any, cast from unittest.mock import AsyncMock, MagicMock import pytest @@ -25,6 +27,146 @@ def sender() -> MessageSender: return MessageSender(onebot, history_manager, bot_qq=10000, config=config) +@pytest.mark.asyncio +async def test_send_group_file_registers_attachment_in_history( + tmp_path: Path, +) -> None: + file_path = tmp_path / "paper.pdf" + file_path.write_bytes(b"pdf") + onebot = MagicMock() + onebot.upload_group_file = AsyncMock() + history_manager = MagicMock() + history_manager.add_group_message = AsyncMock() + config = MagicMock() + config.is_group_allowed.return_value = True + config.access_control_enabled.return_value = False + config.group_access_denied_reason.return_value = None + + record = SimpleNamespace( + prompt_ref=lambda: { + "uid": "file_test", + "kind": "file", + "media_type": "file", + "display_name": "paper.pdf", + } + ) + attachment_registry = SimpleNamespace( + register_local_file=AsyncMock(return_value=record) + ) + sender = MessageSender( + onebot, + history_manager, + bot_qq=10000, + config=config, + attachment_registry=attachment_registry, + ) + + await sender.send_group_file(12345, str(file_path), "paper.pdf") + + attachment_registry.register_local_file.assert_awaited_once() + history_mock = cast(AsyncMock, history_manager.add_group_message) + assert history_mock.await_count == 1 + assert history_mock.await_args is not None + kwargs = history_mock.await_args.kwargs + assert kwargs["attachments"][0]["uid"] == "file_test" + assert "uid=file_test" in kwargs["text_content"] + + +@pytest.mark.asyncio +async def test_send_group_message_registers_local_cq_media( + sender: MessageSender, + tmp_path: Path, +) -> None: + image_path = tmp_path / "card.png" + video_path = tmp_path / "clip.mp4" + image_path.write_bytes(b"png") + video_path.write_bytes(b"video") + + image_record = SimpleNamespace( + prompt_ref=lambda: { + "uid": "pic_card", + "kind": "image", + "media_type": "image", + "display_name": "card.png", + } + ) + video_record = SimpleNamespace( + prompt_ref=lambda: { + "uid": "file_clip", + "kind": "video", + "media_type": "video", + "display_name": "clip.mp4", + } + ) + sender.attachment_registry = SimpleNamespace( + register_local_file=AsyncMock(side_effect=[image_record, video_record]) + ) + sender.onebot.send_group_message = AsyncMock( # type: ignore[method-assign] + return_value={"message_id": 123} + ) + message = ( + f"[CQ:image,file={image_path.resolve().as_uri()}]" + f"[CQ:video,file={video_path.resolve().as_uri()}]" + ) + + await sender.send_group_message(12345, message, history_message="媒体预处理") + + sender.attachment_registry.register_local_file.assert_any_await( + "group:12345", + str(image_path.resolve()), + kind="image", + display_name="card.png", + source_kind="sent_image", + source_ref=image_path.resolve().as_uri(), + ) + sender.attachment_registry.register_local_file.assert_any_await( + "group:12345", + str(video_path.resolve()), + kind="video", + display_name="clip.mp4", + source_kind="sent_video", + source_ref=video_path.resolve().as_uri(), + ) + history_mock = cast(AsyncMock, sender.history_manager.add_group_message) + assert history_mock.await_args is not None + kwargs = history_mock.await_args.kwargs + assert [item["uid"] for item in kwargs["attachments"]] == [ + "pic_card", + "file_clip", + ] + assert "uid=pic_card" in kwargs["text_content"] + assert "uid=file_clip" in kwargs["text_content"] + + +@pytest.mark.asyncio +async def test_send_group_forward_message_records_history( + sender: MessageSender, +) -> None: + onebot = cast(Any, sender.onebot) + onebot.send_forward_msg = AsyncMock() + nodes = [ + { + "type": "node", + "data": {"name": "Bot", "uin": "10000", "content": "长内容"}, + } + ] + + await sender.send_group_forward_message( + 12345, + nodes, + history_message="[命令输出] 合并转发摘要", + ) + + onebot.send_forward_msg.assert_awaited_once_with(12345, nodes) + history_mock = cast(AsyncMock, sender.history_manager.add_group_message) + history_mock.assert_awaited_once() + assert history_mock.await_args is not None + kwargs = history_mock.await_args.kwargs + assert kwargs["group_id"] == 12345 + assert kwargs["sender_id"] == 10000 + assert kwargs["text_content"] == "[命令输出] 合并转发摘要" + + @pytest.mark.asyncio async def test_send_group_message_reads_message_id_from_onebot_envelope( sender: MessageSender, diff --git a/tests/test_skills_http_client.py b/tests/test_skills_http_client.py index 61479e9c..9b1f3ce5 100644 --- a/tests/test_skills_http_client.py +++ b/tests/test_skills_http_client.py @@ -1,10 +1,30 @@ -"""Tests for Undefined.skills.http_client module (pure functions only).""" +"""Tests for Undefined.skills.http_client and http_config helpers.""" from __future__ import annotations +from types import SimpleNamespace +from typing import Any + +import httpx +import pytest + +import Undefined.skills.http_client as http_client_module +import Undefined.skills.http_config as http_config_module from Undefined.skills.http_client import _retry_delay, _should_retry_http_status +class _FakeResponse: + def __init__(self, status_code: int = 200) -> None: + self.status_code = status_code + self.headers: dict[str, str] = {} + self.text = "" + self.content = b"" + + def raise_for_status(self) -> None: + if self.status_code >= 400: + raise AssertionError("unexpected status in fake response") + + class TestShouldRetryHttpStatus: """Tests for _should_retry_http_status().""" @@ -74,3 +94,143 @@ def test_attempt_5_capped(self) -> None: def test_returns_float(self) -> None: assert isinstance(_retry_delay(0), float) + + +class TestRequestProxy: + def test_prefers_scheme_specific_proxy( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr( + http_config_module, + "get_config", + lambda strict=False: SimpleNamespace( + use_proxy=True, + http_proxy="http://http-proxy.local:7890", + https_proxy="http://https-proxy.local:7890", + ), + ) + + assert ( + http_config_module.get_request_proxy( + "https://api.github.com/repos/69gg/Undefined" + ) + == "http://https-proxy.local:7890" + ) + assert ( + http_config_module.get_request_proxy("http://example.com/resource") + == "http://http-proxy.local:7890" + ) + + def test_returns_none_when_proxy_disabled( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr( + http_config_module, + "get_config", + lambda strict=False: SimpleNamespace( + use_proxy=False, + http_proxy="http://http-proxy.local:7890", + https_proxy="http://https-proxy.local:7890", + ), + ) + + assert ( + http_config_module.get_request_proxy( + "https://api.github.com/repos/69gg/Undefined" + ) + is None + ) + + +class TestRequestWithRetryProxy: + @pytest.mark.asyncio + async def test_passes_proxy_to_async_client( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + client_init_kwargs: dict[str, Any] = {} + request_kwargs: dict[str, Any] = {} + + class FakeAsyncClient: + def __init__(self, **kwargs: Any) -> None: + client_init_kwargs.update(kwargs) + + async def __aenter__(self) -> FakeAsyncClient: + return self + + async def __aexit__( + self, + _exc_type: object, + _exc: object, + _tb: object, + ) -> None: + return None + + async def request(self, **kwargs: Any) -> _FakeResponse: + request_kwargs.update(kwargs) + return _FakeResponse() + + monkeypatch.setattr( + http_client_module, "get_request_timeout", lambda _default: 12.0 + ) + monkeypatch.setattr( + http_client_module, "get_request_retries", lambda _default: 0 + ) + monkeypatch.setattr( + http_client_module, + "get_request_proxy", + lambda _url: "http://proxy.local:7890", + ) + monkeypatch.setattr(httpx, "AsyncClient", FakeAsyncClient) + + response = await http_client_module.request_with_retry( + "GET", + "https://api.github.com/repos/69gg/Undefined", + headers={"Accept": "application/json"}, + follow_redirects=True, + ) + + assert response.status_code == 200 + assert client_init_kwargs["proxy"] == "http://proxy.local:7890" + assert client_init_kwargs["trust_env"] is False + assert request_kwargs["url"] == "https://api.github.com/repos/69gg/Undefined" + + @pytest.mark.asyncio + async def test_skips_proxy_when_not_configured( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + client_init_kwargs: dict[str, Any] = {} + + class FakeAsyncClient: + def __init__(self, **kwargs: Any) -> None: + client_init_kwargs.update(kwargs) + + async def __aenter__(self) -> FakeAsyncClient: + return self + + async def __aexit__( + self, + _exc_type: object, + _exc: object, + _tb: object, + ) -> None: + return None + + async def request(self, **_kwargs: Any) -> _FakeResponse: + return _FakeResponse() + + monkeypatch.setattr( + http_client_module, "get_request_timeout", lambda _default: 12.0 + ) + monkeypatch.setattr( + http_client_module, "get_request_retries", lambda _default: 0 + ) + monkeypatch.setattr(http_client_module, "get_request_proxy", lambda _url: None) + monkeypatch.setattr(httpx, "AsyncClient", FakeAsyncClient) + + await http_client_module.request_with_retry( + "GET", + "https://api.github.com/repos/69gg/Undefined", + ) + + assert "proxy" not in client_init_kwargs + assert client_init_kwargs["trust_env"] is False diff --git a/tests/test_system_prompt_constraints.py b/tests/test_system_prompt_constraints.py index 76cbad15..87728678 100644 --- a/tests/test_system_prompt_constraints.py +++ b/tests/test_system_prompt_constraints.py @@ -50,5 +50,9 @@ def test_system_prompts_keep_proactive_participation_narrow_and_meme_post_reply( ) assert "表情包相关规则只决定“怎么回复”,不单独构成“该不该回复”的参与许可" in text assert "只要你已经决定要回复,并且表情包能让表达更像真人" in text + assert "如果本轮既需要文字发言又想配表情包" in text + assert "先调用 `send_message` 发出必要文字" in text + assert "表情包检索可能拖慢首条回复体验" in text + assert "再把表情包检索和发送放到后续轮次" in text assert "群里有多人在公开讨论你擅长或感兴趣的话题" not in text assert "有人说了明显有趣/好笑的话,你有自然的回应冲动" not in text diff --git a/tests/test_webui_render_toml.py b/tests/test_webui_render_toml.py index 67d0f111..8d93e6b9 100644 --- a/tests/test_webui_render_toml.py +++ b/tests/test_webui_render_toml.py @@ -75,6 +75,7 @@ def test_pool_model_request_params_roundtrip(self) -> None: responses_force_stateless_replay = true reasoning_enabled = true reasoning_effort = "high" +stream_enabled = true [models.chat.pool.models.request_params] temperature = 0.7 @@ -96,6 +97,7 @@ def test_pool_model_request_params_roundtrip(self) -> None: assert model["responses_force_stateless_replay"] is True assert model["reasoning_enabled"] is True assert model["reasoning_effort"] == "high" + assert model["stream_enabled"] is True params = model["request_params"] assert params["temperature"] == 0.7 assert params["metadata"]["source"] == "webui" diff --git a/uv.lock b/uv.lock index d1d20530..29931865 100644 --- a/uv.lock +++ b/uv.lock @@ -4638,7 +4638,7 @@ wheels = [ [[package]] name = "undefined-bot" -version = "3.3.2" +version = "3.3.3" source = { editable = "." } dependencies = [ { name = "aiofiles" },